Files
alkblobs/docs/architecture/store-api.md
T
glm-5.3-flash 9bbe6f3ad9 docs(architecture): ADR-009 — pg-lo admitted as the fs tier's second engine on POC #7
- ADR-009 (new): postgres Large Objects ship as the fs tier's second
  engine (feature pg-lo, default-off), admitted on POC #7's passed
  gate (contract 10/10, durable put ~= fs at >=1 MiB, lo_get window
  gets, transactional LO lifecycle = zero crash orphans). Engine
  shape: companion table (lo_entries) is the contract authority; put =
  one tx (lo_create + lowrite + row + pin); get = descriptorless
  lo_get windows (lo_*64 discipline); named operator deltas (catalog
  space never returned / autovacuum inherited / sweep for the legacy
  bypass class only) and named performance deltas (cached-get 20-50x
  behind page-cache fs single-stream, ~700 MB/s aggregate at 16
  readers = the fleet serving picture).
- Ripple: backends-and-dispatch (pg-lo shipped; fleet locality
  contract gains the whole-fleet-pg-lo option), overview (layer map,
  feature table + pg-lo row, OQ -> shipped pointer), requirements
  (engine def + REQ-2 note updated), README (current-state + ADR
  table), store-api (invariant 1/6 engine lists), ADR-008 Status
  amendment (pg-lo posture superseded by ADR-009), open-questions
  (audit trail completed: decided -> sequenced -> completed).

Verification: docs-only; all cross-references resolve (ADR/REQ/POC);
register, findings, spec, and both engine-ADR status lines agree.
2026-10-03 04:30:31 +00:00

8.9 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-10-03

Store API

What this is

The store core's public surface: the typed facade above the backends 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): which kv/fs engine a node runs changes nothing here except fleet-GC mechanism wiring (ADR-008).

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 (fs 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).
  • 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 a file handle. Dispatch fall-through: a small-tier miss queries the large tier (POC #1 finding 6).
  • 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 the fs local engine — 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), delete(key) — deletion participates in ADR-005's delete windows; direct deletes are permitted but refuse protected keys — pinned or re-observed live via registered sources (the same per-key arbitration sweeps apply, typed error) — and are unusual by posture; most deletion flows through sweeps.
  • 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).
  • sweep() → SweepReport — explicit mark-and-sweep; embedders own the cadence (ADR-005, no ambient timers). A sweep with no registered liveness sources aborts without deleting — the safe default. Under a fleet (multiple nodes 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, one sweeper holds the advisory lock, and delete arbitration is SQL-level — the facade's invariant text is unchanged.

Error model

thiserror; no panics in library code; no unwrap/expect outside tests (AGENTS convention 2). Distinguished failure families:

  • Missing — key absent (get/stat miss)
  • Verification — put/get hash-check failure (content ≠ key)
  • Io(String) — backend media failure (stringly because std::io::Error is not stable across versions)
  • GcAborted — nothing deleted; sweep not run. Two cause classes (ADR-005: a protection source failed; ADR-008: another node holds the fleet sweeper lock) — the typed error distinguishes them.
  • KeyInvalid — malformed key bytes at the boundary (hashing doc)

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; fs: local + pg-lo; ADR-007/008/009) (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-backend: kv probe, fs stat, pg-lo companion-row probe — ADR-008/009).

Design Decisions

ADR Decision Summary
002 Hashing & keys derivation + encoding this surface is built on
003 Dispatch routing is a pure function of length; pre-threshold buffering
005 Pools & GC pins, liveness seams, delete windows, sweep semantics
006 Verification whole-blob checks; slice digests out-of-band
008 Trait size + fleet GC stat/accounting ride the trait's size probe; fleet pins/sweeps are engine-backed

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)
  • ops-surface.md — the fanout/fetch consumer of the read path
  • gc-and-namespaces.md — the lifecycle seam details