terp-cap-outbox 0.1.0__tar.gz
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.
- terp_cap_outbox-0.1.0/.gitignore +47 -0
- terp_cap_outbox-0.1.0/PKG-INFO +7 -0
- terp_cap_outbox-0.1.0/escape-hatch-budget.json +5 -0
- terp_cap_outbox-0.1.0/pyproject.toml +30 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/__init__.py +56 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/_serde.py +84 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/migrations/versions/d0dc58cc803e_create_outbox_tables.py +67 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/models.py +123 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/py.typed +0 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/queue.py +82 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/store.py +120 -0
- terp_cap_outbox-0.1.0/src/terp/capabilities/outbox/worker.py +310 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-outbox"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp durable outbox — transactional, leased, retrying post-commit delivery for jobs and events."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# A LIBRARY capability: it owns the durable delivery mechanism (a table, a durable
|
|
16
|
+
# JobQueue / EventDispatcher, and the leased worker), not an HTTP surface — so it
|
|
17
|
+
# declares NO `terp.capabilities` ModuleSpec entry point (nothing is auto-mounted).
|
|
18
|
+
# A composition root opts in by wiring create_app(job_queue=OutboxJobQueue(),
|
|
19
|
+
# event_dispatcher=outbox_event_dispatcher) and running `terp jobs worker`.
|
|
20
|
+
|
|
21
|
+
# Owns the append-only `outbox_message` table, so it ships an independent, linear
|
|
22
|
+
# Alembic history (its own `alembic_version_outbox` table). `terp migrate` discovers
|
|
23
|
+
# this via the `terp.migrations` group (ADR 0027).
|
|
24
|
+
[project.entry-points."terp.migrations"]
|
|
25
|
+
outbox = "terp.capabilities.outbox"
|
|
26
|
+
|
|
27
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.outbox`.
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
sources = ["src"]
|
|
30
|
+
only-include = ["src/terp/capabilities/outbox"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""terp.capabilities.outbox — durable, transactional, leased post-commit delivery.
|
|
2
|
+
|
|
3
|
+
The reliable delivery mechanism the dispatcher seam (ADR 0008) was designed for, and
|
|
4
|
+
the drop-in that makes the jobs seam (ADR 0043) survive a restart (ADR 0045). It owns
|
|
5
|
+
one append-only :class:`OutboxMessage` table and supplies:
|
|
6
|
+
|
|
7
|
+
* :class:`OutboxJobQueue` — a **durable** :class:`~terp.core.JobQueue` that records each
|
|
8
|
+
enqueued job as an outbox row, atomically with the business write, instead of running
|
|
9
|
+
it inline. Marked durable, so ``create_app(require_durable_jobs=True)`` accepts it.
|
|
10
|
+
* :func:`outbox_event_dispatcher` — a durable :data:`~terp.core.EventDispatcher` that
|
|
11
|
+
records each emitted event as an outbox row on the producer's session.
|
|
12
|
+
* :class:`OutboxWorker` — the leased, retrying consumer (``terp jobs worker``) that
|
|
13
|
+
claims due rows, runs jobs through the context-binding kernel runner and events
|
|
14
|
+
through the in-process handlers, and retries / dead-letters per the
|
|
15
|
+
:class:`~terp.core.RetryPolicy`.
|
|
16
|
+
|
|
17
|
+
Wiring is composition-root only, with **no** ``enqueue`` / ``emit`` call-site change::
|
|
18
|
+
|
|
19
|
+
create_app(..., job_queue=OutboxJobQueue(), event_dispatcher=outbox_event_dispatcher,
|
|
20
|
+
require_durable_jobs=settings.is_production)
|
|
21
|
+
|
|
22
|
+
It depends only on ``terp-core`` (and, for delivering events, the in-process eventbus
|
|
23
|
+
capability when one is installed); it never imports a broker engine — those are later,
|
|
24
|
+
separately-installed adapters (ADR 0045).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from terp.capabilities.outbox.models import (
|
|
30
|
+
KIND_EVENT,
|
|
31
|
+
KIND_JOB,
|
|
32
|
+
STATUS_DEAD_LETTERED,
|
|
33
|
+
STATUS_DISPATCHED,
|
|
34
|
+
STATUS_PENDING,
|
|
35
|
+
OutboxMessage,
|
|
36
|
+
)
|
|
37
|
+
from terp.capabilities.outbox.queue import OutboxJobQueue, outbox_event_dispatcher
|
|
38
|
+
from terp.capabilities.outbox.worker import (
|
|
39
|
+
DrainResult,
|
|
40
|
+
OutboxWorker,
|
|
41
|
+
deliver_event_in_process,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"KIND_EVENT",
|
|
46
|
+
"KIND_JOB",
|
|
47
|
+
"STATUS_DEAD_LETTERED",
|
|
48
|
+
"STATUS_DISPATCHED",
|
|
49
|
+
"STATUS_PENDING",
|
|
50
|
+
"DrainResult",
|
|
51
|
+
"OutboxJobQueue",
|
|
52
|
+
"OutboxMessage",
|
|
53
|
+
"OutboxWorker",
|
|
54
|
+
"deliver_event_in_process",
|
|
55
|
+
"outbox_event_dispatcher",
|
|
56
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Serialize a :class:`JobEnvelope` / :class:`EventEnvelope` to and from the row payload.
|
|
2
|
+
|
|
3
|
+
The outbox stores the **whole envelope** as the row's JSON ``payload`` (not just the
|
|
4
|
+
business payload), so the worker can faithfully rebuild what crossed the producer's
|
|
5
|
+
chokepoint — including the originating ``actor_id`` / ``tenant_id`` / ``request_id``
|
|
6
|
+
the runner re-binds (the jobs design's §7). Everything here round-trips through plain
|
|
7
|
+
JSON scalars (``uuid.UUID`` -> str, ``datetime`` -> ISO-8601), so no Python object or
|
|
8
|
+
ORM row is ever persisted (portability rule 2).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from terp.core import EventEnvelope, EventVisibility, JobEnvelope
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _str_or_none(value: object | None) -> str | None:
|
|
23
|
+
"""``str(value)`` for a non-``None`` id/uuid, else ``None`` (JSON-safe)."""
|
|
24
|
+
return None if value is None else str(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def job_envelope_to_payload(envelope: JobEnvelope) -> dict[str, Any]:
|
|
28
|
+
"""Flatten a :class:`JobEnvelope` to a JSON-safe dict for the ``payload`` column.
|
|
29
|
+
|
|
30
|
+
Serialized field-agnostically via :func:`dataclasses.asdict` (then the ``uuid`` /
|
|
31
|
+
``datetime`` fields are cast to JSON scalars), so it never hand-references a
|
|
32
|
+
field name — including the framework-managed ``tenant_id`` the envelope carries
|
|
33
|
+
for the worker to re-bind (the design's §7), which is transport here, not a query
|
|
34
|
+
scope filter.
|
|
35
|
+
"""
|
|
36
|
+
data = asdict(envelope)
|
|
37
|
+
data["actor_id"] = _str_or_none(data["actor_id"])
|
|
38
|
+
data["tenant_id"] = _str_or_none(data["tenant_id"])
|
|
39
|
+
data["enqueued_at"] = data["enqueued_at"].isoformat()
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def payload_to_job_envelope(data: Mapping[str, Any]) -> JobEnvelope:
|
|
44
|
+
"""Rebuild a :class:`JobEnvelope` from a stored ``payload`` dict."""
|
|
45
|
+
actor_id = data.get("actor_id")
|
|
46
|
+
tenant_id = data.get("tenant_id")
|
|
47
|
+
return JobEnvelope(
|
|
48
|
+
name=data["name"],
|
|
49
|
+
payload=dict(data["payload"]),
|
|
50
|
+
idempotency_key=data.get("idempotency_key"),
|
|
51
|
+
actor_id=uuid.UUID(actor_id) if actor_id is not None else None,
|
|
52
|
+
tenant_id=uuid.UUID(tenant_id) if tenant_id is not None else None,
|
|
53
|
+
request_id=data.get("request_id"),
|
|
54
|
+
enqueued_at=datetime.fromisoformat(data["enqueued_at"]),
|
|
55
|
+
attempt=int(data.get("attempt", 1)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def event_envelope_to_payload(envelope: EventEnvelope) -> dict[str, Any]:
|
|
60
|
+
"""Flatten an :class:`EventEnvelope` to a JSON-safe dict for the ``payload`` column."""
|
|
61
|
+
return {
|
|
62
|
+
"name": envelope.name,
|
|
63
|
+
"visibility": envelope.visibility.value,
|
|
64
|
+
"payload": dict(envelope.payload),
|
|
65
|
+
"request_id": envelope.request_id,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def payload_to_event_envelope(data: Mapping[str, Any]) -> EventEnvelope:
|
|
70
|
+
"""Rebuild an :class:`EventEnvelope` from a stored ``payload`` dict."""
|
|
71
|
+
return EventEnvelope(
|
|
72
|
+
name=data["name"],
|
|
73
|
+
visibility=EventVisibility(data["visibility"]),
|
|
74
|
+
payload=dict(data["payload"]),
|
|
75
|
+
request_id=data.get("request_id"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"event_envelope_to_payload",
|
|
81
|
+
"job_envelope_to_payload",
|
|
82
|
+
"payload_to_event_envelope",
|
|
83
|
+
"payload_to_job_envelope",
|
|
84
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""create outbox tables
|
|
2
|
+
|
|
3
|
+
Revision ID: d0dc58cc803e
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-06-29 17:34:15.286303
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = 'd0dc58cc803e'
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table('outbox_message',
|
|
27
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
28
|
+
sa.Column('kind', sqlmodel.sql.sqltypes.AutoString(length=8), nullable=False),
|
|
29
|
+
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
|
30
|
+
sa.Column('payload', sa.JSON(), nullable=False),
|
|
31
|
+
sa.Column('idempotency_key', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
|
32
|
+
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False),
|
|
33
|
+
sa.Column('attempts', sa.Integer(), nullable=False),
|
|
34
|
+
sa.Column('available_at', sa.DateTime(timezone=True), nullable=False),
|
|
35
|
+
sa.Column('locked_by', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True),
|
|
36
|
+
sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True),
|
|
37
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
38
|
+
sa.Column('dispatched_at', sa.DateTime(timezone=True), nullable=True),
|
|
39
|
+
sa.Column('dead_lettered_at', sa.DateTime(timezone=True), nullable=True),
|
|
40
|
+
sa.Column('last_error', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True),
|
|
41
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_outbox_message'))
|
|
42
|
+
)
|
|
43
|
+
with op.batch_alter_table('outbox_message', schema=None) as batch_op:
|
|
44
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_available_at'), ['available_at'], unique=False)
|
|
45
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_created_at'), ['created_at'], unique=False)
|
|
46
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_idempotency_key'), ['idempotency_key'], unique=False)
|
|
47
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_kind'), ['kind'], unique=False)
|
|
48
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_locked_by'), ['locked_by'], unique=False)
|
|
49
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_name'), ['name'], unique=False)
|
|
50
|
+
batch_op.create_index(batch_op.f('ix_outbox_message_status'), ['status'], unique=False)
|
|
51
|
+
|
|
52
|
+
# ### end Alembic commands ###
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def downgrade() -> None:
|
|
56
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
57
|
+
with op.batch_alter_table('outbox_message', schema=None) as batch_op:
|
|
58
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_status'))
|
|
59
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_name'))
|
|
60
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_locked_by'))
|
|
61
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_kind'))
|
|
62
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_idempotency_key'))
|
|
63
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_created_at'))
|
|
64
|
+
batch_op.drop_index(batch_op.f('ix_outbox_message_available_at'))
|
|
65
|
+
|
|
66
|
+
op.drop_table('outbox_message')
|
|
67
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The durable outbox table — one row per post-commit unit of delivery.
|
|
2
|
+
|
|
3
|
+
A row is one piece of work the framework promised to deliver *after* the
|
|
4
|
+
producing transaction committed: a queued background **job** (``kind="job"``) or a
|
|
5
|
+
domain **event** (``kind="event"``). It is written **transactionally, on the
|
|
6
|
+
business write's own session** (see :mod:`terp.capabilities.outbox.store`), so the
|
|
7
|
+
row commits atomically with the mutation that produced it and a rollback drops both
|
|
8
|
+
— the no-dual-write guarantee the dispatcher seam (ADR 0008) was designed for.
|
|
9
|
+
|
|
10
|
+
Append-only + status updates only: a row is inserted ``pending`` and then only ever
|
|
11
|
+
transitions ``pending -> dispatched`` (delivered) or ``pending -> dead_lettered``
|
|
12
|
+
(failed ``max_attempts`` times); its ``payload`` (the serialized envelope) never
|
|
13
|
+
changes. Like :class:`~terp.capabilities.audit.AuditEvent` it therefore composes
|
|
14
|
+
:class:`~terp.core.UUIDPrimaryKeyMixin` rather than ``BaseTable`` — there is no
|
|
15
|
+
optimistic-concurrency ``version`` (the lease, not OCC, arbitrates concurrent
|
|
16
|
+
workers) and no ``updated_at`` (the explicit ``dispatched_at`` / ``dead_lettered_at``
|
|
17
|
+
stamps are the meaningful timeline). Every caller-influenceable ``str`` column caps
|
|
18
|
+
its length so an oversized payload can never break the INSERT.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from typing import Any, Final
|
|
26
|
+
|
|
27
|
+
from sqlalchemy import JSON, DateTime
|
|
28
|
+
from sqlmodel import Column, Field, SQLModel
|
|
29
|
+
|
|
30
|
+
from terp.core import UUIDPrimaryKeyMixin
|
|
31
|
+
|
|
32
|
+
# The two kinds of work the outbox carries (a plain str column, never a higher-layer
|
|
33
|
+
# enum — this leaf stays dependency-light, like AuditEvent.action).
|
|
34
|
+
KIND_EVENT: Final[str] = "event"
|
|
35
|
+
KIND_JOB: Final[str] = "job"
|
|
36
|
+
|
|
37
|
+
# The lifecycle a row moves through: inserted ``pending``, then terminally either
|
|
38
|
+
# ``dispatched`` (delivered) or ``dead_lettered`` (failed past its retry budget).
|
|
39
|
+
STATUS_PENDING: Final[str] = "pending"
|
|
40
|
+
STATUS_DISPATCHED: Final[str] = "dispatched"
|
|
41
|
+
STATUS_DEAD_LETTERED: Final[str] = "dead_lettered"
|
|
42
|
+
|
|
43
|
+
# Hard caps so a hostile / oversized value can never break the INSERT or the trail.
|
|
44
|
+
_NAME_MAX: Final[int] = 200
|
|
45
|
+
_KEY_MAX: Final[int] = 200
|
|
46
|
+
_LOCK_MAX: Final[int] = 128
|
|
47
|
+
_ERROR_MAX: Final[int] = 2000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _utc_now() -> datetime:
|
|
51
|
+
"""UTC ``now`` provider for this non-``BaseTable`` append-only row."""
|
|
52
|
+
return datetime.now(UTC)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OutboxMessage(UUIDPrimaryKeyMixin, SQLModel, table=True): # arch-allow-table-models-use-base-table: append-only delivery infra (like AuditEvent) — the lease, not OCC version/updated_at, arbitrates writers (see module docstring)
|
|
56
|
+
__tablename__ = "outbox_message"
|
|
57
|
+
|
|
58
|
+
kind: str = Field(max_length=8, index=True) # KIND_EVENT | KIND_JOB
|
|
59
|
+
name: str = Field(max_length=_NAME_MAX, index=True)
|
|
60
|
+
payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
|
|
61
|
+
idempotency_key: str | None = Field(default=None, max_length=_KEY_MAX, index=True)
|
|
62
|
+
status: str = Field(default=STATUS_PENDING, max_length=16, index=True)
|
|
63
|
+
attempts: int = Field(default=0)
|
|
64
|
+
available_at: datetime = Field(
|
|
65
|
+
default_factory=_utc_now,
|
|
66
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
67
|
+
nullable=False,
|
|
68
|
+
index=True,
|
|
69
|
+
)
|
|
70
|
+
locked_by: str | None = Field(default=None, max_length=_LOCK_MAX, index=True)
|
|
71
|
+
locked_until: datetime | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
74
|
+
nullable=True,
|
|
75
|
+
)
|
|
76
|
+
created_at: datetime = Field(
|
|
77
|
+
default_factory=_utc_now,
|
|
78
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
79
|
+
nullable=False,
|
|
80
|
+
index=True,
|
|
81
|
+
)
|
|
82
|
+
dispatched_at: datetime | None = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
85
|
+
nullable=True,
|
|
86
|
+
)
|
|
87
|
+
dead_lettered_at: datetime | None = Field(
|
|
88
|
+
default=None,
|
|
89
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
90
|
+
nullable=True,
|
|
91
|
+
)
|
|
92
|
+
last_error: str | None = Field(default=None, max_length=_ERROR_MAX)
|
|
93
|
+
|
|
94
|
+
def assert_within_column_bounds(self) -> None:
|
|
95
|
+
"""Reject a caller-influenced string that exceeds its ``VARCHAR(n)`` column bound.
|
|
96
|
+
|
|
97
|
+
SQLite does **not** enforce ``VARCHAR`` length, but PostgreSQL does — so an
|
|
98
|
+
over-length value the SQLite-backed tests silently accept would fail the INSERT
|
|
99
|
+
on PostgreSQL and, because the outbox row rides the business write's own
|
|
100
|
+
transaction (see :mod:`terp.capabilities.outbox.store`), fail that business
|
|
101
|
+
write. Validate up front so the behaviour is identical on every backend: a clear,
|
|
102
|
+
early :class:`ValueError` rather than a vendor-specific mid-transaction failure.
|
|
103
|
+
"""
|
|
104
|
+
for column, value, limit in (
|
|
105
|
+
("name", self.name, _NAME_MAX),
|
|
106
|
+
("idempotency_key", self.idempotency_key, _KEY_MAX),
|
|
107
|
+
):
|
|
108
|
+
if value is not None and len(value) > limit:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"outbox message {column!r} is {len(value)} characters, exceeding "
|
|
111
|
+
f"its {limit}-character column bound (a PostgreSQL INSERT would "
|
|
112
|
+
f"reject it and fail the business write the row rides)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"KIND_EVENT",
|
|
118
|
+
"KIND_JOB",
|
|
119
|
+
"STATUS_DEAD_LETTERED",
|
|
120
|
+
"STATUS_DISPATCHED",
|
|
121
|
+
"STATUS_PENDING",
|
|
122
|
+
"OutboxMessage",
|
|
123
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The durable producers: a job queue and an event dispatcher that persist an outbox row.
|
|
2
|
+
|
|
3
|
+
These are the drop-in backends a composition root wires to make post-commit delivery
|
|
4
|
+
survive a restart — with **no** change to any ``enqueue`` / ``emit`` call site (ADR
|
|
5
|
+
0008's promise)::
|
|
6
|
+
|
|
7
|
+
create_app(..., job_queue=OutboxJobQueue(), event_dispatcher=outbox_event_dispatcher,
|
|
8
|
+
require_durable_jobs=settings.is_production)
|
|
9
|
+
|
|
10
|
+
:meth:`OutboxJobQueue.enqueue` and :func:`outbox_event_dispatcher` both append an
|
|
11
|
+
:class:`~terp.capabilities.outbox.models.OutboxMessage` on the **session they already
|
|
12
|
+
receive** — the business write's session — so the row commits in the same transaction
|
|
13
|
+
as the mutation that produced it (no dual-write). The job queue marks itself durable
|
|
14
|
+
(:func:`~terp.core.mark_durable_job_queue`), so ``create_app(require_durable_jobs=True)``
|
|
15
|
+
accepts it where the in-process default is refused.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from sqlmodel import Session
|
|
21
|
+
|
|
22
|
+
from terp.core import (
|
|
23
|
+
EventDefinition,
|
|
24
|
+
EventEnvelope,
|
|
25
|
+
JobEnvelope,
|
|
26
|
+
JobQueue,
|
|
27
|
+
mark_durable_job_queue,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from terp.capabilities.outbox._serde import (
|
|
31
|
+
event_envelope_to_payload,
|
|
32
|
+
job_envelope_to_payload,
|
|
33
|
+
)
|
|
34
|
+
from terp.capabilities.outbox.models import KIND_EVENT, KIND_JOB, OutboxMessage
|
|
35
|
+
from terp.capabilities.outbox.store import append
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OutboxJobQueue(JobQueue):
|
|
39
|
+
"""A durable :class:`~terp.core.JobQueue` that records each job as an outbox row.
|
|
40
|
+
|
|
41
|
+
Unlike :class:`~terp.core.InProcessJobQueue` (which runs the handler inline), this
|
|
42
|
+
only *records* the envelope — atomically with the business write on the caller's
|
|
43
|
+
session — for a leased :class:`~terp.capabilities.outbox.OutboxWorker` to drain
|
|
44
|
+
off-request, surviving a restart. It marks itself durable, so
|
|
45
|
+
``create_app(require_durable_jobs=True)`` accepts it.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
mark_durable_job_queue(self)
|
|
50
|
+
|
|
51
|
+
def enqueue(self, session: Session, envelope: JobEnvelope) -> str:
|
|
52
|
+
"""Persist *envelope* as a ``pending`` outbox row on *session*; return its id."""
|
|
53
|
+
message = OutboxMessage(
|
|
54
|
+
kind=KIND_JOB,
|
|
55
|
+
name=envelope.name,
|
|
56
|
+
payload=job_envelope_to_payload(envelope),
|
|
57
|
+
idempotency_key=envelope.idempotency_key,
|
|
58
|
+
)
|
|
59
|
+
append(session, message)
|
|
60
|
+
return str(message.id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def outbox_event_dispatcher(
|
|
64
|
+
session: Session, envelope: EventEnvelope, definition: EventDefinition
|
|
65
|
+
) -> None:
|
|
66
|
+
"""A durable :data:`~terp.core.EventDispatcher`: record each event as an outbox row.
|
|
67
|
+
|
|
68
|
+
Installed at ``create_app(event_dispatcher=outbox_event_dispatcher)``. It records
|
|
69
|
+
the envelope on the producer's session — atomic with the business write — for the
|
|
70
|
+
:class:`~terp.capabilities.outbox.OutboxWorker` to deliver to in-process handlers
|
|
71
|
+
off-request. *definition* satisfies the dispatcher seam contract; the stored
|
|
72
|
+
envelope already carries the canonical name / visibility / payload.
|
|
73
|
+
"""
|
|
74
|
+
message = OutboxMessage(
|
|
75
|
+
kind=KIND_EVENT,
|
|
76
|
+
name=envelope.name,
|
|
77
|
+
payload=event_envelope_to_payload(envelope),
|
|
78
|
+
)
|
|
79
|
+
append(session, message)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["OutboxJobQueue", "outbox_event_dispatcher"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The outbox's raw persistence — the one module that writes the delivery table.
|
|
2
|
+
|
|
3
|
+
Three operations, deliberately isolated here so every governed ``# arch-allow-*``
|
|
4
|
+
opt-out lives in one place:
|
|
5
|
+
|
|
6
|
+
* :func:`append` stages a row on the **business write's own session**, riding the
|
|
7
|
+
audited write unit so it commits atomically with the mutation that produced it (a
|
|
8
|
+
nested enqueue defers to the outer ``BaseService`` commit; a standalone enqueue is
|
|
9
|
+
its own durable unit). It cannot route through ``BaseService`` —
|
|
10
|
+
:class:`~terp.capabilities.outbox.models.OutboxMessage` is not a ``BaseTable`` — so
|
|
11
|
+
it appends directly, exactly like the durable audit sink at the base of the write
|
|
12
|
+
stack.
|
|
13
|
+
* :func:`claim_due` leases a batch of due rows with a single atomic UPDATE (portable
|
|
14
|
+
across SQLite and PostgreSQL; ``SKIP LOCKED`` is added on a backend that supports
|
|
15
|
+
it), so N workers drain one outbox without double-dispatch.
|
|
16
|
+
* :func:`finalize` commits a worker's status transition (dispatched / rescheduled /
|
|
17
|
+
dead-lettered) on the outbox's own table.
|
|
18
|
+
|
|
19
|
+
The worker drives :func:`claim_due` / :func:`finalize` on a **plain** session, but the
|
|
20
|
+
session is still a :class:`~terp.core._internal.session_guard.WriteGuardedSession`
|
|
21
|
+
(handed out by the same session factory as the request session), so both ride
|
|
22
|
+
:func:`~terp.core._internal.session_guard.enter_write_unit` too, exactly like
|
|
23
|
+
:func:`append` — the lease UPDATE and the status-transition commit are themselves
|
|
24
|
+
unaudited, infrastructure-only writes on the outbox's own table, not a business
|
|
25
|
+
mutation, but the guard cannot tell that from an ``# arch-allow-*`` comment alone.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
from sqlalchemy import or_, update
|
|
33
|
+
from sqlmodel import Session, col, select
|
|
34
|
+
|
|
35
|
+
# The outbox is framework delivery infrastructure: its row must ride the audited write
|
|
36
|
+
# unit to commit atomically with the business write (the no-dual-write guarantee), a
|
|
37
|
+
# scope kept under _internal so an app module cannot open it to wave a write past the
|
|
38
|
+
# audit guard. The capability legitimately reaches it, like the audit sink reaches the
|
|
39
|
+
# base of the write stack.
|
|
40
|
+
from terp.core._internal.session_guard import enter_write_unit # arch-allow-no-internal-imports: durable delivery infra must ride the audited write unit to append atomically with the business write; the scope primitive is _internal so app modules cannot open it
|
|
41
|
+
|
|
42
|
+
from terp.capabilities.outbox.models import STATUS_PENDING, OutboxMessage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def append(session: Session, message: OutboxMessage) -> OutboxMessage:
|
|
46
|
+
"""Append *message* to the outbox inside the caller's audited write unit.
|
|
47
|
+
|
|
48
|
+
Rides :func:`~terp.core._internal.session_guard.enter_write_unit` so the INSERT
|
|
49
|
+
joins whatever transaction is open on *session*: enqueued from within a
|
|
50
|
+
``BaseService`` write (the common case), the row commits atomically with the
|
|
51
|
+
business mutation and a rollback drops both; enqueued standalone, it is its own
|
|
52
|
+
outermost, committed unit. Returns *message* (its id is assigned at construction).
|
|
53
|
+
"""
|
|
54
|
+
message.assert_within_column_bounds()
|
|
55
|
+
with enter_write_unit() as outermost:
|
|
56
|
+
session.add(message) # arch-allow-mutations-emit-audit: append-only delivery infra at the base of the write stack (like the audit sink); OutboxMessage is not a BaseTable, so it cannot route through BaseService
|
|
57
|
+
if outermost:
|
|
58
|
+
session.commit() # arch-allow-mutations-emit-audit: a standalone enqueue is its own durable unit; a nested enqueue defers to the outer BaseService commit
|
|
59
|
+
return message
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def claim_due(
|
|
63
|
+
session: Session,
|
|
64
|
+
*,
|
|
65
|
+
claim_id: str,
|
|
66
|
+
now: datetime,
|
|
67
|
+
lease_until: datetime,
|
|
68
|
+
limit: int,
|
|
69
|
+
skip_locked: bool = False,
|
|
70
|
+
) -> list[OutboxMessage]:
|
|
71
|
+
"""Atomically lease up to *limit* due, unlocked rows to *claim_id*; return them.
|
|
72
|
+
|
|
73
|
+
A single UPDATE marks the due rows (``pending``, ``available_at`` reached, lease
|
|
74
|
+
free or expired) with this worker's unique *claim_id* and *lease_until*, so two
|
|
75
|
+
workers never grab the same row — portable across SQLite and PostgreSQL, with
|
|
76
|
+
``SELECT ... FOR UPDATE SKIP LOCKED`` added on a backend that supports it
|
|
77
|
+
(*skip_locked*; SQLite silently ignores the clause, the atomic UPDATE still
|
|
78
|
+
serialises writers). The follow-up SELECT returns exactly the rows this claim won
|
|
79
|
+
(its *claim_id* is unique per cycle). A crashed worker's rows are reclaimed once
|
|
80
|
+
``lease_until`` passes (the ``locked_until < now`` branch) — at-least-once.
|
|
81
|
+
"""
|
|
82
|
+
due = (
|
|
83
|
+
select(OutboxMessage.id)
|
|
84
|
+
.where(
|
|
85
|
+
col(OutboxMessage.status) == STATUS_PENDING,
|
|
86
|
+
col(OutboxMessage.available_at) <= now,
|
|
87
|
+
or_(
|
|
88
|
+
col(OutboxMessage.locked_until).is_(None),
|
|
89
|
+
col(OutboxMessage.locked_until) < now,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
.order_by(col(OutboxMessage.available_at), col(OutboxMessage.id))
|
|
93
|
+
.limit(limit)
|
|
94
|
+
)
|
|
95
|
+
if skip_locked:
|
|
96
|
+
due = due.with_for_update(skip_locked=True)
|
|
97
|
+
with enter_write_unit() as outermost:
|
|
98
|
+
session.execute( # arch-allow-mutations-emit-audit: the atomic lease claim on the outbox's own table — a portable SKIP-LOCKED-style lock, not a business mutation
|
|
99
|
+
update(OutboxMessage)
|
|
100
|
+
.where(col(OutboxMessage.id).in_(due))
|
|
101
|
+
.values(locked_by=claim_id, locked_until=lease_until)
|
|
102
|
+
)
|
|
103
|
+
if outermost:
|
|
104
|
+
session.commit() # arch-allow-mutations-emit-audit: commit the lease so concurrent workers observe it
|
|
105
|
+
return list(
|
|
106
|
+
session.exec(
|
|
107
|
+
select(OutboxMessage)
|
|
108
|
+
.where(col(OutboxMessage.locked_by) == claim_id)
|
|
109
|
+
.order_by(col(OutboxMessage.available_at), col(OutboxMessage.id))
|
|
110
|
+
).all()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def finalize(session: Session) -> None:
|
|
115
|
+
"""Commit a worker's in-place status transition on the outbox's own table."""
|
|
116
|
+
with enter_write_unit():
|
|
117
|
+
session.commit() # arch-allow-mutations-emit-audit: persist the worker's status transition (dispatched / rescheduled / dead-lettered)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["append", "claim_due", "finalize"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""The leased, retrying outbox worker — the consumer that drains pending rows.
|
|
2
|
+
|
|
3
|
+
``terp jobs worker`` (or a worker-container entrypoint) runs this loop. Each cycle it
|
|
4
|
+
**claims** a batch of due rows with a lease (so N workers drain one outbox without
|
|
5
|
+
double-dispatch), **executes** each — a job through the context-binding
|
|
6
|
+
:func:`~terp.core._internal.job_runtime.run_job` (so its writes stay audited +
|
|
7
|
+
actor / tenant stamped, the jobs design's §7), an event through the in-process
|
|
8
|
+
handlers — and records the outcome: ``dispatched`` on success, a **retry** with
|
|
9
|
+
exponential backoff, or ``dead_lettered`` once the job's
|
|
10
|
+
:class:`~terp.core.RetryPolicy` ``max_attempts`` is exhausted.
|
|
11
|
+
|
|
12
|
+
Delivery is **at-least-once**: a worker that crashes mid-flight loses its lease at
|
|
13
|
+
``locked_until``, and another reclaims and re-runs the row — so a handler must be
|
|
14
|
+
idempotent (its ``idempotency_key`` + the business unique keys). To stay correct on
|
|
15
|
+
SQLite (where a held read transaction blocks a writer on another connection), the
|
|
16
|
+
worker **claims a batch and detaches it**, then executes and finalizes each row in its
|
|
17
|
+
own short transaction — so a long-running job never keeps the bookkeeping connection's
|
|
18
|
+
lock open, and each job runs in its **own** audited unit opened by ``run_job``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
from dataclasses import dataclass, replace
|
|
26
|
+
from datetime import UTC, datetime, timedelta
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from sqlmodel import Session
|
|
30
|
+
|
|
31
|
+
from terp.core import EventEnvelope, RetryPolicy
|
|
32
|
+
from terp.core.jobs import active_job_catalog
|
|
33
|
+
|
|
34
|
+
# run_job is the kernel's context-binding executor; only a JobQueue / the durable
|
|
35
|
+
# worker drives it (it stays _internal so an app module reaches background work solely
|
|
36
|
+
# through the typed enqueue chokepoint, never by running a job directly).
|
|
37
|
+
from terp.core._internal.job_runtime import run_job # arch-allow-no-internal-imports: the durable worker is the consumer half of the jobs seam; run_job is the kernel's context-binding executor, kept _internal so app modules cannot run jobs directly
|
|
38
|
+
|
|
39
|
+
from terp.capabilities.outbox._serde import (
|
|
40
|
+
payload_to_event_envelope,
|
|
41
|
+
payload_to_job_envelope,
|
|
42
|
+
)
|
|
43
|
+
from terp.capabilities.outbox.models import (
|
|
44
|
+
KIND_JOB,
|
|
45
|
+
STATUS_DEAD_LETTERED,
|
|
46
|
+
STATUS_DISPATCHED,
|
|
47
|
+
STATUS_PENDING,
|
|
48
|
+
OutboxMessage,
|
|
49
|
+
)
|
|
50
|
+
from terp.capabilities.outbox.store import claim_due, finalize
|
|
51
|
+
|
|
52
|
+
_ERROR_MAX = 2000
|
|
53
|
+
|
|
54
|
+
# Outcome of a finalize whose lease was lost mid-execution: the job outran its lease and
|
|
55
|
+
# another worker reclaimed + finalized the row, so this (stale) worker discards its result
|
|
56
|
+
# rather than clobbering the new owner. Re-delivery already happened (at-least-once); this
|
|
57
|
+
# only protects the bookkeeping. Distinct from every STATUS_* value.
|
|
58
|
+
_LOST = "lost"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _utc_now() -> datetime:
|
|
62
|
+
"""UTC ``now`` provider (the worker's default clock; injectable for tests)."""
|
|
63
|
+
return datetime.now(UTC)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clip(value: str) -> str:
|
|
67
|
+
"""Strip NUL bytes and clamp an error message to the ``last_error`` column bound."""
|
|
68
|
+
return value.replace("\x00", "")[:_ERROR_MAX]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _backoff_seconds(attempts: int, retry: RetryPolicy) -> float:
|
|
72
|
+
"""Exponential backoff for the *attempts*-th failure, capped at the policy max."""
|
|
73
|
+
delay = retry.backoff_seconds * (retry.backoff_multiplier ** (attempts - 1))
|
|
74
|
+
return min(delay, retry.max_backoff_seconds)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def deliver_event_in_process(envelope: EventEnvelope) -> None:
|
|
78
|
+
"""Deliver *envelope* to every in-process handler subscribed to its event.
|
|
79
|
+
|
|
80
|
+
The worker's default terminal delivery for a ``kind="event"`` row: it fans the
|
|
81
|
+
event out to the eventbus capability's handlers (the **real** in-process dispatch,
|
|
82
|
+
never the active dispatcher — which is the outbox itself). An event with no
|
|
83
|
+
subscribers is a no-op success. If the eventbus capability is not installed the
|
|
84
|
+
import fails and the row retries / dead-letters — you should not produce events
|
|
85
|
+
without a consumer.
|
|
86
|
+
"""
|
|
87
|
+
from terp.capabilities.eventbus.registry import handlers_for
|
|
88
|
+
|
|
89
|
+
for handler in handlers_for(envelope.name):
|
|
90
|
+
handler(envelope)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class _Claimed:
|
|
95
|
+
"""A detached snapshot of one claimed row: enough to execute and finalize it.
|
|
96
|
+
|
|
97
|
+
Captured before the claim session closes, so the long-running execution never
|
|
98
|
+
holds the bookkeeping connection's transaction open (SQLite-safe). Carries the
|
|
99
|
+
cycle's *claim_id* so finalize can verify this worker still holds the lease.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
id: uuid.UUID
|
|
103
|
+
kind: str
|
|
104
|
+
name: str
|
|
105
|
+
payload: Mapping[str, Any]
|
|
106
|
+
attempts: int
|
|
107
|
+
claim_id: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class DrainResult:
|
|
112
|
+
"""The tally of one or more drain cycles (for the CLI loop and tests)."""
|
|
113
|
+
|
|
114
|
+
claimed: int = 0
|
|
115
|
+
dispatched: int = 0
|
|
116
|
+
retried: int = 0
|
|
117
|
+
dead_lettered: int = 0
|
|
118
|
+
lost: int = 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OutboxWorker:
|
|
122
|
+
"""Claim, execute, and finalize due outbox rows; retry with backoff; dead-letter.
|
|
123
|
+
|
|
124
|
+
Construct it with a *session_factory* for its own plain bookkeeping session (the
|
|
125
|
+
outbox is infrastructure, not an audited business write) and an optional
|
|
126
|
+
*job_session_factory* passed to :func:`run_job` for each job's own audited unit.
|
|
127
|
+
The clock / lease / batch size / retry budget / SKIP-LOCKED flag are injectable for
|
|
128
|
+
tests and for tuning a deployment.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
session_factory: Callable[[], Session],
|
|
134
|
+
*,
|
|
135
|
+
job_session_factory: Callable[[], Session] | None = None,
|
|
136
|
+
event_dispatcher: Callable[[EventEnvelope], None] = deliver_event_in_process,
|
|
137
|
+
worker_id: str | None = None,
|
|
138
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
139
|
+
lease_seconds: float = 30.0,
|
|
140
|
+
batch_size: int = 10,
|
|
141
|
+
retry: RetryPolicy | None = None,
|
|
142
|
+
skip_locked: bool = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._session_factory = session_factory
|
|
145
|
+
self._job_session_factory = job_session_factory
|
|
146
|
+
self._event_dispatcher = event_dispatcher
|
|
147
|
+
self._worker_id = worker_id or f"outbox-worker-{uuid.uuid4().hex[:8]}"
|
|
148
|
+
self._clock = clock
|
|
149
|
+
self._lease_seconds = lease_seconds
|
|
150
|
+
self._batch_size = batch_size
|
|
151
|
+
self._retry = retry or RetryPolicy()
|
|
152
|
+
self._skip_locked = skip_locked
|
|
153
|
+
|
|
154
|
+
def drain_once(self) -> DrainResult:
|
|
155
|
+
"""Claim one batch of due rows, process each, and return the tally."""
|
|
156
|
+
claimed = self._claim_batch()
|
|
157
|
+
dispatched = retried = dead_lettered = lost = 0
|
|
158
|
+
for message in claimed:
|
|
159
|
+
outcome = self._process(message)
|
|
160
|
+
if outcome == STATUS_DISPATCHED:
|
|
161
|
+
dispatched += 1
|
|
162
|
+
elif outcome == STATUS_DEAD_LETTERED:
|
|
163
|
+
dead_lettered += 1
|
|
164
|
+
elif outcome == _LOST:
|
|
165
|
+
lost += 1
|
|
166
|
+
else:
|
|
167
|
+
retried += 1
|
|
168
|
+
return DrainResult(
|
|
169
|
+
claimed=len(claimed),
|
|
170
|
+
dispatched=dispatched,
|
|
171
|
+
retried=retried,
|
|
172
|
+
dead_lettered=dead_lettered,
|
|
173
|
+
lost=lost,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def run(self, *, max_cycles: int | None = None) -> DrainResult:
|
|
177
|
+
"""Drain repeatedly until no rows remain (or *max_cycles* is reached); return totals.
|
|
178
|
+
|
|
179
|
+
The worker-container entrypoint runs with ``max_cycles=None``, draining batches
|
|
180
|
+
until the outbox holds no *due* row, then returns — a scheduler / supervisor
|
|
181
|
+
re-invokes it (the design's external-trigger model; a rescheduled row waits out
|
|
182
|
+
its backoff). Tests pass a finite *max_cycles*.
|
|
183
|
+
"""
|
|
184
|
+
totals = DrainResult()
|
|
185
|
+
cycles = 0
|
|
186
|
+
while max_cycles is None or cycles < max_cycles:
|
|
187
|
+
result = self.drain_once()
|
|
188
|
+
totals = DrainResult(
|
|
189
|
+
claimed=totals.claimed + result.claimed,
|
|
190
|
+
dispatched=totals.dispatched + result.dispatched,
|
|
191
|
+
retried=totals.retried + result.retried,
|
|
192
|
+
dead_lettered=totals.dead_lettered + result.dead_lettered,
|
|
193
|
+
lost=totals.lost + result.lost,
|
|
194
|
+
)
|
|
195
|
+
cycles += 1
|
|
196
|
+
if result.claimed == 0:
|
|
197
|
+
break
|
|
198
|
+
return totals
|
|
199
|
+
|
|
200
|
+
def _claim_batch(self) -> list[_Claimed]:
|
|
201
|
+
"""Lease a batch and snapshot it, then close the session (release the lock)."""
|
|
202
|
+
now = self._clock()
|
|
203
|
+
claim_id = f"{self._worker_id}:{uuid.uuid4().hex}"
|
|
204
|
+
lease_until = now + timedelta(seconds=self._lease_seconds)
|
|
205
|
+
with self._session_factory() as session:
|
|
206
|
+
rows = claim_due(
|
|
207
|
+
session,
|
|
208
|
+
claim_id=claim_id,
|
|
209
|
+
now=now,
|
|
210
|
+
lease_until=lease_until,
|
|
211
|
+
limit=self._batch_size,
|
|
212
|
+
skip_locked=self._skip_locked,
|
|
213
|
+
)
|
|
214
|
+
return [
|
|
215
|
+
_Claimed(
|
|
216
|
+
id=row.id,
|
|
217
|
+
kind=row.kind,
|
|
218
|
+
name=row.name,
|
|
219
|
+
payload=dict(row.payload),
|
|
220
|
+
attempts=row.attempts,
|
|
221
|
+
claim_id=claim_id,
|
|
222
|
+
)
|
|
223
|
+
for row in rows
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
def _process(self, message: _Claimed) -> str:
|
|
227
|
+
"""Execute one claimed *message*, then finalize its status; return that status."""
|
|
228
|
+
try:
|
|
229
|
+
self._execute(message)
|
|
230
|
+
except Exception as exc: # noqa: BLE001 - any handler failure becomes a retry / dead-letter
|
|
231
|
+
return self._finalize_failure(message, exc)
|
|
232
|
+
return self._finalize_success(message)
|
|
233
|
+
|
|
234
|
+
def _execute(self, message: _Claimed) -> None:
|
|
235
|
+
"""Run the message: a job through :func:`run_job`, an event through the dispatcher."""
|
|
236
|
+
if message.kind == KIND_JOB:
|
|
237
|
+
envelope = payload_to_job_envelope(message.payload)
|
|
238
|
+
run_job(
|
|
239
|
+
replace(envelope, attempt=message.attempts + 1),
|
|
240
|
+
session_factory=self._job_session_factory,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
self._event_dispatcher(payload_to_event_envelope(message.payload))
|
|
244
|
+
|
|
245
|
+
def _finalize_success(self, message: _Claimed) -> str:
|
|
246
|
+
"""Mark *message* dispatched (only while this worker still holds the lease)."""
|
|
247
|
+
|
|
248
|
+
def _mutate(row: OutboxMessage) -> str:
|
|
249
|
+
row.status = STATUS_DISPATCHED
|
|
250
|
+
row.dispatched_at = self._clock()
|
|
251
|
+
return STATUS_DISPATCHED
|
|
252
|
+
|
|
253
|
+
return self._finalize(message, _mutate)
|
|
254
|
+
|
|
255
|
+
def _finalize_failure(self, message: _Claimed, exc: Exception) -> str:
|
|
256
|
+
"""Retry *message* with backoff, or dead-letter it once its budget is spent."""
|
|
257
|
+
retry = self._retry_for(message)
|
|
258
|
+
|
|
259
|
+
def _mutate(row: OutboxMessage) -> str:
|
|
260
|
+
row.attempts += 1
|
|
261
|
+
row.last_error = _clip(f"{type(exc).__name__}: {exc}")
|
|
262
|
+
if row.attempts >= retry.max_attempts:
|
|
263
|
+
row.status = STATUS_DEAD_LETTERED
|
|
264
|
+
row.dead_lettered_at = self._clock()
|
|
265
|
+
return STATUS_DEAD_LETTERED
|
|
266
|
+
row.status = STATUS_PENDING
|
|
267
|
+
row.available_at = self._clock() + timedelta(
|
|
268
|
+
seconds=_backoff_seconds(row.attempts, retry)
|
|
269
|
+
)
|
|
270
|
+
return STATUS_PENDING
|
|
271
|
+
|
|
272
|
+
return self._finalize(message, _mutate)
|
|
273
|
+
|
|
274
|
+
def _finalize(
|
|
275
|
+
self, message: _Claimed, mutate: Callable[[OutboxMessage], str]
|
|
276
|
+
) -> str:
|
|
277
|
+
"""Persist a status transition — but only while this worker still holds the lease.
|
|
278
|
+
|
|
279
|
+
A job that outran ``lease_seconds`` may have been reclaimed and finalized by
|
|
280
|
+
another worker; persisting this now-stale worker's outcome would clobber the new
|
|
281
|
+
owner — resurrecting a ``dispatched`` row into ``pending`` (a redundant
|
|
282
|
+
re-dispatch, potentially looping for a consistently slow job) or flipping it to
|
|
283
|
+
``dead_lettered``, and releasing a lease another worker still holds. So the
|
|
284
|
+
transition is guarded: if ``locked_by`` no longer matches this claim, the worker
|
|
285
|
+
**discards** its outcome (:data:`_LOST`) and the new owner wins (a row that has
|
|
286
|
+
since vanished — e.g. a retention purge between claim and finalize — is likewise
|
|
287
|
+
treated as :data:`_LOST` rather than crashing the cycle). Re-*delivery* already
|
|
288
|
+
happened (at-least-once); this protects only the bookkeeping. Tune
|
|
289
|
+
``lease_seconds`` above the longest expected job to avoid the redundant work.
|
|
290
|
+
"""
|
|
291
|
+
with self._session_factory() as session:
|
|
292
|
+
row = session.get(OutboxMessage, message.id)
|
|
293
|
+
if row is None or row.locked_by != message.claim_id:
|
|
294
|
+
return _LOST
|
|
295
|
+
outcome = mutate(row)
|
|
296
|
+
row.locked_by = None
|
|
297
|
+
row.locked_until = None
|
|
298
|
+
finalize(session)
|
|
299
|
+
return outcome
|
|
300
|
+
|
|
301
|
+
def _retry_for(self, message: _Claimed) -> RetryPolicy:
|
|
302
|
+
"""The retry budget governing *message*: the job's own, or the worker default."""
|
|
303
|
+
if message.kind == KIND_JOB:
|
|
304
|
+
job = active_job_catalog().get(message.name)
|
|
305
|
+
if job is not None:
|
|
306
|
+
return job.retry
|
|
307
|
+
return self._retry
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = ["DrainResult", "OutboxWorker", "deliver_event_in_process"]
|