omnigraph/docs/rfcs/rfc-0018-ingest-wal.md
Andrew Altshuler f758ff0d17
Implement RFC-022 unified graph write protocol (#343)
* Implement unified graph write protocol

* Preserve recovery error wire compatibility
2026-07-11 14:02:54 +03:00

517 lines
32 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.

---
type: spec
title: "RFC-018 — Streaming-ingest WAL on Lance MemWAL"
description: Adds a durability-first streaming ingest path (ack on WAL durability, asynchronous fold into the graph commit chain) built entirely on Lance's MemWAL primitive; reconciled against Lance v8.0.0 and the v9 beta line; analyzed for composition with the upstream multi-table-commit RFCs.
status: superseded
tags: [eng, rfc, wal, ingest, lance, omnigraph]
timestamp: 2026-07-02
owner:
---
# RFC-018 — Streaming-ingest WAL on Lance MemWAL
**Status:** Superseded by [RFC-026](rfc-026-memwal-streaming-ingest.md)
**Date:** 2026-07-02
**Surveyed version:** 0.7.2 (branch `dst-extract-crate`); Lance pinned at 7.0.0
**Upstream surveyed:** Lance v8.0.0 (released; RC votes closed 2026-07-01), v9.0.0-beta.10; MemWAL spec (`lance.org/format/table/mem_wal/`, fetched in full 2026-07-02); discussions #7260, #7264, #7222, #7176
**Audience:** OmniGraph maintainers
> **Supersession note (2026-07-10):** RFC-026 carries the streaming-ingest
> design forward under RFC-022's unified graph-write protocol. It also corrects
> this draft's characterization of MemWAL: MemWAL is a strategic Lance
> architecture and a major substrate investment, not an experimental direction.
> The integration risk is its evolving API and format surface across Lance
> releases.
---
## 0. TL;DR
OmniGraph's write path is optimized for *interactive* graph mutation: every
`mutate_as`/`load` pays the full commit chain (staged Lance commit per touched
table + one `__manifest` publish CAS) before acknowledging, and per-branch
throughput is capped by the `graph_head` row CAS at roughly the object store's
conditional-write rate. That is the right trade for its workload and this RFC
does not touch it. What OmniGraph lacks is a **streaming ingest** path: high-
frequency small writes (event streams, agent telemetry, embedding pipelines)
where per-write ack latency matters and per-write graph commits are wasteful.
This RFC adds one, with a hard constraint set:
1. **The WAL sits in FRONT of the commit point, never beside it.** The
`__manifest` CAS remains the single linearization point for graph
visibility and lineage (the RFC-013 Phase 7 conclusion). The WAL is an
ingestion buffer whose contents *become* graph state only through the
existing publish seam. No second metadata authority is created.
2. **No custom WAL.** Lance ships MemWAL — a complete spec'd LSM layer
(shards, epoch-fenced writers, Arrow-IPC WAL entries, flushed memtables as
Lance tables with pre-built indexes, a MemWAL system index whose merge
progress commits atomically with the merge). Invariant 1 says use it, and
§3 inventories every piece of it we consume.
3. **Ack means durable.** A write is acknowledged only after its WAL entry is
durably on the object store (Lance durability waiters). The deny-list item
"no acks before durable persistence" holds; what moves is *graph
visibility*, which arrives at the next fold. Fresh-tier reads that see
folded-but-unpublished / unfolded WAL data are **explicit, opt-in,
read-only, and documented** (invariant 6's escape hatch, used exactly as
written).
Target substrate is **Lance v8.0.0** (Phase 0 is the bump): v8 is the MemWAL
hardening release (writer-fencing WAL sentinel, durability waiters, LSM read
planners). Delete support arrives with **v9** (tombstones); stream-mode
deletes are phased on it. The design composes cleanly with — and is partially
scaffolding-for — the upstream multi-table commit work (#7260/#7264): §7
analyzes both directions and pins the one design rule (single publish seam)
that makes adopting either a publisher swap rather than a redesign.
---
## 1. Motivation
Today one graph write costs, serially: per touched table a staged write +
`commit_staged` (a Lance commit, itself several object-store round trips),
then one `ManifestBatchPublisher::publish` (merge-insert CAS on `__manifest`).
Latency is the sum of the chain; throughput per branch is bounded by the
shared `graph_head:<branch>` row (the deliberate RFC-013 §7.1 contention
point) at the object store's conditional-write rate — single-digit commits/sec
on S3. `write_cost.rs` keeps this *flat in history*, which is the right
guarantee for the interactive path, but nothing can make it *cheap per op*:
the floor is object-store round trips.
Workloads that don't fit: continuous event/entity streams from agents,
sensor-style feeds, bulk embedding backfills that trickle, any producer that
needs sub-100ms acks or >tens of writes/sec sustained on one branch. Today
such producers must client-side batch into `load` calls, re-implementing an
ingestion buffer badly, with no durability until the batch commits.
The turbopuffer/SlateDB survey (2026-07, conversation record) located the
correct shape: **ack on WAL durability, group-commit, fold asynchronously into
the columnar substrate, keep one CAS commit point.** turbopuffer acks at WAL
durability and bridges reads with a WAL-tail scan; SlateDB acks on WAL flush
and serves cross-node readers only durable published state. Both keep exactly
one commit authority. OmniGraph's commit authority is `__manifest`; the WAL
therefore buffers *in front of* it.
### 1.1 Relationship to PR #318 (warm publish + pinned opens)
PR #318 and this RFC attack different terms of the same budget and compose:
#318 removes the *history-dependent* term (warm attempt-0 publish from
`known_state` drives the per-write `__manifest` scan to 0; pinned `At(v)`
staging opens drop the `_versions/` LIST), making a warm write O(1) in commit
depth. This RFC amortizes the *constant* term that remains — the commit-chain
round-trip floor and the `graph_head` CAS rate — which #318 cannot touch and
which becomes the only wall once #318 lands. Structurally the fold inherits
#318 for free: it publishes only through the `ManifestBatchPublisher` seam,
whose internals (`PublishPlan`, warm/cold attempts) are exactly what #318
changes, and the fold's long-lived coordinator is the ideal warm-path client.
Sequencing notes: (a) §9's concurrency cells should use the
`Cohort<Backend>` multi-coordinator DST harness #318 lands rather than invent
one; (b) Phase 0/1 must sequence against #318's U2 follow-up (internal schema
v4 → v5, the `table_version` row-accumulation wall) so enrollment's
unenforced-PK step lands against v5 rather than straddling the migration;
(c) the ~5% cross-process RC-1 uncovered-drift exposure #318
characterizes-but-tolerates is one more argument for MemWAL's epoch fencing:
ingest tables become the first write surface with a true cross-process
fencing primitive.
## 2. Non-goals and preserved invariants
- **Not a replacement for `mutate_as`/`load`.** The per-query-atomic
interactive path is unchanged, including its "one query = one graph commit"
lineage contract. Stream ingest is a distinct, opt-in surface with a
documented coarser lineage granularity (one fold = one commit).
- **Not a metadata store.** Lineage, branch heads, table versions stay in
`__manifest` rows. Nothing in this RFC writes graph metadata anywhere else.
- **Not a custom WAL / transaction manager** (deny-list). Everything below
the OmniGraph fold logic is Lance MemWAL, spec'd upstream.
- **Not early ack.** `await_durable=false`-style acks (SlateDB's opt-in) are
rejected; invariant 6 and the deny-list stand.
- **Not cross-query transactions.** Branches remain the multi-query
transaction mechanism.
## 3. Substrate inventory — what Lance provides and how we use all of it
Per the lance.md protocol the MemWAL spec and adjacent pages were fetched in
full (2026-07-02). MemWAL is a strategic Lance architecture with substantial
upstream investment. Its API and format surface are still evolving across
releases; §10 treats that maturity boundary as an integration risk. Inventory,
mapped to consumption:
| Lance tooling | Spec/PR | How this RFC uses it |
|---|---|---|
| MemWAL shards; one epoch-fenced writer per shard | spec §Shard | One shard per enrolled `(table, branch)` in Phase 1 (OmniGraph is effectively single-writer per graph today); shard count is a later tunable |
| Sharding specs + transforms (`bucket(murmur3)`, `identity`, …) | spec §Sharding Spec | Phase 2 scale-out: `bucket(key, N)` on the table's `@key` column so each primary key maps to exactly one shard (the spec's last-write-wins correctness requirement) |
| WAL entries: Arrow IPC files, bit-reversed naming (S3 partition spread), strictly sequential positions | spec §WAL | The durable unit. Group commit = batching writes per WAL flush; flush interval/size configurable via `writer_config_defaults` |
| Writer epoch fencing + **WAL sentinel on claim** | spec §Writer Fencing; v8 #7110 | Cross-process safety for ingest writers — a fenced zombie cannot ack lost writes. Note #7110 closes the spec's own documented GC-vs-fencing hazard; v8 is therefore the *minimum* substrate for this RFC |
| Durability waiters (fenced flush surfaces to waiters, not hangs) | v8 #7132 | **The ack point.** `ingest` resolves the client's request when the waiter resolves |
| MemTable + flushed memtables (Lance tables per generation, with pre-built indexes + bloom filter) | spec §MemTable, §Flushed MemTable | The fold input; generation order gives upsert correctness |
| `maintained_indexes` (memtable FTS/vector indexes; vector inherits base PQ/SQ params for distance comparability) | spec §MemWAL Index Details | Phase 2 fresh-tier search: FTS + vector queries stay indexed over unfolded data |
| MemWAL index: `merged_generations` **updated atomically with the merge-insert data commit**, concurrent-merger conflict resolution | spec §Index Details, Appendix 2 | The fold's exactly-once bookkeeping; two racing folds resolve via the conflicting commit's index, no progress regression |
| `index_catchup` progress | spec | Bridges base-table index lag after fold: reads use the flushed memtable's indexes for the gap instead of scanning |
| LSM merging read (`_gen`/`_rowaddr` dedupe), shard pruning, fresh-tier as-of cut, LSM FTS planner, LIMIT/OFFSET pushdown, cache prewarm | spec §Reader Expectations; v8 #7215, #7066, #7256, #7284 | Phase 2 fresh-tier read path — consumed, not reimplemented |
| GC contract (merged + index caught up + no retained version refs) | spec §Garbage Collector | Wired into `omnigraph cleanup`; the "retained version" clause maps to manifest-pinned versions |
| Staged transactions (`execute_uncommitted` for append/delete/merge-insert) | #6781 (merged) | The fold's preferred commit shape (§4.4) |
| `CommitBuilder` commit timeout; `skip_auto_cleanup` | v8 #6773; existing | Fold commit hygiene, same as every existing staged path |
| Tombstone-preserving point lookup; `delete_no_wait`; `Sealed` shard status (drop-table 2PC) | v9 #7482, #7483, #7361 | Phase 3: stream-mode deletes; clean disable/drop of an enrolled table |
| Unenforced primary key (immutable once set) | Lance 7 rule | Enrollment precondition — see §4.1 |
Deliberately **not** consumed: Data Overlay files (v9-adjacent, voted
experimental 2026-06 — a different fast-write shape targeting in-place
overlays; watch-listed, not load-bearing here), MemWAL's multi-shard
horizontal scale-out (deferred to Phase 2 with sharding specs).
## 4. Design
### 4.1 Enrollment
Stream ingest is **per node/edge type**, declared in the schema:
`@stream` annotation on the type. Schema apply records intent (no physical
work — same posture as `@index`). Enrollment's one physical precondition runs
at the first ingest write, not at apply:
- The table's key column must carry Lance's unenforced-PK annotation (MemWAL
needs a PK for last-write-wins upsert and PK lookups). Under Lance ≥7 the
PK is **immutable once set**, so the enrollment step mirrors
`migrate_v1_to_v2`'s guarded shape exactly: key column already the PK →
no-op; no PK → set it; a *different* PK → loud refusal.
- The MemWAL index (sharding spec, `maintained_indexes`, writer config
defaults) is created through the existing index chokepoint discipline:
derived state, idempotent, never fails a logical op.
### 4.2 Write path and ack
A new explicit surface — `omnigraph ingest --stream` / `POST
/graphs/{id}/ingest` (NDJSON stream) — routes rows to a per-`(table, branch)`
`ShardWriter` held by the engine (warm, invariant 15). Per row, **before** the
WAL append, the synchronously-checkable validations run: type/shape, enum,
range/`@check`, required fields, defaults. These fail the row *before*
anything is durable — invariant 9 holds for everything checkable without a
base-table read.
Referential integrity (edge endpoints), cardinality, and cross-row uniqueness
need base-table state and are validated **at fold time** (§4.4). A row that
fails there cannot be un-acked; it lands in a per-graph dead-letter table
(`_ingest_rejects`) with the typed error, surfaced via `ingest status` and the
server API — bounded and observable (invariant 13), never silent, and
documented as the stream-mode contract difference. This is the standard
streaming-system trade, stated honestly rather than hidden.
Ack = the Lance durability waiter for the row's WAL entry resolving. Group
commit falls out of WAL-flush batching; the flush interval is the
latency/throughput knob and lives in `writer_config_defaults` so every writer
process shares it.
### 4.3 Visibility tiers
- **Default reads: unchanged.** A query reads its manifest snapshot
(invariant 3). WAL contents are not graph-visible until folded. Strong
consistency at graph-commit granularity, exactly as today.
- **Fresh-tier reads: explicit opt-in** (query-level annotation, e.g.
`read fresh`). The planner unions base-table-at-snapshot + flushed
memtables + (same-process) the live MemTable via Lance's LSM merging read.
Same-process this is read-your-writes (the spec's strong-consistency
condition: MemTable access + direct shard-manifest reads); cross-process it
is flushed-WAL-consistent (bounded lag = the unflushed MemTable). Both are
documented per-tier. This is invariant 6's explicit/auditable/non-default
clause used precisely as designed — not a silent fallback.
### 4.4 The fold
The fold is the MemWAL *merger* (spec §Background Job Expectations) run under
OmniGraph's commit discipline:
1. Acquire the per-`(table, branch)` write queue (serializes against
interactive writers and other folds in-process).
2. Fold-time validation of pending generations (RI, cardinality, uniqueness
via `loader::composite_unique_key`) against the current snapshot +
generations-in-order; rejects → dead-letter, never a placeholder node
(deny-list).
3. Merge flushed generations in ascending generation order via merge-insert.
**Preferred shape:** `execute_uncommitted` (staged) → `commit_staged`
one `ManifestBatchPublisher::publish` carrying the table version *and* the
graph-commit lineage rows — i.e. the fold is an ordinary OmniGraph writer.
**Open question Q1:** whether the MemWAL index's `merged_generations`
update (which the spec requires to ride the data commit atomically) is
expressible through the staged path. If not, Phase 1 runs the fold's merge
as an inline-commit residual under a `SidecarKind::IngestFold` recovery
sidecar — precisely the `optimize_indices` precedent — and migrates to
staged when upstream exposes it.
4. One fold = **one graph commit** (actor `omnigraph:ingest`, or the
enrolled writer's actor when single). Lineage lands in `__manifest` in the
same CAS as always. Fold triggers: generation count, byte size, max lag —
plus a synchronous `omnigraph ingest fold` for operators and tests.
Crash windows: before WAL flush → row never acked, nothing durable. After
ack, before fold → WAL replays (spec §WAL Replay) under the next claimant's
epoch; acked data cannot be lost. Mid-fold → either the sidecar rolls the
residual forward (Phase 1 inline shape) or the staged state is invisible and
the fold re-runs; `merged_generations`' atomic update makes re-merge of an
already-merged generation impossible (spec Appendix 2). After fold, before
GC → LSM dedupe makes double-reads correct (spec §Reader Consistency).
### 4.5 Branch interactions
Phase 1 rule, chosen for smallness: **branch create/merge/delete on an
enrolled table first drives its WAL to quiescence** (fold-to-empty under the
same write queue). A branch never forks with an un-folded WAL tail, so branch
semantics are untouched. Relaxing this (per-branch WAL lanes) is a Phase 3+
question; MemWAL shards are per-dataset-path, and branches are separate
version lineages, so the mapping exists — it is deferred, not blocked.
### 4.6 Embeddings (`@embed`) interaction
Embedding computation is external and slow; it cannot sit between the client
and the WAL ack. Stream-mode `@embed` columns are therefore computed **at
fold time** (the fold pipeline calls the embedding client on the folded
batch, same code path as `load`). Consequence: memtable-maintained *vector*
indexes cannot cover not-yet-embedded rows — fresh-tier vector search over an
`@embed`-sourced column sees rows only post-fold (fresh-tier FTS and scans
are unaffected). Documented per-column; RFC-015 owns the deeper embedding
pipeline.
## 5. Reconciliation with Lance v8.0.0 (Phase 0 — the bump)
v8.0.0 released ~2026-07-01 (RC3 vote closed). This RFC's Phase 0 is the
7.0.0 → 8.0.0 bump as its own PR with the full lance.md alignment-audit
stanza. Items already identified as load-bearing:
- **MemWAL hardening is the reason v8 is the floor:** #7110 (fencing WAL
sentinel on claim — closes the spec's documented GC-fencing hazard, which
this RFC would otherwise inherit), #7132 (fenced flush surfaces to
durability waiters — without it our ack path can hang), #7215/#7066/#7284/
#7256 (fresh-tier read planning this RFC consumes in Phase 2), #7054
(HNSW params for memtable writers).
- **Blob compaction fix landed** (#7017: all `BlobKind` in blob-v2
`compact_files`): flip `LANCE_SUPPORTS_BLOB_COMPACTION`, remove the
`optimize` skip branch — the `compact_files_still_fails_on_blob_columns`
surface guard turns red on the bump by design and forces this.
- **Critical merge-insert fix** (#7251: silently dropped matches when a
leading payload column is all-null): audit whether any existing OmniGraph
merge path could have hit it (all-null embedding columns in `LoadMode::
Merge` are plausible); add a pinned regression either way.
- **Breaking index-segment migration** (#6869 bitmap→segment-based, #6997
removed segment builder, #7013 BTree on the segmented framework): OmniGraph
builds BTREE/inverted/vector through one chokepoint — expect API churn
there; re-run every `lance_surface_guards.rs` guard first, per the standing
bump protocol.
- **FTS tokenizer default churn** (#6968 ICU default, then #7006 restored
simple): verify the effective default matches what OmniGraph's FTS contract
shipped (Hyrum's-law item — our search results' tokenization is observable
behavior).
- **#7158** (fail-fast casting for indexed columns) intersects schema apply;
**#6916** (index-accelerated filtered `count_rows`) and **#7129** (no
index-file listing after writes) should show up as free improvements in
`warm_read_cost.rs` / `write_cost.rs` — re-baseline, don't loosen budgets.
- **Namespace feature flags in `__manifest`** (#7191): dir-catalog-only, but
re-verify the v7 audit's conclusion that Lance's native `DirectoryNamespace`
stays decoupled from OmniGraph's manifest (that decoupling was contingent
on the legacy boolean PK key — confirm the contingency still holds under
v8's flag writes).
- Re-check the two still-open residual issues on the bump: #6666 (vector
index two-phase) — **still open**, `create_vector_index` stays inline;
#6658 shipped back in 7.0.0 (MR-A unchanged, still pending).
## 6. Reconciliation with the Lance v9 line (beta)
v9 is at beta.10; **not a production target until stable**. What it adds that
this RFC phases on:
- **Stream-mode deletes:** tombstone-preserving point lookup (#7482) and
`ShardWriter::delete_no_wait` (#7483) give MemWAL delete semantics. Phase 3
adopts them; until then stream ingest is insert/upsert-only and delete
remains an interactive-path operation (the D2 rule's scope is unchanged —
note MR-A may retire D2 on the interactive path independently).
- **Enrollment teardown:** `Sealed` shard status + `ShardWriter::abort`
(#7361, the drop-table 2PC fence — also the subject of the 2026-06 format
vote #7418) is the clean disable path for `@stream` removal.
- **#7468** (reject `defer_index_remap` with stable row IDs) touches the
future stable-row-id traversal plans (invariants.md known gap) — flag for
that workstream, not this one.
- **Data Overlay files** (#7401, voted experimental #7447): a different
fast-targeted-write primitive (masking overlays over base files). If it
matures it may fit *small in-place updates* better than WAL-upsert-fold;
watch-listed as a possible Phase 4 refinement, not a dependency.
- MemWAL fixes keep landing on v9 betas (#7489 cross-generation block-list on
in-memory scan arms) — confirming that its API/format integration surface is
still moving (§10) and the value of keeping our exposure transient-state-only.
## 7. Composition with upcoming Lance multi-table commits
Upstream state (verified 2026-07-02): issue #6668 ("Multi-dataset atomic
commit primitive") is **closed into RFC discussions**, not shipped. Two live
proposals:
- **#7260 — batch commit record** (v2, supersedes #6775; authored by this
team — disclosure as in the upstream thread): stage per-table via
`execute_uncommitted` → publish **one immutable record** in a `_txn/` log
via put-if-not-exists → idempotent, reader-completable finalize. Namespace-
reader atomicity; exclusive commit authority per enrolled table (§3.3a
there).
- **#7264 — branching MTT** (authored by the Lance maintainer, jackye1995):
per-transaction shallow-clone branches, one atomic `__manifest` (catalog)
repoint as the commit, leased **barrier commits** to fence the per-table
fast path during rebase, riding #7263 (cross-branch rebase) and #7185
(UUID branch paths). Notably, its Alternative A (fast-forward-main instead
of pointer-swap) is OmniGraph's current architecture verbatim — N dataset
commits + a write-ahead intent record + idempotent roll-forward recovery —
so one plausible upstream outcome is Lance adopting the sidecar shape this
codebase already runs.
Both keep the settled invariants (#7222/#7176): physical files are the
eventual source of truth; put-if-not-exists is the primitive; no per-commit
work on a catalog table. Neither is in v8 or the v9 betas.
**How this RFC composes — the one design rule.** The fold (and every other
writer) reaches publication only through the `ManifestBatchPublisher` seam.
That is already true and this RFC keeps it true. Consequences:
- **Under a #7260-shaped primitive:** the fold's N staged table commits + the
manifest publish collapse into operations of one batch record. The residual
Lance-HEAD-before-manifest gap — the entire reason recovery sidecars exist
(invariant 5) — disappears *by construction*, and the sidecar machinery
(including this RFC's possible `SidecarKind::IngestFold`) becomes removable
scaffolding. This is exactly the degrade-to-no-op shape invariants.md says
to prefer. OmniGraph's `__manifest` remains as the *graph-semantic* layer
(lineage rows, branch heads, snapshot pinning) — the batch record is a
commit mechanism, not a lineage store; graph_commit rows simply become one
more operation in the batch, keeping lineage atomic with visibility as
today. Adoption = swapping the publisher's internals; WAL, fold logic, and
read paths are untouched.
- **Under a #7264-shaped primitive:** OmniGraph's manifest *is* the catalog
in that RFC's vocabulary, and OmniGraph already runs the consumer-side
pattern (lazy per-write forks ≈ transaction branches; publish CAS ≈ the
atomic repoint). The genuinely new upstream piece — the **leased barrier
commit** — is independently valuable to OmniGraph: it is a concrete
candidate for the cross-process serialization primitive that two documented
known gaps explicitly wait on (recovery-sweep-vs-foreign-live-writer;
cross-process fork reclaim). One caveat, already raised on the upstream
thread (ragnorc, 2026-06-13): a TTL lease alone cannot make "barrier held +
tip unmoved + repoint" atomic — if the critical section outruns the lease,
a competing writer breaks the barrier and advances the tip, and the
coordinator's repoint silently loses that commit — so the barrier needs an
epoch/generation, not just a TTL. Any OmniGraph adoption inherits that
requirement. If #7264 lands, adopt the (epoch-carrying) barrier for those
gaps even if the fold never uses MTT directly.
**Update (2026-07-05):** the 2026-07-03 comment on #7264 proposes the
unification directly — detached-commit staging + the #7260 record as the
intent + fast-forward-main promotion, prototype-validated (24-test probe
suite). The barrier is now a slot-occupying content-identical commit
(epoch in the record log, enforced in the writer's commit path), answering
the TTL-lease caveat above structurally. Measured: ~450600 ms per
transaction, ~67 txn/s on S3 with **group commit as the lever** — i.e.
the same amortization shape as this RFC's fold, strengthening Phase 4's
"publisher swap, not redesign" claim with numbers. The record log also
carries a durable pruned-through GC boundary (advance-before-delete,
writer re-verifies after put) — see open question 4.
- **MemWAL + MTT together:** the fold's merge commit, its `merged_generations`
index update, and the manifest publish could all join one batch — closing
Q1 (§4.4) from above rather than below.
The WAL is upstream of the commit point in every scenario; the commit point's
mechanics can change under it freely. That orthogonality is the payoff of
constraint 1 (§0) and the reason this RFC refuses any design where WAL state
participates in publication authority.
## 8. Invariants and deny-list walk
- **1 Respect the substrate:** MemWAL consumed as spec'd; zero WAL code owned
(§3). ✅
- **2 Manifest-atomic visibility:** WAL data becomes graph-visible only via
the fold's single publish; fresh-tier is explicitly outside graph
visibility. ✅
- **3 One snapshot per query:** unchanged; fresh-tier unions the snapshot
with WAL tiers *at plan time*, never re-reads head mid-query. ✅
- **4 One publish boundary per mutation:** the fold is one boundary. ✅
- **5 Recovery coverage:** fold Phase 1 residual (if Q1 forces inline) gets
`SidecarKind::IngestFold`; WAL-side recovery is Lance's replay + fencing. ✅
- **6 Strong consistency default:** ack = durable; default reads unchanged;
fresh-tier is the invariant's own explicit/read-only/auditable/non-default
clause. ✅
- **7 Indexes derived:** memtable maintained indexes + `index_catchup` are
Lance's implementation of this invariant; `@embed` vector gap documented
(§4.6). ✅
- **9 Loud integrity:** sync-checkable validation pre-ack; fold-time RI to a
dead-letter table with typed errors — a *documented contract difference*,
not a silent weakening; no placeholder nodes ever. ⚠️ documented
- **13 Bounded/observable failures:** dead-letter + `ingest status` + fold
lag metrics on the observability surface. ✅
- **15 One source of truth, cheaply derived:** ShardWriter handles held warm;
shard-manifest `version_hint` is the cheap probe; WAL state is transient
(folded + GC'd), so nothing long-lived can drift. ✅
- **Deny-list sweep:** no custom WAL (Lance's); no acks before durability;
no per-table publish outside the publisher (fold uses it); no job queue for
manifest-derivable state (the fold is a reconciler over MemWAL state, which
is NOT manifest-derivable — it is upstream input, the one shape the rule
permits); no silent eventual consistency (explicit tier); no raw FS I/O
(WAL files are Lance-managed inside the table dir). ✅
## 9. Testing plan (per testing.md)
- **Surface guards** (`lance_surface_guards.rs`): pin ShardWriter claim/
append/flush shapes, durability-waiter semantics, `merged_generations`
atomic-update behavior, tombstone APIs when v9 lands. Bump protocol
unchanged: guards run first.
- **Failpoints:** WAL-append failure → no ack, zero durable state; crash
after ack before fold → replay recovers the row (the acked-implies-durable
oracle); fold crash windows (each side of `commit_staged`/publish, sidecar
lifecycle if inline); two folds racing via `Rendezvous` → exactly-once via
`merged_generations`; fencing: zombie writer post-claim cannot ack.
- **DST:** concurrency cells ride PR #318's `Cohort<Backend>` multi-
coordinator harness (in-process and two-process tiers) — two coordinators
ingesting + folding the same graph must converge to one linear chain;
extend the op alphabet with `ingest` + `fold` ops; model gains a
"pending" row set; oracles: acked rows survive any crash, fold equivalence
(model == graph after fold), fresh-tier read-your-writes in-process;
`FaultAdapter` covers WAL object I/O; S3 battery cell for the WAL path.
- **Cost budgets** (`helpers::cost`): ack-path object-store ops O(1) and flat
in history *and* in WAL depth; fold cost bounded by generations folded
(working set), never by table history; extend `write_cost.rs` +
`write_cost_s3.rs` (the opener term is S3-only, per the backend-split
note).
- **CLI parity:** `ingest --stream` in `parity_matrix.rs` (embedded vs
remote) and a DST cross-backend cell.
## 10. Risks
- **MemWAL's API and format surface continues to evolve.** This risk concerns beta-era
API churn, not architectural commitment: Lance has made MemWAL a strategic
streaming-write investment. Mitigation is structural: WAL state is
*transient* (folded then GC'd), so a format change between Lance versions
can be handled by fold-to-quiescent before the bump; no long-lived on-disk
state depends on the MemWAL format. This must stay true — resist any
future temptation to park durable state in WAL form.
- **Q1 (staged fold)** may force the inline-residual shape initially — a
known, sidecar-covered, deny-list-documented pattern with two precedents.
- **Dead-letter semantics** are a real contract change for stream mode;
user-docs must present it before the endpoint ships (maintenance contract
rule 1).
## 11. Phasing
| Phase | Deliverable | Gate |
|---|---|---|
| 0 | Lance 7.0.0 → 8.0.0 bump: full alignment audit stanza, guards re-run, blob gate flipped, #7251 audit | v8.0.0 (released) |
| 1 | `@stream` enrollment; single-shard ShardWriter per (table, branch); sync validation; ack on durability waiter; fold (staged if Q1 allows, else sidecar-covered inline) publishing one graph commit; manifest-tier reads only; dead-letter + `ingest status`; branch-ops fold-to-quiescent | Phase 0 |
| 2 | Fresh-tier reads (explicit opt-in); `maintained_indexes` (FTS + vector, `@embed` caveat); multi-shard via sharding specs; group-commit tuning | Phase 1 |
| 3 | Stream-mode deletes (tombstones); `@stream` removal via Sealed/abort; per-branch WAL lanes exploration | Lance v9 stable |
| 4 | Adopt the multi-table commit primitive (#7260/#7264 outcome): publisher-seam swap, retire fold sidecars; evaluate barrier commits for the cross-process known gaps | upstream vote/ship |
## 12. Open questions
1. **Q1:** Can `merged_generations` ride `execute_uncommitted` +
`commit_staged`, or is the fold's merge inline-only today? (Determines
Phase 1 shape; upstream question — possibly a #6781-shaped PR we offer.)
2. Fold-time RI rejects: dead-letter only, or optional strict mode where a
fold halts (back-pressuring ingest) on the first reject?
3. Actor granularity in fold lineage: single ingest actor vs. per-WAL-entry
actor aggregation into commit metadata.
4. `cleanup` integration ordering with the Q8 cleanup-resurrection watermark
(the internal-tables GC gap) — does WAL GC land before or after it?
**Note (2026-07-05):** the #7264 record-log design carries exactly the
durable GC-boundary primitive `iss-cleanup-boundary-watermark` asks for
(GC advances the boundary before deleting; a writer re-verifies
`seq > boundary` after a successful put). If that ships, the watermark
arrives as substrate rather than omnigraph protocol — factor into the
build-vs-wait decision on that P1.
5. Should fresh-tier be exposed over HTTP in Phase 2, or engine/CLI-only
until the consistency-tier docs mature?