mirror of
https://github.com/django-q2/django-q2.git
synced 2026-10-08 18:18:11 +08:00
+1
-1
@@ -15,7 +15,7 @@ Features
|
|||||||
- Asynchronous tasks
|
- Asynchronous tasks
|
||||||
- Scheduled and repeated tasks
|
- Scheduled and repeated tasks
|
||||||
- Encrypted and compressed packages
|
- Encrypted and compressed packages
|
||||||
- Failure and success database
|
- Failure and success database or cache
|
||||||
- Result hooks and groups
|
- Result hooks and groups
|
||||||
- Django Admin integration
|
- Django Admin integration
|
||||||
- PaaS compatible with multiple instances
|
- PaaS compatible with multiple instances
|
||||||
|
|||||||
+43
-2
@@ -325,7 +325,11 @@ def monitor(result_queue, broker=None):
|
|||||||
if ack_id:
|
if ack_id:
|
||||||
broker.acknowledge(ack_id)
|
broker.acknowledge(ack_id)
|
||||||
# save the result
|
# save the result
|
||||||
save_task(task)
|
if task.get('cached', False):
|
||||||
|
save_cached(task, broker)
|
||||||
|
else:
|
||||||
|
save_task(task)
|
||||||
|
# log the result
|
||||||
if task['success']:
|
if task['success']:
|
||||||
logger.info(_("Processed [{}]").format(task['name']))
|
logger.info(_("Processed [{}]").format(task['name']))
|
||||||
else:
|
else:
|
||||||
@@ -384,7 +388,7 @@ def worker(task_queue, result_queue, timer, timeout=Conf.TIMEOUT):
|
|||||||
|
|
||||||
def save_task(task):
|
def save_task(task):
|
||||||
"""
|
"""
|
||||||
Saves the task package to Django
|
Saves the task package to Django or the cache
|
||||||
"""
|
"""
|
||||||
# SAVE LIMIT < 0 : Don't save success
|
# SAVE LIMIT < 0 : Don't save success
|
||||||
if not task.get('save', Conf.SAVE_LIMIT > 0) and task['success']:
|
if not task.get('save', Conf.SAVE_LIMIT > 0) and task['success']:
|
||||||
@@ -409,6 +413,43 @@ def save_task(task):
|
|||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
|
|
||||||
|
def save_cached(task, broker):
|
||||||
|
task_key = '{}:{}'.format(broker.list_key, task['id'])
|
||||||
|
timeout = task['cached']
|
||||||
|
if timeout is True:
|
||||||
|
timeout = None
|
||||||
|
try:
|
||||||
|
group = task.get('group', False)
|
||||||
|
iter_count = task.get('iter_count', None)
|
||||||
|
# if it's a group append to the group list
|
||||||
|
if group:
|
||||||
|
task_key = '{}:{}:{}'.format(broker.list_key, group, task['id'])
|
||||||
|
group_key = '{}:{}:keys'.format(broker.list_key, group)
|
||||||
|
group_list = broker.cache.get(group_key) or []
|
||||||
|
# if it's an inter group, check if we are ready
|
||||||
|
if iter_count and len(group_list) == iter_count-1:
|
||||||
|
group_args = '{}:{}:args'.format(broker.list_key, group)
|
||||||
|
# collate the results into a Task result
|
||||||
|
results = [signing.SignedPackage.loads(broker.cache.get(k))['result'] for k in group_list]
|
||||||
|
results.append(task['result'])
|
||||||
|
task['result'] = results
|
||||||
|
task['id'] = group
|
||||||
|
task['args'] = signing.SignedPackage.loads(broker.cache.get(group_args))
|
||||||
|
save_task(task)
|
||||||
|
broker.cache.delete_many(group_list)
|
||||||
|
broker.cache.delete_many([group_key, group_args])
|
||||||
|
return
|
||||||
|
# save the group list
|
||||||
|
group_list.append(task_key)
|
||||||
|
broker.cache.set(group_key, group_list)
|
||||||
|
# save the task
|
||||||
|
broker.cache.set(task_key,
|
||||||
|
signing.SignedPackage.dumps(task),
|
||||||
|
timeout)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
|
|
||||||
def scheduler(broker=None):
|
def scheduler(broker=None):
|
||||||
"""
|
"""
|
||||||
Creates a task from a schedule at the scheduled time and schedules next run
|
Creates a task from a schedule at the scheduled time and schedules next run
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ class Conf(object):
|
|||||||
# The Django cache to use
|
# The Django cache to use
|
||||||
CACHE = conf.get('cache', 'default')
|
CACHE = conf.get('cache', 'default')
|
||||||
|
|
||||||
|
# Use the cache as result backend. Can be 'True' or an integer representing the global cache timeout.
|
||||||
|
# i.e 'cached: 60' , will make all results go the cache and expire in 60 seconds.
|
||||||
|
CACHED = conf.get('cached', False)
|
||||||
|
|
||||||
# If set to False the scheduler won't execute tasks in the past.
|
# 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.
|
# Instead it will run once and reschedule the next run in the future. Defaults to True.
|
||||||
CATCH_UP = conf.get('catch_up', True)
|
CATCH_UP = conf.get('catch_up', True)
|
||||||
|
|||||||
+225
-11
@@ -1,4 +1,4 @@
|
|||||||
"""Provides task functionalities."""
|
"""Provides task functionality."""
|
||||||
from multiprocessing import Queue, Value
|
from multiprocessing import Queue, Value
|
||||||
|
|
||||||
# django
|
# django
|
||||||
@@ -15,7 +15,7 @@ from django_q.brokers import get_broker
|
|||||||
|
|
||||||
|
|
||||||
def async(func, *args, **kwargs):
|
def async(func, *args, **kwargs):
|
||||||
"""Send a task to the cluster."""
|
"""Queue a task for the cluster."""
|
||||||
# get options from q_options dict or direct from kwargs
|
# get options from q_options dict or direct from kwargs
|
||||||
options = kwargs.pop('q_options', kwargs)
|
options = kwargs.pop('q_options', kwargs)
|
||||||
hook = options.pop('hook', None)
|
hook = options.pop('hook', None)
|
||||||
@@ -23,6 +23,8 @@ def async(func, *args, **kwargs):
|
|||||||
sync = options.pop('sync', False)
|
sync = options.pop('sync', False)
|
||||||
group = options.pop('group', None)
|
group = options.pop('group', None)
|
||||||
save = options.pop('save', None)
|
save = options.pop('save', None)
|
||||||
|
cached = options.pop('cached', Conf.CACHED)
|
||||||
|
iter_count = options.pop('iter_count', None)
|
||||||
# get an id
|
# get an id
|
||||||
tag = uuid()
|
tag = uuid()
|
||||||
# build the task package
|
# build the task package
|
||||||
@@ -38,6 +40,10 @@ def async(func, *args, **kwargs):
|
|||||||
task['group'] = group
|
task['group'] = group
|
||||||
if save is not None:
|
if save is not None:
|
||||||
task['save'] = save
|
task['save'] = save
|
||||||
|
if cached:
|
||||||
|
task['cached'] = cached
|
||||||
|
if iter_count:
|
||||||
|
task['iter_count'] = iter_count
|
||||||
# sign it
|
# sign it
|
||||||
pack = signing.SignedPackage.dumps(task)
|
pack = signing.SignedPackage.dumps(task)
|
||||||
if sync or Conf.SYNC:
|
if sync or Conf.SYNC:
|
||||||
@@ -83,7 +89,7 @@ def schedule(func, *args, **kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def result(task_id, wait=0):
|
def result(task_id, wait=0, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Return the result of the named task.
|
Return the result of the named task.
|
||||||
|
|
||||||
@@ -91,9 +97,12 @@ def result(task_id, wait=0):
|
|||||||
:param task_id: the task name or uuid
|
:param task_id: the task name or uuid
|
||||||
:type wait: int
|
:type wait: int
|
||||||
:param wait: number of milliseconds to wait for a result
|
:param wait: number of milliseconds to wait for a result
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return: the result object of this task
|
:return: the result object of this task
|
||||||
:rtype: object
|
:rtype: object
|
||||||
"""
|
"""
|
||||||
|
if cached:
|
||||||
|
return result_cached(task_id, wait)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while True:
|
while True:
|
||||||
r = Task.get_result(task_id)
|
r = Task.get_result(task_id)
|
||||||
@@ -104,18 +113,76 @@ def result(task_id, wait=0):
|
|||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
def result_group(group_id, failures=False):
|
def result_cached(task_id, wait=0, broker=None):
|
||||||
|
"""
|
||||||
|
Return the result from the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
start = time.time()
|
||||||
|
while True:
|
||||||
|
r = broker.cache.get('{}:{}'.format(broker.list_key, task_id))
|
||||||
|
if r:
|
||||||
|
return signing.SignedPackage.loads(r)['result']
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def result_group(group_id, failures=False, wait=0, count=None, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Return a list of results for a task group.
|
Return a list of results for a task group.
|
||||||
|
|
||||||
:param str group_id: the group id
|
:param str group_id: the group id
|
||||||
:param bool failures: set to True to include failures
|
:param bool failures: set to True to include failures
|
||||||
|
:param int count: Block until there are this many results in the group
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return: list or results
|
:return: list or results
|
||||||
"""
|
"""
|
||||||
return Task.get_result_group(group_id, failures)
|
if cached:
|
||||||
|
return result_group_cached(group_id, failures, wait, count)
|
||||||
|
start = time.time()
|
||||||
|
if count:
|
||||||
|
while True:
|
||||||
|
if count_group(group_id) == count or wait and (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
while True:
|
||||||
|
r = Task.get_result_group(group_id, failures)
|
||||||
|
if r:
|
||||||
|
return r
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
def fetch(task_id, wait=0):
|
def result_group_cached(group_id, failures=False, wait=0, count=None, broker=None):
|
||||||
|
"""
|
||||||
|
Return a list of results for a task group from the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
start = time.time()
|
||||||
|
if count:
|
||||||
|
while True:
|
||||||
|
if count_group_cached(group_id) == count or wait and (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
while True:
|
||||||
|
group_list = broker.cache.get('{}:{}:keys'.format(broker.list_key, group_id))
|
||||||
|
if group_list:
|
||||||
|
result_list = []
|
||||||
|
for task_key in group_list:
|
||||||
|
task = signing.SignedPackage.loads(broker.cache.get(task_key))
|
||||||
|
if task['success'] or failures:
|
||||||
|
result_list.append(task['result'])
|
||||||
|
return result_list
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(task_id, wait=0, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Return the processed task.
|
Return the processed task.
|
||||||
|
|
||||||
@@ -123,9 +190,12 @@ def fetch(task_id, wait=0):
|
|||||||
:type task_id: str or uuid
|
:type task_id: str or uuid
|
||||||
:param wait: the number of milliseconds to wait for a result
|
:param wait: the number of milliseconds to wait for a result
|
||||||
:type wait: int
|
:type wait: int
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return: the full task object
|
:return: the full task object
|
||||||
:rtype: Task
|
:rtype: Task
|
||||||
"""
|
"""
|
||||||
|
if cached:
|
||||||
|
return fetch_cached(task_id, wait)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while True:
|
while True:
|
||||||
t = Task.get_task(task_id)
|
t = Task.get_task(task_id)
|
||||||
@@ -136,45 +206,169 @@ def fetch(task_id, wait=0):
|
|||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
def fetch_group(group_id, failures=True):
|
def fetch_cached(task_id, wait=0, broker=None):
|
||||||
|
"""
|
||||||
|
Return the processed task from the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
start = time.time()
|
||||||
|
while True:
|
||||||
|
r = broker.cache.get('{}:{}'.format(broker.list_key, task_id))
|
||||||
|
if r:
|
||||||
|
task = signing.SignedPackage.loads(r)
|
||||||
|
t = Task(id=task['id'],
|
||||||
|
name=task['name'],
|
||||||
|
func=task['func'],
|
||||||
|
hook=task.get('hook'),
|
||||||
|
args=task['args'],
|
||||||
|
kwargs=task['kwargs'],
|
||||||
|
started=task['started'],
|
||||||
|
stopped=task['stopped'],
|
||||||
|
result=task['result'],
|
||||||
|
success=task['success'])
|
||||||
|
return t
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_group(group_id, failures=True, wait=0, count=None, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Return a list of Tasks for a task group.
|
Return a list of Tasks for a task group.
|
||||||
|
|
||||||
:param str group_id: the group id
|
:param str group_id: the group id
|
||||||
:param bool failures: set to False to exclude failures
|
:param bool failures: set to False to exclude failures
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return: list of Tasks
|
:return: list of Tasks
|
||||||
"""
|
"""
|
||||||
return Task.get_task_group(group_id, failures)
|
if cached:
|
||||||
|
return fetch_group_cached(group_id, failures, wait, count)
|
||||||
|
start = time.time()
|
||||||
|
if count:
|
||||||
|
while True:
|
||||||
|
if count_group(group_id) == count or wait and (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
while True:
|
||||||
|
r = Task.get_task_group(group_id, failures)
|
||||||
|
if r:
|
||||||
|
return r
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
def count_group(group_id, failures=False):
|
def fetch_group_cached(group_id, failures=True, wait=0, count=None, broker=None):
|
||||||
|
"""
|
||||||
|
Return a list of Tasks for a task group in the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
start = time.time()
|
||||||
|
if count:
|
||||||
|
while True:
|
||||||
|
if count_group_cached(group_id) == count or wait and (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
while True:
|
||||||
|
group_list = broker.cache.get('{}:{}:keys'.format(broker.list_key, group_id))
|
||||||
|
if group_list:
|
||||||
|
task_list = []
|
||||||
|
for task_key in group_list:
|
||||||
|
task = signing.SignedPackage.loads(broker.cache.get(task_key))
|
||||||
|
if task['success'] or failures:
|
||||||
|
t = Task(id=task['id'],
|
||||||
|
name=task['name'],
|
||||||
|
func=task['func'],
|
||||||
|
hook=task.get('hook'),
|
||||||
|
args=task['args'],
|
||||||
|
kwargs=task['kwargs'],
|
||||||
|
started=task['started'],
|
||||||
|
stopped=task['stopped'],
|
||||||
|
result=task['result'],
|
||||||
|
group=task.get('group'),
|
||||||
|
success=task['success'])
|
||||||
|
task_list.append(t)
|
||||||
|
return task_list
|
||||||
|
if (time.time() - start) * 1000 >= wait:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def count_group(group_id, failures=False, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Count the results in a group.
|
Count the results in a group.
|
||||||
|
|
||||||
:param str group_id: the group id
|
:param str group_id: the group id
|
||||||
:param bool failures: Returns failure count if True
|
:param bool failures: Returns failure count if True
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return: the number of tasks/results in a group
|
:return: the number of tasks/results in a group
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
|
if cached:
|
||||||
|
return count_group_cached(group_id, failures)
|
||||||
return Task.get_group_count(group_id, failures)
|
return Task.get_group_count(group_id, failures)
|
||||||
|
|
||||||
|
|
||||||
def delete_group(group_id, tasks=False):
|
def count_group_cached(group_id, failures=False, broker=None):
|
||||||
|
"""
|
||||||
|
Count the results in a group in the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
group_list = broker.cache.get('{}:{}:keys'.format(broker.list_key, group_id))
|
||||||
|
if group_list:
|
||||||
|
if not failures:
|
||||||
|
return len(group_list)
|
||||||
|
failure_count = 0
|
||||||
|
for task_key in group_list:
|
||||||
|
task = signing.SignedPackage.loads(broker.cache.get(task_key))
|
||||||
|
if not task['success']:
|
||||||
|
failure_count += 1
|
||||||
|
return failure_count
|
||||||
|
|
||||||
|
|
||||||
|
def delete_group(group_id, tasks=False, cached=Conf.CACHED):
|
||||||
"""
|
"""
|
||||||
Delete a group.
|
Delete a group.
|
||||||
|
|
||||||
:param str group_id: the group id
|
:param str group_id: the group id
|
||||||
:param bool tasks: If set to True this will also delete the group tasks.
|
:param bool tasks: If set to True this will also delete the group tasks.
|
||||||
Otherwise just the group label is removed.
|
Otherwise just the group label is removed.
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
if cached:
|
||||||
|
return delete_group_cached(group_id)
|
||||||
return Task.delete_group(group_id, tasks)
|
return Task.delete_group(group_id, tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_group_cached(group_id, broker=None):
|
||||||
|
"""
|
||||||
|
Delete a group from the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
group_key = '{}:{}:keys'.format(broker.list_key, group_id)
|
||||||
|
group_list = broker.cache.get(group_key)
|
||||||
|
broker.cache.delete_many(group_list)
|
||||||
|
broker.cache.delete(group_key)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_cached(task_id, broker=None):
|
||||||
|
"""
|
||||||
|
Delete a task from the cache backend
|
||||||
|
"""
|
||||||
|
if not broker:
|
||||||
|
broker = get_broker()
|
||||||
|
return broker.cache.delete('{}:{}'.format(broker.list_key, task_id))
|
||||||
|
|
||||||
|
|
||||||
def queue_size(broker=None):
|
def queue_size(broker=None):
|
||||||
"""
|
"""
|
||||||
Returns the current queue size.
|
Returns the current queue size.
|
||||||
Note that this doesn't count any tasks currently being processed by workers.
|
Note that this doesn't count any tasks curren key = 'django_q:{}:results'.format(broker.list_key)tly being processed by workers.
|
||||||
|
|
||||||
:param broker: optional broker
|
:param broker: optional broker
|
||||||
:return: current queue size
|
:return: current queue size
|
||||||
@@ -185,6 +379,26 @@ def queue_size(broker=None):
|
|||||||
return broker.queue_size()
|
return broker.queue_size()
|
||||||
|
|
||||||
|
|
||||||
|
def async_iter(func, args_iter, **kwargs):
|
||||||
|
iter_count = len(args_iter)
|
||||||
|
iter_group = uuid()[1]
|
||||||
|
# clean up the kwargs
|
||||||
|
options = kwargs.get('q_options', kwargs)
|
||||||
|
options.pop('hook', None)
|
||||||
|
options['broker'] = options.get('broker', get_broker())
|
||||||
|
options['group'] = iter_group
|
||||||
|
options['iter_count'] = iter_count
|
||||||
|
options['cached'] = True
|
||||||
|
# save the original arguments
|
||||||
|
broker = options['broker']
|
||||||
|
broker.cache.set('{}:{}:args'.format(broker.list_key, iter_group), signing.SignedPackage.dumps(args_iter))
|
||||||
|
for args in args_iter:
|
||||||
|
if type(args) is not tuple:
|
||||||
|
args = (args,)
|
||||||
|
async(func, *args, **options)
|
||||||
|
return iter_group
|
||||||
|
|
||||||
|
|
||||||
def _sync(pack):
|
def _sync(pack):
|
||||||
"""Simulate a package travelling through the cluster."""
|
"""Simulate a package travelling through the cluster."""
|
||||||
task_queue = Queue()
|
task_queue = Queue()
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from multiprocessing import Event
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django_q.cluster import Sentinel
|
||||||
|
from django_q.conf import Conf
|
||||||
|
from django_q.tasks import async, result, fetch, count_group, result_group, fetch_group, delete_group, delete_cached, \
|
||||||
|
async_iter
|
||||||
|
from django_q.brokers import get_broker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def broker():
|
||||||
|
Conf.DISQUE_NODES = None
|
||||||
|
Conf.IRON_MQ = None
|
||||||
|
Conf.SQS = None
|
||||||
|
Conf.ORM = None
|
||||||
|
Conf.MONGO = None
|
||||||
|
Conf.DJANGO_REDIS = 'default'
|
||||||
|
return get_broker()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_cached(broker):
|
||||||
|
broker.purge_queue()
|
||||||
|
broker.cache.clear()
|
||||||
|
group = 'cache_test'
|
||||||
|
# queue the tests
|
||||||
|
task_id = async('math.copysign', 1, -1, cached=True, broker=broker)
|
||||||
|
async('math.copysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
async('math.copysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
async('math.copysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
async('math.copysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
async('math.copysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
async('math.popysign', 1, -1, cached=True, broker=broker, group=group)
|
||||||
|
# test wait on cache
|
||||||
|
# test wait timeout
|
||||||
|
assert result(task_id, wait=10, cached=True) is None
|
||||||
|
assert fetch(task_id, wait=10, cached=True) is None
|
||||||
|
assert result_group(group, wait=10, cached=True) is None
|
||||||
|
assert result_group(group, count=2, wait=10, cached=True) is None
|
||||||
|
assert fetch_group(group, wait=10, cached=True) is None
|
||||||
|
assert fetch_group(group, count=2, wait=10, cached=True) is None
|
||||||
|
# run a single cluster
|
||||||
|
start_event = Event()
|
||||||
|
stop_event = Event()
|
||||||
|
stop_event.set()
|
||||||
|
Sentinel(stop_event, start_event, broker=broker)
|
||||||
|
# assert results
|
||||||
|
assert result(task_id, wait=500, cached=True) == -1
|
||||||
|
assert fetch(task_id, wait=500, cached=True).result == -1
|
||||||
|
# make sure it's not in the db backend
|
||||||
|
assert fetch(task_id) is None
|
||||||
|
# assert group
|
||||||
|
assert count_group(group, cached=True) == 6
|
||||||
|
assert count_group(group, cached=True, failures=True) == 1
|
||||||
|
assert result_group(group, cached=True) == [-1, -1, -1, -1, -1]
|
||||||
|
assert len(result_group(group, cached=True, failures=True)) == 6
|
||||||
|
assert len(fetch_group(group, cached=True)) == 6
|
||||||
|
assert len(fetch_group(group, cached=True, failures=False)) == 5
|
||||||
|
delete_group(group, cached=True)
|
||||||
|
assert count_group(group, cached=True) is None
|
||||||
|
delete_cached(task_id)
|
||||||
|
assert result(task_id, cached=True) is None
|
||||||
|
assert fetch(task_id, cached=True) is None
|
||||||
|
broker.cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_iter(broker):
|
||||||
|
broker.purge_queue()
|
||||||
|
broker.cache.clear()
|
||||||
|
it = [i for i in range(10)]
|
||||||
|
it2 = [(1, -1), (2, -1), (3, -4), (5, 6)]
|
||||||
|
it3 = (1, 2, 3, 4, 5)
|
||||||
|
t = async_iter('math.floor', it, sync=True)
|
||||||
|
t2 = async_iter('math.copysign', it2, sync=True)
|
||||||
|
t3 = async_iter('math.floor', it3, sync=True)
|
||||||
|
t4 = async_iter('math.floor', (1,), sync=True)
|
||||||
|
result_t = result(t)
|
||||||
|
assert result_t is not None
|
||||||
|
task_t = fetch(t)
|
||||||
|
assert task_t. __unicode__ is not None
|
||||||
|
assert task_t.result == result_t
|
||||||
|
assert result(t2) is not None
|
||||||
|
assert result(t3) is not None
|
||||||
|
assert result(t4)[0] == 1
|
||||||
@@ -160,6 +160,13 @@ def test_async(broker, admin_user):
|
|||||||
assert broker.queue_size() == 0
|
assert broker.queue_size() == 0
|
||||||
assert task_queue.qsize() == task_count
|
assert task_queue.qsize() == task_count
|
||||||
task_queue.put('STOP')
|
task_queue.put('STOP')
|
||||||
|
# test wait timeout
|
||||||
|
assert result(j, wait=10) is None
|
||||||
|
assert fetch(j, wait=10) is None
|
||||||
|
assert result_group('test_j', wait=10) is None
|
||||||
|
assert result_group('test_j', count=2, wait=10) is None
|
||||||
|
assert fetch_group('test_j', wait=10) is None
|
||||||
|
assert fetch_group('test_j', count=2, wait=10) is None
|
||||||
# let a worker handle them
|
# let a worker handle them
|
||||||
result_queue = Queue()
|
result_queue = Queue()
|
||||||
worker(task_queue, result_queue, Value('f', -1))
|
worker(task_queue, result_queue, Value('f', -1))
|
||||||
|
|||||||
@@ -308,6 +308,13 @@ cache
|
|||||||
For some brokers, you will need to set up the Django `cache framework <https://docs.djangoproject.com/en/1.8/topics/cache/#setting-up-the-cache>`__
|
For some brokers, you will need to set up the Django `cache framework <https://docs.djangoproject.com/en/1.8/topics/cache/#setting-up-the-cache>`__
|
||||||
to gather statistics for the monitor. You can indicate which cache to use by setting this value. Defaults to ``default``.
|
to gather statistics for the monitor. You can indicate which cache to use by setting this value. Defaults to ``default``.
|
||||||
|
|
||||||
|
.. _cached:
|
||||||
|
|
||||||
|
cached
|
||||||
|
~~~~~~
|
||||||
|
Switches all task and result functions from using the database backend to the cache backend. This is the same as setting the keyword ``cached=True`` on all task functions.
|
||||||
|
Instead of a bool this can also be set to the number of seconds you want the cache to retain results. e.g. ``cached=60``
|
||||||
|
|
||||||
scheduler
|
scheduler
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
You can disable the scheduler by setting this option to ``False``. This will reduce a little overhead if you're not using schedules, but is most useful if you want to temporarily disable all schedules.
|
You can disable the scheduler by setting this option to ``False``. This will reduce a little overhead if you're not using schedules, but is most useful if you want to temporarily disable all schedules.
|
||||||
|
|||||||
+27
-11
@@ -253,8 +253,7 @@ Adapted from `Sebastian Raschka's blog <http://sebastianraschka.com/Articles/201
|
|||||||
# Group example with Parzen-window estimation
|
# Group example with Parzen-window estimation
|
||||||
import numpy
|
import numpy
|
||||||
|
|
||||||
from django_q.tasks import async, result_group,\
|
from django_q.tasks import async, result_group, delete_group
|
||||||
count_group, delete_group
|
|
||||||
|
|
||||||
# the estimation function
|
# the estimation function
|
||||||
def parzen_estimation(x_samples, point_x, h):
|
def parzen_estimation(x_samples, point_x, h):
|
||||||
@@ -268,30 +267,47 @@ Adapted from `Sebastian Raschka's blog <http://sebastianraschka.com/Articles/201
|
|||||||
k_n += 1
|
k_n += 1
|
||||||
return h, (k_n / len(x_samples)) / (h ** point_x.shape[1])
|
return h, (k_n / len(x_samples)) / (h ** point_x.shape[1])
|
||||||
|
|
||||||
|
|
||||||
# create 100 calculations and send them to the cluster
|
# create 100 calculations and send them to the cluster
|
||||||
def parzen_async():
|
def parzen_async():
|
||||||
# clear the previous results
|
# clear the previous results
|
||||||
delete_group('parzen', tasks=True)
|
delete_group('parzen', cached=True)
|
||||||
mu_vec = numpy.array([0, 0])
|
mu_vec = numpy.array([0, 0])
|
||||||
cov_mat = numpy.array([[1, 0], [0, 1]])
|
cov_mat = numpy.array([[1, 0], [0, 1]])
|
||||||
sample = numpy.random.\
|
sample = numpy.random. \
|
||||||
multivariate_normal(mu_vec, cov_mat, 10000)
|
multivariate_normal(mu_vec, cov_mat, 10000)
|
||||||
widths = numpy.linspace(1.0, 1.2, 100)
|
widths = numpy.linspace(1.0, 1.2, 100)
|
||||||
x = numpy.array([[0], [0]])
|
x = numpy.array([[0], [0]])
|
||||||
# async them with a group label and a hook
|
# async them with a group label to the cache backend
|
||||||
for w in widths:
|
for w in widths:
|
||||||
async(parzen_estimation, sample, x, w,
|
async(parzen_estimation, sample, x, w,
|
||||||
group='parzen', hook=parzen_hook)
|
group='parzen', cached=True)
|
||||||
|
# return after 100 results
|
||||||
|
return result_group('parzen', count=100, cached=True)
|
||||||
|
|
||||||
# wait for 100 results to return and print it.
|
|
||||||
def parzen_hook(task):
|
|
||||||
if task.group_count() == 100:
|
|
||||||
print(task.group_result())
|
|
||||||
|
|
||||||
|
|
||||||
Django Q is not optimized for distributed computing, but this example will give you an idea of what you can do with task :ref:`groups`.
|
Django Q is not optimized for distributed computing, but this example will give you an idea of what you can do with task :ref:`groups`.
|
||||||
|
|
||||||
|
Alternatively the ``parzen_async()`` function can also be written with :func:`async_iter`, which automatically utilizes the cache backend and groups to return a single result from an iterable:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# create 100 calculations and send them to the cluster
|
||||||
|
# with async_iter
|
||||||
|
def parzen_async():
|
||||||
|
mu_vec = numpy.array([0, 0])
|
||||||
|
cov_mat = numpy.array([[1, 0], [0, 1]])
|
||||||
|
sample = numpy.random. \
|
||||||
|
multivariate_normal(mu_vec, cov_mat, 10000)
|
||||||
|
widths = numpy.linspace(1.0, 1.2, 100)
|
||||||
|
x = numpy.array([[0], [0]])
|
||||||
|
# async them with async iterable
|
||||||
|
args = [(sample, x, w) for w in widths]
|
||||||
|
result_id = async_iter(parzen_estimation, args)
|
||||||
|
# return the result or timeout after 10 seconds
|
||||||
|
return result(result_id, wait=10000)
|
||||||
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
If you have an example you want to share, please submit a pull request on `github <https://github.com/Koed00/django-q/>`__.
|
If you have an example you want to share, please submit a pull request on `github <https://github.com/Koed00/django-q/>`__.
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ Features
|
|||||||
- Asynchronous tasks
|
- Asynchronous tasks
|
||||||
- Scheduled and repeated tasks
|
- Scheduled and repeated tasks
|
||||||
- Encrypted and compressed packages
|
- Encrypted and compressed packages
|
||||||
- Failure and success database
|
- Failure and success database or cache
|
||||||
- Result hooks and groups
|
- Result hooks and groups
|
||||||
- Django Admin integration
|
- Django Admin integration
|
||||||
- PaaS compatible with multiple instances
|
- PaaS compatible with multiple instances
|
||||||
|
|||||||
+117
-10
@@ -59,6 +59,12 @@ sync
|
|||||||
Simulates a task execution synchronously. Useful for testing.
|
Simulates a task execution synchronously. Useful for testing.
|
||||||
Can also be forced globally via the :ref:`sync` configuration option.
|
Can also be forced globally via the :ref:`sync` configuration option.
|
||||||
|
|
||||||
|
cached
|
||||||
|
""""""
|
||||||
|
Redirects the result to the cache backend instead of the database if set to ``True`` or to an integer indicating the cache timeout in seconds.
|
||||||
|
e.g. ``cached=60``. Especially useful with large and group operations where you don't need the all results in your
|
||||||
|
database and want to take advantage of the speed of your cache backend.
|
||||||
|
|
||||||
broker
|
broker
|
||||||
""""""
|
""""""
|
||||||
A broker instance, in case you want to control your own connections.
|
A broker instance, in case you want to control your own connections.
|
||||||
@@ -83,6 +89,28 @@ Please not that this will override any other option keywords.
|
|||||||
For tasks to be processed you will need to have a worker cluster running in the background using ``python manage.py qcluster``
|
For tasks to be processed you will need to have a worker cluster running in the background using ``python manage.py qcluster``
|
||||||
or you need to configure Django Q to run in synchronous mode for testing using the :ref:`sync` option.
|
or you need to configure Django Q to run in synchronous mode for testing using the :ref:`sync` option.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Async Iterable
|
||||||
|
--------------
|
||||||
|
If you have an iterable object with arguments for a function, you can use :func:`async_iter` to async them with a single command::
|
||||||
|
|
||||||
|
# Async Iterable example
|
||||||
|
from django_q.tasks import async_iter, result
|
||||||
|
|
||||||
|
# set up a list of arguments for math.floor
|
||||||
|
iter = [i for i in range(100)]
|
||||||
|
|
||||||
|
# async iter them
|
||||||
|
id=async_iter('math.floor',iter)
|
||||||
|
|
||||||
|
# wait for the collated result for 1 second
|
||||||
|
result_list = result(id, wait=1000)
|
||||||
|
|
||||||
|
This will individually queue 100 tasks to the worker cluster, which will save their results in the cache backend for speed.
|
||||||
|
Once all the 100 results are in the cache, they are collated into a list and saved as a single result in the database. The cache results are then cleared.
|
||||||
|
Needs the Django cache framework.
|
||||||
|
|
||||||
.. _groups:
|
.. _groups:
|
||||||
|
|
||||||
Groups
|
Groups
|
||||||
@@ -97,14 +125,16 @@ You can group together results by passing :func:`async` the optional ``group`` k
|
|||||||
for i in range(4):
|
for i in range(4):
|
||||||
async('math.modf', i, group='modf')
|
async('math.modf', i, group='modf')
|
||||||
|
|
||||||
# after the tasks have finished you can get the group results
|
# wait until the group has 4 results
|
||||||
result = result_group('modf')
|
result = result_group('modf', count=4)
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
[(0.0, 0.0), (0.0, 1.0), (0.0, 2.0), (0.0, 3.0)]
|
[(0.0, 0.0), (0.0, 1.0), (0.0, 2.0), (0.0, 3.0)]
|
||||||
|
|
||||||
|
Note that the same can be achieved much faster with :func:`async_iter`
|
||||||
|
|
||||||
Take care to not limit your results database too much and call :func:`delete_group` before each run, unless you want your results to keep adding up.
|
Take care to not limit your results database too much and call :func:`delete_group` before each run, unless you want your results to keep adding up.
|
||||||
Instead of :func:`result_group` you can also use :func:`fetch_group` to return a queryset of :class:`Task` objects.:
|
Instead of :func:`result_group` you can also use :func:`fetch_group` to return a queryset of :class:`Task` objects.:
|
||||||
|
|
||||||
@@ -149,6 +179,54 @@ You can also access group functions from a task result instance:
|
|||||||
task.group_delete()
|
task.group_delete()
|
||||||
print('Deleted group {}'.format(task.group))
|
print('Deleted group {}'.format(task.group))
|
||||||
|
|
||||||
|
Cached operations
|
||||||
|
-----------------
|
||||||
|
You can run your tasks results against the Django cache backend instead of the database backend by either using the global :ref:`cached` setting or by supplying the ``cached`` keyword to individual functions.
|
||||||
|
This can be useful if you are not interested in persistent results or if you run large group tasks where you only want the final result.
|
||||||
|
By using a cache backend like Redis or Memcached you can speed up access to your task results significantly compared to a relational database.
|
||||||
|
|
||||||
|
When you set ``cached=True``, results will be saved permanently in the cache and you will have to rely on your backend's cleanup strategies (like LRU) to
|
||||||
|
manage stale results.
|
||||||
|
You can also opt to set a manual timeout on the results, by setting ``cached=60``. Meaning the result will be evicted from the cache after 60 seconds.
|
||||||
|
This works both globally or on individual async executions.::
|
||||||
|
|
||||||
|
# simple cached example
|
||||||
|
from django_q.tasks import async, result
|
||||||
|
|
||||||
|
# cache the result for 10 seconds
|
||||||
|
id = async('math.floor', 100, cached=10)
|
||||||
|
|
||||||
|
# wait max 50ms for the result to appear in the cache
|
||||||
|
result(id, wait=50, cached=True)
|
||||||
|
|
||||||
|
# o fetch the task object
|
||||||
|
task = fetch(id, cache=True)
|
||||||
|
|
||||||
|
# and then save it to the database
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
This also works for group actions::
|
||||||
|
|
||||||
|
# cached group example
|
||||||
|
from django_q.tasks import async, result_group
|
||||||
|
from django_q.brokers import get_broker
|
||||||
|
|
||||||
|
# set up a broker instance for better performance
|
||||||
|
broker = get_broker()
|
||||||
|
|
||||||
|
# async a hundred functions under a group label
|
||||||
|
for i in range(100):
|
||||||
|
async('math.frexp',
|
||||||
|
i,
|
||||||
|
group='frexp',
|
||||||
|
cached=True,
|
||||||
|
broker=broker)
|
||||||
|
|
||||||
|
# wait max 50ms for one hundred results to return
|
||||||
|
result_group('frexp', wait=50, count=100, cached=True)
|
||||||
|
|
||||||
|
Note that exact same result can be achieved by using the more convenient :func:`async_iter` in this case, but without hook support.
|
||||||
|
|
||||||
Synchronous testing
|
Synchronous testing
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
@@ -199,7 +277,7 @@ Reference
|
|||||||
---------
|
---------
|
||||||
|
|
||||||
.. py:function:: async(func, *args, hook=None, group=None, timeout=None,\
|
.. py:function:: async(func, *args, hook=None, group=None, timeout=None,\
|
||||||
save=None, sync=False, broker=None, q_options=None, **kwargs)
|
save=None, sync=False, cached=False, broker=None, q_options=None, **kwargs)
|
||||||
|
|
||||||
Puts a task in the cluster queue
|
Puts a task in the cluster queue
|
||||||
|
|
||||||
@@ -210,26 +288,29 @@ Reference
|
|||||||
:param int timeout: Overrides global cluster :ref:`timeout`.
|
:param int timeout: Overrides global cluster :ref:`timeout`.
|
||||||
:param bool save: Overrides global save setting for this task.
|
:param bool save: Overrides global save setting for this task.
|
||||||
:param bool sync: If set to True, async will simulate a task execution
|
:param bool sync: If set to True, async will simulate a task execution
|
||||||
|
:param cached: Output the result to the cache backend. Bool or timeout in seconds
|
||||||
:param broker: Optional broker connection from :func:`brokers.get_broker`
|
:param broker: Optional broker connection from :func:`brokers.get_broker`
|
||||||
:param dict q_options: Options dict, overrides option keywords
|
:param dict q_options: Options dict, overrides option keywords
|
||||||
:param dict kwargs: Keyword arguments for the task function
|
:param dict kwargs: Keyword arguments for the task function
|
||||||
:returns: The uuid of the task
|
:returns: The uuid of the task
|
||||||
:rtype: str
|
:rtype: str
|
||||||
|
|
||||||
.. py:function:: result(task_id, wait=0)
|
.. py:function:: result(task_id, wait=0, cached=False)
|
||||||
|
|
||||||
Gets the result of a previously executed task
|
Gets the result of a previously executed task
|
||||||
|
|
||||||
:param str task_id: the uuid or name of the task
|
:param str task_id: the uuid or name of the task
|
||||||
:param int wait: optional milliseconds to wait for a result
|
:param int wait: optional milliseconds to wait for a result
|
||||||
|
:param bool cached: run this against the cache backend.
|
||||||
:returns: The result of the executed task
|
:returns: The result of the executed task
|
||||||
|
|
||||||
.. py:function:: fetch(task_id, wait=0)
|
.. py:function:: fetch(task_id, wait=0, cached=False)
|
||||||
|
|
||||||
Returns a previously executed task
|
Returns a previously executed task
|
||||||
|
|
||||||
:param str name: the uuid or name of the task
|
:param str name: the uuid or name of the task
|
||||||
:param int wait: optional milliseconds to wait for a result
|
:param int wait: optional milliseconds to wait for a result
|
||||||
|
:param bool cached: run this against the cache backend.
|
||||||
:returns: A task object
|
:returns: A task object
|
||||||
:rtype: Task
|
:rtype: Task
|
||||||
|
|
||||||
@@ -237,6 +318,16 @@ Reference
|
|||||||
|
|
||||||
Renamed from get_task
|
Renamed from get_task
|
||||||
|
|
||||||
|
.. py:function:: async_iter(func, args_iter,**kwargs)
|
||||||
|
|
||||||
|
Runs iterable arguments against the cache backend and returns a single collated result
|
||||||
|
|
||||||
|
:param object func: The task function to execute
|
||||||
|
:param args: An iterable containing arguments for the task function
|
||||||
|
:param dict kwargs: Keyword arguments for the task function. Ignores ``cached`` and ``hook``.
|
||||||
|
:returns: The uuid of the task
|
||||||
|
:rtype: str
|
||||||
|
|
||||||
.. py:function:: queue_size()
|
.. py:function:: queue_size()
|
||||||
|
|
||||||
Returns the size of the broker queue.
|
Returns the size of the broker queue.
|
||||||
@@ -245,42 +336,58 @@ Reference
|
|||||||
:returns: The amount of task packages in the broker
|
:returns: The amount of task packages in the broker
|
||||||
:rtype: int
|
:rtype: int
|
||||||
|
|
||||||
.. py:function:: result_group(group_id, failures=False)
|
.. py:function:: result_group(group_id, failures=False, wait=0, count=None, cached=False)
|
||||||
|
|
||||||
Returns the results of a task group
|
Returns the results of a task group
|
||||||
|
|
||||||
:param str group_id: the group identifier
|
:param str group_id: the group identifier
|
||||||
:param bool failures: set this to ``True`` to include failed results
|
:param bool failures: set this to ``True`` to include failed results
|
||||||
|
:param int wait: optional milliseconds to wait for a result or count
|
||||||
|
:param int count: block until there are this many results in the group
|
||||||
|
:param bool cached: run this against the cache backend
|
||||||
:returns: a list of results
|
:returns: a list of results
|
||||||
:rtype: list
|
:rtype: list
|
||||||
|
|
||||||
.. py:function:: fetch_group(group_id, failures=True)
|
.. py:function:: fetch_group(group_id, failures=True, wait=0, count=None, cached=False)
|
||||||
|
|
||||||
Returns a list of tasks in a group
|
Returns a list of tasks in a group
|
||||||
|
|
||||||
:param str group_id: the group identifier
|
:param str group_id: the group identifier
|
||||||
:param bool failures: set this to ``False`` to exclude failed tasks
|
:param bool failures: set this to ``False`` to exclude failed tasks
|
||||||
:returns: a list of Tasks
|
:param int wait: optional milliseconds to wait for a task or count
|
||||||
|
:param int count: block until there are this many tasks in the group
|
||||||
|
:param bool cached: run this against the cache backend.
|
||||||
|
:returns: a list of :class:`Task`
|
||||||
:rtype: list
|
:rtype: list
|
||||||
|
|
||||||
.. py:function:: count_group(group_id, failures=False)
|
.. py:function:: count_group(group_id, failures=False, cached=False)
|
||||||
|
|
||||||
Counts the number of task results in a group.
|
Counts the number of task results in a group.
|
||||||
|
|
||||||
:param str group_id: the group identifier
|
:param str group_id: the group identifier
|
||||||
:param bool failures: counts the number of failures if ``True``
|
:param bool failures: counts the number of failures if ``True``
|
||||||
|
:param bool cached: run this against the cache backend.
|
||||||
:returns: the number of tasks or failures in a group
|
:returns: the number of tasks or failures in a group
|
||||||
:rtype: int
|
:rtype: int
|
||||||
|
|
||||||
.. py:function:: delete_group(group_id, tasks=False)
|
.. py:function:: delete_group(group_id, tasks=False, cached=False)
|
||||||
|
|
||||||
Deletes a group label from the database.
|
Deletes a group label from the database.
|
||||||
|
|
||||||
:param str group_id: the group identifier
|
:param str group_id: the group identifier
|
||||||
:param bool tasks: also deletes the associated tasks if ``True``
|
:param bool tasks: also deletes the associated tasks if ``True``
|
||||||
|
:param bool cached: run this against the cache backend.
|
||||||
:returns: the numbers of tasks affected
|
:returns: the numbers of tasks affected
|
||||||
:rtype: int
|
:rtype: int
|
||||||
|
|
||||||
|
.. py:function:: delete_cached(task_id, broker=None)
|
||||||
|
|
||||||
|
Deletes a task from the cache backend
|
||||||
|
|
||||||
|
:param task_id: the uuid of the task
|
||||||
|
:param broker: an optional broker instance
|
||||||
|
|
||||||
|
|
||||||
.. py:class:: Task
|
.. py:class:: Task
|
||||||
|
|
||||||
Database model describing an executed task
|
Database model describing an executed task
|
||||||
|
|||||||
Reference in New Issue
Block a user