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.
Files changed (45) hide show
  1. dewey/__init__.py +74 -0
  2. dewey/adapters/__init__.py +5 -0
  3. dewey/adapters/base.py +49 -0
  4. dewey/adapters/huey.py +96 -0
  5. dewey/core/__init__.py +39 -0
  6. dewey/core/backoff.py +43 -0
  7. dewey/core/execution.py +102 -0
  8. dewey/core/logging.py +186 -0
  9. dewey/core/states.py +66 -0
  10. dewey/core/types.py +41 -0
  11. dewey/dispatcher.py +611 -0
  12. dewey/django/__init__.py +148 -0
  13. dewey/django/apps.py +10 -0
  14. dewey/django/conf.py +93 -0
  15. dewey/django/dispatch.py +141 -0
  16. dewey/django/executor.py +265 -0
  17. dewey/django/listen.py +48 -0
  18. dewey/django/management/__init__.py +0 -0
  19. dewey/django/management/commands/__init__.py +0 -0
  20. dewey/django/management/commands/dewey_dispatcher.py +94 -0
  21. dewey/django/migrations/0001_initial.py +94 -0
  22. dewey/django/migrations/__init__.py +0 -0
  23. dewey/django/models.py +157 -0
  24. dewey/django/queries.py +214 -0
  25. dewey/django/sweep.py +260 -0
  26. dewey/errors.py +70 -0
  27. dewey/listen_sync.py +183 -0
  28. dewey/policy.py +330 -0
  29. dewey/py.typed +0 -0
  30. dewey/serialization.py +168 -0
  31. dewey/sqlalchemy/__init__.py +114 -0
  32. dewey/sqlalchemy/async_executor.py +249 -0
  33. dewey/sqlalchemy/async_queries.py +277 -0
  34. dewey/sqlalchemy/async_sweep.py +229 -0
  35. dewey/sqlalchemy/dispatch.py +355 -0
  36. dewey/sqlalchemy/executor.py +259 -0
  37. dewey/sqlalchemy/listen.py +182 -0
  38. dewey/sqlalchemy/models.py +123 -0
  39. dewey/sqlalchemy/queries.py +269 -0
  40. dewey/sqlalchemy/sweep.py +244 -0
  41. dewey-0.4.0.dist-info/METADATA +241 -0
  42. dewey-0.4.0.dist-info/RECORD +45 -0
  43. dewey-0.4.0.dist-info/WHEEL +5 -0
  44. dewey-0.4.0.dist-info/licenses/LICENSE +21 -0
  45. 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
+ ]
@@ -0,0 +1,5 @@
1
+ """Queue transport adapters — broker-agnostic."""
2
+
3
+ from dewey.adapters.base import DispatcherAdapter, ProcessTaskFn
4
+
5
+ __all__ = ["DispatcherAdapter", "ProcessTaskFn"]
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)
@@ -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