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.
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-outbox
3
+ Version: 0.1.0
4
+ Summary: Terp durable outbox — transactional, leased, retrying post-commit delivery for jobs and events.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,5 @@
1
+ {
2
+ "arch-allow-mutations-emit-audit": 5,
3
+ "arch-allow-no-internal-imports": 2,
4
+ "arch-allow-table-models-use-base-table": 1
5
+ }
@@ -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
+ ]
@@ -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"]