dewey 0.4.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.
- dewey/__init__.py +74 -0
- dewey/adapters/__init__.py +5 -0
- dewey/adapters/base.py +49 -0
- dewey/adapters/huey.py +96 -0
- dewey/core/__init__.py +39 -0
- dewey/core/backoff.py +43 -0
- dewey/core/execution.py +102 -0
- dewey/core/logging.py +186 -0
- dewey/core/states.py +66 -0
- dewey/core/types.py +41 -0
- dewey/dispatcher.py +611 -0
- dewey/django/__init__.py +148 -0
- dewey/django/apps.py +10 -0
- dewey/django/conf.py +93 -0
- dewey/django/dispatch.py +141 -0
- dewey/django/executor.py +265 -0
- dewey/django/listen.py +48 -0
- dewey/django/management/__init__.py +0 -0
- dewey/django/management/commands/__init__.py +0 -0
- dewey/django/management/commands/dewey_dispatcher.py +94 -0
- dewey/django/migrations/0001_initial.py +94 -0
- dewey/django/migrations/__init__.py +0 -0
- dewey/django/models.py +157 -0
- dewey/django/queries.py +214 -0
- dewey/django/sweep.py +260 -0
- dewey/errors.py +70 -0
- dewey/listen_sync.py +183 -0
- dewey/policy.py +330 -0
- dewey/py.typed +0 -0
- dewey/serialization.py +168 -0
- dewey/sqlalchemy/__init__.py +114 -0
- dewey/sqlalchemy/async_executor.py +249 -0
- dewey/sqlalchemy/async_queries.py +277 -0
- dewey/sqlalchemy/async_sweep.py +229 -0
- dewey/sqlalchemy/dispatch.py +355 -0
- dewey/sqlalchemy/executor.py +259 -0
- dewey/sqlalchemy/listen.py +182 -0
- dewey/sqlalchemy/models.py +123 -0
- dewey/sqlalchemy/queries.py +269 -0
- dewey/sqlalchemy/sweep.py +244 -0
- dewey-0.4.0.dist-info/METADATA +241 -0
- dewey-0.4.0.dist-info/RECORD +45 -0
- dewey-0.4.0.dist-info/WHEEL +5 -0
- dewey-0.4.0.dist-info/licenses/LICENSE +21 -0
- dewey-0.4.0.dist-info/top_level.txt +1 -0
dewey/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Dewey — Guaranteed delivery engine. Frankapps Built.
|
|
2
|
+
|
|
3
|
+
Postgres is the durable scheduler and backlog; a broker is only a worker-pool
|
|
4
|
+
transport. Declare a task, create work inside your own transaction, and let the
|
|
5
|
+
dispatcher and the ledger handle delivery::
|
|
6
|
+
|
|
7
|
+
import dewey
|
|
8
|
+
|
|
9
|
+
@dewey.task("agent.notify", max_attempts=5, backoff=dewey.Constant(3))
|
|
10
|
+
def notify_agent(command_id: str) -> None:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
Importing ``dewey`` pulls in no framework: the producer and worker APIs live in
|
|
14
|
+
``dewey.sqlalchemy`` and ``dewey.django``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
from dewey.core.states import TaskStatus
|
|
20
|
+
from dewey.errors import (
|
|
21
|
+
DeweyError,
|
|
22
|
+
DuplicateTaskTypeError,
|
|
23
|
+
NonRetryableError,
|
|
24
|
+
RetryAfter,
|
|
25
|
+
SerializationError,
|
|
26
|
+
TransientError,
|
|
27
|
+
UnknownTaskTypeError,
|
|
28
|
+
)
|
|
29
|
+
from dewey.policy import (
|
|
30
|
+
TASK_DEFAULTS,
|
|
31
|
+
BackoffPolicy,
|
|
32
|
+
Constant,
|
|
33
|
+
Custom,
|
|
34
|
+
Exponential,
|
|
35
|
+
PolicyRegistry,
|
|
36
|
+
TaskPolicy,
|
|
37
|
+
clear_project_policies,
|
|
38
|
+
configure_policies,
|
|
39
|
+
registry,
|
|
40
|
+
resolve_policy,
|
|
41
|
+
task,
|
|
42
|
+
)
|
|
43
|
+
from dewey.serialization import encode_args, encode_kwargs
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
__version__ = version("dewey")
|
|
47
|
+
except PackageNotFoundError: # pragma: no cover — source tree without an install
|
|
48
|
+
__version__ = "0.0.0.dev0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"TASK_DEFAULTS",
|
|
52
|
+
"BackoffPolicy",
|
|
53
|
+
"Constant",
|
|
54
|
+
"Custom",
|
|
55
|
+
"DeweyError",
|
|
56
|
+
"DuplicateTaskTypeError",
|
|
57
|
+
"Exponential",
|
|
58
|
+
"NonRetryableError",
|
|
59
|
+
"PolicyRegistry",
|
|
60
|
+
"RetryAfter",
|
|
61
|
+
"SerializationError",
|
|
62
|
+
"TaskPolicy",
|
|
63
|
+
"TaskStatus",
|
|
64
|
+
"TransientError",
|
|
65
|
+
"UnknownTaskTypeError",
|
|
66
|
+
"__version__",
|
|
67
|
+
"clear_project_policies",
|
|
68
|
+
"configure_policies",
|
|
69
|
+
"encode_args",
|
|
70
|
+
"encode_kwargs",
|
|
71
|
+
"registry",
|
|
72
|
+
"resolve_policy",
|
|
73
|
+
"task",
|
|
74
|
+
]
|
dewey/adapters/base.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Abstract adapter interface for queue transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
ProcessTaskFn = Callable[[str], Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class DispatcherAdapter(Protocol):
|
|
13
|
+
"""
|
|
14
|
+
Target protocol for Dewey-driven dispatch.
|
|
15
|
+
|
|
16
|
+
Producers create rows only. A Dewey dispatcher claims ready rows and
|
|
17
|
+
calls :meth:`dispatch` after commit; the transport adapter then hands
|
|
18
|
+
the task ID to its worker pool. :meth:`register` wires the worker-side
|
|
19
|
+
processor that receives the task ID.
|
|
20
|
+
|
|
21
|
+
Lifecycle
|
|
22
|
+
---------
|
|
23
|
+
1. **Construction** — build the adapter with its transport handle
|
|
24
|
+
(Huey instance, etc.) in the worker process *and* in any
|
|
25
|
+
dispatcher process that will call :meth:`dispatch`.
|
|
26
|
+
2. **register(process_fn)** — called once per worker process, before
|
|
27
|
+
the worker pool starts consuming. The protocol does not require
|
|
28
|
+
re-registration to be idempotent; adapters are free to raise if
|
|
29
|
+
called twice. Dispatcher processes that only call :meth:`dispatch`
|
|
30
|
+
do not need to call :meth:`register`.
|
|
31
|
+
3. **dispatch(task_id)** — called by the Dewey dispatcher after the
|
|
32
|
+
claimed row has been committed to ``DISPATCHING``. Must be safe to
|
|
33
|
+
call concurrently from multiple dispatcher processes and must not
|
|
34
|
+
block on the task completing.
|
|
35
|
+
|
|
36
|
+
The protocol is :func:`runtime_checkable`, so adapters can be
|
|
37
|
+
validated with ``isinstance(adapter, DispatcherAdapter)`` at wiring
|
|
38
|
+
time. Note that runtime checks verify method presence only, not
|
|
39
|
+
signatures — see :mod:`tests.test_adapter_protocol` for signature
|
|
40
|
+
contract tests.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def register(self, process_fn: ProcessTaskFn) -> None:
|
|
44
|
+
"""Register the worker-side function that processes a task ID."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def dispatch(self, task_id: str) -> Any:
|
|
48
|
+
"""Dispatch a claimed task ID to the transport worker pool."""
|
|
49
|
+
...
|
dewey/adapters/huey.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Huey adapter — transport only.
|
|
2
|
+
|
|
3
|
+
Huey's job is to carry a task ID from the dispatcher to a worker. It does not
|
|
4
|
+
decide when work runs, how often it retries, or what happens when it fails. That
|
|
5
|
+
is Dewey's, in Postgres.
|
|
6
|
+
|
|
7
|
+
Registering with ``retries=0`` is deliberate: two retry engines fighting over one
|
|
8
|
+
task is how work gets run twice and how attempt counters stop meaning anything.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from huey import Huey
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
ProcessTaskFn = Callable[[str], Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HueyAdapter:
|
|
26
|
+
"""Bridge Dewey's dispatcher to a Huey worker pool.
|
|
27
|
+
|
|
28
|
+
Put the wiring in a module both processes import — the Huey instance, the
|
|
29
|
+
adapter, and the ``register`` call::
|
|
30
|
+
|
|
31
|
+
# myapp/tasks.py
|
|
32
|
+
huey = RedisHuey("myapp")
|
|
33
|
+
adapter = HueyAdapter(huey)
|
|
34
|
+
|
|
35
|
+
def _process(task_id: str) -> bool:
|
|
36
|
+
with Session(engine) as session:
|
|
37
|
+
return process_task(session, task_id)
|
|
38
|
+
|
|
39
|
+
adapter.register(_process)
|
|
40
|
+
|
|
41
|
+
The worker runs ``huey_consumer myapp.tasks.huey``. The dispatcher imports the
|
|
42
|
+
same module and hands IDs over::
|
|
43
|
+
|
|
44
|
+
dispatcher = Dispatcher(SQLAlchemyDispatchBackend(engine), adapter.dispatch)
|
|
45
|
+
dispatcher.run()
|
|
46
|
+
|
|
47
|
+
Producers import none of this. They call ``create_task`` and commit.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, huey: Huey, *, task_name: str = "dewey_process_task") -> None:
|
|
51
|
+
self._huey = huey
|
|
52
|
+
self._task_name = task_name
|
|
53
|
+
self._process_task: Any = None
|
|
54
|
+
|
|
55
|
+
def register(self, process_fn: ProcessTaskFn) -> None:
|
|
56
|
+
"""Register the callable that processes a task ID.
|
|
57
|
+
|
|
58
|
+
Call once per process, at import time, before the consumer starts.
|
|
59
|
+
|
|
60
|
+
``retries=0`` is not an oversight: Dewey owns retry scheduling and the
|
|
61
|
+
attempt budget. A broker retrying underneath it would re-run a handler
|
|
62
|
+
Dewey had deliberately scheduled for later.
|
|
63
|
+
"""
|
|
64
|
+
if self._process_task is not None:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"HueyAdapter.register() was called twice in this process. Register once "
|
|
67
|
+
"at import time — a second call would bind two callables to the task "
|
|
68
|
+
f"name {self._task_name!r}."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@self._huey.task(name=self._task_name, retries=0)
|
|
72
|
+
def _dewey_process_task(task_id: str) -> Any:
|
|
73
|
+
return process_fn(task_id)
|
|
74
|
+
|
|
75
|
+
self._process_task = _dewey_process_task
|
|
76
|
+
logger.info("HueyAdapter registered processor as %r", self._task_name)
|
|
77
|
+
|
|
78
|
+
def dispatch(self, task_id: str) -> Any:
|
|
79
|
+
"""Hand a claimed task ID to the Huey worker pool.
|
|
80
|
+
|
|
81
|
+
Called only by the dispatcher, only after the claim is committed.
|
|
82
|
+
|
|
83
|
+
Raising when the broker is unreachable is correct and expected: the
|
|
84
|
+
dispatcher returns the row to PENDING, backs off, and tries again. Nothing
|
|
85
|
+
is lost, because Postgres — not Redis — is holding the backlog.
|
|
86
|
+
"""
|
|
87
|
+
if self._process_task is None:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"HueyAdapter.register() must be called before dispatch(). Import the "
|
|
90
|
+
"module that wires the adapter (the same one your worker uses) in the "
|
|
91
|
+
"dispatcher process, so both agree on the task name."
|
|
92
|
+
)
|
|
93
|
+
return self._process_task(task_id)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["HueyAdapter", "ProcessTaskFn"]
|
dewey/core/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Core module — pure Python, zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from dewey.core.backoff import (
|
|
4
|
+
BackoffFn,
|
|
5
|
+
default_task_backoff,
|
|
6
|
+
retry_delay,
|
|
7
|
+
)
|
|
8
|
+
from dewey.core.logging import (
|
|
9
|
+
TRACE_METADATA_KEY,
|
|
10
|
+
TraceContextFilter,
|
|
11
|
+
bind_to_metadata,
|
|
12
|
+
extract_trace_context,
|
|
13
|
+
get_trace_context,
|
|
14
|
+
reset_trace_context,
|
|
15
|
+
restore_trace_context,
|
|
16
|
+
set_trace_context,
|
|
17
|
+
update_trace_context,
|
|
18
|
+
)
|
|
19
|
+
from dewey.core.states import TaskStatus, should_die, should_retry
|
|
20
|
+
from dewey.core.types import TaskEntry
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"TaskStatus",
|
|
24
|
+
"should_retry",
|
|
25
|
+
"should_die",
|
|
26
|
+
"BackoffFn",
|
|
27
|
+
"default_task_backoff",
|
|
28
|
+
"retry_delay",
|
|
29
|
+
"TRACE_METADATA_KEY",
|
|
30
|
+
"TraceContextFilter",
|
|
31
|
+
"bind_to_metadata",
|
|
32
|
+
"extract_trace_context",
|
|
33
|
+
"get_trace_context",
|
|
34
|
+
"reset_trace_context",
|
|
35
|
+
"restore_trace_context",
|
|
36
|
+
"set_trace_context",
|
|
37
|
+
"update_trace_context",
|
|
38
|
+
"TaskEntry",
|
|
39
|
+
]
|
dewey/core/backoff.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Retry delay calculation — exponential backoff with jitter and cap."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
# Defaults
|
|
8
|
+
DEFAULT_BASE_DELAY_SECONDS = 120 # 2 minutes
|
|
9
|
+
DEFAULT_MAX_DELAY_SECONDS = 3600 # 1 hour cap
|
|
10
|
+
DEFAULT_JITTER_FRACTION = 0.25 # ±25% jitter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Signature for a custom backoff strategy.
|
|
14
|
+
# Given the number of attempts so far, return how long to wait before retrying.
|
|
15
|
+
BackoffFn = Callable[[int], timedelta]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_task_backoff(attempts: int) -> timedelta:
|
|
19
|
+
"""Default backoff used by process_task / process_task_async when no
|
|
20
|
+
`backoff` argument is supplied. 2 min base, 1 hr cap, ±25% jitter."""
|
|
21
|
+
return retry_delay(attempts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def retry_delay(
|
|
25
|
+
attempts: int,
|
|
26
|
+
base_delay: int = DEFAULT_BASE_DELAY_SECONDS,
|
|
27
|
+
max_delay: int = DEFAULT_MAX_DELAY_SECONDS,
|
|
28
|
+
jitter: float = DEFAULT_JITTER_FRACTION,
|
|
29
|
+
) -> timedelta:
|
|
30
|
+
"""
|
|
31
|
+
Exponential backoff with jitter: base * 2^attempts ± jitter%, capped at max_delay.
|
|
32
|
+
|
|
33
|
+
Jitter prevents thundering herd when many tasks fail simultaneously.
|
|
34
|
+
Set jitter=0 for deterministic behavior (e.g. in tests).
|
|
35
|
+
|
|
36
|
+
attempts=0 → ~2min, attempts=1 → ~4min, attempts=2 → ~8min, ...
|
|
37
|
+
"""
|
|
38
|
+
delay = min(base_delay * (2**attempts), max_delay)
|
|
39
|
+
if jitter > 0:
|
|
40
|
+
jitter_amount = delay * jitter
|
|
41
|
+
delay = delay + random.uniform(-jitter_amount, jitter_amount)
|
|
42
|
+
delay = max(0, min(delay, max_delay)) # Never negative, never above cap
|
|
43
|
+
return timedelta(seconds=delay)
|
dewey/core/execution.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared execution decisions — one implementation for every backend.
|
|
2
|
+
|
|
3
|
+
The SQLAlchemy sync, SQLAlchemy async, and Django executors all differ in how
|
|
4
|
+
they load and save rows, and not at all in what a failure *means*. That decision
|
|
5
|
+
lives here so the three paths cannot drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from dewey.core.backoff import BackoffFn
|
|
16
|
+
from dewey.core.states import TaskStatus, should_die
|
|
17
|
+
from dewey.errors import RetryAfter, UnknownTaskTypeError
|
|
18
|
+
from dewey.policy import TaskPolicy, resolve_policy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class FailureOutcome:
|
|
23
|
+
"""What to write to the row after a handler raised."""
|
|
24
|
+
|
|
25
|
+
status: TaskStatus
|
|
26
|
+
retry_at: datetime | None
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_dead(self) -> bool:
|
|
31
|
+
return self.status == TaskStatus.DEAD
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def classify_failure(
|
|
35
|
+
exc: BaseException,
|
|
36
|
+
*,
|
|
37
|
+
policy: TaskPolicy,
|
|
38
|
+
attempts: int,
|
|
39
|
+
max_attempts: int,
|
|
40
|
+
now: datetime,
|
|
41
|
+
backoff: BackoffFn | None = None,
|
|
42
|
+
) -> FailureOutcome:
|
|
43
|
+
"""Decide whether a failed attempt retries, and when.
|
|
44
|
+
|
|
45
|
+
``attempts`` is the number of attempts *including* the one that just failed.
|
|
46
|
+
|
|
47
|
+
Precedence:
|
|
48
|
+
|
|
49
|
+
1. ``fail_fast_on`` (and anything outside ``retry_on``) dead-letters at once,
|
|
50
|
+
however much of the attempt budget is left — retrying a malformed request
|
|
51
|
+
just burns capacity.
|
|
52
|
+
2. A spent attempt budget dead-letters.
|
|
53
|
+
3. Otherwise retry, no earlier than the policy backoff. A handler raising
|
|
54
|
+
``RetryAfter(n)`` can ask for *later* than the policy would, never
|
|
55
|
+
earlier, so a misbehaving provider cannot pull retries in past your own
|
|
56
|
+
rate budget.
|
|
57
|
+
"""
|
|
58
|
+
requested_delay: timedelta | None = None
|
|
59
|
+
if isinstance(exc, RetryAfter):
|
|
60
|
+
requested_delay = timedelta(seconds=exc.seconds)
|
|
61
|
+
elif not policy.is_retryable(exc):
|
|
62
|
+
return FailureOutcome(TaskStatus.DEAD, None, "non-retryable")
|
|
63
|
+
|
|
64
|
+
if should_die(attempts, max_attempts):
|
|
65
|
+
return FailureOutcome(TaskStatus.DEAD, None, "attempts exhausted")
|
|
66
|
+
|
|
67
|
+
delay = (backoff or policy.delay_for)(attempts)
|
|
68
|
+
if requested_delay is not None and requested_delay > delay:
|
|
69
|
+
delay = requested_delay
|
|
70
|
+
return FailureOutcome(TaskStatus.FAILED, now + delay, "retry")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_handler(
|
|
74
|
+
task_type: str,
|
|
75
|
+
explicit: Callable[..., Any] | None,
|
|
76
|
+
*,
|
|
77
|
+
policy: TaskPolicy | None = None,
|
|
78
|
+
) -> Callable[..., Any]:
|
|
79
|
+
"""Return the callable for ``task_type``.
|
|
80
|
+
|
|
81
|
+
An explicitly passed handler wins — that is the escape hatch for tests and
|
|
82
|
+
for in-process use. Otherwise the registered handler is used.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
UnknownTaskTypeError: nothing is registered and nothing was passed. The
|
|
86
|
+
executor treats this as an ordinary failed attempt rather than a
|
|
87
|
+
crash, so a worker that predates the handler's deploy retries with
|
|
88
|
+
backoff instead of destroying the work.
|
|
89
|
+
"""
|
|
90
|
+
if explicit is not None:
|
|
91
|
+
return explicit
|
|
92
|
+
policy = policy or resolve_policy(task_type)
|
|
93
|
+
if policy.handler is None:
|
|
94
|
+
raise UnknownTaskTypeError(
|
|
95
|
+
f"No handler registered for task type {task_type!r}. Declare one with "
|
|
96
|
+
f"@dewey.task({task_type!r}) and make sure the worker imports that "
|
|
97
|
+
f"module, or pass handler= explicitly."
|
|
98
|
+
)
|
|
99
|
+
return policy.handler
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["FailureOutcome", "classify_failure", "resolve_handler"]
|
dewey/core/logging.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Logging + trace-context helpers.
|
|
2
|
+
|
|
3
|
+
Dewey is a library, not a framework — it doesn't ship a logging config.
|
|
4
|
+
Every Dewey log goes through a logger under the ``dewey.*`` namespace
|
|
5
|
+
(``dewey.core``, ``dewey.sqlalchemy.executor``, ``dewey.django.executor``,
|
|
6
|
+
etc.), so consumers can wire them up like any other library:
|
|
7
|
+
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
# Django LOGGING dict
|
|
11
|
+
LOGGING = {
|
|
12
|
+
"version": 1,
|
|
13
|
+
"loggers": {"dewey": {"level": "INFO", "handlers": ["console"]}},
|
|
14
|
+
...
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# stdlib
|
|
18
|
+
logging.getLogger("dewey").setLevel("INFO")
|
|
19
|
+
|
|
20
|
+
Trace context
|
|
21
|
+
-------------
|
|
22
|
+
|
|
23
|
+
Dewey doesn't know what a "trace ID" means — it just round-trips a small
|
|
24
|
+
dict of correlation IDs from the task creator, through the
|
|
25
|
+
ledger row's ``metadata["trace"]`` field, into the worker, and makes that
|
|
26
|
+
dict available to every log record produced while the handler runs.
|
|
27
|
+
|
|
28
|
+
Producers (typically your API request handler) push values into the
|
|
29
|
+
context with :func:`set_trace_context` or merge them into ``metadata``
|
|
30
|
+
with :func:`bind_to_metadata` before calling ``create_task``. The
|
|
31
|
+
``process_*`` functions automatically restore the trace context from
|
|
32
|
+
``metadata["trace"]`` before invoking the handler and reset it after.
|
|
33
|
+
|
|
34
|
+
Consumers (anyone configuring logs) attach :class:`TraceContextFilter`
|
|
35
|
+
to their handler so every log record gains ``record.dewey_trace`` plus
|
|
36
|
+
flattened ``record.dewey_<field>`` attributes:
|
|
37
|
+
|
|
38
|
+
.. code-block:: python
|
|
39
|
+
|
|
40
|
+
handler = logging.StreamHandler()
|
|
41
|
+
handler.addFilter(TraceContextFilter())
|
|
42
|
+
handler.setFormatter(JsonFormatter()) # python-json-logger, logfire, etc.
|
|
43
|
+
|
|
44
|
+
The carrier is intentionally library-agnostic. Put W3C ``traceparent``,
|
|
45
|
+
an OpenTelemetry context, an AWS X-Ray trace ID, a plain ``request_id``,
|
|
46
|
+
or all of the above into the dict. Dewey will round-trip it untouched.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import logging
|
|
52
|
+
from collections.abc import Iterator
|
|
53
|
+
from contextlib import contextmanager
|
|
54
|
+
from contextvars import ContextVar, Token
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
#: Key inside a task's ``metadata`` dict used to carry
|
|
58
|
+
#: the trace context across the worker boundary.
|
|
59
|
+
TRACE_METADATA_KEY = "trace"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Default is ``None`` (not ``{}``) to avoid the well-known ContextVar
|
|
63
|
+
# mutable-default footgun; callers always go through get_trace_context()
|
|
64
|
+
# which normalizes to an empty dict.
|
|
65
|
+
_trace_context: ContextVar[dict[str, Any] | None] = ContextVar("dewey_trace_context", default=None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_trace_context() -> dict[str, Any]:
|
|
69
|
+
"""Return a copy of the current trace context dict."""
|
|
70
|
+
ctx = _trace_context.get()
|
|
71
|
+
return dict(ctx) if ctx else {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_trace_context(ctx: dict[str, Any] | None) -> Token[dict[str, Any] | None]:
|
|
75
|
+
"""Replace the trace context. Returns a token usable with :func:`reset_trace_context`.
|
|
76
|
+
|
|
77
|
+
Pass ``None`` or ``{}`` to clear the context for the current frame.
|
|
78
|
+
"""
|
|
79
|
+
return _trace_context.set(dict(ctx) if ctx else None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reset_trace_context(token: Token[dict[str, Any] | None]) -> None:
|
|
83
|
+
"""Restore the trace context to its previous value (paired with :func:`set_trace_context`)."""
|
|
84
|
+
_trace_context.reset(token)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def update_trace_context(**fields: Any) -> Token[dict[str, Any] | None]:
|
|
88
|
+
"""Shallow-merge fields into the current trace context.
|
|
89
|
+
|
|
90
|
+
Returns a token so the caller can restore the previous context.
|
|
91
|
+
"""
|
|
92
|
+
current = get_trace_context()
|
|
93
|
+
current.update(fields)
|
|
94
|
+
return _trace_context.set(current)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def bind_to_metadata(
|
|
98
|
+
metadata: dict[str, Any] | None = None,
|
|
99
|
+
*,
|
|
100
|
+
extra: dict[str, Any] | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Build a ``metadata`` dict for ``create_task``
|
|
103
|
+
that carries the current trace context.
|
|
104
|
+
|
|
105
|
+
Merges, in order: ``metadata`` (caller-supplied), the current trace
|
|
106
|
+
context (under ``"trace"``), and any ``extra`` fields the caller wants
|
|
107
|
+
on top.
|
|
108
|
+
|
|
109
|
+
.. code-block:: python
|
|
110
|
+
|
|
111
|
+
await create_task_async(
|
|
112
|
+
session,
|
|
113
|
+
task_type="order.confirmed",
|
|
114
|
+
payload=body,
|
|
115
|
+
metadata=bind_to_metadata({"source": "webhook"}),
|
|
116
|
+
)
|
|
117
|
+
"""
|
|
118
|
+
out: dict[str, Any] = dict(metadata) if metadata else {}
|
|
119
|
+
ctx = get_trace_context()
|
|
120
|
+
if ctx:
|
|
121
|
+
existing = dict(out.get(TRACE_METADATA_KEY) or {})
|
|
122
|
+
existing.update(ctx)
|
|
123
|
+
out[TRACE_METADATA_KEY] = existing
|
|
124
|
+
if extra:
|
|
125
|
+
out.update(extra)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_trace_context(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
130
|
+
"""Pull the trace context dict out of a metadata blob (or ``{}``)."""
|
|
131
|
+
if not metadata:
|
|
132
|
+
return {}
|
|
133
|
+
ctx = metadata.get(TRACE_METADATA_KEY)
|
|
134
|
+
return dict(ctx) if isinstance(ctx, dict) else {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@contextmanager
|
|
138
|
+
def restore_trace_context(metadata: dict[str, Any] | None) -> Iterator[dict[str, Any]]:
|
|
139
|
+
"""Context manager: temporarily set the trace context from a task or
|
|
140
|
+
task's ``metadata`` blob, restoring the previous context on exit.
|
|
141
|
+
|
|
142
|
+
Used by Dewey's ``process_*`` functions to make trace IDs that were
|
|
143
|
+
captured at task-creation time available to logs produced inside the
|
|
144
|
+
handler.
|
|
145
|
+
"""
|
|
146
|
+
ctx = extract_trace_context(metadata)
|
|
147
|
+
token = _trace_context.set(ctx)
|
|
148
|
+
try:
|
|
149
|
+
yield ctx
|
|
150
|
+
finally:
|
|
151
|
+
_trace_context.reset(token)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TraceContextFilter(logging.Filter):
|
|
155
|
+
"""Logging filter that attaches the current Dewey trace context to every record.
|
|
156
|
+
|
|
157
|
+
Attach to a handler in your logging configuration; every record routed
|
|
158
|
+
through that handler will gain:
|
|
159
|
+
|
|
160
|
+
- ``record.dewey_trace`` — a ``dict`` of the full trace context
|
|
161
|
+
- ``record.dewey_<field>`` — one attribute per key (e.g. ``record.dewey_request_id``)
|
|
162
|
+
|
|
163
|
+
JSON formatters (logfire, python-json-logger, structlog renderers) pick
|
|
164
|
+
these up automatically. Plain text formatters can reference them in the
|
|
165
|
+
format string, e.g. ``"%(asctime)s [%(dewey_request_id)s] %(message)s"``.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
169
|
+
ctx = get_trace_context()
|
|
170
|
+
record.dewey_trace = ctx # type: ignore[attr-defined]
|
|
171
|
+
for key, value in ctx.items():
|
|
172
|
+
setattr(record, f"dewey_{key}", value)
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
"TRACE_METADATA_KEY",
|
|
178
|
+
"TraceContextFilter",
|
|
179
|
+
"bind_to_metadata",
|
|
180
|
+
"extract_trace_context",
|
|
181
|
+
"get_trace_context",
|
|
182
|
+
"reset_trace_context",
|
|
183
|
+
"restore_trace_context",
|
|
184
|
+
"set_trace_context",
|
|
185
|
+
"update_trace_context",
|
|
186
|
+
]
|
dewey/core/states.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Task status state machine — the heart of dewey."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskStatus(str, Enum): # noqa: UP042 — keeping (str, Enum) intentional; StrEnum changes str() repr
|
|
7
|
+
"""Task lifecycle states."""
|
|
8
|
+
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
#: Claimed by a dispatcher and handed to the transport, but not yet picked up
|
|
11
|
+
#: by a worker. A row that sits here past the dispatch timeout is reclaimed by
|
|
12
|
+
#: the sweep — that is what makes a dispatcher crash survivable.
|
|
13
|
+
DISPATCHING = "dispatching"
|
|
14
|
+
PROCESSING = "processing"
|
|
15
|
+
COMPLETED = "completed"
|
|
16
|
+
FAILED = "failed"
|
|
17
|
+
DEAD = "dead"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_terminal(self) -> bool:
|
|
21
|
+
"""Terminal for auto-processing — sweep and process_task skip these.
|
|
22
|
+
|
|
23
|
+
Note: DEAD is terminal but allows manual retry (DEAD → PENDING).
|
|
24
|
+
COMPLETED is fully terminal — no transitions out.
|
|
25
|
+
"""
|
|
26
|
+
return self in _TERMINAL_STATES
|
|
27
|
+
|
|
28
|
+
def can_transition_to(self, target: "TaskStatus") -> bool:
|
|
29
|
+
"""Check if transitioning to ``target`` is allowed by the state machine."""
|
|
30
|
+
return target in _ALLOWED_TRANSITIONS.get(self, set())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# States where automatic processing won't pick up the task.
|
|
34
|
+
_TERMINAL_STATES = {TaskStatus.COMPLETED, TaskStatus.DEAD}
|
|
35
|
+
|
|
36
|
+
_ALLOWED_TRANSITIONS: dict[TaskStatus, set[TaskStatus]] = {
|
|
37
|
+
TaskStatus.PENDING: {
|
|
38
|
+
TaskStatus.DISPATCHING, # a dispatcher claimed it
|
|
39
|
+
TaskStatus.PROCESSING, # in-process execution, with no broker in the path
|
|
40
|
+
TaskStatus.DEAD, # manual kill
|
|
41
|
+
},
|
|
42
|
+
TaskStatus.DISPATCHING: {
|
|
43
|
+
TaskStatus.PROCESSING, # a worker picked it up off the transport
|
|
44
|
+
TaskStatus.PENDING, # dispatch failed, or the dispatch timeout swept it
|
|
45
|
+
TaskStatus.DEAD, # manual kill, or attempts exhausted while stuck
|
|
46
|
+
},
|
|
47
|
+
TaskStatus.PROCESSING: {
|
|
48
|
+
TaskStatus.COMPLETED,
|
|
49
|
+
TaskStatus.FAILED,
|
|
50
|
+
TaskStatus.DEAD,
|
|
51
|
+
TaskStatus.PENDING, # sweep_stuck resets abandoned tasks
|
|
52
|
+
},
|
|
53
|
+
TaskStatus.FAILED: {TaskStatus.PENDING, TaskStatus.DEAD},
|
|
54
|
+
TaskStatus.DEAD: {TaskStatus.PENDING}, # manual retry only
|
|
55
|
+
# COMPLETED is fully terminal — no transitions out.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def should_retry(attempts: int, max_attempts: int) -> bool:
|
|
60
|
+
"""Should the task be retried (FAILED → PENDING)?"""
|
|
61
|
+
return attempts < max_attempts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def should_die(attempts: int, max_attempts: int) -> bool:
|
|
65
|
+
"""Should the task be dead-lettered (FAILED → DEAD)?"""
|
|
66
|
+
return attempts >= max_attempts
|