ADR-019/020: second review round — handle surfaces, enqueue semantics, payload bridge + mechanical fixes
ADR-019: mechanism-handle traits pinned (Queue/StreamHandle/Outbox/ Lock/JobHandle), Job/Schedule struct shapes, worker_id claimant identity, core StopToken — closes the 'pin at implementation' residue before ADR-017's amend-in-place window terminates at first release. ADR-020: delay-wins-over-run_at precedence (honker parity), relative expires, scheduler-fired stamps from derived queue defaults, serde_json byte-level payload encoding. Mechanical: overview/engine-postgres ADR tables completed through 020, core-contract Errors section re-framed to class-1, ADR-014 sketch annotated with publish_with_key_tx, ADR-015 status dated, reserved- namespace kinds list normalized to six kinds, redrive gate wording aligned, broken link fixed, StopToken deferral superseded in ADR-009.
This commit is contained in:
1 parent
c49befd195
commit
8323a7853e
13 files changed
+661
-54
No files matched your search
@@ -0,0 +1,309 @@
|
||||
# ADR-019: Mechanism-handle surfaces — handle traits, value shapes, claimant identity, stop token
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-10-06, Phase 1 — second architecture review round,
|
||||
pre-decomposition; amends [ADR-008](008-contract-v1-pinning.md) §1/§2/
|
||||
§8 in place, pre-implementation ([ADR-017](017-contract-versioning.md)
|
||||
class 1 — no release exists; the amend-in-place frame is still open))
|
||||
|
||||
## Context
|
||||
|
||||
Contract v1 pinned the `TxHandle`, `WakeReceiver`, and `EventReceiver`
|
||||
trait shapes exactly, but the mechanism handles were left
|
||||
deliberately open:
|
||||
|
||||
- [core-contract.md](../core-contract.md) (pre-review): "Mechanism
|
||||
handles (`Stream`, `Queue`, `Outbox`, `Lock`) are core-owned trait
|
||||
objects … their trait methods pin at implementation, mirroring the
|
||||
`TxHandle` pattern."
|
||||
- [ADR-009](009-scheduler-collapse.md) §1 deferred the `run_schedules`
|
||||
stop parameter's type the same way ("the exact type shapes at
|
||||
implementation, engine-crate docs").
|
||||
- `Job` and `Schedule` — both `#[non_exhaustive]` consumer-read types
|
||||
under [ADR-017](017-contract-versioning.md) §3 — had prose-only
|
||||
field lists, split across ADR-010 §1/§3a and ADR-009 §1.
|
||||
- The two `save_offset` forms (the pinned
|
||||
`save_offset(stream, consumer, offset)` store/tx form and
|
||||
[ADR-008](008-contract-v1-pinning.md) §8's receiver-side
|
||||
`save_offset(&mut self)`) had no stated composition.
|
||||
|
||||
The second architecture review flagged that "pin at implementation"
|
||||
no longer holds: [ADR-017](017-contract-versioning.md) makes trait
|
||||
methods the lockstep-priced additive class and their shapes permanent
|
||||
contract surface, and that ADR's class-1 window (amend-in-place)
|
||||
**terminates at the core crate's first release** — and these shapes
|
||||
ship in that first release. So the pinning must happen in an ADR now,
|
||||
or the implementation agent would be authoring versioned contract
|
||||
surface with no decision record behind it.
|
||||
|
||||
The decisions below are derived, not invented: honker-rs
|
||||
(`/workspace/honker` `packages/honker-rs/src/lib.rs` @ f4e53c6) is the
|
||||
starting artifact every placement so far has mirrored, and its handle
|
||||
placement is the inherited shape — `[ADR-002](002-feature-scope.md)`'s
|
||||
"fidelity where kept" posture applied to the *contract* side the way
|
||||
[ADR-012](012-forked-substrate-design.md) §3 applied it to the
|
||||
substrate's.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Handle placement mirrors honker-rs — mechanism ops live on the mechanism handles
|
||||
|
||||
Naming, construction, and placement (the return types are boxed
|
||||
trait objects — core-owned traits, engine types never in consumer
|
||||
signatures, the [ADR-008](008-contract-v1-pinning.md) §2 posture):
|
||||
|
||||
```text
|
||||
store.queue(name, opts) -> Box<dyn Queue>
|
||||
store.stream(name) -> Box<dyn StreamHandle>
|
||||
store.outbox(name) -> Box<dyn Outbox>
|
||||
store.try_lock(name, owner, ttl) -> Option<Box<dyn Lock>>
|
||||
```
|
||||
|
||||
*(Trait-name note: the stream handle's trait is `StreamHandle`, not
|
||||
`Stream` — `StreamEvent` already occupies the type-name's vocabulary
|
||||
and a bare `Stream` trait beside `EventReceiver` invites confusion
|
||||
with tokio's `Stream`. The honker-rs struct name maps to
|
||||
`StreamHandle`; everything else keeps honker's names.)*
|
||||
|
||||
```text
|
||||
trait Queue {
|
||||
name(&self) -> &str
|
||||
enqueue(payload, EnqueueOpts) -> job_id
|
||||
claim_one(worker_id) -> Option<Box<dyn JobHandle>>
|
||||
claim_batch(worker_id, n) -> Vec<Box<dyn JobHandle>>
|
||||
ack_batch(ids) -> count // non-claimed ids not counted
|
||||
cancel(job_id) -> bool // unconditional delete
|
||||
get_job(job_id) -> Option<Job> // sees dead rows; a value,
|
||||
// no ops — see §2
|
||||
sweep_expired() -> count // this queue's rows
|
||||
}
|
||||
|
||||
trait StreamHandle {
|
||||
name(&self) -> &str
|
||||
publish(payload) -> offset
|
||||
publish_with_key(key, payload) -> offset
|
||||
read_since(offset, limit) -> Vec<StreamEvent>
|
||||
read_from_consumer(consumer, limit) -> Vec<StreamEvent>
|
||||
save_offset(consumer, offset)
|
||||
get_offset(consumer) -> i64 // absent consumer = 0
|
||||
trim_to(horizon) -> count // deletes offset <= horizon
|
||||
subscribe(consumer) -> Box<EventReceiver> // shape: ADR-008 §8
|
||||
}
|
||||
|
||||
trait Outbox {
|
||||
name(&self) -> &str
|
||||
enqueue(payload, EnqueueOpts) -> job_id // into the derived
|
||||
// backing queue
|
||||
run_once(worker_id, delivery) -> bool // see §4
|
||||
}
|
||||
|
||||
trait Lock {
|
||||
name(&self) -> &str
|
||||
renew(ttl) -> bool // new full TTL window from
|
||||
// now; false = lost it
|
||||
release(self: Box<Self>) -> bool // consuming; true if held
|
||||
}
|
||||
```
|
||||
|
||||
- **Why mirror honker's placement**: it is the starting artifact's
|
||||
proven shape; every prior decision (claim ordering, QueueOpts
|
||||
stamping, the v1 skeleton's method *names*) already assumed
|
||||
queue-scoped claim/maintenance ops — `sweep_expired(queue)` on
|
||||
`Store` would take the name argument that the handle already
|
||||
carries; a `Queue` handle without `claim_*` would be a construction
|
||||
ceremony with no behavior. Honker's `Job`-ops-on-claimed-work shape
|
||||
and `Lock::release(self)` RAII shape are likewise inherited.
|
||||
- `get_job`, `save_offset`, `get_offset`, `read_*` remain available
|
||||
from *inside* a transaction through the `TxHandle`'s `*_tx` methods
|
||||
— the handle forms are the auto-commit convenience counterparts
|
||||
([ADR-007](007-transactional-seam.md)).
|
||||
- The **reserved-prefix / name validation rules apply to the
|
||||
constructors** (`queue`/`stream`/`outbox`/`try_lock`) exactly as
|
||||
pinned by [ADR-008](008-contract-v1-pinning.md) §4 — nothing new;
|
||||
the handles carry validated names.
|
||||
- Trait *method-set* additions on these handle traits post-release
|
||||
are [ADR-017](017-contract-versioning.md) class 2 — the same
|
||||
lockstep cost as `TxHandle` additions; stated here so the cost is
|
||||
priced before any consumer depends on it.
|
||||
|
||||
### 2. Claimant identity — `worker_id`, caller-supplied and consumer-local
|
||||
|
||||
- `claim_one`/`claim_batch`/`run_once` take a **`worker_id: &str`** —
|
||||
the claim's ownership token, stamping the row's `worker_id` column.
|
||||
It is a **consumer-local identifier in the stream-consumer-name
|
||||
class** ([ADR-008](008-contract-v1-pinning.md) §4: no reserved-
|
||||
prefix rule, no shared namespace): callers self-supply (a host id, a
|
||||
task label); the contract requires only non-emptiness
|
||||
(`InvalidName` otherwise — the v1 skeleton's only name-kind rule
|
||||
addition, same variant, no new taxonomy).
|
||||
- Cross-process semantics are the queue mechanism's, not the
|
||||
identifier's: claim exclusivity comes from the claim statement's
|
||||
atomicity ([ADR-010](010-queue-semantics-depth.md)), not from id
|
||||
uniqueness; two workers self-supplying the same string is their
|
||||
confusion to avoid (ops tooling reads it, nothing enforces it).
|
||||
|
||||
### 3. Value shapes — `Job` and `Schedule` pinned as structs
|
||||
|
||||
Both `#[non_exhaustive]` per [ADR-017](017-contract-versioning.md) §3
|
||||
(field additions ride class 2):
|
||||
|
||||
```text
|
||||
struct Job {
|
||||
id: i64,
|
||||
queue: String,
|
||||
state: JobState, // enum: Pending | Processing
|
||||
// | Dead
|
||||
payload: Vec<u8>,
|
||||
priority: i64,
|
||||
run_at: i64, // the claim-order key
|
||||
attempts: i64, // every claim counts
|
||||
max_attempts: i64, // the row's stamp
|
||||
worker_id: Option<String>, // claimant, None pre-claim
|
||||
claim_expires_at: Option<i64>, // deadline, None pre-claim
|
||||
created_at: i64, // unix seconds
|
||||
expires_at: Option<i64>, // job-level expiry, None =
|
||||
// never
|
||||
visibility_timeout_s: i64, // the §3a stamps, dead rows
|
||||
backoff_base_s: i64, // included (get_job returns
|
||||
dead_letter_retention_s: Option<i64>, // the stamps with the row)
|
||||
last_error: Option<String>, // dead-only (None on live)
|
||||
died_at: Option<i64>, // dead-only
|
||||
}
|
||||
```
|
||||
|
||||
- Single `Job` type, two carriers: **claims return
|
||||
`Box<dyn JobHandle>`** — ops + row:
|
||||
|
||||
```text
|
||||
trait JobHandle {
|
||||
job(&self) -> &Job // the row, as a value
|
||||
ack(self: Box<Self>) -> bool
|
||||
retry(self: Box<Self>, err: Option<String>, delay: Option<i64>)
|
||||
// None delay = queue curve
|
||||
fail(self: Box<Self>, err: Option<String>) -> bool
|
||||
heartbeat(self, extend: i64) -> bool // absolute reset
|
||||
}
|
||||
```
|
||||
|
||||
— and **`get_job` returns `Option<Job>`** (pure data). The *same
|
||||
uniform validity predicate* ([ADR-010](010-queue-semantics-depth.md)
|
||||
§2 — `processing` + unexpired caller deadline) governs a
|
||||
`JobHandle`'s ops; a get_job row yields no handle, so no ghost ops
|
||||
exist on it. Ops take `self: Box<Self>` mirroring `TxHandle`'s
|
||||
commit shape (consumed handle, one-shot discipline).
|
||||
`err: Option<String>`: `None` = the engine's
|
||||
documented-default string (taxonomy-consistent: these strings are
|
||||
row content, `get_job` data — not matched error variants).
|
||||
- `Schedule` (the `schedule()` read-back value):
|
||||
`Schedule { name: String, spec: String, queue: String, opts:
|
||||
ScheduleOpts }`.
|
||||
|
||||
### 4. `run_schedules(stop)` — the stop token is a core-crate type
|
||||
|
||||
```text
|
||||
store.run_schedules(stop: StopToken) -> Result<()>
|
||||
|
||||
#[derive(Clone)] struct StopToken
|
||||
impl StopToken {
|
||||
fn cancel(&self)
|
||||
fn is_cancelled(&self) -> bool
|
||||
}
|
||||
```
|
||||
|
||||
- **Pinned in core, not deferred**: the parameter type is contract
|
||||
surface ([ADR-017](017-contract-versioning.md) — a pinned method's
|
||||
signature), and a core-owned `StopToken` is exactly the
|
||||
config-split posture ([ADR-008](008-contract-v1-pinning.md) §6)
|
||||
in miniature — a contract-owned type whose *await mechanics* are
|
||||
engine-internal (tokio watch per engine; not contract).
|
||||
- Semantics ([ADR-009](009-scheduler-collapse.md) §1, unchanged):
|
||||
`cancel()` on any clone flips every clone;
|
||||
`run_schedules` ends its sleep early and returns `Ok(())` — the
|
||||
clean-stop return; leadership loss still returns
|
||||
`Err(LeadershipLost)` regardless of the token.
|
||||
|
||||
### 5. `run_once`'s delivery callable
|
||||
|
||||
`Outbox::run_once(worker_id, delivery) -> bool` (true = claimed and
|
||||
processed) — the delivery argument is the consumer-supplied async
|
||||
callable taking `Box<dyn JobHandle>`, returning the boxed future of
|
||||
`Result<()>` (object safety: no generic methods on the boxed
|
||||
handle — the [ADR-008](008-contract-v1-pinning.md) §2 boxed-future
|
||||
posture applied to the one remaining surface it applies to). Outcome
|
||||
posture is pinned as before: `Ok` ⇒ ack, `Err(e)` ⇒
|
||||
`retry(err, None)` (the queue curve). The exact callable *encoding*
|
||||
(`&mut dyn FnMut…`-form or a named one-method trait object) is
|
||||
Rust mechanics with one workable answer under object safety — the
|
||||
semantics above are the contract; the encoding follows §2's pinned
|
||||
pattern at implementation.
|
||||
|
||||
### 6. The two `save_offset` forms compose by monotonicity
|
||||
|
||||
One op, two conveniences: `StreamHandle::save_offset(consumer,
|
||||
offset)` / `save_offset_tx` write the checkpoint; the receiver's
|
||||
`save_offset(&mut self)` ([ADR-008](008-contract-v1-pinning.md) §8)
|
||||
writes **the receiver's last-yielded event's offset through the same
|
||||
op**. Saves are monotone (a save whose offset is below the stored
|
||||
checkpoint is a silent no-op — inherited: honker's
|
||||
`ON CONFLICT … WHERE excluded.offset > existing` upsert), so the
|
||||
forms interleave freely — they cannot regress or fight one another.
|
||||
No-offset-regression is the pinned discipline: replay is a *read*
|
||||
concern (`read_since(offset)` works from any offset; saved checkpoints
|
||||
do not gate reads). Returns `Result<()>` — refusal (regression) is a
|
||||
no-op, not caller-actionable (the act-differently rule,
|
||||
[ADR-008](008-contract-v1-pinning.md) §5).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Every handle a consumer constructs is now fully specified surface:
|
||||
decomposition off the current text can write the contract-crate
|
||||
task without inventing contract shapes.
|
||||
- The last "pin at implementation" deferral in the contract surface
|
||||
is closed, before the [ADR-017](017-contract-versioning.md) class-1
|
||||
window that makes such pinning possible ends.
|
||||
- Placement honesty: the contract mirrors the starting artifact that
|
||||
every prior queue/lock/stream decision assumed, so no
|
||||
re-interpretation debt accrues at implementation.
|
||||
|
||||
**Negative**
|
||||
|
||||
- Four more boxed-handle traits (plus `JobHandle`) for engines to
|
||||
implement — the same per-trait cost `TxHandle` carries; measured
|
||||
negligible against commit costs ([ADR-008](008-contract-v1-pinning.md)
|
||||
§2), but real surface.
|
||||
- Trait-method additions on five traits now ride ADR-017 class 2
|
||||
lockstep duties (engine adoption releases are mandatory on trait
|
||||
additions). Honest pricing, stated before consumers exist.
|
||||
- `Job`'s field list is pinned wide (all stamps, both namespaces of
|
||||
timestamps) — growth from here is class-2-minor via
|
||||
`#[non_exhaustive]`, but the initial text is larger than a minimal
|
||||
read type would be.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-008](008-contract-v1-pinning.md) §1/§2/§4/§5/§8 — the
|
||||
partition this completes, the boxed-handle/object-safety posture,
|
||||
the validation rules, the act-differently rule, the
|
||||
`EventReceiver` shape and rename table this ADR's handle shapes
|
||||
extend.
|
||||
- [ADR-007](007-transactional-seam.md) — the auto-commit-vs-`_tx`
|
||||
counterpart structure the handle forms slot into.
|
||||
- [ADR-009](009-scheduler-collapse.md) §1 — the
|
||||
`run_schedules(stop)` method whose stop type §4 pins; the return
|
||||
semantics §4 restates unchanged.
|
||||
- [ADR-010](010-queue-semantics-depth.md) §1/§2/§3a — the job
|
||||
lifecycle, the uniform validity predicate §3's `JobHandle` ops ride,
|
||||
the stamps `Job`'s fields carry.
|
||||
- [ADR-017](017-contract-versioning.md) §2 class 1/§3 — the
|
||||
amend-in-place window this ADR uses (and which its urgency derives
|
||||
from), and the `#[non_exhaustive]` classifications §3's structs
|
||||
satisfy.
|
||||
- honker-rs surface (`/workspace/honker`
|
||||
`packages/honker-rs/src/lib.rs` @ f4e53c6) — the placement record:
|
||||
`Queue`/`Outbox`/`Stream`/`Lock` methods, `JobRow`/`Job` split,
|
||||
`SaveOffset` upsert, `Lock::release(self)`, claim `worker_id`.
|
||||
- [core-contract.md](../core-contract.md) — the spec this ADR's §1–§5
|
||||
pin into place.
|
||||
Reference in new issue
Block a user