ADR-014: transactional outbox enqueue — outbox_enqueue_tx on TxHandle (OQ-13 resolved)
This commit is contained in:
1 parent
d401908f13
commit
04a04651dc
8 files changed
+359
-122
No files matched your search
@@ -64,13 +64,17 @@ implement identically):
|
||||
and `release`.
|
||||
- outbox helper — `outbox(name)` with `enqueue` + `run_once` delivery
|
||||
worker. *(Depth annotation 2026-10-05: the transactional *enqueue*
|
||||
shape on the `TxHandle` seam is OQ-13's — the v1 method list has no
|
||||
outbox enqueue method and the derived backing queue's reserved
|
||||
prefix makes plain `enqueue_tx` illegal; see
|
||||
[open-questions.md](../open-questions.md). `run_once` worker
|
||||
semantics are pinned in core-contract.md's outbox section.)*
|
||||
shape was OQ-13's — this §1/§2 method list had no outbox enqueue
|
||||
method and the derived backing queue's reserved prefix made plain
|
||||
`enqueue_tx` illegal; resolved by
|
||||
[ADR-014](014-outbox-tx-enqueue.md) — `outbox_enqueue_tx` joins the
|
||||
`TxHandle` trait. `run_once` worker semantics are pinned in
|
||||
core-contract.md's outbox section.)*
|
||||
- the tx seam — `begin_tx` / commit / rollback and the `*_tx` methods
|
||||
([ADR-007](007-transactional-seam.md)).
|
||||
([ADR-007](007-transactional-seam.md)). *(Amended 2026-10-05 by
|
||||
[ADR-014](014-outbox-tx-enqueue.md):
|
||||
`outbox_enqueue_tx(outbox, opts, payload)` added to the
|
||||
`TxHandle` trait.)*
|
||||
- `subscribe(consumer) -> Box<dyn EventReceiver>` — the durable
|
||||
stream subscription handle, delivering `StreamEvent`s with an
|
||||
explicit `save_offset` on the receiver (no auto-checkpoint;
|
||||
@@ -113,6 +117,7 @@ trait TxHandle {
|
||||
publish_tx(stream, payload) -> event_id
|
||||
notify_tx(channel, payload)
|
||||
save_offset_tx(stream, consumer, offset)
|
||||
outbox_enqueue_tx(outbox, opts, payload) -> job_id // ADR-014
|
||||
commit(self: Box<Self>) -> Result<()> // or rollback
|
||||
}
|
||||
```
|
||||
@@ -195,7 +200,8 @@ struct Wake { channel: String }
|
||||
- **Reserved prefix: `__alkstore_`** (leading double underscore). The
|
||||
namespace exists per [ADR-006](006-wake-and-delivery-contract.md);
|
||||
this pins its string. Engine-independent — applies to channel,
|
||||
stream, queue, and lock names on every engine.
|
||||
stream, queue, lock, and outbox names on every engine *(outbox names
|
||||
added to the kinds list by [ADR-014](014-outbox-tx-enqueue.md))*.
|
||||
- **The one v1-reserved string:**
|
||||
`__alkstore_listener_reconnected__` — the Postgres forwarder's
|
||||
synthetic reconnect-wake channel ([ADR-004](004-postgres-driver.md)).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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) -> event_id
|
||||
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).
|
||||
Reference in new issue
Block a user