From 0740c90148dc58308779aa4af54ff0e02cd3bc06 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sat, 29 Aug 2015 18:54:07 +0200 Subject: [PATCH 01/48] adds pluggable brokers * added redis broker * added django_redis broker * added task acknowledgement --- django_q/brokers/__init__.py | 47 ++++++++++ django_q/brokers/django_redis.py | 10 +++ django_q/brokers/redis.py | 43 +++++++++ django_q/cluster.py | 45 +++++----- django_q/conf.py | 1 + django_q/monitor.py | 19 ++-- django_q/status.py | 40 ++++----- django_q/tasks.py | 16 ++-- django_q/tests/test_cluster.py | 145 ++++++++++++++++--------------- django_q/tests/test_config.py | 5 -- django_q/tests/test_monitor.py | 10 +-- django_q/tests/test_scheduler.py | 29 ++++--- 12 files changed, 247 insertions(+), 163 deletions(-) create mode 100644 django_q/brokers/__init__.py create mode 100644 django_q/brokers/django_redis.py create mode 100644 django_q/brokers/redis.py diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py new file mode 100644 index 0000000..23ebf21 --- /dev/null +++ b/django_q/brokers/__init__.py @@ -0,0 +1,47 @@ +from django_q.conf import Conf + + +class Broker(object): + def __init__(self, list_key=Conf.Q_LIST): + self.connection = self.get_connection() + self.list_key = list_key + + def enqueue(self, task): + pass + + def dequeue(self): + pass + + def queue_size(self): + pass + + def delete_queue(self, list_key=None): + pass + + def acknowledge(self, ack_id): + pass + + def ping(self): + pass + + def set(self, key, value, timeout): + pass + + def get(self, key): + pass + + def get_pattern(self, pattern): + pass + + @staticmethod + def get_connection(): + return 0 + + +def get_broker(list_key=Conf.Q_LIST): + if Conf.REDIS: + from brokers import redis + return redis.Redis(list_key=list_key) + elif Conf.DJANGO_REDIS: + from brokers import django_redis + return django_redis.DjangoRedis(list_key=list_key) diff --git a/django_q/brokers/django_redis.py b/django_q/brokers/django_redis.py new file mode 100644 index 0000000..35b67bf --- /dev/null +++ b/django_q/brokers/django_redis.py @@ -0,0 +1,10 @@ +import django_redis +from django_q.brokers import redis +from django_q.conf import Conf + + +class DjangoRedis(redis.Redis): + + @staticmethod + def get_connection(): + return django_redis.get_redis_connection(Conf.DJANGO_REDIS) \ No newline at end of file diff --git a/django_q/brokers/redis.py b/django_q/brokers/redis.py new file mode 100644 index 0000000..86e0143 --- /dev/null +++ b/django_q/brokers/redis.py @@ -0,0 +1,43 @@ +import redis +from django_q.brokers import Broker +from django_q.conf import Conf, logger + + +class Redis(Broker): + def enqueue(self, task): + return self.connection.rpush(self.list_key, task) + + def dequeue(self): + task = self.connection.blpop(self.list_key, 1) + if task: + return None, task[1] + + def queue_size(self): + return self.connection.llen(self.list_key) + + def delete_queue(self, list_key=None): + list_key = list_key if list_key else self.list_key + return self.connection.delete(list_key) + + def ping(self): + try: + return self.connection.ping() + except Exception as e: + logger.error('Can not connect to Redis server.') + raise e + + def set(self, key, value, timeout): + self.connection.set(key, value, timeout) + + def get(self, key): + if self.connection.exists(key): + return self.connection.get(key) + + def get_pattern(self, pattern): + keys = self.connection.keys(pattern=pattern) + if keys: + return self.connection.mget(keys) + + @staticmethod + def get_connection(): + return redis.StrictRedis(**Conf.REDIS) diff --git a/django_q/cluster.py b/django_q/cluster.py index 1e03148..c28c4ad 100644 --- a/django_q/cluster.py +++ b/django_q/cluster.py @@ -30,19 +30,20 @@ from django import db import signing import tasks -from django_q.conf import Conf, redis_client, logger, psutil, get_ppid +from django_q.conf import Conf, logger, psutil, get_ppid from django_q.models import Task, Success, Schedule -from django_q.status import Stat, Status, ping_redis +from django_q.status import Stat, Status +from django_q.brokers import get_broker class Cluster(object): - def __init__(self, list_key=Conf.Q_LIST): + def __init__(self, broker=get_broker()): + self.broker = broker self.sentinel = None self.stop_event = None self.start_event = None self.pid = current_process().pid self.host = socket.gethostname() - self.list_key = list_key self.timeout = Conf.TIMEOUT signal.signal(signal.SIGTERM, self.sig_handler) signal.signal(signal.SIGINT, self.sig_handler) @@ -58,7 +59,7 @@ class Cluster(object): self.stop_event = Event() self.start_event = Event() self.sentinel = Process(target=Sentinel, - args=(self.stop_event, self.start_event, self.list_key, self.timeout)) + args=(self.stop_event, self.start_event, self.broker, self.timeout)) self.sentinel.start() logger.info(_('Q Cluster-{} starting.').format(self.pid)) while not self.start_event.is_set(): @@ -105,15 +106,14 @@ class Cluster(object): class Sentinel(object): - def __init__(self, stop_event, start_event, list_key=Conf.Q_LIST, timeout=Conf.TIMEOUT, start=True): + def __init__(self, stop_event, start_event, broker=get_broker(), timeout=Conf.TIMEOUT, start=True): # Make sure we catch signals for the pool signal.signal(signal.SIGINT, signal.SIG_IGN) signal.signal(signal.SIGTERM, signal.SIG_DFL) self.pid = current_process().pid self.parent_pid = get_ppid() self.name = current_process().name - self.list_key = list_key - self.r = redis_client + self.broker = broker self.reincarnations = 0 self.tob = timezone.now() self.stop_event = stop_event @@ -130,7 +130,7 @@ class Sentinel(object): self.start() def start(self): - ping_redis(self.r) + self.broker.ping() self.spawn_cluster() self.guard() @@ -165,7 +165,7 @@ class Sentinel(object): return p def spawn_pusher(self): - return self.spawn_process(pusher, self.task_queue, self.event_out, self.list_key) + return self.spawn_process(pusher, self.task_queue, self.event_out, self.broker) def spawn_worker(self): self.spawn_process(worker, self.task_queue, self.result_queue, Value('f', -1), self.timeout) @@ -216,7 +216,7 @@ class Sentinel(object): self.start_event.set() Stat(self).save() logger.info(_('Q Cluster-{} running.').format(self.parent_pid)) - scheduler(list_key=self.list_key) + scheduler(broker=self.broker) counter = 0 cycle = 0.5 # guard loop sleep in seconds # Guard loop. Runs at least once @@ -240,7 +240,7 @@ class Sentinel(object): counter += cycle if counter == 30: counter = 0 - scheduler(list_key=self.list_key) + scheduler(broker=self.broker) # Save current status Stat(self).save() sleep(cycle) @@ -287,38 +287,38 @@ class Sentinel(object): Stat(self).save() -def pusher(task_queue, event, list_key=Conf.Q_LIST): +def pusher(task_queue, event, broker=get_broker()): """ Pulls tasks of the Redis List and puts them in the task queue :type task_queue: multiprocessing.Queue :type event: multiprocessing.Event - :type list_key: str """ logger.info(_('{} pushing tasks at {}').format(current_process().name, current_process().pid)) - r = redis_client while True: try: - task = r.blpop(list_key, 1) + task = broker.dequeue() except Exception as e: logger.error(e) - # redis probably crashed. Let the sentinel handle it. + # broker probably crashed. Let the sentinel handle it. sleep(10) break if task: + ack_id = task[0] # unpack the task try: task = signing.SignedPackage.loads(task[1]) except (TypeError, signing.BadSignature) as e: logger.error(e) continue + task['ack_id'] = ack_id task_queue.put(task) - logger.debug(_('queueing from {}').format(list_key)) + logger.debug(_('queueing from {}').format(broker.list_key)) if event.is_set(): break logger.info(_("{} stopped pushing tasks").format(current_process().name)) -def monitor(result_queue): +def monitor(result_queue, broker=get_broker()): """ Gets finished tasks from the result queue and saves them to Django :type result_queue: multiprocessing.Queue @@ -327,6 +327,9 @@ def monitor(result_queue): logger.info(_("{} monitoring at {}").format(name, current_process().pid)) db.close_old_connections() for task in iter(result_queue.get, 'STOP'): + ack_id = task.pop('ack_id', False) + if ack_id: + broker.acknowledge(ack_id) save_task(task) if task['success']: logger.info(_("Processed [{}]").format(task['name'])) @@ -410,7 +413,7 @@ def save_task(task): logger.error(e) -def scheduler(list_key=Conf.Q_LIST): +def scheduler(broker=get_broker()): """ Creates a task from a schedule at the scheduled time and schedules next run """ @@ -456,7 +459,7 @@ def scheduler(list_key=Conf.Q_LIST): s.next_run = next_run.datetime s.repeats += -1 # send it to the cluster - q_options['list_key'] = list_key + q_options['broker'] = broker q_options['group'] = s.name or s.id kwargs['q_options'] = q_options s.task = tasks.async(s.func, *args, **kwargs) diff --git a/django_q/conf.py b/django_q/conf.py index 16a00b1..c31834c 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -46,6 +46,7 @@ class Conf(object): # Maximum number of tasks that each cluster can work on QUEUE_LIMIT = conf.get('queue_limit', None) + # Number of workers in the pool. Default is cpu count if implemented, otherwise 4. WORKERS = conf.get('workers', False) if not WORKERS: diff --git a/django_q/monitor.py b/django_q/monitor.py index 9acc8b0..c978607 100644 --- a/django_q/monitor.py +++ b/django_q/monitor.py @@ -11,13 +11,14 @@ from django.utils.translation import ugettext as _ # local from django_q.conf import Conf, redis_client -from django_q.status import Stat, ping_redis +from django_q.status import Stat +from django_q.brokers import get_broker from django_q import models -def monitor(run_once=False, r=redis_client): +def monitor(run_once=False, broker=get_broker()): term = Terminal() - ping_redis(r) + broker.ping() with term.fullscreen(), term.hidden_cursor(), term.cbreak(): val = None start_width = int(term.width / 8) @@ -36,7 +37,7 @@ def monitor(run_once=False, r=redis_client): print(term.move(0, 6 * col_width) + term.black_on_green(term.center(_('RC'), width=col_width - 1))) print(term.move(0, 7 * col_width) + term.black_on_green(term.center(_('Up'), width=col_width - 1))) i = 2 - stats = Stat.get_all(r=r) + stats = Stat.get_all(broker=broker) print(term.clear_eos()) for stat in stats: status = stat.status @@ -79,15 +80,15 @@ def monitor(run_once=False, r=redis_client): i += 1 # for testing if run_once: - return Stat.get_all(r=r) + return Stat.get_all(broker=broker) print(term.move(i + 2, 0) + term.center(_('[Press q to quit]'))) val = term.inkey(timeout=1) -def info(r=redis_client): +def info(broker=get_broker()): term = Terminal() - ping_redis(r) - stat = Stat.get_all(r) + broker.ping() + stat = Stat.get_all(broker=broker) # general stats clusters = len(stat) workers = 0 @@ -141,7 +142,7 @@ def info(r=redis_client): ) print(term.cyan(_('Queued')) + term.move_x(1 * col_width) + - term.white(str(r.llen(Conf.Q_LIST))) + + term.white(str(broker.queue_size())) + term.move_x(2 * col_width) + term.cyan(_('Successes')) + term.move_x(3 * col_width) + diff --git a/django_q/status.py b/django_q/status.py index ef98148..63139c2 100644 --- a/django_q/status.py +++ b/django_q/status.py @@ -1,6 +1,7 @@ import socket from django.utils import timezone -from django_q.conf import Conf, logger, redis_client +from django_q.brokers import get_broker +from django_q.conf import Conf, logger import signing @@ -27,7 +28,7 @@ class Stat(Status): def __init__(self, sentinel): super(Stat, self).__init__(sentinel.parent_pid or sentinel.pid) - self.r = sentinel.r + self.broker = sentinel.broker or get_broker() self.tob = sentinel.tob self.reincarnations = sentinel.reincarnations self.sentinel = sentinel.pid @@ -63,7 +64,7 @@ class Stat(Status): def save(self): try: - self.r.set(self.key, signing.SignedPackage.dumps(self, True), 3) + self.broker.set(self.key, signing.SignedPackage.dumps(self, True), 3) except Exception as e: logger.error(e) @@ -71,15 +72,14 @@ class Stat(Status): return self.done_q_size + self.task_q_size == 0 @staticmethod - def get(cluster_id, r=redis_client): + def get(cluster_id, broker=get_broker()): """ gets the current status for the cluster :param cluster_id: id of the cluster :return: Stat or Status """ - key = Stat.get_key(cluster_id) - if r.exists(key): - pack = r.get(key) + pack = broker.get(Stat.get_key(cluster_id)) + if pack: try: return signing.SignedPackage.loads(pack) except signing.BadSignature: @@ -87,33 +87,23 @@ class Stat(Status): return Status(cluster_id) @staticmethod - def get_all(r=redis_client): + def get_all(broker=get_broker()): """ Get the status for all currently running clusters with the same prefix and secret key. :return: list of type Stat """ stats = [] - keys = r.keys(pattern='{}:*'.format(Conf.Q_STAT)) - if keys: - packs = r.mget(keys) - for pack in packs: - try: - stats.append(signing.SignedPackage.loads(pack)) - except signing.BadSignature: - continue + packs = broker.get_pattern('{}:*'.format(Conf.Q_STAT)) or [] + for pack in packs: + try: + stats.append(signing.SignedPackage.loads(pack)) + except signing.BadSignature: + continue return stats def __getstate__(self): # Don't pickle the redis connection state = dict(self.__dict__) - del state['r'] + del state['broker'] return state - - -def ping_redis(r): - try: - r.ping() - except Exception as e: - logger.error('Can not connect to Redis server.') - raise e diff --git a/django_q/tasks.py b/django_q/tasks.py index b9c7360..599078f 100644 --- a/django_q/tasks.py +++ b/django_q/tasks.py @@ -7,9 +7,10 @@ from django.utils import timezone # local import signing import cluster -from django_q.conf import Conf, redis_client, logger +from django_q.conf import Conf, logger from django_q.models import Schedule, Task from django_q.humanhash import uuid +from django_q.brokers import get_broker def async(func, *args, **kwargs): @@ -17,8 +18,7 @@ def async(func, *args, **kwargs): # get options from q_options dict or direct from kwargs options = kwargs.pop('q_options', kwargs) hook = options.pop('hook', None) - list_key = options.pop('list_key', Conf.Q_LIST) - redis = options.pop('redis', redis_client) + broker = options.pop('broker', get_broker()) sync = options.pop('sync', False) group = options.pop('group', None) save = options.pop('save', None) @@ -42,7 +42,7 @@ def async(func, *args, **kwargs): if sync or Conf.SYNC: return _sync(pack) # push it - redis.rpush(list_key, pack) + broker.enqueue(pack) logger.debug('Pushed {}'.format(tag)) return task['id'] @@ -152,17 +152,17 @@ def delete_group(group_id, tasks=False): return Task.delete_group(group_id, tasks) -def queue_size(list_key=Conf.Q_LIST, r=redis_client): +def queue_size(broker=get_broker()): """ Returns the current queue size. Note that this doesn't count any tasks currently being processed by workers. - :param list_key: optional redis key - :param r: optional redis connection + :param list_key: optional list key + :param broker: optional broker :return: current queue size :rtype: int """ - return r.llen(list_key) + return broker.queue_size() def _sync(pack): diff --git a/django_q/tests/test_cluster.py b/django_q/tests/test_cluster.py index 7e5a407..f35c3c3 100644 --- a/django_q/tests/test_cluster.py +++ b/django_q/tests/test_cluster.py @@ -13,8 +13,9 @@ from django_q.cluster import Cluster, Sentinel, pusher, worker, monitor from django_q.humanhash import DEFAULT_WORDLIST from django_q.tasks import fetch, fetch_group, async, result, result_group, count_group, delete_group, queue_size from django_q.models import Task, Success -from django_q.conf import Conf, redis_client +from django_q.conf import Conf from django_q.status import Stat +from django_q.brokers import get_broker from .tasks import multiply @@ -27,25 +28,25 @@ class WordClass(object): @pytest.fixture -def r(): - return redis_client +def broker(): + return get_broker() -def test_redis_connection(r): - assert r.ping() is True +def test_redis_connection(broker): + assert broker.ping() is True @pytest.mark.django_db -def test_sync(r): - task = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, redis=r, sync=True) +def test_sync(broker): + task = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, broker=broker, sync=True) assert result(task) == 1506 @pytest.mark.django_db -def test_cluster_initial(r): - list_key = 'initial_test:q' - r.delete(list_key) - c = Cluster(list_key=list_key) +def test_cluster_initial(broker): + broker.list_key = 'initial_test:q' + broker.delete_queue() + c = Cluster(broker=broker) assert c.sentinel is None assert c.stat.status == Conf.STOPPED assert c.start() > 0 @@ -59,7 +60,7 @@ def test_cluster_initial(r): assert c.stop() is True assert c.sentinel.is_alive() is False assert c.has_stopped - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db @@ -67,17 +68,17 @@ def test_sentinel(): start_event = Event() stop_event = Event() stop_event.set() - s = Sentinel(stop_event, start_event, list_key='sentinel_test:q') + s = Sentinel(stop_event, start_event, broker=get_broker('sentinel_test:q')) assert start_event.is_set() assert s.status() == Conf.STOPPED @pytest.mark.django_db -def test_cluster(r): - list_key = 'cluster_test:q' - r.delete(list_key) - task = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, list_key=list_key) - assert queue_size(list_key=list_key, r=r) == 1 +def test_cluster(broker): + broker.list_key = 'cluster_test:q' + broker.delete_queue() + task = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, broker=broker) + assert broker.queue_size() == 1 task_queue = Queue() assert task_queue.qsize() == 0 result_queue = Queue() @@ -85,9 +86,9 @@ def test_cluster(r): event = Event() event.set() # Test push - pusher(task_queue, event, list_key=list_key) + pusher(task_queue, event, broker=broker) assert task_queue.qsize() == 1 - assert queue_size(list_key=list_key, r=r) == 0 + assert queue_size(broker=broker) == 0 # Test work task_queue.put('STOP') worker(task_queue, result_queue, Value('f', -1)) @@ -99,36 +100,36 @@ def test_cluster(r): assert result_queue.qsize() == 0 # check result assert result(task) == 1506 - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_async(r, admin_user): - list_key = 'cluster_test:q' - r.delete(list_key) +def test_async(broker, admin_user): + broker.list_key = 'cluster_test:q' + broker.delete_queue() a = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, hook='django_q.tests.test_cluster.assert_result', - list_key=list_key, redis=r) + broker=broker) b = async('django_q.tests.tasks.count_letters2', WordClass(), hook='django_q.tests.test_cluster.assert_result', - list_key=list_key, redis=r) + broker=broker) # unknown argument c = async('django_q.tests.tasks.count_letters', DEFAULT_WORDLIST, 'oneargumentoomany', - hook='django_q.tests.test_cluster.assert_bad_result', list_key=list_key, redis=r) + hook='django_q.tests.test_cluster.assert_bad_result', broker=broker) # unknown function d = async('django_q.tests.tasks.does_not_exist', WordClass(), hook='django_q.tests.test_cluster.assert_bad_result', - list_key=list_key, redis=r) + broker=broker) # function without result - e = async('django_q.tests.tasks.countdown', 100000, list_key=list_key, redis=r) + e = async('django_q.tests.tasks.countdown', 100000, broker=broker) # function as instance - f = async(multiply, 753, 2, hook=assert_result, list_key=list_key, redis=r) + f = async(multiply, 753, 2, hook=assert_result, broker=broker) # model as argument - g = async('django_q.tests.tasks.get_task_name', Task(name='John'), list_key=list_key, redis=r) + g = async('django_q.tests.tasks.get_task_name', Task(name='John'), broker=broker) # args,kwargs, group and broken hook - h = async('django_q.tests.tasks.word_multiply', 2, word='django', hook='fail.me', list_key=list_key, redis=r) + h = async('django_q.tests.tasks.word_multiply', 2, word='django', hook='fail.me', broker=broker) # args unpickle test - j = async('django_q.tests.tasks.get_user_id', admin_user, list_key=list_key, group='test_j', redis=r) + j = async('django_q.tests.tasks.get_user_id', admin_user, broker=broker, group='test_j') # q_options and save opt_out test k = async('django_q.tests.tasks.get_user_id', admin_user, - q_options={'list_key': list_key, 'group': 'test_k', 'redis': r, 'save': False, 'timeout': 90}) + q_options={'broker': broker, 'group': 'test_k', 'save': False, 'timeout': 90}) # check if everything has a task id assert isinstance(a, str) assert isinstance(b, str) @@ -142,14 +143,14 @@ def test_async(r, admin_user): assert isinstance(k, str) # run the cluster to execute the tasks task_count = 10 - assert queue_size(list_key=list_key, r=r) == task_count + assert broker.queue_size() == task_count task_queue = Queue() stop_event = Event() stop_event.set() # push the tasks for i in range(task_count): - pusher(task_queue, stop_event, list_key=list_key) - assert queue_size(list_key=list_key, r=r) == 0 + pusher(task_queue, stop_event, broker=broker) + assert broker.queue_size() == 0 assert task_queue.qsize() == task_count task_queue.put('STOP') # let a worker handle them @@ -218,64 +219,64 @@ def test_async(r, admin_user): assert delete_group('test_j', tasks=True) is None # task k should not have been saved assert fetch(k) is None - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_timeout(r): +def test_timeout(broker): # set up the Sentinel - list_key = 'timeout_test:q' - async('django_q.tests.tasks.count_forever', list_key=list_key) + broker.list_key = 'timeout_test:q' + async('django_q.tests.tasks.count_forever',broker=broker) start_event = Event() stop_event = Event() # Set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event, list_key=list_key, timeout=1) + s = Sentinel(stop_event, start_event, broker=broker, timeout=1) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_timeout(r): +def test_timeout(broker): # set up the Sentinel - list_key = 'timeout_test:q' - async('django_q.tests.tasks.count_forever', list_key=list_key) + broker.list_key = 'timeout_test:q' + async('django_q.tests.tasks.count_forever', broker=broker) start_event = Event() stop_event = Event() # Set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event, list_key=list_key, timeout=1) + s = Sentinel(stop_event, start_event,broker=broker, timeout=1) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_timeout_override(r): +def test_timeout_override(broker): # set up the Sentinel - list_key = 'timeout_override_test:q' - async('django_q.tests.tasks.count_forever', list_key=list_key, timeout=1) + broker.list_key = 'timeout_override_test:q' + async('django_q.tests.tasks.count_forever', broker=broker, timeout=1) start_event = Event() stop_event = Event() # Set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event, list_key=list_key, timeout=10) + s = Sentinel(stop_event, start_event, broker=broker, timeout=10) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_recycle(r): +def test_recycle(broker): # set up the Sentinel - list_key = 'test_recycle_test:q' - async('django_q.tests.tasks.multiply', 2, 2, list_key=list_key, redis=r) - async('django_q.tests.tasks.multiply', 2, 2, list_key=list_key, redis=r) - async('django_q.tests.tasks.multiply', 2, 2, list_key=list_key, redis=r) + broker.list_key = 'test_recycle_test:q' + async('django_q.tests.tasks.multiply', 2, 2, broker=broker) + async('django_q.tests.tasks.multiply', 2, 2, broker=broker) + async('django_q.tests.tasks.multiply', 2, 2, broker=broker) start_event = Event() stop_event = Event() # override settings @@ -283,17 +284,17 @@ def test_recycle(r): Conf.WORKERS = 1 # set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event, list_key=list_key) + s = Sentinel(stop_event, start_event,broker=broker) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 - async('django_q.tests.tasks.multiply', 2, 2, list_key=list_key, redis=r) - async('django_q.tests.tasks.multiply', 2, 2, list_key=list_key, redis=r) + async('django_q.tests.tasks.multiply', 2, 2, broker=broker) + async('django_q.tests.tasks.multiply', 2, 2, broker=broker) task_queue = Queue() result_queue = Queue() # push two tasks - pusher(task_queue, stop_event, list_key=list_key) - pusher(task_queue, stop_event, list_key=list_key) + pusher(task_queue, stop_event, broker=broker) + pusher(task_queue, stop_event, broker=broker) # worker should exit on recycle worker(task_queue, result_queue, Value('f', -1)) # check if the work has been done @@ -304,30 +305,30 @@ def test_recycle(r): # run monitor monitor(result_queue) assert Success.objects.count() == Conf.SAVE_LIMIT - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db -def test_bad_secret(r, monkeypatch): - list_key = 'test_bad_secret' - async('math.copysign', 1, -1, list_key=list_key) +def test_bad_secret(broker, monkeypatch): + broker.list_key='test_bad_secret:q' + async('math.copysign', 1, -1, broker=broker) stop_event = Event() stop_event.set() start_event = Event() - s = Sentinel(stop_event, start_event, list_key=list_key, start=False) + s = Sentinel(stop_event, start_event, broker=broker, start=False) Stat(s).save() # change the SECRET monkeypatch.setattr(Conf, "SECRET_KEY", "OOPS") - stat = Stat.get_all(r) + stat = Stat.get_all() assert len(stat) == 0 - assert Stat.get(s.parent_pid, r) is None + assert Stat.get(s.parent_pid) is None task_queue = Queue() - pusher(task_queue, stop_event, list_key=list_key) + pusher(task_queue, stop_event, broker=broker) result_queue = Queue() task_queue.put('STOP') worker(task_queue, result_queue, Value('f', -1), ) assert result_queue.qsize() == 0 - r.delete(list_key) + broker.delete_queue() @pytest.mark.django_db diff --git a/django_q/tests/test_config.py b/django_q/tests/test_config.py index de5ab71..10ade3d 100644 --- a/django_q/tests/test_config.py +++ b/django_q/tests/test_config.py @@ -3,11 +3,6 @@ import pytest from django_q import conf -@pytest.fixture -def r(): - return conf.redis_client - - def test_django_redis(): conf.Conf.DJANGO_REDIS = None assert conf.redis_client.ping() is True diff --git a/django_q/tests/test_monitor.py b/django_q/tests/test_monitor.py index 4f94b75..e1a3ac0 100644 --- a/django_q/tests/test_monitor.py +++ b/django_q/tests/test_monitor.py @@ -1,10 +1,9 @@ import pytest -import redis from django_q import async from django_q.cluster import Cluster from django_q.monitor import monitor, info -from django_q.status import Stat, ping_redis +from django_q.status import Stat @pytest.mark.django_db @@ -37,10 +36,3 @@ def test_info(): def do_sync(): async('django_q.tests.tasks.countdown', 1, sync=True, save=True) - - -@pytest.mark.django_db -def test_ping_redis(): - r = redis.StrictRedis(port=6388) - with pytest.raises(Exception): - ping_redis(r) diff --git a/django_q/tests/test_scheduler.py b/django_q/tests/test_scheduler.py index 7709477..2ee337a 100644 --- a/django_q/tests/test_scheduler.py +++ b/django_q/tests/test_scheduler.py @@ -6,20 +6,21 @@ import arrow from django.utils import timezone -from django_q.conf import redis_client, Conf +from django_q.brokers import get_broker +from django_q.conf import Conf from django_q.cluster import pusher, worker, monitor, scheduler from django_q.tasks import Schedule, fetch, schedule as create_schedule, queue_size @pytest.fixture -def r(): - return redis_client +def broker(): + return get_broker() @pytest.mark.django_db -def test_scheduler(r): - list_key = 'scheduler_test:q' - r.delete(list_key) +def test_scheduler(broker): + broker.list_key = 'scheduler_test:q' + broker.delete_queue() schedule = create_schedule('math.copysign', 1, -1, name='test math', @@ -28,15 +29,15 @@ def test_scheduler(r): repeats=1) assert schedule.last_run() is None # run scheduler - scheduler(list_key=list_key) + scheduler(broker=broker) # set up the workflow task_queue = Queue() stop_event = Event() stop_event.set() # push it - pusher(task_queue, stop_event, list_key=list_key) + pusher(task_queue, stop_event, broker=broker) assert task_queue.qsize() == 1 - assert queue_size(list_key=list_key, r=r) == 0 + assert broker.queue_size() == 0 task_queue.put('STOP') # let a worker handle them result_queue = Queue() @@ -91,7 +92,7 @@ def test_scheduler(r): ) assert schedule is not None assert schedule.last_run() is None - scheduler(list_key=list_key) + scheduler(broker=broker) # via model Schedule.objects.create(func='django_q.tests.tasks.word_multiply', args='2', @@ -99,7 +100,7 @@ def test_scheduler(r): schedule_type=Schedule.DAILY ) # scheduler - scheduler(list_key=list_key) + scheduler(broker=broker) # ONCE schedule should be deleted assert Schedule.objects.filter(pk=once_schedule.pk).exists() is False # Catch up On @@ -112,13 +113,13 @@ def test_scheduler(r): next_run=timezone.now() - timedelta(hours=12), repeats=-1 ) - scheduler(list_key=list_key) + scheduler(broker=broker) schedule = Schedule.objects.get(pk=schedule.pk) assert schedule.next_run < now # Catch up off Conf.CATCH_UP = False - scheduler(list_key=list_key) + scheduler(broker=broker) schedule = Schedule.objects.get(pk=schedule.pk) assert schedule.next_run > now # Done - r.delete(list_key) + broker.delete_queue() From 93cbfc37e23e16b5a71da2463b6b571499ca6fbf Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sat, 29 Aug 2015 19:08:19 +0200 Subject: [PATCH 02/48] fixes import confusion in python 2.7 --- django_q/brokers/__init__.py | 4 ++-- django_q/brokers/{django_redis.py => djangoredis.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename django_q/brokers/{django_redis.py => djangoredis.py} (100%) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 23ebf21..1d6f746 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -43,5 +43,5 @@ def get_broker(list_key=Conf.Q_LIST): from brokers import redis return redis.Redis(list_key=list_key) elif Conf.DJANGO_REDIS: - from brokers import django_redis - return django_redis.DjangoRedis(list_key=list_key) + from brokers import djangoredis + return djangoredis.DjangoRedis(list_key=list_key) diff --git a/django_q/brokers/django_redis.py b/django_q/brokers/djangoredis.py similarity index 100% rename from django_q/brokers/django_redis.py rename to django_q/brokers/djangoredis.py From d3283571a6b15d52a03b652799cf4cfded7ac308 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 14:33:35 +0200 Subject: [PATCH 03/48] First draft of a Disque broker TODO monitoring doesn't work yet --- django_q/brokers/__init__.py | 12 ++++++++---- django_q/brokers/disque.py | 36 ++++++++++++++++++++++++++++++++++++ django_q/conf.py | 8 +++++++- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 django_q/brokers/disque.py diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 1d6f746..213ed49 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -39,9 +39,13 @@ class Broker(object): def get_broker(list_key=Conf.Q_LIST): - if Conf.REDIS: - from brokers import redis - return redis.Redis(list_key=list_key) - elif Conf.DJANGO_REDIS: + if Conf.DJANGO_REDIS: from brokers import djangoredis return djangoredis.DjangoRedis(list_key=list_key) + elif Conf.DISQUE: + from brokers import disque + return disque.Disque(list_key=list_key) + # default to redis + else: + from brokers import redis + return redis.Redis(list_key=list_key) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py new file mode 100644 index 0000000..faff2c9 --- /dev/null +++ b/django_q/brokers/disque.py @@ -0,0 +1,36 @@ +import redis +from django_q.brokers import Broker +from django_q.conf import Conf + + +class Disque(Broker): + def enqueue(self, task): + return self.connection.execute_command( + 'ADDJOB {} {} 500 RETRY {}'.format(self.list_key, task, Conf.RETRY)).decode() + + def dequeue(self): + task = self.connection.execute_command('GETJOB TIMEOUT 1000 FROM {}'.format(self.list_key)) + if task: + return task[0][1].decode(), task[0][2].decode() + + def queue_size(self): + return self.connection.execute_command('QLEN {}'.format(self.list_key)) + + def acknowledge(self, ack_id): + return self.connection.execute_command('ACKJOB {}'.format(ack_id)) + + def ping(self): + return self.connection.ping() + + @staticmethod + def get_connection(): + for node in Conf.DISQUE: + host, port = node.split(':') + redis_client = redis.Redis(host, int(port)) + try: + redis_client.ping() + redis_client.decode_responses = True + return redis_client + except redis.exceptions.ConnectionError: + pass + raise ConnectionError('Could not connect to any Disque nodes') diff --git a/django_q/conf.py b/django_q/conf.py index c31834c..615abf3 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -33,6 +33,9 @@ class Conf(object): DJANGO_REDIS = conf.get('django_redis', None) + # Disque broker + DISQUE = conf.get('disque', None) + # Name of the cluster or site. For when you run multiple sites on one redis server PREFIX = conf.get('name', 'default') @@ -46,7 +49,6 @@ class Conf(object): # Maximum number of tasks that each cluster can work on QUEUE_LIMIT = conf.get('queue_limit', None) - # Number of workers in the pool. Default is cpu count if implemented, otherwise 4. WORKERS = conf.get('workers', False) if not WORKERS: @@ -70,6 +72,10 @@ class Conf(object): # Number of seconds to wait for a worker to finish. TIMEOUT = conf.get('timeout', None) + # Number of seconds to wait for acknowledgement before retrying a task + # Only works with brokers that guarantee delivery. Defaults to 0. Meaning no retries. + RETRY = conf.get('retry', 0) + # The Django Admin label for this app LABEL = conf.get('label', 'Django Q') From 4f39062785e17eacf960967b74bfae2fc7fc3222 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 18:28:26 +0200 Subject: [PATCH 04/48] Adds django cache as monitor cache Not the best solution yet, but it solves monitoring problems with brokers that don't have a pattern getter for cluster stats. --- django_q/brokers/__init__.py | 36 ++++++++++++++++++++++++++++++------ django_q/brokers/redis.py | 6 +++--- django_q/conf.py | 3 +++ django_q/status.py | 6 +++--- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 213ed49..6ac89cf 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -1,10 +1,12 @@ from django_q.conf import Conf +from django.core.cache import caches, InvalidCacheBackendError class Broker(object): def __init__(self, list_key=Conf.Q_LIST): self.connection = self.get_connection() self.list_key = list_key + self.cache=self.get_cache() def enqueue(self, task): pass @@ -24,14 +26,36 @@ class Broker(object): def ping(self): pass - def set(self, key, value, timeout): - pass + def set_stat(self, key, value, timeout): + key_list=self.cache.get(Conf.Q_STAT, []) + if key not in key_list: + key_list.append(key) + self.cache.set(Conf.Q_STAT, key_list) + return self.cache.set(key, value, timeout) - def get(self, key): - pass + def get_stat(self, key): + return self.cache.get(key) - def get_pattern(self, pattern): - pass + def get_stats(self, pattern): + key_list = self.cache.get(Conf.Q_STAT) + if not key_list or len(key_list) == 0: + return [] + stats = [] + for key in key_list: + stat = self.cache.get(key) + if stat: + stats.append(stat) + else: + key_list.remove(key) + self.cache.set(Conf.Q_STAT,key_list) + return stats + + @staticmethod + def get_cache(): + try: + return caches[Conf.CACHE] + except InvalidCacheBackendError: + return None @staticmethod def get_connection(): diff --git a/django_q/brokers/redis.py b/django_q/brokers/redis.py index 86e0143..042a0cd 100644 --- a/django_q/brokers/redis.py +++ b/django_q/brokers/redis.py @@ -26,14 +26,14 @@ class Redis(Broker): logger.error('Can not connect to Redis server.') raise e - def set(self, key, value, timeout): + def set_stat(self, key, value, timeout): self.connection.set(key, value, timeout) - def get(self, key): + def get_stat(self, key): if self.connection.exists(key): return self.connection.get(key) - def get_pattern(self, pattern): + def get_stats(self, pattern): keys = self.connection.keys(pattern=pattern) if keys: return self.connection.mget(keys) diff --git a/django_q/conf.py b/django_q/conf.py index 615abf3..394861c 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -85,6 +85,9 @@ class Conf(object): # Global sync option to for debugging SYNC = conf.get('sync', False) + # The Django cache to use + CACHE = conf.get('cache', 'default') + # If set to False the scheduler won't execute tasks in the past. # Instead it will run once and reschedule the next run in the future. Defaults to True. CATCH_UP = conf.get('catch_up', True) diff --git a/django_q/status.py b/django_q/status.py index 63139c2..7c4aa17 100644 --- a/django_q/status.py +++ b/django_q/status.py @@ -64,7 +64,7 @@ class Stat(Status): def save(self): try: - self.broker.set(self.key, signing.SignedPackage.dumps(self, True), 3) + self.broker.set_stat(self.key, signing.SignedPackage.dumps(self, True), 3) except Exception as e: logger.error(e) @@ -78,7 +78,7 @@ class Stat(Status): :param cluster_id: id of the cluster :return: Stat or Status """ - pack = broker.get(Stat.get_key(cluster_id)) + pack = broker.get_stat(Stat.get_key(cluster_id)) if pack: try: return signing.SignedPackage.loads(pack) @@ -94,7 +94,7 @@ class Stat(Status): :return: list of type Stat """ stats = [] - packs = broker.get_pattern('{}:*'.format(Conf.Q_STAT)) or [] + packs = broker.get_stats('{}:*'.format(Conf.Q_STAT)) or [] for pack in packs: try: stats.append(signing.SignedPackage.loads(pack)) From d6b9b10e3e2ac6a38c9613ffab68fa005ae253be Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 20:26:30 +0200 Subject: [PATCH 05/48] Adds tests for brokers * test for redis and django-redis * test for disque TODO add disque to Travis --- django_q/brokers/__init__.py | 6 ++-- django_q/brokers/disque.py | 3 ++ django_q/tests/test_brokers.py | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 django_q/tests/test_brokers.py diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 6ac89cf..9bdb7fa 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -6,7 +6,7 @@ class Broker(object): def __init__(self, list_key=Conf.Q_LIST): self.connection = self.get_connection() self.list_key = list_key - self.cache=self.get_cache() + self.cache = self.get_cache() def enqueue(self, task): pass @@ -27,7 +27,7 @@ class Broker(object): pass def set_stat(self, key, value, timeout): - key_list=self.cache.get(Conf.Q_STAT, []) + key_list = self.cache.get(Conf.Q_STAT, []) if key not in key_list: key_list.append(key) self.cache.set(Conf.Q_STAT, key_list) @@ -47,7 +47,7 @@ class Broker(object): stats.append(stat) else: key_list.remove(key) - self.cache.set(Conf.Q_STAT,key_list) + self.cache.set(Conf.Q_STAT, key_list) return stats @staticmethod diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index faff2c9..45f20d5 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -22,6 +22,9 @@ class Disque(Broker): def ping(self): return self.connection.ping() + def delete_queue(self, list_key=None): + raise NotImplementedError + @staticmethod def get_connection(): for node in Conf.DISQUE: diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py new file mode 100644 index 0000000..5c2020c --- /dev/null +++ b/django_q/tests/test_brokers.py @@ -0,0 +1,61 @@ +from time import sleep +import pytest +from django_q.conf import Conf +from django_q.brokers import get_broker, Broker + + +def test_broker(): + broker = Broker() + broker.enqueue('test') + broker.dequeue() + broker.queue_size() + broker.delete_queue() + broker.acknowledge('test') + broker.ping() + assert broker.get_stat('test_1') is None + broker.set_stat('test_1', 'test', 3) + assert broker.get_stat('test_1') == 'test' + assert broker.get_stats('test:*')[0] == 'test' + + +def test_redis(): + Conf.DJANGO_REDIS = None + broker = get_broker() + assert broker.ping() is True + Conf.REDIS = {'host': '127.0.0.1', 'port': 7712} + broker = get_broker() + with pytest.raises(Exception): + broker.ping() + + +def test_disque(): + Conf.DISQUE = ['127.0.0.1:7711'] + broker = get_broker() + assert broker.ping() is True + broker.enqueue('test') + assert broker.queue_size() == 1 + task = broker.dequeue() + assert task[1] == 'test' + broker.acknowledge(task[0]) + assert broker.queue_size() == 0 + # Retry test + Conf.RETRY = 1 + broker.enqueue('test') + assert broker.queue_size() == 1 + broker.dequeue() + assert broker.queue_size() == 0 + sleep(1.5) + assert broker.queue_size() == 1 + task = broker.dequeue() + assert broker.queue_size() == 0 + broker.acknowledge(task[0]) + sleep(1.5) + assert broker.queue_size() == 0 + # errors + with pytest.raises(NotImplementedError): + broker.delete_queue() + Conf.DISQUE = ['127.0.0.1:7712', '127.0.0.1:7713'] + with pytest.raises(ConnectionError): + broker.get_connection() + # back to djangoredis + Conf.DJANGO_REDIS = 'default' From 3a27af087747783b27db81152b56cb40a44ca375 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 20:33:23 +0200 Subject: [PATCH 06/48] Adds disque build to Travis --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.travis.yml b/.travis.yml index eff67cd..663833c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,13 @@ env: - DJANGO=1.8.4 - DJANGO=1.7.10 +before_script: + - sudo apt-get install tcl8.5 + - git clone https://github.com/antirez/disque.git disque_server + - "cd disque_server/src && make && PREFIX=../ make install && cd -" + - "./disque_server/bin/disque-server &" + - ./disque_server/bin/disque PING + install: - pip install -q django==$DJANGO - pip install -r requirements.txt From 452f53f8994c92314c9122342e51dccb68dab8e3 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 20:57:05 +0200 Subject: [PATCH 07/48] New container build on Travis --- .travis.yml | 9 +++++++-- django_q/tests/test_brokers.py | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 663833c..208212f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,8 +11,13 @@ env: - DJANGO=1.8.4 - DJANGO=1.7.10 +sudo: false +addons: + apt: + packages: + - tcl8.5 + before_script: - - sudo apt-get install tcl8.5 - git clone https://github.com/antirez/disque.git disque_server - "cd disque_server/src && make && PREFIX=../ make install && cd -" - "./disque_server/bin/disque-server &" @@ -25,7 +30,7 @@ install: - python setup.py install script: - - coverage run --source=django_q -m py.test + - coverage run --source=django_q -m py.testsudo: false - sphinx-build -b html -d docs/_build/doctrees -nW docs docs/_build/html after_success: diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 5c2020c..0005d4f 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -26,6 +26,8 @@ def test_redis(): broker = get_broker() with pytest.raises(Exception): broker.ping() + Conf.REDIS = None + Conf.DJANGO_REDIS = 'default' def test_disque(): @@ -57,5 +59,6 @@ def test_disque(): Conf.DISQUE = ['127.0.0.1:7712', '127.0.0.1:7713'] with pytest.raises(ConnectionError): broker.get_connection() - # back to djangoredis + # back to django-redis + Conf.DISQUE = None Conf.DJANGO_REDIS = 'default' From 5171f1a1835aae254c5dae65cb19f1820ca0a45e Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 21:04:07 +0200 Subject: [PATCH 08/48] Disabled Disque tests for now --- .travis.yml | 10 ---------- django_q/tests/test_brokers.py | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index 208212f..07473d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,16 +12,6 @@ env: - DJANGO=1.7.10 sudo: false -addons: - apt: - packages: - - tcl8.5 - -before_script: - - git clone https://github.com/antirez/disque.git disque_server - - "cd disque_server/src && make && PREFIX=../ make install && cd -" - - "./disque_server/bin/disque-server &" - - ./disque_server/bin/disque PING install: - pip install -q django==$DJANGO diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 0005d4f..4e9ff11 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -30,7 +30,7 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -def test_disque(): +def disabled_test_disque(): Conf.DISQUE = ['127.0.0.1:7711'] broker = get_broker() assert broker.ping() is True From c4b0a92e74a9a6b0c6cefd67f889ffb146613cd5 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 21:07:11 +0200 Subject: [PATCH 09/48] Fixes typo in travis conf --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 07473d6..c4982da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ install: - python setup.py install script: - - coverage run --source=django_q -m py.testsudo: false + - coverage run --source=django_q -m py.test - sphinx-build -b html -d docs/_build/doctrees -nW docs docs/_build/html after_success: From 9e04d8c9a84cec6a34d0558fb2c033b5835ba26b Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 30 Aug 2015 21:12:47 +0200 Subject: [PATCH 10/48] Fixes import problems in python 2.7 --- django_q/brokers/__init__.py | 4 ++-- django_q/brokers/djangoredis.py | 4 ++-- django_q/brokers/{redis.py => redis_broker.py} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename django_q/brokers/{redis.py => redis_broker.py} (100%) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 9bdb7fa..a57ea78 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -71,5 +71,5 @@ def get_broker(list_key=Conf.Q_LIST): return disque.Disque(list_key=list_key) # default to redis else: - from brokers import redis - return redis.Redis(list_key=list_key) + from brokers import redis_broker + return redis_broker.Redis(list_key=list_key) diff --git a/django_q/brokers/djangoredis.py b/django_q/brokers/djangoredis.py index 35b67bf..1b519d8 100644 --- a/django_q/brokers/djangoredis.py +++ b/django_q/brokers/djangoredis.py @@ -1,9 +1,9 @@ import django_redis -from django_q.brokers import redis +from django_q.brokers import redis_broker from django_q.conf import Conf -class DjangoRedis(redis.Redis): +class DjangoRedis(redis_broker.Redis): @staticmethod def get_connection(): diff --git a/django_q/brokers/redis.py b/django_q/brokers/redis_broker.py similarity index 100% rename from django_q/brokers/redis.py rename to django_q/brokers/redis_broker.py From da20ebe734e0c00bd934d6736b7b60601e76f22b Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Mon, 31 Aug 2015 15:15:24 +0200 Subject: [PATCH 11/48] fixes connection creation on import --- django_q/cluster.py | 20 +++++++++++++------- django_q/monitor.py | 10 +++++++--- django_q/status.py | 8 ++++++-- django_q/tasks.py | 5 +++-- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/django_q/cluster.py b/django_q/cluster.py index c28c4ad..5faf7d5 100644 --- a/django_q/cluster.py +++ b/django_q/cluster.py @@ -37,8 +37,8 @@ from django_q.brokers import get_broker class Cluster(object): - def __init__(self, broker=get_broker()): - self.broker = broker + def __init__(self, broker=None): + self.broker = broker or get_broker() self.sentinel = None self.stop_event = None self.start_event = None @@ -106,14 +106,14 @@ class Cluster(object): class Sentinel(object): - def __init__(self, stop_event, start_event, broker=get_broker(), timeout=Conf.TIMEOUT, start=True): + def __init__(self, stop_event, start_event, broker=None, timeout=Conf.TIMEOUT, start=True): # Make sure we catch signals for the pool signal.signal(signal.SIGINT, signal.SIG_IGN) signal.signal(signal.SIGTERM, signal.SIG_DFL) self.pid = current_process().pid self.parent_pid = get_ppid() self.name = current_process().name - self.broker = broker + self.broker = broker or get_broker() self.reincarnations = 0 self.tob = timezone.now() self.stop_event = stop_event @@ -287,12 +287,14 @@ class Sentinel(object): Stat(self).save() -def pusher(task_queue, event, broker=get_broker()): +def pusher(task_queue, event, broker=None): """ Pulls tasks of the Redis List and puts them in the task queue :type task_queue: multiprocessing.Queue :type event: multiprocessing.Event """ + if not broker: + broker = get_broker() logger.info(_('{} pushing tasks at {}').format(current_process().name, current_process().pid)) while True: try: @@ -318,11 +320,13 @@ def pusher(task_queue, event, broker=get_broker()): logger.info(_("{} stopped pushing tasks").format(current_process().name)) -def monitor(result_queue, broker=get_broker()): +def monitor(result_queue, broker=None): """ Gets finished tasks from the result queue and saves them to Django :type result_queue: multiprocessing.Queue """ + if not broker: + broker = get_broker() name = current_process().name logger.info(_("{} monitoring at {}").format(name, current_process().pid)) db.close_old_connections() @@ -413,10 +417,12 @@ def save_task(task): logger.error(e) -def scheduler(broker=get_broker()): +def scheduler(broker=None): """ Creates a task from a schedule at the scheduled time and schedules next run """ + if not broker: + broker = get_broker() try: for s in Schedule.objects.exclude(repeats=0).filter(next_run__lt=timezone.now()): args = () diff --git a/django_q/monitor.py b/django_q/monitor.py index c978607..db0d167 100644 --- a/django_q/monitor.py +++ b/django_q/monitor.py @@ -10,13 +10,15 @@ from django.utils import timezone from django.utils.translation import ugettext as _ # local -from django_q.conf import Conf, redis_client +from django_q.conf import Conf from django_q.status import Stat from django_q.brokers import get_broker from django_q import models -def monitor(run_once=False, broker=get_broker()): +def monitor(run_once=False, broker=None): + if not broker: + broker = get_broker() term = Terminal() broker.ping() with term.fullscreen(), term.hidden_cursor(), term.cbreak(): @@ -85,7 +87,9 @@ def monitor(run_once=False, broker=get_broker()): val = term.inkey(timeout=1) -def info(broker=get_broker()): +def info(broker=None): + if not broker: + broker = get_broker() term = Terminal() broker.ping() stat = Stat.get_all(broker=broker) diff --git a/django_q/status.py b/django_q/status.py index 7c4aa17..db03825 100644 --- a/django_q/status.py +++ b/django_q/status.py @@ -72,12 +72,14 @@ class Stat(Status): return self.done_q_size + self.task_q_size == 0 @staticmethod - def get(cluster_id, broker=get_broker()): + def get(cluster_id, broker=None): """ gets the current status for the cluster :param cluster_id: id of the cluster :return: Stat or Status """ + if not broker: + broker = get_broker() pack = broker.get_stat(Stat.get_key(cluster_id)) if pack: try: @@ -87,12 +89,14 @@ class Stat(Status): return Status(cluster_id) @staticmethod - def get_all(broker=get_broker()): + def get_all(broker=None): """ Get the status for all currently running clusters with the same prefix and secret key. :return: list of type Stat """ + if not broker: + broker = get_broker() stats = [] packs = broker.get_stats('{}:*'.format(Conf.Q_STAT)) or [] for pack in packs: diff --git a/django_q/tasks.py b/django_q/tasks.py index 599078f..17a8952 100644 --- a/django_q/tasks.py +++ b/django_q/tasks.py @@ -152,16 +152,17 @@ def delete_group(group_id, tasks=False): return Task.delete_group(group_id, tasks) -def queue_size(broker=get_broker()): +def queue_size(broker=None): """ Returns the current queue size. Note that this doesn't count any tasks currently being processed by workers. - :param list_key: optional list key :param broker: optional broker :return: current queue size :rtype: int """ + if not broker: + broker = get_broker() return broker.queue_size() From 8ce441bed5850faddc59a4b333135fb2d83f8551 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 19:04:03 +0200 Subject: [PATCH 12/48] Adds more brokers * added IronMQ * added Aws SQS * removed SafeRedis. During tests the 'safe' part wasn't consistent enough --- django_q/__init__.py | 2 +- django_q/brokers/__init__.py | 94 +++++++++++++++++++++++++++++--- django_q/brokers/aws_sqs.py | 57 +++++++++++++++++++ django_q/brokers/disque.py | 26 ++++++--- django_q/brokers/djangoredis.py | 10 ---- django_q/brokers/iron_mq.py | 38 +++++++++++++ django_q/brokers/redis_broker.py | 20 +++++-- django_q/conf.py | 18 ++++-- django_q/tests/test_brokers.py | 80 +++++++++++++++++++++++++-- docs/conf.py | 4 +- requirements.in | 2 + requirements.txt | 8 ++- setup.py | 4 +- 13 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 django_q/brokers/aws_sqs.py delete mode 100644 django_q/brokers/djangoredis.py create mode 100644 django_q/brokers/iron_mq.py diff --git a/django_q/__init__.py b/django_q/__init__.py index c0913fb..9fc91f1 100644 --- a/django_q/__init__.py +++ b/django_q/__init__.py @@ -9,6 +9,6 @@ from .models import Task, Schedule, Success, Failure from .cluster import Cluster from .status import Stat -VERSION = (0, 5, 3) +VERSION = (0, 6, 0) default_app_config = 'django_q.apps.DjangoQConfig' diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index a57ea78..ce7f9a8 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -3,30 +3,74 @@ from django.core.cache import caches, InvalidCacheBackendError class Broker(object): - def __init__(self, list_key=Conf.Q_LIST): - self.connection = self.get_connection() + def __init__(self, list_key=Conf.PREFIX): + self.connection = self.get_connection(list_key) self.list_key = list_key self.cache = self.get_cache() def enqueue(self, task): + """ + Puts a task onto the queue + :type task: str + :return: task id + """ pass def dequeue(self): + """ + Gets a task from the queue + :return: tuple with task id and task message + """ pass def queue_size(self): + """ + :return: the amount of tasks in the queue + """ pass - def delete_queue(self, list_key=None): + def delete_queue(self): + """ + Deletes the queue from the broker + """ pass - def acknowledge(self, ack_id): + def purge_queue(self): + """ + Purges the queue of any tasks + """ + pass + + def delete(self, task_id): + """ + Deletes a task from the queue + :param task_id: the id of the task + """ + pass + + def acknowledge(self, task_id): + """ + Acknowledges completion of the task and removes it from the queue. + :param task_id: the id of the task + """ pass def ping(self): + """ + Checks whether the broker connection is available + :rtype: bool + """ pass def set_stat(self, key, value, timeout): + """ + Saves a cluster statistic to the cache provider + :type key: str + :type value: str + :type timeout: int + """ + if not self.cache: + return key_list = self.cache.get(Conf.Q_STAT, []) if key not in key_list: key_list.append(key) @@ -34,9 +78,23 @@ class Broker(object): return self.cache.set(key, value, timeout) def get_stat(self, key): + """ + Gets a cluster statistic from the cache provider + :type key: str + :return: a cluster Stat + """ + if not self.cache: + return return self.cache.get(key) def get_stats(self, pattern): + """ + Returns a list of all cluster stats from the cache provider + :type pattern: str + :return: a list of Stats + """ + if not self.cache: + return key_list = self.cache.get(Conf.Q_STAT) if not key_list or len(key_list) == 0: return [] @@ -52,23 +110,41 @@ class Broker(object): @staticmethod def get_cache(): + """ + Gets the current cache provider + :return: a cache provider + """ try: return caches[Conf.CACHE] except InvalidCacheBackendError: return None @staticmethod - def get_connection(): + def get_connection(list_key=Conf.PREFIX): + """ + Gets a connection to the broker + :param list_key: Optional queue name + :return: a broker connection + """ return 0 -def get_broker(list_key=Conf.Q_LIST): - if Conf.DJANGO_REDIS: - from brokers import djangoredis - return djangoredis.DjangoRedis(list_key=list_key) +def get_broker(list_key=Conf.PREFIX): + """ + Gets the configured broker type + :param list_key: optional queue name + :type list_key: str + :return: + """ + if Conf.IRONMQ: + from brokers import iron_mq + return iron_mq.IronMQBroker(list_key=list_key) elif Conf.DISQUE: from brokers import disque return disque.Disque(list_key=list_key) + elif Conf.SQS: + from brokers import aws_sqs + return aws_sqs.Sqs(list_key=list_key) # default to redis else: from brokers import redis_broker diff --git a/django_q/brokers/aws_sqs.py b/django_q/brokers/aws_sqs.py new file mode 100644 index 0000000..4e58bae --- /dev/null +++ b/django_q/brokers/aws_sqs.py @@ -0,0 +1,57 @@ +import os +from django_q.conf import Conf +from django_q.brokers import Broker +import boto.sqs +from boto.sqs.message import Message + + +class Sqs(Broker): + def __init__(self, list_key=Conf.PREFIX): + super().__init__(list_key) + self.queue = self.get_queue() + + def enqueue(self, task): + m = Message() + m.set_body(task) + self.queue.write(m) + return m.id + + def dequeue(self): + rs = self.queue.get_messages(visibility_timeout=Conf.RETRY or 30) + if rs: + m = rs[0] + return m.receipt_handle, m.get_body() + + def acknowledge(self, task_id): + return self.delete(task_id) + + def queue_size(self): + return self.queue.count() + + def delete(self, task_id): + m = Message() + m.receipt_handle = task_id + return self.queue.delete_message(m) + + def delete_queue(self): + self.connection.delete_queue(self.queue) + + def purge_queue(self): + self.queue.purge() + + def ping(self): + try: + self.connection.get_all_queues() + return True + except Exception as e: + raise e + + @staticmethod + def get_connection(list_key=Conf.PREFIX): + conn = boto.sqs.connect_to_region(Conf.SQS['region'], + aws_access_key_id=Conf.SQS['aws_access_key_id'], + aws_secret_access_key=Conf.SQS['aws_secret_access_key']) + return conn + + def get_queue(self): + return self.connection.create_queue(self.list_key) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 45f20d5..d26d29b 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -1,9 +1,11 @@ +import random import redis from django_q.brokers import Broker from django_q.conf import Conf class Disque(Broker): + def enqueue(self, task): return self.connection.execute_command( 'ADDJOB {} {} 500 RETRY {}'.format(self.list_key, task, Conf.RETRY)).decode() @@ -16,24 +18,34 @@ class Disque(Broker): def queue_size(self): return self.connection.execute_command('QLEN {}'.format(self.list_key)) - def acknowledge(self, ack_id): - return self.connection.execute_command('ACKJOB {}'.format(ack_id)) + def acknowledge(self, task_id): + return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): return self.connection.ping() - def delete_queue(self, list_key=None): - raise NotImplementedError + def delete(self, task_id): + return self.connection.execute_command('DELJOB {}'.format(task_id)) + + def delete_queue(self): + jobs = self.connection.execute_command('JSCAN QUEUE {}'.format(self.list_key))[1] + if jobs: + self.connection.execute_command('DELJOB {}'.format(' '.join(map(str, jobs)))) @staticmethod - def get_connection(): + def get_connection(list_key=Conf.PREFIX): + # randomize nodes + random.shuffle(Conf.DISQUE) + # find one that works for node in Conf.DISQUE: host, port = node.split(':') redis_client = redis.Redis(host, int(port)) try: - redis_client.ping() + if Conf.DISQUE_AUTH: + redis_client.execute_command('AUTH {}'.format(Conf.DISQUE_AUTH)) redis_client.decode_responses = True + redis_client.execute_command('HELLO') return redis_client except redis.exceptions.ConnectionError: - pass + continue raise ConnectionError('Could not connect to any Disque nodes') diff --git a/django_q/brokers/djangoredis.py b/django_q/brokers/djangoredis.py deleted file mode 100644 index 1b519d8..0000000 --- a/django_q/brokers/djangoredis.py +++ /dev/null @@ -1,10 +0,0 @@ -import django_redis -from django_q.brokers import redis_broker -from django_q.conf import Conf - - -class DjangoRedis(redis_broker.Redis): - - @staticmethod - def get_connection(): - return django_redis.get_redis_connection(Conf.DJANGO_REDIS) \ No newline at end of file diff --git a/django_q/brokers/iron_mq.py b/django_q/brokers/iron_mq.py new file mode 100644 index 0000000..9fbc79d --- /dev/null +++ b/django_q/brokers/iron_mq.py @@ -0,0 +1,38 @@ +from django_q.conf import Conf +from django_q.brokers import Broker +from iron_mq import IronMQ + + +class IronMQBroker(Broker): + + def enqueue(self, task): + return self.connection.post(task)['ids'][0] + + def dequeue(self): + timeout = Conf.RETRY or None + task = self.connection.get(timeout=timeout, wait=1)['messages'] + if task: + return task[0]['id'], task[0]['body'] + + def ping(self): + return self.connection.name == self.list_key + + def queue_size(self): + return self.connection.size() + + def delete_queue(self): + return self.connection.delete_queue()['msg'] + + def purge_queue(self): + return self.connection.clear()['msg'] + + def delete(self, task_id): + return self.connection.delete(task_id)['msg'] + + def acknowledge(self, task_id): + return self.delete(task_id) + + @staticmethod + def get_connection(list_key=Conf.PREFIX): + ironmq = IronMQ(name=None, **Conf.IRONMQ) + return ironmq.queue(queue_name=list_key) diff --git a/django_q/brokers/redis_broker.py b/django_q/brokers/redis_broker.py index 042a0cd..de85882 100644 --- a/django_q/brokers/redis_broker.py +++ b/django_q/brokers/redis_broker.py @@ -2,8 +2,17 @@ import redis from django_q.brokers import Broker from django_q.conf import Conf, logger +try: + import django_redis +except ImportError: + django_redis = None + class Redis(Broker): + + def __init__(self, list_key=Conf.PREFIX): + super().__init__(list_key='django_q:{}:q'.format(list_key)) + def enqueue(self, task): return self.connection.rpush(self.list_key, task) @@ -15,14 +24,13 @@ class Redis(Broker): def queue_size(self): return self.connection.llen(self.list_key) - def delete_queue(self, list_key=None): - list_key = list_key if list_key else self.list_key - return self.connection.delete(list_key) + def delete_queue(self): + return self.connection.delete(self.list_key) def ping(self): try: return self.connection.ping() - except Exception as e: + except redis.ConnectionError as e: logger.error('Can not connect to Redis server.') raise e @@ -39,5 +47,7 @@ class Redis(Broker): return self.connection.mget(keys) @staticmethod - def get_connection(): + def get_connection(list_key=Conf.PREFIX): + if django_redis and Conf.DJANGO_REDIS: + return django_redis.get_redis_connection(Conf.DJANGO_REDIS) return redis.StrictRedis(**Conf.REDIS) diff --git a/django_q/conf.py b/django_q/conf.py index 394861c..0e9c7b7 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -35,6 +35,16 @@ class Conf(object): # Disque broker DISQUE = conf.get('disque', None) + # Optional Authentication + DISQUE_AUTH = conf.get('disque_auth', None) + + # Amazon SQS broker + SQS = conf.get('sqs', None) + + # IronMQ broker + IRONMQ = conf.get('ironmq', None) + if IRONMQ and os.environ.get('IRONMQ_TOKEN'): + IRONMQ['token'] = os.environ['IRONMQ_TOKEN'] # Name of the cluster or site. For when you run multiple sites on one redis server PREFIX = conf.get('name', 'default') @@ -46,9 +56,6 @@ class Conf(object): # Failures are always saved SAVE_LIMIT = conf.get('save_limit', 250) - # Maximum number of tasks that each cluster can work on - QUEUE_LIMIT = conf.get('queue_limit', None) - # Number of workers in the pool. Default is cpu count if implemented, otherwise 4. WORKERS = conf.get('workers', False) if not WORKERS: @@ -63,6 +70,9 @@ class Conf(object): # sensible default WORKERS = 4 + # Maximum number of tasks that each cluster can work on + QUEUE_LIMIT = conf.get('queue_limit', None) + # Sets compression of redis packages COMPRESSED = conf.get('compress', False) @@ -96,8 +106,6 @@ class Conf(object): # Django itself should raise an error if it's not configured SECRET_KEY = settings.SECRET_KEY - # The redis list key - Q_LIST = 'django_q:{}:q'.format(PREFIX) # The redis stats key Q_STAT = 'django_q:{}:cluster'.format(PREFIX) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 4e9ff11..f666cff 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -1,5 +1,6 @@ from time import sleep import pytest +import os from django_q.conf import Conf from django_q.brokers import get_broker, Broker @@ -30,10 +31,13 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -def disabled_test_disque(): +@pytest.mark.skipif(Conf.DISQUE_AUTH is None, + reason="No disque server configured") +def test_disque(): Conf.DISQUE = ['127.0.0.1:7711'] - broker = get_broker() + broker = get_broker(list_key='disque_test') assert broker.ping() is True + broker.delete_queue() broker.enqueue('test') assert broker.queue_size() == 1 task = broker.dequeue() @@ -53,12 +57,76 @@ def disabled_test_disque(): broker.acknowledge(task[0]) sleep(1.5) assert broker.queue_size() == 0 - # errors - with pytest.raises(NotImplementedError): - broker.delete_queue() Conf.DISQUE = ['127.0.0.1:7712', '127.0.0.1:7713'] with pytest.raises(ConnectionError): broker.get_connection() + broker.delete_queue() + assert broker.queue_size() == 0 + # back to django-redis + Conf.DISQUE = None + + +@pytest.mark.skipif(not os.getenv('AWS_ACCESS_KEY_ID'), + reason="requires AWS SQS credentials") +def test_sqs(): + Conf.SQS = {'aws_access_key_id': os.getenv('AWS_ACCESS_KEY_ID'), + 'aws_secret_access_key': os.getenv('AWS_SECRET_ACCESS_KEY'), + 'region': os.getenv('SQS_REGION', 'eu-west-1')} + broker = get_broker(list_key='sqs_test') + assert broker.ping() is True + if broker.queue_size() > 0: + broker.purge_queue() + broker.enqueue('test') + task = broker.dequeue() + assert task[1] == 'test' + broker.acknowledge(task[0]) + assert broker.queue_size() == 0 + # Retry test + Conf.RETRY = 1 + broker.enqueue('test') + broker.dequeue() + assert broker.queue_size() == 0 + sleep(2) + # task should re-queue + assert broker.queue_size() == 1 + task = broker.dequeue() + assert task[1] == 'test' + assert broker.acknowledge(task[0]) is True + assert broker.queue_size() == 0 + broker.delete_queue() + # back to defaults + Conf.SQS = None + + +@pytest.mark.skipif(not os.getenv('IRONMQ_TOKEN'), + reason="requires IronMQ credentials") +def test_ironmq(): + Conf.IRONMQ = {'host': 'mq-aws-eu-west-1.iron.io', + 'token': os.getenv('IRONMQ_TOKEN'), + 'project_id': os.getenv('IRONMQ_PROJECT')} + broker = get_broker(list_key='djangoQ_test') + assert broker.ping() is True + broker.delete_queue() + broker.enqueue('test') + assert broker.queue_size() == 1 + task = broker.dequeue() + assert task[1] == 'test' + broker.acknowledge(task[0]) + assert broker.queue_size() == 0 + # Retry test + Conf.RETRY = 1 + broker.enqueue('test') + assert broker.queue_size() == 1 + broker.dequeue() + assert broker.queue_size() == 0 + sleep(1.5) + assert broker.queue_size() == 1 + task = broker.dequeue() + assert broker.queue_size() == 0 + broker.acknowledge(task[0]) + sleep(1.5) + assert broker.queue_size() == 0 + broker.delete_queue() + assert broker.queue_size() == 0 # back to django-redis Conf.DISQUE = None - Conf.DJANGO_REDIS = 'default' diff --git a/docs/conf.py b/docs/conf.py index 4775ee9..4140e5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,9 +70,9 @@ author = 'Ilan Steemers' # built documents. # # The short X.Y version. -version = '0.5' +version = '0.6' # The full version, including alpha/beta/rc tags. -release = '0.5.3' +release = '0.6.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/requirements.in b/requirements.in index 1ab7ca1..0198050 100644 --- a/requirements.in +++ b/requirements.in @@ -6,3 +6,5 @@ hiredis redis psutil django-redis +boto +iron-mq diff --git a/requirements.txt b/requirements.txt index 83e7e99..2ac9cf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,17 @@ # arrow==0.6.0 blessed==1.9.5 +boto==2.38.0 django-picklefield==0.3.1 django-redis==4.2.0 future==0.15.0 hiredis==0.2.0 +iron-core==1.1.9 # via iron-mq +iron-mq==0.7 msgpack-python==0.4.6 # via django-redis -psutil==3.1.1 -python-dateutil==2.4.2 # via arrow +psutil==3.2.0 +python-dateutil==2.4.2 # via arrow, iron-core redis==2.10.3 +requests==2.7.0 # via iron-core six==1.9.0 # via django-picklefield, python-dateutil wcwidth==0.1.4 # via blessed diff --git a/setup.py b/setup.py index aad8571..35b0afc 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ class PyTest(Command): setup( name='django-q', - version='0.5.3', + version='0.6.0', author='Ilan Steemers', author_email='koed00@gmail.com', keywords='django task queue worker redis multiprocessing', @@ -36,7 +36,7 @@ setup( license='MIT', description='A multiprocessing task queue for Django', long_description=README, - install_requires=['django>=1.7', 'redis', 'django-picklefield', 'blessed', 'arrow', 'future'], + install_requires=['django>=1.7', 'django-picklefield', 'blessed', 'arrow', 'future'], test_requires=['pytest', 'pytest-django', ], cmdclass={'test': PyTest}, classifiers=[ From 46ae50043c9fb8c9aeb2d81f5082df0e9a51e388 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 19:50:04 +0200 Subject: [PATCH 13/48] Fixes class super for python 2.7 --- django_q/brokers/aws_sqs.py | 2 +- django_q/brokers/redis_broker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/django_q/brokers/aws_sqs.py b/django_q/brokers/aws_sqs.py index 4e58bae..fd01070 100644 --- a/django_q/brokers/aws_sqs.py +++ b/django_q/brokers/aws_sqs.py @@ -7,7 +7,7 @@ from boto.sqs.message import Message class Sqs(Broker): def __init__(self, list_key=Conf.PREFIX): - super().__init__(list_key) + super(Sqs, self).__init__(list_key) self.queue = self.get_queue() def enqueue(self, task): diff --git a/django_q/brokers/redis_broker.py b/django_q/brokers/redis_broker.py index de85882..6f57567 100644 --- a/django_q/brokers/redis_broker.py +++ b/django_q/brokers/redis_broker.py @@ -11,7 +11,7 @@ except ImportError: class Redis(Broker): def __init__(self, list_key=Conf.PREFIX): - super().__init__(list_key='django_q:{}:q'.format(list_key)) + super(Redis, self).__init__(list_key='django_q:{}:q'.format(list_key)) def enqueue(self, task): return self.connection.rpush(self.list_key, task) From 0756fb9896c7d107baf6750b4cbbc73b66ed51b5 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 20:00:43 +0200 Subject: [PATCH 14/48] Experimenting with disque build on travis --- .travis.yml | 11 +++++++++++ django_q/tests/settings.py | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c4982da..b7b557c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,17 @@ env: sudo: false +addons: + apt: + packages: + - tcl8.5 + +before_script: + - git clone https://github.com/antirez/disque.git disque_server + - "cd disque_server/src && make && PREFIX=../ make install && cd -" + - "./disque_server/bin/disque-server &" + - ./disque_server/bin/disque PING + install: - pip install -q django==$DJANGO - pip install -r requirements.txt diff --git a/django_q/tests/settings.py b/django_q/tests/settings.py index 8bad66c..63c57f1 100644 --- a/django_q/tests/settings.py +++ b/django_q/tests/settings.py @@ -119,4 +119,5 @@ Q_CLUSTER = {'name': 'django_q_test', 'cpu_affinity': 1, 'testing': True, 'log_level': 'DEBUG', - 'django_redis': 'default'} + 'django_redis': 'default', + 'disque_auth': 'foobared'} From 1a61fa2821047a3dab57de0fbdfb851cc6ec578a Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 20:04:48 +0200 Subject: [PATCH 15/48] Experimenting with disque build on travis --- django_q/tests/settings.py | 3 +-- django_q/tests/test_brokers.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/django_q/tests/settings.py b/django_q/tests/settings.py index 63c57f1..8bad66c 100644 --- a/django_q/tests/settings.py +++ b/django_q/tests/settings.py @@ -119,5 +119,4 @@ Q_CLUSTER = {'name': 'django_q_test', 'cpu_affinity': 1, 'testing': True, 'log_level': 'DEBUG', - 'django_redis': 'default', - 'disque_auth': 'foobared'} + 'django_redis': 'default'} diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index f666cff..3945068 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -31,7 +31,7 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -@pytest.mark.skipif(Conf.DISQUE_AUTH is None, +@pytest.mark.skipif(os.getenv('DISQUE', False), reason="No disque server configured") def test_disque(): Conf.DISQUE = ['127.0.0.1:7711'] From b901039a44febb2d3faf5a536a3c55937477bcd5 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 20:08:20 +0200 Subject: [PATCH 16/48] Experimenting with disque build on travis --- django_q/tests/test_brokers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 3945068..da9f4a0 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -31,7 +31,7 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -@pytest.mark.skipif(os.getenv('DISQUE', False), +@pytest.mark.skipif(os.getenv('DISQUE', True), reason="No disque server configured") def test_disque(): Conf.DISQUE = ['127.0.0.1:7711'] From dfb1b03cd0c00a6bfa4475f9f65ff3dcfd3c4083 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 20:12:00 +0200 Subject: [PATCH 17/48] Experimenting with disque build on travis --- django_q/tests/test_brokers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index da9f4a0..ade1557 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -31,7 +31,7 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -@pytest.mark.skipif(os.getenv('DISQUE', True), +@pytest.mark.skipif(not os.getenv('DISQUE', None), reason="No disque server configured") def test_disque(): Conf.DISQUE = ['127.0.0.1:7711'] From e22980834caf044e6917066480ac46997cd06695 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Wed, 2 Sep 2015 20:17:44 +0200 Subject: [PATCH 18/48] Fixes connectionerror bug on 2.7 --- django_q/brokers/disque.py | 2 +- django_q/tests/test_brokers.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index d26d29b..94fb4f8 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -48,4 +48,4 @@ class Disque(Broker): return redis_client except redis.exceptions.ConnectionError: continue - raise ConnectionError('Could not connect to any Disque nodes') + raise redis.exceptions.ConnectionError('Could not connect to any Disque nodes') diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index ade1557..84fc9be 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -1,6 +1,7 @@ from time import sleep import pytest import os +import redis from django_q.conf import Conf from django_q.brokers import get_broker, Broker @@ -58,7 +59,7 @@ def test_disque(): sleep(1.5) assert broker.queue_size() == 0 Conf.DISQUE = ['127.0.0.1:7712', '127.0.0.1:7713'] - with pytest.raises(ConnectionError): + with pytest.raises(redis.exceptions.ConnectionError): broker.get_connection() broker.delete_queue() assert broker.queue_size() == 0 From b1f2143a3d3871a12310abf4e638a66d976f0014 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 12:55:49 +0200 Subject: [PATCH 19/48] uses disque_nodes convention --- django_q/brokers/__init__.py | 2 +- django_q/brokers/disque.py | 4 ++-- django_q/conf.py | 2 +- django_q/tests/test_brokers.py | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index ce7f9a8..1de452b 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -139,7 +139,7 @@ def get_broker(list_key=Conf.PREFIX): if Conf.IRONMQ: from brokers import iron_mq return iron_mq.IronMQBroker(list_key=list_key) - elif Conf.DISQUE: + elif Conf.DISQUE_NODES: from brokers import disque return disque.Disque(list_key=list_key) elif Conf.SQS: diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 94fb4f8..b274e1f 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -35,9 +35,9 @@ class Disque(Broker): @staticmethod def get_connection(list_key=Conf.PREFIX): # randomize nodes - random.shuffle(Conf.DISQUE) + random.shuffle(Conf.DISQUE_NODES) # find one that works - for node in Conf.DISQUE: + for node in Conf.DISQUE_NODES: host, port = node.split(':') redis_client = redis.Redis(host, int(port)) try: diff --git a/django_q/conf.py b/django_q/conf.py index 0e9c7b7..b01d1d0 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -34,7 +34,7 @@ class Conf(object): DJANGO_REDIS = conf.get('django_redis', None) # Disque broker - DISQUE = conf.get('disque', None) + DISQUE_NODES = conf.get('disque_nodes', None) # Optional Authentication DISQUE_AUTH = conf.get('disque_auth', None) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 84fc9be..b0bbfca 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -35,7 +35,7 @@ def test_redis(): @pytest.mark.skipif(not os.getenv('DISQUE', None), reason="No disque server configured") def test_disque(): - Conf.DISQUE = ['127.0.0.1:7711'] + Conf.DISQUE_NODES = ['127.0.0.1:7711'] broker = get_broker(list_key='disque_test') assert broker.ping() is True broker.delete_queue() @@ -58,13 +58,13 @@ def test_disque(): broker.acknowledge(task[0]) sleep(1.5) assert broker.queue_size() == 0 - Conf.DISQUE = ['127.0.0.1:7712', '127.0.0.1:7713'] + Conf.DISQUE_NODES = ['127.0.0.1:7712', '127.0.0.1:7713'] with pytest.raises(redis.exceptions.ConnectionError): broker.get_connection() broker.delete_queue() assert broker.queue_size() == 0 # back to django-redis - Conf.DISQUE = None + Conf.DISQUE_NODES = None @pytest.mark.skipif(not os.getenv('AWS_ACCESS_KEY_ID'), @@ -120,14 +120,14 @@ def test_ironmq(): assert broker.queue_size() == 1 broker.dequeue() assert broker.queue_size() == 0 - sleep(1.5) + sleep(2) assert broker.queue_size() == 1 task = broker.dequeue() assert broker.queue_size() == 0 broker.acknowledge(task[0]) - sleep(1.5) + sleep(2) assert broker.queue_size() == 0 broker.delete_queue() assert broker.queue_size() == 0 # back to django-redis - Conf.DISQUE = None + Conf.IRONMQ = None From 01f8d9ce4735d2a8da5b90748598ebfee87f0f9f Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 14:29:03 +0200 Subject: [PATCH 20/48] adds info method to brokers --- django_q/brokers/__init__.py | 6 ++++++ django_q/brokers/aws_sqs.py | 3 +++ django_q/brokers/disque.py | 4 ++++ django_q/brokers/iron_mq.py | 3 +++ django_q/brokers/redis_broker.py | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 1de452b..47e55e8 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -62,6 +62,12 @@ class Broker(object): """ pass + def info(self): + """ + Shows the broker type + """ + pass + def set_stat(self, key, value, timeout): """ Saves a cluster statistic to the cache provider diff --git a/django_q/brokers/aws_sqs.py b/django_q/brokers/aws_sqs.py index fd01070..12844ac 100644 --- a/django_q/brokers/aws_sqs.py +++ b/django_q/brokers/aws_sqs.py @@ -46,6 +46,9 @@ class Sqs(Broker): except Exception as e: raise e + def info(self): + return 'AWS SQS' + @staticmethod def get_connection(list_key=Conf.PREFIX): conn = boto.sqs.connect_to_region(Conf.SQS['region'], diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index b274e1f..d763d87 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -32,6 +32,10 @@ class Disque(Broker): if jobs: self.connection.execute_command('DELJOB {}'.format(' '.join(map(str, jobs)))) + def info(self): + info = self.connection.info('server') + return 'Disque {}'.format(info['disque_version']) + @staticmethod def get_connection(list_key=Conf.PREFIX): # randomize nodes diff --git a/django_q/brokers/iron_mq.py b/django_q/brokers/iron_mq.py index 9fbc79d..57cac5a 100644 --- a/django_q/brokers/iron_mq.py +++ b/django_q/brokers/iron_mq.py @@ -17,6 +17,9 @@ class IronMQBroker(Broker): def ping(self): return self.connection.name == self.list_key + def info(self): + return 'IronMQ' + def queue_size(self): return self.connection.size() diff --git a/django_q/brokers/redis_broker.py b/django_q/brokers/redis_broker.py index 6f57567..8ef157c 100644 --- a/django_q/brokers/redis_broker.py +++ b/django_q/brokers/redis_broker.py @@ -34,6 +34,10 @@ class Redis(Broker): logger.error('Can not connect to Redis server.') raise e + def info(self): + info = self.connection.info('server') + return 'Redis {}'.format(info['redis_version']) + def set_stat(self, key, value, timeout): self.connection.set(key, value, timeout) From c626c3137cf9c4c59dfe11b627e617f232c0de51 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 14:40:32 +0200 Subject: [PATCH 21/48] Defaults RETRY to 60 --- django_q/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/conf.py b/django_q/conf.py index b01d1d0..620c9d8 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -84,7 +84,7 @@ class Conf(object): # Number of seconds to wait for acknowledgement before retrying a task # Only works with brokers that guarantee delivery. Defaults to 0. Meaning no retries. - RETRY = conf.get('retry', 0) + RETRY = conf.get('retry', 60) # The Django Admin label for this app LABEL = conf.get('label', 'Django Q') From 653ae80477e575d6c0607cbb47d7f586c2a046fd Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 14:41:03 +0200 Subject: [PATCH 22/48] Disque: If retry is 0 , explicitly set replicate to 1 --- django_q/brokers/disque.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index d763d87..89d7729 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -7,8 +7,9 @@ from django_q.conf import Conf class Disque(Broker): def enqueue(self, task): + retry = Conf.RETRY if Conf.RETRY > 0 else '{} REPLICATE 1'.format(Conf.RETRY) return self.connection.execute_command( - 'ADDJOB {} {} 500 RETRY {}'.format(self.list_key, task, Conf.RETRY)).decode() + 'ADDJOB {} {} 500 RETRY {}'.format(self.list_key, task, retry)).decode() def dequeue(self): task = self.connection.execute_command('GETJOB TIMEOUT 1000 FROM {}'.format(self.list_key)) From c0f7df3f07b94b4a5ad640e13fe6a784e7bf3a24 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 14:59:57 +0200 Subject: [PATCH 23/48] Changes Disque ping to hello --- django_q/brokers/disque.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 89d7729..30c0358 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -23,7 +23,7 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): - return self.connection.ping() + return self.connection.execute_command('HELLO')[0] == 1 def delete(self, task_id): return self.connection.execute_command('DELJOB {}'.format(task_id)) From 1f5952044ede4804adceb9b9e7417e794b6bb816 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 15:08:55 +0200 Subject: [PATCH 24/48] Changes Disque ping to hello --- django_q/brokers/disque.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 30c0358..8ed76ed 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -23,7 +23,7 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): - return self.connection.execute_command('HELLO')[0] == 1 + return self.connection.execute_command('HELLO')[0] > 1 def delete(self, task_id): return self.connection.execute_command('DELJOB {}'.format(task_id)) From a342aaf0098c26c00a52146429782da07e1119fd Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 15:13:25 +0200 Subject: [PATCH 25/48] Changes Disque ping to hello --- django_q/brokers/disque.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 8ed76ed..3b47a47 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -23,7 +23,7 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): - return self.connection.execute_command('HELLO')[0] > 1 + return self.connection.execute_command('HELLO')[0] > 0e def delete(self, task_id): return self.connection.execute_command('DELJOB {}'.format(task_id)) From 453fc3b1331217c0d22cee7c6c6fa76c769783ba Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 15:13:44 +0200 Subject: [PATCH 26/48] Changes Disque ping to hello --- django_q/brokers/disque.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 3b47a47..bbeb7d7 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -23,7 +23,7 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): - return self.connection.execute_command('HELLO')[0] > 0e + return self.connection.execute_command('HELLO')[0] > 0 def delete(self, task_id): return self.connection.execute_command('DELJOB {}'.format(task_id)) From 2e4c1c86ed59877cc029130bc4b826032a04e9a5 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 15:50:22 +0200 Subject: [PATCH 27/48] Adds extra AUTH before PING --- django_q/brokers/disque.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index bbeb7d7..4ba7ee9 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -23,6 +23,8 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): + if Conf.DISQUE_AUTH: + self.connection.execute_command('AUTH {}'.format(Conf.DISQUE_AUTH)) return self.connection.execute_command('HELLO')[0] > 0 def delete(self, task_id): From 7be533d9a65146071b3a277b608e3938d99a8edf Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 16:14:20 +0200 Subject: [PATCH 28/48] moved auth to redis py --- django_q/brokers/disque.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 4ba7ee9..ab61694 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -5,7 +5,6 @@ from django_q.conf import Conf class Disque(Broker): - def enqueue(self, task): retry = Conf.RETRY if Conf.RETRY > 0 else '{} REPLICATE 1'.format(Conf.RETRY) return self.connection.execute_command( @@ -23,8 +22,6 @@ class Disque(Broker): return self.connection.execute_command('ACKJOB {}'.format(task_id)) def ping(self): - if Conf.DISQUE_AUTH: - self.connection.execute_command('AUTH {}'.format(Conf.DISQUE_AUTH)) return self.connection.execute_command('HELLO')[0] > 0 def delete(self, task_id): @@ -46,11 +43,12 @@ class Disque(Broker): # find one that works for node in Conf.DISQUE_NODES: host, port = node.split(':') - redis_client = redis.Redis(host, int(port)) + kwargs = {'host': host, 'port': port} + if Conf.DISQUE_AUTH: + kwargs['password'] = Conf.DISQUE_AUTH + redis_client = redis.Redis(**kwargs) + redis_client.decode_responses = True try: - if Conf.DISQUE_AUTH: - redis_client.execute_command('AUTH {}'.format(Conf.DISQUE_AUTH)) - redis_client.decode_responses = True redis_client.execute_command('HELLO') return redis_client except redis.exceptions.ConnectionError: From 1ccfc66254e336f226ad52980fa5ad91372b0ad4 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Thu, 3 Sep 2015 16:34:00 +0200 Subject: [PATCH 29/48] adds fail method to broker --- django_q/brokers/__init__.py | 7 +++++++ django_q/brokers/aws_sqs.py | 3 +++ django_q/brokers/disque.py | 3 +++ django_q/brokers/iron_mq.py | 3 +++ django_q/cluster.py | 1 + 5 files changed, 17 insertions(+) diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 47e55e8..4dffaf0 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -55,6 +55,13 @@ class Broker(object): """ pass + def fail(self, task_id): + """ + Fails a task message + :param task_id: + :return: + """ + def ping(self): """ Checks whether the broker connection is available diff --git a/django_q/brokers/aws_sqs.py b/django_q/brokers/aws_sqs.py index 12844ac..2b9e254 100644 --- a/django_q/brokers/aws_sqs.py +++ b/django_q/brokers/aws_sqs.py @@ -33,6 +33,9 @@ class Sqs(Broker): m.receipt_handle = task_id return self.queue.delete_message(m) + def fail(self, task_id): + self.delete(task_id) + def delete_queue(self): self.connection.delete_queue(self.queue) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index ab61694..8ae163e 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -27,6 +27,9 @@ class Disque(Broker): def delete(self, task_id): return self.connection.execute_command('DELJOB {}'.format(task_id)) + def fail(self, task_id): + return self.delete(task_id) + def delete_queue(self): jobs = self.connection.execute_command('JSCAN QUEUE {}'.format(self.list_key))[1] if jobs: diff --git a/django_q/brokers/iron_mq.py b/django_q/brokers/iron_mq.py index 57cac5a..d59d2f4 100644 --- a/django_q/brokers/iron_mq.py +++ b/django_q/brokers/iron_mq.py @@ -32,6 +32,9 @@ class IronMQBroker(Broker): def delete(self, task_id): return self.connection.delete(task_id)['msg'] + def fail(self, task_id): + self.delete(task_id) + def acknowledge(self, task_id): return self.delete(task_id) diff --git a/django_q/cluster.py b/django_q/cluster.py index 5faf7d5..d3b5663 100644 --- a/django_q/cluster.py +++ b/django_q/cluster.py @@ -311,6 +311,7 @@ def pusher(task_queue, event, broker=None): task = signing.SignedPackage.loads(task[1]) except (TypeError, signing.BadSignature) as e: logger.error(e) + broker.fail(ack_id) continue task['ack_id'] = ack_id task_queue.put(task) From 3f75e7fa05c55741b2369112068f28f130bc188a Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Fri, 4 Sep 2015 14:49:17 +0200 Subject: [PATCH 30/48] removed SQS and IronMQ brokers for now Both brokers are not atomic and cause all kinds of problems during testing. Needs more time. Meanwhile I want to release the pluggable broker backend. --- README.rst | 2 +- django_q/brokers/__init__.py | 9 +--- django_q/brokers/aws_sqs.py | 63 ----------------------- django_q/brokers/disque.py | 4 +- django_q/brokers/iron_mq.py | 44 ---------------- django_q/brokers/redis_broker.py | 3 ++ django_q/conf.py | 31 ----------- django_q/tests/tasks.py | 9 ---- django_q/tests/test_brokers.py | 88 ++++++++------------------------ django_q/tests/test_cluster.py | 10 ++-- django_q/tests/test_config.py | 10 ---- setup.py | 2 +- 12 files changed, 36 insertions(+), 239 deletions(-) delete mode 100644 django_q/brokers/aws_sqs.py delete mode 100644 django_q/brokers/iron_mq.py delete mode 100644 django_q/tests/test_config.py diff --git a/README.rst b/README.rst index 9f8550b..dde7c86 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Features - Django Admin integration - PaaS compatible with multiple instances - Multi cluster monitor -- Redis broker +- Redis broker and Disque broker - Python 2 and 3 Requirements diff --git a/django_q/brokers/__init__.py b/django_q/brokers/__init__.py index 4dffaf0..c247bb1 100644 --- a/django_q/brokers/__init__.py +++ b/django_q/brokers/__init__.py @@ -149,15 +149,10 @@ def get_broker(list_key=Conf.PREFIX): :type list_key: str :return: """ - if Conf.IRONMQ: - from brokers import iron_mq - return iron_mq.IronMQBroker(list_key=list_key) - elif Conf.DISQUE_NODES: + # disque + if Conf.DISQUE_NODES: from brokers import disque return disque.Disque(list_key=list_key) - elif Conf.SQS: - from brokers import aws_sqs - return aws_sqs.Sqs(list_key=list_key) # default to redis else: from brokers import redis_broker diff --git a/django_q/brokers/aws_sqs.py b/django_q/brokers/aws_sqs.py deleted file mode 100644 index 2b9e254..0000000 --- a/django_q/brokers/aws_sqs.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -from django_q.conf import Conf -from django_q.brokers import Broker -import boto.sqs -from boto.sqs.message import Message - - -class Sqs(Broker): - def __init__(self, list_key=Conf.PREFIX): - super(Sqs, self).__init__(list_key) - self.queue = self.get_queue() - - def enqueue(self, task): - m = Message() - m.set_body(task) - self.queue.write(m) - return m.id - - def dequeue(self): - rs = self.queue.get_messages(visibility_timeout=Conf.RETRY or 30) - if rs: - m = rs[0] - return m.receipt_handle, m.get_body() - - def acknowledge(self, task_id): - return self.delete(task_id) - - def queue_size(self): - return self.queue.count() - - def delete(self, task_id): - m = Message() - m.receipt_handle = task_id - return self.queue.delete_message(m) - - def fail(self, task_id): - self.delete(task_id) - - def delete_queue(self): - self.connection.delete_queue(self.queue) - - def purge_queue(self): - self.queue.purge() - - def ping(self): - try: - self.connection.get_all_queues() - return True - except Exception as e: - raise e - - def info(self): - return 'AWS SQS' - - @staticmethod - def get_connection(list_key=Conf.PREFIX): - conn = boto.sqs.connect_to_region(Conf.SQS['region'], - aws_access_key_id=Conf.SQS['aws_access_key_id'], - aws_secret_access_key=Conf.SQS['aws_secret_access_key']) - return conn - - def get_queue(self): - return self.connection.create_queue(self.list_key) diff --git a/django_q/brokers/disque.py b/django_q/brokers/disque.py index 8ae163e..7e43ccd 100644 --- a/django_q/brokers/disque.py +++ b/django_q/brokers/disque.py @@ -33,7 +33,9 @@ class Disque(Broker): def delete_queue(self): jobs = self.connection.execute_command('JSCAN QUEUE {}'.format(self.list_key))[1] if jobs: - self.connection.execute_command('DELJOB {}'.format(' '.join(map(str, jobs)))) + job_ids = ' '.join(jid.decode() for jid in jobs) + self.connection.execute_command('DELJOB {}'.format(job_ids)) + return len(jobs) def info(self): info = self.connection.info('server') diff --git a/django_q/brokers/iron_mq.py b/django_q/brokers/iron_mq.py deleted file mode 100644 index d59d2f4..0000000 --- a/django_q/brokers/iron_mq.py +++ /dev/null @@ -1,44 +0,0 @@ -from django_q.conf import Conf -from django_q.brokers import Broker -from iron_mq import IronMQ - - -class IronMQBroker(Broker): - - def enqueue(self, task): - return self.connection.post(task)['ids'][0] - - def dequeue(self): - timeout = Conf.RETRY or None - task = self.connection.get(timeout=timeout, wait=1)['messages'] - if task: - return task[0]['id'], task[0]['body'] - - def ping(self): - return self.connection.name == self.list_key - - def info(self): - return 'IronMQ' - - def queue_size(self): - return self.connection.size() - - def delete_queue(self): - return self.connection.delete_queue()['msg'] - - def purge_queue(self): - return self.connection.clear()['msg'] - - def delete(self, task_id): - return self.connection.delete(task_id)['msg'] - - def fail(self, task_id): - self.delete(task_id) - - def acknowledge(self, task_id): - return self.delete(task_id) - - @staticmethod - def get_connection(list_key=Conf.PREFIX): - ironmq = IronMQ(name=None, **Conf.IRONMQ) - return ironmq.queue(queue_name=list_key) diff --git a/django_q/brokers/redis_broker.py b/django_q/brokers/redis_broker.py index 8ef157c..68289aa 100644 --- a/django_q/brokers/redis_broker.py +++ b/django_q/brokers/redis_broker.py @@ -27,6 +27,9 @@ class Redis(Broker): def delete_queue(self): return self.connection.delete(self.list_key) + def purge_queue(self): + return self.connection.ltrim(self.list_key, 1, 0) + def ping(self): try: return self.connection.ping() diff --git a/django_q/conf.py b/django_q/conf.py index 620c9d8..024d9fc 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -8,7 +8,6 @@ from django.conf import settings # external import os -import redis # optional try: @@ -38,14 +37,6 @@ class Conf(object): # Optional Authentication DISQUE_AUTH = conf.get('disque_auth', None) - # Amazon SQS broker - SQS = conf.get('sqs', None) - - # IronMQ broker - IRONMQ = conf.get('ironmq', None) - if IRONMQ and os.environ.get('IRONMQ_TOKEN'): - IRONMQ['token'] = os.environ['IRONMQ_TOKEN'] - # Name of the cluster or site. For when you run multiple sites on one redis server PREFIX = conf.get('name', 'default') @@ -142,28 +133,6 @@ if not logger.handlers: logger.addHandler(handler) -# Django-redis support -if Conf.DJANGO_REDIS: - try: - import django_redis - except ImportError: - django_redis = None - - -def get_redis_client(): - """ - Returns a connection from redis-py or django-redis - :return: a redis client - """ - if Conf.DJANGO_REDIS and django_redis: - return django_redis.get_redis_connection(Conf.DJANGO_REDIS) - return redis.StrictRedis(**Conf.REDIS) - - -# redis client -redis_client = get_redis_client() - - # get parent pid compatibility def get_ppid(): if hasattr(os, 'getppid'): diff --git a/django_q/tests/tasks.py b/django_q/tests/tasks.py index 9c8f8ad..70eb782 100644 --- a/django_q/tests/tasks.py +++ b/django_q/tests/tasks.py @@ -1,7 +1,3 @@ -# simple countdown, returns nothing -from time import sleep - - def countdown(n): while n > 0: n -= 1 @@ -26,11 +22,6 @@ def word_multiply(x, word=''): return len(word) * x -def count_forever(): - while True: - sleep(0.5) - - def get_task_name(task): return task.name diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index b0bbfca..feef253 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -11,9 +11,12 @@ def test_broker(): broker.enqueue('test') broker.dequeue() broker.queue_size() + broker.purge_queue() + broker.delete('id') broker.delete_queue() broker.acknowledge('test') broker.ping() + broker.info() assert broker.get_stat('test_1') is None broker.set_stat('test_1', 'test', 3) assert broker.get_stat('test_1') == 'test' @@ -24,6 +27,7 @@ def test_redis(): Conf.DJANGO_REDIS = None broker = get_broker() assert broker.ping() is True + assert broker.info() is not None Conf.REDIS = {'host': '127.0.0.1', 'port': 7712} broker = get_broker() with pytest.raises(Exception): @@ -32,15 +36,18 @@ def test_redis(): Conf.DJANGO_REDIS = 'default' -@pytest.mark.skipif(not os.getenv('DISQUE', None), - reason="No disque server configured") def test_disque(): Conf.DISQUE_NODES = ['127.0.0.1:7711'] + Conf.DISQUE_AUTH = 'foobared' broker = get_broker(list_key='disque_test') assert broker.ping() is True + assert broker.info() is not None + # clear before we start broker.delete_queue() + # enqueue broker.enqueue('test') assert broker.queue_size() == 1 + # dequeue task = broker.dequeue() assert task[1] == 'test' broker.acknowledge(task[0]) @@ -58,76 +65,21 @@ def test_disque(): broker.acknowledge(task[0]) sleep(1.5) assert broker.queue_size() == 0 + # connection test Conf.DISQUE_NODES = ['127.0.0.1:7712', '127.0.0.1:7713'] with pytest.raises(redis.exceptions.ConnectionError): broker.get_connection() + # delete job + task_id = broker.enqueue('test') + broker.delete(task_id) + assert broker.queue_size() == 0 + # fail + task_id=broker.enqueue('test') + broker.fail(task_id) + # delete queue + broker.enqueue('test') + broker.enqueue('test') broker.delete_queue() assert broker.queue_size() == 0 # back to django-redis Conf.DISQUE_NODES = None - - -@pytest.mark.skipif(not os.getenv('AWS_ACCESS_KEY_ID'), - reason="requires AWS SQS credentials") -def test_sqs(): - Conf.SQS = {'aws_access_key_id': os.getenv('AWS_ACCESS_KEY_ID'), - 'aws_secret_access_key': os.getenv('AWS_SECRET_ACCESS_KEY'), - 'region': os.getenv('SQS_REGION', 'eu-west-1')} - broker = get_broker(list_key='sqs_test') - assert broker.ping() is True - if broker.queue_size() > 0: - broker.purge_queue() - broker.enqueue('test') - task = broker.dequeue() - assert task[1] == 'test' - broker.acknowledge(task[0]) - assert broker.queue_size() == 0 - # Retry test - Conf.RETRY = 1 - broker.enqueue('test') - broker.dequeue() - assert broker.queue_size() == 0 - sleep(2) - # task should re-queue - assert broker.queue_size() == 1 - task = broker.dequeue() - assert task[1] == 'test' - assert broker.acknowledge(task[0]) is True - assert broker.queue_size() == 0 - broker.delete_queue() - # back to defaults - Conf.SQS = None - - -@pytest.mark.skipif(not os.getenv('IRONMQ_TOKEN'), - reason="requires IronMQ credentials") -def test_ironmq(): - Conf.IRONMQ = {'host': 'mq-aws-eu-west-1.iron.io', - 'token': os.getenv('IRONMQ_TOKEN'), - 'project_id': os.getenv('IRONMQ_PROJECT')} - broker = get_broker(list_key='djangoQ_test') - assert broker.ping() is True - broker.delete_queue() - broker.enqueue('test') - assert broker.queue_size() == 1 - task = broker.dequeue() - assert task[1] == 'test' - broker.acknowledge(task[0]) - assert broker.queue_size() == 0 - # Retry test - Conf.RETRY = 1 - broker.enqueue('test') - assert broker.queue_size() == 1 - broker.dequeue() - assert broker.queue_size() == 0 - sleep(2) - assert broker.queue_size() == 1 - task = broker.dequeue() - assert broker.queue_size() == 0 - broker.acknowledge(task[0]) - sleep(2) - assert broker.queue_size() == 0 - broker.delete_queue() - assert broker.queue_size() == 0 - # back to django-redis - Conf.IRONMQ = None diff --git a/django_q/tests/test_cluster.py b/django_q/tests/test_cluster.py index f35c3c3..16d131e 100644 --- a/django_q/tests/test_cluster.py +++ b/django_q/tests/test_cluster.py @@ -226,7 +226,8 @@ def test_async(broker, admin_user): def test_timeout(broker): # set up the Sentinel broker.list_key = 'timeout_test:q' - async('django_q.tests.tasks.count_forever',broker=broker) + broker.purge_queue() + async('django_q.tests.tasks.count_forever', broker=broker) start_event = Event() stop_event = Event() # Set a timer to stop the Sentinel @@ -242,12 +243,13 @@ def test_timeout(broker): def test_timeout(broker): # set up the Sentinel broker.list_key = 'timeout_test:q' + broker.purge_queue() async('django_q.tests.tasks.count_forever', broker=broker) start_event = Event() stop_event = Event() # Set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event,broker=broker, timeout=1) + s = Sentinel(stop_event, start_event, broker=broker, timeout=1) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 @@ -284,7 +286,7 @@ def test_recycle(broker): Conf.WORKERS = 1 # set a timer to stop the Sentinel threading.Timer(3, stop_event.set).start() - s = Sentinel(stop_event, start_event,broker=broker) + s = Sentinel(stop_event, start_event, broker=broker) assert start_event.is_set() assert s.status() == Conf.STOPPED assert s.reincarnations == 1 @@ -310,7 +312,7 @@ def test_recycle(broker): @pytest.mark.django_db def test_bad_secret(broker, monkeypatch): - broker.list_key='test_bad_secret:q' + broker.list_key = 'test_bad_secret:q' async('math.copysign', 1, -1, broker=broker) stop_event = Event() stop_event.set() diff --git a/django_q/tests/test_config.py b/django_q/tests/test_config.py deleted file mode 100644 index 10ade3d..0000000 --- a/django_q/tests/test_config.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from django_q import conf - - -def test_django_redis(): - conf.Conf.DJANGO_REDIS = None - assert conf.redis_client.ping() is True - conf.Conf.DJANGO_REDIS = 'default' - assert conf.redis_client.ping() is True diff --git a/setup.py b/setup.py index 35b0afc..1631e60 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( version='0.6.0', author='Ilan Steemers', author_email='koed00@gmail.com', - keywords='django task queue worker redis multiprocessing', + keywords='django task queue worker redis disque multiprocessing', packages=['django_q'], include_package_data=True, url='https://django-q.readthedocs.org', From e76e65640b39e83ad6ff9c08ac9ef84b7b8a459b Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Fri, 4 Sep 2015 14:53:42 +0200 Subject: [PATCH 31/48] removes AUTH for Travis test --- django_q/tests/test_brokers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index feef253..de3b9f3 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -38,7 +38,6 @@ def test_redis(): def test_disque(): Conf.DISQUE_NODES = ['127.0.0.1:7711'] - Conf.DISQUE_AUTH = 'foobared' broker = get_broker(list_key='disque_test') assert broker.ping() is True assert broker.info() is not None From 3524ad2deeadf602fc0a05e1c5c4cac003b3d6a1 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Fri, 4 Sep 2015 15:16:30 +0200 Subject: [PATCH 32/48] Adds Tynd envs for Travis and fixes timeout tests --- django_q/tests/tasks.py | 8 ++++++++ django_q/tests/test_brokers.py | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/django_q/tests/tasks.py b/django_q/tests/tasks.py index 70eb782..62e5674 100644 --- a/django_q/tests/tasks.py +++ b/django_q/tests/tasks.py @@ -1,3 +1,6 @@ +from time import sleep + + def countdown(n): while n > 0: n -= 1 @@ -22,6 +25,11 @@ def word_multiply(x, word=''): return len(word) * x +def count_forever(): + while True: + sleep(0.5) + + def get_task_name(task): return task.name diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index de3b9f3..6a62b08 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -37,7 +37,11 @@ def test_redis(): def test_disque(): - Conf.DISQUE_NODES = ['127.0.0.1:7711'] + # Either local disque or Heroku Tynd + Conf.DISQUE_NODES = os.getenv('TYND_DISQUE_NODES', ['127.0.0.1:7711']) + if os.getenv('TYND_DISQUE_AUTH', False): + Conf.DISQUE_AUTH = os.environ['TYND_DISQUE_AUTH'] + # check broker broker = get_broker(list_key='disque_test') assert broker.ping() is True assert broker.info() is not None From 8340a2ae577abb65acee75716c6e3859c5e4f09a Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Fri, 4 Sep 2015 15:27:10 +0200 Subject: [PATCH 33/48] Adds Tynd envs for Travis --- django_q/tests/test_brokers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 6a62b08..1108426 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -38,7 +38,7 @@ def test_redis(): def test_disque(): # Either local disque or Heroku Tynd - Conf.DISQUE_NODES = os.getenv('TYND_DISQUE_NODES', ['127.0.0.1:7711']) + Conf.DISQUE_NODES = [os.getenv('TYND_DISQUE_NODES', '127.0.0.1:7711')] if os.getenv('TYND_DISQUE_AUTH', False): Conf.DISQUE_AUTH = os.environ['TYND_DISQUE_AUTH'] # check broker From 3be488f918d2ddbe423fd379917e2d32cff9a2a1 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Fri, 4 Sep 2015 15:48:43 +0200 Subject: [PATCH 34/48] Adds Tynd envs for Travis attempt #3 --- django_q/tests/test_brokers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 1108426..83af14a 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -38,7 +38,7 @@ def test_redis(): def test_disque(): # Either local disque or Heroku Tynd - Conf.DISQUE_NODES = [os.getenv('TYND_DISQUE_NODES', '127.0.0.1:7711')] + Conf.DISQUE_NODES = os.getenv('TYND_DISQUE_NODES', '127.0.0.1:7711').split(',') if os.getenv('TYND_DISQUE_AUTH', False): Conf.DISQUE_AUTH = os.environ['TYND_DISQUE_AUTH'] # check broker From c7039168cfab292d2f0ea53bfceb719ab5dfc098 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 12:07:43 +0200 Subject: [PATCH 35/48] Updated psutil --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2ac9cf2..344dc7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ hiredis==0.2.0 iron-core==1.1.9 # via iron-mq iron-mq==0.7 msgpack-python==0.4.6 # via django-redis -psutil==3.2.0 +psutil==3.2.1 python-dateutil==2.4.2 # via arrow, iron-core redis==2.10.3 requests==2.7.0 # via iron-core From d90db10f5eeddcea3200fefc03e64c8bb5d00937 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 12:28:12 +0200 Subject: [PATCH 36/48] Just use local disque for testing --- django_q/tests/test_brokers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/django_q/tests/test_brokers.py b/django_q/tests/test_brokers.py index 83af14a..836e59f 100644 --- a/django_q/tests/test_brokers.py +++ b/django_q/tests/test_brokers.py @@ -37,10 +37,7 @@ def test_redis(): def test_disque(): - # Either local disque or Heroku Tynd - Conf.DISQUE_NODES = os.getenv('TYND_DISQUE_NODES', '127.0.0.1:7711').split(',') - if os.getenv('TYND_DISQUE_AUTH', False): - Conf.DISQUE_AUTH = os.environ['TYND_DISQUE_AUTH'] + Conf.DISQUE_NODES = ['127.0.0.1:7711'] # check broker broker = get_broker(list_key='disque_test') assert broker.ping() is True From 44473ca408fb65fb73e4bc3a81eaa2b788097a6d Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 12:29:21 +0200 Subject: [PATCH 37/48] Adds broker and receipts to architecture flowchart --- docs/_static/cluster.png | Bin 45910 -> 63139 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/_static/cluster.png b/docs/_static/cluster.png index 69fb38f80ee3daf6ca62ea6bf875375a36ae6830..d7b9b87ce77c60663980c124ae51971a58c410e5 100644 GIT binary patch literal 63139 zcmeFZWmweR+cr83-O?%DjdTiwlt_tyL#KdrcM3=djwm6LQYu3Y0tyI-#LyujAl+R` z$6nm`|9PJG(|$kfy^sCnh}5YhCNA^IQ|Jr=w0xKu-XHK!`Op?mdJ+peXQ9 z9}fro=Ih5tNC<=lqIpmGQGmr(HntB{Cr!s)4rz`r&b&J$?bG?#Cii(sR1>LrjF8e7 zycT%@RZ!Ig8h`MGj}@nMaM%AZg^QKd-SX&LUBm8_w--xaZ+39F|Ch@G$UYR2&aOd|097C z5FiT^{9MoGaXs2F4Dnj)=9Mj=i-g>pdM&A+rIkrn$O>dj=Tc(b9A0d5+$j;uEC)R7tQ|QrWZNN4)hDR-V4Pse_dW3* z>6gvj#q318^-(mDc%|SNIh(D#H^0nfV3uzku0lHfQ zv^&FE5?C_|heW@o4(n)`XHK~cf9krs-sQBDt8`4z>^7^wjM8@rA#-WwN7yTQ+gO6#s-YM7OfATTCK6=xtgvahS(eJJwMgCf}$FOaQ z#Ra(uQWAGusC#ZB(MetVt#nG%{5(>>M5wqw=ee}!LiCwvjEkcQf0|H7#Eimz_fN4_ z`1;O#WS7{r((+S@_ln-c9CmQwf*iMRCL&tjM))n&&s14AH75I+6JcYgGL`RepYZmM zA<<**ClYGOxwO4^SXdzLGmYQ-Hpf|7T_$n~Q*%czQ5eMo<`IrVSNy)t*B0^oG{Wyg zmic(hK4(lHSY!RDF4q1oeD~PMtS^;YSk`Yl`JsI`t7i&FK)|))(e6ad`s?r?qdn;? zr8Df@7qPphwaF%qlqmhh&Y8?xH&S86eoxXv)5}NlO_v}H@q0D;`W|MKk)bT5oD$-H zI;dsjV0%9)a6e{F&AqO2!r`-E83rtJjpbzPlQ z{na4OrcniK>sM7(;BlB;&#Rw)S(K>SYFphtyo@4?r#Njbu{?IfS@H~KC5Re~O#S+d zA`xd+J5C3|cOglAo7c)$Q8AUCs}==?L)KxE9}J0ck9L$l-m$L=*~YPNmw4YfFhFY9 z^QNtca8)1WLx(hLp76WNYv=0FnUhI&vnHarNMe07y>y`D7adk-Oz{FAQ7!AS`{)D> ziYl&wc&%~79mA)~ySbC>!8^T9y>DeOpC4_v|9DQiH-8z-p`F9Xc|Wy-G`pU~Ju)i4 zSY@YoeZTvVb4Na$cmgF7JV|LL9j-Icji^vk{=E6e_KR@#>G}C$1!uU;eAdL5pk>r> zQ$W|lwNZDLqpjbO)|lt{4zpKlr{33kiwePKSYlkdt!YV{o~vWpZ9d*AwOcZSd7c#F zu0sTFZf-`}#&p##ub}s*M&DhjR;3iNZF37pNn97_54#<|qCjT~1{a35x+<|cGe{i` zzlnfv6cy_cJ$(Ti%ZslbawUK>e5cEQ2PddDf*oC+AEPs6=|Vz7Q$taIn+Pba$?)M5 z##7baxN+z|dm1mq*N1uCcW0Y}jZaS$jF68X*Pk)JIeG81=YvO%G7q|lj<;3kEzRV8 zSlm`!qRU#Eaqu8R7BlIcB;gKwZT)BGq*4Af{%b*l6#%woK|T;PL}sOD#3=nkat?&B8xkUasO9~5In73i(q*~}>w*UE9rZE%VU{Pl>$CnTPBriz&(&Zf zZ-0(rOoHJj>!E~Z75oXBfn46%#7noke^p!W#qV8A82GqN(F5XwfwD^8dEO>Ozcy?5dg0XD@#i1YcFRCmIl5{80QQ#cbbs$VX8Ut|gA#Vt?nn0< z47igE%nx@xR>uVGg)6u4leg9PrwhHe>3ZKy;Gw}P=e=P^v@@;_|27@A zx2CS15RX^>us(X&{jfZg*)sI)9J%BZ?@3>g!sb%m-mUGoD+ z-_urD8~^6$6SU`bfTN!&%`jbzs3FTDtA7iQKCc~rDqk$&YClZz?D{mFm5cy>8tKA| zpqjwKrwDtq6WZXPub)k?@s^KD$E^P972+~_}qe$2mU4WU zCAk@(C-M66rh6Y|)KH>Sy&MdVz8o>%Va%@ods2uX?ylNW{T8whb({H;%rIWg3wt~> zMw9)fQt8QNWAMh@Cmv$MqBQf&GI|!aZ>yDS-jV+}*-oU*TW$c4}b|Qd3EO>C5 z@VIt`xwND%E|Bc6SK8KE{OhtEnoq1TNY~%Lv<#pi+GWB-KZp{*pKV zfye|O{`DiDLx*^G=c#x4Ma)wP=g^1fZbBn=9MXr;&S>>otR?hYLQ^T%7kX&732#W? z!SYKzboaic7tya>11@Wg>!uG6zu~M1o6_xkPD$_RAY!J!kG{{CBW!8&cg2;*=)K*S z%i;_7tcCCzlNwQf&$&c9G6iK*Dspbk1aGOG#}eOE(X0JgI{UUK8T*cZRd7qsLPz3Y zW0G!LMZ6f*BU;+|v?H`JNM#C#f+iyTb2gvLOPy|9$rbSD4thG_|+ zWxyMXxz`2(Z^$o$`3HEzTDAX9p|Vm*ubAilZb+1yHN~a&q~Taf-kRy$n#Z5mWe6=Y zgXvXzklELWt}Q`j+O2vkR9J)WFUM0z36DjXNJP^4R0ylQRho0UYdL8`@Vx>J|mAmuMc)TJ)f5>L?qGQ zJsOL?3FcfPL~hZru{r!l>xwbR`&MdC-O^fJk`Ogq!YJ*z@SkDVj(_jSvj+d!V--gl zYQynUsnCts1UypSUHCX&A!%`aw$DkG-3aBv51BxsTMI{b!z;WZzjydLQ!#i+BJu3t ztVs;QOvA-m6&K%*Bb2+b36)=(g|JMKEM=N}#SjQC{~d6AROZE=A&(-E^Ix~rNa6S> z5fC*Mr?)*)&uP&VLS}t;!jC1aqz8fVL+vGLn5WLDl5!GoB>$UTQU%V$oDKT3P`d>A zN%HJSZn>yzFDI`2Ln?c1M>1{k?>B$fzGHGc$iSQMUWOHPXzywNe1cFY@+uuIoOLL$ zUgVM_gs=0w_qbxNuJjp#nl*UAPtg`f2S>N1X&2Qf6x>#?HKqUm0T z8wiPg@n4l(=tz2woT%nkl)P#R+;g>iTC&+gBU~BqUEXEli-pY7-p#+x9QT)!8NYfL zJWVn=Icq3>EbSoK@$$lu?1XLWdghDawn*gm|28gPy+Mq!k-9crQ!|W}f3F-`9IOvj z7ku#aDf=1>ONP*>R&8ya?A7_y7g3EjJY;H@?kTS2`sm4yq;KLk3C>4e%4hTi2nojp z7oT^qF*OghwI^Wr-B}rYFvq@V8lxZib|vy6yNEdR-LkmG1MCi&L}xgLr0ms+gO;T?>u z4{zM}E1V4|F(`>yx>#X_2no+7Pzi0b+G%I`Eyk#^IyYv$9kc~{q$pPN)u2RudPP@{ z8H3FKdF-?b9^|#tme=GICxE}^{zW@~Bkr@>&~Jr^8evOy({$qS1NuMT@NkL|zCLP< z(m8Mfu^1#Kcam7_dxS5JZ!%phIQ*OxgkWEssY8gZSR)J}K{4#0-s(%;)f)mkWdL?8 z#Y=T;sY^m900`T`>ULL`*KWIW44%e}iO~8r3q{C@s1TTFM26-Kz8F8^jm0;YwK?@hX#@~{i>u3HLfM58NA%nfme-edU}Be{=`eT{HIj3cPAWZZVQdS1>{-p4ug^tYW z57MQUAE+jR8+7Kn55mdzt}yRmIh32?OuT#aBT=i)!>g5?qxTK{%z)+h)pqBl?OzaH z0{c!}X3MOsFGK_V7AnZ-eFTpIJr>qUfk*C>0KX&I{{F`?$1{ov8hsNe$%m=vLw&y} zBfECLdy9?rp3K}_001{>19~HA+9n92_}Pi*L?MbGw#Y-Aay1xmfSjbJMx>e}6mk5qe{s z85Tu#)0Kb=6N?-ZFTWRr9T`KWF$AzHNn5ZX!WfN^ys-5iy)Q)bE}D>P2-fX;y!@yX zhOdvVq7`D0t2GV3_J25zOv&OKr@0u`QZMZuNcde#W2oZRKB{GI3_Kw+eMh3U9p16{ zw+#2!s1qaK@FrMOmnv?Xwffij^LTT zBUP@loaA}G_jmhw!ljo%5UX-MWj;COL$sQBf0aJr+$)6wHmh01kk~`-YvotD@7R|T zwo2a}dj=g?^2ngrZQ8tklxgF79_D`Q&S0}sZ86TACKa{mY~2@4yd~$vw&ZAX`yD;$ zy`ji5N^7Bo^FK?=Jcp*FF7B87_b<+#vGuF|+TD<*=2Z8+*ZPOIK>T|zn_U4`WVWV~ z9Mq2rg-l1LPih;E*A}XQPQo`FD@cTDd6M?{P%ai?7Nqo8E>aMF0j8<1BC|hsm^%yl z&+zk&?Ch8Khw{Dm>d$E_+!flUx7GCzcZU)@iHG&nnJ3kDWb_yHh;DYRs%vV*k*o*C#*VD@SAeqi%3LJ<@58mAE1wi zxk6KIq$Qs!eiEW5@8%}^92~3-YyEQ_?{>GsA3a^JIBdwnSQM|y%D@@2^#E#(UuhBh zu?Lxcq5fVS`ZOAeZ0t7Oclbk#7y1wn-M+txAy3F|^=$7~jmYfd2Hd(jCfL`mZ&5KW zJ7d07`kBRM${c-%bib<$+tYS&3syeqCmD6UV8bgvg)rA-Ks|W*pPUQJ|*GiFp zaJFBiZWBL$ei1o9ZxnIvu)cRyCMDEs`m);_ho{xhf`~Baqu|>4i&3qGwYb0!^&TFT zx;f*!ywHY9IlHSevTw<)DzP^?smNHtU= zdo`L)r1F_|$kSm;pYhUH=qZvm-e1=OEJs-A7i56pSl>m7KV&cXZMUFFZw9qgFJ}c`JGK1dt?}6JtDy0rd;3uqtQao%CBV;j)5-g zgsi>hgJx2%(0bbNKI=(*(7kVJY5dUQO$ztgiQoh~{}lF85g*+5MLJv6f!Cw_+)TD0 zWCp0|$x=(W1UsvL8qcS(2-$G!M1SEW=ZBrHua`=npUt~V zMyq{h<1Z~dV9RJUi(MN^rTOAj^QmCVe0tixk-Ue-R(5f3PzC~}6wTrAr~Yl$Oe$gg zqV4Z!!w+*%vQ4h8y3h6gNlnNdn*NQb&{E;(dOLA2YYdd-j&*{|a_&DG_4Q--lZ=l4 zEZ52sG^@M7F54clS|uTF6UFq~|x8L}TJ3)v`?ZAYaVnh9qRzlE&x zOPl_dMhk1qY1>JAK)@Pf0;y90Qfcy_vHny^hI;dd9P_gLq#ZVj)v_eEdqX&P55H2x z`w1eV7>p><{!!Q#nSR!-{-`nj0lIsD6nVP!obrkJy^ScHN|6SuVLFro zIJA?m%U`=zTgb{_(33E~MQG#AF+o<%2MVn##j&ZMQ0PLzf86Q$GS48JMPeW193PBn z;YRwkh`hp|_ybvLiwYvyi&6QN3n8q&q?2>SqJC16zuL0G(Guj;-^iz5Ws%AK6+HR^ z;)j8-%|4;Md2rvG)*4HomrF-T#Ty4>Ov^#fe=q}?P6FMIum&A=2wYvIQe=~d&ZMEW z6)B-V>Fo(crgP;$km)O$LNUldFDGpdf!;B_JB|$0Po8llyKSZd;Vi@oA}x{W^hzV~ z_yq85j#g#2N)b7{&tkFG)_x#=aUUxP#^XXzn2lRJIv zUTdohG3T>dYimizg|%7>pwkTrG5VACppO{$vNQ4!qnd1tOE`qp&y?hBX{nz$=&fdO zaRL1-ETL|+Ly21HDw23rDPpS9mFN061GK*C%Z!1nL47Z9!m2EuLiD-0cRaSxD$!{R3o%q(p?IcoQh`d9h)zljR{K9@egvngHbOX?&FeJ|d!&9DsMDy$teh zpj*d{*a-&^rvNwJ4aoXf9dwywbzF+Xz~i!7c+*2~04i53?eqfRk8J?h)6Oyzdk(6W zm90CH1n~ZUpw-G`@QiQPulo+*0pGM+^#!~D6V&z`rUjG516UCl+!K-{*jVp`y~}@4 zQ4Xuy;F(S7s?>fwfIAAX`luU#DKi%eMFA*E0pLXmPp>{k`WWQf>YiUbcH4Jx$)Rg0 zinbkh+-DoRmR2|4bzG^aDZ-+#@Ea|Hg3juL0~kX+c4yQe-fX+=)bqEj!pt2^0AQ+9 zu1&=ogZwz$Wz~8s4^gxDMk7T4KH%>@L*g`19{M8Fce6t?MV!v2oqVZ3{Jr|iSmT_W zfU_l0$#TnUuc0h>6=*AMKz{9n?7o=CIJ|)g)&NWpE8^7c3D$eNGm~BB2D;47Nar!E z51)Up8vK{}tn_(y?BK{&BF(_i=lS#j+YYbNFuUt3_Dp)Ie9@KhXw4*f4uT0ZT~*9~ za91DA8wTdoS|ys%cr5l%nb zoWSOY4969KosrFy?)1Ka!IPZ0wR1vt_J$TsN1O-sYMZPATXSe#~#Z;r!V#jVHOdMn^!&gN#6s4-6){%m00qj1<-a`BB6?eRn(hYMvq zE?N!Ev#6Y%9glZsh(DMYwLW`(yjxT=8moCO+RUYmOtscWo8Tw({ZR!_x#ek|)Fe0& zqiGHu_jY!;g=fO7;@Ioh2rhTFr!Z2rXpTdc*WW%im5;6KxS&^z(I<$2N!~yCH30`x zW~=5b%0>a@*S_KP`Mms5%DJ~<=FY<7a9|ic?+TpmfeF|V;ssh@ZzqdmVCtqhLDd}> z8-nb~e?b$as!=9W?M8B_rda(AAnt0y^M8isSpMPMRVdNM6k)LccH8;!?tWnH z7C=eEAAK@0-)C{oT>2Hd#4%i`U0SAYjl8W`$$S~U5m^>YswUkJI?b4T=|VfF0CcZ1 z&lb(`Tp16L`8jO+50fJ82RLi8L=w1OP;g*e^VNBLnw<5Rb3?CW!mjLjY&w&ZvZrd_ zn;#bcLajnjA!$_O)s0Fv zkP&3lFKZR-ANLJ4`cBP})w%qRF#CGQVjfEbs=boSqq`R}?j#G`58_fR-*j|@k1g!I zP0UbqV$)%gBVcBBZwxsl$H2hIP;`hyB?PaGXaZ;rx5Bbn?X%<1U{SE}NcnZqow1SY zq|OEGLj-#I_DxG`;%CVhHfPhBx0X|1-WJisAmMk`Z9IX`eB5d-UX=V zY$gr%-)+58XsGh}E4$R2&HWhNpD9V_=2kW6zNgnNMwZ>jAVoxtN*BuNY+Dyg&Fv!C zE@K!U0`PcRkxxA@|cutP+P&nEnOC}H+78XW($LqyG^$%Rqyzq*h|2sJhbNBv0 zcJ2)dn&ls^&0}5+*GX{=WMWE5)yLip)bX$3=p!8>Z{psV|pu5@CaDTTCRA$R~}7X>Z-WrrJT#BO;u zuifaXdC-@KVXB`MP{gmDf`MWrYgR}AQ2+bX_wO2y-RM9BE~Ff~Ml|;*V`rY=woFl8 zNEPhE;~M&~wLk0+i!?DOV1r*>`Nf?_akNcqNvKh%)=2=}BWJ4^;wiYl1I|g6?@t^? z*-&M8B(W}V>xWXJ&y1 z3nTH1zXI1DN5hPa9ADm+d69p4-rCbccy)DkQnjjkf*O*#@8cboTOPuxk#P%~kmmc) zkLN%;s|qky{<|W|Vb8FTS-9ztmirK5*B^G1Hv(#xeZ>TAa{n-M1z7;kK2cJNbF*N@ z+V1&JSeUbZ2cP>BzbNfvb$8P54YsA#%1D=V*pkSTUQ@?X&C$z%WS3|(+cY}^oy+YN zDP%O8=uohTP-E!E-CEA~>w{pr4{$ZiooZ=aQU{n3=g13& zW)ftF8(k;Sz2I@NThyUNKPDO2@3Gfib?o~EAz@Ru{E5HKSlg|)#? zbj7_{&lZZB^stlpQDh4?VTOGil7I<0hzZuEU>B)~h!Dkd1_{j#X^La+DV8_|3aVpdQRh1S0`L_U4%!nRf3uP4 zpZW3(B-xI7)qJ4YpFF(WU7(g7ggy(Dv{$VKlngvPyq3CIsm?)tTh|3$0Hv_@Tuh2f zOvHmC1P5%Uq>?QSxraV96R>QAa|%o*9)2+2)uQCrCy?=8)i<&mFl&ebfcN*QDTZ^u z-L|IbhDw`t@(cG#BEa>70@6lIO!#Gzk#^JftoEKauDDMD9CS_yUoqZB+69C1!xUu3 zllPM^HCp}xHQ62SP9zC!o6PS`XvLAS$D-(`s(cBv>b)?Xjmz3V4BcyXYUYogl5~!O zsV49WCtw4Xz|+%*FBn7_O^=!GU0~rqvX6p|UBS{$4J9}yGO#pQ2!M_XATP}|TiS1C zuqR!9xoxtz5Ob)J#eol!ljpBHlWkf@=(;ytj&<(Nbr)&H0XX@NefNufgW|=>QU3ho z*{9Q=MQ=C`p0A5P-3O4-KYQ@>K1viEF-nlCs9`WQq(mf=Rl?C5@f4sw0P~MhV4T6A zgbmU1xV{i|_%Q=iX&p*V$yh!*!2Vb9-v@K# zvESs)+|O=KDJ6;?vq*+~PzKm=Hh0|4tB7Q<$fvnP{~t`+mBOj(xdZqTygo~>pBk5& ze`VBb4q8qe$WiPos;@q}{fEQut>yNiR^rX!1`J+jhZb>N=o2;suT~mEaB@yJCA%8Y zk67Q6IgE=Fl}+M0_Zei-GZyf+?-v_ZYJ=Fo$_#;m4CifZ$vs@5@vph2MXkGE9E?8Q zP{y20OZ!MaRs|H-c#rrVB2Nx#O#L}T zpFW^_{q_9x^pq1aSNe?Bhtm#2q**qD(kBuen;@Xbv1%JFFfYXEXR-NY_*&fLUk1qp zcX1X#OX&0-wba5@2tKe&(FqxUhp-Rxy(78?oOOgRn5=BE%?K=%6^~Njp^x(vNwyZf zX7ABi)z8x4I(L%U8r$bgwUk&xBiRV5&J4>dZ*IV?CIH)1V3!b<648%WsQoSszTxA< z@4q%IsJXl$F}Jc(EJhlcE^J|SJ|;*z+&tjSY&pAMVbR3+UX0t9*Di+au(bLu%pZ8e zR!%_fj#q|q+@gX~rEqCWvg9`er%72EI%8qo6lEtnCF_lll#Lt3;T?tkWb zxkCmU5X9@Uhoy&I_l`AMsDK-&w;7`o18T6qqt!%j#L!*d5Xd8~fHt#;fJ!*#OMn1VDBaz{LYgkcX-wg@Gj+=eQ(mBo!O=u@JCF^F(0HFx*ca=mW~ zzUK{eYWCm7RH*5miQ)ah5b3^CiR`V%h;ul-5{x+M_eii!iC9km6bos>Q&FK7yoXN1 zymgR{T!Xk_tU;}O;}j2Or!XHAuo&a9%0ZO)AlZVh%jb5GdJI~OJW0r12r1+ua>h-R)l1$nwxkg;93vGaEDY0 zM&d*VSmtv^1eQZ0L^o1EHp@nW8@o6ynW&e=xZvP~@f&-N%%U$h^;g}u} zCE1=aEGK`gmM0KaW4mxHJz6FH9?9o3X@-_?sl{@0Gg4 zz3)-0L>r7hA7H`CyYHKsB}o|gCDCb-_H>?dVxYOPQR$}9HUf}wf#7<3QdjgUS425v zEc$fUS#S!n5dF(!aMq^!bx~O(nzOmFYYaNXj>@e~v2Wg8NI`+( zRImN2P>TnL52c2F3o*NiLxh~Bi+b=qah&_!Lh39_p9G9;&4n)Oi5}m}4=EHDcI`Tf zvVqv~NA?ciK;^E}0Nsa#eSA!% z{Lj2ZBJ&L4W*D}E|NWDrffz(6jADiMuuXKk4n0= z;9iX5*&?0pT^pOZ5Q4`ROs{EBHjmG`yKoO?V(=c3RUe=-w#HLKE1DsQ%c^0iwIY!T z%wT*a2FL*_{lau10ZFLiLI9_EP#?=$J?Dh?y4Wlw;$i?kbCMPs9-^T%fTdWCtnKa= zU?v|V299RBu=P=L<=k7oY@!Y|=)uRB3sW>-I2VRuB8J^Hr0o>a7EIcP4_PR|vAB!1 zppH4y2AOHcnrVm3G(pP)aGL_WM^Sh6uU@Pv1u>Z0N!_?U5lJwv?dr=Ja3^Q&xl)*n z!%xf)?`~-v-a=;*>~-PY8-TV^sPemiLmO%y1iB8+E(f178a6GJ(VwOaXe=jD$&9ZN z>}@dJIwZTrecR*Uw#OQ1NVtxev`cT2CXpye@=qG zqz&~shBonIoJe3TtX4NhZ97^rL!~jTSm%6k_edbcK^O)Y@}8fC8DHKw;w)tlBmfe_ z1>Q`CZ>Nj@AQ^-5np)$Lv4ekC_vXA8V%4i2h94UPek| zci>Zt^A9LFMyNuhsNa!PDCjRIPLVREmNow~AWJtE)7>E2!AifsG@!SxN&i+Ztdm|BA7~-d(em`YiYTSfGZ^Q!GY_p52_qMg#*n}s$keNbF29DBmOyvLK1)4( zr`A%y5Rv77uA$P*YbOz{X#j=?)9JAG(5Zmv>Ii*GkV6$2)P}*di~Sqv==_Dt7~%un zxr;mebWQ%B=6?d%Ojw%tP3Vrn1B5CAOj9|G)Y5kuvm!12G=r<0BKid)U~JIY1!G5I zjg0t~qrJv}0n%nhWJb|w=2=qW7+sTah>73g&#J&{48i8W-J3~Rc_opLNP2PWpZ!eu zN2XE&L57>-kOba$%scxUQvD9Itof&Dgx_8X|DGQ+h+wny86}8O2%9z-|3Y0!+q?v^ z+zT)x5pl5kU4zJCXg@L3i%LwKcrz;(--=LVk!$dKJyWiWDr+gmy>A)P-WwbwThq^a zX!;lT3!VpDtdTJ(1jV^bpcW67wKU$|p?X;OAbyG0fHk<^jK{E8;9%+XG43fM!tZ8b z1n})IKT}@gRqn~+H|oS18tf@9doj-S-Bpq&&2F>Tm8!+0CEN;wzWHgzNB}2hk^R}0 z9A0O&*+KjZ<*D$HLpPFIFq7T7HCiKER^nmNeX5I9aBF6N?mH&d<@)Ic)ylxTmkt9t z`CdtxA503kx{W_A^y*31YT|wPxCwFq&HB=q@2#6p>0U}(fYnlrpo$=VE|B3b(1}f@ z7Sfg^nidE8Hm#eLI%M3`pIk4QpAoH#pf5XECe9JyKvsm~qMCRo+uZFHY=P4wm|p_7^)@wduFR!YTBpO2>7-1gw*v5!1zj zOB*tG#FOpGp-(qtW00CDZbu@I1Nw5Trxp#sB%F4(P~%t(xAML3Yr>FP=x#{O*RP=6 zceK8_mlIm=`@JC8U-U_@#HCBEY>ipwIQGtxBLd`$bv~GDHXRlV?ky3*y%1uKu@<06oLk#YHrF}RC;6xT-2~3RT(J_V1!1|0 zp@U7K!IZ)ljE1E~hTGj>V$Zcf*4|7B!UFY|YOlGpVH1dLoWR4C_QoQ9y~YumPutbc zHi7H8aVhR$v}`ui7Al3BG&-Mn0{~0mc7(bEL;^B0tm-o*&Xt zP2-9@{#5!QM_X_-f&2F;Y?UN9^0DWG zOc`9dYP%s`!TKVae@(&yrFJ(8zjDv(&IeZDi{b2cMM*BgXo9>FdUc=QV+=~9kfK`w zgsqt1BBq0u+_;^x)$cu4=J;PcySyobrnoUP-~$MrW*X+?d@yf)lZn? zC$7N7rpKCZlR`9DLjIXdz~NOnIL8}&w_`ojw7*vyq3<_vLdKwugn92-HKKQyHd@4N z_wPcK(-pw=KIwd;dLh&5ZYti065ZU`jiEAAsm7qc1l}vXRl_OUeRcybU}90X-(U39 z0@ zFP10Ia7km+(~Uo*PPk6hS`Q5`(~5?@e>FI4jQg+6+tc0qB~!pz`ULvLp zH1bMACMX&l2AH0^aL2`EZl#tzEFx&SzB-@dO64=`AT9!s%l*u(q48=y~xvOe#@EhrJRG=}`fLiC|%42@u_YsHlpZwVse`hG!d!dl75 zJ{~ON&&4lqRxRtU*K+UK3#v*x;37zstRWu_ACMBjsVa%aK0jS2fBe})dCwl#OZNL< zzDlYVN!I1a2YpO-wHJ7beRIF6Y#o2r-j>|@z3DP8-PHfDyO!0;;jjPEx|skU7kVOr zt?SInFgAnR!}l@db2v>z;}Z%F*zyiLT=g%;t%#NeYt@l%xkU#Ex(mu?H4SOQK-^L~ zxMj%}XK$*))noh!53o36a>CBLsb(7L+&WS?QDj_uuMSO@-(v=6>Exxgb-m_JWp7>@ ztH>+SFR0Kj@>3W&9quiWD&=fHx^cJytBD5SY{^nJ^WqpA>}?6!m0|Nm8Q&|7Giko{ z-YDADPIY2ZRB;q^iU||Ws`WqG>0dPXYV8qPcuho_8C81B!=|PAr6Rg&YLghGj6zaq znxBq-d?px7veBU~yKr-=g6t&3*dxs81t3`Z^TZmr3>!0Iick=4ou zXjwVT$(PNGvx1LBym2I5Cuv>eT_-o;MszvwTvV=U-RTF*Ad?Tj^E!!rjFgx+T)am4RV#2ur!!aqD+YtgM&`? zyFMolaFIwSwny}`G4OD{?rk`WgWGmR@2DV_ zEH&#tCq0d#ix=y0Kz7vhn`gjNQ=$N`{6bv>TsjPBOH3*^Z{V93v}|JJH!g2O+4uM` zxPch8H|;+=Us?gx<)@VjlW)8WDVy>iDI4(V_53%>>0FLsk6{6_uIEwxm#n3Ms~U18 zI*jVzb}<_x%6OIJ!=okvlbWYRpVz1Agb6^*t!TcQeC76uUMm?(F8D|w?DDU1(JTml zmBH7_0NF|4lXG#=`PbFUtVZk zp0!$2V~@Xv;Df6_YTzo_VECTL2+~wo`pt&Dmi5eUneS3hs01G0@)C57gkleV^>*mZ z5LKB-q!%<1Z;K;Sk-6FkzdmR7T0&b>BxB)|&uxsBYNX!ZDXrd?3A>PfV&+JC-;I2`D>Lc9g?p8i53*%zyn@s4w8>CxfJ&P6;m_a0LzNPpY>$;*I!0-a z7B147TPRW{d8PT*7|-jg(01I@CcXJq>9GAwZN;kBU;j1fmF{oW?|Cy6531`D{9=prS%YAVDeYt1$ zVD+4I_u2K;{Il!u@MF?xp)W7n)%3!bn@uNiCXYG+qXn~b|JprE_b;jQ4@(!UQ@UZl z%IRBSu`jsa3`9!8&Zvi1Nv|R$XG~rA?h^i--Tcen7*K&6ZuR$@3 zW(vm#kZ^mYv>t6wr7AQID;DP7@q6pFTp2RW^~{i^-kJ2@GoWxs>D?>xlX@NBpOiD) zx;YC&KXCELE6GDHPVn`ro*K3#(%gMW?Q%;3dh?+M;09WVV#rA)2SM5bH`b?J$Rk(B zG^&Y8SZ#^Dy&+L5h8|M{GPo-!!%Uk$ygOgRYfUd0G!vsKQ6++<)juGVSe0}wY>+Dm zIpo&)#8OKzge}Ydl8-w=lrJisYN7&Sc~?o2qoZq)F*{E}uH{-C@*rG}x@Q8exR-*ic8 z2IWQEbMlT@8(|~>7OSicuV01`&X=^4?o0eiYk%IdTTq};?vgNQxS%%QPg%PBFr&6j z;pU4dt#lz)%chV7o-~?TyV8~{8E;BS+m09d7HcMzR*`IW3i9%MtGVHPp7P;WS^Oxz ztG;>@1JFb6`GT=%Sj|>?+-llzWt<_k>E9MdN1-?~^ZmNOX{h)%0PfscwrG2nX@o6t zUNFkJ|%5aGmt+eAuDg-Kf$jgF^bwW@77*n46THINm|6NZQn{`zmK9!U$qYFYQjxIHl7 ze`*9R-+wB!Jiv-4cRleR_#SW_KdkiXQ(UE9)Yjeo;#Tu?_p|dinzW*Jx9pZRzmS~y zQfAhy`1q+A=SvD%s zTc?MsuUJ)ZmV?2E)GVKaw0)sFmB0J=AkHe)b2-zjinW9XD;wFOfD*b}b{8LX0uEJ< zv3iXH?C);HeaBS6pZpzoXbtX<*AK)D-M%Rn$(%bty>dI^IX#oLv7Io+8cE*skb8tm z(OX`SbqFKI-aC6=3C9;RkOT}gwvrGvF@&XJ5B;fB-W!5mc|*yX732m_N+q?bz@+`u zVXbAl^9p>%1x171{a24C*oHP&G1krua$m}yj`UQ|y9(kt<=s!^a~vIQIO} z>FG2N3pMib07QT5bBuDWz{S=1?&CVQ?BTQ$YH81?bq_~SGMW!C##5M3~Rd3z8i$+qqK~j(sk&=`SDd`kp(IrSDB@I#v2+|E*Kf`-#~ksFcZ})JvG8dUvlM;GuhElEx#`McE++*OI^s?XB8yXEm@Ate zCUn2BXe@{jR?4o2)rrXb;OvK<694`3NBfy*pd5_XydNK@J$du!k`YSroC%`GW~^n% z^vMfwoPH&_dXT%XRkFN^TF%+=idzTa!po+WpwjMuqL3co&RGENY^XJf^FR+7Dpl=s z^H;mvIFL?W>Sr&P;crAOU;<2gV_P^wdZ;&cCM!%%cP3jnJp=V);wTzHZs2JerS$A4 zye;ArO<7vj`Lr^0Rrg7Si*Df;8m$dGjJwcmO&7A>aOm%r!Vt}J&U(sKf73}(R!%@j znzA<}>-i0@i>s~U@Vmb>BaJnwlYDn^-d$qrB?24QibN!6VO35Qa_4eC)nDn3(@Vn{ zW(@|p(K!93Xc46^i@8U)u`jW*M^t+#XYzOVs8UDk=flms+HPRFOUk#BoCQmEsB=)O zm1}KPvz2;~r8b+Z%g+3Fj-ZngH(fuJ{pl_D@!?E^yV^JO=bEqmQ^7r54lWT^$$5w3#V^O1taKa7 zJA2gWkAql#yst4nrtmI))%_*hI9Z^b`MBCTs11u~BIL-4QnVQ;YQE zya!R}(^Jmd#@mJhOevWkK|o9Mm#_0F{b$Y>36Bu~j-sI-L-E~vV*qcvX5XdSn(~$T z-6^Cn_7ly%UUcp#vL>mxTUFug&*9u`Fk9pGX{_8torV$>&O2f@?ev*njGVp#+kf`z z_f-lt*;m&A0m(^U4G>b@Qvb0JKd!bNN;YSyD=->Juxm-rUc7m#3>Q~A(9_d8iHy-uPRaO$wx z=D8@Fhtc%S;wA0939u*cwt5xY!~5%Fc2bbCrnKP48S%xG;G(b9y7lX`2Ml*P(-?x< zOFjuW7n0t4Gpa3EoW_@`SC;_#rQy#tu>_xrS8vOOVncB zD)C54fyW-l7SQf!+TOX|#4mhqtW{aau^GyKc>cc!K@aq{%cX?DKwv1u#mSJ_m=ldVyWYC`DG z1l5yf7@Es$owIg@89TMG`v*JG>kq(06jrz4Fx;2C!yy{D2!ZXA$o@h5>)*m~5@S&* zpl6Z06Lk(q)pq((lA-t+QsHa8Y0sz8-=Fpb&Cs*@_-t;5{yAD}+wP-K&o?B{uo{s} z{7r;KEId0(f2TW9WM7qI@QoHD2xERc1?nk+z}N;_#~8(Mv><2b=o{$C^(ZT&59W@k zLkV8-gJBiQHRF(+3PpW>SK`aFolY>z=5S?DLP-6zdrpgk%kA=%{bkOJK=phzdiP%S z?s)Ep@}JqGD8D%hPTNM>Ke-c7&&U0+MM-rxZu6e^dVKbx10w^s_91rPW8m0Y% ziK8L@oHxYZudN?}T`3!=!k^@CuIo5k<9Q$G%{dc=UIyj<4=DF3W0}z}n`F_q3pXQMU&N)<%4p9y5YAqA%Liz}7-2G>1ss$-G~ zG^7a>8}kx+<>q|@F80HCZmai3z9a7pym<_YSFg85)jOekwHFI4Gcz;CTXfe?@a`<9 zWT-jw4PNwHvs|TQUsLP9w0D9ll8QmpJ|t2pj0n{<6NHrRA=&OB_~`s-=>;>aG~YjQ ztSGYlxO^O!6he~Lg+d_^C4FdcYft1gH*+5$qMhw_B)keX9t~nIxF|0_goC(NnF_c+ zOVFS08o!U5%xO;25=*-5J=N|5G{(M|t$w4D50>SCjq1jrR3y5-5wm%RO5`YrBH<%; zMN)PyqcmrbS1j%iEZN4>+3$QkmT)TsQpA1bG7jsKcxuuiLsw6W!fN0JdOs|u(^ zY{*bb-eg*L))zI74IiW@c>{0mL zsdd1IA(>HRj#Sa$$YMRCOF#O8OPGK8EruP+Mm8{pO&W`PV1dlQe4og~Wvd>$8Dn8q zW8n~UfdUe|^Hg0Hgw{B|J3F-UMBV-!KBZDQKXsCKF}hq4?k+Xv3v?~4L%^i8I8>C_ z5pd}xYIDs`7$p>nXs&#Lb47_QdyO5P&g=V?oUFRzXP?;NRt0%T>V5tLWKtBPEcR-f z9f{kyY0j)#Mfbr>uMhnd>yJN9ACIQrDXTwIqMgr|<`lh}fifj$K&SW1djUebmSIw< z>T@v}-1Ium=n~`wP2>;_Qn5!7Xc(4z1v?06Z|=K!Qih}=@?TSGFW-BCvS8VjLH|UN zb{lykk{TwziJ!pl#pJl&x4w}fkcxR1-5I=jUn|~?OCu!Khmq}YwZx-^KAvp?lFl9U zN36b%J-!$FzMM}G?Gb&ECm~VC5UAE`RLDJq_NsS*uJkQ}5XY4UKHpr(ikau1I+Sw* z%&Y$XG(;|A$aE@_WAzLNM0tnwGfxf+v^WELz{|rL+X66?6t8?81$(O_9jfX^5C!<0%fp5dQLH9`#16 zxB<|o0m!~XIvj8GN$$eFx`FmL*($HTd!V@gI2}p}E+MQ|=q$)t*u+wC`f)l{4awP5 zN!JYr12T1AqS}q;DPcQZ=5sbN>-}n09dOP9X#hN|`lxY{ALa{&O*)#s@ZXL~E!^tr z?4$=4X7h0!4dev`G+GN$U6@BG04}A<6SpL$SECa3N-3&MFNyqR#LptcZe_tEF1?v$ zg-9dvPAq4Iy7uU*YO86-hz))xaOgb|nvt5hAw5nTX*?YNE`aV+yAY;6y$mpX^nYJ~ zB=oNe$&i|~M)Bi~-}g9ZD!_>!ICY9u6LwqKAVLfw-pG%&%tdsUaRQ4#Tk>3vZ3R=_ z{DSpJf!6#&%zzjDznw*^URV(Lv9WwwxGKI3A?QMP<(ed<6GVXj8tRMF#(;)e!zS0Y zn9Ay}q`bi(a(YArSPv%cEM; z?~}!{>q6X*h?V}^1Q2Qo8 zR%!}yA#+G>Ce0ckC!kVfV>cih`p)?`feZU^`5_z;KG@r(IB)ccvKY*K-7~+f@>Sb` zJvfUUAD!toU3Oeb2oeK$=8#9bOvCx1YMqTcC7{i8$j7(-vb|YqyF8n4K1Y56?S+1E zBa?jq2c{qXEI^omENDUeA?JGSJDdlx6A8uan`w5(#>6#4I%>Bj0k zVT*x~jcU9*V=djy>{a=tdE!;@>n(eNh3#b|S6p1TCM+SrCixTcgzq-lqb?Hz{BZl7 zA`M2bNED)^Jfn!zBZPJK#91kKMO|q8`TApgTx)ZCF@|(_?dLL=et{g!pZw1Hk;81P z(Ge5X82qB5X4c5gyQwGRw)yA*v=MZWrc?me`TUBVvN2e3b06%J_!cq#W|v4Hsq`+DR7$r@Jd^@9YAF%Z|fG zKFYB<_+mXTjk5qFh@z!D$poD(`4TM%=bQ{Q?IiHqGHt#46K8_D#>{bNQ=BI)jrW@0 znkxl^QJVBI^@3|0hLIn$e{0I@#Kb@{a}JqXrXa z*6n6ACO`Gbi3#5Yj>4}_A4B}WV9_kkE}Cerw`My+PBRedVJSVh)31(vz3%M|S<+Qv zFFR6k;Q~DeBt;ibJbq88o?raQ&<_20;rL-L;p3fW>Sbw84-iXvL^RN-EUI4gv%aNI zh#r0rj&E3KJ@P{sj0>P`4eph4|)9AQK*K}Dx-5E*c52{_Q zv$L}yhePK01HuU3uGRq_->X&cvioLlO}IURmF9|cSru-~=zCZ&OSzbY8=s0G=wX6l zPOh4~h+{3PZ}U;ahcj(PDB!XOa@guFEp=dahDTF>^7XqX5C~5) zE<-C}2jphIhE+d-$jJ@3#}s47d$iL{t7oKKPUw#7zlw*S4zS}t=Jfk^w6-{t&ARdD z2qJ-;4+ogM>9W}xf)e(fhQ1;f!S|M`q}r3w^_#37U6)=&v$#9QA?j=pTU8n>UgQ%X z!|lv9Qiz_>_!4bLI9R6NeV(kR)w{H{mF*1lg6QJSK3+gE9gg^;MNjm(F&C=azAT$v4LijnWB%v1#~ zsT9>JI9O!xcd*}|VdO_ve4`Muc%MP`?>>&rUqxA%z03Sw6QAIb*PrO0N#D+X{+z5h zn@x1+D4~DoRF3NIHXAFU1AyD~F_aUgd&;bD#qmourNyTf^U#?Y4XIm)nd4hAb>@R{ z+h(@L+5w;Gh1Z&eQ^;2xK*XazSZMJt1GT}WV+|UEqUY3xe5=uI7&>wd7lxHX9@q5g zv2O0@%2{~>vuXK|==4z&F0%CKcLiOq<9NdZd|!%;hV=_Ia0J>s&0-4GVjb4`pI@bg zv!Fc^8~=t8!Y(MdEzA9B(_@vZ5HWE}6=+$(fZMg=N(s9A2ngBjKP2491GPZ|1AK*xtKcqL-&@$$k#rcKwl7C)lMqp z5c7|afBt24AO2fMeFpw^ALU9BzM_e}*Nl<^twxbwDkn4UtMumTA_NAHn@Va*L#v+H zXH(%L$V7C@0|2;5QjE?>1Nm!o9G*_0(&Ncm@b3r0i1MMI*OTg{9lTKqY(frrvhxG6L{RgvTaG7}FPzoqh!ULUabUlvD@+Uy zY%x!&BbUMO$7iVwvw;6_0XUy20>k#>8ITJ$AsO9@+|Ba;zHK!cLhYH?rsJc zlNQp7sk-TNPVw!9)bWucAMZA^bVe!03t_N0(FPOuz-CBD!Qgiuf)K)v2Jh= z*88)FU>qDAMasR)Jh6bEX9Q?cKY!)X!MM$Sih&l_#QRZHg1nv|0-REwIX|`@W22yB z*VKaFPQ#0A??>|_M5`}d$N||AjaQqA~ zQc<~2P<&kAy+7ADQDaZzvOgCAR>7U2u!#;}P_s=`23@tUvUn|~5>_oh$_de59e1aA zhz+P78$lEl0h0#}z@sfgBme;G-BQm}K>$<0WeU{u2VkZOLLSG=4F*0wKBqU8?mK|y zm|ltc2*8ezPzU_+P<%#XV1YGb? z$+4g;Vxlx^k$0rUdNpSGNQ4~>G4hBX=~81~iqXXy{us_jV|cSki0IK7O+THjK*B`F z!b9trp@z*Y8prFYQJ@|wh_(H>!dJ50zq*8rc$}fpvP%q3#v?&pM6vkZ73vFYBa?u#(A_M_$z* ztMi1;8iN+WN7>kUyu={y)%PafxzP2Ga5uCLcSA*lFx|u&%^C^((otH?Dx_P-Qb3+( zXrjEK@A>ckj4tQNB~f7j8&W`|;s)-V4$D7h;D`PnkJ)}}5#cUU_10?~e*U*U)+5a+ zTD&*vv=mqmXdw8N9&AtO3cFN4@d^?mzU)TS(I9vD6p;H;l$ejcKcw|^WRid$ad(qsgX`!Nlx(h|n&WM=v(MQMB?uhDR$s638Ur`YfYcn~dz zv)krO9xi_5P2so#PK^a>Li$p!p)O&6NcK6N%AHTkqZIGPKc$}LH97h+x-)PI48FA! zKU~yY_tWVgZptrqM$DFZR0zVLKXNF#ZV!l{?NO)}-;p8tBM9Q!{ab~m^MQ2S1|+J4 zF+BX$!(J5eSHd)Zqpt6hlNZSNWK|~G6+1dQ;-8-8bG~W{h*<_i-zV7=P69cv!9P!f z0O(EJDoR2^qW|q3SxLmq!KLr>?v&3s(u2y2i_U6DYXkWxnFRK-va;HllA$4PihW4e zKfUP{HVqGJ9hPDBT~chQc=tICg=lBEKi|ekV#25@ln}VX;h?pCa{%}%ZiiRjsIMc2 z)=~|gr8{3LF{_m{JQg7nggI_5l|5`}*$}h{iB2P#8!rippb?-fI?L zH$i$r!LueNC93h6{Vy6{W2s5&apDEn6h{2-`>8~AT3K?U5JrUCh>?}9P)5cC_DyN^ zcf#?w4I#^EmPHAd&(~-oM{S;MLU=S-IQhD=>2)UNb`Gla@*eT+rFv zocJjvj-ac^W@?Pd36LT2&3H|xK?SQ2?1!#eCAy5<| zyju;_KsK~j4CR2LooxobmkP^N3|8`>AeTC|o2^9f6ml_Y`M4Uk6|Nf^eDE~P51_tO zq|_C?ggsFw(=K#xHLEyUS2kxaoWWRLkxp3f4SjRl_RH>P2S4!)x+5u{po>C#KX80M zC3K9HYcDZqL{zxZo(@Fnt$Ej+Not)y?kCqwIocoL z2}Snz5O2*EqwMyT&S2n9`yhQxyTCn<6N+ujN@~rsE<~CsM`jApGtSgNNT(Q93%q3|)%fAA~qk8bar@ zqgNBLVI|qh9%Fvp{+Qo8%m$Dv@m|0_%HRF&BfpX^Gf5Jd0?Bp6 zG~c_&-czqUD5*d3&L1>^iA_^W1Nm>6w!`%O;D^-a1&O=8_zrHgm%I$ffp1MacDq_U zC`F~f<_po8bmSQCHXkQ8Zo&;V&%^q8d1q5Cfy>p8I=Jg=AMW0)#8Hsw@=CV#fBmXI z{OA)BetLlCbqvNa*1yUf9?LxjGkE-1U5_se7_5|O=DyQ-V@N=0t*->BaA>#_JJ~h1 zp6=|&a7v=%U`UdGuJmwLJaf5?it1?Wj;QV%blc6=Qp2bJT={%X#QhkQhJ|$?vn#*) z=CIQMThG#K*bTk^Vk}{_P54<5;jQHe{?N^Fq*l$AF-!LVq{2s}Zu=#pO1;(*lFirr|Qb>w{BU^bIpv;m?)?U9Ve; z63^dWNfNRezMtKxf}T9f`a$xkt8Jq|ZQSJgi-zQjiE4NHwQC-# zLO$EklbRg>lbBqtMVyK-__O?&ESNJ)x~Db9Iyiq_jUyX zysu67q>Oc8Adeg8%t%Myyc$hd7=TzdJ)qvPv~8cs1=NimY#r zZG>)3&3epQWy^O$LY7sRKQe@J+g+e+%?m)B9DZH!6do>KU%v6#K<7DYM2iu$7omMO zl0|T9La1dX5R-y&ccK8a1d~c)mcwuGI&%JO`)Q4x>k`bNt1L>9!Ce3O9=M$yQpzy8 z=w^j@A%S`k*vrwaDXx(h3U#g)NN0UX6T@Km&TG~5^TXvVgY^U(#I+Bf{9?U5A_(dcmsm|zUm&Tn%JIowQ?#rOp;Qa^lS8KtH@RU?x^&Uft1%H zc`vIRm4W%urO6znn>_>6^l7|LYTh+6ZmHnc=xq8#9GZ!!X+Hb+_;vnBNcRazLzUutl8FN%T{5x9i~9Pks!^%-QWVVFu-x)&eiEZ(<=;?o+`Imv?jFF+It5piLEAGa;-_$<1{zT9Gv4%!z_1%P0w z;yTgo(<*=PbCniFehI*`L?(&M?WBR&zZE=jGFYqgmN8ACE|1%4!-(zGmGU`jA8JIB z5#WH-l^NL50}y7nNyBQN*H?EQxmaFts*)@?q8q>0)oxyVwwLJO;)J!9$J$RI{BIS@ zdG*H6`*GQv{d7_$*b`(jrYF=9L{i(pf**i!SXTlzXqH!Qhl^9qMA-TUs&$orz9|4^ zaVxK@tIc#BcG~xM(n*migZY>C)eHEe7mtA?h$DD>oxm)wO3SGbDN~Ry;OqxSX|yS` zVqZto-P?HJ(%UobwSo*=@6IP0wy{FPU7Q?wyqYqLsDgH9)`($GjqFaW7Jteq&m3b* zf_I3H`Gc5jIW(9#Q-$5%PybbZF*a&~64q;{UyoXkLo;j|E&sH*t!=v%W74@$%eXZU z&tkNI$a{Yja}s8%=4A_7mTPYEaCB5iJG@J5y0q4&^efkecn}=2>im0q&jD!@3Q>tptARidxJ$ zb4E^_cop;TmG_l;1|0@5M`ob|(X0&K6@dX=Syb~PES~wjE+`%2>ke4TU*Oa ze-HyCqaqCv1tT52nkbv__%-sNyg&uRi-;pA==%*GEz|;3Rtk`aiRc>`43@o?a4}4G zpjCbGp4YNBHi1`0utqMl%tFx%15jZ${w9*oCwTWuxWylvbeP*i@^HmU>+u3fV8N&R z9Zd#;lOmX(pK@5tH&G?0q$uu{x`B?%cuu`Yz{AP(NU3A%R{cg!rIOJ|R~JS1XB}NP zICwnpK5T!k$uuV4{q1_X5|unpxHgmGmw-mkGdig-!WRKUxYsE)_sMMMn^NvAuJ@-^ zGyyRm8*uMU*JSzJAWXO}JryMGp_ck8MM34AdC6SoV^xev-Ktio95)>!u4yVZY87dR zmdx#i@x;9M11fftw+o=2#MJ+hiJ~s=kF=ehIXoDoKc+rLv+&O$4%-k3TS=q0q@p$) zZ;TcO?=^Tk6o>!#%n1NFN<6nkz!Gf%cD2&o`2aeDvfi%$!khXI zvONqEjt|A2IO!qSr7VP&gVKCPcgy0G1k0+okDEF|5bKG90}=t@-P9swxCGLXVl9X$ zkG(FQ0{+wUDjFkxB!UO%Ev-5~pj2mah0MrLlul%3` zGhX_j!%O8l=tbBkDl_s-sG&rURspHrd(_e9k4syO9}1xKiV}Ox0*nU+?J?$dOeeqs z=wWaSz04ClTYUf<2EVzyUO%4(6M-P9x=|vY zr?Cb%SMyt=dHUaAx_w`4)`8#w4O#fqGZlfV)4mU`L>KFMa3unHDoeYF%LL85l=02^ zGVXvi#Z-c6=}?3F+xR8GTRKN)Qh~b6948j!kcHG*&_^YKbKXm1AEO0kqm`QbqE<;J z2Jzg(KlpFh9z86zD>75BUXx8qvF|BjwuD@oULXchE3As5*rg#3{LPItdTY3bq(o;! z*Tn^%&;v{kQKr&%IzB~Gtn|;W0n>{FNZ7!TqETlculMXRCZfSZD@#0wLFrCOCmN}< z{vm;qV|SnpQ#lda21o;Uk)r|TWPOeO>t_2|hu>dwXUjP?3Z9zXR3PiX%(X^|fy@U* zWyaw$7o^oI(Wc#*u7B9t$$?)E?zPl+?`w5~tN5q9&Rgm((z^fxZB2UJ<@$}cIi`Yy z-IAP+W2)Qt_6F-bZ4sbZqZRs?~JgKJOwQU&@ zICB3JGD_k+NbXNt7nONwKGt5nKug_PkxpSP;z0yq9Qy>y5QCNImrx+iOjXV&2cYc5MhgS1uVLTNSteEcNVb!nk(U!LDIy_jMJaL=Lne8H<%OIYf z6Zd!=>oHmSuDI5W-BcyZl|>1l-(^@m=?;vOB{Pn9<)nEe38 zCDU7FHi}*xiw9R1+j=}hC=Ex$p=>Uw+U9zF@nTL!$Lrswk%?g7Wy*l@%}?6ZHkRk_ zb0Q1?_*}V(r)O4v&lmhL9q_Mfoi<;(5dZ}9d7`|=!YL+-@e2}FlH&69+sl+=UhZs# z{s78}WPu`fIp3Q_Ep8wM-E3Gh#auV(Pn{i0jDA$DTdeEsv^|SU#NSU#GApBc{YiKI zCLDf~@M73Mva*&KenSMv${OSBg!A7k_p9r(`A08_v;9OB4unnxehLU(AhcK;Sp`Uk5b4;j`Mo%=8+rH_B4xc(d3&qhGa6Mr5(+Tqp=YxO1*sng zYFAGUZr(Y~Qy1wRr7fB77?fKMo8=w8MH;am(T-sOQY-Lzxhpzwl6A26!4LDa57<##rt%Z>VT@5N6kHxvbTAaZ-LZ!6H>5{%`L}D?NQ} z+h2%bBIBhQp!KiT(t$Yu#?}+$4CaQJiaW+PO4|&)!VNcJLm=gB=jTA*(;?>gt}5pkh;ha0~sk zR3kF~YVE;RCJ(hBP}x~g_96)og}-l9oe<;u>I4ys(Gey#kc5JGz#l-UQc=`gxsH|& z5Ksv6R~Ucy*rm6Uw2uI;aBrQ82}ZxJEjlY=>Wpv2ak>7oiRCc(@@EpX)T`&eMA@Ah z)Z_Px_qn+y0hOe7&}or~1ls7tZv6bqOwa!AQ{n^rBKQbyUvdLAx+UCn%lI%TI3OFy z%`n!Ro%Ljn0GrUOZ*SI)ErGuu^p;JWJObqjm2zXo}XSDnvWXtW!JLFIAsK$}mX2?++dz6euL~h*|82N$bQhdxe9-Wf+4->3-od;ZRDA1sSGe`Hj*yOQ#bby z4yZeQ>gP^CnnwBriVkj6jxzBRSRELYalT#pT_xuQ+39y5Pz(GiaFnj9R+s(T8V_t4 zH}zJ@HHqm!QJ`Z@)@8u1p96AS0DH;_y-DOl>mlfn0hJk^XDBa{cx*468g8(hw*Fej z#z~#2s^3r=Y)kSTfihKvxXVz2s%+8|vLGC)-mBzi&X2%LT_0ZgM0nsOE^iVh5B4ql=il5WL3`a79rJswS*_ucCkbtn7LgEIy;nJS+`C$T|5v26eLe2 zH|kZqcR(-I^L9zbNAKZcj!M4Ut!4%x%!TG2Fb&m;6dAA5=JM2XBWGKljQdgvx)|+U zp=j5+n8Ydq)0_8Kpl%FLw_7^3(s_GK3Y0#b6eWWYjt&l*-=koPwMz#6753Z{V$(aj z8775aolly#E_ntQT9rm1)}4wXrnkxRq_8U!n1cExW$kLGd!UL%9Ld>8ly!X%M2%8| znNpKC!^JV65ZDHo0ji*l;M50nDjV4&_a_Tvx!L3>CZW>Mq#Yj9kzx z)(r&lWrq9FTCLagF%L}jM8>@lWKdloey6|sf}Rh0^LD-%JW|Dr%?lqRMZdk9Z^sj+ z`tLn}%F$k<-j<-(0&U8LCKsI#>A+V>5avPPp9LyVa4P@h4Pbw?_4M2Wo#Cy38ZvQ% z7YtUC>czp#K6W2=I}6v%LI2qykeab$ zunI8*77>Z9TE9FJuhzMq;&)l;b~RMhC27Q)u5)Gu?c2ei?-_<^Z8ctkU8fq3;S~=b z0>UUC3!zHmf4lagJ&Xtw)J4;;ae&lJ`IPYI)dt?zrtlt<)4>=*NI3MnMbb9Rx=mS+LH57J(Cb} znH;*ya;)&n*OlvBq@@R;^&}VLg!IlE=;128)g5byj@VPkE-mE@BV<8*MjV68-guG~ z?kb!7(Jgz#A6T(+Bczl+Kyj_BzxyGxG*)Fr)Qy+ELh}+)(QRG*@fnZLPM_(GTsP>H zxew+8nAm>OP7(Ix^(3f)_TaZiQc#DL&MTOY(dOk?lZHwi zs*Z3uITh1aMr-_L4@B=M`x7o{0Wy>4eCCXbUkCwRKq^B#aA9xn*DqVI-z)bngB0ci zUVZoOi)^X`HF*V@2l#(3_rH^=lF2|L)G$Nx@^J7pS_AGS`sOenmc4Gv+zsyLDi}dj zpo3*b<0~36e^EF54;Mf+w5RMbh^;9*Od`yY@56E-1K(8OSr#yiS@K6*5XCeCc#Hnk zpQFijLuA#4TZOE*v10N5*%38_Fq7x(qiHmC~-LkYgFzC*FEWS+ILoi3kR6KqvWDh%CKmbPfhk1+i*{$t))I-4q5u1Ov z`EC-p99Z6NlhQeDI-RT)Z z|H5P4iS9pyj1`!gVLw2qk*y`k;EOFjcCep72dmr+cTB;Ol)AA4vtuf+RUV0}i;oSd zVM;z=Y*kS=e)5wKWT5r$F3fx(;-?J0O%Ucg!#n*QxR0ncN*(;-V;%8JHmrvPDqmwA zO*V-{rNlScX{5|p-aL1f-T8Yq@AH5o#}{47h#pf%B9hH^C%YLVJ5%)$LNQZX*w%pghjik$SmcZ9K-Bf1Q0yRv9Tp+=##lW3h_FsLIjelTwzKPuVfFg1+(+N?Y<}o2g z(EZ@yeEuNuWW_(FpuhTcj9RaIHl^Ow4pB-pBc8%0=5kk*IRbIrN^oXjERupf)~3Eg z*v=Px*fH7h9_GL|L8>>0UU>h#wG<(jr$Y>`W&(LZ=oX;#NjI}2H6H+cOSd5sp3Xog zt9)#(eGg$=_9wK9v;BT=nRW?eaaCP;#t@}$e244fVdbKJ;8$cOc8KDlv(asxt|e-W=eWa5{O#eyP$3k@Fd%!QvhUmNvPAN;Pfa4q}ElWx=^ zo>1Y&@_UW*zA3@~@EJ+q;n8I@*T=7~+}3h)a~~2ESJEo%2lA%GBxKK)siWQ;qQTaF z1p@>yu%79^BhBI>34{Q)m?^PLfnuXx%iy&@wm)hGV$^OPsYd4QTg5W~Pt+*TZ0k`N zH-2gWeEKmwqZklB-TY*v&Z36c^8ERt_fR3FJWaZ6iXrB%akiGK)6VCN0qbl6P?dEL zBLtBhNYSbJ{u9_D1nf9mh+vA9&JQ`$LW6NO!aqu3%D5Hg2L35*N0onFyW~Y+!lWEg zUI&7d4S0N~Y)NhC`lLJr21!@@TJcE724m|DUaED(qXceiCPDl_45})P{FjWC_Vb3% zwWf;1|EM_Gkgj&T%sBS<^S^0cGq`Nx)^3-gn3VgshA01m>j!~F*r;mZP7-SLT~X-LT69Ny$sriy3$Q{5?2RLnN1$TJ(~G`6|--&Fov&7)kio!5D`5K z3R5_{zng)(a`vxC(jyqL+z9f(ZZa$|Dgcf+hvCwia@$6P&I?Xket^l;5yYj%UKF8WH{0=E9zMaGMI{*;=yB1 zAY?SAoN##?i=fPsjf74+Vei1M=U?c0!*35b%Q~gIA}M&B2SnNi{J1UNO$j{KX0>UR zli`vNY(EYu+{3^yHorGy#wqbVivagmb zQ1<8jAIyV~_amy=zhqgB7MKayCdr)c&4yP|@R?jye*d|p@}FySC>$5E! z(@eevAeO%jJkkIVS6rQXeWC91r>m&&+SGxh?_2Df`3z6fD6%EwjbQMJJlgf9SwKxYH0j3DfpuJftWVquHCsW}*% zBB(u-z+__}qkzqWhk}gT3`@l4?}x!GmWkh0Hghl(y_)^zmBhDwTk>#B75y%RR-U@#_O(+ho}Gg9ZjQTX{k-zSgZiB{^s zms%`05!`TY%ospHI^Op~+N^W5jRl3pr51lQ?bW~mlLadJx_ljtH`SAP^j!0Z zLnC$qzzthV%esYC6|MYUt9qXwAh>g9gIJ{`Td5mU;TM%0_~dHY92 zK*(*mULXmf60GiEf-eB^4qjc7D=9+&@an|Z;zBuEYwPx`ji@1St60_uFvhJc+}cKC z!|8p=Y~3=b6o$VwR-9zJtw(I!Ldg%ImKlb1ZV2GZE#KbaapIGLNhpG^;vLO*HJBe( z15zm7(7o>XOsVkKTQexk+O;88IiZynSx8dw%_~>+P)kSv6bRGv=Z1->Z|EKPB|>M7 zby4=$>tJDz3b6mkI>Je6rrZWh(ZHxE>FSwpT;2ulGB%0U@N{?SAG(|!o!#MibwFEW z2tx;fXJ}bGh}5H0v!q{x#DY!Sx=b~AE-_Ypz)Dw7ptFNslcFe zE^)R18XVQVoUfALbHSz-N}$TG0^sZcXfq~a!51{1kt__=mJo$63swGBOLW7d0Y+%* zAGj+|YfbAr=JD<_zAr)01VewQHvKu0R&8A!okBd@%&aO78;Vx7?OPe6H}@~FDBpWv zlRMU!M4*AIhI~V*dRG%+W4FR+7FM9W2CGjG zSAURHf3pEj^97q44Z!AIh&c2~L9tScC#5+ykd94P=4=qJb6|w@=qfe%z-_APwLu-L z^P^h^Osf=>9=CN+0gY2x~|A;_&RuT(nZ={oi+o zzZv0$FFsn@Jk@#*tC9S0jJQGz#}6Pf-2W@^Wemy2#k7&-~HN= z&QT1szP^}`X|J>#o(M#i6grOh;tfw!6q)?|{C4!J>gxp!zk5f3Y&{DkPV-e2mPckP zO&kVp>7AV%b`q0bOCQ}%{w^+|n^^psiM+zXUCbX$Bbu5(GJ1Bq4b4U8V#GzS-A6)E zR2|s++vqr0V!q!+sfFM9#^$Rn_ic~MCh<}YWh+NLwH&5(ZFpi z(a?z0(!?{_>Na@%8Is!fudee9RI=D`~e$3x?1iF zGl~*A68!Wan%*?qNf@KW5;?LmbkXG8Oz|Ou%1T`1Fx{cNc*5Tg`pk_+3+XLyMid5CStbOF5wxb0XSplMo7>O;$zIaqBIGtzPjFIx$fp*|Ix1DXS>aI@Pgn=`- z+hSZG5iE$M;&>lLyBvyl`7j%K;G~++fTxBMSncVV*+jPp3Qp>&%KZQrgSy1&WP>yU zgKkt5)O54Jv<2s*5(9^7vvneQ!j2)H?HKOy8~eHnGap@>q~L=}3sJ}|30l5ys>bu7 zCi^wXk8#_V@De{Dtnbni+}1}AfCx_P<{SKk1%=Ce&Xi{A7X3L4>S_`x@yxkf zHC#Sj+0|paSf^R(`AjovJuX%3AxbavOMfbLlBo!+=0eybAjr93d1sWOjygBt1E(5; z=$QR<=ue%|si=I>JU4JMbGyio*OSsm^Fq14tRCP^{tu#G)N*dO84HxC1$ri*X#vcp zD=DqDQ2j>S5uE$8KE|#x5{W+n$XQ|(>5gl?fzvg1<$ccq_10gZ?)Z&d5)OEJMtaqX zCt64fqh49P3JdVi=ITAsif74}+B;aSp67gX2?GTzDzr#CuczM@zHm=qpdS9?Y~n(W z{==!<^mM=vAo~CMWQAF9-S{FA5~0i(3YChS`uAY^hl zlDkLp-gmVd#s_*b5F)jeL>pLikHPkx!|FbLM zGz&myEB6@I6vl~?OWNF8xzc&EV7I{i$yutT8O{NHe%pfevf}EU*I>G)MsmH2+W%qf zEyJ?h!Z&YPy1QFIKtfu&OQgHIJC#zpyOEOaR=Na4K)R$$I;4Bnqx(Pa%zS#k?ZadH zc*nZey5c;4*Dg&<`Sak4qY#Bzgn!~ja+@Df1p0#$U%PEJSbOWZNSI;e3Y0qiE|BSv zH~XWdp@|#ExGlhvvS(gDGA9Y}hu*osR0)k8M0f_D@AH-8i`zc=)$0BhG^5!ChlY8)UXfcbsg*B)SVV)X@4kOG zxm)Gm%QqP0?{S|4lM9@!_hv2r8X!71>}t1w^0Sg;))YkIX-~%-k+l;huz}EzCHkeL ztjm~J$Od#@K!-Kq^5CsBvbI1#l|?EQuvu7rI{30h@Y$k1!$uxVCq)ZA=?AI_+`1m6 z&>b>d{lTgyN4%Z>>~xezESDr-3L%LNAFg@6x)?pkSIuh=T|*jRkoc8Mh?<0$IAW1V ze-4Q{AINki$+Ycnfm;dK3S3d`O_F!SJTB_!PC%fjYB|z~^r`7+^C{W&cz-%%dkJw&ui3vxM> zx{m4zIBCiy&8Cr?D4-x-KePODbi-6Sk>@gz- z;y0Sxsr{?RHUMB0e{}Kyuq`o^y|kf&TqgnvbOrQ(7Cxw(I9GMboEo~yIX$-m9l0J>~E zHmeLUy?6ViVcRvkcb(bka+`Nz-vl?LkN?u+U@ix^p3c5aG=bq)cMNQllzzY~^i$aX z(oQI#Af>5v)f_m6G?l;t>C9k88~BM=rGeChxgX zD6~tE_Tr~e@VKT_NZZU*rgpkC5`Xs_OhS@Pr0WHBGW=Jij|H_Rd~32!Y*k+ReXOx$ z&_c6Sr02fpCCRsfl2+qX!OYoYt?s=(ZaWLvfy&hWtY3}=qELxAO%?~&u1nubs#Pja z4vpzn-+(rq$?`A}gJCI$ToMzSDb+rR?k>TgvC$>}X?Lb?MeepDn!Y^tlzy zlSB1UFtlsi7kt?1{d9w$Xnr4V+45Kn#|t#4E6)9{Wg(gA)NXTHE{*&>I#ZJmkN$lh zjCzvjK1dgV4j}Km>TVh#885ScHk=_89A40<+f)Ele-((W7nvX=V2mN<4`{nVULC-u z>lG6-py6d0+&!07GW+of_AVx;t^-Tsz_>SKnsBQ1c>=vsOpJbO=poq6=;TwKfs@2a zk3z`T{M)kmB>TTFmVZv~mP!&Ee_TFQ2-e}&D|BnbuGu$MsuAv|TD&S!beA$*oFFwD4i&=x4MMaPHw z!V`_oMgOG$Uct3LLvPBasw+nPw+o>h1LvhUjE>r2D z?>vv~U{-_h4qUXTg8>!;Xw>lYlVdvh)@Pl>}v=HsMZ6ejU#&Zyi*AE8fy?rGbWL43f11u{621G=1 zfJe+`ch1{zyr`}VYh1)d{;O&I${|||cjhf*Qf)HHxsOj`y2$%?Q6M>dCzrxJ?|bDN zTDLirn0lqGI9;q+h$9uPbpbj|+!}1d#=*?S)}*Al{>4Azuz9CzcFcs4aomVqZX2mljwYIGScSh;l@5n)+@7p$^ZhbHCWtjG@LNiz9=IN>q8`dpm zELpeMllN@6(Yh|bhrc9$?LI@i9y^KqqfGen0?l^0W*JzZ9q{TZ3K?=dyr|@x5o7LL z3FT*7JdWe>D4QG;LrUzx=+WTs(a}*1lIFX#5sx23hZeQ&-tf!_{MG&kLf3~Pf&c5d zsEa9$sn_HH$wisENilxb*F95=u*zl2qbO^vTXtLm$3SvRa{p$UBMYVVp5l=$m z4Rv|+M+BI)))oW}m!hM?{smFgqfo&@TxF`TnXfN*9zTEBS)^03S*+k=Qme|x#PU_w zZ8IVV<(Y85kD+@%oRTYnzHzjwFistTFg5Pt-8tf!#hZacT^=U@c`z3o)WL=hha!Nn zp7<;ZhCC`aiAkaoUp}9$*C`o>#2ie7?6PghA_?lWF!*8fBHi-Kz7Ket>EE&2;X3(JhJyJWrXFzOhbDf4cOL%~U2p$|jY<^(Yu3x|0 zFj8Nrm!yF>7VJ=IsKaORC)F>hu&&9W$svBVHx-auOrxgy)hk^Sv&<9sM1Z_YnRaENkv-AAtMPTyW^W2i?)pVJtlB`Tr6a;*wEncV1-DzD~Za{X!($hA+-Q z5{IY-I0=7m?juNb5Yl7~iZ?p{KN17OzVb{0q72*@OkgMw)dEsc=i9Pa))`8S>P(kG z$;u!0T9o_*p$T--5x%c|h*&t6(094NVRls?}f&hH~Q-=+7xB#>Q6w z8!0^m9gx9&(f|tU9Mmsp>R!9b&m<$xq5iX%X7vNVDo2-@ga*+Yuwc3z5E-^qTZtHG z>h{E6(je}cGU+BEo=}3TIHWSOO?nBtB8`Fd(%}8}1dxWr@hcLe-nhQ9dD6=nP?U6c zwUGH(VA&o=W!i7Ge|$O@n90*NUcS|miMcK|bY96Po&V7SK>xM;Iw${Mmfx|;I0zNtFe~&z!w4ekUuM_`!!jE_7J?*WYX{(2GmCqJD4x);cKCs(~2rst3duAx) zF9qx%rd?xePA)DVsHj`*ClRuEec*sa^+O`g+_A;*6Uo&P82!`4qHUPn@)WXidmb53 z2%r~^&h|?!cmV$@2`ukD(Z$~CVBi5WAVC1Ax$<-=katp1QbK_y$G0Z?U!aBNj!MKq zJ0vvN1jI8v0|TMHUzs$kNCBncBakzd*(BO22m_FnI@C5-3{pQD!FaT|3J*!AZ9 zUrO!MN5B0%7Yl-L0OImTm?c)P_4wg&&u2ziHeC{&a>ySLeNCC&jJ;PkljDIc__DwctaIc-PKWfKvoS@O&D>)72P?{)9o!uti- ze`xA}m;5H`Zsr56!E|mC8uVX#@&Hmr{?vzOS4Vm7lTr#!j|;6Y_w&7bZ-M`s8;tzy ze^jKnM8c-+8WkUi8=-@L-CiX*=vgSEJ9=Ik&-E_mq#?-DaV1|aI)hNaTL+GcWc7)# z;Q1TlLx>Lpoh4&^@j_Q+&;`9}rd;}4F|hDU9xgPFR~rhu9WRpsS?Ug;V>m3e1cRun z0L&^P6!du&IfVg4n*tsOMutt`!-)lma4J9tW&R)+uX!tgcYBriTuF;I<*^g!)gX+G z$fGL|`F$`#M?;hRKWQ96v}UqyKthupZeJI>kSF#;F91Ym|0jo|KJin5Prq2kr#$~e zeNM~KsBxvr*aD3MRX|Ot{U85(qxXw&P3&r$L|JLEl=mHg%?03+pkg7Qd|T{dwXH)C zo$*RMvgweBmR3$&Y?%^RBo&g#h5dfqC^PT-Z5TjqAAQWpZ*<=1 zlf<_CjVLzrR7(L#0rnu2%q4+_?(`QzDuZQwH?PLPg27!$EV}q$msTiAU;yk{TCgC@RR$AsqgkonZ4Ke! zzR*C0$cfs#PN{R<{(s>+dQ?fMQRa8ASFjZ4`|e?DCg{|hjxXu%TFi(nUkU< z^_M=AJ;@}Ar7fQ6|HzIxfKJf^Eb*rxk9JzAL(23xun=>T>0E}OzVVs0V=hNBbFEIX zcQ{Wl=rwFXk4c@R*<>-NS3Ak$&oH0Y(-9f*bZMZ*p#a3Vj1_b+A&FA^yX1MobEQDP zPIu}2zHoNGJyeApUUJ}aM#-!Vk0JsLkN;=a)Zg)|T59T))x6|dI`C5PEy*Pj}| zU5Enc{SfI>S)-X!)n}8%O42}&g@ujXL;Z!;C*LS=chSihIf|- zqF{=av5bEh6ZQW3dXLL{h(+j;v@#O(O6p7MsHA7L21nD_X>^1wM=}Zn<0nFqAW)5E zP&tVjl;X7EkWTUEmtTKiGNU{F^E12T)4y-cnkY5S7Zm!22wr}05cHRxUGXuVNQ<9q zen;>tEty7>RUwlz$Nm!eLfw zVt=`CSWAY80`VRyZw{=dy)wne`C<9SCtp0$qGV$MYSQ9o#lG83=lF z{huD~Hgteo>J*G=a0+^OcmO)kZT33b<+f^9Fw_9+FVba;{iZZ9ZzA~b6Z{`6Ng1~z z-KpHVx;=;W>qZepVebVUws-VLEA74cO`y!HP2SL;%)=y9s0J)26!OvE#_H$(b@+%Y9WIG!psT>`A zP@-HgM*=sb6s?E<(qQf?ihq;Fe>6}4(edHjoO(&vX z1unhgpc%kR02arhQ^5NVEdy5|cXT#j^M_vm(SJQa>w>RmrZSToLH-$p<*th2#-@1a zm``gU+h#K=_TQ%?G_rI+2ZkXb?tSr5doOo1CJS>emksDP9UvU8m0^&yVw83VB^u@k zVY@jtyTvc@l9sZ=33DUBOZP((2%m0+H8nNq*}&&ODmn;E23cDRDiOHh%Y(so1#VDv zfhu?m>vGhYLC?1~R5C<>T5mq23gE`;2OUGv$Is-I6f{8nB%yZ!t=QlAwz zw1*9yj0MOKQfBI(RO^@hWF#F#2p8WPXk9qlw zKZWcdJ&ee&MOVK&PT_zas5*<($o<_ zFT#oR#K$ZCeq7awi=0fz(TbY2Ig079?$M)ccf@Sbr?wYYl3b-ul!63 z;v6X`1Di|CY$v|)V8C*6`xkm#F|dsw2e8)1-s{{W1hjy?8#f8B0^^s2B(R$6d32E!8xIlVLBE9Z`sA&EG5h;w|OZL92ycL|6R z|0N^=5i5N!kml-F`g9SXHaaf+F+?sM{Ry-O6Cxd<$F{4A!T4v9uU(x`41W~I5=sQi zj4YV2>jat{x@X*|Zsz6)jC6Q3_MVD12+Dxk=volnTIW3q*M6;QQ_t@PQVKy^#4 zPYWY|W{~D)33wInm&yzbc7azPJ`KzYw9HoNNfq#O9kqEsWV~St(-VTxg3lGl?JPJ^ ztat3;u8%JfG*#2AjVrYDXmRXK==|LaA_g&W>&|p$ACXl+5P+~<+!9k6mvWzZ1~rd= z`v$McY8YZm!*efWgb8T1#V>L|hUxu<_OuMOBs$}ckT_z_d7V}54?6QezRc`ZtLOi5j%i>zCUex zaaOEUz^@Y-Ls>xcWe2gYhA^sdut9VRn=(YkzNXaGtia~%FA!!C6SrAFVgd>Wu|ltf z_W%@dZ_$aVgDcQ2P<(d>uRr=3(u3x6p)7s)tX!8)>AaypBbx2XR}#5omgnG)2+QXy z`1=*LDL>!K+|VCyFWcZ{Ti>-WzH2;^A9H02f;sHe#|FI z(wXM9)PVI|iPW?wIo}tLNi%$v5Mxa{K;{#mR2&@5dj`li>NMhf{an~?yui}%)i!n@ z<_fgNS`U8j>!No(f1yLNW$`j+e|ELQ8wXrL5J+@<@fy*V+!|dMBGPuNfV$3|$E=0> zU;p6Zt^9_m7@)EEj$*{x>(<(zAsj5%K9qa8+kV(<+>_V`stoNg88J)C-&nE<^!Dep z3=GmX1gX!dstu((u!ehBncuzc#Sn&DJU{)uoAqiJvh^VtiLs{EY*yhc=jQmKTx!twZr!N=Qu9E^@(pN zPAhHkg_NI_a@I0S+{G8%+}>3&>qvt8tBi?C^8j61M(mVQc#~1A&=WaiZF`Kf+TPH# zG^$gbCcWU3wpq}gav}ow3_Y|TLvmQF*meEUfbJX;u5C(2*hmBXWUc0mOyuYc1{@2)GPtVOwn~w<4fmJItK|Bp*i- zLWDRl7AV>qQlP18W@yJy&8^h&-jJp-dQqelv2Kn6oKc6u2r*{Rko|UFhO@W(Jt!TJ z!1{gLr^aE><*^s(j}m5ETd0v${j89zg9i)b7s=@6H1Sw(B?pOl_Ph^vnq>~@ahY_Y zB&}uV=$$=Mc77p2eig3mqsNcGP@x@KXSikn8d_mkIX^}J>8*HhKuYf2*uPB<6s8o^ zVpkO{aa>aSJ9!^LCJ~PGOU2yJ#BwU3L@|vmZ!Fhlgb&h`r#ATo`tzWjDo8-OAi!v5 zFK~}>ykc35Hde&K&;P?|#`cobEvWPY8LXk7$e|ANWnwdg^N*gRz(vY{@U4c-bz@>BgX9@;= z)m+`43TTww`!~Lw{a!0ZTWoc``=wp2s9fQNwC`q>uMQqSWZ=4|4FQ!c>VDPX(c11a zVIHf#{DuqSSGW}3BtF1Lht{2<@4Pez{ws^EhZ_8xkNDl=!`+E)Q`=|`iV_I~zU7mh zHV@Xh1x)d>sRuz_f7RQ)z`K-aO6R)^-fH>-I}>eYaj-CdoFpop{cFL;4-y8bJyag&0EqZvuYi zV6Xj1h3iV~lS#ki`UFphkR^sGa4FCi@qk`y+9^4hse66BOwwf!<=OFYqb)nYT~)wH zWqa}fOrvWAFG&}gdG6PP(;UL<*&SN;-he$3E%hjR3sQh}7haqQ+<|6z!7!eU}a@>?QHNohRj%5XSUyAY$26nA1!{11++={Lh9hR{(XQ$&H&UL0@l&c zvjd<&U(0M_(yTt^x?T7Z{s-YeD-oz9j4S~0aT2%M@IDHcHAgvD4SSObb(0y6Hjl(k zomB}oFq0sV#31jB^COO!@OzQAYul5(8-6Hh^&>@V*9`C)W7^)`af3&7W~mfv*aqb6 zc!iJrW`cm!?lO0{{R;BU+BO>}km-_u7ilVWy?JW77C&q9O+MszJH1R?*MfL~%^hG? zsSE_KX0^Rs4sHG4wZqXdwWPmA}DLOIV&Yn_~8YNTh z^wdbW%ay>X??G_AZ$2tc(;j+`x>%zbe8CSOX|3b5k)2!a@a6m%rjY@!ee*+-GVeMXHVO++f&C}pR&M%Uj^Ixr}L4BJjj!xJ=s zq#4Lv%9m=f#yS4(7kxylT|zfN4;7Wp!43!NCs6+WpXGCwslaH?I}NYRWMIB-w&QY$ z7}l;w{w)j+^R@SMyx;htn#pUG3*(7mf8E44)(ds*U$eCNy$QYdt4ZMMLDDwOx~C9E z@ART#U^uaTW8d2kQN6wBF3q;coT>gOCK+MeH+QPV>*?`dOtbmQ^bg#@N;`@!z^HocWUjk&-CWuQXMw^@|*k$`WfDq)ZiI4`X|zASt@11@L#MI|G_)m zJu9w3`8~=X?0fS8@c7rOZ~T9)$XEx){rjZE-arqymszI9%(eNkfU0CjpKQYF^bdl7 zM4TOk@4dyNG^+S56>qZsoXa|0Tf_SKby&@PCK9mEeH)*@&THL+1H{d!atWKAG`63@ zci0WYWosfTV_VjZnw7lHICuiy;Wy#lIEP|^4+|*1Q@18n3|RCD#r9()>_5G~Q4g8z z7%_j7jhZ9mCjt2LqA-^rp3H52t$8f;!FA&ECD)rvR_1@IjGDk@GI|?5Z^Ah`H}f81Y9-Rg6%$uSzikA$txS|rIPl=KXiz`tJ+IefB%jT18f3 zWcRtE!TAE#;%;72fBMG&DkfVmZXo#P_4<=lRZt69O}81`HP$q5a$aNkEr{I6C65Hk zlvn~0PvFLPEFM2h3!gsqx0?Ry_eU`G&xHFke-o*{T3!pTU{B#`tM2tZqxbksKxQtT zKhvm;WA;5oQF74!R}vMIeEX19g?+8()bd~ao^&=H%pDmYQ~7VJ128;f&KedV2f=se zWc$H}%NFIe&wUlxt-sHxwj6x4E#70$9x+p2($L}(#7$K~ml}*rXS$&TY;6&PR+pRd zlBG5*w?4A1DHY@o#^Pmi*ShUQ8zmJ{{W@e0^U%MSpD~(kC1GJhi>qnGj?ekFdV<|; zru2B|4N1vtZeHBR=MYsUyOeRD11BqH+p~nFi1o-K7X6C?7CMuKNA6!rOOWlyylN*LYxPK5S(QdZ%;Jfj^ zDJ|55=TGR_ zB3?ipjD5YHgMl22*A*;|f^||7iu@6f$l)omG2Y`fX2uCPE<2{a{6Yn~IQR`V^97o$ z-hov$9lO3{qJhxB;V)ri1O-_09|7i|Cag|=p+P2*n@pf^tr!_kxrXhKDoK($7yaMb z=TG~gaS#S|NGRlU0@YTnFX?0i1p^C1xVt@+OaiFjW}h>;_4%b*Zjf=s3T>w2Xe?P< zwG^recBiQ&4LB`^hJ$Ryo9SiJkn8~YrU}!UjbeL z0U8j!$)vLCU*|wWL!Vq-g|7Wftu`>esoV0pJ!dSNEse}@-^&NeBPJ^9OlP%!yCqoA z{8x7Xra0H?`t0)P97@rIdI{7++3L6+sd~5`frqIFOtD_W{ei@_{}=^k8h0-YXe^zd z_RmBcP9p5UhWEXAFdHn{?sv|{D1qNT!!$r$+LZb;70TvkkAR{u{k zg>7}VQ$|Hq=o5io^R+cR+QRPPbK1yyH|s}1u__2mV*Yo-KK_^5?<&cD^*Pw@)@@0~ z=pc*ABuv$mMl+HOEfT55CqLFW%s~Nsg-DUsbrCpOjX_Ig?yD=oC!u#C9pWKK@4m7J z&SA!F%xVwzhp?@fa>LLXJ&|*fTB{U?)T^ok^EW0ax+mKI1RncZ*WYilUb`b73tt;= z28A=I7gZei`MLMvI6B7L2TYhh+~43tFTPsml71t;gYYrk8jI|^v)8x+FNx7cv}~rG zQR)!&7YTvzL;WN?82tvQBF%fXWNz7%>cycl*PwdRbdUtg;gm+X zwtV74t~1Wu!h+n9zg4HuiSN-z5QXGw)ehEHs=c)UWM_acVFE_uJQO+zBFzd%gv3VV z3XF$Wv16cBAiQzR2W55R!MMD^Bo+bskt~*Zp^iAD!P9+avm0;a*MY(HmL`Wocxmju z{KQ-l{AhUX6D>|_FoVuE;z)zL%dMVD1U7H;C6JO{8j^#yS+vV2C_<|J?V<3! z;y`S#Gr*JsYVzTjA0gbb$V575`P$lr>p7(yA!E=gSU*guujd4l62rl)YH8vp(BK4o zG}xG|cEr}C5U5K%_~4B;a`(`qbV#j^Yf4p~e}>&N+0%oT9UNcIpqXVlmMco#mJ3(~Vo5{ao0*2%kLIBFeggmu@F?*!sg+fY$>^f@oa;f-^;KLPCY1r>>UaoFSpTC1=$%wSOWrs!!L6J0 zm@{2w%T?gY&ANZ$)O@`W?HY)CDRPrRl{N*Q{yKp|M_`X;@OFuy&q)Agqx;_Uz{HKK z^U>nG6B%T#CP0ly09Zxv^(%?2irCm#%x?g333qrhk;(P|ctL!_H=cZTTf>b0F7HF9 z|7|WhBc^$u!Y9^Bh=IA|aBrIWQLb~`@a8;KFkESYFU%H@C$ccV@wsmI0Zdx2yC<03 z=yo&#U-;tz;i~XyuLr^AtImmozFuI)njas!QbJr|qy+{7#}?G0Pr9*`6ycu%Por^} z+_&0Yg^VZ5slgRkY%eN*bajagr=9aOIW7bMp+*R&BWP~rI+X$AVxE(6@4)Hg=rNe~ z&^KxahP_aS&tu`x=N~w(@n9zP#~!j%{9B7RGvU&Y@PRJDXB#`?G=quHnP>MwqgsT$ z8hFw0jNw8YAYDb!!tE0c8BXWiZ|MXR;j_xWuHHUKhma@SabiM)3$BYRM2U5T9VLU( z`vvpu1<9Zer<#a0x&&zpd%Iau(iF$e92eomV!D~#L#_0=_mXah}=UF35WCnQCeg-2t;lDiUL8NmV||)udG}(CiR%q|YCzTC*Tks|ogN z0%}eWCrV}BfngUxT17PCiVW{OwVX8=BsiO7?*o~6m7hh4Tb5CKa*r-NLLUx z2BIew57&A_t(IE2MstK6>vmNvK7t)A+zHm6M92gVQy2?)-0V06yeO?9S2!=G@virs zVKiHtKbcG>7K0i(gC2ejryBvQ^FKZakv*uHYTl!2zyjm{xonZ+lpYC=Kb1SI!n`X zy2;z7g`#~J-Q6AVJDsgNjf3lLK5`sCY#bv&9J>c!nE4s%J@jjTxa-$&LLIQ96mZyd zGkskNgQL2yZ&|emhoq{CNJ{1c@ABb6t25K}FJK9~&gQ+h`G28LD!u`lw;R#oOR0z8c2ePFC3bf+hA?cP<&gr$|ld8?+# zwjS|mRykW-D?=RpN4E)Pv%-3nwPdar$pabFERZ_JxxWopW`hXCQ=EeY5R&-On zh@+BUB!|VXbKWRkZ7t>fQQG^g7R)QjPF8L==9tCJ_nDk&u;!d9nZ>*yd2(Q__tFc- z`Wsd;*_~F5XN5h(#%I+FdwRUXK*48b6Nmvak33+2-C0m~)e6C2Nb{AYCIYX3NPkf7 zoA`%RI2PXr)TyuqxbRo4jYVFt-C`Pha-~Q7sVhUPgKOd~um)-e0qDb03NNij!%@u(LtRpGQp&b89$(h$TV=e7?NgP~PFfxROF}Zy#*EYwmWSumHz5nfo zv+y3x-!-bfa;uZ{4d`VDGSrQ0^^$DIW@ib2+f#M;HE=k`0_a!Zn|qawG}dp-(QFbi zy_{ij5BFGv3lca?BGDdiC;4qAT-8xWoZnjso{SP$m3mv?0?7uGgDk4?*h%2S+a@#T z^MiR+H|gZYl7L~*|3@8f7ro16Mo1!((q%S2TKsy_=1zc96!MeDc1-ju=YQu@nT0mX z&#~V^LRNae;D4SojY7m(z+Nm444rtKYhY|~1jiT9RiBvO1nQ8>9NuVD^q7d+NgcF! zu(+7aiVvX@=$|npcK2p>O`HbkD&o$sTsT)2 zL-B%ziLuo=Mv5{~tEj=Cn@XgWs;fY^t4Xr!$}!`k@x=mc@Q)w59{6>Cyh>O1o?cg* z?&SEdE;N?+EC4xDU{3=K3=FQV?*}-fAm7ss+d3~?Mit!pC;bqZU{pg6&05U48ixPk zTq+Kdz$Qf!Q=`s~TsP(^>W&_sOk-tKQufV24x(Yh2ErFJjv5K+8rG5EUeu`MiI9rU z;kHTP&>hEu@L%nt4IeynBh5n+&TY^({RPeG0j<_$bD*{=T>DW$KL^%fxo9DW@;waPE}Cs1p@ z`y4*j>}Y6w3ou?1vd3WR5t9d#V#X^hct^h{{lrS6+bE*zOPrm>f>aZGi(Uoszvr=S z2G^Q0H6|i5vRHZl#j0~cU8}VYSb5)e7xccmFviRRsBa&mE>mK;eKM0~f05v`k%ZqG z&H=+#khcZy(A&)N9Nyvxy_4qd(AKJvg(CW-?LBH*9h%pb3Qi0gRl_;^;aF7`Oq2n- z!V%J|9XMR(0`{9ekJVk+Ts<_SyUwnfU}IHn4vqRq-NL|gbt9@5*~xNMq`=OhmsOUl z`g}w;-?%p<4;%$nGZpD3mLigt4$E!fPY=ibJ^gg@DXUZSXKDNb6>Kpk(U!eYa{q2? zs&IAGUvhPKW58;>gtd)A5IYsjDMAF+{T`sv#6^i-Hv=gq3%Z~3j|8q4O%%Kl3hCwt zL5vWHL2WE8j+)>JWgvh7M^M(IwB@X z5{&9k`91LIE;b%!&mG4PMys+=qYUzPMG@!6R*EkW_L?wo>YjT-DILL@m%|FA1QFCd z#lZi;h0P+7H|$FbX0{K%fku>f3lx2_bTMa^fVqQ7mT-`ax8Q? z@1(7pGA^xu5_~7|^4xd%L$Fq@u{^2ImM%+RfJ7&2(U8~-wuWA?>lZLD@R6Jy6+{d; zGdTjDZ%E~M-(6OiSgKa(@r`|70EGf%5|HGC)~Xg2NR&=QG|m&FCa#@yz~rjIoBk{9 zzd6+l=!DvrrTT?1i<-={>cdRR`EVu7hhTTFv76^`4$~m%muv#yIW04^T)lY^wnij- zm(B{@B*y)d3?9fq9#?0&%EVKfTf?X73Cj#gZshDLerK9-T2B4C>Sv z_1f>=x=_5&GsliwgA^gQX>#wiD|I?>m#-X5$Hf#YDEL&Al$78Q5SV6mFL%bp4?KWE zR)21>T^eJqP&Tv$jhK-FSii18Y#HYq$0lX&cxKxf3KyA^_vCmxyYqya z(X{-4b%*?r-H(VK&K5&RbN{f=A(sC|<4T+M9aqzUm1EL|EpJdJEcYYd1HeH2N08EIX6v}@S^!j;SrQ-p?zDMAbE8d!yLV~y-j~q&}#wG3d}r{q&hZ$ ztjXtJuh6bHva}3cXta&ALmr4B!+AJfXq2BTwNEbB_*S&^%;d}vZN*d+PHRwS%tU8T zErm5J7B_r-KLYjb0VAI%^1Wu+WaZnu4>FyFEKwrdKeM<{WD~~d@p)Xaa)tfcD107J zk&uuC!mn&iC*n^cPEL8C3gTbgIDJH%cjx2^ZEcBycEJrJ!ymG$Iigs77f=*rzq}rx z`*P%{no1`vj*eZf$3WQmOd1LU>YkQGGDGFK_3`JzPwa286rCm@FW;D-o9mjHkvhQr zwXhKJF|Os9yQwDp+VQs2*1xG1$&+FUr?h&^*4{4vF{;Vx{1{7k@+qG%M<~+lC8Jx5 zMne1AZz2yRGX%5hl<=7j276?4%J0i#LL^geA-^y7m5&B3etseYkQ9Q}YTCD;{u1r~ zc=HTY9=d_{JSeVGxd1`t9vut?1T@Rsq>r2XB}SrvTKVk)7eVC4;B4zhDA031lk6d6sFpB?G$|gMtHB@@~#ZGpihx8?900|Vs=4$q4uYnCT zjlFaV6L%Cm+iSHNdgP_}LL4i9nJlJId>J_N;UD>t@pL(I`N=`QK_M@Q(ta#AFieq- zO2BfIv{qF<4+gj_F)b#%p|IPMIwFIZ?K$2GPwiZRuR6l1rX#1^v6%l!8(k>4clwco)VmvFp`-yut(+2;`=F$~;s2I&4a!y97gXmRx=1Kc!%3 zoFu66^}S(Kzs>)s4cNWSn04nK;Xw!BqTIw%`VIj=NP?aSy9#jhT9ynBk3nS%*qBsa zvql%=)$uqiVhXg;Cdm%kh+{FxcY~6TsQ1@W=NTANVY8XnF<`wZ zY0r(%HjpDnG#ve<{m%ZMUwHyGI?(0WxG3{ATZd>yHdY>?-Pjsn*M^hn?=Uku{!~(x zDxFgI?5%?Ex6%ZqLd_=-k>Ix$xOLfV52q6fcvP@9bbb3Kd5|&T3mQz=#t7hKz^|=U z31Yt3`u+R4iScuVv;aWEj~Ig~TbA^VlPOx#_l3kQj!P|ca9muYG`0uD5`qu%=ldpN zS$^_@`!>ocLht^-jSx?Dn>sqOzvMR(sm|ZQ3{c5t{T;8qO~cT;wgvC>L{M);MM8x7 z4ZAa?2GktrL^v+#o>VBiq5O(B5OU8Y@WNwn=iE(jv+bq8 z?bG}-wZIVKLc2V|Dj*zr;a~zXtX|ORVRL6{cMg^zsy|tw)9P?YT(@iF{s^io^@#Hi zwt;JrT;i&JT4j%o_VGphnzzHmRKI*E<2pZYXN2+uYQqLNmj_M}Mb&39n??6d54WeV zBw>Jn56dr!_|PvwxKh15C7mVm_4~dug9e-fy%G4|uN@gjHMj@Q;reW z^_;G_@mVG|6AIftBsFaT4S9sAgmp8+QRJHqmHmmr7s$9TAVvYEQ4L#88IS|E#!VB8 zOt-7d*{aw`(wPy&!t_@mB&gWK(7Xcug8-XH)=-SSSdMRz`I;}w*ib9eFnB41D|_uM zDgx1(c#Gq6VtF|Bc%HR+132}=zEpz>^B$^;lUrZ6s%5;;$D#;yo->f*pXAq0c;&(0 z3e@Sq#yG+{WWnCPnM9obNfGFE8jwp#c_Q$(8uqR9ng5BDHF$0xrWdn6@%j)RMdGin zcGrA>X%LX7>jEAXJBKs34OMSFeg&!4nuYCk6|$u}bP@CCmfz;Shor@VV#ui2%=`U5 z8rTx`EuIP&PlDRosU6xwAQfddMM!?9DODiH1wg|+U^E9KYRmf@ju!Xh!1jUO^>@j} zrS3XZ18a=ZgAUJ*44{1;$-u5XfTG2VZ8d?zpgqkS2?AYSJ-jj7#y^ElJFBAlUfp0E zjY#$B#gEC1Ukdy(QkLipIm}$xO+cp9wKhpGY)>3pjrp&z7tEZ|m?63b|FVt3ZHm2sx*2e&##+J)qZ4F6U`lho@?1cq>jBhP;K8u%)IH~=k{@VfAAhomoepeKZL zgIhX2Dysh6GOH6SrM+vBTj%cDh4j;Wu-#iqkT2F88X9e4Q>xz%id~Ml8eJOY8obMG zi$~wBiEG?>vZL0xq4t-%F)l4j{YnxXJD$n2K$+_SsaGG*qr8jF=4iHl-SGkOZwm*n z!D+^3C0?R*_x*;GleltYH5$>Ok* z2w3uwar&3d7M{sEpfHv4TI3-{r0IQze*xjV5(N~At|D?(pKRcYjcpaXhAEXTb6v#F z)zoQ$7QZ7pIM+YdTLQG#IwcDk6sM*wD6kGTH`_?UU%ncd|4RHw$P0&8PRJP5ua~&x zydm*7wIg{x^}aq2kvEwoJX@fN#{coYg|(DgK|};guqE@bGS!waOH_D-Og_1Fw~ z%$W*h{JT}N9aMRklI6GXP;eP?Nm_KTj!F!gsdCD=E=>B^K9PnFh;b)Dp=bvSe(Ps{ zZepNK9BiwRxP_B~kyS}*z&crwzo)FbQ=3(R&n)FUFPpI zFYkeGGD`$Bjp$2zoyaNX1u18J2&N5G?t{-bHSVrY3lQz+YK&n^pguH>AColO^?ume zj%4AS$zsVfLj`S%|13tT5~(2kxk;LgA!IM}=7x zj}Lj`q4}WVy=G#v+W|^{rUyy84Ejxa3QH%yHU3NP3o*hIiXU`q#paqlF?KFGnv@;1 zvD1@KYJqzXfye*%Xf}WC!78T4X26>}Zu=Qha?RIhLiCYN3PjC>$;Ssxaa=$3QLmU-{7 zGX@R%bRWyh%MXy0$AclEj1xTew*`}D3o#7zDJl+?E487XCjW1f?n1vT|B9kL$aUHg zlmZCW>p=xYY?#|-N=8FgcSxqjbO0S#(hxp<`Xq)cjXMk~0=?^W^0jAU?9(T88sc03 zE~@gP6DNb`+we?@7CMMtLL(#PX>n=AK-h;kL@(ml!O-^tv^SmH+@^fn-``-LUx0M; zJrJmZ1011!yiBziU8lh+klUU*Rio&!gm`_tp5TAg_1%F~zG4689LG2!GkYB?D zPWDPx$flx5MyQlyWfTrFlGQ+FW|DQx4w59RzGas}5tT&V>(Tpr|NHe%@$}r!J+Aw@ zukpFBkDB;cj-U>A&|W6IA2pDblTRkPyib)oE_;h#QT+8ZB&j-}M0Jcr5xe5;yQJI; zaHFz^Q_Z!qORU6Hp*na6F2KcZip80#F^W7%ePGb>=d&UpkX~)DePlU}M9}RiwWN$4 zqQf+!2+hx~bVDYEOJCS;OW8`jW{@=JSQu&xTu9vXbOkbP@E?FB9XDm7ohfx=c!!Z! z`PTQ!1N^V~4|_=j&JW()HJ@WY#jz>D?Ps#L4Idn$3iXx1-261yn;60A-n;SnVo=b{ z+IKsDenk_|UE&@Y-U1vhj)fxCttw2gL^kIKcsX~a>{G7`pJuDxo=N;Qe*p{M`# z0D{ajHKfz{f!)vEbYZ!w$;O_B_|PgRumPUw&U!ZZN?K%wm;RbN$fX^E;nbLpvrzt* z?(uC2k5;4G)V!96dvIfJG)QnJ7^4)4KoK@roVf39uEXMB}j z`#r$<=Fv(b&n2fO?^}nn>}~D(5R8FsL%nZBCVAA9Ghmaf&G%m;?a{Uf!{__?NwUuB z&fyghnd3cExbqk4p;ufCH3XQsJMP<%8D5^vQtRs zGT&WQ#s<+&MSZIiR&e26r*>GjE_xR9XD#TZub`r|ty93KG4tFXs2CLX6k-=|q~g{0YajIusa~YUR-lCfIn9W;n17j{@9ACG zY+@O*TncoPgO5huGG=@!&50A8%i86Smp*9JU6m1hZG*Fhq=eG@8ikWbbJT7#$fL#G zVzCV3UXjHY&$VF9ca413xJrIpzVSS?46~|Cc5>*vNB$b*tBN%WxGuo6pDkxSWnjmW2S?Is=?>I zehyl&R6=spoq9L%$1>NsT7_$3&X0x~lR4e&M+E7%+$n5+GG3#Dc;H;`BRk9Z{y}ihPCIPQj%7;X9B2_dkAAh`6g*` zGB(UGvj7c$SHu%LVr6RTjyF;ta9-+cs9acn@ls$prBSYVdZ&Dl=DMamg@;Qb^f|U7 z4>5i$r@)WgTxC+HXXvKr0_Ow__`CLhH88WzeF%hw z+k1;Y_3)r;bxlBw_1wI~Nvdj7zrrfd;lc6};`6WHRO^N~`mA!Jr{49Uem=inGV!64 zp!{ThQybRc{=eB-h>Hi|v)#*+Y7Vy;-G0bvH8E~5o$`X>Duw=@VHB^a4~`#$=53eI zAwjf@3Q;4gefBTosfm)N$dv0EYbzt+D8VF^Rk^EY@aJh3HAUH~z5eY~)9b8Z7tF}? zh?p33r}FC9P86Ms5uB11zLqJcb1~5CrC&-gcVsdl?XW%!XHI6<=Dajl7ul{)YuNw(Sc*$cpx8zVH1*&z(+;T;ayq; ztLq}DBH9@?xo@M^W@&}5Zwtm}O-nP+-t6vknQr~YjbcKFp`X%{hho-AhuQZzNZ#J$ zfk_mFd4tHWNpC;rVYGOm9@snl)F-^e6SBlI;L8e0;9c&{%TP`Q61 zf1C0&Lwg3|2lfwb2^i%ou*oA*J(L~NUF&bkT)T!+?G5}J&I`hCHhmHsg=$H}rIg0X zXtZS8#T-40F%we8imJ6t1pCfkmC>c}aI=ICfihy-R6<+tb8y)QQvyHHiI54I1vJPH zI(&51q$DcMwhZV9(oY$$IXaR*v_yV}1E=cik57{@05=zSFgQ?P2oH1?Aa+|8j-CvM z25iiUu@KxyNkZQE4<6)Pr4B!!sW+yDG{hAY0p&zR!szTr;ZXn4SQ@li zdk*Y1TP#KhI$?fyLb5G4qN;o}TjRMzfsifbE zIvHrA+QHy8Ie_|Q<1WwZGl~ewkM9Eon)@7nzu4RlGhLCaLw)k(xF49pgN0*v&i17( z6s4pbx7)91xkT@n@?OTP@@ysJVe9`BD7OwQ0|zV4h@I2-VMM? zC*ZmVGNi|k$;mzT6&DyU>~d}jq`q+BLL8J5a2hD&4?1f`CPT-0xE>LjJ@MtQV%_Ut z=T@s??m%AfO83Z!VnNQ?pWv*M0F>wYM~{y3+uhu$_-&!Y8ZY4mXz|3Rpii?flXN(o zE(FQty(r8GhP(Jx6!0n}TrHh^_s;Y0_HPN7mZzK702&0G3)I*DZYKbA%MA)xR@dOJ zjP-T07KR9%+C|rC6ylCbU4N zMHuqT_U+fTdXxEYf)uz1H3}7fP1VS%1l5OhD$B*A@ltMQZ{7B za0J)MSv`zrZZ9%5-1X?PC;Wt(4hS!CGM`!$gr4aGj)%OPoWw<%?^--5I2!RGMw+Af zo;{^EnRluOQN1KnSRW^ka4oiYDv+3WPm2|w_)p1(oY)0YTKs>?z7s_7>2V5|XI?Kb zUu|YTA;5KIW07UC{K}6nojiaFLTbtYvQG~d3t@O>5YV&_H_*tgr!vK4Vee&SWo?E- zu^)B3=`Wbg{YvNFFwf@bAt`2(lC&5VZtA+2mthi**cRYt?r!ENo=s*RuJ5|CHmkqp zFQ^^PtTA+C2#}I-OJM+bSsZ^{!2AiCUI8Yw+0ZX>s>9y^26*7t2n3? zgQKb6r*&n+Tfvp5+^r+ZLl&~k2CY|qbmzu%uKfX0&YmCL1zX~HS0D?7e$YTVqZbK4 ze%dgkS~-_K@pZJE6S63@{b$YBg+D-{P!5d`8-}=*w7ZTJtA!jj6wmXS$@x=zn(dq@ zXwjtdUgh#kVjJmw(*-ev$QZ)tbFk}YJ{|#>Z@uq(@rPlhvtO z%^`j@4-np@;ZKm?S%vB)P~?*LnF0hbFjtZuzfTogQ@=Q^gVzq4r{G;?tS8V3Mv{cF z@$r+lbY~S#+}HDX9tyZxRRjxrcSA9$0O;3(A%Zfo)xNtU=QJ+LWlVYfH4tdSdy*9f z^_!1Xw2NGY9Cp3`lmPr8bf=MhKeRM4Kk6oiR3K?O>?f#&7qI@(9Lm0!&`rWl-p67# z4k;cs0KBqlbcn_nXQwS5j9mKiMJgnQii)boLM_38wa1HdQ0#cV=By*@bocu66v0Kn z^<-yVQ>RTobEt4a-XFzxs>pGid;>8%OksC;;*$M6F*SJR6O=T!`|pplIzq^t-6IhT z;j6A!EvL&#tUZYa(q2ceZN&gmMA*GE#v@KiHwyd6p{|(u<`!hc4ksM-yD8x%@-nvj z6i_DXIZ5@bd}(xDp}_qno0h*NS3z-$gk<}aq{qk%rgR@t+RCeP0Q!yFbVnop%6u)6 zV{Jf4?!w$44W&Td-Ck!pe7gVP(%g@wal)&Rum3qLKTIPR8Q^(E3qPk&v(}1ky=yps z@NZt&u&hE4*ZDgnu97B*ah;-bKL>OI2bwz%oqEFlYs9H^So0C92k4{Ny!w2&3ZeKr zS!VJ(j(A4!?w*B2zJgd0`3)cMND)C?c3SO}Se`$FI1s+y+t42f3p`W~7r(;f(6{pV zL*A14_*Pt#H)jp7XL!VA@Zg-@Mn0T)=Zc$zi?TOKRF#`z_`64ZtO6FLj@?*ou)5D$ z%YOG=y^nQGv)SWc#%?23vN(Izsy8YLCn`y!yUyoF;B3-wkxS6`dvJ35i1xLE_^PDT zJb8D_*Qs{7{S`jE38t*?*)Qm>kwv{5gl~5%{4ghE?r$H~Y#?O%7CAdsl8X54ax_+~ zKJ{mqZma<*A^r-I!wd28ad~TiXG4Rpp7n99i?-T07xm%e8Zlc*Sm~Z} zt6ub#!Q&bU0M#7BX*^S&O_pi3Dw(jR7ZXx2(#VG_Y46(HbCJqZHiS$MOHdQiSjCBxrEH ztdHMFU?Y9M4VM)3#V&iGf*CA4aVmQT)Foyxp!kHtma(NOOqEv8%1Sy z(l1`R6#TjI@79tW;3D;x`M#)piF3I*@=`WI=j}q##7p3;fyBREsE^G(YIa3aVba%o zn?pz;G@uqui&;jH+J(hxmc`ga5$9U31fxNHpN@yil-D|d8fA7BHTv9e9paX;EA?Gw z_vqiqDZND(TT*Y`+V3!&>%(WlZGZ{eT%DPKJ1*IZIOm+ZKdVogY1E2(xweEw!bYMC zu&8MU2r;XfWUwxbl!I>TK{Qq)R_@h)*?>CZ?CyEO7&X}yb>#W(Q!od?h=~T9Z^`~_ zei4E0qAoU8CMI=I{?Zs`CHM)Z(D6N=i`zW)$FHC^Eg8V}OLU-D?&d-E!wp;IYp3N7 z*&3pP9}ab>%dgAqsa7}q027)`9uKF|UCq?usIkY6Ms!hrzRVmOJm;JAs zaO;%@NQfU&PO>l?k@QG>aMOH_wV~!-@2~7=*qBPsH#Lj<>ADG&*9?6cLrjCx&1mu+ z%K+Yr+Rt@Od*^&M;-(}pN4&%`7{LjOc6U5>vWC6Z@yJWmBN+pI)Fhfj(n1&NF)R~A ztCdETih1ISeamXhl=crvZqce0UyA=6Wc^*~js3a1>Fih8ij7k57q+*z=V9B$Ow4-g zMNFHxOS?Zpgs!{PQ|gzI#uFR49oW9YjbeEhi*s+m)l*K%h>cLMQdZ`bmsSqu>NPS& zYsnStCgDk}bS)c9-hzPES?REg>hO)ejGK7->}UD~7Y~{Djaz!I;9$PBdpqGEE~ z8qlaLjyen6lF#SHDo^S20w2|>Nbw) zRgx!!i?yQV_kr>8GkhcuuoETDqi$lyU6l;Ka!o?cu@#u20Wkf)@qh+axV|%% z;n@r#D6fo;-seyjX|W(HSx=mGE42~Jh;wQQQ$VWl0k?Vr2~(DCA*NiqEE+50UB_YR z99A}LaayAEZ`1AG|9^{1^Nr%+hQoy)Y z0*zCC!`6cr838S9w2y@^Jt5XW#(KdMX(|raet6TC1@w~bz+gHudr8Rb1&mIdL1s@E zq*+p8ooi3ZRpAw0+OTkGSRf9>v(}fBJB~O|rr`Ag+A;ML;w37;UQnX3-HRQFyXn9g z&q$Zv>;Ve@KC-_bEzI{JK5hbh#&NdndoGxr@>uBEjY7(`JqPd>vV~Y?0>_a7=ID%p z;VQ7PvGL;ZM+eQTKV=>QlW!wHVy*QC*WKRjDTfo#|A8UuJ-IoDF$Q0#71UO$?jTW9 zOX~y}EJ46wTvmv9Ajm)vUlR$IB9j1~-_sh%@(P2;lp2s#D~oWoW;Eyh1}4|Y{@PoW z?udKborn5H>CMCcLI$|N3Q2~bpsAM2L5CYGh9TQDH9nrzDdJLgX}_-u(g}!t*i*jO zA%Rx5_!~M^PEG#-IqBPQIMWkGY5GS;M~6m6g2<3MvU@Zb@+1qoBrX&SOZmTXp^6+G zu-NSYpHk8H>fW*R41lYw$pCM83>*S2Yu~?rL8|BZC8}gIdhK;D4YhC5K5FhA;)AJU z%=!5E3}|U-HO^2-GhLAQ;=gSm6!h^$8@Lgsn~)nB(AL(rMmbsKB>6HS{Uwv2zdUK; zzrYC~Dlr?OgtSm%TSG#qHWZ=49UqpwH)~?*9xex9(%>WT&R$T2StN3oN`>`IO_o5* Q3l#h@*0<2BBDh8U537m3g8%>k literal 45910 zcmce;Wl$VZ*Di_%clR*3LvSa!1r6>N+}$BT1`iHF6EwlyCAbqHxH}V^!Sy!pIp25d z)U8{$&iQe>YKobj-n)0Nz2sTXT0@kYiW~+iDJl#M42FWdv<3_eTn+G2Lw*CC$?Oq% z{Q|{BUe6r{1`Y4^1q<^nn-~V>EsTQn`;R`^N2}gGAFY-K&z+H(B;*gi{xJwq!+*~S zM@sgCT}xW}TlB$5Et+f^{|_ECkY_p~0gf~*4gu4a2H|^s?DvJF-~Q5u>PN}>VQv~{U5Nn!1|b5B0M-0X(J-6I9o ztp$HiK(f|NBX$p=NHm}pi--zewf;$~T|e7$`?G?BA!k(e8Aqn@y=_1Y zs~ApV_D?XIY&-m;S4p2q+wzpF{N@?v*@GzEv%ZqtpgLo_1B2bn&wV48-VEm5Z6%G% zAZn~2cG;~c@9>RrNK!E?dKli8U%?db@1{FiJUdxsJ(d@@`u{$aWX8w8M;*Sw z!h$7w;in+WcnpV_Pk7CxRq|UYm-J zNyr47KpTUx0|jx~N7b6njd&_^0&9r;E%_NH3_-J_SKw9uFn@mT^v`M_vkr1ksJnCTj(yIe)CJK_`7w1S!jkLi)D+&&++j>;vth${v+DR6u$@IH&=D&^@9DHoaWty(8S4<^N+{S zx;V}LNPJqTcOpHE-)Q+2NzCnz_$T~Sg@gWYu}19uv*< zj1d$gh7FHbmJ`$lH}?ydL-kv4$=*#e26WNKjnT+%f6EjE3Rm6+#O`mqkQjueN3UJ& zc!~&hcaO0uj+p&GC6ZnfQh_5GM+!++XJMEsf)cANI&AMcuIcR18jt@7K?L7>5eGkY z@0r)59s1ThOEnQx540waA+mPM;5w3K(O6&YnJvRL$|5^TPL~bM0*ctaY-wHxrmn+l zYtqZR33|ATMfCC_z23+RWE&J73N6i8ai9Zf9K70p)=%j}B+43alNKLM{aEt%&#UjUp`!1|kn8eUj#joRcms@Pe z!On8eakG1Oyp*KBw;bi7qJ{haA#U5EOZb`nbM$qy#CZYhn&pV!CRg;4FAUcXi7zxb z5jwJ>@x^|c-g;U@vWcg@_wZ{mYOxC^pKCfR@*95xsYyNW>}>^sWL_OpbKVN3gv&80 z!eZWx2wP$$@T$Y!44+Bm4%wnpO84gRPa4Xi;7r3*liaxvoaWfmRuUU`p1cTH? zVo`t%2}b^aX{Rn$!7t1-motzY=?|=*ME_-mBVg z+HNk_iud|`I$rE`+$#d-~RTQ(C4Wf!+ zk~LugBER3M*kQeDT7LnDXBHpKa*YArb@ix-p>ABR8G^MS@_QKX)pblzlYT;K$43oz z{p;YA?ZGYI{M}<34$BrRMRUK$prDo|S?R*+pI`zUe)mvEvKfxjUmxiY4QBP{a>NVq zaL`UX_J0W@pY5yoH_0nE+XT(U`)46pb!hCdV?XoSQlgU4P?wrLIh>|h8E~%a(S2LJ zL3Yr^%HFwck#=euVN)Vn=n{Q;`j`M1JK;Yd5S`F{A}cHB7dcRk<)H^qTmjvl--aWie{cTs*h2l;*C zGpQ7DT@F&Mc%1fwekKhR-ZwOT<7uwDeu*|jFESgY%5S-HdetldtxHyhJP0`Ju1_wt zUrUydV*;SS%m)l+a{dZ0NM z(Ot)#i{x#tg+b@`)gQjG#%mtk$=r@Rs-5+&4^%yDy-qTna%D~yU=iG2z+B2Rcw;f) zt*vXcV1j*lJA`|WI#oHXcAX9{Yj~3(i{25Q_q*>C_L$Syw~KK;

