├── django_toosimple_q
├── contrib
│ ├── __init__.py
│ └── mail
│ │ ├── __init__.py
│ │ ├── backend.py
│ │ ├── tasks.py
│ │ └── tests.py
├── tests
│ ├── __init__.py
│ ├── demo
│ │ ├── __init__.py
│ │ └── tasks.py
│ ├── concurrency
│ │ ├── __init__.py
│ │ └── tasks.py
│ ├── urls.py
│ ├── settings_bg.py
│ ├── utils.py
│ ├── settings_bg_lag.py
│ ├── tests_integration.py
│ ├── settings.py
│ ├── tests_regression.py
│ ├── tests_concurrency.py
│ ├── tests_worker.py
│ ├── tests_admin.py
│ ├── base.py
│ ├── tests_schedules.py
│ └── tests_tasks.py
├── management
│ ├── __init__.py
│ ├── commands
│ │ ├── __init__.py
│ │ └── worker.py
│ └── __pycache__
│ │ ├── __init__.cpython-36.pyc
│ │ └── __init__.cpython-37.pyc
├── migrations
│ ├── __init__.py
│ ├── 0003_task_queue.py
│ ├── 0004_auto_20200507_1339.py
│ ├── 0007_schedule_datetime_kwarg.py
│ ├── 0014_alter_workerstatus_exit_code.py
│ ├── 0006_task_replacement.py
│ ├── 0009_auto_20210902_2245.py
│ ├── 0012_rename_last_run_scheduleexec_last_task.py
│ ├── 0013_workerstatus_exit_code_workerstatus_exit_log.py
│ ├── 0002_auto_20191101_1838.py
│ ├── 0015_taskexec_result_preview.py
│ ├── 0016_alter_scheduleexec_options_alter_taskexec_options.py
│ ├── 0008_auto_20210902_2111.py
│ ├── 0005_auto_20210302_1748.py
│ ├── 0011_workerstatus.py
│ ├── 0010_auto_20220324_0419.py
│ └── 0001_initial.py
├── templates
│ └── toosimpleq
│ │ ├── task.html
│ │ └── schedule.html
├── __init__.py
├── registry.py
├── apps.py
├── logging.py
├── schedule.py
├── decorators.py
├── task.py
├── models.py
└── admin.py
├── requirements.txt
├── .gitignore
├── MANIFEST.in
├── requirements-dev.txt
├── .editorconfig
├── scripts
├── ci_set_version_from_git.py
└── ci_assert_version_from_git.py
├── manage.py
├── Dockerfile
├── .github
└── workflows
│ ├── pypi.yml
│ └── test.yml
├── .pre-commit-config.yaml
├── LICENSE
├── setup.py
├── docker-compose.yml
└── README.md
/django_toosimple_q/contrib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/contrib/mail/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/demo/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/concurrency/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/django_toosimple_q/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | django>=3.2
2 | django-picklefield>=3.0
3 | croniter>=1.3
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | .vscode
3 | *.egg-info
4 | *.sqlite3
5 | build
6 | dist
7 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include django_toosimple_q *
2 | recursive-exclude django_toosimple_q *.pyc
3 |
--------------------------------------------------------------------------------
/django_toosimple_q/templates/toosimpleq/task.html:
--------------------------------------------------------------------------------
1 |
2 | queue: {{task.queue}}
3 | priority: {{task.priority}}
4 |
5 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | # tests
2 | freezegun==1.5.1
3 | psycopg2==2.*
4 |
5 | # devenv
6 | pre-commit
7 | mypy
8 |
9 | # django-toosimple-q
10 | -e .
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
--------------------------------------------------------------------------------
/django_toosimple_q/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "dev" # DO NOT CHANGE THIS LINE - it will be replaced by CI workflow
2 | default_app_config = "django_toosimple_q.apps.DjangoToosimpleQConfig"
3 |
--------------------------------------------------------------------------------
/django_toosimple_q/management/__pycache__/__init__.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/olivierdalang/django-toosimple-q/HEAD/django_toosimple_q/management/__pycache__/__init__.cpython-36.pyc
--------------------------------------------------------------------------------
/django_toosimple_q/management/__pycache__/__init__.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/olivierdalang/django-toosimple-q/HEAD/django_toosimple_q/management/__pycache__/__init__.cpython-37.pyc
--------------------------------------------------------------------------------
/django_toosimple_q/templates/toosimpleq/schedule.html:
--------------------------------------------------------------------------------
1 |
2 | cron: {{schedule.cron}}
3 | args: {{schedule.args}}
4 | kwargs: {{schedule.kwargs}}
5 | queue: {{schedule.queue}}
6 |
7 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/urls.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, unicode_literals
2 |
3 | from django.contrib import admin
4 | from django.urls import path
5 |
6 | urlpatterns = [
7 | path("admin/", admin.site.urls),
8 | ]
9 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/settings_bg.py:
--------------------------------------------------------------------------------
1 | """Settings overrides for testing background workers"""
2 |
3 | from .settings import *
4 |
5 | # The background workers must also use the test database
6 | DATABASES["default"].update(DATABASES["default"]["TEST"])
7 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def is_postgres():
5 | return os.getenv("TOOSIMPLEQ_TEST_DB", None) == "postgres"
6 |
7 |
8 | class FakeException(Exception):
9 | """An artification exception to simulate an unexpected error"""
10 |
--------------------------------------------------------------------------------
/django_toosimple_q/contrib/mail/backend.py:
--------------------------------------------------------------------------------
1 | from django.core.mail.backends.base import BaseEmailBackend
2 |
3 | from .tasks import send_email
4 |
5 |
6 | class QueueBackend(BaseEmailBackend):
7 | def __init__(self, **kwargs):
8 | super().__init__(kwargs)
9 |
10 | def send_messages(self, email_messages):
11 | send_email.queue(email_messages)
12 |
--------------------------------------------------------------------------------
/scripts/ci_set_version_from_git.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | basedir = os.path.dirname(os.path.dirname(__file__))
4 |
5 | name = os.getenv("GITHUB_TAG").split("/")[2]
6 | path = os.path.join(basedir, "django_toosimple_q", "__init__.py")
7 |
8 | # read file
9 | contents = open(path, "r").read()
10 |
11 | # replace contents
12 | open(path, "w").write(contents.replace("dev", name, 1))
13 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | from __future__ import absolute_import, unicode_literals
4 |
5 | import os
6 | import sys
7 |
8 | if __name__ == "__main__":
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_toosimple_q.tests.settings")
10 | from django.core.management import execute_from_command_line
11 |
12 | execute_from_command_line(sys.argv)
13 |
--------------------------------------------------------------------------------
/scripts/ci_assert_version_from_git.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | basedir = os.path.dirname(os.path.dirname(__file__))
5 | sys.path.append(basedir)
6 |
7 | import django_toosimple_q # noqa
8 |
9 | version = django_toosimple_q.__version__
10 | tag = os.getenv("GITHUB_TAG")
11 |
12 | assert (
13 | f"refs/heads/{version}" == tag or f"refs/tags/{version}" == tag
14 | ), f"Version mismatch : {version} != {tag}"
15 |
--------------------------------------------------------------------------------
/django_toosimple_q/registry.py:
--------------------------------------------------------------------------------
1 | class Registry(dict):
2 | def for_queue(self, queues=None, excluded_queues=None):
3 | for item in self.values():
4 | if queues and item.queue not in queues:
5 | continue
6 | if excluded_queues and item.queue in excluded_queues:
7 | continue
8 | yield item
9 |
10 |
11 | schedules_registry = Registry()
12 | tasks_registry = Registry()
13 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/settings_bg_lag.py:
--------------------------------------------------------------------------------
1 | """Settings overrides for testing concurrent workers by simulating latency with toxiproxy which"""
2 |
3 | from .settings_bg import *
4 |
5 | # On postgres, background workers connect through toxiproxy to simluate latency
6 | if is_postgres():
7 | DATABASES["default"]["HOST"] = os.environ.get("POSTGRES_HOST_WORKER", "127.0.0.1")
8 | DATABASES["default"]["PORT"] = os.environ.get("POSTGRES_PORT_WORKER", "5433")
9 |
--------------------------------------------------------------------------------
/django_toosimple_q/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.utils.module_loading import autodiscover_modules
3 |
4 | from .logging import logger, show_registry
5 |
6 |
7 | class DjangoToosimpleQConfig(AppConfig):
8 | name = "django_toosimple_q"
9 | label = "toosimpleq"
10 |
11 | def ready(self):
12 | # Autodicover tasks.py modules
13 |
14 | logger.info("Autodiscovering tasks.py...")
15 | autodiscover_modules("tasks")
16 |
17 | show_registry()
18 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0003_task_queue.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.6 on 2019-12-04 13:32
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0002_auto_20191101_1838"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="task",
14 | name="queue",
15 | field=models.CharField(default="default", max_length=32),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0004_auto_20200507_1339.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2020-05-07 11:39
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0003_task_queue"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="schedule",
14 | name="name",
15 | field=models.CharField(max_length=1024, unique=True),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0007_schedule_datetime_kwarg.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2021-06-22 17:08
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0006_task_replacement"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="schedule",
14 | name="datetime_kwarg",
15 | field=models.CharField(blank=True, max_length=1024, null=True),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/django_toosimple_q/contrib/mail/tasks.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.mail import get_connection
3 |
4 | from django_toosimple_q.decorators import register_task
5 |
6 |
7 | @register_task(unique=True, retries=10, retry_delay=3)
8 | def send_email(emails):
9 | backend = getattr(
10 | settings,
11 | "TOOSIMPLEQ_EMAIL_BACKEND",
12 | "django.core.mail.backends.smtp.EmailBackend",
13 | )
14 |
15 | conn = get_connection(backend=backend)
16 | conn.open()
17 | conn.send_messages(emails)
18 | conn.close()
19 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG TOOSIMPLEQ_PY_VERSION
2 |
3 | FROM python:$TOOSIMPLEQ_PY_VERSION
4 |
5 | WORKDIR /app
6 |
7 | # Install app in editable mode
8 | ADD ./requirements.txt /app/requirements.txt
9 | ADD ./requirements-dev.txt /app/requirements-dev.txt
10 | RUN touch /app/README.md
11 | ADD ./django_toosimple_q/__init__.py /app/django_toosimple_q/__init__.py
12 | ADD ./setup.py /app/setup.py
13 | RUN pip install -r requirements-dev.txt
14 |
15 | # Override django version
16 | ARG TOOSIMPLEQ_DJ_VERSION
17 | RUN pip install Django==$TOOSIMPLEQ_DJ_VERSION
18 |
19 | # Add source files
20 | ADD . /app
21 |
22 | # Default command runs tests
23 | ENTRYPOINT ["python", "manage.py"]
24 | CMD ["--help"]
25 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0014_alter_workerstatus_exit_code.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.5 on 2023-02-23 23:54
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0013_workerstatus_exit_code_workerstatus_exit_log"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="workerstatus",
14 | name="exit_code",
15 | field=models.IntegerField(
16 | blank=True,
17 | choices=[(0, "Stopped"), (77, "Terminated"), (99, "Crashed")],
18 | null=True,
19 | ),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0006_task_replacement.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2021-06-14 19:54
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("toosimpleq", "0005_auto_20210302_1748"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="task",
15 | name="replaced_by",
16 | field=models.ForeignKey(
17 | blank=True,
18 | null=True,
19 | on_delete=django.db.models.deletion.SET_NULL,
20 | to="toosimpleq.Task",
21 | ),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0009_auto_20210902_2245.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2021-09-02 20:45
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0008_auto_20210902_2111"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="schedule",
14 | name="id",
15 | field=models.BigAutoField(primary_key=True, serialize=False),
16 | ),
17 | migrations.AlterField(
18 | model_name="task",
19 | name="id",
20 | field=models.BigAutoField(primary_key=True, serialize=False),
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0012_rename_last_run_scheduleexec_last_task.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.5 on 2023-01-12 21:14
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0011_workerstatus"),
9 | ]
10 |
11 | operations = [
12 | migrations.RenameField(
13 | model_name="scheduleexec",
14 | old_name="last_run",
15 | new_name="last_task",
16 | ),
17 | migrations.AddField(
18 | model_name="scheduleexec",
19 | name="last_due",
20 | field=models.DateTimeField(blank=True, null=True),
21 | ),
22 | migrations.RemoveField(
23 | model_name="scheduleexec",
24 | name="last_tick",
25 | ),
26 | ]
27 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0013_workerstatus_exit_code_workerstatus_exit_log.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.5 on 2023-01-11 23:24
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0012_rename_last_run_scheduleexec_last_task"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="workerstatus",
14 | name="exit_log",
15 | field=models.TextField(blank=True, null=True),
16 | ),
17 | migrations.AddField(
18 | model_name="workerstatus",
19 | name="exit_code",
20 | field=models.IntegerField(
21 | blank=True,
22 | choices=[(0, "Stopped"), (1, "Terminated"), (2, "Crashed")],
23 | null=True,
24 | ),
25 | ),
26 | ]
27 |
--------------------------------------------------------------------------------
/.github/workflows/pypi.yml:
--------------------------------------------------------------------------------
1 | name: pypi
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v1
13 | - name: Install dependencies
14 | run: |
15 | python -m pip install --upgrade pip
16 | pip install setuptools wheel twine==6.0.1
17 | - name: Set version
18 | env:
19 | GITHUB_TAG: ${{github.ref}}
20 | run: python scripts/ci_set_version_from_git.py
21 | - name: Build
22 | run: python setup.py sdist bdist_wheel
23 | - name: Assert version
24 | env:
25 | GITHUB_TAG: ${{github.ref}}
26 | run: python scripts/ci_assert_version_from_git.py
27 | - name: Publish
28 | env:
29 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
30 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
31 | run: twine upload dist/*
32 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0002_auto_20191101_1838.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.6 on 2019-11-01 17:38
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0001_initial"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="task",
14 | name="state",
15 | field=models.CharField(
16 | choices=[
17 | ("QUEUED", "QUEUED"),
18 | ("PROCESSING", "PROCESSING"),
19 | ("FAILED", "FAILED"),
20 | ("SUCCEEDED", "SUCCEEDED"),
21 | ("INVALID", "INVALID"),
22 | ("INTERRUPTED", "INTERRUPTED"),
23 | ],
24 | default="QUEUED",
25 | max_length=32,
26 | ),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0015_taskexec_result_preview.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1 on 2024-03-04 15:28
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | def populate_result_preview(apps, schema_editor):
7 | # Populate the new result preview field
8 | TaskExec = apps.get_model("toosimpleq", "TaskExec")
9 | TaskExec.objects.filter(result__isnull=False).update(
10 | result_preview="*preview not generated*"
11 | )
12 |
13 |
14 | class Migration(migrations.Migration):
15 | dependencies = [
16 | ("toosimpleq", "0014_alter_workerstatus_exit_code"),
17 | ]
18 |
19 | operations = [
20 | migrations.AddField(
21 | model_name="taskexec",
22 | name="result_preview",
23 | field=models.CharField(
24 | blank=True, editable=False, max_length=255, null=True
25 | ),
26 | ),
27 | migrations.RunPython(populate_result_preview),
28 | ]
29 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0016_alter_scheduleexec_options_alter_taskexec_options.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1 on 2025-04-17 08:30
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("toosimpleq", "0015_taskexec_result_preview"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterModelOptions(
13 | name="scheduleexec",
14 | options={
15 | "permissions": [
16 | ("force_run_scheduleexec", "Can force execution of schedules")
17 | ],
18 | "verbose_name": "Schedule Execution",
19 | },
20 | ),
21 | migrations.AlterModelOptions(
22 | name="taskexec",
23 | options={
24 | "permissions": [("requeue_taskexec", "Can requeue tasks")],
25 | "verbose_name": "Task Execution",
26 | },
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0008_auto_20210902_2111.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2021-09-02 19:11
2 |
3 | import django.db.models.deletion
4 | import django.utils.timezone
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("toosimpleq", "0007_schedule_datetime_kwarg"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name="schedule",
16 | name="last_check",
17 | field=models.DateTimeField(
18 | blank=True, default=django.utils.timezone.now, null=True
19 | ),
20 | ),
21 | migrations.AlterField(
22 | model_name="schedule",
23 | name="last_run",
24 | field=models.ForeignKey(
25 | blank=True,
26 | null=True,
27 | on_delete=django.db.models.deletion.SET_NULL,
28 | to="toosimpleq.task",
29 | ),
30 | ),
31 | ]
32 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # files: '^.*\.py|.*\.tpl|.*\.sql|.*\.yaml|.*\.txt|.*\.md$'
2 | # exclude: '^.*\.py|.*\.tpl|.*\.sql|.*\.yaml|.*\.txt|.*\.md$'
3 |
4 | repos:
5 | # Fix end of files
6 | - repo: https://github.com/pre-commit/pre-commit-hooks
7 | rev: v4.4.0
8 | hooks:
9 | - id: trailing-whitespace
10 | - id: end-of-file-fixer
11 | - id: mixed-line-ending
12 | args:
13 | - '--fix=lf'
14 |
15 | # Remove unused imports/variables
16 | - repo: https://github.com/myint/autoflake
17 | rev: v2.0.1
18 | hooks:
19 | - id: autoflake
20 | args:
21 | - "--in-place"
22 | - "--remove-all-unused-imports"
23 | - "--remove-unused-variable"
24 |
25 | # Sort imports
26 | - repo: https://github.com/pycqa/isort
27 | rev: "5.12.0"
28 | hooks:
29 | - id: isort
30 | args:
31 | - --profile
32 | - black
33 |
34 | # Black formatting
35 | - repo: https://github.com/psf/black
36 | rev: "23.1.0"
37 | hooks:
38 | - id: black
39 |
--------------------------------------------------------------------------------
/django_toosimple_q/logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from .registry import schedules_registry, tasks_registry
4 |
5 | formatter = logging.Formatter(
6 | "[%(asctime)s][%(levelname)s][toosimpleq] %(message)s", "%Y-%m-%d %H:%M:%S"
7 | )
8 |
9 | handler = logging.StreamHandler()
10 | handler.setFormatter(formatter)
11 |
12 | logger = logging.getLogger("toosimpleq")
13 | logger.setLevel(logging.INFO)
14 | logger.addHandler(handler)
15 |
16 |
17 | def show_registry():
18 | """Helper functions that shows the registry contents"""
19 |
20 | if len(schedules_registry):
21 | schedules_names = ", ".join(schedules_registry.keys())
22 | logger.info(
23 | f"{len(schedules_registry)} schedules registered: {schedules_names}"
24 | )
25 | else:
26 | logger.info("No schedules registered")
27 |
28 | if len(tasks_registry):
29 | tasks_names = ", ".join(tasks_registry.keys())
30 | logger.info(f"{len(tasks_registry)} tasks registered: {tasks_names}")
31 | else:
32 | logger.info("No tasks registered")
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2021 Olivier Dalang
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/concurrency/tasks.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from django.contrib.auth.models import User
4 | from django.db import IntegrityError
5 |
6 | from ...decorators import register_task, schedule_task
7 |
8 |
9 | @schedule_task(cron="* * * * *", queue="schedules", run_on_creation=True)
10 | @register_task(name="create_user", queue="tasks")
11 | def create_user():
12 | time.sleep(0.5)
13 | retry = 0
14 | while True:
15 | suffix = f"-copy{retry}" if retry > 0 else ""
16 | try:
17 | User.objects.create(username=f"user{suffix}")
18 | break
19 | except IntegrityError:
20 | retry += 1
21 |
22 | if retry > 0:
23 | raise Exception("Failed: had to rename the user")
24 | return 0
25 |
26 |
27 | @register_task(name="sleep_task", queue="tasks")
28 | def sleep_task(duration):
29 | t1 = time.time()
30 | while time.time() - t1 < duration:
31 | pass
32 | return True
33 |
34 |
35 | @register_task(name="output_string_task", queue="tasks")
36 | def output_string_task():
37 | return "***OUTPUT_A***"
38 |
39 |
40 | @schedule_task(cron="* * * * * *", queue="regr_schedule_short")
41 | @register_task(name="regr_schedule_short", queue="_")
42 | def regr_schedule_short():
43 | return True
44 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_integration.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.core.management import call_command
4 | from django.test import TestCase
5 |
6 |
7 | class TestIntegration(TestCase):
8 | def test_readme(self):
9 | # Test code in the readme
10 |
11 | readme = open("README.md", "r").read()
12 |
13 | # This finds all ```python``` blocks
14 | python_blocks = re.findall(r"```python\n([\s\S]*?)```", readme, re.MULTILINE)
15 |
16 | # Run each block
17 | full_python_code = ""
18 | for python_block in python_blocks:
19 | # We concatenate all blocks with the previous ones (so we keep imports)
20 | full_python_code += python_block
21 | try:
22 | exec(full_python_code)
23 | except Exception as e:
24 | hr = "~" * 80
25 | raise Exception(
26 | f"Invalid readme block:\n{hr}\n{python_block}{hr}"
27 | ) from e
28 |
29 | def test_makemigrations(self):
30 | # Ensure migrations are up to date with model changes
31 | try:
32 | call_command("makemigrations", "--check", "--dry-run")
33 | except SystemExit:
34 | raise AssertionError(
35 | "Migrations are not up to date. You need to run `makemigrations`."
36 | )
37 |
--------------------------------------------------------------------------------
/django_toosimple_q/schedule.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Dict, List, Optional
3 |
4 | from .logging import logger
5 | from .task import Task
6 |
7 |
8 | class Schedule:
9 | """A configuration for repeated execution of tasks. These are typically configured in `tasks.py`"""
10 |
11 | def __init__(
12 | self,
13 | name: str,
14 | task: Task,
15 | cron: str,
16 | queue: str = "default",
17 | args: List = [],
18 | kwargs: Dict = {},
19 | datetime_kwarg: str = None,
20 | catch_up: bool = False,
21 | run_on_creation: bool = False,
22 | ):
23 | self.name = name
24 | self.task = task
25 | self.cron = cron
26 | self.queue = queue
27 | self.args = args
28 | self.kwargs = kwargs
29 | self.datetime_kwarg = datetime_kwarg
30 | self.catch_up = catch_up
31 | self.run_on_creation = run_on_creation
32 |
33 | def execute(self, dues: List[Optional[datetime]]):
34 | """Enqueues the related tasks at the given due dates"""
35 |
36 | # We enqueue the due tasks
37 | for due in dues:
38 | logger.debug(f"{self} is due at {due}")
39 |
40 | dt_kwarg = {}
41 | if self.datetime_kwarg:
42 | dt_kwarg = {self.datetime_kwarg: due}
43 |
44 | self.task.enqueue(*self.args, due=due, **dt_kwarg, **self.kwargs)
45 |
46 | def __str__(self):
47 | return f"Schedule {self.name}"
48 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 | from setuptools import find_packages, setup
5 |
6 |
7 | def get_version(*file_paths):
8 | """Retrieves the version from django_toosimple_q/__init__.py"""
9 | filename = os.path.join(os.path.dirname(__file__), *file_paths)
10 | version_file = open(filename).read()
11 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
12 | if version_match:
13 | version_str = version_match.group(1)
14 | return "0.0.0-dev" if version_str == "dev" else version_str
15 | raise RuntimeError("Unable to find version string.")
16 |
17 |
18 | setup(
19 | name="django-toosimple-q",
20 | version=get_version("django_toosimple_q", "__init__.py"),
21 | description="""A simplistic task queue and cron-like scheduler for Django""",
22 | long_description=open("README.md").read(),
23 | long_description_content_type="text/markdown",
24 | author="Olivier Dalang",
25 | author_email="olivier.dalang@gmail.com",
26 | url="https://github.com/olivierdalang/django-toosimple-q",
27 | packages=find_packages(include=["django_toosimple_q", "django_toosimple_q.*"]),
28 | include_package_data=True,
29 | install_requires=open("requirements.txt").readlines(),
30 | license="MIT",
31 | zip_safe=False,
32 | keywords="django-toosimple-q",
33 | classifiers=[
34 | "Development Status :: 3 - Alpha",
35 | "Framework :: Django :: 2.2",
36 | "Intended Audience :: Developers",
37 | "License :: OSI Approved :: BSD License",
38 | "Natural Language :: English",
39 | "Programming Language :: Python :: 3",
40 | "Programming Language :: Python :: 3.7",
41 | ],
42 | )
43 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | test:
11 | name: Tests dj${{ matrix.dj }} / ${{ matrix.db }} / py${{ matrix.py }}
12 |
13 | runs-on: ubuntu-latest
14 | strategy:
15 | # all supported django version on lowest/highest supported python
16 | # see https://www.djangoproject.com/download/#supported-versions
17 | # and https://docs.djangoproject.com/en/4.2/faq/install/#what-python-version-can-i-use-with-django
18 | matrix:
19 | include:
20 | - { dj: "4.2", db: "sqlite", py: "3.8" }
21 | - { dj: "4.2", db: "sqlite", py: "3.12" }
22 | - { dj: "4.2", db: "postgres", py: "3.8" }
23 | - { dj: "4.2", db: "postgres", py: "3.12" }
24 | - { dj: "5.1", db: "sqlite", py: "3.10" }
25 | - { dj: "5.1", db: "sqlite", py: "3.12" }
26 | - { dj: "5.1", db: "postgres", py: "3.10" }
27 | - { dj: "5.1", db: "postgres", py: "3.12" }
28 | - { dj: "5.2", db: "sqlite", py: "3.10" }
29 | - { dj: "5.2", db: "sqlite", py: "3.13" }
30 | - { dj: "5.2", db: "postgres", py: "3.10" }
31 | - { dj: "5.2", db: "postgres", py: "3.13" }
32 | fail-fast: false
33 |
34 | env:
35 | TOOSIMPLEQ_DJ_VERSION: ${{ matrix.dj }}
36 | TOOSIMPLEQ_PY_VERSION: ${{ matrix.py }}
37 | TOOSIMPLEQ_TEST_DB: ${{ matrix.db }}
38 |
39 | steps:
40 | - uses: actions/checkout@v1
41 |
42 | - name: Lint with pre-commit
43 | uses: pre-commit/action@v3.0.1
44 |
45 | - name: Docker build
46 | run: docker compose build
47 |
48 | - name: Run tests
49 | run: docker compose run django test
50 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0005_auto_20210302_1748.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2021-03-02 16:48
2 |
3 | import django.utils.timezone
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("toosimpleq", "0004_auto_20200507_1339"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="task",
15 | name="due",
16 | field=models.DateTimeField(default=django.utils.timezone.now),
17 | ),
18 | migrations.AddField(
19 | model_name="task",
20 | name="retries",
21 | field=models.IntegerField(
22 | default=0, help_text="retries left, -1 means infinite"
23 | ),
24 | ),
25 | migrations.AddField(
26 | model_name="task",
27 | name="retry_delay",
28 | field=models.IntegerField(
29 | default=0,
30 | help_text="Delay before next retry in seconds. Will double after each failure.",
31 | ),
32 | ),
33 | migrations.AlterField(
34 | model_name="task",
35 | name="state",
36 | field=models.CharField(
37 | choices=[
38 | ("QUEUED", "QUEUED"),
39 | ("SLEEPING", "SLEEPING"),
40 | ("PROCESSING", "PROCESSING"),
41 | ("FAILED", "FAILED"),
42 | ("SUCCEEDED", "SUCCEEDED"),
43 | ("INVALID", "INVALID"),
44 | ("INTERRUPTED", "INTERRUPTED"),
45 | ],
46 | default="QUEUED",
47 | max_length=32,
48 | ),
49 | ),
50 | ]
51 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0011_workerstatus.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-27 20:22
2 |
3 | import datetime
4 |
5 | import django.db.models.deletion
6 | import django.utils.timezone
7 | from django.db import migrations, models
8 |
9 |
10 | class Migration(migrations.Migration):
11 | dependencies = [
12 | ("toosimpleq", "0010_auto_20220324_0419"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="WorkerStatus",
18 | fields=[
19 | ("id", models.BigAutoField(primary_key=True, serialize=False)),
20 | ("label", models.CharField(max_length=1024, unique=True)),
21 | ("included_queues", models.JSONField(default=list)),
22 | ("excluded_queues", models.JSONField(default=list)),
23 | (
24 | "timeout",
25 | models.DurationField(default=datetime.timedelta(seconds=3600)),
26 | ),
27 | ("last_tick", models.DateTimeField(default=django.utils.timezone.now)),
28 | ("started", models.DateTimeField(default=django.utils.timezone.now)),
29 | ("stopped", models.DateTimeField(blank=True, null=True)),
30 | ],
31 | options={
32 | "verbose_name": "Worker Status",
33 | "verbose_name_plural": "Workers Statuses",
34 | },
35 | ),
36 | migrations.AddField(
37 | model_name="taskexec",
38 | name="worker",
39 | field=models.ForeignKey(
40 | blank=True,
41 | null=True,
42 | on_delete=django.db.models.deletion.SET_NULL,
43 | to="toosimpleq.workerstatus",
44 | ),
45 | ),
46 | ]
47 |
--------------------------------------------------------------------------------
/django_toosimple_q/decorators.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ImproperlyConfigured
2 |
3 | from .registry import schedules_registry, tasks_registry
4 | from .schedule import Schedule
5 | from .task import Task
6 |
7 |
8 | def register_task(**kwargs):
9 | """Attaches ._task attribute, the .queue() method and adds the callable to the tasks registry"""
10 |
11 | def inner(func):
12 | # Default name is the qualified function name
13 | if "name" not in kwargs:
14 | kwargs["name"] = func.__globals__["__name__"] + "." + func.__qualname__
15 |
16 | # Create the task instance
17 | kwargs["callable"] = func
18 | task = Task(**kwargs)
19 |
20 | # Attach that instance to the callable
21 | func._task = task
22 |
23 | # Include the `queue` callable
24 | func.queue = task.enqueue
25 |
26 | # Add to the registry
27 | tasks_registry[task.name] = task
28 |
29 | # Decorator returns the function itself
30 | return func
31 |
32 | return inner
33 |
34 |
35 | def schedule_task(**kwargs):
36 | """Adds the task to the schedules registry"""
37 |
38 | def inner(func):
39 | if not hasattr(func, "_task"):
40 | raise ImproperlyConfigured(
41 | "Only registered tasks can be scheduled."
42 | " Are you sure you registered your callable with the @register_task() decorator ?"
43 | )
44 |
45 | # Default name is the name of the task
46 | if "name" not in kwargs:
47 | kwargs["name"] = func._task.name
48 |
49 | # Create the schedule instance
50 | kwargs["task"] = func._task
51 | schedule = Schedule(**kwargs)
52 |
53 | # Add to the registry
54 | schedules_registry[kwargs["name"]] = schedule
55 |
56 | # Decorator returns the function itself
57 | return func
58 |
59 | return inner
60 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # Docker-compose configuration to run tests
2 |
3 | version: "3"
4 |
5 | x-default-django:
6 | &default-django
7 | build:
8 | context: .
9 | args:
10 | TOOSIMPLEQ_DJ_VERSION: ${TOOSIMPLEQ_DJ_VERSION:-5.0}
11 | TOOSIMPLEQ_PY_VERSION: ${TOOSIMPLEQ_PY_VERSION:-3.11}
12 | depends_on:
13 | postgres:
14 | condition: service_healthy
15 | toxiproxy-config:
16 | condition: service_started
17 | environment:
18 | TOOSIMPLEQ_TEST_DB: ${TOOSIMPLEQ_TEST_DB:-postgres}
19 | POSTGRES_HOST: postgres
20 | POSTGRES_PORT: 5432
21 | POSTGRES_HOST_WORKER: postgres-laggy
22 | POSTGRES_PORT_WORKER: 5433
23 | volumes:
24 | - .:/app
25 |
26 | services:
27 |
28 | django:
29 | <<: *default-django
30 | command: runserver 0.0.0.0:8000
31 | ports:
32 | - 8000:8000
33 |
34 | worker:
35 | <<: *default-django
36 | command: worker --queue demo --verbosity 3
37 |
38 | postgres:
39 | image: postgres
40 | environment:
41 | POSTGRES_PASSWORD: postgres
42 | healthcheck:
43 | test: ["CMD", "bash", "-c", "pg_isready -U postgres"]
44 | interval: 5s
45 | retries: 6
46 | start_period: 1s
47 | ports:
48 | - 5432:5432
49 |
50 | postgres-laggy:
51 | image: ghcr.io/shopify/toxiproxy
52 | depends_on:
53 | postgres:
54 | condition: service_healthy
55 | environment:
56 | POSTGRES_PASSWORD: postgres
57 | ports:
58 | - 5433:5433
59 |
60 | toxiproxy-config:
61 | image: docker:cli
62 | depends_on:
63 | postgres-laggy:
64 | condition: service_started
65 | volumes:
66 | - /var/run/docker.sock:/var/run/docker.sock
67 | command: |
68 | sh -c '
69 | docker exec django-toosimple-q-postgres-laggy-1 /toxiproxy-cli create -l 0.0.0.0:5433 -u postgres:5432 postgres
70 | docker exec django-toosimple-q-postgres-laggy-1 /toxiproxy-cli toxic add -t latency -n my_lag -a latency=100 -a jitter=5 postgres
71 | '
72 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/demo/tasks.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import random
3 | import sys
4 | import time
5 |
6 | from django.utils import timezone
7 | from django.utils.formats import time_format
8 |
9 | from ...decorators import register_task, schedule_task
10 | from ...models import TaskExec
11 |
12 |
13 | @schedule_task(
14 | cron="* * * * * */30", datetime_kwarg="scheduled_time", queue="demo", catch_up=True
15 | )
16 | @register_task(name="say_hi", queue="demo")
17 | def say_hi(scheduled_time):
18 | if scheduled_time is None:
19 | return "Hi ! This was not scheduled..."
20 | return f"Hi at {time_format(scheduled_time)}"
21 |
22 |
23 | @schedule_task(cron="0 * * * * *", queue="demo")
24 | @register_task(name="flaky", retries=3, retry_delay=2, queue="demo")
25 | def flaky():
26 | if random.random() < 0.5:
27 | raise Exception("This failed, we'll retry !")
28 | else:
29 | return "This succeeded"
30 |
31 |
32 | @schedule_task(cron="0 * * * * *", queue="demo")
33 | @register_task(name="logging", queue="demo")
34 | def logging():
35 | sys.stdout.write("This should go to standard output")
36 | sys.stderr.write("This should go to error output")
37 | return "This is the result"
38 |
39 |
40 | @schedule_task(cron="0 */5 * * * *", queue="demo")
41 | @register_task(name="long_running", queue="demo")
42 | def long_running():
43 | text = f"started at {timezone.now()}\n"
44 | time.sleep(15)
45 | text += f"continue at {timezone.now()}\n"
46 | time.sleep(15)
47 | text += f"continue at {timezone.now()}\n"
48 | time.sleep(15)
49 | text += f"continue at {timezone.now()}\n"
50 | time.sleep(15)
51 | text += f"finishing at {timezone.now()}\n"
52 | return text
53 |
54 |
55 | @schedule_task(cron="manual", queue="demo")
56 | @register_task(name="cleanup", queue="demo", priority=-5)
57 | def cleanup():
58 | old_tasks_execs = TaskExec.objects.filter(
59 | created__lte=timezone.now() - datetime.timedelta(minutes=10)
60 | )
61 | deletes = old_tasks_execs.delete()
62 | print(f"Deleted {deletes}")
63 | return True
64 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/settings.py:
--------------------------------------------------------------------------------
1 | """Settings for running tests"""
2 |
3 | import os
4 |
5 | from .utils import is_postgres
6 |
7 | DEBUG = True
8 | USE_TZ = True
9 | TIME_ZONE = "UTC"
10 | SECRET_KEY = "secret_key"
11 |
12 | if is_postgres():
13 | DATABASES = {
14 | "default": {
15 | "ENGINE": "django.db.backends.postgresql",
16 | "HOST": os.environ.get("POSTGRES_HOST", "127.0.0.1"),
17 | "PORT": os.environ.get("POSTGRES_PORT", "5432"),
18 | "NAME": "postgres",
19 | "USER": "postgres",
20 | "PASSWORD": "postgres",
21 | "TEST": {
22 | "NAME": "test_postgres",
23 | },
24 | }
25 | }
26 | else:
27 | DATABASES = {
28 | "default": {
29 | "ENGINE": "django.db.backends.sqlite3",
30 | "NAME": "db.sqlite3",
31 | "OPTIONS": {"timeout": 50},
32 | "TEST": {
33 | "NAME": "db-test.sqlite3",
34 | },
35 | }
36 | }
37 |
38 | INSTALLED_APPS = [
39 | "django_toosimple_q.tests.concurrency",
40 | "django_toosimple_q.tests.demo",
41 | "django_toosimple_q",
42 | "django.contrib.admin",
43 | "django.contrib.auth",
44 | "django.contrib.contenttypes",
45 | "django.contrib.messages",
46 | "django.contrib.sessions",
47 | "django.contrib.staticfiles",
48 | ]
49 |
50 | ROOT_URLCONF = "django_toosimple_q.tests.urls"
51 |
52 | MIDDLEWARE = (
53 | "django.contrib.sessions.middleware.SessionMiddleware",
54 | "django.contrib.auth.middleware.AuthenticationMiddleware",
55 | "django.contrib.messages.middleware.MessageMiddleware",
56 | )
57 |
58 | TEMPLATES = [
59 | {
60 | "BACKEND": "django.template.backends.django.DjangoTemplates",
61 | "APP_DIRS": True,
62 | "OPTIONS": {
63 | "context_processors": [
64 | "django.contrib.auth.context_processors.auth",
65 | "django.contrib.messages.context_processors.messages",
66 | "django.template.context_processors.request",
67 | ]
68 | },
69 | }
70 | ]
71 |
72 | STATIC_URL = "/static/"
73 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_regression.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from django.core import management
4 |
5 | from django_toosimple_q.decorators import register_task, schedule_task
6 | from django_toosimple_q.models import TaskExec
7 | from django_toosimple_q.registry import schedules_registry, tasks_registry
8 |
9 | from .base import TooSimpleQBackgroundTestCase, TooSimpleQRegularTestCase
10 |
11 |
12 | class TestRegressionBackground(TooSimpleQBackgroundTestCase):
13 | def test_regr_schedule_short(self):
14 | # Regression test for an issue where a schedule with smaller periods was not always processed
15 |
16 | # A worker that ticks every second should trigger a schedule due every second
17 | self.start_worker_in_background(
18 | queue="regr_schedule_short", tick=1, until_done=False, verbosity=3
19 | )
20 | time.sleep(20)
21 |
22 | # It should do almost 20 tasks
23 | self.assertGreaterEqual(TaskExec.objects.all().count(), 18)
24 |
25 |
26 | class TestRegressionRegular(TooSimpleQRegularTestCase):
27 | def test_deleting_schedule(self):
28 | # Regression test for an issue where deleting a schedule in code would crash the admin view
29 |
30 | @schedule_task(cron="0 12 * * *", datetime_kwarg="scheduled_on")
31 | @register_task(name="normal")
32 | def a(scheduled_on):
33 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
34 |
35 | management.call_command("worker", "--until_done")
36 |
37 | schedules_registry.clear()
38 | tasks_registry.clear()
39 |
40 | # the admin view still works even for deleted schedules
41 | response = self.client.get("/admin/toosimpleq/scheduleexec/")
42 | self.assertEqual(response.status_code, 200)
43 |
44 | def test_different_schedule_and_task(self):
45 | # Regression test for an issue where schedule with a different name than the task would fail
46 |
47 | @schedule_task(cron="0 12 * * *", name="name_a", run_on_creation=True)
48 | @register_task(name="name_b")
49 | def a():
50 | return True
51 |
52 | management.call_command("worker", "--until_done")
53 |
--------------------------------------------------------------------------------
/django_toosimple_q/task.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from django.utils import timezone
4 |
5 | from .logging import logger
6 |
7 |
8 | class Task:
9 | """Represents an asnychronous task.
10 |
11 | This class is responsible of queuing and executing the tasks, by managing
12 | TaskExec instances."""
13 |
14 | def __init__(
15 | self,
16 | name: str,
17 | callable: Callable,
18 | queue: str = "default",
19 | priority: int = 0,
20 | unique: bool = False,
21 | retries: int = 0,
22 | retry_delay: int = 0,
23 | ):
24 | self.name = name
25 | self.callable = callable
26 | self.queue = queue
27 | self.priority = priority
28 | self.unique = unique
29 | self.retries = retries
30 | self.retry_delay = retry_delay
31 |
32 | def enqueue(self, *args_, due=None, **kwargs_):
33 | """Creates a TaskExec instance, effectively queuing execution of this task.
34 |
35 | Returns the created TaskExec, or False if no task was created (which can happen
36 | for tasks set as unique, if that task already exists)."""
37 |
38 | from .models import TaskExec
39 |
40 | logger.debug(f"Enqueuing task '{self.name}'")
41 |
42 | due_datetime = due or timezone.now()
43 |
44 | if self.unique:
45 | existing_tasks = TaskExec.objects.filter(
46 | task_name=self.name, args=args_, kwargs=kwargs_
47 | )
48 | # If already queued, we don't do anything
49 | queued_task = existing_tasks.filter(state=TaskExec.States.QUEUED).first()
50 | if queued_task is not None:
51 | return False
52 | # If there's already a same task that's sleeping
53 | sleeping_task = existing_tasks.filter(
54 | state=TaskExec.States.SLEEPING
55 | ).first()
56 | if sleeping_task is not None:
57 | if due is None:
58 | # If the queuing is not delayed, we enqueue it now
59 | sleeping_task.due = due_datetime
60 | sleeping_task.state = TaskExec.States.QUEUED
61 | sleeping_task.save()
62 | elif sleeping_task.due > due_datetime:
63 | # If it's delayed to less than the current due date of the task
64 | sleeping_task.due = min(sleeping_task.due, due_datetime)
65 | sleeping_task.save()
66 | return False
67 |
68 | return TaskExec.objects.create(
69 | task_name=self.name,
70 | args=args_,
71 | kwargs=kwargs_,
72 | state=TaskExec.States.SLEEPING if due else TaskExec.States.QUEUED,
73 | due=due_datetime,
74 | retries=self.retries,
75 | retry_delay=self.retry_delay,
76 | )
77 |
78 | def __str__(self):
79 | return f"Task {self.name}"
80 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0010_auto_20220324_0419.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-23 20:40
2 |
3 | import django
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("toosimpleq", "0009_auto_20210902_2245"),
10 | ]
11 |
12 | operations = [
13 | migrations.RenameModel(
14 | old_name="Schedule",
15 | new_name="ScheduleExec",
16 | ),
17 | migrations.RenameModel(
18 | old_name="Task",
19 | new_name="TaskExec",
20 | ),
21 | migrations.AlterModelOptions(
22 | name="scheduleexec",
23 | options={"verbose_name": "Schedule Execution"},
24 | ),
25 | migrations.AlterModelOptions(
26 | name="taskexec",
27 | options={"verbose_name": "Task Execution"},
28 | ),
29 | migrations.RenameField(
30 | model_name="taskexec",
31 | old_name="function",
32 | new_name="task_name",
33 | ),
34 | migrations.RenameField(
35 | model_name="scheduleexec",
36 | old_name="last_check",
37 | new_name="last_tick",
38 | ),
39 | migrations.AlterField(
40 | model_name="scheduleexec",
41 | name="last_tick",
42 | field=models.DateTimeField(default=django.utils.timezone.now),
43 | ),
44 | migrations.RemoveField(
45 | model_name="scheduleexec",
46 | name="args",
47 | ),
48 | migrations.RemoveField(
49 | model_name="scheduleexec",
50 | name="catch_up",
51 | ),
52 | migrations.RemoveField(
53 | model_name="scheduleexec",
54 | name="cron",
55 | ),
56 | migrations.RemoveField(
57 | model_name="scheduleexec",
58 | name="datetime_kwarg",
59 | ),
60 | migrations.RemoveField(
61 | model_name="scheduleexec",
62 | name="function",
63 | ),
64 | migrations.RemoveField(
65 | model_name="scheduleexec",
66 | name="kwargs",
67 | ),
68 | migrations.AddField(
69 | model_name="scheduleexec",
70 | name="state",
71 | field=models.CharField(
72 | choices=[("ACTIVE", "Active"), ("INVALID", "Invalid")],
73 | default="ACTIVE",
74 | max_length=32,
75 | ),
76 | ),
77 | migrations.AddField(
78 | model_name="taskexec",
79 | name="error",
80 | field=models.TextField(blank=True, null=True),
81 | ),
82 | migrations.AlterField(
83 | model_name="taskexec",
84 | name="state",
85 | field=models.CharField(
86 | choices=[
87 | ("SLEEPING", "Sleeping"),
88 | ("QUEUED", "Queued"),
89 | ("PROCESSING", "Processing"),
90 | ("SUCCEEDED", "Succeeded"),
91 | ("INTERRUPTED", "Interrupted"),
92 | ("FAILED", "Failed"),
93 | ("INVALID", "Invalid"),
94 | ],
95 | default="QUEUED",
96 | max_length=32,
97 | ),
98 | ),
99 | migrations.RemoveField(
100 | model_name="taskexec",
101 | name="priority",
102 | ),
103 | migrations.RemoveField(
104 | model_name="taskexec",
105 | name="queue",
106 | ),
107 | ]
108 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_concurrency.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import unittest
4 |
5 | from django.contrib.auth.models import User
6 |
7 | from django_toosimple_q.models import TaskExec, WorkerStatus
8 |
9 | from .base import TooSimpleQBackgroundTestCase
10 | from .concurrency.tasks import create_user, sleep_task
11 | from .utils import is_postgres
12 |
13 | COUNT = 32
14 |
15 |
16 | # FIXME: not sure if we really can't have this working on SQLITE ?
17 | @unittest.skipIf(not is_postgres(), "requires postgres backend")
18 | class ConcurrencyTest(TooSimpleQBackgroundTestCase):
19 | """This runs some concurrency tests. It sets up a database with simulated lag to
20 | increase race conditions likelyhood, thus requires a running docker daemon."""
21 |
22 | postgres_lag_for_background_worker = True
23 |
24 | def test_schedules(self):
25 | # We create COUNT workers with different labels
26 | for i in range(COUNT):
27 | self.start_worker_in_background(
28 | queue="schedules",
29 | label=f"w-{i}",
30 | verbosity=3,
31 | once=True,
32 | until_done=False,
33 | )
34 | self.workers_get_stdout()
35 |
36 | # Ensure they were all created
37 | self.assertEqual(WorkerStatus.objects.count(), COUNT)
38 |
39 | # The schedule should have run just once and thus the task only queued once despite run_on_creation
40 | self.assertEqual(TaskExec.objects.count(), 1)
41 |
42 | def test_tasks(self):
43 | # Create a task
44 | create_user.queue()
45 |
46 | self.assertEqual(User.objects.count(), 0)
47 |
48 | # We create COUNT workers with different labels
49 | for i in range(COUNT):
50 | self.start_worker_in_background(
51 | queue="tasks", label=f"w-{i}", verbosity=3, once=True, until_done=False
52 | )
53 | self.workers_get_stdout()
54 |
55 | # Ensure they were all created
56 | self.assertEqual(WorkerStatus.objects.count(), COUNT)
57 |
58 | # The task shouldn't have run concurrently and thus have run only once
59 | self.assertEqual(User.objects.count(), 1)
60 |
61 | def test_task_processing_state(self):
62 | t = sleep_task.queue(duration=10)
63 |
64 | # Check that the task correctly queued
65 | t.refresh_from_db()
66 | self.assertEqual(t.state, TaskExec.States.QUEUED)
67 |
68 | # Start the task in a background process
69 | self.start_worker_in_background(queue="tasks")
70 |
71 | # Check that it is now processing
72 | time.sleep(5)
73 | t.refresh_from_db()
74 | self.assertEqual(t.state, TaskExec.States.PROCESSING)
75 |
76 | # Wait for the background process to finish
77 | self.workers_get_stdout()
78 |
79 | # Check that it correctly succeeds
80 | t.refresh_from_db()
81 | self.assertEqual(t.state, TaskExec.States.SUCCEEDED)
82 |
83 | @unittest.skipIf(
84 | os.name == "nt", "didn't find a way to gracefully stop subprocess on windows"
85 | )
86 | def test_task_graceful_stop(self):
87 | """Ensure that on graceful stop, running tasks status is set to interrupted and a replacement task is created"""
88 |
89 | t = sleep_task.queue(duration=10)
90 |
91 | # Check that the task correctly queued
92 | t.refresh_from_db()
93 | self.assertEqual(t.state, TaskExec.States.QUEUED)
94 |
95 | # Start the task in a background process
96 | self.start_worker_in_background(queue="tasks")
97 |
98 | # Check that it is now processing
99 | time.sleep(5)
100 | t.refresh_from_db()
101 | self.assertEqual(t.state, TaskExec.States.PROCESSING)
102 |
103 | # Gracefully stop the background process
104 | self.workers_gracefully_stop()
105 |
106 | # Wait for the background process to finish
107 | self.processes[0].wait(timeout=5)
108 |
109 | # Check that the state is correctly set to interrupted and that a replacing task was added
110 | t.refresh_from_db()
111 | self.assertEqual(t.state, TaskExec.States.INTERRUPTED)
112 | self.assertEqual(t.replaced_by.state, TaskExec.States.SLEEPING)
113 |
--------------------------------------------------------------------------------
/django_toosimple_q/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.6 on 2019-11-01 12:50
2 |
3 | import django.db.models.deletion
4 | import django.utils.timezone
5 | import picklefield.fields
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | initial = True
11 |
12 | dependencies = []
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Task",
17 | fields=[
18 | (
19 | "id",
20 | models.AutoField(
21 | auto_created=True,
22 | primary_key=True,
23 | serialize=False,
24 | verbose_name="ID",
25 | ),
26 | ),
27 | ("function", models.CharField(max_length=1024)),
28 | (
29 | "args",
30 | picklefield.fields.PickledObjectField(
31 | blank=True, default=list, editable=False
32 | ),
33 | ),
34 | (
35 | "kwargs",
36 | picklefield.fields.PickledObjectField(
37 | blank=True, default=dict, editable=False
38 | ),
39 | ),
40 | ("priority", models.IntegerField(default=0)),
41 | ("created", models.DateTimeField(default=django.utils.timezone.now)),
42 | ("started", models.DateTimeField(blank=True, null=True)),
43 | ("finished", models.DateTimeField(blank=True, null=True)),
44 | (
45 | "state",
46 | models.CharField(
47 | choices=[
48 | ("QUEUED", "QUEUED"),
49 | ("PROCESSING", "PROCESSING"),
50 | ("FAILED", "FAILED"),
51 | ("SUCCEEDED", "SUCCEEDED"),
52 | ("INVALID", "INVALID"),
53 | ],
54 | default="QUEUED",
55 | max_length=32,
56 | ),
57 | ), # NOQA
58 | (
59 | "result",
60 | picklefield.fields.PickledObjectField(
61 | blank=True, editable=False, null=True
62 | ),
63 | ),
64 | ("stdout", models.TextField(blank=True, default="")),
65 | ("stderr", models.TextField(blank=True, default="")),
66 | ],
67 | ),
68 | migrations.CreateModel(
69 | name="Schedule",
70 | fields=[
71 | (
72 | "id",
73 | models.AutoField(
74 | auto_created=True,
75 | primary_key=True,
76 | serialize=False,
77 | verbose_name="ID",
78 | ),
79 | ),
80 | ("name", models.CharField(max_length=1024)),
81 | ("function", models.CharField(max_length=1024)),
82 | (
83 | "args",
84 | picklefield.fields.PickledObjectField(
85 | blank=True, default=list, editable=False
86 | ),
87 | ),
88 | (
89 | "kwargs",
90 | picklefield.fields.PickledObjectField(
91 | blank=True, default=dict, editable=False
92 | ),
93 | ),
94 | (
95 | "last_check",
96 | models.DateTimeField(default=django.utils.timezone.now, null=True),
97 | ),
98 | ("catch_up", models.BooleanField(default=False)),
99 | ("cron", models.CharField(max_length=1024)),
100 | (
101 | "last_run",
102 | models.ForeignKey(
103 | null=True,
104 | on_delete=django.db.models.deletion.SET_NULL,
105 | to="toosimpleq.Task",
106 | ),
107 | ),
108 | ],
109 | ),
110 | ]
111 |
--------------------------------------------------------------------------------
/django_toosimple_q/contrib/mail/tests.py:
--------------------------------------------------------------------------------
1 | import importlib
2 |
3 | from django.core import mail, management
4 | from django.core.mail import send_mail, send_mass_mail
5 | from django.test.utils import override_settings
6 |
7 | from ...models import TaskExec
8 | from ...tests.base import TooSimpleQRegularTestCase
9 | from . import tasks as mail_tasks
10 |
11 |
12 | class TestMail(TooSimpleQRegularTestCase):
13 | def setUp(self):
14 | super().setUp()
15 | # Reload the tasks modules to repopulate the registries (emulates auto-discovery)
16 | importlib.reload(mail_tasks)
17 |
18 | @override_settings(
19 | EMAIL_BACKEND="django_toosimple_q.contrib.mail.backend.QueueBackend",
20 | TOOSIMPLEQ_EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
21 | )
22 | def test_queue_mail(self):
23 | self.assertQueue(0)
24 |
25 | send_mail(
26 | "Subject here",
27 | "Here is the message.",
28 | "from@example.com",
29 | ["to@example.com"],
30 | )
31 |
32 | self.assertQueue(1, state=TaskExec.States.QUEUED)
33 | self.assertQueue(1)
34 | self.assertEqual(len(mail.outbox), 0)
35 |
36 | management.call_command("worker", "--until_done")
37 |
38 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
39 | self.assertQueue(1)
40 | self.assertEqual(len(mail.outbox), 1)
41 |
42 | @override_settings(
43 | EMAIL_BACKEND="django_toosimple_q.contrib.mail.backend.QueueBackend",
44 | TOOSIMPLEQ_EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
45 | )
46 | def test_queue_mail_two(self):
47 | self.assertQueue(0)
48 |
49 | send_mail(
50 | "Subject here",
51 | "Here is the message.",
52 | "from@example.com",
53 | ["to@example.com"],
54 | )
55 | send_mail(
56 | "Other subject here",
57 | "Here is the message.",
58 | "from@example.com",
59 | ["to@example.com"],
60 | )
61 |
62 | self.assertQueue(2, state=TaskExec.States.QUEUED)
63 | self.assertQueue(2)
64 | self.assertEqual(len(mail.outbox), 0)
65 |
66 | management.call_command("worker", "--until_done")
67 |
68 | self.assertQueue(2, state=TaskExec.States.SUCCEEDED)
69 | self.assertQueue(2)
70 | self.assertEqual(len(mail.outbox), 2)
71 |
72 | @override_settings(
73 | EMAIL_BACKEND="django_toosimple_q.contrib.mail.backend.QueueBackend",
74 | TOOSIMPLEQ_EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
75 | )
76 | def test_queue_mail_duplicate(self):
77 | self.assertQueue(0)
78 |
79 | send_mail(
80 | "Subject here",
81 | "Here is the message.",
82 | "from@example.com",
83 | ["to@example.com"],
84 | )
85 | send_mail(
86 | "Subject here",
87 | "Here is the message.",
88 | "from@example.com",
89 | ["to@example.com"],
90 | )
91 |
92 | self.assertQueue(1, state=TaskExec.States.QUEUED)
93 | self.assertQueue(1)
94 | self.assertEqual(len(mail.outbox), 0)
95 |
96 | management.call_command("worker", "--until_done")
97 |
98 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
99 | self.assertQueue(1)
100 | self.assertEqual(len(mail.outbox), 1)
101 |
102 | @override_settings(
103 | EMAIL_BACKEND="django_toosimple_q.contrib.mail.backend.QueueBackend",
104 | TOOSIMPLEQ_EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
105 | )
106 | def test_queue_mass_mail(self):
107 | self.assertQueue(0)
108 |
109 | send_mass_mail(
110 | [
111 | ("Subject A", "Message.", "from@example.com", ["to@example.com"]),
112 | ("Subject B", "Message.", "from@example.com", ["to@example.com"]),
113 | ("Subject C", "Message.", "from@example.com", ["to@example.com"]),
114 | ]
115 | )
116 |
117 | self.assertQueue(1, state=TaskExec.States.QUEUED)
118 | self.assertQueue(1)
119 | self.assertEqual(len(mail.outbox), 0)
120 |
121 | management.call_command("worker", "--until_done")
122 |
123 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
124 | self.assertQueue(1)
125 | self.assertEqual(len(mail.outbox), 3)
126 |
127 | @override_settings(
128 | EMAIL_BACKEND="django_toosimple_q.contrib.mail.backend.QueueBackend",
129 | TOOSIMPLEQ_EMAIL_BACKEND="failing_backend",
130 | )
131 | def test_queue_mail_failing_backend(self):
132 | self.assertQueue(0)
133 |
134 | send_mail(
135 | "Subject here",
136 | "Here is the message.",
137 | "from@example.com",
138 | ["to@example.com"],
139 | )
140 |
141 | self.assertQueue(1, state=TaskExec.States.QUEUED)
142 | self.assertQueue(1)
143 | self.assertEqual(len(mail.outbox), 0)
144 |
145 | management.call_command("worker", "--until_done")
146 |
147 | self.assertQueue(1, state=TaskExec.States.FAILED, replaced=True)
148 | self.assertQueue(1, state=TaskExec.States.SLEEPING)
149 | self.assertQueue(2)
150 | self.assertEqual(len(mail.outbox), 0)
151 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_worker.py:
--------------------------------------------------------------------------------
1 | import signal
2 | import time
3 |
4 | from django.core import management
5 | from freezegun import freeze_time
6 |
7 | from django_toosimple_q.decorators import register_task
8 | from django_toosimple_q.models import TaskExec, WorkerStatus
9 |
10 | from ..models import WorkerStatus
11 | from .base import TooSimpleQBackgroundTestCase, TooSimpleQRegularTestCase
12 | from .concurrency.tasks import sleep_task
13 |
14 |
15 | class TestWorker(TooSimpleQRegularTestCase):
16 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
17 | def test_worker(self, frozen_datetime):
18 | """Checking that worker status are correctly created"""
19 |
20 | # Syntaxic sugar
21 | S = WorkerStatus.States
22 |
23 | # Workers status is correctly created
24 | management.call_command("worker", "--once", "--label", "w1")
25 | ws = WorkerStatus.objects.all()
26 | self.assertEqual(ws.count(), 1)
27 | self.assertCountEqual([w.label for w in ws], ["w1"])
28 | self.assertCountEqual([w.state for w in ws], [S.STOPPED])
29 |
30 | # A second call doesn't change it
31 | ws = WorkerStatus.objects.all()
32 | self.assertEqual(ws.count(), 1)
33 | self.assertCountEqual([w.label for w in ws], ["w1"])
34 | self.assertCountEqual([w.state for w in ws], [S.STOPPED])
35 |
36 | # Another worker adds a new status
37 | management.call_command("worker", "--once", "--label", "w2")
38 | ws = WorkerStatus.objects.all()
39 | self.assertEqual(ws.count(), 2)
40 | self.assertCountEqual([w.label for w in ws], ["w1", "w2"])
41 | self.assertCountEqual([w.state for w in ws], [S.STOPPED, S.STOPPED])
42 |
43 | # Worker are properly populating attached to task
44 | @register_task(name="a")
45 | def a():
46 | return True
47 |
48 | t = a.queue()
49 | self.assertEqual(t.worker, None)
50 | management.call_command("worker", "--once", "--label", "w2")
51 | t.refresh_from_db()
52 | self.assertEqual(t.worker.label, "w2")
53 |
54 | # TODO: test for worker timeout status
55 | # TODO: test for no label/pid clashes with multiple workers
56 |
57 |
58 | class TestWorkerExit(TooSimpleQBackgroundTestCase):
59 | """This tests all types of worker termination (kill, terminate, quit, crash) and their effect on tasks and output states"""
60 |
61 | def _start_worker_with_task(self, duration=10):
62 | """Helper to create a worker that picked up a 10s task"""
63 |
64 | # Add a task that takes some time
65 | sleep_task.queue(duration=duration)
66 |
67 | # Start a worker
68 | self.start_worker_in_background(
69 | queue="tasks", tick=1, until_done=False, verbosity=3, timeout=3
70 | )
71 |
72 | # Wait for the task to be picked up by the worker
73 | self.wait_for_qs(TaskExec.objects.filter(state=TaskExec.States.PROCESSING))
74 |
75 | # Keep id of the first task for further reference
76 | self.__first_task_pk = TaskExec.objects.first().pk
77 |
78 | # Let the worker actually start processing the task
79 | # FIXME: this should not be needed
80 | time.sleep(1)
81 |
82 | @property
83 | def workerstatus(self):
84 | """The workerstatus object corresponding to the worker"""
85 | return WorkerStatus.objects.first()
86 |
87 | @property
88 | def taskexec(self):
89 | """The long running task that should have been picked up by the worker"""
90 | return TaskExec.objects.get(pk=self.__first_task_pk)
91 |
92 | @property
93 | def process(self):
94 | """The subprocess"""
95 | return self.processes[0]
96 |
97 | def test_kill(self):
98 | self._start_worker_with_task()
99 |
100 | # Hard kill, the worker can't cleanly quit
101 | self.process.kill()
102 |
103 | # -9 is exit code for SIGKILL
104 | exit_code = self.process.wait(timeout=5)
105 | self.assertEqual(exit_code, -9)
106 |
107 | # Initially the worker still looks online
108 | self.assertEqual(self.workerstatus.state, WorkerStatus.States.ONLINE)
109 |
110 | # The task also will look like it's still processing
111 | self.assertEqual(self.taskexec.state, TaskExec.States.PROCESSING)
112 |
113 | # After a while though, it should timeout
114 | time.sleep(5)
115 | self.assertEqual(self.workerstatus.state, WorkerStatus.States.TIMEDOUT)
116 | self.assertIsNone(self.workerstatus.exit_log)
117 |
118 | # Same for the task
119 | # FIXME: we have no timeout status for tasks, so it stays processing indefinitely. We should
120 | # add a timeout status and assign it
121 | self.assertEqual(self.taskexec.state, TaskExec.States.PROCESSING)
122 |
123 | def test_terminate(self):
124 | self._start_worker_with_task()
125 |
126 | # Soft kill, the worker should interrupt the task and quit as soon as possible
127 | self.process.send_signal(signal.SIGTERM)
128 |
129 | # We should have our custom exit code
130 | exit_code = self.process.wait(timeout=5)
131 | self.assertEqual(exit_code, WorkerStatus.ExitCodes.TERMINATED.value)
132 |
133 | # The worker should correctly set its state
134 | self.assertEqual(self.workerstatus.state, WorkerStatus.States.TERMINATED)
135 | self.assertIn("KeyboardInterrupt", self.workerstatus.exit_log)
136 |
137 | # The task should be noted as interrupted, and replaced by another task
138 | self.assertEqual(self.taskexec.state, TaskExec.States.INTERRUPTED)
139 | self.assertIsNone(self.taskexec.result)
140 | self.assertIsNotNone(self.taskexec.replaced_by)
141 | self.assertEqual(self.taskexec.replaced_by.state, TaskExec.States.SLEEPING)
142 |
143 | def test_worker_crash(self):
144 | self._start_worker_with_task()
145 |
146 | # Simluate a crash by deleting the workerexec instance
147 | self.process.send_signal(signal.SIGUSR1)
148 |
149 | # The exit code should be 0, it's a graceful quit
150 | exit_code = self.process.wait(timeout=15)
151 | self.assertEqual(exit_code, WorkerStatus.ExitCodes.CRASHED.value)
152 |
153 | # The worker should correctly set its state
154 | self.assertEqual(self.workerstatus.state, WorkerStatus.States.CRASHED)
155 | self.assertIn("FakeException", self.workerstatus.exit_log)
156 |
157 | # The failure is not linked to the task
158 |
159 | def test_quit(self):
160 | self._start_worker_with_task()
161 |
162 | # Regular quit, the process should let the task finish, then exit
163 | self.process.send_signal(signal.SIGINT)
164 |
165 | # The exit code should be 0, it's a graceful quit
166 | exit_code = self.process.wait(timeout=15)
167 | self.assertEqual(exit_code, WorkerStatus.ExitCodes.STOPPED.value)
168 |
169 | # The worker should correctly set its state
170 | self.assertEqual(self.workerstatus.state, WorkerStatus.States.STOPPED)
171 | self.assertEqual("", self.workerstatus.exit_log)
172 |
173 | # The task should have been left enough time to finish
174 | self.assertEqual(self.taskexec.state, TaskExec.States.SUCCEEDED)
175 | self.assertTrue(self.taskexec.result)
176 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin.models import CHANGE, LogEntry
3 | from django.contrib.auth.models import Permission, User
4 | from django.contrib.contenttypes.models import ContentType
5 | from django.core import management
6 | from django.test import RequestFactory
7 |
8 | from django_toosimple_q.decorators import register_task, schedule_task
9 | from django_toosimple_q.models import ScheduleExec, TaskExec
10 |
11 | from .base import TooSimpleQRegularTestCase
12 |
13 |
14 | class TestAdmin(TooSimpleQRegularTestCase):
15 | def test_task_admin(self):
16 | """Check if task admin pages work"""
17 |
18 | @register_task(name="a")
19 | def a():
20 | return 2
21 |
22 | task_exec = a.queue()
23 |
24 | management.call_command("worker", "--until_done")
25 |
26 | response = self.client.get("/admin/toosimpleq/taskexec/")
27 | self.assertEqual(response.status_code, 200)
28 |
29 | response = self.client.get(f"/admin/toosimpleq/taskexec/{task_exec.pk}/change/")
30 | self.assertEqual(response.status_code, 200)
31 |
32 | def test_schedule_admin(self):
33 | """Check if schedule admin pages work"""
34 |
35 | @schedule_task(cron="* * * * *")
36 | @register_task(name="a")
37 | def a():
38 | return 2
39 |
40 | management.call_command("worker", "--until_done")
41 |
42 | response = self.client.get("/admin/toosimpleq/scheduleexec/")
43 | self.assertEqual(response.status_code, 200)
44 |
45 | scheduleexec = ScheduleExec.objects.first()
46 | response = self.client.get(
47 | f"/admin/toosimpleq/scheduleexec/{scheduleexec.pk}/change/"
48 | )
49 | self.assertEqual(response.status_code, 200)
50 |
51 | def test_manual_schedule_admin(self):
52 | """Check that manual schedule admin action work"""
53 |
54 | @schedule_task(cron="manual")
55 | @register_task(name="a")
56 | def a():
57 | return 2
58 |
59 | self.assertSchedule("a", None)
60 | management.call_command("worker", "--until_done")
61 | self.assertQueue(0)
62 |
63 | data = {
64 | "action": "action_force_run",
65 | "_selected_action": ScheduleExec.objects.get(name="a").pk,
66 | }
67 | response = self.client.post(
68 | "/admin/toosimpleq/scheduleexec/", data, follow=True
69 | )
70 | self.assertEqual(response.status_code, 200)
71 |
72 | self.assertQueue(1, state=TaskExec.States.QUEUED)
73 |
74 | management.call_command("worker", "--until_done")
75 |
76 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
77 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
78 |
79 | def test_schedule_admin_force_action(self):
80 | """Check if he force execute schedule action works"""
81 |
82 | @schedule_task(cron="13 0 1 1 *")
83 | @register_task(name="a")
84 | def a():
85 | return 2
86 |
87 | self.assertSchedule("a", None)
88 | self.assertQueue(0, state=TaskExec.States.SUCCEEDED)
89 | self.assertQueue(0, state=TaskExec.States.QUEUED)
90 |
91 | management.call_command("worker", "--until_done")
92 |
93 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
94 | self.assertQueue(0, state=TaskExec.States.SUCCEEDED)
95 | self.assertQueue(0, state=TaskExec.States.QUEUED)
96 |
97 | data = {
98 | "action": "action_force_run",
99 | "_selected_action": ScheduleExec.objects.get(name="a").pk,
100 | }
101 | response = self.client.post(
102 | "/admin/toosimpleq/scheduleexec/", data, follow=True
103 | )
104 | self.assertEqual(response.status_code, 200)
105 |
106 | management.call_command("worker", "--until_done")
107 |
108 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
109 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
110 | self.assertQueue(0, state=TaskExec.States.QUEUED)
111 |
112 | # ensure the action gets logged
113 | self.assertEqual(
114 | LogEntry.objects.filter(
115 | content_type_id=ContentType.objects.get_for_model(ScheduleExec).pk,
116 | action_flag=CHANGE,
117 | ).count(),
118 | 1,
119 | )
120 |
121 | def test_task_admin_requeue_action(self):
122 | """Check if the requeue action works"""
123 |
124 | @register_task(name="a")
125 | def a():
126 | return 2
127 |
128 | task_exec = a.queue()
129 |
130 | self.assertQueue(0, state=TaskExec.States.SUCCEEDED)
131 | self.assertQueue(1, state=TaskExec.States.QUEUED)
132 |
133 | management.call_command("worker", "--until_done")
134 |
135 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
136 | self.assertQueue(0, state=TaskExec.States.QUEUED)
137 |
138 | data = {
139 | "action": "action_requeue",
140 | "_selected_action": task_exec.pk,
141 | }
142 | response = self.client.post("/admin/toosimpleq/taskexec/", data, follow=True)
143 | self.assertEqual(response.status_code, 200)
144 |
145 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
146 | self.assertQueue(1, state=TaskExec.States.QUEUED)
147 |
148 | management.call_command("worker", "--until_done")
149 |
150 | self.assertQueue(2, state=TaskExec.States.SUCCEEDED)
151 | self.assertQueue(0, state=TaskExec.States.QUEUED)
152 |
153 | # ensure the action gets logged
154 | self.assertEqual(
155 | LogEntry.objects.filter(
156 | content_type_id=ContentType.objects.get_for_model(TaskExec).pk,
157 | action_flag=CHANGE,
158 | ).count(),
159 | 1,
160 | )
161 |
162 | def test_task_admin_result_preview(self):
163 | """Check the the task results correctly displays, including if long"""
164 |
165 | @register_task()
166 | def a(length):
167 | return "o" * length
168 |
169 | # a short result appears as is
170 | a.queue(length=10)
171 | management.call_command("worker", "--until_done")
172 | response = self.client.get("/admin/toosimpleq/taskexec/", follow=True)
173 | self.assertContains(response, "o" * 10)
174 |
175 | # a long results gets trimmed
176 | a.queue(length=300)
177 | management.call_command("worker", "--until_done")
178 | response = self.client.get("/admin/toosimpleq/taskexec/", follow=True)
179 | self.assertContains(response, "o" * 254 + "…")
180 |
181 | def test_admin_actions_permissions(self):
182 | """Check that admin actions are only available with the correct permissions"""
183 |
184 | perms = Permission.objects.filter(
185 | codename__in=["force_run_scheduleexec", "requeue_taskexec"]
186 | )
187 |
188 | request_with = RequestFactory().get("/some-url/")
189 | request_with.user = User.objects.create(username="mike")
190 | request_with.user.user_permissions.set(perms)
191 |
192 | request_without = RequestFactory().get("/some-url/")
193 | request_without.user = User.objects.create(username="peter")
194 |
195 | # prefer admin.site.get_model_admin(TaskExec) once we drop support for 4.2
196 | task_model_admin = admin.site._registry[TaskExec]
197 | schedule_model_admin = admin.site._registry[ScheduleExec]
198 |
199 | self.assertCountEqual(
200 | task_model_admin.get_actions(request_with).keys(), ["action_requeue"]
201 | )
202 | self.assertCountEqual(
203 | schedule_model_admin.get_actions(request_with).keys(), ["action_force_run"]
204 | )
205 | self.assertCountEqual(task_model_admin.get_actions(request_without).keys(), [])
206 | self.assertCountEqual(
207 | schedule_model_admin.get_actions(request_without).keys(), []
208 | )
209 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/base.py:
--------------------------------------------------------------------------------
1 | import os
2 | import signal
3 | import subprocess
4 | import time
5 | from typing import List
6 |
7 | from django.contrib.auth.models import User
8 | from django.core import mail
9 | from django.db.models import Count
10 | from django.test import Client, TestCase, TransactionTestCase
11 |
12 | from django_toosimple_q.models import ScheduleExec, TaskExec
13 | from django_toosimple_q.registry import schedules_registry, tasks_registry
14 |
15 | from ..logging import logger
16 |
17 |
18 | class TooSimpleQTestCaseMixin:
19 | """
20 | Base TestCase for TooSimpleQ.
21 |
22 | - Clears the schedules and task registries (reverting the autodiscovery)
23 | - Creates a superuser
24 | - Clears the mailbox
25 | - Adds assertQueue and assertTask helpers
26 |
27 | Use this if you want to keep autoloaded task (from contrib.mail) from polluting the tests.
28 | """
29 |
30 | def setUp(self):
31 | # Clean the registry
32 | schedules_registry.clear()
33 | tasks_registry.clear()
34 |
35 | # Create a superuser
36 | user = User.objects.create_superuser("admin", "test@example.com", "pass")
37 | self.client = Client()
38 | self.client.force_login(user)
39 |
40 | # Clear the mailbox
41 | mail.outbox.clear()
42 |
43 | def assertQueue(
44 | self, expected_count, task_name=None, state=None, replaced=None, due=None
45 | ):
46 | tasks = TaskExec.objects.all()
47 | if task_name:
48 | tasks = tasks.filter(task_name=task_name)
49 | if state:
50 | tasks = tasks.filter(state=state)
51 | if replaced is not None:
52 | tasks = tasks.filter(replaced_by__isnull=not replaced)
53 | if due is not None:
54 | tasks = tasks.filter(due=due)
55 | actual_count = tasks.count()
56 | if actual_count != expected_count:
57 | vals = (
58 | TaskExec.objects.values("task_name", "state")
59 | .annotate(count=Count("*"))
60 | .order_by("task_name", "state")
61 | )
62 | debug = "\n".join(
63 | f"{v['task_name']}/{v['state']} : {v['count']}" for v in vals
64 | )
65 | raise AssertionError(
66 | f"Expected {expected_count} tasks, got {actual_count} tasks.\n{debug}"
67 | )
68 |
69 | def assertResults(self, expected=[], task_name=None):
70 | tasks_execs = TaskExec.objects.order_by("created", "result")
71 | if task_name:
72 | tasks_execs = tasks_execs.filter(task_name=task_name)
73 | results = list(tasks_execs.values_list("result", flat=True))
74 |
75 | self.assertEqual(results, expected)
76 |
77 | def assertTask(self, task, expected_state):
78 | actual_state = TaskExec.objects.get(pk=task.pk).state
79 | if actual_state != expected_state:
80 | raise AssertionError(
81 | f"Expected {expected_state}, got {actual_state} [{task}]"
82 | )
83 |
84 | def assertSchedule(self, name, expected_state):
85 | try:
86 | state = ScheduleExec.objects.get(name=name).state
87 | except ScheduleExec.DoesNotExist:
88 | state = None
89 |
90 | if state != expected_state:
91 | raise AssertionError(f"Expected {expected_state}, got {state} [{name}]")
92 |
93 |
94 | class TooSimpleQRegularTestCase(TooSimpleQTestCaseMixin, TestCase):
95 | """
96 | Base TestCase for TooSimpleQ.
97 |
98 | See TooSimpleQTestCaseMixin
99 | """
100 |
101 |
102 | class TooSimpleQBackgroundTestCase(TransactionTestCase):
103 | """
104 | Base TransactionTestCase for TooSimpleQ.
105 |
106 | - Ensures the database is accessible from background workers (transactiontestcase do no wrap tests in transactions)
107 | - Adds some helpers methods to manage the workers
108 |
109 | See TooSimpleQTestCaseMixin.
110 | """
111 |
112 | postgres_lag_for_background_worker = False
113 |
114 | def setUp(self):
115 | super().setUp()
116 | self.processes: List[subprocess.Popen] = []
117 |
118 | def tearDown(self):
119 | super().tearDown()
120 |
121 | self.workers_gracefully_stop()
122 | self.workers_kill()
123 |
124 | def start_worker_in_background(
125 | self,
126 | queue=None,
127 | tick=None,
128 | until_done=True,
129 | once=False,
130 | skip_checks=True,
131 | verbosity=None,
132 | label=None,
133 | timeout=None,
134 | ):
135 | """Starts N workers in the background on the specified queue."""
136 |
137 | if self.postgres_lag_for_background_worker:
138 | settings = "django_toosimple_q.tests.settings_bg_lag"
139 | else:
140 | settings = "django_toosimple_q.tests.settings_bg"
141 |
142 | command = ["python", "manage.py", "worker"]
143 |
144 | if label:
145 | command.extend(["--label", str(label)])
146 | if tick:
147 | command.extend(["--tick", str(tick)])
148 | if queue:
149 | command.extend(["--queue", str(queue)])
150 | if until_done:
151 | command.extend(["--until_done"])
152 | if once:
153 | command.extend(["--once"])
154 | if skip_checks:
155 | command.extend(["--skip-checks"])
156 | if verbosity:
157 | command.extend(["--verbosity", str(verbosity)])
158 | if timeout:
159 | command.extend(["--timeout", str(timeout)])
160 |
161 | logger.debug(f"Starting workers: {' '.join(command)}")
162 | self.processes.append(
163 | subprocess.Popen(
164 | command,
165 | encoding="utf-8",
166 | env={**os.environ, "DJANGO_SETTINGS_MODULE": settings},
167 | stdout=subprocess.PIPE,
168 | stderr=subprocess.STDOUT,
169 | )
170 | )
171 |
172 | def wait_for_qs(self, queryset, exists=True, timeout=15):
173 | """Waits until the queryset exists (or does not exist)"""
174 | start_time = time.time()
175 | while queryset.exists() == (not exists):
176 | if (time.time() - start_time) > timeout:
177 | raise AssertionError(
178 | f"Expected queryset was not present after {timeout} seconds"
179 | if exists
180 | else f"Unexpected queryset was still present after {timeout} seconds"
181 | )
182 |
183 | def wait_for_tasks(self, timeout=15):
184 | """Waits untill all tasks are marked as done in the database"""
185 | return self.wait_for_qs(
186 | TaskExec.objects.filter(state__in=TaskExec.States.todo()),
187 | exists=False,
188 | timeout=timeout,
189 | )
190 |
191 | def workers_get_stdout(self):
192 | """Stops the workers if needed and returns the stdout of the last worker, or raises an exception on error.
193 |
194 | Can be used to check output or to assert success"""
195 |
196 | outputs = []
197 | for process in self.processes:
198 | try:
199 | stdout, stderr = process.communicate(timeout=15)
200 | except subprocess.TimeoutExpired:
201 | process.kill()
202 | stdout, stderr = process.communicate()
203 | outputs.append((process.returncode, stdout, stderr))
204 |
205 | # Outputs that errored
206 | error_outputs = [o for o in outputs if o[0] != 0]
207 |
208 | # Last output is last one
209 | last_output = error_outputs[-1] if error_outputs else outputs[-1]
210 |
211 | last_retcod, last_stdout, last_stderr = last_output
212 |
213 | # Raise exception if error
214 | if last_retcod != 0:
215 | all_retcodes = ", ".join([f"{r[0]}" for r in outputs])
216 | logger.warn(f"Some workers errored. All return codes: {all_retcodes}\n")
217 | logger.warn(f"Last error retcod:\n{last_retcod}\n")
218 | logger.warn(f"Last error stdout:\n{last_stdout}\n")
219 | logger.warn(f"Last error stderr:\n{last_stderr}")
220 | raise AssertionError(f"Some workers errored.")
221 |
222 | return last_stdout
223 |
224 | def workers_gracefully_stop(self):
225 | """Gracefully stops all workers (note that you must still wait for them to finish using wait_for_success)."""
226 |
227 | for process in self.processes:
228 | if os.name == "nt":
229 | # FIXME: This is buggy. When running, test passes, but then the test stops, and further
230 | # tests are not run. Not sure if sending CTRL_C to the child process also affects the current
231 | # process for some reason ?
232 | process.send_signal(signal.CTRL_C_EVENT)
233 | else:
234 | process.send_signal(signal.SIGTERM)
235 |
236 | def workers_kill(self):
237 | """Forces kill all processes (use this for cleanup)"""
238 |
239 | open_processes = [p for p in self.processes if p.poll() is None]
240 | if not open_processes:
241 | return
242 |
243 | logger.warn(f"Killing {len(open_processes)} dangling worker processes...")
244 | for process in open_processes:
245 | process.kill()
246 |
--------------------------------------------------------------------------------
/django_toosimple_q/models.py:
--------------------------------------------------------------------------------
1 | import io
2 | import traceback
3 | from contextlib import redirect_stderr, redirect_stdout
4 | from datetime import datetime, timedelta
5 | from typing import List
6 |
7 | from croniter import croniter, croniter_range
8 | from django.db import models
9 | from django.template.defaultfilters import truncatechars
10 | from django.utils import timezone
11 | from django.utils.functional import cached_property
12 | from django.utils.timezone import now
13 | from django.utils.translation import gettext_lazy as _
14 | from picklefield.fields import PickledObjectField
15 |
16 | from .logging import logger
17 | from .registry import schedules_registry, tasks_registry
18 |
19 |
20 | class TaskExec(models.Model):
21 | """TaskExecution represent a specific planned or past call of a task, including inputs (arguments) and outputs.
22 |
23 | This is a model, whose instanced are typically created using `mycallable.queue()` or from schedules.
24 | """
25 |
26 | class Meta:
27 | verbose_name = "Task Execution"
28 | permissions = [
29 | ("requeue_taskexec", "Can requeue tasks"),
30 | ]
31 |
32 | class States(models.TextChoices):
33 | SLEEPING = "SLEEPING", _("Sleeping")
34 | QUEUED = "QUEUED", _("Queued")
35 | PROCESSING = "PROCESSING", _("Processing")
36 | SUCCEEDED = "SUCCEEDED", _("Succeeded")
37 | INTERRUPTED = "INTERRUPTED", _("Interrupted")
38 | FAILED = "FAILED", _("Failed")
39 | INVALID = "INVALID", _("Invalid")
40 |
41 | @classmethod
42 | def icon(cls, state):
43 | if state == cls.SLEEPING:
44 | return "💤"
45 | elif state == cls.QUEUED:
46 | return "⌚"
47 | elif state == cls.PROCESSING:
48 | return "🚧"
49 | elif state == cls.SUCCEEDED:
50 | return "✔️"
51 | elif state == cls.FAILED:
52 | return "❌"
53 | elif state == cls.INTERRUPTED:
54 | return "🛑"
55 | elif state == cls.INVALID:
56 | return "⚠️"
57 | raise NotImplementedError(f"Unknown state: {state}")
58 |
59 | @classmethod
60 | def todo(cls) -> List[str]:
61 | """A list of values that are not done (opposite of done)"""
62 | return [
63 | cls.SLEEPING.value,
64 | cls.QUEUED.value,
65 | cls.PROCESSING.value,
66 | ]
67 |
68 | @classmethod
69 | def done(cls) -> List[str]:
70 | """A list of values that are done (opposite of todo)"""
71 | return [v for v in cls.values if v not in cls.todo()]
72 |
73 | id = models.BigAutoField(primary_key=True)
74 | task_name = models.CharField(max_length=1024)
75 | args = PickledObjectField(blank=True, default=list)
76 | kwargs = PickledObjectField(blank=True, default=dict)
77 | retries = models.IntegerField(
78 | default=0, help_text="retries left, -1 means infinite"
79 | )
80 | retry_delay = models.IntegerField(
81 | default=0,
82 | help_text="Delay before next retry in seconds. Will double after each failure.",
83 | )
84 |
85 | due = models.DateTimeField(default=now)
86 | created = models.DateTimeField(default=now)
87 | started = models.DateTimeField(blank=True, null=True)
88 | finished = models.DateTimeField(blank=True, null=True)
89 | state = models.CharField(
90 | max_length=32, choices=States.choices, default=States.QUEUED
91 | )
92 | result = PickledObjectField(blank=True, null=True)
93 | result_preview = models.CharField(
94 | max_length=255, blank=True, null=True, editable=False
95 | )
96 | error = models.TextField(blank=True, null=True)
97 | replaced_by = models.ForeignKey(
98 | "self", null=True, blank=True, on_delete=models.SET_NULL
99 | )
100 | worker = models.ForeignKey(
101 | "WorkerStatus", null=True, blank=True, on_delete=models.SET_NULL
102 | )
103 |
104 | stdout = models.TextField(blank=True, default="")
105 | stderr = models.TextField(blank=True, default="")
106 |
107 | def __str__(self):
108 | return f"Task '{self.task_name}' {self.icon} [{self.id}]"
109 |
110 | @property
111 | def task(self):
112 | """The corresponding task instance, or None if it's not in the registry"""
113 | try:
114 | return tasks_registry[self.task_name]
115 | except KeyError:
116 | return None
117 |
118 | @property
119 | def icon(self):
120 | return TaskExec.States.icon(self.state)
121 |
122 | def execute(self):
123 | logger.info(f"{self} started")
124 | try:
125 | # Get the task from the registry
126 | task = tasks_registry[self.task_name]
127 |
128 | # Run the task
129 | stdout, stderr = io.StringIO(), io.StringIO()
130 | with redirect_stderr(stderr), redirect_stdout(stdout):
131 | self.result = task.callable(*self.args, **self.kwargs)
132 | self.result_preview = truncatechars(str(self.result), 255)
133 | logger.info(f"{self} succeeded")
134 | self.state = TaskExec.States.SUCCEEDED
135 | except Exception:
136 | logger.warning(f"{self} failed !")
137 | self.state = TaskExec.States.FAILED
138 | self.error = traceback.format_exc()
139 | if self.retries != 0:
140 | self.create_replacement(is_retry=True)
141 | finally:
142 | self.finished = now()
143 | self.stdout = stdout.getvalue()
144 | self.stderr = stderr.getvalue()
145 | self.save()
146 |
147 | def create_replacement(self, is_retry):
148 | logger.info(f"Creating a replacement task for {self}")
149 |
150 | if is_retry:
151 | # If it's a retry (failed task), we increment the retry count
152 | retries = self.retries - 1 if self.retries > 0 else -1
153 | delay = self.retry_delay * 2
154 | else:
155 | # If it's a replacement, we don't
156 | retries = self.retries
157 | delay = self.retry_delay
158 |
159 | replaced_by = TaskExec.objects.create(
160 | task_name=self.task_name,
161 | args=self.args,
162 | kwargs=self.kwargs,
163 | retries=retries,
164 | retry_delay=delay,
165 | state=TaskExec.States.SLEEPING,
166 | due=now() + timedelta(seconds=self.retry_delay),
167 | )
168 | self.replaced_by = replaced_by
169 | self.save()
170 |
171 |
172 | class ScheduleExec(models.Model):
173 | class Meta:
174 | verbose_name = "Schedule Execution"
175 | permissions = [
176 | ("force_run_scheduleexec", "Can force execution of schedules"),
177 | ]
178 |
179 | class States(models.TextChoices):
180 | ACTIVE = "ACTIVE", _("Active")
181 | INVALID = "INVALID", _("Invalid")
182 |
183 | @classmethod
184 | def icon(cls, state):
185 | if state == cls.ACTIVE:
186 | return "🟢"
187 | elif state == cls.INVALID:
188 | return "⚠️"
189 | raise NotImplementedError(f"Unknown state: {state}")
190 |
191 | id = models.BigAutoField(primary_key=True)
192 | name = models.CharField(max_length=1024, unique=True)
193 | last_due = models.DateTimeField(null=True, blank=True)
194 | last_task = models.ForeignKey(
195 | TaskExec, null=True, blank=True, on_delete=models.SET_NULL
196 | )
197 | state = models.CharField(
198 | max_length=32, choices=States.choices, default=States.ACTIVE
199 | )
200 |
201 | def __str__(self):
202 | return f"Schedule '{self.name}' {self.icon}"
203 |
204 | @property
205 | def schedule(self):
206 | """The corresponding schedule instance, or None if it's not in the registry"""
207 | try:
208 | return schedules_registry[self.name]
209 | except KeyError:
210 | return None
211 |
212 | @property
213 | def icon(self):
214 | return ScheduleExec.States.icon(self.state)
215 |
216 | @cached_property
217 | def past_dues(self):
218 | if self.schedule is None:
219 | # Deal with invalid schedule (e.g. deleted from the code but still in the DB)
220 | return []
221 |
222 | if self.schedule.cron == "manual":
223 | # A manual schedule is never due
224 | return []
225 |
226 | if self.last_due is None:
227 | # If the schedule has no last due date (probaby create with run_on_creation), we run it
228 | return [croniter(self.schedule.cron, now()).get_prev(datetime)]
229 |
230 | # Otherwise, we find all execution times since last check
231 | dues = list(
232 | croniter_range(self.last_due, now(), self.schedule.cron, exclude_ends=True)
233 | )
234 | # We keep only the last one if catchup wasn't specified
235 | if not self.schedule.catch_up:
236 | return dues[-1:]
237 |
238 | return dues
239 |
240 | @cached_property
241 | def upcomming_due(self):
242 | if self.schedule.cron == "manual":
243 | # A manual schedule is never due
244 | return None
245 |
246 | return croniter(self.schedule.cron, timezone.now()).get_next(datetime)
247 |
248 | def execute(self):
249 | did_something = False
250 |
251 | if self.past_dues:
252 | logger.info(f"{self} is due ({len(self.past_dues)} occurences)")
253 | self.schedule.execute(self.past_dues)
254 | did_something = True
255 | self.last_due = self.past_dues[-1]
256 |
257 | self.state = ScheduleExec.States.ACTIVE
258 | self.save()
259 |
260 | return did_something
261 |
262 |
263 | class WorkerStatus(models.Model):
264 | """Represents the status of a worker. At each tick, the worker will update it's status.
265 | After a certain tim"""
266 |
267 | class Meta:
268 | verbose_name = "Worker Status"
269 | verbose_name_plural = "Workers Statuses"
270 |
271 | class ExitCodes(models.IntegerChoices):
272 | STOPPED = 0, _("Stopped")
273 | TERMINATED = 77, _("Terminated")
274 | CRASHED = 99, _("Crashed")
275 |
276 | class States(models.TextChoices):
277 | ONLINE = "ONLINE", _("Online")
278 | STOPPED = "STOPPED", _("Stopped")
279 | TERMINATED = "TERMINATED", _("Terminated")
280 | CRASHED = "CRASHED", _("Crashed")
281 | TIMEDOUT = "TIMEDOUT", _("Timedout")
282 |
283 | @classmethod
284 | def icon(cls, state):
285 | if state == cls.ONLINE:
286 | return "🟢"
287 | elif state == cls.STOPPED:
288 | return "⚪"
289 | elif state == cls.TERMINATED:
290 | return "🟧"
291 | elif state == cls.CRASHED:
292 | return "🟥"
293 | elif state == cls.TIMEDOUT:
294 | return "❓"
295 | raise NotImplementedError(f"Unknown state: {state}")
296 |
297 | id = models.BigAutoField(primary_key=True)
298 | label = models.CharField(max_length=1024, unique=True)
299 | included_queues = models.JSONField(default=list)
300 | excluded_queues = models.JSONField(default=list)
301 | timeout = models.DurationField(default=timedelta(hours=1))
302 | last_tick = models.DateTimeField(default=now)
303 | started = models.DateTimeField(default=now)
304 | stopped = models.DateTimeField(null=True, blank=True)
305 | exit_code = models.IntegerField(choices=ExitCodes.choices, null=True, blank=True)
306 | exit_log = models.TextField(null=True, blank=True)
307 |
308 | @property
309 | def state(self):
310 | if self.stopped:
311 | if self.exit_code == WorkerStatus.ExitCodes.STOPPED:
312 | return WorkerStatus.States.STOPPED
313 | elif self.exit_code == WorkerStatus.ExitCodes.TERMINATED:
314 | return WorkerStatus.States.TERMINATED
315 | else:
316 | return WorkerStatus.States.CRASHED
317 | elif self.last_tick < now() - self.timeout:
318 | return WorkerStatus.States.TIMEDOUT
319 | else:
320 | return WorkerStatus.States.ONLINE
321 |
322 | def __str__(self):
323 | return f"Worker '{self.label}' {self.icon}"
324 |
325 | @property
326 | def icon(self):
327 | return WorkerStatus.States.icon(self.state)
328 |
--------------------------------------------------------------------------------
/django_toosimple_q/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin.models import CHANGE, LogEntry
3 | from django.contrib.contenttypes.models import ContentType
4 | from django.contrib.messages.constants import SUCCESS
5 | from django.db.models import F
6 | from django.db.models.functions import Coalesce
7 | from django.template.defaultfilters import truncatechars
8 | from django.template.loader import render_to_string
9 | from django.urls import reverse
10 | from django.utils import timezone
11 | from django.utils.formats import date_format
12 | from django.utils.html import escape, format_html
13 | from django.utils.safestring import mark_safe
14 | from django.utils.translation import gettext_lazy as _
15 |
16 | from .models import ScheduleExec, TaskExec, WorkerStatus
17 | from .registry import schedules_registry, tasks_registry
18 |
19 |
20 | class AbstractQueueListFilter(admin.SimpleListFilter):
21 | title = _("queue")
22 | parameter_name = "queue"
23 | registry = None
24 | name_field = None
25 |
26 | def lookups(self, request, model_admin):
27 | queues = set(item.queue for item in self.registry.values())
28 | return [(q, q) for q in sorted(list(queues))]
29 |
30 | def queryset(self, request, queryset):
31 | queue = self.value()
32 | if queue:
33 | names = [
34 | item.name for item in self.registry.values() if item.queue == queue
35 | ]
36 | return queryset.filter(**{f"{self.name_field}__in": names})
37 |
38 |
39 | class TaskQueueListFilter(AbstractQueueListFilter):
40 | registry = tasks_registry
41 | name_field = "task_name"
42 |
43 |
44 | class ScheduleQueueListFilter(AbstractQueueListFilter):
45 | registry = schedules_registry
46 | name_field = "name"
47 |
48 |
49 | class ReadOnlyAdmin(admin.ModelAdmin):
50 | def has_change_permission(self, request, obj=None):
51 | return False
52 |
53 | def has_add_permission(self, request):
54 | return False
55 |
56 |
57 | @admin.register(TaskExec)
58 | class TaskExecAdmin(ReadOnlyAdmin):
59 | list_display = [
60 | "icon",
61 | "task_name",
62 | "arguments_",
63 | "timestamp_",
64 | "execution_time_",
65 | "replaced_by_",
66 | "result_preview",
67 | "task_",
68 | ]
69 | list_display_links = ["task_name"]
70 | list_filter = ["task_name", TaskQueueListFilter, "state"]
71 | actions = ["action_requeue"]
72 | ordering = ["-created"]
73 | readonly_fields = ["task_", "result"]
74 | date_hierarchy = "created"
75 | fieldsets = [
76 | (
77 | None,
78 | {"fields": ["icon", "task_name", "state", "task_"]},
79 | ),
80 | (
81 | "Arguments",
82 | {"fields": ["args", "kwargs"]},
83 | ),
84 | (
85 | "Time",
86 | {"fields": ["due_", "created_", "started_", "finished_"]},
87 | ),
88 | (
89 | "Retries",
90 | {"fields": ["retries", "retry_delay", "replaced_by"]},
91 | ),
92 | (
93 | "Execution",
94 | {"fields": ["worker", "error"]},
95 | ),
96 | (
97 | "Output",
98 | {"fields": ["stdout", "stderr", "result"]},
99 | ),
100 | ]
101 |
102 | def get_queryset(self, request):
103 | # defer stdout, stderr and results which may host large values
104 | qs = super().get_queryset(request)
105 | qs = qs.defer("stdout", "stderr", "result")
106 | # aggregate time for an unique field
107 | qs = qs.annotate(
108 | sortable_time=Coalesce("finished", "started", "due", "created"),
109 | execution_time=F("finished") - F("started"),
110 | )
111 | return qs
112 |
113 | def arguments_(self, obj):
114 | return format_html(
115 | "{}
{}",
116 | truncatechars(str(obj.args), 32),
117 | truncatechars(str(obj.kwargs), 32),
118 | )
119 |
120 | @admin.display(ordering="due")
121 | def due_(self, obj):
122 | return short_naturaltime(obj.due)
123 |
124 | @admin.display(ordering="created")
125 | def created_(self, obj):
126 | return short_naturaltime(obj.created)
127 |
128 | @admin.display(ordering="started")
129 | def started_(self, obj):
130 | return short_naturaltime(obj.started)
131 |
132 | @admin.display(ordering="finished")
133 | def finished_(self, obj):
134 | return short_naturaltime(obj.finished)
135 |
136 | @admin.display(ordering="sortable_time")
137 | def timestamp_(self, obj):
138 | if obj.finished:
139 | label = "finished"
140 | elif obj.started:
141 | label = "started"
142 | elif obj.due:
143 | label = "due"
144 | else:
145 | label = "created"
146 | return mark_safe(f"{short_naturaltime(obj.sortable_time)} [{label}]")
147 |
148 | @admin.display(ordering="execution_time")
149 | def execution_time_(self, obj):
150 | if not obj.execution_time:
151 | return None
152 | return short_seconds(obj.execution_time.seconds, additional_details=1)
153 |
154 | def replaced_by_(self, obj):
155 | if obj.replaced_by:
156 | return f"{obj.replaced_by.icon} [{obj.replaced_by.pk}]"
157 |
158 | def task_(self, obj):
159 | if not obj.task:
160 | return None
161 | return render_to_string("toosimpleq/task.html", {"task": obj.task})
162 |
163 | def get_actions(self, request):
164 | actions = super().get_actions(request)
165 | if not request.user.has_perm("toosimpleq.requeue_taskexec"):
166 | actions.pop("action_requeue", None)
167 | return actions
168 |
169 | @admin.display(description="Requeue task")
170 | def action_requeue(self, request, queryset):
171 | for task in queryset:
172 | new_task = tasks_registry[task.task_name].enqueue(*task.args, **task.kwargs)
173 | LogEntry.objects.log_action(
174 | user_id=request.user.id,
175 | content_type_id=ContentType.objects.get_for_model(task).pk,
176 | object_id=task.pk,
177 | object_repr=str(task),
178 | action_flag=CHANGE,
179 | change_message=(
180 | f"Requeued task through admin action (new task id: {new_task.pk})"
181 | ),
182 | )
183 | self.message_user(
184 | request, f"{queryset.count()} tasks successfully requeued", level=SUCCESS
185 | )
186 |
187 |
188 | @admin.register(ScheduleExec)
189 | class ScheduleExecAdmin(ReadOnlyAdmin):
190 | list_display = [
191 | "icon",
192 | "name",
193 | "last_due_",
194 | "next_due_",
195 | "last_task_",
196 | "schedule_",
197 | ]
198 | list_display_links = ["name"]
199 | ordering = ["-last_due"]
200 | list_filter = ["name", ScheduleQueueListFilter, "state"]
201 | actions = ["action_force_run"]
202 | readonly_fields = ["schedule_"]
203 | fieldsets = [
204 | (
205 | None,
206 | {"fields": ["icon", "name", "state", "schedule_"]},
207 | ),
208 | (
209 | "Time",
210 | {"fields": ["last_due_", "next_due_"]},
211 | ),
212 | (
213 | "Execution",
214 | {"fields": ["last_task_"]},
215 | ),
216 | ]
217 |
218 | def schedule_(self, obj):
219 | if not obj.schedule:
220 | return None
221 | return render_to_string("toosimpleq/schedule.html", {"schedule": obj.schedule})
222 |
223 | def last_task_(self, obj):
224 | if obj.last_task:
225 | app, model = obj.last_task._meta.app_label, obj.last_task._meta.model_name
226 | edit_link = reverse(f"admin:{app}_{model}_change", args=(obj.last_task_id,))
227 | return format_html('{}', edit_link, obj.last_task)
228 | return "-"
229 |
230 | @admin.display(ordering="last_due")
231 | def last_due_(self, obj):
232 | return short_naturaltime(obj.last_due)
233 |
234 | @admin.display()
235 | def next_due_(self, obj):
236 | # for schedule not in the code anymore
237 | if not obj.schedule:
238 | return "invalid"
239 |
240 | if len(obj.past_dues) >= 1:
241 | next_due = obj.past_dues[0]
242 | else:
243 | next_due = obj.upcomming_due
244 |
245 | if next_due is None:
246 | return "never"
247 |
248 | formatted_next_due = short_naturaltime(next_due)
249 | if len(obj.past_dues) > 1:
250 | formatted_next_due += mark_safe(f" [×{len(obj.past_dues)}]")
251 | if next_due < timezone.now():
252 | return mark_safe(f"{formatted_next_due}")
253 | return formatted_next_due
254 |
255 | def get_actions(self, request):
256 | actions = super().get_actions(request)
257 | if not request.user.has_perm("toosimpleq.force_run_scheduleexec"):
258 | actions.pop("action_force_run", None)
259 | return actions
260 |
261 | @admin.display(description="Force run schedule")
262 | def action_force_run(self, request, queryset):
263 | for schedule_exec in queryset:
264 | LogEntry.objects.log_action(
265 | user_id=request.user.id,
266 | content_type_id=ContentType.objects.get_for_model(schedule_exec).pk,
267 | object_id=schedule_exec.pk,
268 | object_repr=str(schedule_exec),
269 | action_flag=CHANGE,
270 | change_message=("Forced schedule execution through admin action"),
271 | )
272 | schedule_exec.schedule.execute(dues=[None])
273 | self.message_user(
274 | request,
275 | f"{queryset.count()} schedules successfully executed",
276 | level=SUCCESS,
277 | )
278 |
279 |
280 | @admin.register(WorkerStatus)
281 | class WorkerStatusAdmin(ReadOnlyAdmin):
282 | list_display = [
283 | "icon",
284 | "label",
285 | "last_tick_",
286 | "started_",
287 | "stopped_",
288 | "included_queues",
289 | "excluded_queues",
290 | ]
291 | list_display_links = ["label"]
292 | ordering = ["-started", "label"]
293 | readonly_fields = ["state"]
294 | fieldsets = [
295 | (
296 | None,
297 | {"fields": ["icon", "label"]},
298 | ),
299 | (
300 | "Queues",
301 | {"fields": ["included_queues", "excluded_queues"]},
302 | ),
303 | (
304 | "Time",
305 | {"fields": ["timeout", "last_tick_", "started_", "stopped_"]},
306 | ),
307 | (
308 | "Exit state",
309 | {"fields": ["exit_code", "exit_log"]},
310 | ),
311 | ]
312 |
313 | @admin.display(ordering="last_tick")
314 | def last_tick_(self, obj):
315 | return short_naturaltime(obj.last_tick)
316 |
317 | @admin.display(ordering="started")
318 | def started_(self, obj):
319 | return short_naturaltime(obj.started)
320 |
321 | @admin.display(ordering="stopped")
322 | def stopped_(self, obj):
323 | return short_naturaltime(obj.stopped)
324 |
325 |
326 | def short_seconds(seconds, additional_details=0):
327 | if seconds is None:
328 | return None
329 | disps = [
330 | (60, "second"),
331 | (60 * 60, "minute"),
332 | (60 * 60 * 24, "hour"),
333 | (60 * 60 * 24 * 7, "day"),
334 | (60 * 60 * 24 * 30, "week"),
335 | (60 * 60 * 24 * 365, "month"),
336 | (float("inf"), "year"),
337 | ]
338 | last_v = 1
339 | for threshold, abbr in disps:
340 | if abs(seconds) < threshold:
341 | count = int(abs(seconds) // last_v)
342 | plural = "s" if count > 1 else ""
343 | text = f"{count} {abbr}{plural}"
344 | if additional_details:
345 | remainder = seconds - count * last_v
346 | if remainder > 0:
347 | text += " " + short_seconds(remainder, additional_details - 1)
348 | return text
349 | last_v = threshold
350 |
351 |
352 | def short_naturaltime(datetime):
353 | if datetime is None:
354 | return None
355 | seconds = (timezone.now() - datetime).total_seconds()
356 | text = short_seconds(seconds)
357 | shorttime = f"in {text}" if seconds < 0 else f"{text} ago"
358 | longtime = date_format(datetime, format="DATETIME_FORMAT", use_l10n=True)
359 | return mark_safe(f'{shorttime}')
360 |
--------------------------------------------------------------------------------
/django_toosimple_q/management/commands/worker.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import os
4 | import signal
5 | from time import sleep
6 | from traceback import format_exc
7 |
8 | from django.core.management.base import BaseCommand, CommandError
9 | from django.db import IntegrityError, transaction
10 | from django.db.models import Case, Value, When
11 | from django.utils.timezone import now
12 |
13 | from ...logging import logger
14 | from ...models import ScheduleExec, TaskExec, WorkerStatus
15 | from ...registry import schedules_registry, tasks_registry
16 | from ...tests.utils import FakeException
17 |
18 |
19 | class Command(BaseCommand):
20 | help = "Run tasks and schedules"
21 |
22 | def add_arguments(self, parser):
23 | queue = parser.add_mutually_exclusive_group()
24 | queue.add_argument(
25 | "--queue",
26 | action="append",
27 | help="which queue to run (can be used several times, all queues are run if not provided)",
28 | )
29 | queue.add_argument(
30 | "--exclude_queue",
31 | action="append",
32 | help="which queue not to run (can be used several times, all queues are run if not provided)",
33 | )
34 |
35 | mode = parser.add_mutually_exclusive_group()
36 | mode.add_argument(
37 | "--once",
38 | action="store_true",
39 | help="run once then exit (useful for debugging)",
40 | )
41 | mode.add_argument(
42 | "--until_done",
43 | action="store_true",
44 | help="run until no tasks are available then exit (useful for debugging)",
45 | )
46 |
47 | parser.add_argument(
48 | "--no_schedules",
49 | action="store_true",
50 | help="do not process schedules",
51 | )
52 |
53 | parser.add_argument(
54 | "--tick",
55 | default=10.0,
56 | type=float,
57 | help="frequency in seconds at which the database is checked for new tasks/schedules",
58 | )
59 |
60 | parser.add_argument(
61 | "--label",
62 | default=r"worker-{pid}",
63 | help=r"the name of the worker to help identifying it ('{pid}' will be replaced by the process id)",
64 | )
65 | parser.add_argument(
66 | "--timeout",
67 | default=60 * 5,
68 | type=float,
69 | help="the time in seconds after which this worker will be considered offline (set this to a value higher than the longest tasks this worker will execute)",
70 | )
71 |
72 | def handle(self, *args, **options):
73 | try:
74 | self._handle(*args, **options)
75 | except Exception as e:
76 | logger.exception(e)
77 | raise e
78 |
79 | def _handle(self, *args, **options):
80 | # Handle interuption signals
81 | signal.signal(signal.SIGINT, self.handle_signal)
82 | signal.signal(signal.SIGTERM, self.handle_signal)
83 | # Custom signal to provoke an artifical exception, used for testing only
84 | signal.signal(signal.SIGUSR1, self.handle_signal)
85 |
86 | # TODO: replace by simple-parsing
87 | self.queues = options["queue"] or []
88 | self.excluded_queues = options["exclude_queue"] or []
89 | self.tick_duration = options["tick"]
90 | self.once = options["once"]
91 | self.until_done = options["until_done"]
92 | self.no_schedules = options["no_schedules"]
93 | self.timeout = options["timeout"]
94 | self.label = options["label"].replace(r"{pid}", f"{os.getpid()}")
95 |
96 | self.exit_requested = False
97 | self.simulate_exception = False
98 | self.cur_task_exec = None
99 |
100 | logger.info(f"Starting worker '{self.label}'...")
101 | if self.queues:
102 | logger.info(f"Included queues: {self.queues}")
103 | elif self.excluded_queues:
104 | logger.info(f"Excluded queues: {self.excluded_queues}")
105 |
106 | self.verbosity = int(options["verbosity"])
107 | if self.verbosity == 0:
108 | logger.setLevel(logging.WARNING)
109 | elif self.verbosity == 1:
110 | logger.setLevel(logging.INFO)
111 | else:
112 | logger.setLevel(logging.DEBUG)
113 |
114 | # On startup, we report the worker went online
115 | logger.debug(f"Get or create worker instance")
116 | self.worker_status, _ = WorkerStatus.objects.update_or_create(
117 | label=self.label,
118 | defaults={
119 | "started": now(),
120 | "stopped": None,
121 | "exit_code": None,
122 | "exit_log": None,
123 | "included_queues": self.queues,
124 | "excluded_queues": self.excluded_queues,
125 | "timeout": datetime.timedelta(seconds=self.timeout),
126 | },
127 | )
128 |
129 | exc = None
130 |
131 | try:
132 | # Run the loop
133 | while self.do_loop():
134 | pass
135 |
136 | self.worker_status.exit_code = WorkerStatus.ExitCodes.STOPPED.value
137 | self.worker_status.exit_log = ""
138 |
139 | except (KeyboardInterrupt, SystemExit) as e:
140 | exc = e
141 | logger.critical(f"Terminated by user request")
142 | if self.cur_task_exec:
143 | logger.critical(f"{self.cur_task_exec} got terminated !")
144 | self.cur_task_exec.state = TaskExec.States.INTERRUPTED
145 | self.cur_task_exec.save()
146 | self.cur_task_exec.create_replacement(is_retry=False)
147 | self.cur_task_exec = None
148 | self.worker_status.exit_code = WorkerStatus.ExitCodes.TERMINATED.value
149 | self.worker_status.exit_log = format_exc()
150 |
151 | except Exception as e:
152 | exc = e
153 | logger.exception(e)
154 | self.worker_status.exit_code = WorkerStatus.ExitCodes.CRASHED.value
155 | self.worker_status.exit_log = format_exc()
156 |
157 | finally:
158 | self.worker_status.stopped = now()
159 | self.worker_status.save()
160 |
161 | if self.worker_status.exit_code:
162 | raise CommandError(returncode=self.worker_status.exit_code) from exc
163 |
164 | def do_loop(self) -> bool:
165 | """Runs one tick, returns True if it should continue looping"""
166 |
167 | logger.debug(f"Tick !")
168 |
169 | last_run = now()
170 |
171 | did_something = False
172 |
173 | logger.debug(f"1. Update status...")
174 | self.worker_status.last_tick = now()
175 | self.worker_status.save()
176 |
177 | logger.debug(f"2. Disabling orphaned schedules")
178 | with transaction.atomic():
179 | if (
180 | ScheduleExec.objects.exclude(state=ScheduleExec.States.INVALID)
181 | .exclude(name__in=schedules_registry.keys())
182 | .update(state=ScheduleExec.States.INVALID)
183 | ):
184 | logger.warning(f"Found invalid schedules")
185 |
186 | logger.debug(f"3. Disabling orphaned tasks")
187 | with transaction.atomic():
188 | if (
189 | TaskExec.objects.exclude(state=TaskExec.States.INVALID)
190 | .exclude(task_name__in=tasks_registry.keys())
191 | .update(state=TaskExec.States.INVALID)
192 | ):
193 | logger.warning(f"Found invalid tasks")
194 |
195 | if not self.no_schedules:
196 | logger.debug(f"4. Create missing schedules")
197 | existing_schedules_names = ScheduleExec.objects.values_list(
198 | "name", flat=True
199 | )
200 | for schedule in self._relevant_schedules:
201 | # Create the schedule exec if it does not exist
202 | if schedule.name in existing_schedules_names:
203 | continue
204 | try:
205 | last_due = None if schedule.run_on_creation else now()
206 | ScheduleExec.objects.create(name=schedule.name, last_due=last_due)
207 | logger.debug(f"Created schedule {schedule.name}")
208 | except IntegrityError:
209 | # This could happen with concurrent workers, and can be ignored
210 | logger.debug(
211 | f"Schedule {schedule.name} already exists, probably created concurrently"
212 | )
213 |
214 | logger.debug(f"5. Execute schedules")
215 | with transaction.atomic():
216 | for schedule_exec in self._build_schedules_list_qs():
217 | did_something |= schedule_exec.execute()
218 |
219 | logger.debug(f"6. Waking up tasks")
220 | TaskExec.objects.filter(state=TaskExec.States.SLEEPING).filter(
221 | due__lte=now()
222 | ).update(state=TaskExec.States.QUEUED)
223 |
224 | logger.debug(f"7. Locking task")
225 | with transaction.atomic():
226 | self.cur_task_exec = self._build_due_tasks_qs().first()
227 | if self.cur_task_exec:
228 | logger.debug(f"Picking up for execution : {self.cur_task_exec}")
229 | self.cur_task_exec.started = now()
230 | self.cur_task_exec.state = TaskExec.States.PROCESSING
231 | self.cur_task_exec.worker = self.worker_status
232 | self.cur_task_exec.save()
233 |
234 | logger.debug(f"8. Running task")
235 | if self.cur_task_exec:
236 | logger.debug(f"Executing : {self.cur_task_exec}")
237 | did_something = True
238 | self.cur_task_exec.execute()
239 | self.cur_task_exec = None
240 |
241 | if self.once:
242 | logger.info("Exiting loop because --once was passed")
243 | return False
244 |
245 | if self.until_done and not did_something:
246 | logger.info("Exiting loop because --until_done was passed")
247 | return False
248 |
249 | if self.exit_requested:
250 | logger.critical("Exiting gracefully on user request")
251 | return False
252 |
253 | if self.simulate_exception:
254 | # for testing
255 | raise FakeException()
256 |
257 | if not did_something:
258 | logger.debug(f"Waiting for next tick...")
259 | next_run = last_run + datetime.timedelta(seconds=self.tick_duration)
260 | while not self.exit_requested and now() < next_run:
261 | sleep(1)
262 |
263 | return True
264 |
265 | def handle_signal(self, sig, stackframe):
266 | # For testing, simulates a unexpected crash of the worker
267 | if sig == signal.SIGUSR1:
268 | self.simulate_exception = True
269 | return
270 |
271 | # A termination signal or a second interruption signal should force exit
272 | if sig == signal.SIGTERM or (sig == signal.SIGINT and self.exit_requested):
273 | logger.critical(f"User requested termination...")
274 | raise KeyboardInterrupt()
275 |
276 | # A first interruption signal is graceful exit
277 | if sig == signal.SIGINT:
278 | logger.critical(f"User requested graceful exit...")
279 | if self.cur_task_exec is not None:
280 | logger.critical(f"Waiting for `{self.cur_task_exec}` to finish...")
281 | self.exit_requested = True
282 |
283 | @property
284 | def _relevant_schedules(self):
285 | """Get a list of schedules for this worker"""
286 | return schedules_registry.for_queue(self.queues, self.excluded_queues)
287 |
288 | def _build_schedules_list_qs(self):
289 | """The queryset to select the list of schedules for update"""
290 |
291 | return ScheduleExec.objects.filter(
292 | name__in=[s.name for s in self._relevant_schedules]
293 | ).select_for_update(skip_locked=True)
294 |
295 | @property
296 | def _relevant_tasks(self):
297 | """Get a list of tasks for this worker"""
298 | return tasks_registry.for_queue(self.queues, self.excluded_queues)
299 |
300 | def _build_due_tasks_qs(self):
301 | """The queryset to select the task due by this worker for update"""
302 |
303 | # Build a order_by clause using the task priorities
304 | whens = [
305 | When(task_name=t.name, then=Value(-t.priority))
306 | for t in self._relevant_tasks
307 | ]
308 | order_by_priority = Case(*whens, default=Value(0))
309 |
310 | # Build the queryset
311 | return (
312 | TaskExec.objects.filter(state=TaskExec.States.QUEUED)
313 | .filter(task_name__in=[t.name for t in self._relevant_tasks])
314 | .order_by(order_by_priority, "due", "created")
315 | .select_for_update(skip_locked=True)
316 | )
317 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_schedules.py:
--------------------------------------------------------------------------------
1 | from django.core import management
2 | from freezegun import freeze_time
3 |
4 | from django_toosimple_q.decorators import register_task, schedule_task
5 | from django_toosimple_q.models import ScheduleExec, TaskExec
6 | from django_toosimple_q.registry import schedules_registry
7 |
8 | from .base import TooSimpleQRegularTestCase
9 |
10 |
11 | class TestSchedules(TooSimpleQRegularTestCase):
12 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
13 | def test_schedule(self, frozen_datetime):
14 | """Testing schedules"""
15 |
16 | @schedule_task(cron="0 12 * * *", datetime_kwarg="scheduled_on")
17 | @register_task(name="normal")
18 | def a(scheduled_on):
19 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
20 |
21 | @schedule_task(
22 | cron="0 12 * * *", run_on_creation=True, datetime_kwarg="scheduled_on"
23 | )
24 | @register_task(name="autostart")
25 | def b(scheduled_on):
26 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
27 |
28 | @schedule_task(cron="0 12 * * *", catch_up=True, datetime_kwarg="scheduled_on")
29 | @register_task(name="catchup")
30 | def c(scheduled_on):
31 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
32 |
33 | @schedule_task(
34 | cron="0 12 * * *",
35 | run_on_creation=True,
36 | catch_up=True,
37 | datetime_kwarg="scheduled_on",
38 | )
39 | @register_task(name="autostartcatchup")
40 | def d(scheduled_on):
41 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
42 |
43 | self.assertEqual(len(schedules_registry), 4)
44 | self.assertEqual(ScheduleExec.objects.count(), 0)
45 | self.assertQueue(0)
46 |
47 | management.call_command("worker", "--until_done")
48 |
49 | # first run, only tasks with run_on_creation=True should run as no time passed
50 | self.assertQueue(0, task_name="normal")
51 | self.assertQueue(1, task_name="autostart")
52 | self.assertQueue(0, task_name="catchup")
53 | self.assertQueue(1, task_name="autostartcatchup")
54 | self.assertQueue(2)
55 |
56 | management.call_command("worker", "--until_done")
57 |
58 | # second run, no time passed so no change
59 | self.assertQueue(0, task_name="normal")
60 | self.assertQueue(1, task_name="autostart")
61 | self.assertQueue(0, task_name="catchup")
62 | self.assertQueue(1, task_name="autostartcatchup")
63 | self.assertQueue(2)
64 |
65 | frozen_datetime.move_to("2020-01-02")
66 | management.call_command("worker", "--until_done")
67 |
68 | # one day passed, all tasks should have run once
69 | self.assertQueue(1, task_name="normal")
70 | self.assertQueue(2, task_name="autostart")
71 | self.assertQueue(1, task_name="catchup")
72 | self.assertQueue(2, task_name="autostartcatchup")
73 | self.assertQueue(6)
74 |
75 | frozen_datetime.move_to("2020-01-05")
76 | management.call_command("worker", "--until_done")
77 |
78 | # three day passed, catch_up should have run thrice and other once
79 | self.assertQueue(2, task_name="normal")
80 | self.assertQueue(3, task_name="autostart")
81 | self.assertQueue(4, task_name="catchup")
82 | self.assertQueue(5, task_name="autostartcatchup")
83 | self.assertQueue(14)
84 |
85 | # make sure all tasks succeeded
86 | self.assertQueue(14, state=TaskExec.States.SUCCEEDED)
87 |
88 | # make sure we got correct dates
89 | self.assertResults(
90 | task_name="normal",
91 | expected=[
92 | "2020-01-01 12:00",
93 | "2020-01-04 12:00",
94 | ],
95 | )
96 | self.assertResults(
97 | task_name="autostart",
98 | expected=[
99 | "2019-12-31 12:00",
100 | "2020-01-01 12:00",
101 | "2020-01-04 12:00",
102 | ],
103 | )
104 | self.assertResults(
105 | task_name="catchup",
106 | expected=[
107 | "2020-01-01 12:00",
108 | "2020-01-02 12:00",
109 | "2020-01-03 12:00",
110 | "2020-01-04 12:00",
111 | ],
112 | )
113 | self.assertResults(
114 | task_name="autostartcatchup",
115 | expected=[
116 | "2019-12-31 12:00",
117 | "2020-01-01 12:00",
118 | "2020-01-02 12:00",
119 | "2020-01-03 12:00",
120 | "2020-01-04 12:00",
121 | ],
122 | )
123 |
124 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
125 | def test_manual_schedule(self, frozen_datetime):
126 | """Testing manual schedules"""
127 |
128 | @schedule_task(cron="manual", datetime_kwarg="scheduled_on")
129 | @register_task(name="normal")
130 | def a(scheduled_on):
131 | return f"{scheduled_on:%Y-%m-%d %H:%M}"
132 |
133 | self.assertEqual(len(schedules_registry), 1)
134 | self.assertEqual(ScheduleExec.objects.count(), 0)
135 | self.assertQueue(0)
136 |
137 | # a "manual" schedule never runs
138 | management.call_command("worker", "--until_done")
139 | frozen_datetime.move_to("2050-01-01")
140 | management.call_command("worker", "--until_done")
141 | self.assertQueue(0)
142 |
143 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
144 | def test_invalid_schedule(self, frozen_datetime):
145 | """Testing invalid schedules"""
146 |
147 | @schedule_task(cron="0 * * * *")
148 | @register_task(name="valid")
149 | def a():
150 | return f"Valid task"
151 |
152 | @schedule_task(cron="0 * * * *")
153 | @register_task(name="invalid")
154 | def a():
155 | return f"Invalid task"
156 |
157 | all_schedules = ScheduleExec.objects.all()
158 |
159 | management.call_command("worker", "--until_done")
160 |
161 | self.assertEqual(
162 | all_schedules.filter(state=ScheduleExec.States.ACTIVE).count(), 2
163 | )
164 | self.assertEqual(
165 | all_schedules.filter(state=ScheduleExec.States.INVALID).count(), 0
166 | )
167 | self.assertEqual(all_schedules.count(), 2)
168 |
169 | del schedules_registry["invalid"]
170 |
171 | management.call_command("worker", "--until_done")
172 |
173 | self.assertEqual(
174 | all_schedules.filter(state=ScheduleExec.States.ACTIVE).count(), 1
175 | )
176 | self.assertEqual(
177 | all_schedules.filter(state=ScheduleExec.States.INVALID).count(), 1
178 | )
179 | self.assertEqual(all_schedules.count(), 2)
180 |
181 | def test_named_queues(self):
182 | """Checking named queues"""
183 |
184 | @schedule_task(cron="* * * * *") # queue="default"
185 | @register_task(name="a")
186 | def a(x):
187 | return x * 2
188 |
189 | @schedule_task(cron="* * * * *", queue="queue_b")
190 | @register_task(name="b")
191 | def b(x):
192 | return x * 2
193 |
194 | @schedule_task(cron="* * * * *", queue="queue_c")
195 | @register_task(name="c")
196 | def c(x):
197 | return x * 2
198 |
199 | @schedule_task(cron="* * * * *", queue="queue_d")
200 | @register_task(name="d")
201 | def d(x):
202 | return x * 2
203 |
204 | @schedule_task(cron="* * * * *", queue="queue_e")
205 | @register_task(name="e")
206 | def e(x):
207 | return x * 2
208 |
209 | @schedule_task(cron="* * * * *", queue="queue_f")
210 | @register_task(name="f")
211 | def f(x):
212 | return x * 2
213 |
214 | @schedule_task(cron="* * * * *", queue="queue_g")
215 | @register_task(name="g")
216 | def g(x):
217 | return x * 2
218 |
219 | @schedule_task(cron="* * * * *", queue="queue_h")
220 | @register_task(name="h")
221 | def h(x):
222 | return x * 2
223 |
224 | self.assertSchedule("a", None)
225 | self.assertSchedule("b", None)
226 | self.assertSchedule("c", None)
227 | self.assertSchedule("d", None)
228 | self.assertSchedule("e", None)
229 | self.assertSchedule("f", None)
230 | self.assertSchedule("g", None)
231 | self.assertSchedule("h", None)
232 |
233 | # make sure schedules get assigned to default queue by default
234 | management.call_command("worker", "--until_done", "--queue", "default")
235 |
236 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
237 | self.assertSchedule("b", None)
238 | self.assertSchedule("c", None)
239 | self.assertSchedule("d", None)
240 | self.assertSchedule("e", None)
241 | self.assertSchedule("f", None)
242 | self.assertSchedule("g", None)
243 | self.assertSchedule("h", None)
244 |
245 | # make sure worker only runs their queue
246 | management.call_command("worker", "--until_done", "--queue", "queue_c")
247 |
248 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
249 | self.assertSchedule("b", None)
250 | self.assertSchedule("c", ScheduleExec.States.ACTIVE)
251 | self.assertSchedule("d", None)
252 | self.assertSchedule("e", None)
253 | self.assertSchedule("f", None)
254 | self.assertSchedule("g", None)
255 | self.assertSchedule("h", None)
256 |
257 | # make sure worker can run multiple queues
258 | management.call_command(
259 | "worker", "--until_done", "--queue", "queue_b", "--queue", "queue_d"
260 | )
261 |
262 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
263 | self.assertSchedule("b", ScheduleExec.States.ACTIVE)
264 | self.assertSchedule("c", ScheduleExec.States.ACTIVE)
265 | self.assertSchedule("d", ScheduleExec.States.ACTIVE)
266 | self.assertSchedule("e", None)
267 | self.assertSchedule("f", None)
268 | self.assertSchedule("g", None)
269 | self.assertSchedule("h", None)
270 |
271 | # make sure worker exclude queue works
272 | management.call_command(
273 | "worker",
274 | "--until_done",
275 | "--exclude_queue",
276 | "queue_g",
277 | "--exclude_queue",
278 | "queue_h",
279 | )
280 |
281 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
282 | self.assertSchedule("b", ScheduleExec.States.ACTIVE)
283 | self.assertSchedule("c", ScheduleExec.States.ACTIVE)
284 | self.assertSchedule("d", ScheduleExec.States.ACTIVE)
285 | self.assertSchedule("e", ScheduleExec.States.ACTIVE)
286 | self.assertSchedule("f", ScheduleExec.States.ACTIVE)
287 | self.assertSchedule("g", None)
288 | self.assertSchedule("h", None)
289 |
290 | # make sure worker run all queues by default
291 | management.call_command("worker", "--until_done")
292 |
293 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
294 | self.assertSchedule("b", ScheduleExec.States.ACTIVE)
295 | self.assertSchedule("c", ScheduleExec.States.ACTIVE)
296 | self.assertSchedule("d", ScheduleExec.States.ACTIVE)
297 | self.assertSchedule("e", ScheduleExec.States.ACTIVE)
298 | self.assertSchedule("f", ScheduleExec.States.ACTIVE)
299 | self.assertSchedule("g", ScheduleExec.States.ACTIVE)
300 | self.assertSchedule("h", ScheduleExec.States.ACTIVE)
301 |
302 | def test_no_schedules(self):
303 | """Checking named queues"""
304 |
305 | @schedule_task(cron="0 * * * *", run_on_creation=True)
306 | @register_task(name="a")
307 | def a():
308 | return True
309 |
310 | @schedule_task(cron="0 * * * *", run_on_creation=True)
311 | @register_task(name="b")
312 | def b():
313 | return True
314 |
315 | a.queue()
316 |
317 | self.assertSchedule("a", None)
318 | self.assertSchedule("b", None)
319 | self.assertQueue(1, "a", TaskExec.States.QUEUED)
320 | self.assertQueue(0, "b")
321 |
322 | # runninng with --no_schedules doesn't populate schedules but runs tasks
323 | management.call_command("worker", "--until_done", "--no_schedules")
324 |
325 | print(TaskExec.objects.all().last().error)
326 |
327 | self.assertSchedule("a", None)
328 | self.assertSchedule("b", None)
329 | self.assertQueue(1, "a", TaskExec.States.SUCCEEDED)
330 | self.assertQueue(0, "b")
331 |
332 | # runninng with --no_schedules doesn't pickup existing schedules either
333 | ScheduleExec.objects.create(name="a", state=ScheduleExec.States.INVALID)
334 | management.call_command("worker", "--until_done", "--no_schedules")
335 |
336 | self.assertSchedule("a", ScheduleExec.States.INVALID)
337 | self.assertSchedule("b", None)
338 | self.assertQueue(1, "a", TaskExec.States.SUCCEEDED)
339 | self.assertQueue(0, "b")
340 |
341 | # just ensure schedules work as expected without --no_schedules
342 | management.call_command("worker", "--until_done")
343 |
344 | self.assertSchedule("a", ScheduleExec.States.ACTIVE)
345 | self.assertSchedule("b", ScheduleExec.States.ACTIVE)
346 | self.assertQueue(2, "a", TaskExec.States.SUCCEEDED)
347 | self.assertQueue(1, "b", TaskExec.States.SUCCEEDED)
348 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django Too Simple Queue
2 |
3 | [](https://pypi.org/project/django-toosimple-q/)
4 | [](https://github.com/olivierdalang/django-toosimple-q/actions/workflows/test.yml)
5 |
6 | This packages provides a simplistic task queue and scheduler for Django.
7 |
8 | It is geared towards basic apps, where simplicity primes. The package offers simple decorator syntax, including cron-like schedules.
9 |
10 | Features :
11 |
12 | - no celery/redis/rabbitmq/whatever... just Django !
13 | - clean decorator syntax to register tasks and schedules
14 | - simple queuing syntax
15 | - cron-like scheduling
16 | - tasks.py autodiscovery
17 | - django admin integration
18 | - tasks results stored using the Django ORM
19 | - replacement tasks on interruption
20 |
21 | Limitations :
22 |
23 | - no multithreading yet (but running multiple workers should work)
24 | - not well suited for projects spawning a high volume of tasks
25 |
26 | Compatibility:
27 |
28 | - Django 3.2 and 4.0
29 | - Python 3.8, 3.9, 3.10
30 |
31 | ## Installation
32 |
33 | Install the library :
34 | ```shell
35 | pip install django-toosimple-q
36 | ```
37 |
38 | Enable the app in `settings.py` :
39 | ```python
40 | INSTALLED_APPS = [
41 | # ...
42 | 'django_toosimple_q',
43 | # ...
44 | ]
45 | ```
46 |
47 | ## Quickstart
48 |
49 | Tasks need to be registered using the `@register_task()` decorator. Once registered, they can be added to the queue by calling the `.queue()` function.
50 |
51 | ```python
52 | from django_toosimple_q.decorators import register_task
53 |
54 | # Register a task
55 | @register_task()
56 | def my_task(name):
57 | return f"Hello {name} !"
58 |
59 | # Enqueue tasks
60 | my_task.queue("John")
61 | my_task.queue("Peter")
62 | ```
63 |
64 | Registered tasks can be scheduled from code using this cron-like syntax :
65 | ```python
66 | from django_toosimple_q.decorators import register_task, schedule_task
67 |
68 | # Register and schedule tasks (each morning at 8:30)
69 | @schedule_task(cron="30 8 * * *", args=['John'])
70 | @register_task()
71 | def morning_routine(name):
72 | return f"Good morning {name} !"
73 | ```
74 |
75 | To consume the tasks, you need to run at least one worker :
76 | ```shell
77 | python manage.py worker
78 | ```
79 | The workers will take care of adding scheduled tasks to the queue when needed, and will execute the tasks.
80 |
81 | The package autoloads `tasks.py` from all installed apps. While this is the recommended place to define your tasks, you can do so from anywhere in your code.
82 |
83 | ## Advanced usage
84 |
85 | ### Tasks
86 |
87 | You can optionnaly give a custom name to your tasks. This is required when your task is defined in a local scope.
88 | ```python
89 | @register_task(name="my_favourite_task")
90 | def my_task():
91 | ...
92 | ```
93 |
94 | You can set task priorities.
95 | ```python
96 | @register_task(priority=0)
97 | def my_favourite_task():
98 | ...
99 |
100 | @register_task(priority=1)
101 | def my_other_task():
102 | ...
103 |
104 | # Enqueue tasks
105 | my_other_task.queue()
106 | my_favourite_task.queue() # will be executed before the other one
107 | ```
108 |
109 | You can define `retries=N` and `retry_delay=S` to retry the task in case of failure. The delay (in second) will double on each failure.
110 |
111 | ```python
112 | @register_task(retries=10, retry_delay=60)
113 | def download_data():
114 | ...
115 | ```
116 |
117 | You can mark a task as `unique=True` if the task shouldn't be queued again if already queued with the same arguments. This is usefull for tasks such as cleaning or refreshing.
118 |
119 | ```python
120 | @register_task(unique=True)
121 | def cleanup():
122 | ...
123 |
124 | cleanup.queue()
125 | cleanup.queue() # this will be ignored as long as the first one is still queued
126 | ```
127 |
128 | You can assign tasks to specific queues, and then have your worker only consume tasks from specific queues using `--queue myqueue` or `--exclude_queue myqueue`. By default, workers consume all tasks.
129 |
130 | ```python
131 | @register_task(queue='long_running')
132 | def long_task():
133 | ...
134 |
135 | @register_task()
136 | def short_task():
137 | ...
138 |
139 | # Then run those with these workers, so that long
140 | # running tasks don't prevent short running tasks
141 | # from being run :
142 | # manage.py worker --exclude_queue long_running
143 | # manage.py worker
144 | ```
145 |
146 | You can enqueue tasks with a specific due date.
147 | ```python
148 | @register_task()
149 | def my_task():
150 | ...
151 |
152 | # Enqueue tasks
153 | from datetime import datetime, timedelta
154 | my_task.queue("John", due=datetime.now() + timedelta(hours=1))
155 | ```
156 |
157 | The `queue()` function returns a `TaskExec` model instance, which holds information about the task execution, including the task result.
158 |
159 | ```python
160 | from django.core.management import call_command
161 | from django_toosimple_q.models import TaskExec
162 |
163 | @register_task()
164 | def multiply(a, b):
165 | return a * b
166 |
167 | t = multiply.queue(3, 4)
168 |
169 | assert t.state == TaskExec.States.QUEUED
170 | assert t.result == None
171 |
172 | call_command("worker", "--until_done") # equivalent to `python manage.py worker --until_done`
173 |
174 | t.refresh_from_db()
175 | assert t.state == TaskExec.States.SUCCEEDED
176 | assert t.result == 12
177 | ```
178 |
179 | ### Schedules
180 |
181 | You may define multiple schedules for the same task. In this case, it is mandatory to specify a unique name :
182 |
183 | ```python
184 | @schedule_task(name="afternoon_routine", cron="30 16 * * *", args=['afternoon'])
185 | @schedule_task(name="morning_routine", cron="30 8 * * *", args=['morning'])
186 | @register_task()
187 | def my_task(time_of_day):
188 | return f"Good {time_of_day} John !"
189 | ```
190 |
191 | By default, `last_run` is set to `now()` on schedule creation. This means they will only run on next cron occurence. If you need your schedules to be run as soon as possible after initialisation, you can specify `run_on_creation=True`.
192 |
193 | ```python
194 | @schedule_task(cron="30 8 * * *", run_on_creation=True)
195 | @register_task()
196 | def my_task():
197 | ...
198 | ```
199 |
200 | By default, if some crons where missed (e.g. after a server shutdown or if the workers can't keep up with all tasks), the missed tasks will be lost. If you need the tasks to catch up, set `catch_up=True`.
201 |
202 | ```python
203 | @schedule_task(cron="30 8 * * *", catch_up=True)
204 | @register_task()
205 | def my_task():
206 | ...
207 | ```
208 |
209 | You may get the schedule's cron datetime provided as a keyword argument to the task using the `datetime_kwarg` argument. This is often useful in combination with catch_up, for things like report generation. Remember to treat the case where the argument is `None` (which happens when the task is run outside of the schedule).
210 |
211 | ```python
212 | @schedule_task(cron="30 8 * * *", datetime_kwarg="scheduled_on", catch_up=True)
213 | @register_task()
214 | def my_task(scheduled_on):
215 | if scheduled_on:
216 | return f"This was scheduled for {scheduled_on.isoformat()}."
217 | else:
218 | return "This was not scheduled."
219 | ```
220 |
221 | Similarly to tasks, you can assign schedules to specific queues, and then have your worker only consume tasks from specific queues using `--queue myqueue` or `--exclude_queue myqueue`.
222 |
223 | ```python
224 | @schedule_task(cron="30 8 * * *", queue='scheduler')
225 | @register_task(queue='worker')
226 | def task():
227 | ...
228 |
229 | # Then run those with these workers
230 | # manage.py worker --queue scheduler
231 | # manage.py worker --queue worker
232 | ```
233 |
234 | Schedule's cron support a non-standard sixth argument for seconds :
235 | ```python
236 | from django_toosimple_q.decorators import register_task, schedule_task
237 |
238 | # A schedule running every 15 seconds
239 | @schedule_task(cron="* * * * * */15")
240 | @register_task()
241 | def morning_routine():
242 | return f"15 seconds passed !"
243 | ```
244 |
245 | Schedule's cron can also be set to `manual` in which case it never runs, but can only be triggered manually from the admin :
246 | ```python
247 | from django_toosimple_q.decorators import register_task, schedule_task
248 |
249 | # A schedule that only runs when manually triggered
250 | @schedule_task(cron="manual")
251 | @register_task()
252 | def for_special_occasions():
253 | return f"this was triggered manually !"
254 | ```
255 |
256 | ### Management comment
257 |
258 | Besides standard django management commands arguments, the management command supports following arguments.
259 |
260 | ```
261 | usage: manage.py worker [--queue QUEUE | --exclude_queue EXCLUDE_QUEUE]
262 | [--tick TICK]
263 | [--once | --until_done]
264 | [--no_schedules]
265 | [--label LABEL]
266 | [--timeout TIMEOUT]
267 |
268 | optional arguments:
269 | --queue QUEUE which queue to run (can be used several times, all
270 | queues are run if not provided)
271 | --exclude_queue EXCLUDE_QUEUE
272 | which queue not to run (can be used several times, all
273 | queues are run if not provided)
274 | --tick TICK frequency in seconds at which the database is checked
275 | for new tasks/schedules
276 | --once run once then exit (useful for debugging)
277 | --until_done run until no tasks are available then exit (useful for
278 | debugging)
279 | --no_schedules do not process schedules
280 | --label LABEL the name of the worker to help identifying it ('{pid}'
281 | will be replaced by the process id)
282 | --timeout TIMEOUT the time in seconds after which this worker will be considered
283 | offline (set this to a value higher than the longest tasks this
284 | worker will execute)
285 | ```
286 |
287 | ## Contrib apps
288 |
289 | ### django_toosimple_q.contrib.mail
290 |
291 | A queued email backend to send emails asynchronously, preventing your website from failing completely in case the upstream backend is down.
292 |
293 | Enable and configure the app in `settings.py` :
294 | ```python
295 | INSTALLED_APPS = [
296 | # ...
297 | 'django_toosimple_q.contrib.mail',
298 | # ...
299 | ]
300 |
301 | EMAIL_BACKEND = 'django_toosimple_q.contrib.mail.backends.QueueBackend'
302 |
303 | # Actual Django email backend used, defaults to django.core.mail.backends.smtp.EmailBackend, see https://docs.djangoproject.com/en/3.2/ref/settings/#email-backend
304 | TOOSIMPLEQ_EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
305 | ```
306 |
307 | Head to the [Django documentation](https://docs.djangoproject.com/en/4.0/topics/email/) for usage.
308 |
309 | ## Dev
310 |
311 | ### Automated tests
312 |
313 | To run tests, we recommend using Docker :
314 | ```shell
315 | docker compose build
316 | # run all tests
317 | docker compose run django test
318 | # or to run just a specific test
319 | docker compose run django test django_toosimple_q.tests.tests_worker.TestWorker
320 | ```
321 |
322 | Tests are run automatically on github.
323 |
324 | ### Manual testing
325 |
326 | Manual testing can be done like this:
327 |
328 | ```shell
329 | # start a dev server and a worker
330 | docker compose build
331 | docker compose run django migrate
332 | docker compose run django createsuperuser
333 | docker compose up
334 | ```
335 |
336 | Then connect on 127.0.0.1:8000/admin/
337 |
338 | ### Without docker
339 |
340 | To run tests locally without Docker (by default, tests runs against an in-memory sqlite database):
341 |
342 | ```shell
343 | pip install -r requirements-dev.txt
344 | python manage.py test
345 | ```
346 |
347 | ### Contribute
348 |
349 | Code style is done with pre-commit :
350 | ```shell
351 | pip install -r requirements-dev.txt
352 | pre-commit install
353 | ```
354 |
355 | ## Internals
356 |
357 | ### Terms
358 |
359 | **Task**: a callable with a known name in the *registry*. These are typically registered in `tasks.py`.
360 |
361 | **TaskExecution**: a specific planned or past call of a *task*, including inputs (arguments) and outputs. This is a model, whose instanced are typically created using `mycallable.queue()` or from schedules.
362 |
363 | **Schedule**: a configuration for repeated execution of *tasks*. These are typically configured in `tasks.py`.
364 |
365 | **ScheduleExecution**: the last execution of a *schedule* (e.g. keeps track of the last time a schedule actually lead to generate a task execution). This is a model, whose instances are created by the worker.
366 |
367 | **Registry**: a dictionary keeping all registered schedules and tasks.
368 |
369 | **Worker**: a management command that executes schedules and tasks on a regular basis.
370 |
371 |
372 | ## Changelog
373 |
374 | - 2025-09-01 : v1.0.0 **⚠ BACKWARDS INCOMPATIBLE RELEASE ⚠**
375 | - feature: added workerstatus to the admin, allowing to monitor workers
376 | - feature: queue tasks for later (`mytask.queue(due=now()+timedelta(hours=2))`)
377 | - feature: assign queues to schedules (`@schedule_task(queue="schedules")`)
378 | - feature: allow manual schedules that are only run manually through the admin (`@schedule_task(cron="manual")`)
379 | - feature: custom permissions to force run schedules and to requeue tasks
380 | - feature: log admin actions
381 | - refactor: removed non-execution related data from the database (clarifying the fact tha the source of truth is the registry)
382 | - refactor: better support for concurrent workers
383 | - refactor: better names for models and decorators
384 | - refactor: optimise task exec admin listing when `results`, `stdout` or `stderr` holds large data
385 | - infra: included a demo project
386 | - infra: improved testing, including for concurrency behaviour
387 | - infra: updated compatibility to Django 4.2/5.1/5.2 and Python 3.8-3.13
388 | - quick migration guide:
389 | - rename `@schedule` -> `@schedule_task`
390 | - task name must now be provided as a kwarg: `@register_task("mytask")` -> `@register_task(name="mytask")`)
391 | - replace `@schedule_task(..., last_check=None)` -> `@schedule_task(..., run_on_creation=True)`
392 | - models: `Schedule` -> `ScheduleExec` and `Task` -> `TaskExec`
393 | - renamed `ScheduleExec.last_run` to `ScheduleExec.last_task`
394 |
395 | - 2022-03-24 : v0.4.0
396 | - made `last_check` and `last_run` optional in the admin
397 | - defined `id` fields
398 |
399 | - 2021-07-15 : v0.3.0
400 | - added `contrib.mail`
401 | - task replacement now tracked with a FK instead of a state
402 | - also run tests on postgres
403 | - added `datetime_kwarg` argument to schedules
404 |
405 | - 2021-06-11 : v0.2.0
406 | - added `retries`, `retry_delay` options for tasks
407 | - improve logging
408 |
409 | - 2020-11-12 : v0.1.0
410 | - fixed bug where updating schedule failed
411 | - fixed worker not doing all available tasks for each tick
412 | - added --tick argument
413 | - enforce uniqueness of schedule
414 |
--------------------------------------------------------------------------------
/django_toosimple_q/tests/tests_tasks.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.core import management
4 | from django.utils import timezone
5 | from freezegun import freeze_time
6 |
7 | from django_toosimple_q.decorators import register_task
8 | from django_toosimple_q.models import TaskExec
9 |
10 | from .base import TooSimpleQRegularTestCase
11 |
12 |
13 | class TestTasks(TooSimpleQRegularTestCase):
14 | def test_task_states(self):
15 | """Checking correctness of task states"""
16 |
17 | @register_task(name="a")
18 | def a(x):
19 | return x * 2
20 |
21 | @register_task(name="b")
22 | def b(x):
23 | return x / 0
24 |
25 | # Succeeding task
26 | t = a.queue(2)
27 | self.assertEqual(t.state, TaskExec.States.QUEUED)
28 | management.call_command("worker", "--once")
29 | t.refresh_from_db()
30 | self.assertEqual(t.state, TaskExec.States.SUCCEEDED)
31 | self.assertEqual(t.result, 4)
32 | self.assertEqual(t.error, None)
33 |
34 | # Failing task
35 | t = b.queue(2)
36 | self.assertEqual(t.state, TaskExec.States.QUEUED)
37 | management.call_command("worker", "--once")
38 | t.refresh_from_db()
39 | self.assertEqual(t.state, TaskExec.States.FAILED)
40 | self.assertEqual(t.result, None)
41 | self.assertNotEqual(t.error, None)
42 |
43 | # Task with due date
44 | t = a.queue(2, due=timezone.now() - datetime.timedelta(hours=1))
45 | self.assertEqual(t.state, TaskExec.States.SLEEPING)
46 | management.call_command("worker", "--once")
47 | t.refresh_from_db()
48 | self.assertEqual(t.state, TaskExec.States.SUCCEEDED)
49 | self.assertEqual(t.result, 4)
50 | self.assertEqual(t.error, None)
51 |
52 | # Invalid task
53 | t = TaskExec.objects.create(task_name="invalid")
54 | self.assertEqual(t.state, TaskExec.States.QUEUED)
55 | management.call_command("worker", "--once")
56 | t.refresh_from_db()
57 | self.assertEqual(t.state, TaskExec.States.INVALID)
58 | self.assertEqual(t.result, None)
59 | self.assertEqual(t.error, None)
60 |
61 | def test_task_registration(self):
62 | """Checking task registration"""
63 |
64 | # We cannot run arbitrary functions
65 | self.assertQueue(0)
66 | t = TaskExec.objects.create(task_name="print", args=["test"])
67 | self.assertQueue(1, state=TaskExec.States.QUEUED)
68 | management.call_command("worker", "--once")
69 | self.assertQueue(1, state=TaskExec.States.INVALID)
70 |
71 | # We can run registered functions
72 | @register_task(name="a")
73 | def a(x):
74 | pass
75 |
76 | TaskExec.objects.create(task_name="a", args=["test"])
77 | self.assertQueue(1, state=TaskExec.States.QUEUED)
78 | management.call_command("worker", "--once")
79 | self.assertQueue(1, state=TaskExec.States.SUCCEEDED)
80 |
81 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
82 | def test_task_queuing(self, frozen_datetime):
83 | """Checking task queuing"""
84 |
85 | @register_task(name="a")
86 | def a(x):
87 | return x * 2
88 |
89 | t1 = a.queue(1)
90 | t2 = a.queue(2)
91 | t3 = a.queue(3, due=timezone.make_aware(datetime.datetime(2020, 1, 1, 2)))
92 | t4 = a.queue(4, due=timezone.make_aware(datetime.datetime(2020, 1, 1, 1)))
93 | self.assertEqual(t1.state, TaskExec.States.QUEUED)
94 | self.assertEqual(t2.state, TaskExec.States.QUEUED)
95 | self.assertEqual(t3.state, TaskExec.States.SLEEPING)
96 | self.assertEqual(t4.state, TaskExec.States.SLEEPING)
97 | self.assertQueue(4)
98 |
99 | # Run a due task
100 | management.call_command("worker", "--once")
101 | t1.refresh_from_db()
102 | t2.refresh_from_db()
103 | t3.refresh_from_db()
104 | t4.refresh_from_db()
105 | self.assertEqual(t1.state, TaskExec.States.SUCCEEDED)
106 | self.assertEqual(t2.state, TaskExec.States.QUEUED)
107 | self.assertEqual(t3.state, TaskExec.States.SLEEPING)
108 | self.assertEqual(t4.state, TaskExec.States.SLEEPING)
109 |
110 | # Run a due task
111 | management.call_command("worker", "--once")
112 | t1.refresh_from_db()
113 | t2.refresh_from_db()
114 | t3.refresh_from_db()
115 | t4.refresh_from_db()
116 | self.assertEqual(t1.state, TaskExec.States.SUCCEEDED)
117 | self.assertEqual(t2.state, TaskExec.States.SUCCEEDED)
118 | self.assertEqual(t3.state, TaskExec.States.SLEEPING)
119 | self.assertEqual(t4.state, TaskExec.States.SLEEPING)
120 |
121 | # All currently due tasks have been run, nothing happens
122 | management.call_command("worker", "--once")
123 | t1.refresh_from_db()
124 | t2.refresh_from_db()
125 | t3.refresh_from_db()
126 | t4.refresh_from_db()
127 | self.assertEqual(t1.state, TaskExec.States.SUCCEEDED)
128 | self.assertEqual(t2.state, TaskExec.States.SUCCEEDED)
129 | self.assertEqual(t3.state, TaskExec.States.SLEEPING)
130 | self.assertEqual(t4.state, TaskExec.States.SLEEPING)
131 |
132 | # We move to the future, due tasks are now queued, and the first due one is run
133 | frozen_datetime.move_to(datetime.datetime(2020, 1, 1, 5))
134 | management.call_command("worker", "--once")
135 | t1.refresh_from_db()
136 | t2.refresh_from_db()
137 | t3.refresh_from_db()
138 | t4.refresh_from_db()
139 | self.assertEqual(t1.state, TaskExec.States.SUCCEEDED)
140 | self.assertEqual(t2.state, TaskExec.States.SUCCEEDED)
141 | self.assertEqual(t3.state, TaskExec.States.QUEUED)
142 | self.assertEqual(t4.state, TaskExec.States.SUCCEEDED)
143 |
144 | # Now the last one is run too
145 | management.call_command("worker", "--once")
146 | t1.refresh_from_db()
147 | t2.refresh_from_db()
148 | t3.refresh_from_db()
149 | t4.refresh_from_db()
150 | self.assertEqual(t1.state, TaskExec.States.SUCCEEDED)
151 | self.assertEqual(t2.state, TaskExec.States.SUCCEEDED)
152 | self.assertEqual(t3.state, TaskExec.States.SUCCEEDED)
153 | self.assertEqual(t4.state, TaskExec.States.SUCCEEDED)
154 |
155 | def test_task_queuing_with_priorities(self):
156 | """Checking task queuing with priorities"""
157 |
158 | @register_task(name="p2", priority=2)
159 | def p2(x):
160 | return x * 2
161 |
162 | @register_task(name="p1a", priority=1)
163 | def p1a(x):
164 | return x * 2
165 |
166 | @register_task(name="p1b", priority=1)
167 | def p1b(x):
168 | return x * 2
169 |
170 | p1a.queue(1)
171 | p1b.queue(1)
172 | p2.queue(1)
173 |
174 | self.assertQueue(1, task_name="p2", state=TaskExec.States.QUEUED)
175 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.QUEUED)
176 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.QUEUED)
177 | self.assertQueue(0, task_name="p2", state=TaskExec.States.SUCCEEDED)
178 | self.assertQueue(0, task_name="p1a", state=TaskExec.States.SUCCEEDED)
179 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.SUCCEEDED)
180 | self.assertQueue(3)
181 |
182 | management.call_command("worker", "--once")
183 |
184 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
185 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.QUEUED)
186 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.QUEUED)
187 | self.assertQueue(1, task_name="p2", state=TaskExec.States.SUCCEEDED)
188 | self.assertQueue(0, task_name="p1a", state=TaskExec.States.SUCCEEDED)
189 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.SUCCEEDED)
190 | self.assertQueue(3)
191 |
192 | management.call_command("worker", "--once")
193 |
194 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
195 | self.assertQueue(0, task_name="p1a", state=TaskExec.States.QUEUED)
196 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.QUEUED)
197 | self.assertQueue(1, task_name="p2", state=TaskExec.States.SUCCEEDED)
198 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.SUCCEEDED)
199 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.SUCCEEDED)
200 | self.assertQueue(3)
201 |
202 | management.call_command("worker", "--once")
203 |
204 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
205 | self.assertQueue(0, task_name="p1a", state=TaskExec.States.QUEUED)
206 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.QUEUED)
207 | self.assertQueue(1, task_name="p2", state=TaskExec.States.SUCCEEDED)
208 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.SUCCEEDED)
209 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.SUCCEEDED)
210 | self.assertQueue(3)
211 |
212 | p2.queue(1)
213 | p1b.queue(1)
214 | p1a.queue(1)
215 |
216 | self.assertQueue(1, task_name="p2", state=TaskExec.States.QUEUED)
217 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.QUEUED)
218 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.QUEUED)
219 | self.assertQueue(1, task_name="p2", state=TaskExec.States.SUCCEEDED)
220 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.SUCCEEDED)
221 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.SUCCEEDED)
222 | self.assertQueue(6)
223 |
224 | management.call_command("worker", "--once")
225 |
226 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
227 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.QUEUED)
228 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.QUEUED)
229 | self.assertQueue(2, task_name="p2", state=TaskExec.States.SUCCEEDED)
230 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.SUCCEEDED)
231 | self.assertQueue(1, task_name="p1b", state=TaskExec.States.SUCCEEDED)
232 | self.assertQueue(6)
233 |
234 | management.call_command("worker", "--once")
235 |
236 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
237 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.QUEUED)
238 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.QUEUED)
239 | self.assertQueue(2, task_name="p2", state=TaskExec.States.SUCCEEDED)
240 | self.assertQueue(1, task_name="p1a", state=TaskExec.States.SUCCEEDED)
241 | self.assertQueue(2, task_name="p1b", state=TaskExec.States.SUCCEEDED)
242 | self.assertQueue(6)
243 |
244 | management.call_command("worker", "--once")
245 |
246 | self.assertQueue(0, task_name="p2", state=TaskExec.States.QUEUED)
247 | self.assertQueue(0, task_name="p1a", state=TaskExec.States.QUEUED)
248 | self.assertQueue(0, task_name="p1b", state=TaskExec.States.QUEUED)
249 | self.assertQueue(2, task_name="p2", state=TaskExec.States.SUCCEEDED)
250 | self.assertQueue(2, task_name="p1a", state=TaskExec.States.SUCCEEDED)
251 | self.assertQueue(2, task_name="p1b", state=TaskExec.States.SUCCEEDED)
252 | self.assertQueue(6)
253 |
254 | def test_task_queuing_with_unique(self):
255 | """Checking task queuing with unique"""
256 |
257 | @register_task(name="normal")
258 | def normal(x):
259 | return x * 2
260 |
261 | @register_task(name="unique", unique=True)
262 | def unique(x):
263 | return x * 2
264 |
265 | self.assertQueue(0)
266 |
267 | normal.queue(1)
268 | normal.queue(1)
269 | unique.queue(1)
270 | unique.queue(1)
271 |
272 | self.assertQueue(1, task_name="unique", state=TaskExec.States.QUEUED)
273 | self.assertQueue(2, task_name="normal", state=TaskExec.States.QUEUED)
274 | self.assertQueue(0, task_name="unique", state=TaskExec.States.SUCCEEDED)
275 | self.assertQueue(0, task_name="normal", state=TaskExec.States.SUCCEEDED)
276 | self.assertQueue(3)
277 |
278 | management.call_command("worker", "--until_done")
279 |
280 | self.assertQueue(0, task_name="unique", state=TaskExec.States.QUEUED)
281 | self.assertQueue(0, task_name="normal", state=TaskExec.States.QUEUED)
282 | self.assertQueue(1, task_name="unique", state=TaskExec.States.SUCCEEDED)
283 | self.assertQueue(2, task_name="normal", state=TaskExec.States.SUCCEEDED)
284 | self.assertQueue(3)
285 |
286 | normal.queue(1)
287 | normal.queue(1)
288 | unique.queue(1)
289 | unique.queue(1)
290 |
291 | self.assertQueue(1, task_name="unique", state=TaskExec.States.QUEUED)
292 | self.assertQueue(2, task_name="normal", state=TaskExec.States.QUEUED)
293 | self.assertQueue(1, task_name="unique", state=TaskExec.States.SUCCEEDED)
294 | self.assertQueue(2, task_name="normal", state=TaskExec.States.SUCCEEDED)
295 | self.assertQueue(6)
296 |
297 | def test_task_retries(self):
298 | """Checking task retries"""
299 |
300 | @register_task(name="div_zero", retries=10)
301 | def div_zero(x):
302 | return x / 0
303 |
304 | self.assertQueue(0)
305 |
306 | div_zero.queue(1)
307 |
308 | management.call_command("worker", "--until_done")
309 |
310 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.QUEUED)
311 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.SLEEPING)
312 | self.assertQueue(
313 | 1, task_name="div_zero", state=TaskExec.States.FAILED, replaced=False
314 | )
315 | self.assertQueue(
316 | 10, task_name="div_zero", state=TaskExec.States.FAILED, replaced=True
317 | )
318 | self.assertQueue(11)
319 |
320 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
321 | def test_task_retries_delay(self, frozen_datetime):
322 | """Checking task retries with delay"""
323 |
324 | @register_task(name="div_zero", retries=3, retry_delay=60)
325 | def div_zero(x):
326 | return x / 0
327 |
328 | self.assertQueue(0)
329 |
330 | # Create the task
331 | div_zero.queue(1)
332 | self.assertQueue(
333 | 1,
334 | task_name="div_zero",
335 | state=TaskExec.States.QUEUED,
336 | replaced=False,
337 | due=datetime.datetime(2020, 1, 1, 0, 0),
338 | )
339 | self.assertQueue(1)
340 |
341 | # the task failed, it should be replaced with a task due in the future (+1 min)
342 | management.call_command("worker", "--until_done")
343 | self.assertQueue(
344 | 1,
345 | task_name="div_zero",
346 | state=TaskExec.States.FAILED,
347 | replaced=True,
348 | due=datetime.datetime(2020, 1, 1, 0, 0),
349 | )
350 | self.assertQueue(
351 | 1,
352 | task_name="div_zero",
353 | state=TaskExec.States.SLEEPING,
354 | replaced=False,
355 | due=datetime.datetime(2020, 1, 1, 0, 1),
356 | )
357 | self.assertQueue(2)
358 |
359 | # if we don't wait, no further task will be processed
360 | management.call_command("worker", "--until_done")
361 | self.assertQueue(
362 | 1,
363 | task_name="div_zero",
364 | state=TaskExec.States.FAILED,
365 | replaced=True,
366 | due=datetime.datetime(2020, 1, 1, 0, 0),
367 | )
368 | self.assertQueue(
369 | 1,
370 | task_name="div_zero",
371 | state=TaskExec.States.SLEEPING,
372 | replaced=False,
373 | due=datetime.datetime(2020, 1, 1, 0, 1),
374 | )
375 | self.assertQueue(2)
376 |
377 | # if we wait, one retry will be done ( +1 + 2*+1 = +3min)
378 | frozen_datetime.move_to(datetime.datetime(2020, 1, 1, 0, 1))
379 | management.call_command("worker", "--until_done")
380 | self.assertQueue(
381 | 1,
382 | task_name="div_zero",
383 | state=TaskExec.States.FAILED,
384 | replaced=True,
385 | due=datetime.datetime(2020, 1, 1, 0, 0),
386 | )
387 | self.assertQueue(
388 | 1,
389 | task_name="div_zero",
390 | state=TaskExec.States.FAILED,
391 | replaced=True,
392 | due=datetime.datetime(2020, 1, 1, 0, 1),
393 | )
394 | self.assertQueue(
395 | 1,
396 | task_name="div_zero",
397 | state=TaskExec.States.SLEEPING,
398 | replaced=False,
399 | due=datetime.datetime(2020, 1, 1, 0, 3),
400 | )
401 | self.assertQueue(3)
402 |
403 | # if we wait more, delay continues to increase ( +1 + 2*+1 + 2*2*+1 = +7min)
404 | frozen_datetime.move_to(datetime.datetime(2020, 1, 1, 0, 3))
405 | management.call_command("worker", "--until_done")
406 | self.assertQueue(
407 | 1,
408 | task_name="div_zero",
409 | state=TaskExec.States.FAILED,
410 | replaced=True,
411 | due=datetime.datetime(2020, 1, 1, 0, 0),
412 | )
413 | self.assertQueue(
414 | 1,
415 | task_name="div_zero",
416 | state=TaskExec.States.FAILED,
417 | replaced=True,
418 | due=datetime.datetime(2020, 1, 1, 0, 1),
419 | )
420 | self.assertQueue(
421 | 1,
422 | task_name="div_zero",
423 | state=TaskExec.States.FAILED,
424 | replaced=True,
425 | due=datetime.datetime(2020, 1, 1, 0, 3),
426 | )
427 | self.assertQueue(
428 | 1,
429 | task_name="div_zero",
430 | state=TaskExec.States.SLEEPING,
431 | replaced=False,
432 | due=datetime.datetime(2020, 1, 1, 0, 7),
433 | )
434 | self.assertQueue(4)
435 |
436 | # if we wait more, last task runs, but we're out of retries, so no new task
437 | frozen_datetime.move_to(datetime.datetime(2020, 1, 1, 0, 7))
438 | management.call_command("worker", "--until_done")
439 | self.assertQueue(
440 | 1,
441 | task_name="div_zero",
442 | state=TaskExec.States.FAILED,
443 | replaced=True,
444 | due=datetime.datetime(2020, 1, 1, 0, 0),
445 | )
446 | self.assertQueue(
447 | 1,
448 | task_name="div_zero",
449 | state=TaskExec.States.FAILED,
450 | replaced=True,
451 | due=datetime.datetime(2020, 1, 1, 0, 1),
452 | )
453 | self.assertQueue(
454 | 1,
455 | task_name="div_zero",
456 | state=TaskExec.States.FAILED,
457 | replaced=True,
458 | due=datetime.datetime(2020, 1, 1, 0, 3),
459 | )
460 | self.assertQueue(
461 | 1,
462 | task_name="div_zero",
463 | state=TaskExec.States.FAILED,
464 | replaced=False,
465 | due=datetime.datetime(2020, 1, 1, 0, 7),
466 | )
467 | self.assertQueue(4)
468 |
469 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
470 | def test_task_retries_delay_unique(self, frozen_datetime):
471 | """Checking unique task retries with delay"""
472 |
473 | @register_task(name="div_zero", retries=10, retry_delay=10, unique=True)
474 | def div_zero(x):
475 | return x / 0
476 |
477 | self.assertQueue(0)
478 |
479 | div_zero.queue(1)
480 |
481 | self.assertQueue(1)
482 |
483 | management.call_command("worker", "--until_done")
484 |
485 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.QUEUED)
486 | self.assertQueue(
487 | 1, task_name="div_zero", state=TaskExec.States.FAILED, replaced=True
488 | )
489 | self.assertQueue(1, task_name="div_zero", state=TaskExec.States.SLEEPING)
490 | self.assertQueue(2)
491 | self.assertEqual(TaskExec.objects.last().retries, 9)
492 | self.assertEqual(TaskExec.objects.last().retry_delay, 20)
493 |
494 | # if we don't wait, no further task will be processed
495 | management.call_command("worker", "--until_done")
496 |
497 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.QUEUED)
498 | self.assertQueue(
499 | 1, task_name="div_zero", state=TaskExec.States.FAILED, replaced=True
500 | )
501 | self.assertQueue(1, task_name="div_zero", state=TaskExec.States.SLEEPING)
502 | self.assertQueue(2)
503 | self.assertEqual(TaskExec.objects.last().retries, 9)
504 | self.assertEqual(TaskExec.objects.last().retry_delay, 20)
505 |
506 | # if we requeue the task, it will be run immediatly
507 | div_zero.queue(1)
508 | self.assertQueue(2)
509 |
510 | self.assertQueue(1, task_name="div_zero", state=TaskExec.States.QUEUED)
511 | self.assertQueue(
512 | 1, task_name="div_zero", state=TaskExec.States.FAILED, replaced=True
513 | )
514 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.SLEEPING)
515 | self.assertQueue(2)
516 | self.assertEqual(TaskExec.objects.last().retries, 9)
517 | self.assertEqual(TaskExec.objects.last().retry_delay, 20)
518 |
519 | management.call_command("worker", "--until_done")
520 |
521 | self.assertQueue(0, task_name="div_zero", state=TaskExec.States.QUEUED)
522 | self.assertQueue(
523 | 2, task_name="div_zero", state=TaskExec.States.FAILED, replaced=True
524 | )
525 | self.assertQueue(1, task_name="div_zero", state=TaskExec.States.SLEEPING)
526 | self.assertQueue(3)
527 | self.assertEqual(TaskExec.objects.last().retries, 8)
528 | self.assertEqual(TaskExec.objects.last().retry_delay, 40)
529 |
530 | @freeze_time("2020-01-01", as_kwarg="frozen_datetime")
531 | def test_task_due_date(self, frozen_datetime):
532 | """Checking unique task retries with delay"""
533 |
534 | @register_task(name="my_task", unique=True)
535 | def my_task(x):
536 | return x * 2
537 |
538 | def getDues():
539 | return [
540 | (t.state, f"{t.due:%Y-%m-%d %H:%M}")
541 | for t in TaskExec.objects.order_by("due")
542 | ]
543 |
544 | # Normal queue is due right now
545 | my_task.queue(1)
546 | self.assertEqual(
547 | getDues(),
548 | [
549 | (TaskExec.States.QUEUED.value, "2020-01-01 00:00"),
550 | ],
551 | )
552 | management.call_command("worker", "--until_done")
553 | self.assertEqual(
554 | getDues(),
555 | [
556 | (TaskExec.States.SUCCEEDED.value, "2020-01-01 00:00"),
557 | ],
558 | )
559 |
560 | # Delayed queue is due in the future
561 | my_task.queue(1, due=timezone.now() + datetime.timedelta(hours=3))
562 | self.assertEqual(
563 | getDues(),
564 | [
565 | (TaskExec.States.SUCCEEDED.value, "2020-01-01 00:00"),
566 | (TaskExec.States.SLEEPING.value, "2020-01-01 03:00"),
567 | ],
568 | )
569 |
570 | # Delayed closer reduces the due date
571 | my_task.queue(1, due=timezone.now() + datetime.timedelta(hours=2))
572 | self.assertEqual(
573 | getDues(),
574 | [
575 | (TaskExec.States.SUCCEEDED.value, "2020-01-01 00:00"),
576 | (TaskExec.States.SLEEPING.value, "2020-01-01 02:00"),
577 | ],
578 | )
579 |
580 | def test_named_queues(self):
581 | """Checking named queues"""
582 |
583 | @register_task(name="a") # queue="default"
584 | def a(x):
585 | return x * 2
586 |
587 | @register_task(name="b", queue="queue_b")
588 | def b(x):
589 | return x * 2
590 |
591 | @register_task(name="c", queue="queue_c")
592 | def c(x):
593 | return x * 2
594 |
595 | @register_task(name="d", queue="queue_d")
596 | def d(x):
597 | return x * 2
598 |
599 | @register_task(name="e", queue="queue_e")
600 | def e(x):
601 | return x * 2
602 |
603 | @register_task(name="f", queue="queue_f")
604 | def f(x):
605 | return x * 2
606 |
607 | @register_task(name="g", queue="queue_g")
608 | def g(x):
609 | return x * 2
610 |
611 | @register_task(name="h", queue="queue_h")
612 | def h(x):
613 | return x * 2
614 |
615 | task_a = a.queue(1)
616 | task_b = b.queue(1)
617 | task_c = c.queue(1)
618 | task_d = d.queue(1)
619 | task_e = e.queue(1)
620 | task_f = f.queue(1)
621 | task_g = g.queue(1)
622 | task_h = h.queue(1)
623 |
624 | self.assertTask(task_a, TaskExec.States.QUEUED)
625 | self.assertTask(task_b, TaskExec.States.QUEUED)
626 | self.assertTask(task_c, TaskExec.States.QUEUED)
627 | self.assertTask(task_d, TaskExec.States.QUEUED)
628 | self.assertTask(task_e, TaskExec.States.QUEUED)
629 | self.assertTask(task_f, TaskExec.States.QUEUED)
630 | self.assertTask(task_g, TaskExec.States.QUEUED)
631 | self.assertTask(task_h, TaskExec.States.QUEUED)
632 |
633 | # make sure tasks get assigned to default queue by default
634 | management.call_command("worker", "--until_done", "--queue", "default")
635 |
636 | self.assertTask(task_a, TaskExec.States.SUCCEEDED)
637 | self.assertTask(task_b, TaskExec.States.QUEUED)
638 | self.assertTask(task_c, TaskExec.States.QUEUED)
639 | self.assertTask(task_d, TaskExec.States.QUEUED)
640 | self.assertTask(task_e, TaskExec.States.QUEUED)
641 | self.assertTask(task_f, TaskExec.States.QUEUED)
642 | self.assertTask(task_g, TaskExec.States.QUEUED)
643 | self.assertTask(task_h, TaskExec.States.QUEUED)
644 |
645 | # make sure worker only runs their queue
646 | management.call_command("worker", "--until_done", "--queue", "queue_c")
647 |
648 | self.assertTask(task_a, TaskExec.States.SUCCEEDED)
649 | self.assertTask(task_b, TaskExec.States.QUEUED)
650 | self.assertTask(task_c, TaskExec.States.SUCCEEDED)
651 | self.assertTask(task_d, TaskExec.States.QUEUED)
652 | self.assertTask(task_e, TaskExec.States.QUEUED)
653 | self.assertTask(task_f, TaskExec.States.QUEUED)
654 | self.assertTask(task_g, TaskExec.States.QUEUED)
655 | self.assertTask(task_h, TaskExec.States.QUEUED)
656 |
657 | # make sure worker can run multiple queues
658 | management.call_command(
659 | "worker", "--until_done", "--queue", "queue_b", "--queue", "queue_d"
660 | )
661 |
662 | self.assertTask(task_a, TaskExec.States.SUCCEEDED)
663 | self.assertTask(task_b, TaskExec.States.SUCCEEDED)
664 | self.assertTask(task_c, TaskExec.States.SUCCEEDED)
665 | self.assertTask(task_d, TaskExec.States.SUCCEEDED)
666 | self.assertTask(task_e, TaskExec.States.QUEUED)
667 | self.assertTask(task_f, TaskExec.States.QUEUED)
668 | self.assertTask(task_g, TaskExec.States.QUEUED)
669 | self.assertTask(task_h, TaskExec.States.QUEUED)
670 |
671 | # make sure worker exclude queue works
672 | management.call_command(
673 | "worker",
674 | "--until_done",
675 | "--exclude_queue",
676 | "queue_g",
677 | "--exclude_queue",
678 | "queue_h",
679 | )
680 |
681 | self.assertTask(task_a, TaskExec.States.SUCCEEDED)
682 | self.assertTask(task_b, TaskExec.States.SUCCEEDED)
683 | self.assertTask(task_c, TaskExec.States.SUCCEEDED)
684 | self.assertTask(task_d, TaskExec.States.SUCCEEDED)
685 | self.assertTask(task_e, TaskExec.States.SUCCEEDED)
686 | self.assertTask(task_f, TaskExec.States.SUCCEEDED)
687 | self.assertTask(task_g, TaskExec.States.QUEUED)
688 | self.assertTask(task_h, TaskExec.States.QUEUED)
689 |
690 | # make sure worker run all queues by default
691 | management.call_command("worker", "--until_done")
692 |
693 | self.assertTask(task_a, TaskExec.States.SUCCEEDED)
694 | self.assertTask(task_b, TaskExec.States.SUCCEEDED)
695 | self.assertTask(task_c, TaskExec.States.SUCCEEDED)
696 | self.assertTask(task_d, TaskExec.States.SUCCEEDED)
697 | self.assertTask(task_e, TaskExec.States.SUCCEEDED)
698 | self.assertTask(task_f, TaskExec.States.SUCCEEDED)
699 | self.assertTask(task_g, TaskExec.States.SUCCEEDED)
700 | self.assertTask(task_h, TaskExec.States.SUCCEEDED)
701 |
--------------------------------------------------------------------------------