Files
alkblobs/docs/architecture/decisions/010-backend-io-seams-and-fleet-state-home.md
T
glm-5.3-flash bee86b0dbd docs(architecture): ADR-013 — family composition, reactive boundary, sweep lease; OQ-11 resolved
- No sibling (alkstore) dependency in-core; reactivity is
  deployment-layer composition above the facade — cadence stays
  embedder-owned (ADR-005), with alkstore the named substrate for it;
  in-crate notify/scheduler/queues/invalidation hooks are non-goals.
- Fleet sweeper lock pinned as 'named exclusive sweep lease'
  semantics; shipped provider unchanged (pg advisory clause);
  provider swaps are ADR-007-shaped substitution doors.
- Composition contract for downstream ADRs: co-tenancy of CAS/GC
  state + reactive machinery + consumer/liveness tables on one fleet
  host; no-cross-store-atomicity ruled; compensation rests on
  idempotent re-puts, windowed deletes, sweeps-as-report-data.
- OQ-11 resolved (the register's one live circular hedge): engine
  features pull their own driver clusters — sqlite becomes feature
  'sqlite' (default-on, carries rusqlite); 'kv'/'large' tier features
  retired; 'local' ungated; pg-pure builds are supported,
  non-default. Deciding fact already in-tree (REQ-2/ADR-009).
- requirements.md: UC-4/REQ-5 records the composition facts
  (operator record 2026-10-11, corroborated by alkstore's
  consumer-inventory).
- Publish guard stated pre-code: facade ships before publish;
  semver-is-contract from first publish (alkstore ADR-017 discipline).
- Amended accepted ADRs' feature wording (ADR-010 §1, ADR-011 §3/§4,
  ADR-003) with supersession notes; README/overview/open-questions/
  backends/store-api/gc aligned; parked index now empty.

Verification: docs-only round (no Rust surface touched); full-tree
single-value check on the feature graph and sweep-lease wording.
2026-10-11 16:47:22 +00:00

379 lines
21 KiB
Markdown

