Files
alkblobs/docs/architecture/store-api.md
T
glm-5.3-flash 6f6ab19b8e docs(architecture): fix YAML-invalid frontmatter; changelog text moves to notes
- last_updated annotations containing ': ' ('family composition: ...',
  'feature graph re-pinned: ...', 'REQ-5 added: ...') broke Gitea's
  frontmatter parser in docs/architecture/README.md,
  backends-and-dispatch.md, and requirements.md
- last_updated is now a bare date; the annotation lives in a quoted
  notes field across all annotated architecture docs
- convention documented in .opencode/agents/architect.md: bare
  last_updated date, quoted notes, quotes required for ': ' scalars

Verification: all docs/**/*.md frontmatter parsed with yaml.safe_load
(16/16 OK); cargo test, clippy --all-targets -D warnings, fmt --check pass
2026-10-11 17:20:50 +00:00

257 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-10-11
notes: "ADR-013 — the fleet sweeper lock's semantics pinned as the named exclusive sweep lease; provider unchanged; composition boundary referenced"
---
# Store API
## What this is
The store core's public surface: the typed facade above the tier
engines that consumers (alkgit, alkfs, the ops module) program against.
It owns typed keys and hashing (ADR-002), size-threshold dispatch
(ADR-003), the put-pinning seam, and the liveness/sweep seam (ADR-005)
— and nothing else: no paths, no manifests, no wire (ADR-004, ADR-001).
The facade is **engine-blind** (ADR-007/008/010): which engines a node
runs changes nothing here except fleet-GC mechanism wiring (ADR-008's
mechanism lives in the engine-state seam, ADR-010 §2).
## Public surface
### Write path
- **`put(len: Option<u64>, stream)` → `(Key, Pin)`** — the streaming
put seam (POC #3 finding A1). Two internal paths behind one signature:
- *Known length* (encouraged, documented as such on the API): the
preamble hashes before content flows; one pass, no staging needed
for hashing. Covers git objects, pre-staged files (`stat`-derived
length), network offers, `Content-Length`-bearing uploads.
- *Unknown length*: content buffers in memory up to the dispatch
threshold; on commit the buffer is the whole stored artifact
(kv tier) — or, on mid-stream threshold overflow, the buffer
flushes into a stage file and the stream continues into it
(large tier — the streaming pass cannot include the preamble, so
the derivation pass restarts over preamble + staged bytes; forced
by the preamble being inside the hashed input, not a design
choice). Verification runs over the eventual stored artifact as a
whole; a stream error on either path leaves no entry and no stage
file. Eliminates the A6 dispatch asymmetry by construction:
routing is a pure function of content length (ADR-003). The large
tier's stage mechanics are the **staged-put contract** (ADR-010
§1): the engine hands out a staging cursor (`local`: the stage file;
pg-lo: chunked `lowrite` statements inside its tx), commit
publishes, every failure path discards.
- **Stage-file hygiene invariant** (POC #3 finding A5, codified): every
failure path converges on stage-discard; every success path converges
on commit-rename. No early returns that bypass cleanup; this is an
asserted invariant with dedicated exact-count tests, not review
hygiene.
- **`Pin` / `Batch`** — RAII guard on the put path (ADR-005): the
entry is liveness-protected until the guard drops or the caller
converts it into a consumer-side reference (registered liveness, a
root tag). **`Batch`** groups multi-put writes under one batch-scoped
pin (manifest writes are exactly this); a batch's pins drop together
on batch commit or drop (ADR-005 §Decision).
### Read path
- **`get(key) → Option<(len, stream)>`** — small tier returns a
bounded read; large tier streams (`local`: a pread loop; pg-lo: `lo_get`
windows). The per-engine shapes are hidden behind the crate-internal
read cursor the store core drives (ADR-010 §1 — engines construct
their own cursor bodies; the facade's shape is uniform). Dispatch
fall-through: a small-tier miss queries the large tier (POC #1
finding 6 — the general rule, ADR-012 §6.1: every key-addressed op
— `get`, `stat`, `has`, `delete`, `read_range` — consults the kv
tier first, then the large tier). A miss on both tiers is a `None`
(get/stat) or `Err(Missing)` (read_range/delete) — see the error
model.
- **`stat(key) → Option<EntryMeta>`** — cheap length/type probe
(phase-0 OQ-BL-04 addendum; gix's header-only read is git's cheapest
primitive and the packfile-serving surface rides this). `EntryMeta`
carries `len: u64` and the key's algorithm (the "type" from the
store's perspective is the hash algorithm — there is no other type at
this layer). `stat` rides the `Backend` trait's `size` method
(ADR-008): an indexed-column probe on kv engines, a file stat on
`local`, a companion-row probe on pg-lo — never a value/content
load.
- **`read_range(key, range) → (slice, slice_digest)`** — slice plus an
out-of-band slice digest: SHA-256 over the slice bytes, fixed by
convention so callers and ops handlers agree without negotiation
(per-range verification against the canonical digest is impossible —
ADR-006). Range reads fall through tiers exactly as `get` does
(small-tier miss slices the large tier's value). Local range serving
(packfiles) is the consumer of this.
- **`get_stream` / fanout** — the ops module's fetch handler is a
broadcast above the store: one reader, store arm + subscriber
arms (POC #3 finding A3); late joiners degrade to normal verified
`get` after commit. The store exposes the seam; the fanout policy
lives in the ops layer (ADR-001).
### Lifecycle
- **`has(key) → bool`** — the pure existence probe (ADR-008's
`has` discipline; a `size`-probe under the dispatch general rule —
absent on both tiers is `false`; virgin-store reads are no-ops).
- **`delete(key) → Result<(), Error>`** — falls through tiers per the
dispatch general rule above. Participates in ADR-005's
delete windows; direct deletes are permitted but refuse protected
keys — pinned, re-observed live via registered sources, or named by
the protect callback (ADR-012 §6.4: the full protection set, not
just pins — the ADR-005 invariant applied to the direct path) —
with the typed `GcRefuse` error. Direct deletes are unusual by
posture; most deletion flows through sweeps. An embedder's
correction flow deregisters/re-registers around the delete.
- **`list() → stream of keys`** — whole-pool enumeration; complete by
contract (see backends doc for why list correctness is load-bearing).
- **`register_liveness_source(...)`** — the live-shared GC seam
(ADR-005, §Decision; the POC's "install implies copy" failure
(finding 7) is why the verb/name is pinned here). Fleet form (ADR-012
§6.6): the same verb registers a **named liveness table reference**
in the shared engine (the embedder's own table per ADR-008 §2 — the
store core reads it, never owns it); in-process callback sources
remain the single-node form.
- **`sweep() → Result<SweepReport, Error>`** — explicit mark-and-sweep; embedders own
the cadence (ADR-005, no ambient timers). **Sweeps are never
errors: an aborted sweep returns `Ok(SweepReport)` with
`aborted: Some(GcAbortCause)` — there is no sweep-abort error
variant** (ADR-012 §1; the previously specced `GcAborted` sweep
error is retired — see the error model). A sweep with no registered
liveness sources aborts without deleting — the safe default —
carrying `GcAbortCause::NoLivenessSources`.
`SweepReport`'s shape: `candidates_staged`, `deleted`,
`cancelled_by_arbitration` (u64 counts), `aborted:
Option<GcAbortCause>`, and `started_at`/`finished_at`. `GcAbortCause`
is a data enum with exactly three variants — `ProtectFailed`
(protection-source failure, ADR-005), `SweeperLock` (fleet
sweeper-lock contention, ADR-008), `NoLivenessSources` (the safe
default above); callers match non-exhaustively; new causes are new
ADRs (ADR-012 §1). The direct-delete refusal path is a *different*
type: the `GcRefuse` error (above), not a sweep outcome.
Staged-window candidates overdue at sweep time are re-arbitrated
and executed by that sweep (the executor is the next sweep —
ADR-012 §6.7; non-SQL engines execute their window inline within
the same sweep, ADR-012 §5).
Under a fleet (multiple store instances over one pool —
requirements.md REQ-2), sweep/put/delete semantics are
mechanism-extended by ADR-008: pins are DB-backed rows committed
atomically with entries (the store core holds the joint tx,
ADR-010 §2; all GC state hosts on the fleet's kv engine —
ADR-012 §4), one sweeper holds the advisory lock (named exclusive
sweep lease semantics: one sweeper per pool, atomic test-and-set,
holdership ends at release/owner death/engine-session loss;
contention is a report abort, not an error — ADR-012 §1, semantics
pinned ADR-013 §2), and delete
arbitration is SQL-level — the facade's invariant text is
unchanged. The facade owns no reactive machinery: cadence,
maintenance dispatch, and invalidation wiring are embedder
composition above it (ADR-005; ADR-013 §1).
## Error model
`thiserror`; no panics in library code; no `unwrap`/`expect` outside
tests (AGENTS convention 2). Distinguished failure families:
- `Missing` — key absent on ops that signal absence via `Err`:
`read_range` and `delete` (ADR-012 §6.5). `get` and `stat` signal
absence with `None` — `Missing` is not theirs.
- `Verification` — put/get hash-check failure (content ≠ key)
- `Io(String)` — backend media failure, covering cursor and staged-put
I/O errors surface through the same trait error family (stringly
because `std::io::Error` is not stable across versions)
- `GcRefuse` — direct delete refused: the key is protected (pinned,
registered-source-live, or protect-callback-named) — nothing
deleted; the delete-window arbitration decides at delete time
(ADR-005; named `GcRefuse` per ADR-012 §1 — there is no
sweep-abort error).
- `KeyInvalid` — malformed key bytes at the boundary (truncated,
unknown algorithm — rejects, never guesses; ADR-002/hashing doc).
One enum, both altitudes: the **facade's** error enum exposes
`Missing`/`Verification`/`Io`/`GcRefuse`/`KeyInvalid`; the
`Backend` trait's methods return the same family as crate-internal
variants — `KeyInvalid` never fires at the trait boundary (keys
arrive opaque there) and `GcRefuse` never fires below the facade
(arbitration answers flow up as data; the facade decides and owns
the refusal), so the trait's effective members are
`Missing`/`Verification`/`Io`/`KeyInvalid`; the facade re-exposes
them with `GcRefuse` added (ADR-012 §6.5's family statement reads
through this split).
Virgin-store semantics: read paths on a fresh store see absent/empty,
never "table does not exist" errors (POC #1 finding 2 — the redb
lesson generalizes to any kv engine).
## Concurrency posture
- Async I/O throughout; `tokio::sync` for lifecycle correlation;
`parking_lot` for short-held internal locks (AGENTS convention 3);
poisoned locks degrade via `unwrap_or_else(|e| e.into_inner())`.
- Blocking file work lives in `spawn_blocking` inside backend impls
(the alkgit trait-execution pattern); the store never blocks the
executor.
- The sweep-vs-put window is an architectural mechanism — delete
windows — specified in ADR-005, not an implementation note.
## Invariants (the test gate)
1. Whole-blob put/get round-trips byte-identical under the canonical
derivation, both tiers, both put paths, **and every shipped engine
of both tiers** (kv: sqlite + postgres + mem; large: `local` +
pg-lo; ADR-007/008/009/011)
(interop with real git remains the source of truth, per POC #1's
lesson about hardcoded vectors).
2. Dedup: putting identical content twice (same or different path) is
one pool entry.
3. Stage hygiene: any failure mid-put leaves zero stage files; any
success leaves exactly one committed entry.
4. Sweep safety: with correct liveness registered, sweep counts are
exact; with aborting sources, sweep deletes nothing. **Engine-gated
form (ADR-008):** single-node postures assert this under the
in-process protocol; fleet postures (shared-pool engines) assert it
under DB-backed pins + the advisory-locked sweep, with the
exact-count sweep-outcome shape run against dockerized postgres
under `--all-features`.
5. `list()` correctness is observable only through GC — list-related
tests assert through sweep outcomes (POC #1 finding 2's lesson
codified).
6. `size`/`stat` on any engine returns the entry length without
fetching content (asserted per engine: kv row probe, `local` stat,
pg-lo companion-row probe — ADR-008/009).
7. **Engine-conformance uniformity (ADR-010 §1):** every engine's
cursor/stage/list bodies pass the same contract suite mem
(the reference engine) passes — exact-count sweep outcomes, stage
hygiene, virgin-store no-ops, CAS re-put, ranged reads (the POC #7
gate is the norm; per-engine suite runs in CI, `--all-features` for
the feature-gated engines).
## Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| [002](decisions/002-canonical-hash-and-key-encoding.md) | Hashing & keys | derivation + encoding this surface is built on |
| [003](decisions/003-backend-contract-and-dispatch.md) | Dispatch | routing is a pure function of length; pre-threshold buffering |
| [005](decisions/005-pooled-cas-and-gc-mechanism.md) | Pools & GC | pins, liveness seams, delete windows, sweep semantics |
| [006](decisions/006-verification-posture-and-transfer-encoding.md) | Verification | whole-blob checks; slice digests out-of-band |
| [008](decisions/008-trait-size-probe-fleet-gc-and-large-engines.md) | Trait `size` + fleet GC | stat/accounting ride the trait's `size` probe; fleet pins/sweeps are engine-backed |
| [010](decisions/010-backend-io-seams-and-fleet-state-home.md) | I/O seams + GC-state home | read cursor / staged put behind the facade; the store core holds the joint entry+pin tx |
| [012](decisions/012-pre-decomposition-consistency-rulings.md) | Sweep-abort shape + wire/activation rulings | swept = Ok(report) always, `GcRefuse` for direct-delete refusal; fleet mode is a constructor declaration; GC state hosts on the kv engine |
| [013](decisions/013-family-composition-reactive-boundary-and-feature-graph.md) | Sweep lease + composition boundary | the advisory lock's semantics = named exclusive sweep lease (provider unchanged); no reactive machinery/facade hooks in-core |
## Open Questions
None owned by this document beyond the cross-references above.
## References
- `docs/research/poc-largeblob-findings.md` findings A1/A5/A6,
benchmark table
- `docs/research/poc-trait-dispatch-findings.md` findings 1–3, 7
- ADR-003 (put-path buffering), ADR-005 (pin/Batch/Pin lifecycle),
ADR-006 (range-read semantics), ADR-002 (keys), ADR-012 (the
sweep-abort shape, error-model ruling, fall-through general rule)
- [ops-surface.md](ops-surface.md) — the fanout/fetch consumer of the
read path
- [gc-and-namespaces.md](gc-and-namespaces.md) — the lifecycle seam
details