RBI8l3$T~NL( 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 Date: Sun, 6 Sep 2015 14:48:33 +0200 Subject: [PATCH 38/48] docs: adjusted for multiple brokers --- README.rst | 2 +- docs/brokers.rst | 95 ++++++++++++++++++++++++++++++++++++++++++++++ docs/cluster.rst | 12 +++--- docs/configure.rst | 69 ++++++++++++++++++++++++++++----- docs/index.rst | 3 +- docs/install.rst | 12 +++--- 6 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 docs/brokers.rst diff --git a/README.rst b/README.rst index dde7c86..b5e64ae 100644 --- a/README.rst +++ b/README.rst @@ -55,7 +55,7 @@ Installation $ python manage.py migrate - Make sure you have a `Redis `__ server running - somewhere + somewhere or configure one of the other `brokers `__. Read the full documentation at `https://django-q.readthedocs.org `__ diff --git a/docs/brokers.rst b/docs/brokers.rst new file mode 100644 index 0000000..e8b487f --- /dev/null +++ b/docs/brokers.rst @@ -0,0 +1,95 @@ +Brokers +======= + +The broker sits between your Django instances and your Django Q cluster instances, accepting and delivering task packages. +Currently we only support Redis and Disque, but support for other brokers is being worked on. +Even though `Disque `__ is still considered Alpha software, it's been gathering a lot of support and test results are positive. + +Clients for `Amazon SQS `__ and `IronMQ `__ are being tested. + + +Redis +----- +The default broker for Django Q clusters is `Redis `__. + +* Atomic +* Does not need separate cache framework for monitoring +* Can use existing `Django-Redis `__ connections through the :ref:`django_redis` setting +* Requires `Redis-py `__ as a client +* Does not support receipts + +Can be configured with :ref:`redis_configuration` configuration settings or :ref:`django_redis`. + + +Disque +------ +Unlike Redis, Disque supports message receipts which make delivery to the cluster workers guaranteed. If a task never produces a failed or successful result, it will automatically be sent to the cluster again for a retry. +You can control the amount of time Disque should wait for completion of a task by configuring the :ref:`retry` setting. + +* Supports receipts +* Atomic +* Needs Django's `Cache framework `__ configured for monitoring +* Compatible with `Tynd `__ Disque addon on `Heroku `__ +* Still considered Alpha software +* Requires `Redis-py `__ as a client + +See the :ref:`disque_configuration` configuration section for more info. + +Amazon SQS +---------- +*TBA* + + +Iron MQ +------- +*TBA* + +Reference +--------- +The :class:`Broker` class is used internally to communicate with the different types of brokers. +You can override this class if you want to contribute and support your own broker. + +.. py:class:: Broker + + .. py:method:: enqueue(task) + + Sends a task package to the broker queue and returns a tracking id. + + .. py:method:: dequeue() + + Gets a task package from the broker. + + .. py:method:: acknowledge(id) + + Notifies the broker that the task has been processed. + Only works with brokers that support delivery receipts. + + .. py:method:: fail(id) + + Tells the broker that the message failed to be processed by the cluster. + Only available on brokers that support this. + Currently only occurs when a cluster fails to unpack a task package. + + .. py:method:: delete(id) + + Instructs the broker to delete this message from the queue. + + .. py:method:: purge_queue() + + Empties the current queue of all messages. + + .. py:method:: delete_queue() + + Deletes the current queue from the broker. + + .. py:method:: queue_size() + + Returns the amount of messages in the brokers queue. + + .. py:method:: ping() + + Returns True if the broker can be reached. + + .. py:method:: info() + + Shows the name and version of the currently configured broker. diff --git a/docs/cluster.rst b/docs/cluster.rst index 4911889..278521a 100644 --- a/docs/cluster.rst +++ b/docs/cluster.rst @@ -48,7 +48,7 @@ Multiple Clusters ----------------- You can have multiple clusters on multiple machines, working on the same queue as long as: -- They connect to the same Redis server or Redis cluster. +- They connect to the same :doc:`broker`. - They use the same cluster name. See :doc:`configure` - They share the same ``SECRET_KEY`` for Django. @@ -92,15 +92,16 @@ Architecture Signed Tasks """""""""""" -Tasks are first pickled and then signed using Django's own :mod:`django.core.signing` module using the ``SECRET_KEY`` and cluster name as salt, before being sent to a Redis list. This ensures that task -packages on the Redis server can only be executed and read by clusters +Tasks are first pickled and then signed using Django's own :mod:`django.core.signing` module using the ``SECRET_KEY`` and cluster name as salt, before being sent to a message broker. This ensures that task +packages on the broker can only be executed and read by clusters and django servers who share the same secret key and cluster name. -Optionally the packages can be compressed before transport +If a package fails to unpack, it will be marked failed with the broker and discarded. +Optionally the packages can be compressed before transport. Pusher """""" -The pusher process continuously checks the Redis list for new task +The pusher process continuously checks the broker for new task packages. It checks the signing and unpacks the task to the Task Queue. Worker @@ -115,6 +116,7 @@ Monitor The result monitor checks the Result Queue for processed packages and saves both failed and successful packages to the Django database. +If the broker supports it, a delivery receipt is sent. .. _sentinel: diff --git a/docs/configure.rst b/docs/configure.rst index fe76ffe..a2bdeac 100644 --- a/docs/configure.rst +++ b/docs/configure.rst @@ -52,6 +52,14 @@ timeout The number of seconds a worker is allowed to spend on a task before it's terminated. Defaults to ``None``, meaning it will never time out. Set this to something that makes sense for your project. Can be overridden for individual tasks. +.. _retry: + +retry +~~~~~ + +The number of seconds a broker will wait for a cluster to finish a task, before it's presented again. +Only works with brokers that support delivery receipts. Defaults to 60 seconds. + compress ~~~~~~~~ @@ -100,20 +108,25 @@ The default behavior for schedules that didn't run while a cluster was down, is You can override this behavior by setting ``catch_up`` to ``False``. This will make those schedules run only once when the cluster starts and normal scheduling resumes. Defaults to ``True``. +.. _redis_configuration: + redis ~~~~~ Connection settings for Redis. Defaults:: - redis: { - 'host': 'localhost', - 'port': 6379, - 'db': 0, - 'password': None, - 'socket_timeout': None, - 'charset': 'utf-8', - 'errors': 'strict', - 'unix_socket_path': None + # redis defaults + Q_CLUSTER = { + 'redis': { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + 'password': None, + 'socket_timeout': None, + 'charset': 'utf-8', + 'errors': 'strict', + 'unix_socket_path': None + } } For more information on these settings please refer to the `Redis-py `__ documentation @@ -131,7 +144,7 @@ of the cache connection you want to use:: 'name': 'DJRedis', 'workers': 4, 'timeout': 90, - 'django_redis: 'default' + 'django_redis': 'default' } @@ -139,6 +152,42 @@ of the cache connection you want to use:: .. tip:: Django Q uses your ``SECRET_KEY`` to encrypt task packages and prevent task crossover. So make sure you have it set up in your Django settings. +.. _disque_configuration: + +disque_nodes +~~~~~~~~~~~~ +If you want to use Disque as your broker, set this to a list of available Disque nodes and each cluster will randomly try to connect to them:: + + # example disque connection + Q_CLUSTER = { + 'name': 'DisqueBroker', + 'workers': 4, + 'timeout': 60, + 'retry': 60, + 'disque_nodes': ['127.0.0.1:7711', '127.0.0.1:7712'] + } + + +Django Q is also compatible with the `Tynd `__ addon on `Heroku `__:: + + # example Tynd connection + import os + + Q_CLUSTER = { + 'name': 'TyndBroker', + 'workers': 8, + 'timeout': 30, + 'retry': 60, + 'disque_nodes': os.environ['TYND_DISQUE_NODES'].split(','), + 'disque_auth': os.environ['TYND_DISQUE_AUTH'] + } + + +disque_auth +~~~~~~~~~~~ + +Optional Disque password for servers that require authentication. + cpu_affinity ~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index 3bad6a1..5d38a22 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,7 +20,7 @@ Features - Django Admin integration - PaaS compatible with multiple instances - Multi cluster monitor -- Redis broker +- Redis or Disque broker - Python 2 and 3 @@ -33,6 +33,7 @@ Contents: Installation Configuration + Brokers Tasks Schedules Cluster diff --git a/docs/install.rst b/docs/install.rst index 1297921..f63f3de 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -19,7 +19,8 @@ Installation $ python manage.py migrate - Make sure you have a `Redis `__ server running - somewhere and know how to connect to it. + somewhere and know how to connect to it or configure one of the alternative :doc:`brokers`. + Requirements ------------ @@ -47,10 +48,6 @@ Django Q is tested for Python 2.7 and 3.4 This feature-filled fork of Erik Rose's blessings project provides the terminal layout of the monitor. -- `Redis server `__ - - Django Q uses Redis as a centralized hub between your Django instances and your Q clusters. - Optional ~~~~~~~~ @@ -65,3 +62,8 @@ Optional $ pip install hiredis +- `Redis `__ server is the default broker for Django Q. It provides the best performance and does not require Django's cache framework for monitoring. + +- `Disque `__ server is based on Redis by the same author, but focuses on reliable queues. Currently in Alpha, but highly recommended. You can either build it from source or use it on Heroku through the `Tynd `__ beta. + + From d72dd8b3385b365d8e1b7f0a28d056854b44dbde Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 15:19:05 +0200 Subject: [PATCH 39/48] adds bottom bar with broker info --- django_q/monitor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/django_q/monitor.py b/django_q/monitor.py index db0d167..9b35614 100644 --- a/django_q/monitor.py +++ b/django_q/monitor.py @@ -80,6 +80,17 @@ def monitor(run_once=False, broker=None): print(term.move(i, 6 * col_width) + term.center(stat.reincarnations, width=col_width - 1)) print(term.move(i, 7 * col_width) + term.center(uptime, width=col_width - 1)) i += 1 + # bottom bar + i += 1 + print(term.move(i, 0) + term.white_on_cyan(term.center(broker.info(), width=col_width * 2))) + print(term.move(i, 2 * col_width) + term.black_on_cyan(term.center(_('Queued'), width=col_width))) + print(term.move(i, 3 * col_width) + term.white_on_cyan(term.center(broker.queue_size(), width=col_width))) + print(term.move(i, 4 * col_width) + term.black_on_cyan(term.center(_('Success'), width=col_width))) + print(term.move(i, 5 * col_width) + term.white_on_cyan( + term.center(models.Success.objects.count(), width=col_width))) + print(term.move(i, 6 * col_width) + term.black_on_cyan(term.center(_('Failures'), width=col_width))) + print(term.move(i, 7 * col_width) + term.white_on_cyan( + term.center(models.Failure.objects.count(), width=col_width))) # for testing if run_once: return Stat.get_all(broker=broker) @@ -169,3 +180,4 @@ def info(broker=None): term.white('{0:.4f}'.format(exec_time)) ) return True + From b201979eaedf30b618a5169a01010bc90151bbc1 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 15:45:17 +0200 Subject: [PATCH 40/48] defaults queue_limit to workers squared. --- django_q/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_q/conf.py b/django_q/conf.py index 024d9fc..eec8823 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -62,7 +62,7 @@ class Conf(object): WORKERS = 4 # Maximum number of tasks that each cluster can work on - QUEUE_LIMIT = conf.get('queue_limit', None) + QUEUE_LIMIT = conf.get('queue_limit', int(WORKERS)**2) # Sets compression of redis packages COMPRESSED = conf.get('compress', False) From 815cbeede97c2e47195fc3e2ee4b2ddce891e253 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 16:21:58 +0200 Subject: [PATCH 41/48] docs: defaults queue_limit to workers squared. --- docs/configure.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/configure.rst b/docs/configure.rst index a2bdeac..423af59 100644 --- a/docs/configure.rst +++ b/docs/configure.rst @@ -90,10 +90,9 @@ Effectively making everything synchronous. Useful for testing. Defaults to ``Fal queue_limit ~~~~~~~~~~~ -This does not limit the amount of tasks that can be queued overall on Redis, but rather how many tasks are kept in memory by a single cluster. +This does not limit the amount of tasks that can be queued on the broker, but rather how many tasks are kept in memory by a single cluster. Setting this to a reasonable number, can help balance the workload and the memory overhead of each individual cluster. -It can also be used to manage the loss of data in case of a cluster failure. -Defaults to ``None``, meaning no limit. +Defaults to ``workers**2``. label ~~~~~ From 87c3b7c9ce7aaf6dadef24d3a371021338ebdcf7 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 16:22:06 +0200 Subject: [PATCH 42/48] docs: minor changes --- docs/brokers.rst | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/brokers.rst b/docs/brokers.rst index e8b487f..c488071 100644 --- a/docs/brokers.rst +++ b/docs/brokers.rst @@ -3,7 +3,7 @@ Brokers The broker sits between your Django instances and your Django Q cluster instances, accepting and delivering task packages. Currently we only support Redis and Disque, but support for other brokers is being worked on. -Even though `Disque `__ is still considered Alpha software, it's been gathering a lot of support and test results are positive. +Even though `Disque `__ is still considered to be in alpha, it's been gathering a lot of support and test results are positive. Clients for `Amazon SQS `__ and `IronMQ `__ are being tested. @@ -15,7 +15,7 @@ The default broker for Django Q clusters is `Redis `__. * Atomic * Does not need separate cache framework for monitoring * Can use existing `Django-Redis `__ connections through the :ref:`django_redis` setting -* Requires `Redis-py `__ as a client +* Requires `Redis-py `__ * Does not support receipts Can be configured with :ref:`redis_configuration` configuration settings or :ref:`django_redis`. @@ -26,24 +26,15 @@ Disque Unlike Redis, Disque supports message receipts which make delivery to the cluster workers guaranteed. If a task never produces a failed or successful result, it will automatically be sent to the cluster again for a retry. You can control the amount of time Disque should wait for completion of a task by configuring the :ref:`retry` setting. -* Supports receipts +* Delivery receipts * Atomic * Needs Django's `Cache framework `__ configured for monitoring * Compatible with `Tynd `__ Disque addon on `Heroku `__ * Still considered Alpha software -* Requires `Redis-py `__ as a client +* Requires `Redis-py `__ See the :ref:`disque_configuration` configuration section for more info. -Amazon SQS ----------- -*TBA* - - -Iron MQ -------- -*TBA* - Reference --------- The :class:`Broker` class is used internally to communicate with the different types of brokers. From 73d15c3608b0a864e02b4ed86cd4d10baa2c3896 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 16:54:20 +0200 Subject: [PATCH 43/48] docs: minor updates --- README.rst | 10 ++++++++-- django_q/conf.py | 2 +- docs/brokers.rst | 20 ++++++++------------ docs/install.rst | 11 +++++------ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index b5e64ae..a46bf33 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,13 @@ Requirements Tested with: Python 2.7 & 3.4. Django 1.7.10 & 1.8.4 +Brokers +~~~~~~~ +- `Redis `__ +- `Disque `__ +- `Amazon SQS `__ TBA +- `IronMQ `__ TBA + Installation ~~~~~~~~~~~~ @@ -54,8 +61,7 @@ Installation $ python manage.py migrate -- Make sure you have a `Redis `__ server running - somewhere or configure one of the other `brokers `__. +- Choose a `broker `__ and install the appropriate client library. Read the full documentation at `https://django-q.readthedocs.org `__ diff --git a/django_q/conf.py b/django_q/conf.py index eec8823..ebd2842 100644 --- a/django_q/conf.py +++ b/django_q/conf.py @@ -74,7 +74,7 @@ class Conf(object): TIMEOUT = conf.get('timeout', None) # Number of seconds to wait for acknowledgement before retrying a task - # Only works with brokers that guarantee delivery. Defaults to 0. Meaning no retries. + # Only works with brokers that guarantee delivery. Defaults to 60 seconds. RETRY = conf.get('retry', 60) # The Django Admin label for this app diff --git a/docs/brokers.rst b/docs/brokers.rst index c488071..c285e15 100644 --- a/docs/brokers.rst +++ b/docs/brokers.rst @@ -2,24 +2,21 @@ Brokers ======= The broker sits between your Django instances and your Django Q cluster instances, accepting and delivering task packages. -Currently we only support Redis and Disque, but support for other brokers is being worked on. -Even though `Disque `__ is still considered to be in alpha, it's been gathering a lot of support and test results are positive. +Currently we only support `Redis `__ and `Disque `__, but support for other brokers is being worked on. -Clients for `Amazon SQS `__ and `IronMQ `__ are being tested. +Clients for `Amazon SQS `__ and `IronMQ `__ are TBA. Redis ----- -The default broker for Django Q clusters is `Redis `__. +The default broker for Django Q clusters. * Atomic * Does not need separate cache framework for monitoring -* Can use existing `Django-Redis `__ connections through the :ref:`django_redis` setting -* Requires `Redis-py `__ * Does not support receipts - -Can be configured with :ref:`redis_configuration` configuration settings or :ref:`django_redis`. - +* Requires `Redis-py `__ client library: ``pip install redis`` +* Can use existing :ref:`django_redis` connections. +* Configure with :ref:`redis_configuration`-py compatible configuration Disque ------ @@ -31,9 +28,8 @@ You can control the amount of time Disque should wait for completion of a task b * Needs Django's `Cache framework `__ configured for monitoring * Compatible with `Tynd `__ Disque addon on `Heroku `__ * Still considered Alpha software -* Requires `Redis-py `__ - -See the :ref:`disque_configuration` configuration section for more info. +* Requires `Redis-py `__ client library: ``pip install redis`` +* See the :ref:`disque_configuration` configuration section for more info. Reference --------- diff --git a/docs/install.rst b/docs/install.rst index f63f3de..09263f1 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -18,8 +18,7 @@ Installation $ python manage.py migrate -- Make sure you have a `Redis `__ server running - somewhere and know how to connect to it or configure one of the alternative :doc:`brokers`. +- Choose a :doc:`broker` and install the appropriate client library. Requirements @@ -36,10 +35,6 @@ Django Q is tested for Python 2.7 and 3.4 Used to store args, kwargs and result objects in the database. -- `Redis-py `__ - - Andy McCurdy's excellent Redis python client. - - `Arrow `__ The scheduler uses Chris Smith's wonderful project to determine correct dates in the future. @@ -51,6 +46,10 @@ Django Q is tested for Python 2.7 and 3.4 Optional ~~~~~~~~ +- `Redis-py `__ + + Andy McCurdy's excellent Redis python client is used to interface with both the Redis and Disque brokers. + .. _psutil: - `Psutil `__ python system and process utilities module by Giampaolo Rodola', is an optional requirement and adds cpu affinity settings to the cluster:: From 43065233f62bac235b3646d60e34326a06b971a7 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 16:55:46 +0200 Subject: [PATCH 44/48] Updated README --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index a46bf33..519f126 100644 --- a/README.rst +++ b/README.rst @@ -38,8 +38,8 @@ Brokers ~~~~~~~ - `Redis `__ - `Disque `__ -- `Amazon SQS `__ TBA -- `IronMQ `__ TBA +- `Amazon SQS `__ (TBA) +- `IronMQ `__ (TBA) Installation From a4bdc809dccc8c0a4f9b2f1c4921e806455e5a74 Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 17:14:43 +0200 Subject: [PATCH 45/48] getting ready for new release --- README.rst | 3 +-- docs/install.rst | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 519f126..0909c8d 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,6 @@ Features Requirements ~~~~~~~~~~~~ -- `Redis-py `__ - `Django `__ > = 1.7 - `Django-picklefield `__ - `Arrow `__ @@ -61,7 +60,7 @@ Installation $ python manage.py migrate -- Choose a `broker `__ and install the appropriate client library. +- Choose a message `broker `__ , configure and install the appropriate client library. Read the full documentation at `https://django-q.readthedocs.org `__ diff --git a/docs/install.rst b/docs/install.rst index 09263f1..fa750be 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -18,7 +18,7 @@ Installation $ python manage.py migrate -- Choose a :doc:`broker` and install the appropriate client library. +- Choose a message :doc:`broker` , configure it and install the appropriate client library. Requirements @@ -46,9 +46,9 @@ Django Q is tested for Python 2.7 and 3.4 Optional ~~~~~~~~ -- `Redis-py `__ +- `Redis-py `__ client by Andy McCurdy is used to interface with both the Redis and Disque brokers:: - Andy McCurdy's excellent Redis python client is used to interface with both the Redis and Disque brokers. + $ pip install redis .. _psutil: @@ -56,7 +56,6 @@ Optional $ pip install psutil - - `Hiredis `__ parser. This C library maintained by the core Redis team is faster than the standard PythonParser during high loads:: $ pip install hiredis From ba4a6f104a9175f934ec53dfffa1249ca2c954ea Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 17:52:52 +0200 Subject: [PATCH 46/48] removed boto and ironmq for now --- requirements.in | 2 -- requirements.txt | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/requirements.in b/requirements.in index 0198050..1ab7ca1 100644 --- a/requirements.in +++ b/requirements.in @@ -6,5 +6,3 @@ hiredis redis psutil django-redis -boto -iron-mq diff --git a/requirements.txt b/requirements.txt index 344dc7f..1c376c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,17 +6,13 @@ # arrow==0.6.0 blessed==1.9.5 -boto==2.38.0 django-picklefield==0.3.1 django-redis==4.2.0 future==0.15.0 hiredis==0.2.0 -iron-core==1.1.9 # via iron-mq -iron-mq==0.7 msgpack-python==0.4.6 # via django-redis psutil==3.2.1 -python-dateutil==2.4.2 # via arrow, iron-core +python-dateutil==2.4.2 # via arrow redis==2.10.3 -requests==2.7.0 # via iron-core six==1.9.0 # via django-picklefield, python-dateutil wcwidth==0.1.4 # via blessed From 61306143bdac81dbdc32445a8e1f6bdae182fc2a Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 19:57:53 +0200 Subject: [PATCH 47/48] adds brokers to manifest --- MANIFEST.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 7a75164..b3169f4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,4 +2,5 @@ include LICENSE include README.rst include django_q/management/*.py include django_q/management/commands/*.py -include django_q/migrations/*.py \ No newline at end of file +include django_q/migrations/*.py +include django_q/brokers/*.py \ No newline at end of file From cba7b60fcc160cb09a35330567bb1a3df587dafe Mon Sep 17 00:00:00 2001 From: Ilan Steemers Date: Sun, 6 Sep 2015 19:58:04 +0200 Subject: [PATCH 48/48] fixing term clear --- django_q/monitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django_q/monitor.py b/django_q/monitor.py index 9b35614..3a0dcec 100644 --- a/django_q/monitor.py +++ b/django_q/monitor.py @@ -28,7 +28,7 @@ def monitor(run_once=False, broker=None): col_width = int(term.width / 8) # In case of resize if col_width != start_width: - print(term.clear) + print(term.clear()) start_width = col_width print(term.move(0, 0) + term.black_on_green(term.center(_('Host'), width=col_width - 1))) print(term.move(0, 1 * col_width) + term.black_on_green(term.center(_('Id'), width=col_width - 1))) @@ -140,7 +140,7 @@ def info(broker=None): else: tasks_per = tasks_per_day # print to terminal - term.clear_eos() + print(term.clear_eos()) col_width = int(term.width / 6) print(term.black_on_green(term.center(_('-- {} summary --').format(Conf.PREFIX)))) print(term.cyan(_('Clusters')) +