
This patch implements the register/deregister/lookup operations for the sharding manager. As a result of this patch, the sharding driver now takes the control driver as a paramater. This gives it the access needed to make decisions about queue placement (and lookups) based on the entries in the control plane. In order to get the sharding manager working correctly, several methods had to be overridden to properly handle the QueueNotMapped error. When a QueueNotMapped error is encountered under normal conditions, it is equivalent to the target resource not being found. The overrides mimic the default behavior of those controllers' methods in the case where the queue isn't found. An inconsistency was found between the base storage driver and the concrete implementations. message_controller.list did not provide an include_claimed parameter. This has been corrected. To avoid DuplicateOptErrors in the mongodb driver in a sharded context, a filtering algorithm is applied. To make it clear to data drivers that they need to apply this filtering algorithm, conf.dynamic is set by the sharding driver. Unit tests have been added for the weighted select algorithm. Up next: sharded queue listing support, caching Change-Id: Ic36cd657ba13fa4c0b624dc5c71bfe99f9a70139 Partitally-implements: blueprint storage-sharding
499 lines
18 KiB
Python
499 lines
18 KiB
Python
# Copyright (c) 2013 Rackspace, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import json
|
|
import uuid
|
|
|
|
import ddt
|
|
import falcon
|
|
import six
|
|
from testtools import matchers
|
|
|
|
import base # noqa
|
|
from marconi.openstack.common import timeutils
|
|
from marconi.queues.transport import validation
|
|
from marconi import tests as testing
|
|
|
|
|
|
@ddt.ddt
|
|
class MessagesBaseTest(base.TestBase):
|
|
|
|
def setUp(self):
|
|
super(MessagesBaseTest, self).setUp()
|
|
|
|
if self.conf.sharding:
|
|
for i in range(4):
|
|
uri = self.conf['queues:drivers:storage:mongodb'].uri
|
|
doc = {'weight': 100, 'uri': uri}
|
|
self.simulate_put('/v1/shards/' + str(i),
|
|
body=json.dumps(doc))
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_201)
|
|
|
|
self.project_id = '7e55e1a7e'
|
|
self.queue_path = '/v1/queues/fizbit'
|
|
self.messages_path = self.queue_path + '/messages'
|
|
|
|
doc = '{"_ttl": 60}'
|
|
self.simulate_put(self.queue_path, self.project_id, body=doc)
|
|
|
|
self.headers = {
|
|
'Client-ID': str(uuid.uuid4()),
|
|
}
|
|
|
|
def tearDown(self):
|
|
self.simulate_delete(self.queue_path, self.project_id)
|
|
if self.conf.sharding:
|
|
for i in range(4):
|
|
self.simulate_delete('/v1/shards/' + str(i))
|
|
|
|
super(MessagesBaseTest, self).tearDown()
|
|
|
|
def _test_post(self, sample_messages):
|
|
sample_doc = json.dumps(sample_messages)
|
|
|
|
result = self.simulate_post(self.messages_path, self.project_id,
|
|
body=sample_doc, headers=self.headers)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_201)
|
|
|
|
result_doc = json.loads(result[0])
|
|
|
|
msg_ids = self._get_msg_ids(self.srmock.headers_dict)
|
|
self.assertEqual(len(msg_ids), len(sample_messages))
|
|
|
|
expected_resources = [unicode(self.messages_path + '/' + id)
|
|
for id in msg_ids]
|
|
self.assertEqual(expected_resources, result_doc['resources'])
|
|
self.assertFalse(result_doc['partial'])
|
|
|
|
self.assertEqual(len(msg_ids), len(sample_messages))
|
|
|
|
lookup = dict([(m['ttl'], m['body']) for m in sample_messages])
|
|
|
|
# Test GET on the message resource directly
|
|
# NOTE(cpp-cabrera): force the passing of time to age a message
|
|
timeutils.set_time_override(timeutils.utcnow())
|
|
timeutils.advance_time_seconds(10)
|
|
for msg_id in msg_ids:
|
|
message_uri = self.messages_path + '/' + msg_id
|
|
|
|
# Wrong project ID
|
|
self.simulate_get(message_uri, '777777')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_404)
|
|
|
|
# Correct project ID
|
|
result = self.simulate_get(message_uri, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
self.assertEqual(self.srmock.headers_dict['Content-Location'],
|
|
message_uri)
|
|
|
|
# Check message properties
|
|
message = json.loads(result[0])
|
|
self.assertEqual(message['href'], message_uri)
|
|
self.assertEqual(message['body'], lookup[message['ttl']])
|
|
|
|
# no negative age
|
|
# NOTE(cpp-cabrera): testtools lacks GreaterThanEqual on py26
|
|
self.assertThat(message['age'],
|
|
matchers.GreaterThan(-1))
|
|
timeutils.clear_time_override()
|
|
|
|
# Test bulk GET
|
|
query_string = 'ids=' + ','.join(msg_ids)
|
|
result = self.simulate_get(self.messages_path, self.project_id,
|
|
query_string=query_string)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
result_doc = json.loads(result[0])
|
|
expected_ttls = set(m['ttl'] for m in sample_messages)
|
|
actual_ttls = set(m['ttl'] for m in result_doc)
|
|
self.assertFalse(expected_ttls - actual_ttls)
|
|
|
|
def test_exceeded_payloads(self):
|
|
# Get a valid message id
|
|
self._post_messages(self.messages_path)
|
|
msg_id = self._get_msg_id(self.srmock.headers_dict)
|
|
|
|
# Posting restriction
|
|
self._post_messages(self.messages_path, repeat=23)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
# Bulk GET restriction
|
|
query_string = 'ids=' + ','.join([msg_id] * 21)
|
|
self.simulate_get(self.messages_path, self.project_id,
|
|
query_string=query_string)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
# Listing restriction
|
|
self.simulate_get(self.messages_path, self.project_id,
|
|
query_string='limit=21',
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
# Bulk deletion restriction
|
|
query_string = 'ids=' + ','.join([msg_id] * 22)
|
|
self.simulate_delete(self.messages_path, self.project_id,
|
|
query_string=query_string)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
def test_post_single(self):
|
|
sample_messages = [
|
|
{'body': {'key': 'value'}, 'ttl': 200},
|
|
]
|
|
|
|
self._test_post(sample_messages)
|
|
|
|
def test_post_multiple(self):
|
|
sample_messages = [
|
|
{'body': 239, 'ttl': 100},
|
|
{'body': {'key': 'value'}, 'ttl': 200},
|
|
{'body': [1, 3], 'ttl': 300},
|
|
]
|
|
|
|
self._test_post(sample_messages)
|
|
|
|
def test_post_to_non_ascii_queue(self):
|
|
# NOTE(kgriffs): This test verifies that routes with
|
|
# embedded queue name params go through the validation
|
|
# hook, regardless of the target resource.
|
|
|
|
path = u'/v1/queues/non-ascii-n\u0153me/messages'
|
|
|
|
if six.PY2:
|
|
path = path.encode('utf-8')
|
|
|
|
self._post_messages(path)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
def test_post_with_long_queue_name(self):
|
|
# NOTE(kgriffs): This test verifies that routes with
|
|
# embedded queue name params go through the validation
|
|
# hook, regardless of the target resource.
|
|
|
|
game_title = 'v' * validation.QUEUE_NAME_MAX_LEN
|
|
self._post_messages('/v1/queues/' + game_title + '/messages')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_404)
|
|
|
|
game_title += 'v'
|
|
self._post_messages('/v1/queues/' + game_title + '/messages')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
def test_post_to_missing_queue(self):
|
|
self._post_messages('/v1/queues/nonexistent/messages')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_404)
|
|
|
|
def test_get_from_missing_queue(self):
|
|
self.simulate_get('/v1/queues/nonexistent/messages', self.project_id,
|
|
headers={'Client-ID':
|
|
'dfcd3238-425c-11e3-8a80-28cfe91478b9'})
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
@ddt.data('', '0xdeadbeef', '550893e0-2b6e-11e3-835a-5cf9dd72369')
|
|
def test_bad_client_id(self, text_id):
|
|
self.simulate_post(self.queue_path + '/messages',
|
|
body='{"ttl": 60, "body": ""}',
|
|
headers={'Client-ID': text_id})
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
self.simulate_get(self.queue_path + '/messages',
|
|
query_string='limit=3&echo=true',
|
|
headers={'Client-ID': text_id})
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
@ddt.data(None, '[', '[]', '{}', '.')
|
|
def test_post_bad_message(self, document):
|
|
self.simulate_post(self.queue_path + '/messages',
|
|
body=document,
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
@ddt.data(-1, 59, 1209601)
|
|
def test_unacceptable_ttl(self, ttl):
|
|
self.simulate_post(self.queue_path + '/messages',
|
|
body=json.dumps([{'ttl': ttl,
|
|
'body': None}]),
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
def test_exceeded_message_posting(self):
|
|
# Total (raw request) size
|
|
doc = json.dumps([{'body': "some body", 'ttl': 100}] * 20, indent=4)
|
|
long_doc = doc + (' ' *
|
|
(self.wsgi_cfg.content_max_length - len(doc) + 1))
|
|
|
|
self.simulate_post(self.queue_path + '/messages',
|
|
body=long_doc,
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
@ddt.data('{"overflow": 9223372036854775808}',
|
|
'{"underflow": -9223372036854775809}')
|
|
def test_unsupported_json(self, document):
|
|
self.simulate_post(self.queue_path + '/messages',
|
|
body=document,
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
def test_delete(self):
|
|
self._post_messages(self.messages_path)
|
|
msg_id = self._get_msg_id(self.srmock.headers_dict)
|
|
target = self.messages_path + '/' + msg_id
|
|
|
|
self.simulate_get(target, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
|
|
self.simulate_delete(target, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
self.simulate_get(target, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_404)
|
|
|
|
# Safe to delete non-existing ones
|
|
self.simulate_delete(target, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_bulk_delete(self):
|
|
path = self.queue_path + '/messages'
|
|
self._post_messages(path, repeat=5)
|
|
[target, params] = self.srmock.headers_dict['Location'].split('?')
|
|
|
|
# Deleting the whole collection is denied
|
|
self.simulate_delete(path, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
self.simulate_delete(target, self.project_id, query_string=params)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
self.simulate_get(target, self.project_id, query_string=params)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
# Safe to delete non-existing ones
|
|
self.simulate_delete(target, self.project_id, query_string=params)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
# Even after the queue is gone
|
|
self.simulate_delete(self.queue_path, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
self.simulate_delete(target, self.project_id, query_string=params)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_list(self):
|
|
path = self.queue_path + '/messages'
|
|
self._post_messages(path, repeat=10)
|
|
|
|
query_string = 'limit=3&echo=true'
|
|
body = self.simulate_get(path, self.project_id,
|
|
query_string=query_string,
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
self.assertEqual(self.srmock.headers_dict['Content-Location'],
|
|
path + '?' + query_string)
|
|
|
|
cnt = 0
|
|
while self.srmock.status == falcon.HTTP_200:
|
|
contents = json.loads(body[0])
|
|
[target, params] = contents['links'][0]['href'].split('?')
|
|
|
|
for msg in contents['messages']:
|
|
self.simulate_get(msg['href'], self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
|
|
body = self.simulate_get(target, self.project_id,
|
|
query_string=params,
|
|
headers=self.headers)
|
|
cnt += 1
|
|
|
|
self.assertEqual(cnt, 4)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
# Stats
|
|
body = self.simulate_get(self.queue_path + '/stats', self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
|
|
message_stats = json.loads(body[0])['messages']
|
|
self.assertEqual(self.srmock.headers_dict['Content-Location'],
|
|
self.queue_path + '/stats')
|
|
|
|
# NOTE(kgriffs): The other parts of the stats are tested
|
|
# in tests.storage.base and so are not repeated here.
|
|
expected_pattern = self.queue_path + '/messages/[^/]+$'
|
|
for message_stat_name in ('oldest', 'newest'):
|
|
self.assertThat(message_stats[message_stat_name]['href'],
|
|
matchers.MatchesRegex(expected_pattern))
|
|
|
|
# NOTE(kgriffs): Try to get messages for a missing queue
|
|
self.simulate_get('/v1/queues/nonexistent/messages', self.project_id,
|
|
headers=self.headers)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_list_with_bad_marker(self):
|
|
path = self.queue_path + '/messages'
|
|
self._post_messages(path, repeat=5)
|
|
|
|
query_string = 'limit=3&echo=true&marker=sfhlsfdjh2048'
|
|
self.simulate_get(path, self.project_id,
|
|
query_string=query_string,
|
|
headers=self.headers)
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_no_uuid(self):
|
|
path = self.queue_path + '/messages'
|
|
|
|
self.simulate_post(path, '7e7e7e', body='[{"body": 0, "ttl": 100}]')
|
|
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
self.simulate_get(path, '7e7e7e')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
|
|
|
# NOTE(cpp-cabrera): regression test against bug #1210633
|
|
def test_when_claim_deleted_then_messages_unclaimed(self):
|
|
path = self.queue_path
|
|
self._post_messages(path + '/messages', repeat=5)
|
|
|
|
# post claim
|
|
self.simulate_post(path + '/claims', self.project_id,
|
|
body='{"ttl": 100, "grace": 100}')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_201)
|
|
location = self.srmock.headers_dict['Location']
|
|
|
|
# release claim
|
|
self.simulate_delete(location, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
# get unclaimed messages
|
|
self.simulate_get(path + '/messages', self.project_id,
|
|
query_string='echo=true',
|
|
headers=self.headers)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
|
|
# NOTE(cpp-cabrera): regression test against bug #1203842
|
|
def test_get_nonexistent_message_404s(self):
|
|
path = '/v1/queues/notthere'
|
|
|
|
self.simulate_get(path + '/messages/a')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_404)
|
|
|
|
def test_get_multiple_invalid_messages_204s(self):
|
|
path = '/v1/queues/notthere'
|
|
|
|
self.simulate_get(path + '/messages', query_string='ids=a,b,c')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_delete_multiple_invalid_messages_204s(self):
|
|
path = '/v1/queues/notthere'
|
|
|
|
self.simulate_delete(path + '/messages', query_string='ids=a,b,c')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
def test_delete_message_with_invalid_claim_doesnt_delete_message(self):
|
|
path = self.queue_path
|
|
resp = self._post_messages(path + '/messages', 1)
|
|
location = json.loads(resp[0])['resources'][0]
|
|
|
|
self.simulate_delete(location, query_string='claim_id=invalid')
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
|
|
|
self.simulate_get(location, self.project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_200)
|
|
|
|
def _post_messages(self, target, repeat=1):
|
|
doc = json.dumps([{'body': 239, 'ttl': 300}] * repeat)
|
|
return self.simulate_post(target, self.project_id, body=doc,
|
|
headers=self.headers)
|
|
|
|
def _get_msg_id(self, headers):
|
|
return self._get_msg_ids(headers)[0]
|
|
|
|
def _get_msg_ids(self, headers):
|
|
return headers['Location'].rsplit('=', 1)[-1].split(',')
|
|
|
|
|
|
class MessagesSQLiteTests(MessagesBaseTest):
|
|
|
|
config_filename = 'wsgi_sqlite.conf'
|
|
|
|
|
|
# TODO(cpp-cabrera): restore sqlite sharded test suite once shards and
|
|
# catalogue get an sqlite implementation.
|
|
|
|
|
|
@testing.requires_mongodb
|
|
class MessagesMongoDBTests(MessagesBaseTest):
|
|
|
|
config_filename = 'wsgi_mongodb.conf'
|
|
|
|
def setUp(self):
|
|
super(MessagesMongoDBTests, self).setUp()
|
|
|
|
def tearDown(self):
|
|
super(MessagesMongoDBTests, self).tearDown()
|
|
|
|
|
|
@testing.requires_mongodb
|
|
class MessagesMongoDBShardedTests(MessagesBaseTest):
|
|
|
|
config_filename = 'wsgi_mongodb_sharded.conf'
|
|
|
|
def setUp(self):
|
|
super(MessagesMongoDBShardedTests, self).setUp()
|
|
|
|
def tearDown(self):
|
|
super(MessagesMongoDBShardedTests, self).tearDown()
|
|
|
|
# TODO(cpp-cabrera): remove this skipTest once sharded queue
|
|
# listing is implemented
|
|
def test_list(self):
|
|
self.skipTest("Need to implement sharded queue listing.")
|
|
|
|
|
|
class MessagesFaultyDriverTests(base.TestBaseFaulty):
|
|
|
|
config_filename = 'wsgi_faulty.conf'
|
|
|
|
def test_simple(self):
|
|
project_id = 'xyz'
|
|
path = '/v1/queues/fizbit/messages'
|
|
doc = '[{"body": 239, "ttl": 100}]'
|
|
headers = {
|
|
'Client-ID': str(uuid.uuid4()),
|
|
}
|
|
|
|
self.simulate_post(path, project_id,
|
|
body=doc,
|
|
headers=headers)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_503)
|
|
|
|
self.simulate_get(path, project_id,
|
|
headers=headers)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_503)
|
|
|
|
self.simulate_get(path + '/nonexistent', project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_503)
|
|
|
|
self.simulate_delete(path + '/nada', project_id)
|
|
self.assertEqual(self.srmock.status, falcon.HTTP_503)
|