195 lines
9.5 KiB
Markdown
195 lines
9.5 KiB
Markdown
# ADR-014: Transactional outbox enqueue — `outbox_enqueue_tx` on the `TxHandle` trait
|
|
|
|
## Status
|
|
|
|
Accepted (2026-10-05, Phase 1 — OQ-13's resolution; amends
|
|
[ADR-008](008-contract-v1-pinning.md) §1's outbox and tx-seam bullets
|
|
and §2's method list in place, pre-implementation)
|
|
|
|
## Context
|
|
|
|
The outbox is "enqueue inside the business transaction + delivery
|
|
workers" ([ADR-002](002-feature-scope.md)) — the commit-atomic enqueue
|
|
is the mechanism's *only reason to exist*; the delivery side (`run_once`
|
|
pull semantics, the backing queue's derived `QueueOpts`) is pinned
|
|
(core-contract.md outbox section;
|
|
[ADR-010](010-queue-semantics-depth.md) §3/§3a). But the enqueue side
|
|
has no method. The hole, stated precisely:
|
|
|
|
1. The derived backing queue name `__alkstore_outbox:{name}`
|
|
([ADR-008](008-contract-v1-pinning.md) §4) carries the reserved
|
|
prefix, and every name-bearing entry point *and its `*_tx`
|
|
counterpart* rejects that prefix with `ReservedName` — so
|
|
`handle.enqueue_tx("__alkstore_outbox:x", …)` is contract-illegal
|
|
by construction.
|
|
2. `Outbox` (what `store.outbox(name)` returns) is not in the
|
|
`TxHandle` method set ([ADR-008](008-contract-v1-pinning.md) §1,
|
|
§2), and `TxHandle` has no outbox method of any name.
|
|
|
|
So as pinned by [ADR-008](008-contract-v1-pinning.md), a consumer can
|
|
open a transaction, write business rows, and have *no way* to put an
|
|
outbox send inside it — the load-bearing property
|
|
([ADR-007](007-transactional-seam.md), guiding principle 2) is
|
|
unreachable for this mechanism. Honker solves the same need with a
|
|
different seam: `outbox.enqueue_tx(&tx, payload, opts)` routes through
|
|
the caller's raw `Transaction`
|
|
(`/workspace/honker @ f4e53c6`, `packages/honker-rs/src/lib.rs:506-513`)
|
|
— a seam our handle design deliberately does not have: the handle *is*
|
|
the tx, opaque, no downcast, no driver types in the contract
|
|
([ADR-007](007-transactional-seam.md);
|
|
[ADR-008](008-contract-v1-pinning.md) §2).
|
|
|
|
The evidence base is complete — the contract surfaces (both ADRs), the
|
|
honker source (read at revision), the reserved-namespace rules, and
|
|
[ADR-010](010-queue-semantics-depth.md) §3a's stamping resolution. No
|
|
POC is needed: this is a contract-surface gap, not a semantics debate.
|
|
|
|
## Decision
|
|
|
|
### 1. `outbox_enqueue_tx(outbox, opts, payload) -> job_id` on the `TxHandle` trait
|
|
|
|
One method, added to the `TxHandle` trait:
|
|
|
|
```text
|
|
trait TxHandle {
|
|
enqueue_tx(queue, opts, payload) -> job_id
|
|
publish_tx(stream, payload) -> offset
|
|
notify_tx(channel, payload)
|
|
save_offset_tx(stream, consumer, offset)
|
|
outbox_enqueue_tx(outbox, opts, payload) -> job_id // the addition
|
|
commit(self: Box<Self>) -> Result<()> // or rollback
|
|
}
|
|
```
|
|
|
|
- **The method takes the outbox name, not the derived queue name.**
|
|
The engine validates the outbox name (non-empty → else
|
|
`InvalidName`; reserved-prefix → else `ReservedName` — the same
|
|
entry-point validation `store.outbox(name)` applies, per
|
|
[ADR-008](008-contract-v1-pinning.md) §4's rule that `*_tx`
|
|
counterparts validate identically) and derives the backing queue
|
|
name `__alkstore_outbox:{outbox}` *engine-side*. No prior
|
|
`store.outbox(name)` handle is required — outboxes are names, not
|
|
registered objects (the queues posture). The outbox name is used
|
|
verbatim in the derivation; no further charset/length rule is
|
|
pinned in v1 (both engines treat the derived value as an opaque
|
|
storage key). The reserved-prefix
|
|
rejection is not bypassed: it governs *directly-supplied* names in
|
|
the queue/channel/stream/lock namespaces, while the name this method
|
|
takes is an **outbox name** — a distinct name kind whose derived
|
|
name happens to land under the prefix. The derivation is legitimate
|
|
engine-internal naming, exactly as the auto-commit path's
|
|
`run_once` consumption of the same backing queue already is.
|
|
- **Opts carry `EnqueueOpts` whole** — same type, same position
|
|
(name, opts, payload) as `enqueue_tx`. Rationale: ADR-010 §3a makes
|
|
enqueue the stamping point — `EnqueueOpts.max_attempts` is the one
|
|
per-job override, resolved over the backing queue's derived
|
|
`QueueOpts` (visibility 60 s, max_attempts 5, backoff base 5 s;
|
|
[ADR-010](010-queue-semantics-depth.md) §3) — and the auto-commit
|
|
sibling `outbox.enqueue(payload, opts)` carries opts (honker
|
|
parity). A tx form that dropped opts would be asymmetric with its
|
|
own auto-commit twin for no gain; and stamping is what makes the
|
|
job's behavior inspectable via `get_job` no matter which form
|
|
enqueued it.
|
|
- **The property delivered**: the outbox send sits in the caller's
|
|
transaction — commit makes the backing-queue job row visible to
|
|
`run_once`/delivery exactly when the business write commits;
|
|
rollback drops both (the no-ghosts property,
|
|
[ADR-007](007-transactional-seam.md)). Transactional
|
|
local-adjacency (guiding principle 2) is restored for the outbox —
|
|
the mechanism exists to carry it.
|
|
|
|
### 2. Why not an `OutboxHandle` off the tx handle — option (b)
|
|
|
|
`handle.outbox(name) -> OutboxHandle` (with enqueue) mirrors the
|
|
store-side handle pattern, but it costs a second boxed mechanism
|
|
handle carried across the transaction to deliver exactly one needed
|
|
operation. Every new engine implements another trait for one method;
|
|
the "no downcast, no extra hops" rationale of
|
|
[ADR-008](008-contract-v1-pinning.md) §2 applies with force — a
|
|
one-method handle is surface weight, not symmetry. Option (a) is the
|
|
smallest surface that restores the property.
|
|
|
|
### 3. Explicit rejections
|
|
|
|
- **Honker's raw-`Transaction` seam is not inherited.** There is no
|
|
engine-neutral `Transaction` type to put in a core-crate signature —
|
|
rusqlite's `Transaction` and tokio-postgres's client are different
|
|
driver types — so routing through one would force either a core
|
|
dependency on engine types (inverting
|
|
[ADR-001](001-crate-split.md)'s dependency direction) or a
|
|
per-engine downcast seam (exactly what
|
|
[ADR-008](008-contract-v1-pinning.md) §2 dissolved). Honker's shape
|
|
is evidence that the *property* must exist, not that this *seam*
|
|
should.
|
|
- **Non-transactional-only (option (c)) is rejected outright.**
|
|
`outbox.enqueue` auto-commit alone drops the commit-atomic property
|
|
— the outbox would degrade to a queues alias, defeating its scope
|
|
row's purpose. Listed to be rejected, as the OQ required.
|
|
|
|
### 4. Amendment framing and verification
|
|
|
|
- **This completes contract v1 in place, pre-implementation.** No
|
|
artifact is released (no crate exists), so no versioning event
|
|
occurs — OQ-10's discipline is untouched by this; the method joins
|
|
the v1 surface partition's tx-seam bullet and
|
|
[core-contract.md](../core-contract.md)'s seam. ADR-008's outbox
|
|
bullet carries a pointer annotation, mirroring how ADR-010 §8's
|
|
SQLite half recorded its supersession.
|
|
- **Contract-suite row**: `outbox_enqueue_tx` commit-atomicity on both
|
|
engines — rollback drops the backing-queue job row with the business
|
|
write (no ghost job); commit makes it claimable by `run_once`;
|
|
reserved/empty outbox names rejected identically on the tx path
|
|
(`ReservedName`/`InvalidName`); the stamped opts visible via
|
|
`get_job` identical across engines (added to core-contract.md's
|
|
verification backlog).
|
|
|
|
## Consequences
|
|
|
|
**Positive**
|
|
|
|
- The outbox's scope row
|
|
([ADR-002](002-feature-scope.md)) becomes implementable: both halves
|
|
(commit-atomic enqueue, `run_once` delivery) now have pinned
|
|
surfaces — decomposition off the current text no longer produces an
|
|
unperformable core operation.
|
|
- Smallest possible addition: one method, one existing opts type, one
|
|
job-id return — no new handle type, no new error variant (the
|
|
validation failures reuse the pinned taxonomy,
|
|
[ADR-008](008-contract-v1-pinning.md) §5).
|
|
- New engines stay purely additive: one more trait method, same
|
|
implementation pattern as the other `*_tx` ops.
|
|
|
|
**Negative**
|
|
|
|
- `TxHandle` grows by one engine-implemented method — the trait cost
|
|
every engine inherits even if it never uses the outbox (the same
|
|
per-method cost the rest of the seam carries; measured negligible
|
|
against commit costs, ADR-008 §2).
|
|
- A mild surface asymmetry: the tx handle enqueues into queues *by
|
|
queue name* but into outboxes *by outbox name* (the derived backing
|
|
queue is unreachable by `enqueue_tx` by design). This is the
|
|
reserved-namespace rule doing its job, but it is a thing consumer
|
|
documentation must state plainly: **there is no way to enqueue into
|
|
the backing queue except through the outbox surface** (auto-commit
|
|
`outbox.enqueue` or tx `outbox_enqueue_tx`) — itself the guarantee
|
|
that the derivation cannot be collided with.
|
|
|
|
## References
|
|
|
|
- OQ-13 (`docs/architecture/open-questions.md`) — this ADR's
|
|
resolution; the evidence and option set sketched there.
|
|
- [ADR-008](008-contract-v1-pinning.md) §1 (outbox bullet, annotated),
|
|
§2 (`TxHandle` shape — the method joins its list), §4 (reserved
|
|
namespace — the rejection this method is designed around), §5 (error
|
|
taxonomy — reused, not extended).
|
|
- [ADR-007](007-transactional-seam.md) — the seam whose property this
|
|
method restores for the outbox; the no-ghosts property.
|
|
- [ADR-010](010-queue-semantics-depth.md) §3/§3a — the backing queue's
|
|
derived opts and the enqueue-time stamping this method's `EnqueueOpts`
|
|
rides.
|
|
- [ADR-002](002-feature-scope.md) — the outbox scope row whose
|
|
enqueue half this completes.
|
|
- Honker's seam (`/workspace/honker` `packages/honker-rs/src/lib.rs`
|
|
`Outbox::enqueue_tx` @ f4e53c6) — the reference checkout's raw-
|
|
`Transaction` shape, read and rejected (§3).
|
|
- OQ-10 — unaffected (amendment framing, §4). |