django-spec-tasks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_spec_tasks/__init__.py +60 -0
- django_spec_tasks/__init__.pyi +33 -0
- django_spec_tasks/admin.py +65 -0
- django_spec_tasks/apps.py +35 -0
- django_spec_tasks/backends/__init__.py +1 -0
- django_spec_tasks/backends/celery_backend.py +41 -0
- django_spec_tasks/backends/django_tasks_backend.py +45 -0
- django_spec_tasks/binding/__init__.py +1 -0
- django_spec_tasks/binding/task_binding.py +93 -0
- django_spec_tasks/caller/__init__.py +1 -0
- django_spec_tasks/caller/enqueue.py +135 -0
- django_spec_tasks/checks.py +39 -0
- django_spec_tasks/management/__init__.py +1 -0
- django_spec_tasks/management/commands/__init__.py +1 -0
- django_spec_tasks/management/commands/prune_spec_runs.py +26 -0
- django_spec_tasks/management/commands/sweep_spec_runs.py +30 -0
- django_spec_tasks/migrations/0001_initial.py +122 -0
- django_spec_tasks/migrations/__init__.py +0 -0
- django_spec_tasks/models/__init__.py +3 -0
- django_spec_tasks/models/spec_run.py +64 -0
- django_spec_tasks/models/storable_json.py +33 -0
- django_spec_tasks/py.typed +0 -0
- django_spec_tasks/read/__init__.py +1 -0
- django_spec_tasks/read/read_run.py +54 -0
- django_spec_tasks/read/state_of.py +16 -0
- django_spec_tasks/read/utils.py +23 -0
- django_spec_tasks/sweep/__init__.py +1 -0
- django_spec_tasks/sweep/prune_runs.py +31 -0
- django_spec_tasks/sweep/sweep_runs.py +189 -0
- django_spec_tasks/types/__init__.py +1 -0
- django_spec_tasks/types/run_state.py +28 -0
- django_spec_tasks/types/run_status.py +28 -0
- django_spec_tasks/types/sweep_report.py +25 -0
- django_spec_tasks/types/task_backend.py +17 -0
- django_spec_tasks/types/terminal_states.py +6 -0
- django_spec_tasks/version.py +7 -0
- django_spec_tasks/worker/__init__.py +1 -0
- django_spec_tasks/worker/row_progress.py +67 -0
- django_spec_tasks/worker/run_spec.py +298 -0
- django_spec_tasks/worker/run_state_for.py +42 -0
- django_spec_tasks-0.1.0.dist-info/METADATA +73 -0
- django_spec_tasks-0.1.0.dist-info/RECORD +44 -0
- django_spec_tasks-0.1.0.dist-info/WHEEL +4 -0
- django_spec_tasks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Run a django-service-specs spec as a background task."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
from django_spec_tasks.binding.task_binding import TaskBinding
|
|
6
|
+
from django_spec_tasks.types.run_state import RunState
|
|
7
|
+
from django_spec_tasks.types.run_status import RunStatus
|
|
8
|
+
from django_spec_tasks.types.sweep_report import SweepReport
|
|
9
|
+
from django_spec_tasks.types.task_backend import TaskBackend
|
|
10
|
+
from django_spec_tasks.types.terminal_states import TERMINAL_STATES
|
|
11
|
+
from django_spec_tasks.version import __version__
|
|
12
|
+
from django_spec_tasks.worker.run_state_for import run_state_for
|
|
13
|
+
|
|
14
|
+
# Served on first attribute access rather than imported here. Each of these reaches
|
|
15
|
+
# the model layer when its module is imported, and Django imports this package while
|
|
16
|
+
# it is still loading apps, before a model may be imported, so a plain import of one
|
|
17
|
+
# here makes the package unimportable with AppRegistryNotReady. A consumer sees the
|
|
18
|
+
# same names, and their static types come from __init__.pyi.
|
|
19
|
+
_LAZY_EXPORTS: dict[str, str] = {
|
|
20
|
+
"SpecRun": "django_spec_tasks.models.spec_run",
|
|
21
|
+
"enqueue": "django_spec_tasks.caller.enqueue",
|
|
22
|
+
"prune_runs": "django_spec_tasks.sweep.prune_runs",
|
|
23
|
+
"read_run": "django_spec_tasks.read.read_run",
|
|
24
|
+
"run_spec": "django_spec_tasks.worker.run_spec",
|
|
25
|
+
"state_of": "django_spec_tasks.read.state_of",
|
|
26
|
+
"sweep_runs": "django_spec_tasks.sweep.sweep_runs",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"TERMINAL_STATES",
|
|
31
|
+
"RunState",
|
|
32
|
+
"RunStatus",
|
|
33
|
+
"SpecRun",
|
|
34
|
+
"SweepReport",
|
|
35
|
+
"TaskBackend",
|
|
36
|
+
"TaskBinding",
|
|
37
|
+
"__version__",
|
|
38
|
+
"enqueue",
|
|
39
|
+
"prune_runs",
|
|
40
|
+
"read_run",
|
|
41
|
+
"run_spec",
|
|
42
|
+
"run_state_for",
|
|
43
|
+
"state_of",
|
|
44
|
+
"sweep_runs",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __getattr__(name: str) -> object:
|
|
49
|
+
"""Resolve a lazily exported name from its leaf module, and cache the result."""
|
|
50
|
+
module_path = _LAZY_EXPORTS.get(name)
|
|
51
|
+
if module_path is None:
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
53
|
+
value = getattr(importlib.import_module(module_path), name)
|
|
54
|
+
globals()[name] = value
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __dir__() -> list[str]:
|
|
59
|
+
"""List every public name, lazy ones included, so tab completion sees them."""
|
|
60
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django_spec_tasks.binding.task_binding import TaskBinding as TaskBinding
|
|
2
|
+
from django_spec_tasks.caller.enqueue import enqueue as enqueue
|
|
3
|
+
from django_spec_tasks.models.spec_run import SpecRun as SpecRun
|
|
4
|
+
from django_spec_tasks.read.read_run import read_run as read_run
|
|
5
|
+
from django_spec_tasks.read.state_of import state_of as state_of
|
|
6
|
+
from django_spec_tasks.sweep.prune_runs import prune_runs as prune_runs
|
|
7
|
+
from django_spec_tasks.sweep.sweep_runs import sweep_runs as sweep_runs
|
|
8
|
+
from django_spec_tasks.types.run_state import RunState as RunState
|
|
9
|
+
from django_spec_tasks.types.run_status import RunStatus as RunStatus
|
|
10
|
+
from django_spec_tasks.types.sweep_report import SweepReport as SweepReport
|
|
11
|
+
from django_spec_tasks.types.task_backend import TaskBackend as TaskBackend
|
|
12
|
+
from django_spec_tasks.types.terminal_states import TERMINAL_STATES as TERMINAL_STATES
|
|
13
|
+
from django_spec_tasks.version import __version__ as __version__
|
|
14
|
+
from django_spec_tasks.worker.run_spec import run_spec as run_spec
|
|
15
|
+
from django_spec_tasks.worker.run_state_for import run_state_for as run_state_for
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"TERMINAL_STATES",
|
|
19
|
+
"RunState",
|
|
20
|
+
"RunStatus",
|
|
21
|
+
"SpecRun",
|
|
22
|
+
"SweepReport",
|
|
23
|
+
"TaskBackend",
|
|
24
|
+
"TaskBinding",
|
|
25
|
+
"__version__",
|
|
26
|
+
"enqueue",
|
|
27
|
+
"prune_runs",
|
|
28
|
+
"read_run",
|
|
29
|
+
"run_spec",
|
|
30
|
+
"run_state_for",
|
|
31
|
+
"state_of",
|
|
32
|
+
"sweep_runs",
|
|
33
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.db.models.constants import LOOKUP_SEP
|
|
7
|
+
from django.http import HttpRequest
|
|
8
|
+
|
|
9
|
+
from django_spec_tasks.models.spec_run import SpecRun
|
|
10
|
+
|
|
11
|
+
_PAYLOADS = frozenset({"params", "carried", "output"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@admin.register(SpecRun)
|
|
15
|
+
class SpecRunAdmin(admin.ModelAdmin):
|
|
16
|
+
"""Runs, to look at and never to change.
|
|
17
|
+
|
|
18
|
+
Nothing here adds, changes or deletes a row, and there are no actions: an admin action
|
|
19
|
+
skips the change permission, so an action that deleted would delete for a user who
|
|
20
|
+
could not. ``params``, ``carried`` and ``output`` hold what the call was given and what
|
|
21
|
+
it returned, so they are hidden, and refused as URL lookups, since a count of results
|
|
22
|
+
would answer a guess; a project that wants them in its admin sets ``show_payloads`` on a
|
|
23
|
+
subclass. The ``traceback`` shows, because triage needs it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
show_payloads = False
|
|
27
|
+
|
|
28
|
+
list_display = (
|
|
29
|
+
"id",
|
|
30
|
+
"state",
|
|
31
|
+
"spec_name",
|
|
32
|
+
"binding",
|
|
33
|
+
"actor_pk",
|
|
34
|
+
"attempts",
|
|
35
|
+
"created_at",
|
|
36
|
+
"claimed_at",
|
|
37
|
+
"finished_at",
|
|
38
|
+
)
|
|
39
|
+
list_filter = ("state", "binding")
|
|
40
|
+
search_fields = ("spec_name", "=actor_pk")
|
|
41
|
+
ordering = ("-created_at",)
|
|
42
|
+
show_full_result_count = False
|
|
43
|
+
actions = None
|
|
44
|
+
|
|
45
|
+
def get_fields(self, request: HttpRequest, obj: Any = None) -> list[Any]:
|
|
46
|
+
names = [field.name for field in SpecRun._meta.concrete_fields]
|
|
47
|
+
return [name for name in names if self.show_payloads or name not in _PAYLOADS]
|
|
48
|
+
|
|
49
|
+
def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> list[str]:
|
|
50
|
+
return self.get_fields(request, obj)
|
|
51
|
+
|
|
52
|
+
def lookup_allowed(self, lookup: str, *args: Any, **kwargs: Any) -> bool:
|
|
53
|
+
# The rest of the signature differs across the Django versions this package supports.
|
|
54
|
+
if not self.show_payloads and lookup.split(LOOKUP_SEP)[0] in _PAYLOADS:
|
|
55
|
+
return False
|
|
56
|
+
return super().lookup_allowed(lookup, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def has_delete_permission(self, request: HttpRequest, obj: Any = None) -> bool:
|
|
65
|
+
return False
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from importlib.util import find_spec
|
|
5
|
+
|
|
6
|
+
from django.apps import AppConfig
|
|
7
|
+
from django.core.checks import register
|
|
8
|
+
from django.utils.module_loading import autodiscover_modules
|
|
9
|
+
|
|
10
|
+
from django_spec_tasks.checks import check_spec_run_on_default_alias
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DjangoSpecTasksConfig(AppConfig):
|
|
14
|
+
"""Registers the check, the Celery task and every project's bindings."""
|
|
15
|
+
|
|
16
|
+
name = "django_spec_tasks"
|
|
17
|
+
verbose_name = "Spec tasks"
|
|
18
|
+
|
|
19
|
+
def ready(self) -> None:
|
|
20
|
+
register(check_spec_run_on_default_alias)
|
|
21
|
+
_register_celery_task()
|
|
22
|
+
autodiscover_modules("spec_tasks")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _register_celery_task() -> None:
|
|
26
|
+
"""Import the Celery backend whenever ``celery`` is installed.
|
|
27
|
+
|
|
28
|
+
A Celery worker runs a message by looking its name up in its own registry, so the
|
|
29
|
+
task has to be registered by importing the module, in that worker, at startup. The
|
|
30
|
+
module imports ``celery`` at its top, so the import is gated on the optional
|
|
31
|
+
dependency being there, and asking first rather than catching ``ImportError`` keeps
|
|
32
|
+
a broken backend module from reading as an absent queue.
|
|
33
|
+
"""
|
|
34
|
+
if find_spec("celery") is not None:
|
|
35
|
+
import_module("django_spec_tasks.backends.celery_backend")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Where a run's code executes: one ``TaskBackend`` per queue."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The Celery backend. ``apps.py`` imports this module whenever ``celery`` is installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from celery import shared_task
|
|
6
|
+
|
|
7
|
+
from django_spec_tasks.worker.run_spec import run_spec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@shared_task(name="django_spec_tasks.run_spec", ignore_result=True)
|
|
11
|
+
def _run_spec(run_id: str) -> None:
|
|
12
|
+
"""The task a worker runs: hand the id to the package's worker.
|
|
13
|
+
|
|
14
|
+
Defined here at the top of the module, never inside ``enqueue``: a Celery worker finds a
|
|
15
|
+
message's task by looking its name up in its own registry, so the task exists there only
|
|
16
|
+
if importing this module registered it, and ``apps.py`` does that at startup. The name
|
|
17
|
+
is spelled out because messages already on a broker carry it, and a name derived from
|
|
18
|
+
this module's path would orphan them the day the module moved.
|
|
19
|
+
|
|
20
|
+
Returns ``None`` and ignores its result: the outcome is on the run's row.
|
|
21
|
+
"""
|
|
22
|
+
run_spec(run_id)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CeleryBackend:
|
|
26
|
+
"""Hands a run to a Celery worker.
|
|
27
|
+
|
|
28
|
+
``queue`` names the queue to send to; ``None`` leaves routing to the project's
|
|
29
|
+
``task_routes``, keyed by the stable task name. The package sets no retry, no late
|
|
30
|
+
acknowledgement, no result backend and no schedule, each because the row already
|
|
31
|
+
answers it: a second copy of a message finds the claim taken, a lost one is asked
|
|
32
|
+
for again by the sweep, and the result is on the row.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, queue: str | None = None) -> None:
|
|
36
|
+
self.queue = queue
|
|
37
|
+
|
|
38
|
+
def enqueue(self, run_id: str) -> None:
|
|
39
|
+
"""Send the run, with its id as Celery's ``task_id`` so a log line names the run."""
|
|
40
|
+
options = {} if self.queue is None else {"queue": self.queue}
|
|
41
|
+
_run_spec.apply_async(args=[run_id], task_id=run_id, **options)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The Django Tasks backend: ``django.tasks`` from 6.0, the ``django-tasks`` backport below."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from django.tasks import task
|
|
7
|
+
except ImportError:
|
|
8
|
+
from django_tasks import task
|
|
9
|
+
|
|
10
|
+
from django_spec_tasks.worker.run_spec import run_spec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@task
|
|
14
|
+
def _run_spec(run_id: str) -> None:
|
|
15
|
+
"""The task body.
|
|
16
|
+
|
|
17
|
+
Decorated at the top of the module because Django Tasks finds a task again by its dotted
|
|
18
|
+
path, which has to resolve to the ``Task`` itself: a worker reading a stored result, or
|
|
19
|
+
a ``db_worker`` picking up a row, imports ``module._run_spec`` and refuses a plain
|
|
20
|
+
function there. Nested in a method it would not be importable at all. Returns ``None``,
|
|
21
|
+
since a returned frozen dataclass fails the task after the work has committed, and the
|
|
22
|
+
outcome is on the run's row.
|
|
23
|
+
"""
|
|
24
|
+
run_spec(run_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DjangoTasksBackend:
|
|
28
|
+
"""Hands a run to Django's Tasks framework.
|
|
29
|
+
|
|
30
|
+
Name the backend in the binding; it is never chosen by what imports. ``django.tasks``
|
|
31
|
+
imports on any Django 6, and its default backend runs a task inline on the caller's
|
|
32
|
+
thread, so choosing by availability would pick that in a project running Celery.
|
|
33
|
+
|
|
34
|
+
``alias`` is the ``TASKS`` entry to send to, ``queue`` the queue on it; ``None`` leaves
|
|
35
|
+
the framework's default. The module's task is built against the ``default`` entry, as
|
|
36
|
+
any ``@task`` is, so ``TASKS`` needs one.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, alias: str = "default", queue: str | None = None) -> None:
|
|
40
|
+
self.alias = alias
|
|
41
|
+
self.queue = queue
|
|
42
|
+
|
|
43
|
+
def enqueue(self, run_id: str) -> None:
|
|
44
|
+
"""Send the run to the aliased backend."""
|
|
45
|
+
_run_spec.using(backend=self.alias, queue_name=self.queue).enqueue(run_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""What a queue needs that a spec does not declare."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from contextlib import AbstractContextManager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from django_service_specs import DEFAULT_POOL_SEEDS, PoolSeeds, SpecRegistry, resolve_principal
|
|
10
|
+
|
|
11
|
+
from django_spec_tasks.types.task_backend import TaskBackend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, kw_only=True, eq=False)
|
|
15
|
+
class TaskBinding:
|
|
16
|
+
"""A registry served as background tasks, with everything a queue needs beside it.
|
|
17
|
+
|
|
18
|
+
Collaborators are objects passed here, never dotted paths in settings. A seam
|
|
19
|
+
that takes a run is handed the ``SpecRun`` row, typed ``Any`` because nothing here
|
|
20
|
+
imports a model: that is what keeps this class importable before the app registry
|
|
21
|
+
is ready.
|
|
22
|
+
|
|
23
|
+
Constructing one registers it under its name, since a worker has only the name a
|
|
24
|
+
row recorded. Autodiscovery of each installed app's ``spec_tasks`` module is what
|
|
25
|
+
makes every binding known in a worker that imported Django.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
name: Recorded on every row, so it must not change while runs are pending.
|
|
29
|
+
registry: Where enqueue and the worker find a spec by name.
|
|
30
|
+
backend: Where a run's code executes.
|
|
31
|
+
resolve_actor: Turns a row's ``actor_pk`` into the principal. Raises
|
|
32
|
+
``PrincipalUnavailable`` for anyone who may no longer act.
|
|
33
|
+
pool_seeds: The same set at enqueue and in the worker, so the prelude's seed
|
|
34
|
+
check agrees between them.
|
|
35
|
+
lease: How long a claimed run may take. Sized to the binding's longest run: a
|
|
36
|
+
worker past it is refused at its terminal write.
|
|
37
|
+
grace: How long a ``PENDING`` row waits, from the last time a backend was asked for
|
|
38
|
+
it, before the sweep asks again. Sized to how long the queue may back up: each
|
|
39
|
+
ask past it is one more message.
|
|
40
|
+
max_attempts: How many claims an idempotent run gets before it is abandoned.
|
|
41
|
+
max_output_bytes: Past this, a run ends ``OUTPUT_TOO_LARGE``. Output is never
|
|
42
|
+
truncated.
|
|
43
|
+
can_read: ``(principal, run) -> bool``, consulted beside the run's owner.
|
|
44
|
+
progress_using: A database alias progress is written through, which is what
|
|
45
|
+
makes it visible during an ``atomic=True`` run. A second connection to the same
|
|
46
|
+
database: on any other it finds no row, and writes nothing.
|
|
47
|
+
carry: ``(actor) -> JSON mapping``, recorded on the row at enqueue and handed
|
|
48
|
+
to the call context in both processes.
|
|
49
|
+
around_run: ``(run, principal) -> context manager`` around ``dispatch`` and
|
|
50
|
+
``present``.
|
|
51
|
+
on_finish: ``(run) -> None``, inside the transaction of the terminal write.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
registry: SpecRegistry
|
|
56
|
+
backend: TaskBackend
|
|
57
|
+
resolve_actor: Callable[[str], Any] = resolve_principal
|
|
58
|
+
pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS
|
|
59
|
+
lease: timedelta = timedelta(hours=1)
|
|
60
|
+
grace: timedelta = timedelta(minutes=10)
|
|
61
|
+
max_attempts: int = 3
|
|
62
|
+
max_output_bytes: int = 1_048_576
|
|
63
|
+
can_read: Callable[[Any, Any], bool] | None = None
|
|
64
|
+
progress_using: str | None = None
|
|
65
|
+
carry: Callable[[Any], Mapping[str, Any]] | None = None
|
|
66
|
+
around_run: Callable[[Any, Any], AbstractContextManager[Any]] | None = None
|
|
67
|
+
on_finish: Callable[[Any], None] | None = None
|
|
68
|
+
|
|
69
|
+
# On the class rather than in a module of its own: a module holding bindings
|
|
70
|
+
# would have to import this class, and this class has to reach it to register.
|
|
71
|
+
_registered: ClassVar[dict[str, TaskBinding]] = {}
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if self.name in TaskBinding._registered:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"A TaskBinding named {self.name!r} is already registered. A row records "
|
|
77
|
+
"a binding by name, so two of one name could not be told apart."
|
|
78
|
+
)
|
|
79
|
+
TaskBinding._registered[self.name] = self
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def named(cls, name: str) -> TaskBinding | None:
|
|
83
|
+
"""The binding registered under ``name``, or ``None``.
|
|
84
|
+
|
|
85
|
+
A row naming no binding is a rolling deploy rather than a bug, so an unknown
|
|
86
|
+
name is an answer here and not an exception.
|
|
87
|
+
"""
|
|
88
|
+
return TaskBinding._registered.get(name)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def clear_registry(cls) -> None:
|
|
92
|
+
"""Forget every binding: the one way a test suite empties the registry."""
|
|
93
|
+
TaskBinding._registered.clear()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The caller's half: refuse what cannot run, write the row, ask a backend on commit."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator, Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import router, transaction
|
|
8
|
+
from django_service_specs import (
|
|
9
|
+
Parameters,
|
|
10
|
+
build_call_context,
|
|
11
|
+
check_call,
|
|
12
|
+
kernel_spec,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from django_spec_tasks.binding.task_binding import TaskBinding
|
|
16
|
+
from django_spec_tasks.models.spec_run import SpecRun
|
|
17
|
+
from django_spec_tasks.models.storable_json import storable_json
|
|
18
|
+
from django_spec_tasks.types.run_state import RunState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def enqueue(
|
|
22
|
+
binding: TaskBinding,
|
|
23
|
+
spec_name: str,
|
|
24
|
+
params: Mapping[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
actor: Any,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Record a run of ``spec_name`` and ask the binding's backend to execute it on commit.
|
|
29
|
+
|
|
30
|
+
Everything that can be refused while the caller is still on the stack is refused here,
|
|
31
|
+
and a refusal writes no row and sends nothing. What is left is decided by the worker,
|
|
32
|
+
which checks again through the kernel's ``dispatch``: a grant cannot cross a process,
|
|
33
|
+
so none is carried.
|
|
34
|
+
|
|
35
|
+
``actor`` is required. ``None`` is the declared system run, which a spec admits or
|
|
36
|
+
refuses by its own permissions; leaving it out is a ``TypeError``, so an unauthenticated
|
|
37
|
+
call cannot become a system run by omission.
|
|
38
|
+
|
|
39
|
+
The Validator does not run here. A stock one queries, needs the row it acts on, and its
|
|
40
|
+
answer would not hold by the time a worker ran.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The run's id, as a string. A backend that carries a task id is given this one.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
LookupError: The binding's registry holds no spec of that name.
|
|
47
|
+
TypeError: The spec takes a ``file`` parameter; ``actor`` is a principal with no
|
|
48
|
+
primary key (an anonymous user); or ``params`` or the carried values would
|
|
49
|
+
change on their way through JSON.
|
|
50
|
+
PrincipalUnavailable: ``actor`` is deactivated.
|
|
51
|
+
InvalidArguments: ``params`` do not fit the spec.
|
|
52
|
+
NotPermitted: ``actor`` fails the spec's class-level permissions.
|
|
53
|
+
ImproperlyConfigured: A parameter collides with one of the binding's pool seeds.
|
|
54
|
+
"""
|
|
55
|
+
registered = binding.registry.get(spec_name)
|
|
56
|
+
if registered is None:
|
|
57
|
+
raise LookupError(f"Binding {binding.name!r} has no spec named {spec_name!r}.")
|
|
58
|
+
spec = registered.spec
|
|
59
|
+
|
|
60
|
+
for path in _file_parameters(kernel_spec(spec).parameters()):
|
|
61
|
+
raise TypeError(
|
|
62
|
+
f"Spec {spec_name!r} takes a file parameter, {path!r}, which cannot cross to a "
|
|
63
|
+
"worker. Save the upload to storage and declare a parameter holding its key."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if actor is not None and getattr(actor, "pk", None) is None:
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"actor {actor!r} has no primary key, so the worker could not load it. Pass "
|
|
69
|
+
"actor=None for a system run; an unauthenticated call is not one."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
carried = dict(binding.carry(actor)) if binding.carry is not None else {}
|
|
73
|
+
_require_json_round_trip(carried, "carried value")
|
|
74
|
+
|
|
75
|
+
# The grant and the seeds the checks resolved are discarded: neither crosses a process,
|
|
76
|
+
# and the worker resolves its own.
|
|
77
|
+
check_call(
|
|
78
|
+
spec,
|
|
79
|
+
principal=actor,
|
|
80
|
+
arguments=params,
|
|
81
|
+
pool_seeds=binding.pool_seeds,
|
|
82
|
+
call_context=build_call_context("task", base={"carried": carried}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_require_json_round_trip(params, "parameter")
|
|
86
|
+
|
|
87
|
+
alias = router.db_for_write(SpecRun)
|
|
88
|
+
run = SpecRun.objects.using(alias).create(
|
|
89
|
+
binding=binding.name,
|
|
90
|
+
spec_name=spec_name,
|
|
91
|
+
params=dict(params),
|
|
92
|
+
actor_pk=None if actor is None else str(actor.pk),
|
|
93
|
+
carried=carried,
|
|
94
|
+
state=RunState.PENDING,
|
|
95
|
+
)
|
|
96
|
+
run_id = str(run.pk)
|
|
97
|
+
# robust: a broker down at commit would otherwise raise out of this callback and cancel
|
|
98
|
+
# every later one the project registered in the same transaction, while the row already
|
|
99
|
+
# makes the lost message recoverable.
|
|
100
|
+
transaction.on_commit(lambda: binding.backend.enqueue(run_id), using=alias, robust=True)
|
|
101
|
+
return run_id
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _file_parameters(parameters: Parameters, prefix: str = "") -> Iterator[str]:
|
|
105
|
+
"""The dotted path of every ``file`` parameter, however deeply it is nested."""
|
|
106
|
+
for parameter in parameters:
|
|
107
|
+
path = f"{prefix}{parameter.name}"
|
|
108
|
+
if parameter.type == "file" or parameter.items == "file":
|
|
109
|
+
yield path
|
|
110
|
+
for nested in (parameter.items, parameter.fields):
|
|
111
|
+
if isinstance(nested, Parameters):
|
|
112
|
+
yield from _file_parameters(nested, f"{path}.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _require_json_round_trip(values: Mapping[str, Any], label: str) -> None:
|
|
116
|
+
"""Refuse a value that JSON would change or cannot carry, naming its key.
|
|
117
|
+
|
|
118
|
+
A queue stores JSON, and what it hands a worker is what survived: a tuple comes back a
|
|
119
|
+
list, bytes a string, an integer key a string. Django Tasks accepts some of those
|
|
120
|
+
without complaint, so a backend's own gate is not this check. ``inf``, a NUL and half a
|
|
121
|
+
surrogate pair survive the round trip and fail the write, where ``TypeError`` is promised.
|
|
122
|
+
"""
|
|
123
|
+
for key, value in values.items():
|
|
124
|
+
if not isinstance(key, str):
|
|
125
|
+
raise TypeError(f"The {label} key {key!r} is not a string, and JSON keys are.")
|
|
126
|
+
try:
|
|
127
|
+
storable_json(key)
|
|
128
|
+
restored = json.loads(storable_json(value))
|
|
129
|
+
except (TypeError, ValueError) as error:
|
|
130
|
+
raise TypeError(f"The {label} {key!r} is not JSON: {error}") from error
|
|
131
|
+
if restored != value:
|
|
132
|
+
raise TypeError(
|
|
133
|
+
f"The {label} {key!r} changes on its way through JSON ({value!r} comes back "
|
|
134
|
+
f"as {restored!r}). Pass the form a JSON body would carry."
|
|
135
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.apps import AppConfig, apps
|
|
7
|
+
from django.core.checks import CheckMessage, Error
|
|
8
|
+
from django.db import DEFAULT_DB_ALIAS, router
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_spec_run_on_default_alias(
|
|
12
|
+
app_configs: Sequence[AppConfig] | None, **kwargs: Any
|
|
13
|
+
) -> list[CheckMessage]:
|
|
14
|
+
"""Refuse a database router sending ``SpecRun`` away from the default alias.
|
|
15
|
+
|
|
16
|
+
The kernel's ``atomic`` opens on the default alias, and a run's guarantee is that
|
|
17
|
+
the service's writes and its terminal state commit together, which needs the row in
|
|
18
|
+
that transaction. Reads count as well: a worker or a caller reading a run from a
|
|
19
|
+
replica sees a state the row has already left.
|
|
20
|
+
|
|
21
|
+
Registered with no tag, because a ``Tags.database`` check runs only when ``check``
|
|
22
|
+
is told to look at a database. It reads the model from the app registry, since
|
|
23
|
+
``apps.py`` imports this module before a model may be imported.
|
|
24
|
+
"""
|
|
25
|
+
spec_run = apps.get_model("django_spec_tasks", "SpecRun")
|
|
26
|
+
routed = {"writes": router.db_for_write(spec_run), "reads": router.db_for_read(spec_run)}
|
|
27
|
+
return [
|
|
28
|
+
Error(
|
|
29
|
+
f"A database router sends SpecRun {operation} to {alias!r}.",
|
|
30
|
+
hint=(
|
|
31
|
+
"A run's terminal state commits in the transaction the service ran in, "
|
|
32
|
+
f"which the kernel opens on {DEFAULT_DB_ALIAS!r}. Route the "
|
|
33
|
+
"django_spec_tasks app there."
|
|
34
|
+
),
|
|
35
|
+
id="django_spec_tasks.E001",
|
|
36
|
+
)
|
|
37
|
+
for operation, alias in routed.items()
|
|
38
|
+
if alias != DEFAULT_DB_ALIAS
|
|
39
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Management commands: the sweep and retention, for a project's scheduler to call."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The sweep and retention commands."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
|
7
|
+
|
|
8
|
+
from django_spec_tasks.sweep.prune_runs import prune_runs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = (
|
|
13
|
+
"Delete runs that finished more than --older-than days ago. A run that is still "
|
|
14
|
+
"pending or running is never deleted."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
18
|
+
# No default: how long to keep a run is the project's decision, not the package's.
|
|
19
|
+
parser.add_argument("--older-than", type=int, required=True, metavar="DAYS")
|
|
20
|
+
|
|
21
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
22
|
+
days: int = options["older_than"]
|
|
23
|
+
if days < 0:
|
|
24
|
+
raise CommandError("--older-than must not be negative.")
|
|
25
|
+
deleted = prune_runs(older_than=timedelta(days=days))
|
|
26
|
+
self.stdout.write(f"Deleted {deleted} {'run' if deleted == 1 else 'runs'}.")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
6
|
+
|
|
7
|
+
from django_spec_tasks.sweep.sweep_runs import sweep_runs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
help = (
|
|
12
|
+
"Ask the backend again for runs it seems to have lost, and end the runs whose "
|
|
13
|
+
"worker did not come back. Run it on the project's own schedule."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--limit",
|
|
19
|
+
type=int,
|
|
20
|
+
default=1000,
|
|
21
|
+
help="The most rows one sweep looks at for one binding.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
25
|
+
report = sweep_runs(limit=options["limit"])
|
|
26
|
+
self.stdout.write(
|
|
27
|
+
f"Swept spec runs: {report.requeued} requeued, {report.abandoned} abandoned, "
|
|
28
|
+
f"{report.unavailable} spec unavailable, {report.reasked} asked again, "
|
|
29
|
+
f"{report.errors} errors."
|
|
30
|
+
)
|