# ADR-010: The Backend trait's I/O seams — read cursor, staged put, and the engine-state companion seam
## Status
Accepted (fleet-state activation, the GC-state host under mixed
engines, the sweep-abort return shape, the pin token's wire shape,
the sqlite pin/sweep-lock clause reductions, and the window staging
for non-SQL engines are ruled by [ADR-012](012-pre-decomposition-consistency-rulings.md);
§2's sqlite rows and the "fleet state is active" predicate read
through that ADR — where noted inline)
## Context
The engine story closed at the decision level (ADR-007/008/009), but
three seams that implementation must not guess are unpinned. All three
concentrate where the tier/engine split stretched the original
contract:
1. **The `get`/`put` value shapes were never unified.** The trait POC's
`get` returns `Option<Vec<u8>>`; the largeblob POC's fs arm "yields
a file handle, not a loaded buffer"; the pg-lo POC's get is a
`LoGetHandle` driving `lo_get(oid, off, len)` windows — three
structurally different shapes behind one trait, and no document
named the unified contract. Same on the put side: a byte slice
(kv), a stage-then-commit-rename stream (`local`), and a
chunked-`lowrite` transaction (pg-lo) are all "the put". A unified
trait is the crate's second stable seam (ADR-003); its I/O shapes
are decided before code, not discovered per engine.
2. **ADR-008's fleet state has no seam.** Pins become expiry-carrying
rows committed with the entry; staged delete-window candidates
(including on sqlite); `SELECT … FOR UPDATE` arbitration; a pg
advisory lock for the sweeper. None of that is expressible through
the public trait method set, and ADR-003's "the shipped trait
impls write plain content-addressed rows into the chosen engine,
nothing else" reads as a bar the fleet mechanism violates. Also
unresolved: who owns the joint entry+pin transaction — ADR-009
says the pg-lo put's single tx carries the pin row, but ADR-005
says `Pin` is a store-core RAII guard under the arbitration lock.
The trait has no parameter for either, and "the backend learns
about pins" would contradict ADR-005. The ops pin-token contract
(ops-surface.md) inherits the same conflict: a remote putter holds
a token, but an expiry-guarded pin row goes stale if the holder
pauses — a sweep could delete a remotely-in-flight put whose row
expired.
3. **The kv tier had no fleet-validity rule.** ADR-008 §3 constrains
the large tier's fleet forms (shared media, re-routing) but pins
nothing for the kv tier, which the fleet story centers on: two
nodes' sqlite engines are trivially partitioned (two files = two
pools, not one), and no document said so.
The deciding facts are in hand (Schrödinger's-code rule — none of
these may wait on consumers): the POCs fixed every engine's native
shape; ADR-008 fixed the fleet mechanism's shape (rows, expiry,
advisory lock, staged window); ADR-001 §4 fixed that ops is a
store-facade consumer with no backend side-door. What is missing is
composition: how the store core drives all engines through one
contract while owning GC state.
Alternatives considered for the fleet-state seam, rejected:
- **Put the fleet machinery on the public trait** (pin/window/table
methods on `Backend`): every engine — including `mem` and `local` —
must then implement SQL-shaped machinery it cannot express. Rejected.
- **Backends grow pin awareness** (the pg-lo put tx carries the pin
row, backend-maintained): contradicts ADR-005's "backends never
learn liveness"; and the arbitration decision (expiry compare,
liveness consult) still belongs to the store, so the store ends up
reaching into the engine's tx anyway. Rejected.
- **A raw-SQL side-channel** (connection escape hatch the store core
may use arbitrarily): turns the engine abstraction off for exactly
the machinery (fleet GC) it most needs to be sound on. Rejected.
The seam below is that escape hatch made honest — named clauses,
contract-tested per engine, instead of raw access.
## Decision
### 1. One `Backend` trait; two I/O abstractions per tier — read cursor and staged put
**The trait stays one trait.** Every engine implements the same method
set (ADR-003's methods + ADR-008's `size`) — no per-tier trait split.
What changes: the trait's I/O types are the crate-internal
abstractions below, which each engine constructs its own way — the
store core programs the abstraction, never the engine shape.
- **`get` returns a read cursor.** One abstraction, two body families:
- **kv engines** (sqlite, postgres, mem): the cursor materializes
the whole value (the engine's row read; bounded by the small-blob
tier policy). Its range reads are in-memory slices; `len()` reads
the row's `size` column.
- **large engines** (`local`, pg-lo): the cursor streams; the medium
is hidden in the cursor body — `local`'s is a pread loop over the
committed file (immutable once committed); pg-lo's drives
descriptorless `lo_get(oid, off, len)` windows (ADR-009 §3's C4
posture — pool-friendly, stateless per window; the
tx-scoped-descriptor form is the fallback). `len()` reads the
companion row or stats the file — metadata only, never content
(ADR-008's discipline).
- Both body families serve the ADR-006 range contract (`read_range`
→ slice + slice digest) at one seam. The cursor is
**store-internal — not public API**: store consumers see the
facade's `(len, stream)` shapes (store-api.md) with the cursor
staying behind them; the store's stream utility adapts engine
cursor bodies, it does not expose them.
- **`put` exists in two forms, selected by the store core before
content flows; both land in the CAS entry shape and both
participate in pin-before-publish (§2 decides who holds the tx).**
- **Whole-value put** for the kv tier: the settled row write.
POC-shaped bodies: sqlite `INSERT OR IGNORE`, postgres
`ON CONFLICT DO NOTHING`, mem `entry().or_insert()`.
- **Staged put** for the large tier — ADR-003's
stage-then-commit-rename promoted from behavior to a **named
per-engine contract**: the engine hands out a *staging cursor*
(`local`: the stage file under the store's stage-hygiene protocol;
pg-lo: chunked `lowrite` statements over a tx the store core
holds — §2's `pin_commit` is that holder; 512 KiB per POC #7's
measured knee). Commit publishes the entry;
every failure path converges on discard (store-api.md stage
hygiene, per engine, exact-count tested — POC #3 A5's shape).
- **`list`/`delete`/`has`/`size`/`name` are tier-agnostic.** `list`
walks the engine's companion structure (rows / files / LO
companion); `size` is metadata-only (ADR-008). The exact-count
sweep-outcome gate (ADR-003) runs per engine.
**Feature-graph consequence** (fills a gap ADR-007/009 left): feature
`pg-lo` does **not** imply feature `postgres`. Each is an engine
feature pulling the shared driver cluster (`tokio-postgres` +
`deadpool-postgres`) independently — a `large`=pg-lo + kv=sqlite
node is coherent and supported. overview.md's dependency table pins
the edge. *(The same independent-driver discipline is extended to
every engine by ADR-013 §4 — `sqlite` becomes its own default-on
feature carrying rusqlite, the `kv`/`large` tier features retire, and
pg-pure builds become supported, non-default.)*
### 2. GC state is store-core-owned; per-engine state seams are crate-internal and contract-tested
**Ownership: the store core owns all GC state — in-process pin and
liveness maps *and* the fleet state's rows and locks. The public
`Backend` trait stays exactly what ADR-003 says (opaque
`hash → bytes`, namespace-blind; "plain content-addressed rows,
nothing else" remains true of the public trait). Backends never learn
liveness (ADR-005) — verbatim.**
The store core reaches the fleet machinery through a **crate-internal
(not exported) engine-state companion trait**, implemented by the
SQL-backed engines (sqlite, postgres, pg-lo), with `local` and
`mem` excluded by construction (it cannot be expressed on them and
they are never fleet engines). ADR-008's mechanism fixes *what*; the
seam is *where* the mechanism's SQL lives. Clauses (per-engine
applicability is ruled by [ADR-012](012-pre-decomposition-consistency-rulings.md)
§5's table, which reduces the original sqlite rows; fleet state hosts
on the kv-tier engine when fleet mode is declared — ADR-012 §4):
- `ensure_gc_schema()` — create `gc_pins (key, owner, expiry)` and
`gc_window (key, visible_after)` at store construction (store-core
call, engine-neutral API; dialect-specific SQL bodies). `owner`
is the **store-instance id** (ADR-011's vocabulary; ADR-008's
"node id" wording predates that split). The pin row's identity is
`(key, owner)` — a dedup re-put by a different instance is a
different pin row; protection composes by row (an entry is
protected while any un-expired pin row exists), which is
ADR-005's refcount semantics expressed in rows. The embedder's
liveness table is *not* created by this seam — ADR-008: schema
pinned by the embedder; the sweep reads it and never owns it.
**Per-engine applicability (the test-gate shape; final per
ADR-012 §5):** sqlite — schema creates **`gc_window` only** +
`window_stage`/`window_take` (durable single-node staging);
the originally-specced sqlite pin clauses and sweep-lock body
are **dropped** by ADR-012 §5 (the pin bodies inserted into a
table nothing created — dead machinery; sqlite has no advisory
lock, and ADR-008's unchanged single-node protocol keeps the
sweep single-flighter in-process). postgres — all clause
families, contract-tested, fleet-active (the fleet host). pg-lo —
the window family only (durable window staging where the kv
engine is not SQL-backed — the one-window-host rule, ADR-012 §5;
it hosts no fleet pin/lock state,
ADR-012 §4), contract-tested.
- `pin_commit(joint_tx, entries)` — the store core **holds the
transaction**; the engine contributes its put mechanics (row
shape, CAS clause, LO writes) and the pin-row inserts, and both
commit together. This is the joint entry+pin transaction's owner,
ruled: **the store holds the tx; the engine contributes mechanics
to it.** The public `put` remains the plain, pin-free path — the
store's normal put path routes through `pin_commit` wherever the
engine is seam-capable and fleet mode is active (**activation
ruled by ADR-012 §3: fleet mode is an explicit `fleet: true`
constructor declaration, never inferred from the engine choice**;
for large=local fleet puts the joint tx is unachievable across
media — ADR-012 §4 pins pin-row-first, publish-second ordering).
- pg-lo correction: ADR-009 §2's "put = one transaction" is
unchanged in content but its ownership statement is hereby
corrected — the tx is held by the store core; the pg-lo engine
contributes its staged-put mechanics and companion-row insert
into it. The engine sees pins only as rows; never as semantics.
- `pin_renew(key, owner)` — the engine executes the expiry update
(renamed and TTL dropped by ADR-012 §2: the TTL lives with the
store's renewal schedule, not in the clause signature; the original
`pin_renew(key, owner, ttl)` read is superseded). The store's pin
guard owns the TTL/3 renewal schedule
(ADR-008); the engine owns only the SQL. *(postgres host only per
ADR-012 §5 — sqlite bodies dropped.)*
- `pin_check(key) → PinState` / `arbitrate(key) → bool` — the engine
executes ADR-008's delete-time arbitration (un-expired pin rows +
embedded liveness tables, `SELECT … FOR UPDATE` finger-lock,
keyed, at delete time); the store core receives the answer and
applies the decision. `PinState` is a data enum with two variants:
`Protected` (an un-expired pin row exists) and `Unprotected` — the
engine never decides deletion, it only classifies; *when the seam
supports SQL-level liveness tables the store consults those through
`arbitrate` instead, which folds the full answer into the bool*. If
fleet clauses ever grow, they grow here —
the seam is the named home, with contract tests per clause.
- `window_stage(candidates)` / `window_take(now)` — the staged
delete-window candidates. Per ADR-008 §2 the durable window table
exists on **every SQL-backed engine** (final wording per ADR-012
§5: sqlite included; `local` and `mem` are excluded from the seam
here and stage candidates in-process, executing the ADR-005 window
protocol inline in the same sweep; in dual-SQL instances the
non-host engine's window clauses are dormant — the one-window-host
rule, ADR-012 §5): staged candidates are
engine rows; what differs by topology is where *liveness* lives
(in-process maps for single-node engines, tables for fleets), not
whether staging exists among SQL-backed engines.
- `sweep_lock_acquire()/release()` — the sweep single-flighter: the
pg advisory lock (ADR-008), applying to the fleet host (postgres)
only — ADR-012 §5 drops the originally-specced sqlite body (no
advisory-lock equivalent; in-process lock suffices and is
ADR-008-verbatim). The
store treats lock contention exactly as ADR-008 says — the second
sweeper's sweep aborts with **`GcAbortCause::SweeperLock` as report
data (`Ok(report)` — ADR-012 §1 retired the typed `GcAborted` sweep
error)**, nothing deleted.
Each clause is contract-tested per its applicability (the
per-engine split above): the conformance suite of §1 grows the fleet
clauses for postgres (the host); pg-lo runs the window-family
contract tests; `local` and
`mem` are excluded by construction.
### 3. The kv tier's fleet-validity rule (the mirror of ADR-008 §3)
**A fleet constructor requires a pool-shared kv engine.** Postgres is
the shipped engine that satisfies this. A sqlite-fleet mechanism does
not exist and is not on offer (per ADR-007's redb precedent this is
recorded as a non-option, not an open question); two nodes' sqlite
engines over "one pool" are two pools — the partitioning failure, the
kv-tier edition of what ADR-008 §3 prevents for the large tier. The
enforceable
seam is the same as ADR-008 §3's: the constructor can require an
explicit declaration; it cannot prove cross-node truth. The
constructor table is pinned in [ADR-011](011-vocabulary-tiers-engines-and-fleet-state.md)
and is closed under both tiers.
### 4. The ops pin-token contract's TTL race, resolved by the ownership rule
**The token *is* the pin — there is no second pin state.** Under fleet
engines the put's pin is the expiry-carrying row (ADR-008); the token
identifies the putter as the row's owner. The token itself is an
**opaque id minted by the ops layer** over the pin row it just
committed — the ops layer (not the store facade) owns the
token↔(key, owner) mapping, in-process state with **process lifetime**
(ADR-012 §2 corrected the original "handler's lifetime" — the handler's
guard drops at response time, but the mapping must outlive it, or
renewal could never arrive); no schema is specced
for it beyond that
(it is a handle, not durable state — the durable state is the pin row
the seam wrote). The token's **validity domain is the minting node**
(ADR-012 §2: renewal probes reach the serving node that minted the
token; fleet peers refuse foreign tokens; a dead minting node is the
ADR-005 crash case with the re-put backstop).
**Wire shapes (ADR-012 §2):** the `blobs/put` response is
`{token, digest}`; renewal rides the token form of `blobs/have`
(one digest, token alongside). Concretely:
- **When the server-side `Pin` guard drops:** at response time. The
put lands pinned (the joint tx guarantees the entry and its pin row
commit together); the handler's in-process guard — the thing ADR-005
pins *in-process* with — is released when the `blobs/put` response
completes and the token is handed out. From that moment the pin
row's expiry is the only protection, which is exactly why the
renewal duty below exists. (If the handler kept its guard for the
token's whole life, the TTL machinery would be dead code on the ops
path — the row's expiry, not the guard's presence, is the fleet
contract's liveness signal.)
- The putter renews the pin by renewing the row — through ops it
already speaks: a `blobs/have` probe whose handler, observing a
valid token presented alongside the digest, calls the
crate-internal `pin_renew(key, owner)` via the ops module's own
store-core access (the ops module is in-crate — it crosses the
crate-internal seam by construction, the same as the put handler
holds the `Pin` guard; what never happens is a *consumer* reaching
the seam. ADR-008's `has` remains a pure probe: the renewal is the
ops layer's token-authenticated act on the pin row, not a `has`
semantic), or a re-put of the same digest (CAS dedup; the re-put's
joint tx upserts the key+owner pin row, renewing it). The renewal
cadence is the one ADR-008 fixed on the pin guard: **every TTL/3 —
20 s at the default 60 s TTL.**
- A putter pausing past TTL falls to ADR-008's expiry semantics
verbatim: the row is abandoned and reaped; the backstop is
delete-then-recover (ADR-005) — the digest is re-offered, the put
is idempotent under CAS. The race resolves to a re-put, never to
silent loss.
- For the `local` engine the token is the in-process guard as ADR-005
had it: nothing persists, nothing expires; the process crash is
ADR-005's in-flight-put loss case with the same backstop. (The
cross-wire token under `local` is the ops layer's in-memory handle
to that guard — same mapping discipline, no expiry.) ADR-012 §2
generalizes this to every non-fleet mode: the token is the
in-process guard's handle on sqlite, single-instance postgres, and
pg-lo serving nodes too — nothing expires outside fleet mode.
ops-surface.md's hand-over contract gains the renewal line and the
token-mapping note; nothing else changes.
## Consequences
**Positive**
- The trait's I/O shapes are decided before code; engine tasks can be
written without guessing signatures. The per-engine
conformance-suite shape (POC #7's 10/10 gate as the norm) gives
decomposition a uniform test gate across all five engines.
- "Backends never learn liveness" survives the fleet mechanism
intact; the store core owns GC state per ADR-005 exactly as
written; ADR-008's mechanism lives in a named, tested seam instead
of an implied raw-SQL escape hatch; ADR-003's "plain rows, nothing
else" stays true of the public trait.
- The ops pin-token TTL race is resolved with no new ops; the
backstop is already-validated semantics (delete-then-recover).
- The kv tier's fleet-validity rule closes the constructor table
under both tiers.
**Negative**
- A crate-internal companion trait is real machinery: the full clause
family on postgres (the fleet host — pins, window, sweep lock,
contract-tested, fleet-active); sqlite and pg-lo carry the window
family only (ADR-012 §5's reduction — the original draft's sqlite
pin/sweep-lock bodies were dead machinery and are dropped). The
seam bounds where fleet-GC growth lands; it does not shrink it.
- Remote putters gain a real protocol duty (renew past 20 s;
pause-past-TTL ⇒ delete-then-recover; route renewal to the minting
node — ADR-012 §2) — documented, but a duty.
**Neutral**
- The `Backend` trait's method set is unchanged from ADR-003/008 —
no public API change beyond what store-api.md already specced; the
cursor/staging abstractions are crate-internal.
- The staged-put contract already existed behaviorally (POC #3 A5);
this ADR names it.
- ADR-009's engine content is unchanged; only its tx-ownership
statement is corrected here.
## References
- ADR-003 (the trait contract this ADR's I/O shapes refine), ADR-004
(tier ≠ engine — the vocabulary is ADR-011's), ADR-005 (the
liveness-ownership rule this ADR preserves), ADR-006 (the range
contract the cursor serves), ADR-008 (the fleet mechanism whose
state home this ADR is), ADR-009 (the pg-lo engine; tx-ownership
correction), [ADR-011](011-vocabulary-tiers-engines-and-fleet-state.md)
(vocabulary + constructor table), [ADR-012](012-pre-decomposition-consistency-rulings.md)
(fleet activation predicate, GC-state host, sweep-abort shape,
token wire shape, the §5 clause reduction this ADR's seam table
now references)
- `docs/research/poc-trait-dispatch-findings.md` finding 1;
`docs/research/poc-largeblob-findings.md` findings A1/A5/A6;
`docs/research/poc-pglo-findings.md` (C1-C7 — the handle shape, the
512 KiB knee, windowed gets)
- [ops-surface.md](../ops-surface.md) (the pin-token contract §4
extends); [store-api.md](../store-api.md) (stage hygiene, sweep
semantics, cursor-adjacent facade shapes); [requirements.md](../requirements.md)
(REQ-2, the fleet requirement §2/§3 serve)
- [ADR-013](013-family-composition-reactive-boundary-and-feature-graph.md)
(family composition — §4's feature-graph extension of this ADR's §1
consequence; §2's sweep-lease semantics for this ADR's sweep-lock
clause)