ADR-029: Transactional follow-up jobs for durable lifecycle effects¶
Status¶
Accepted. Worker-driven outcomes and callback resolution via the worker Client commit follow-ups atomically with the state transition; maintenance rescue dispatches follow-ups best-effort in a separate transaction (see Atomicity matrix).
Context¶
Awa exposes two ways for application code to react to a job's lifecycle:
- Builder-side lifecycle hooks (ADR-015). Closures registered on
ClientBuilder::on_eventfire after the state commit, in-process, best-effort. They are explicitly not durable: a process crash between the state commit and the hook dispatch loses the event. ADR-015 states the guidance: "If a hook side effect must be durable or retried, that logic should enqueue another job instead." That guidance lives in prose, not in the API. - Tracing spans and structured logs (PRD §15). Useful for observability, but not a delivery mechanism for side effects.
Two adjacent design tracks make the gap acute:
- Callback ingress as a separate surface (ADR-027, proposed). A callback-only receiver has no
Clientand no in-process hook registry, so resolving a callback there fires no hook. ADR-027 punts: "If callback completion should produce lifecycle notifications beyond the storage transition itself, that mechanism needs to be durable or otherwise runtime-independent." - Maintenance-only runtime role (ADR-028, proposed). Rescue paths (expired callback, stale heartbeat, exceeded deadline) commit state transitions from the maintenance worker, with no notion of a per-kind hook registry. Today an attempt that times out emits a
Startedand then goes silent; the rescue itself produces no lifecycle event.
Users routinely ask for "reliable events" for things like "send a welcome email when this signup completes." They reach for hooks — the only event surface that exists — and ship code that quietly drops events on a deploy or when the resolving process changes. The naming and ergonomics of the current hooks API encourage this mistake.
Two distinct needs are being conflated:
| Need | Examples | Acceptable loss | Latency budget |
|---|---|---|---|
| Observation | metrics, traces, logs, alerting | low single-digit % | sub-second |
| Side effects | send email, kick off workflow, persist record | zero | seconds to minutes |
A single mechanism cannot honestly serve both.
Decision¶
Treat observation and side effects as separate mechanisms with separate APIs. Keep ADR-015's in-process hooks unchanged for observation. Add a first-class transactional follow-up enqueue API for durable side effects.
Principle¶
A side effect that must survive process crash, deployment, or a split deployment topology (per ADR-027) is delivered as an Awa job. For worker-driven outcomes and callback resolution through the worker Client, follow-up INSERTs run in the same database transaction as the triggering state transition — both commit together or both roll back. Maintenance rescue dispatches its follow-ups in a separate transaction (best-effort, see Atomicity matrix) because the rescue transition has already committed by the time the dispatcher sees it. Either way the follow-up inherits Awa's existing durability properties: at-least-once delivery, retries, dead-letter (ADR-020), DLQ replay, observability through the admin UI, and crash recovery via run-lease guarded finalization (ADR-013).
Builder API¶
ClientBuilder gains transactional enqueue counterparts to on_event. The naming mirrors the existing hook surface so the choice between observation and side effect is explicit at the registration site:
Client::builder(pool)
.register::<Signup, _, _>(handle_signup)
// Observation — best-effort, in-process, no delivery guarantee.
.on_event::<Signup, _, _>(|event| async move { metrics::record(event) })
// Durable side effect — committed atomically with the Signup outcome.
.on_completed_enqueue::<Signup, SendWelcomeEmail, _>(|signup_args, _job| {
SendWelcomeEmail { user_id: signup_args.user_id }
})
.on_exhausted_enqueue::<Signup, NotifyOps, _>(|signup_args, job| {
NotifyOps {
user_id: signup_args.user_id,
failure: job.errors.clone(),
}
});
The closure returns a follow-up JobArgs value (default InsertOpts) or an EnqueueRequest<F> with InsertOpts overrides. For worker-driven outcomes and callback resolution via the worker Client, the engine INSERTs the follow-up in the same transaction as the triggering state change; the follow-up commits with the trigger or rolls back with it (a spec INSERT failure or a panic in the user-supplied closure rolls the trigger transition back as well). For maintenance rescue the engine INSERTs the follow-up in a separate transaction opened after the rescue commits (see Atomicity matrix). In either case, once the follow-up INSERT commits the row is durably visible and rides Awa's existing retry / DLQ machinery.
Counterparts cover the outcomes that benefit most from durable delivery: on_completed_enqueue, on_retried_enqueue, on_exhausted_enqueue, on_cancelled_enqueue, on_waiting_for_callback_enqueue, and on_rescued_enqueue. Started is intentionally excluded — claim-time enqueue would join the dispatcher's hot path and the use case for "job started" is observation, which on_event already covers. Snooze continues to emit nothing on either surface.
Where the enqueue runs¶
The enqueue is performed by whatever process performs the state transition:
| Transition | Performed by | Follow-up enqueued by | Atomic? |
|---|---|---|---|
| Inline outcomes (Completed, Retried, Exhausted, Cancelled, WaitingForCallback) | Worker executor | Worker executor, in the finalization transaction | yes |
Callback resolution via worker Client |
The resolving process | That process, in the resolution transaction (via admin::*_external_in_tx / store::*_external_in_tx) |
yes |
| Callback resolution via callback-only ingress (ADR-027) | Callback receiver | Pending: callback-only ingress does not yet own a worker registry. When that surface lands it can reuse the same _in_tx admin / store helpers used by the worker Client. |
pending |
| Caller-owned completion (ADR-042) | Handler-owned Postgres transaction | The handler explicitly inserts any follow-up/outbox work before guarded completion | yes — registered process-local Completed specs are rejected for this kind in v1 |
| Expired-callback / stale-heartbeat / deadline rescue (ADR-028) | Maintenance runtime | Maintenance runtime, in a separate transaction after the rescue commits | no — best-effort |
Why rescue stays best-effort: a panic in a user-supplied on_rescued_enqueue closure would otherwise roll the rescue UPDATE back, coupling rescue liveness (the maintenance loop's job recovery guarantee) to the correctness of user follow-up code. A zero-loss rescue-notification story is better served by an outbox/sweeper than by inlining user code into the rescue tx. The current best-effort behaviour means the trigger has already committed, so the worst case is a missing follow-up — not a phantom follow-up. See Atomicity matrix for the call sites involved.
Registry lives in process — for now¶
The mapping from "trigger kind + outcome" to "make follow-up args" is a Rust closure (or a Python callable, when the Python API gains parity). Closures cannot be portably stored in the database, so each process that performs a state transition must have the spec registered locally:
- A worker process: registers via its own
ClientBuilder. - A callback-only ingress process (ADR-027): registers via the callback router's config (planned in #279).
- A maintenance-only process (ADR-028): registers via its own
ClientBuilderin the library form, or via a future config file for the pool-only CLI form.
In normal deployments every process is built from the same code, so the registries agree. When they disagree — a worker on a new build registers a new follow-up that an older callback-receiver doesn't know about — the emitter that doesn't know the spec simply doesn't enqueue; the trigger still commits. This is the same drift behavior as in-process hooks today and is acceptable for v1.
A future ADR may introduce a database-stored registry (mapping trigger → follow-up kind, with the args transformation expressed declaratively — for example, JSONata-style mapping or a CEL expression like ADR-021's callback filters use). That option is deferred until the in-process surface is in use and the missing mapping in another process becomes a real operational problem.
NOTIFY is a wakeup hint, not the delivery channel¶
Postgres LISTEN / NOTIFY is suitable for low-latency wakeup but not for delivery: payloads are capped, undelivered notifications are dropped, and a process not currently listening misses the event. The follow-up enqueue already provides durability; pairing it with a pg_notify of the follow-up job's queue (in the same transaction) is a useful latency optimisation — listening workers can claim immediately rather than waiting for the next poll interval — but the notification carries no payload and no event semantics. It is purely a wakeup signal layered on top of a durable INSERT.
Boundary with hooks¶
The two APIs are deliberately separate and orthogonal:
- A user may register
on_eventandon_*_enqueuefor the same trigger. Both fire; the hook is observation, the enqueue is delivery. - An
on_*_enqueueregistration on a process that does not perform the relevant state transition is dormant; the engine has nothing to insert for that process. This is consistent with the registry being per-process. - Snooze and rescue produce no hooks today; only the enqueue path covers them. This is intentional — rescues happen in the maintenance process, which has no user handler registry, but can still insert a follow-up job from its transaction. Cancellation has both:
Cancelledis a hook event in ADR-015 and gains anon_cancelled_enqueuecounterpart here.
docs/lifecycle-hooks.md describes the hook path. The follow-up enqueue path gets its own section that names the trade-off explicitly: pick the API whose guarantees match the side effect.
What this is not¶
- Not a generic event bus. The mechanism is one Awa job per follow-up registration per transition. Fan-out across application components happens by registering multiple follow-ups, each its own job.
- Not a replacement for tracing spans or logs (PRD §15). Those continue to describe execution; this describes deliberate downstream work.
- Not a CDC stream. WAL-level change capture is out of scope (alternative E below).
- Not a sub-millisecond reactor. Follow-up latency is bounded by claim cadence (helped, but not eliminated, by the NOTIFY-as-wakeup hint).
Consequences¶
Positive¶
- A single durable mechanism covers every state transition Awa makes, across every deployment role ADR-027 and ADR-028 introduce: inline outcomes, callback resolution (worker-
Clientor callback-only), rescue, and any future transition. - The rescue / timeout event gap closes: a rescue can dispatch
job_failed_rescue(or any user-defined follow-up) — best-effort, in a separate transaction after the rescue commits, but the follow-up itself is then a durable Awa job with full retry / DLQ semantics. Closing this gap fully atomically is tracked as an open extension. - The "what should be in a hook?" mistake disappears at the API level — observation and delivery have different names and obviously different guarantees.
- No new event log table, no new subscriber protocol, no separate retention policy. The follow-up is a job, with all of Awa's existing operator and developer affordances.
- Composes with the bridge adapters (ADR-016, ADR-017): a user resolving a callback from their own application can pass their own transaction to the resolution path, and the follow-up enqueues in their transaction with the app rows.
Negative / Risks¶
- The cost of a "durable event" equals the cost of an Awa job: a row in storage, a claim cycle, a worker pass. That is correctly more expensive than a hook. Users who reach for the durable path for what is really observation will pay that cost without benefit; docs and naming have to make the cheaper option obvious for metrics-style use cases.
- Latency for downstream work is bounded by Awa's poll cadence rather than by an in-process callback. The NOTIFY-as-wakeup hint reduces but does not eliminate this.
- More API surface to teach. The split into two APIs is the design's whole point, but the difference between
on_eventandon_*_enqueuemust be unmistakable in docs and examples; otherwise users will pick the wrong one and observe one of the two failure modes (lost reliable side effect or expensive metrics counter). - Cross-process registry drift is possible. The in-process registry choice trades portability for simplicity; future work may have to harden it with a database-stored spec if real deployments hit drift.
- The maintenance runtime now has a way to insert user-defined jobs from its rescue transactions. That is a deliberate broadening of what maintenance writes and must be documented alongside ADR-028.
Alternatives considered¶
A. Stay with in-process hooks only¶
Status quo. Loses on every cross-process and crash scenario above. The prose-only guidance from ADR-015 does not survive contact with real deployments where the resolver process is different from the worker process. Rejected as the long-term answer.
B. Postgres NOTIFY / LISTEN fan-out as the event channel¶
Tempting because awa:cancel is precedent. NOTIFY is at-most-once delivery to currently-connected listeners, with an ~8 KB payload cap, and dropped notifications are not replayed. It is a viable wakeup primitive but cannot provide durability on its own. Using NOTIFY as the substrate would re-introduce the same losses this ADR exists to fix; using it as a wakeup hint on top of a durable enqueue is the right role.
C. Dedicated awa.events outbox table¶
A standard transactional-outbox pattern. Considered seriously. The decision weighed:
- A dedicated events table is durable and replayable and supports cursor- based subscribers.
- But Awa is already a durable, retry-capable, dead-letter-aware queue. Building an events log next to it duplicates the engine: a new subscriber protocol, cursor management, retention policy, admin UI surface, and test matrix.
- "Queryable event history" — the main argument for an events table — is addressable with tagged follow-up jobs and the existing completed/failed history (ADR-026).
- The follow-up-job pattern is composable: a follow-up may itself emit follow-ups, may fan out across kinds, and is observable in the existing UI.
Rejected because the engine for it already exists; building another would trade familiarity for parallel infrastructure.
D. (Chosen) Transactional follow-up jobs¶
Described above. Reuses Awa's existing primitives; needs only an API on ClientBuilder and an enqueue hook in each state-transition path.
E. Postgres logical decoding / WAL CDC¶
Powerful and durable but heavy infra (logical replication slot, Debezium-class consumer), inconsistent support across managed Postgres providers, and contrary to Awa's "self-contained queue in Postgres" positioning (ADR-001). Out of scope.
Relationship to other ADRs¶
- ADR-015 (lifecycle hooks). This ADR codifies ADR-015's prose advice ("enqueue another job") into a first-class API. ADR-015's hooks remain the correct mechanism for observation; this ADR adds the mechanism for delivery.
- ADR-027 (callback ingress, proposed). Addresses the open question ADR-027 punts on: durable callback notifications. The worker
Clientdrives resolution + follow-up dispatch in a single transaction viaadmin::*_external_in_tx. A callback-only ingress process built per ADR-027 will need its own registry hookup to reuse the same_in_txadmin path; until that hookup lands, callback ingress outside the workerClientis a separate transition with no follow-up dispatch. See the "Open extensions" section below for the current implementation boundary. - ADR-028 (maintenance-only runtime, proposed). Gives rescue paths a way to dispatch durable lifecycle work without owning a handler registry. The dispatch is best-effort (separate transaction); the follow-up itself is a regular Awa job once enqueued.
- ADR-013 (run-lease, guarded finalization). For worker-driven outcomes the follow-up enqueue joins the same transaction as the guarded-finalization UPDATE; if the UPDATE matches zero rows (stale outcome), the transaction rolls back and the follow-up is not enqueued, preserving the at-most-once finalization contract.
- ADR-020 (DLQ). Follow-up jobs participate in the DLQ family unchanged; a side effect that exhausts retries lands in the DLQ with full args and history.
- ADR-021 (sequential callbacks, callback heartbeats). The callback resolution surface is unchanged; resolution through the worker
Clientdispatches follow-ups in the same transaction, while bare storage/admin callers have no process-local registry to dispatch. - ADR-006 (insert-only transaction bridge) and ADR-016/017 (Postgres adapter / Python transaction bridge). The same transactional insert primitive is reused; nothing new is added to the bridge contract.
- ADR-042 (caller-owned finalization). A caller-owned completion cannot evaluate a process-local
on_completed_enqueueclosure from the generic SQL function. Its v1 contract therefore rejects combining registered Completed specs and caller-owned completion for the same kind; the handler inserts explicit follow-up/outbox work in its transaction instead.
Implementation¶
- Outcome surface.
Outcome::{Completed, Retried, Exhausted, Cancelled, WaitingForCallback, Rescued}.Startedis intentionally excluded — claim-time enqueue would join the dispatcher's hot path, and the durable-side-effect use case for "job started" is uncommon. Observation belongs toon_event.on_started_enqueueis not blocked by anything in this design; whether to add it is left as an open question rather than a v1 commitment. - Builder API. Each outcome has a pair of builders:
on_<outcome>_enqueue(closure returnsJobArgs) andon_<outcome>_enqueue_with(closure returnsEnqueueRequest<F>forInsertOptsoverrides — queue, priority, max_attempts, metadata, tags, unique, run_at, deadline_duration, ordering_key). - Closure signatures carry the per-outcome context: Completed/WaitingForCallback:
(args, &job_row); Cancelled:(args, &job_row, &reason); Retried:(args, &job_row, &error, attempt, next_run_at); Exhausted:(args, &job_row, &error, attempt); Rescued:(args, &job_row, RescueReason). - Rescued event variant. Maintenance rescues fire
JobEvent::Rescued { args, job, reason: RescueReason }alongside the follow-up spec dispatch, keeping rescue context distinct from Retried/Exhausted rather than overloading either.
Atomicity matrix¶
| Emission site | Atomic with state commit? |
|---|---|
| Worker-driven outcomes on canonical storage | yes — *_canonical_with_followups helpers open one tx, run the state UPDATE (UPDATE...RETURNING on awa.jobs_hot; for retryable the DELETE+INSERT move into awa.scheduled_jobs is in a CTE), dispatch specs, commit |
| Worker-driven outcomes on queue storage | yes — *_queue_storage_with_followups helpers run inside complete_runtime_batch_slow_in_tx / cancel_running_in_tx / fail_*_in_tx / retry_*_in_tx / enter_callback_wait_in_tx |
Callback resolution on Client (complete_external, fail_external, retry_external, resolve_callback) on either storage engine |
yes — Client::*_external opens one tx, drives the transition via admin::*_external_in_tx / store::*_external_in_tx, dispatches specs via dispatch_specs_in_tx, commits. A spec failure rolls the resolution back so the external sender can retry. |
| Caller-owned completion (ADR-042) | yes — explicit application/outbox/follow-up writes and the guarded completion share the caller's transaction; process-local registered Completed specs are incompatible with that kind in v1 |
| Maintenance rescue (stale heartbeat, deadline exceeded, expired callback) | no — best-effort. Spec dispatch runs in a separate tx after the rescue commits. A spec failure leaves the rescue applied and is logged. Kept best-effort by design (see Open extensions). |
Worker-driven outcomes inherit ADR-013's run-lease guard (zero rows matched → tx rollback → no follow-up); callback resolution inherits a matching guard from the SELECT ... FOR UPDATE lookup on awa.jobs_hot or FOR UPDATE OF lease on the queue-storage leases table. The trigger transition and the follow-up INSERT either both commit or neither does, so each committed worker-driven outcome or callback resolution enqueues each registered follow-up exactly once. Delivery of the follow-up itself is at-least-once — once enqueued the row rides Awa's normal claim/retry semantics, and the follow-up handler must remain safe under re-execution. The maintenance rescue path remains best-effort because coupling rescue liveness to the correctness of user follow-up code (a panic in on_rescued_enqueue) would be a worse trade than a missed Rescued follow-up; the rescue UPDATE has already committed when the dispatch runs, so the worst case is a missing follow-up — not a phantom follow-up.
Open extensions¶
- Python parity. The Python binding does not yet expose
on_*_enqueueoron_event. The implementation path mirrorsregister_workerinawa-python/src/worker.rs: aPythonFollowUptype that wraps a Python callable, takes the GIL insidedispatch_specs_in_tx, serialisesJobRow + OutcomeContextto Python, invokes the callable, and decodes the returned dict to (JobArgs,InsertOpts) before the underlyinginsert_withfires. ADR-015's hook surface (@on_event) needs the same scaffold first. - Atomic callback-resolution for callback-only ingress (ADR-027). The worker
Client::*_externalpaths now drive the resolution and spec dispatch in a single tx viaadmin::*_external_in_txand the matchingstore::*_external_in_txhelpers. The callback-only ingress process landed by ADR-027 will need its own registry hookup to reuse the same_in_txadmin path; until then, callback ingress outside the workerClientremains a separate transition with no follow-up dispatch. - Atomic rescue + follow-up enqueue: deliberately not pursued. Threading
&mut txthroughmaintenance::rescue_*would couple rescue liveness to the correctness of user follow-up code — a panic inon_rescued_enqueuecould roll back a rescue UPDATE and prevent maintenance from recovering stale jobs. A future zero-loss rescue-notification design should use an outbox/sweeper rather than inlining user code into the rescue tx. on_started_enqueue. Not blocked by the design; deliberately out of scope to keep the claim/dispatch hot path uncontended. The observation use case is already covered byon_event.- Conditional spec dispatch. Skipping enqueue based on a predicate over the triggering row is not part of the API; conditioning belongs inside the follow-up handler instead.
- In-DB spec registry. Not part of the design; the in-process registry is sufficient while specs are declared in one place per deployment.
- NOTIFY-as-wakeup-hint for follow-up queue poll cadence. An implementation-level tuning lever rather than an architectural concern.