mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
1204 lines
78 KiB
Markdown
1204 lines
78 KiB
Markdown
|
|
# RFC-013: Write-path latency — capture-once `WriteTxn`, manifest-authoritative publish, bounded history, and a measured cost contract
|
|||
|
|
|
|||
|
|
**Status:** Proposed
|
|||
|
|
**Author(s):** write-path latency investigation (handoff + multi-agent validation)
|
|||
|
|
**Date:** 2026-06-19
|
|||
|
|
**Audience:** engine / storage maintainers
|
|||
|
|
**Builds on:**
|
|||
|
|
[rfc-009-unify-access-paths.md](rfc-009-unify-access-paths.md) (`GraphClient` — embedded ≡ remote),
|
|||
|
|
the query-latency work (PR #268, read-path warm-up — the read-side twin of this change),
|
|||
|
|
the iss-991 handoff (manifest-authoritative graph lineage / Phase 7),
|
|||
|
|
[writes.md](writes.md), [execution.md](execution.md), [invariants.md](invariants.md).
|
|||
|
|
**Tracking (dev graph `modernrelay`):** primary `iss-write-s3-roundtrip-amplification`; depth term `iss-991`; substrate seam `iss-863`/`iss-864`; branch-create `iss-691`; recovery `iss-856`/`iss-recovery-sweep-live-writer-rollback`/`iss-merge-recovery-partial-rollforward`; MemWAL `iss-681`; read twin `gap-read-path-rederivation`.
|
|||
|
|
|
|||
|
|
> Status maintained by maintainers: `Proposed` while open, `Accepted` on merge.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
On object-store-backed clusters a single trivial write (one edge, one branch op)
|
|||
|
|
issues **hundreds of mostly-sequential object-store round-trips**, and that count
|
|||
|
|
**grows without bound with the graph's commit history**, so a long-lived graph
|
|||
|
|
degrades to minutes per edge. The cost is invisible on a local filesystem
|
|||
|
|
(µs/call) and to correctness tests (results are right, just slow), and it was
|
|||
|
|
never measured because nothing in the suite counts *object-store round-trips per
|
|||
|
|
logical operation*.
|
|||
|
|
|
|||
|
|
This RFC specifies the optimal write path from first principles — **a write is a
|
|||
|
|
pure function of one version-pinned snapshot, published in a single
|
|||
|
|
manifest-atomic CAS** — and the **cost contract that makes its O(1)-in-history
|
|||
|
|
guarantee provable and non-regressable** (deterministic IO-counted tests on every
|
|||
|
|
PR). It collapses four hand-rolled writers into one `GraphPublishAuthority`,
|
|||
|
|
moves graph lineage into the manifest (so the per-write `_graph_commits` scan
|
|||
|
|
disappears), brings the internal metadata tables into compaction (so the
|
|||
|
|
per-write `__manifest` scan stops growing), takes recovery off the hot path, and
|
|||
|
|
adds an epoch fence for multi-writer safety. None of it is a substrate rewrite —
|
|||
|
|
the manifest-CAS model is already correct and is exactly what Lance native
|
|||
|
|
multi-table transactions (lance#7264) will later formalize; this RFC builds the
|
|||
|
|
seam to that future and pays down the write path onto it.
|
|||
|
|
|
|||
|
|
**The dominant fix is demonstrated, not proposed:** a one-line opener-bypass
|
|||
|
|
prototype (open writes direct-by-URI instead of through the lance-namespace builder)
|
|||
|
|
flattens the depth-dominant term `31 + 12·depth → flat 4` and cuts a depth-80 edge
|
|||
|
|
**2.7×** (1618 → 593 ops), measured end-to-end and functionally correct on
|
|||
|
|
main/branch/node paths (§2.4). It is shippable as a standalone PR first (§9 step
|
|||
|
|
3a); the rest of the RFC is the constant-factor + correctness + internal-residual
|
|||
|
|
work layered on the same seam.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 0. Validation ledger (read this first)
|
|||
|
|
|
|||
|
|
Every claim is tagged: **[M]** measured by me this cycle, **[S]** verified in
|
|||
|
|
v0.7.0 source (`file:line` given), **[U]** verified against upstream
|
|||
|
|
Lance/LanceDB/SlateDB source or docs, **[G]** tracked in the dev graph (slug
|
|||
|
|
given), **[I]** inferred/reasoned.
|
|||
|
|
|
|||
|
|
A correction from the originating handoff: it hypothesized that **Cloudflare R2
|
|||
|
|
walks the full manifest listing on every open** (a prod-only amplifier absent
|
|||
|
|
from AWS). **This is false for the pinned Lance 7.0.0 [U].** R2 is treated as
|
|||
|
|
lexically ordered (`list_is_lexically_ordered = !is_s3_express`,
|
|||
|
|
`lance-io/.../providers/aws.rs:183`), so R2 gets the O(1) head-only manifest fast
|
|||
|
|
path, same as AWS; only S3-Express buckets are excluded, and even those are O(1)
|
|||
|
|
via the v7 `latest_version_hint.json`. There is no R2-list config fix because
|
|||
|
|
there is no R2-list problem.
|
|||
|
|
|
|||
|
|
**The depth term — corrected attribution.** Two measurements, one
|
|||
|
|
instrumentation-blind, one complete:
|
|||
|
|
|
|||
|
|
*(a) IOTracker probe [M] — internal tables only.* A throwaway probe (the
|
|||
|
|
`warm_read_cost` harness applied to a single insert to `main`, swept across
|
|||
|
|
commit depth) counted the two internal tables: `__manifest` ≈ 14 + 2·depth,
|
|||
|
|
`_graph_commits` ≈ 9 + 2·depth → ≈ 23 + 4·depth, `write_iops = 1`. **But this
|
|||
|
|
probe is structurally blind to the write path's per-table *data* opens** — they
|
|||
|
|
bypass the instrumented opener (`table_wrapper`), so it reports `probes=0` for the
|
|||
|
|
data tables. It measured the *minority* of the cost.
|
|||
|
|
|
|||
|
|
*(b) Network-proxy measurement [M] — all RPCs, fresh graph.* A counting proxy in
|
|||
|
|
front of `rustfs` (sees every object-store RPC, under `--mode merge` — the
|
|||
|
|
production path), on a brand-new graph (400 seed nodes, one committing merge per
|
|||
|
|
checkpoint), classified by S3 key:
|
|||
|
|
|
|||
|
|
| commit depth | data `_versions` | `__manifest` | `_graph_commits` | node (RI) | schema | TOTAL | `write_iops` |
|
|||
|
|
|---:|---:|---:|---:|---:|---:|---:|---:|
|
|||
|
|
| 0 | 31 | 29 | 13 | 6 | 46 | 156 | 1 |
|
|||
|
|
| 5 | 121 | 44 | 23 | 6 | 46 | 268 | 1 |
|
|||
|
|
| 10 | 181 | 59 | 33 | 6 | 46 | 358 | 1 |
|
|||
|
|
| 20 | 301 | 89 | 53 | 6 | 46 | 538 | 1 |
|
|||
|
|
| 40 | 541 | 149 | 93 | 6 | 46 | 898 | 1 |
|
|||
|
|
| 80 | 1021 | 269 | 173 | 6 | 46 | 1618 | 1 |
|
|||
|
|
|
|||
|
|
Slopes: **data table +12/depth (~67%)**, `__manifest` +3/depth, `_graph_commits`
|
|||
|
|
+2/depth → **TOTAL ≈ 156 + 18·depth**, `write_iops` flat at 1. The IOTracker probe
|
|||
|
|
(a) saw only the +4/depth internal subset — blind to the data-table opens, the
|
|||
|
|
dominant ~67%.
|
|||
|
|
|
|||
|
|
**Constant-factor finding [M]: the schema contract is a flat 46 reads/write** — not
|
|||
|
|
depth-scaling, but **29% of the depth-0 cost (46/156)**, from
|
|||
|
|
`validate_schema_contract` re-running uncached on every resolve (`omnigraph.rs:561`).
|
|||
|
|
A depth-slope gate will *not* catch it; WriteTxn's resolve/validate-once kills it,
|
|||
|
|
and the §5.1 fitness assert (`validate_schema_contract_calls == 1`) is what pins it
|
|||
|
|
(constant-factor delta, §6).
|
|||
|
|
|
|||
|
|
The dominant term is **the written table's open routed through the lance-namespace
|
|||
|
|
builder ~13× per write** — now source-traced. The **write** path opens via
|
|||
|
|
`DatasetBuilder::from_namespace` (`namespace.rs:174`, from `open_table_head_for_write`
|
|||
|
|
`table_store.rs:181` / `namespace.rs:544`). Lance's builder calls the namespace's
|
|||
|
|
`describe_table` once and uses only `response.location` (`lance` `builder.rs:130-178`)
|
|||
|
|
— but omnigraph's `describe_table` **opens the whole dataset** just to produce that
|
|||
|
|
location (`open_head` → `Dataset::open`, `namespace.rs:362`/`:112`), and `.load()`
|
|||
|
|
then **resolves the latest version again** — a **double latest-resolution per
|
|||
|
|
open**, ~13× per write, nothing cached. Crucially, latest-resolution is **not
|
|||
|
|
inherently O(depth)**: the namespace path is O(depth) because it **misses the V2
|
|||
|
|
lexical / `latest_version_hint.json` fast path** that the direct opener engages
|
|||
|
|
(most likely because `load_table_from_namespace` attaches no shared `Session`/store
|
|||
|
|
params, `namespace.rs:174` — inferred, not traced). The **read** path skips all of
|
|||
|
|
it — `from_uri(location).with_version(N)`, one HEAD, O(1) — which is why reads are
|
|||
|
|
flat (+12/depth on the data table, §0(b)). **Proven on omnigraph's own table [M]:**
|
|||
|
|
a direct `Dataset::open` of the *same physical* 85-version edge table = **2 ops
|
|||
|
|
(O(1))**, the `from_namespace` open of that identical table = the O(depth) sweep —
|
|||
|
|
same bytes, two open paths. `checkout_version` is also O(1) — **exonerated**, not a
|
|||
|
|
back-walk. So `from_uri().with_version(N)` is the O(1) primitive and step 3 makes
|
|||
|
|
each open O(1) *intrinsically* (cleanup then becomes hygiene/interim, not
|
|||
|
|
load-bearing for read cost — §2.3). **Mode-independent [U]:** `append` ≡ `merge` ≡ +12/depth, so §0(a)
|
|||
|
|
measuring a single insert was *not* the defect — the defect is the namespace open
|
|||
|
|
path, not the verb. **Using `from_namespace` per-open is a misuse of Lance's
|
|||
|
|
design** (the namespace is a catalog/discovery layer — resolve once, then open the
|
|||
|
|
dataset directly, `lance-namespace` `operations/index.md` **[U]**); the read path
|
|||
|
|
already bypasses it (PR #268 Fix 2 — see §2.4).
|
|||
|
|
|
|||
|
|
**Corrected conclusion.** The depth blow-up is in omnigraph's DB layer and is
|
|||
|
|
**data-table-dominated**: the redundant per-table opens (fixed by §9 step 3 —
|
|||
|
|
WriteTxn open-once-by-pinned-version — plus scheduled *version cleanup* of the
|
|||
|
|
node/edge tables) are ~70% of it; the uncompacted internal tables (§9 step 2) are
|
|||
|
|
the secondary ~30%. Both the originating R2 hypothesis and the earlier "entirely
|
|||
|
|
the internal tables" framing are wrong. The exact Lance call doing the data-table
|
|||
|
|
chain re-read (`checkout_version` back-walk vs merge-insert conflict replay) is the
|
|||
|
|
one unpinned item — see §12. Reads, by contrast, are flat in depth
|
|||
|
|
(`warm_read_cost.rs`, PR #268). This is the O(history)-per-write →
|
|||
|
|
O(N²)-cumulative behavior the production incident hit.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Problem & measurements
|
|||
|
|
|
|||
|
|
On object storage every call is a 10–100 ms RPC, there is no cheap stat, and
|
|||
|
|
sequential RPCs serialize. A long-lived production graph on R2, originating handoff
|
|||
|
|
**[M]**:
|
|||
|
|
|
|||
|
|
| operation | prod (R2) | local `file://` |
|
|||
|
|
|---|---|---|
|
|||
|
|
| one-edge `load --mode merge` → main | ~3 min (90 s workflow timeout) | <1 s |
|
|||
|
|
| `branch create --from main` | 120 s | <1 s |
|
|||
|
|
| one-row `load` → a branch | 204 s | <1 s |
|
|||
|
|
| `branch delete` | 216 s | <1 s |
|
|||
|
|
| warm read / `/healthz` | fast (0.2–2 s) | fast |
|
|||
|
|
|
|||
|
|
`iss-write-s3-roundtrip-amplification` **[G]** independently records the same:
|
|||
|
|
cross-region single insert ~46 s, 5-node mutation ~110 s, vs ~390 ms for a
|
|||
|
|
no-storage `/healthz`. Its acceptance criteria are this RFC's goal: *"a single
|
|||
|
|
insert issues O(1)-to-few S3 round-trips, not O(number of tables); bulk mutations
|
|||
|
|
amortize the manifest commit."*
|
|||
|
|
|
|||
|
|
The cost decomposes into terms; the dominant one scales with history (§0):
|
|||
|
|
|
|||
|
|
1. **Per-table opens through the O(depth) lance-namespace builder (DOMINANT,
|
|||
|
|
O(tables × depth)).** Each stage opens via `DatasetBuilder::from_namespace`
|
|||
|
|
(`namespace.rs:174`); its `describe_table` opens the whole dataset just to return
|
|||
|
|
a location (`open_head` → `Dataset::open`, `namespace.rs:362`/`:112`) and
|
|||
|
|
`.load()` resolves latest **again** — a double latest-resolution per open,
|
|||
|
|
O(depth) on the repro store, ~13× per write with nothing caching it **[S]**
|
|||
|
|
(§2.2). The read path's direct `from_uri().with_version(N)` is O(1). →
|
|||
|
|
**+12 reads/depth, ~70% of the slope [M]**. Fixed by opening once, by pinned
|
|||
|
|
version via the direct opener (§9 step 3); node/edge version *cleanup* bounds it
|
|||
|
|
further.
|
|||
|
|
2. **Per-write `__manifest` scan (O(history), secondary).** Every publish
|
|||
|
|
full-scans the uncompacted `__manifest` (`load_publish_state` →
|
|||
|
|
`read_manifest_scan`, `state.rs:133-141`) **[S]**; the internal tables are
|
|||
|
|
never compacted/cleaned (`optimize` iterates node/edge only,
|
|||
|
|
`optimize.rs:895-904`) **[S]**. +3.1 reads/depth **[M]**.
|
|||
|
|
3. **Per-write `_graph_commits` refresh (O(history), secondary).**
|
|||
|
|
`record_graph_commit` reloads the entire commit cache before each append
|
|||
|
|
(`commit_graph.rs:136-164`) **[S]**; never compacted/cleaned. +2.1 reads/depth
|
|||
|
|
**[M]**. The "read-path anti-pattern, now on writes" (`iss-991` handoff **[G]**).
|
|||
|
|
|
|||
|
|
Terms 2+3 are the secondary ~30%; term 1 dominates. Plus per-write fixed taxes: a `list_dir("__recovery/")` (`loader/mod.rs:197`,
|
|||
|
|
`exec/mutation.rs:725`, `exec/merge.rs:1090`) **[S]**, and the publisher CAS
|
|||
|
|
retry budget (`PUBLISHER_RETRY_BUDGET = 5`, `publisher.rs:51`) **[S]**.
|
|||
|
|
|
|||
|
|
Branch ops compound it: `branch create` is a per-table sequential fork loop
|
|||
|
|
(`fork_branch_from_state`, `table_store.rs:282`); `branch delete` opens a
|
|||
|
|
snapshot per *other* branch (`ensure_branch_delete_safe`, `omnigraph.rs:1317`)
|
|||
|
|
and force-deletes per forked table sequentially (`cleanup_deleted_branch_tables`,
|
|||
|
|
`omnigraph.rs:1359`) **[S]**.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Root cause (validated)
|
|||
|
|
|
|||
|
|
### 2.1 The write re-derives its world from storage every stage
|
|||
|
|
|
|||
|
|
`loader/mod.rs:400` captures a `snapshot` once, but downstream stages **ignore
|
|||
|
|
it** and re-resolve **[S]**:
|
|||
|
|
|
|||
|
|
- `open_for_mutation_on_branch` (`table_ops.rs:505`) re-calls
|
|||
|
|
`resolved_branch_target` **per table** (`:512`), which runs
|
|||
|
|
`ensure_schema_state_valid` (a full schema-contract storage read with no cache,
|
|||
|
|
`omnigraph.rs:561-568`) and then opens **by head** via
|
|||
|
|
`open_dataset_head_for_write` (`:522`/`:559`), asserting head == pinned only
|
|||
|
|
*after* the open.
|
|||
|
|
- `fresh_snapshot_for_branch` (`omnigraph.rs:771`) always does fresh I/O; the
|
|||
|
|
fork authority path re-reads the live manifest (`table_ops.rs:574`).
|
|||
|
|
- The captured snapshot is used only for membership/fork checks, never for the
|
|||
|
|
actual opens.
|
|||
|
|
|
|||
|
|
The drift guards, CAS retries, and recovery scans are **compensating machinery**
|
|||
|
|
for the staleness this self-inflicts. The `Snapshot`/coordinator primitive
|
|||
|
|
already exists; it is treated as cheap-to-reacquire rather than as the
|
|||
|
|
operation's authoritative identity.
|
|||
|
|
|
|||
|
|
### 2.2 The depth terms — data-table re-reads dominate, internal tables secondary
|
|||
|
|
|
|||
|
|
Confirmed in code and measurement (§0). The **dominant** term is §2.1's per-table
|
|||
|
|
opens: ~13 opens per write through the lance-namespace builder
|
|||
|
|
(`DatasetBuilder::from_namespace`, `namespace.rs:174`). The builder calls the
|
|||
|
|
namespace's `describe_table` (`lance` `builder.rs:130-178`), and omnigraph's
|
|||
|
|
`describe_table` opens the whole dataset just to return a location (`open_head` →
|
|||
|
|
`Dataset::open`, `namespace.rs:362`/`:112`); `.load()` then resolves latest again —
|
|||
|
|
a **double latest-resolution per open**, O(depth) on the repro store — so cost
|
|||
|
|
grows with the table's version count (+12 reads/depth, ~70%). The **read** path
|
|||
|
|
opens direct `from_uri().with_version(N)` (`namespace.rs:112` / `SubTableEntry::open`)
|
|||
|
|
— O(1) — and native pylance is flat 6 ops at any depth **[U]**, so this is
|
|||
|
|
omnigraph's *namespace-open* pattern, not Lance; `checkout_version` is O(1) and not
|
|||
|
|
implicated. (The heavier `list_table_versions` — `versions()` + a checkout per
|
|||
|
|
version, `namespace.rs:395-427` — is **not** on this path; it is test-only today, a
|
|||
|
|
separate latent O(depth): §10 follow-up.) The **secondary** terms are the two
|
|||
|
|
internal tables: `load_publish_state` and
|
|||
|
|
`commit_graph.refresh` each full-scan a table that gains a fragment per write and
|
|||
|
|
is never compacted (+5 reads/depth, ~30%). This is the `gap-read-path-rederivation`
|
|||
|
|
**[G]** failure mode — "cost grows with fragment count" — on the *write* path,
|
|||
|
|
where PR #268 never reached. `invariants.md` documents the internal-table half:
|
|||
|
|
*"the internal metadata tables (`__manifest`, `_graph_commits`) are still not
|
|||
|
|
compacted, so the probe and refresh cost still grows with fragment count."*
|
|||
|
|
|
|||
|
|
### 2.3 The `skip_auto_cleanup` interaction — and compaction ≠ cleanup
|
|||
|
|
|
|||
|
|
v0.7.0 sets `skip_auto_cleanup: true` deliberately (`table_store.rs` 10 sites +
|
|||
|
|
`publisher.rs:392`) **[S]** — load-bearing, because Lance 7's on-by-default
|
|||
|
|
`auto_cleanup` would GC `__manifest`-pinned snapshot versions (`lance.md` audit)
|
|||
|
|
**[U]**. Two distinct levers were turned off and must be replaced *separately*:
|
|||
|
|
**compaction** (`compact_files`) rewrites small fragments into fewer larger ones
|
|||
|
|
but does **not** prune old versions; **cleanup** (`cleanup_old_versions`) prunes
|
|||
|
|
old versions. Measured on a ~85-version graph **[M]**: `optimize`/compaction
|
|||
|
|
*added a version* (data-table reads 1035 → 1083, frags 81→1 — **no help** against
|
|||
|
|
the depth term); `cleanup --keep 3` dropped it 1035 → 63 (89 versions pruned across
|
|||
|
|
7 tables, **16×**). So only *cleanup* bounds the version-chain length. Note today's
|
|||
|
|
`cleanup`/`optimize` cover **node/edge tables only** (the "7 tables"; internal
|
|||
|
|
`__manifest`/`_graph_commits` are excluded, `optimize.rs:895-904` **[M]**) — so
|
|||
|
|
bounding the internal +5/depth residual needs them **added** to the key set (§9 step
|
|||
|
|
2's code change). Operationally: `cleanup` aborts on a remote store without `--yes`
|
|||
|
|
(the
|
|||
|
|
scheduled job must pass it). Relation to step 3: while the namespace open is still
|
|||
|
|
on the write path, cleanup **caps** the dominant term — a real interim mitigation;
|
|||
|
|
once step 3 opens direct-by-version (O(1) regardless of version count, §2.4),
|
|||
|
|
cleanup is **storage hygiene + internal-table sprawl**, not load-bearing for read
|
|||
|
|
cost. The correct replacement is *scheduled* compaction **and** version cleanup
|
|||
|
|
(§9 step 2), **not** re-enabling `auto_cleanup`. Without it, version history (and
|
|||
|
|
per-write cost) grows forever.
|
|||
|
|
|
|||
|
|
### 2.4 Lance namespace: proper use (why the fix is bypass, not patch)
|
|||
|
|
|
|||
|
|
The upstream Lance Namespace is a **catalog / discovery layer** — "table
|
|||
|
|
discovery, resolving table locations, and coordinating commits" — whose intended
|
|||
|
|
division of labor is *"the namespace provides basic information about the table,
|
|||
|
|
[then] the Lance SDK … fulfill[s] the other operations"* (`lance-namespace`
|
|||
|
|
`namespace/index.md`, `operations/index.md`) **[U]**. It is meant to be consulted
|
|||
|
|
to *resolve a table once*, after which you operate on the `Dataset` directly — **not
|
|||
|
|
consulted on every per-table open on a hot path.** `DatasetBuilder::from_namespace`
|
|||
|
|
itself reflects this: it calls `describe_table` only to extract `location`, then
|
|||
|
|
reduces to a `from_uri` builder (`lance` `builder.rs:130-178`). For a system that
|
|||
|
|
*already holds* each table's location + version (omnigraph's `__manifest` does, via
|
|||
|
|
`SubTableEntry`), routing per-open resolution back through the namespace is the
|
|||
|
|
anti-pattern — and it aligns with this project's invariant 1 ("resolve latest state
|
|||
|
|
through the substrate's cheap primitive instead of re-scanning") and the deny-list
|
|||
|
|
"cold re-derivation on the hot path."
|
|||
|
|
|
|||
|
|
So the fix is **bypass, not patch**: open writes by URI + pinned version
|
|||
|
|
(`from_uri(location).with_version(N)`) — exactly what the **read** path already does
|
|||
|
|
(PR #268 Fix 2; the read path's own comment notes the namespace open "would
|
|||
|
|
full-scan `__manifest` twice per open (`describe_table` + `describe_table_version`)"),
|
|||
|
|
so this completes #268's open-by-location migration on the write side (§9 step 3).
|
|||
|
|
The **custom namespace impl stays** — it is still the right home for legitimate
|
|||
|
|
*catalog* operations (`describe_table` / `table_exists` / `list_table_versions` /
|
|||
|
|
`create_table_version` / managed-versioning commit coordination); only the
|
|||
|
|
per-open *resolution* leaves it. Two Lance facts make this safe and final: opening
|
|||
|
|
by explicit version is `default_resolve_version` = a single HEAD, O(1) (`lance`
|
|||
|
|
`commit.rs:939-981`), and Lance's own latest-resolution cost work (version-hint, PR
|
|||
|
|
#6752) confirms the latest path is the expensive one to avoid. **Proven on
|
|||
|
|
omnigraph's own table [M]:** a direct `Dataset::open` of the *same physical*
|
|||
|
|
85-version edge table is 2 ops (O(1)), while the `from_namespace` open of that
|
|||
|
|
identical table is the O(depth) sweep — so latest-resolution is not inherently
|
|||
|
|
O(depth); the namespace path is O(depth) only because it misses the fast path the
|
|||
|
|
direct opener engages (likely the un-threaded `Session`). Step 3 therefore makes
|
|||
|
|
each write open O(1) on its own — so node/edge `cleanup` (§2.3) is an **interim
|
|||
|
|
mitigation + storage hygiene**, not load-bearing for read cost once step 3 ships.
|
|||
|
|
|
|||
|
|
**End-to-end proof [M] — the one-line opener bypass, measured.** A prototype
|
|||
|
|
patched `open_dataset_head_for_write` (`table_store.rs:174`) to open directly by URI
|
|||
|
|
(bypassing `from_namespace` — exactly step 3 / Alternative B), rebuilt v0.7.0, and
|
|||
|
|
re-ran the depth sweep on a fresh graph:
|
|||
|
|
|
|||
|
|
| depth | data `edgeVER` baseline | data patched | TOTAL baseline | TOTAL patched |
|
|||
|
|
|---:|---:|---:|---:|---:|
|
|||
|
|
| 0 | 31 | **4** | 156 | 121 |
|
|||
|
|
| 10 | 181 | **4** | 358 | 173 |
|
|||
|
|
| 20 | 301 | **4** | 538 | 233 |
|
|||
|
|
| 40 | 541 | **4** | 898 | 353 |
|
|||
|
|
| 80 | 1021 | **4** | 1618 | **593** |
|
|||
|
|
|
|||
|
|
The dominant data-table term collapses `31 + 12·depth → flat 4` (O(1) in history),
|
|||
|
|
the total slope drops `+18/depth → +5/depth` (the residual +5 is exactly the two
|
|||
|
|
internal tables — step 2's scope), and at depth 80 a single edge drops **1618 → 593
|
|||
|
|
ops (2.7×)** from this one change alone, before step 2 / Phase 7. Functional
|
|||
|
|
correctness verified on the hot paths: main edge merge, branch create + write +
|
|||
|
|
read-back, node merge (managed-versioning still correct) — the direct opener already
|
|||
|
|
handles `checkout_branch` for non-main, so the namespace layer was not load-bearing
|
|||
|
|
for write correctness on these paths. **Caveat:** the prototype did **not** exercise
|
|||
|
|
schema-apply, branch merge, fork-on-first-write to a new table on a branch, overwrite
|
|||
|
|
mode, or concurrent writers — a production step 3 must pass the full
|
|||
|
|
`merge_truth_table`/recovery/failpoint suite (the namespace may do
|
|||
|
|
managed-versioning work that matters there). It proves the thesis + hot-path
|
|||
|
|
correctness, not drop-in completeness.
|
|||
|
|
|
|||
|
|
**Step 2 also proven [M].** On the step-3-patched binary at depth ~87, compacting
|
|||
|
|
the internal tables to 1 fragment each (content-preserving) collapsed their scans:
|
|||
|
|
`__manifest` 285 → 32 (8.9×), `_graph_commits` 177 → 11 (16×); the step-3 data term
|
|||
|
|
stayed flat at 4. So **both depth terms are now empirically eliminated** — a depth-87
|
|||
|
|
single edge drops **~1720 → 198 ops (~8.7×; ≈258 s → ≈30 s at 150 ms/RTT)** with
|
|||
|
|
both fixes. The internal term is **fragment-scan growth** (`read_manifest_scan` /
|
|||
|
|
`commit_graph.refresh` read all fragments of the *latest* version), so the fix is
|
|||
|
|
**compaction** (merge fragments) — distinct from the data table's version-chain term
|
|||
|
|
that step 3 / version-cleanup handle. `optimize`'s `all_table_keys`
|
|||
|
|
(`optimize.rs:895-904`) excludes the internal tables, so step 2 is a real code
|
|||
|
|
change, not just scheduling.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. First principles
|
|||
|
|
|
|||
|
|
On object storage the only objective function is **minimize the number of
|
|||
|
|
*sequential* round-trips per logical operation, and make that number invariant to
|
|||
|
|
graph age, history depth, and table count** — under the hard floor of SI,
|
|||
|
|
durability, atomicity, and loud integrity. Three generating principles fall out,
|
|||
|
|
each mapped to a validated failure:
|
|||
|
|
|
|||
|
|
1. **Pin once, derive the rest (MVCC / invariant 15).** A write is a pure
|
|||
|
|
function of one immutable, fully-pinned snapshot
|
|||
|
|
`{branch, manifest_version, per-table (location, version, e_tag), schema_hash,
|
|||
|
|
writer_epoch}`, resolved exactly once; every stage reads only from it
|
|||
|
|
(open-by-pinned-version, O(1), cacheable); the only contact with "current" is
|
|||
|
|
the final CAS. → fixes §2.1.
|
|||
|
|
2. **One source of truth, one commit (invariant 2).** Visibility + lineage +
|
|||
|
|
version bumps are **one atomic manifest write**; the commit graph, indexes,
|
|||
|
|
and topology are *projections* of the manifest, never second authorities to
|
|||
|
|
keep in sync. → fixes the §2.2 `_graph_commits` term (iss-991 Phase 7).
|
|||
|
|
3. **The plan is the contract (correct-by-construction recovery).** The writer
|
|||
|
|
serializes its *complete* publish intent **before any HEAD moves**; the live
|
|||
|
|
commit and crash-recovery execute the *identical* plan, so they cannot
|
|||
|
|
diverge. → fixes the partial-publish bug class structurally
|
|||
|
|
(`iss-merge-recovery-partial-rollforward`, PR #277).
|
|||
|
|
|
|||
|
|
The optimal single-edge write under these: **~2–3 sequential hops, O(1) in size**
|
|||
|
|
— 1 warm probe (0 if the coordinator is unchanged), 1 parallel stage of fragment
|
|||
|
|
writes, 1 manifest CAS — regardless of 5 tables or 500, 10 commits or 10M.
|
|||
|
|
Lance's own `test_commit_iops` (read 1 / write 2 / stages 3) **[U]** proves the
|
|||
|
|
per-table primitive already hits this; the job is to make the *graph* write
|
|||
|
|
inherit it.
|
|||
|
|
|
|||
|
|
This is not speculative: it is exactly what the two reference object-storage
|
|||
|
|
databases do. **LanceDB** threads a pinned `Arc<Dataset>` + shared `Session` and
|
|||
|
|
commits with one CAS off a captured `read_version`, never re-resolving "latest"
|
|||
|
|
under default consistency **[U]**. **SlateDB** captures a snapshot, treats a
|
|||
|
|
monotonic-ID manifest (no pointer file) as the *sole* authority, commits with one
|
|||
|
|
conditional-PUT, recovers on open (never per-write), fences with a monotonic
|
|||
|
|
`writer_epoch`, and compacts on a schedule **[U]**.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Reference-level design
|
|||
|
|
|
|||
|
|
### 4.1 The interface — one publish authority, one declarative plan
|
|||
|
|
|
|||
|
|
The deepest structural flaw is **four hand-rolled writers** (`load_as`,
|
|||
|
|
`mutate_as`, `apply_schema_as`, `branch_merge_as`), each re-implementing open →
|
|||
|
|
stage → commit → sidecar → lineage. There is **one publish machine**; the verbs
|
|||
|
|
are different declarative plans fed to it.
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// The pinned, immutable operation identity — resolved ONCE.
|
|||
|
|
struct WriteTxn {
|
|||
|
|
branch: BranchRef,
|
|||
|
|
base: PinnedSnapshot, // {manifest_version, per-table (loc,version,e_tag), schema_hash, writer_epoch}
|
|||
|
|
session: Arc<Session>, // shared per-graph; warms metadata/index caches across opens
|
|||
|
|
handles: HandleCache, // open-by-version; each table opened once, reused across stages
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A typed, declarative publish plan — the COMPLETE "what", built before any HEAD moves.
|
|||
|
|
enum TableAction {
|
|||
|
|
Append(Stream), Upsert(Batch), Overwrite(Image), Delete(Pred),
|
|||
|
|
Fork { from_version: u64 }, Register(Schema), Tombstone,
|
|||
|
|
}
|
|||
|
|
struct PublishPlan {
|
|||
|
|
base: PinnedSnapshot,
|
|||
|
|
actions: Map<TableKey, Vec<TableAction>>,
|
|||
|
|
lineage: GraphCommitIntent, // parent = base.head; rides the SAME manifest CAS (Phase 7)
|
|||
|
|
expected: Expectations, // per-table versions + graph_head + writer_epoch
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl GraphPublishAuthority {
|
|||
|
|
async fn open_txn(&self, branch: BranchRef) -> WriteTxn; // 1 warm probe
|
|||
|
|
async fn publish(&self, txn: &WriteTxn, plan: PublishPlan) -> PublishedSnapshot; // stage∥ → 1 CAS
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Properties that make it optimal:
|
|||
|
|
|
|||
|
|
- **Stages take `&WriteTxn`/`&PublishPlan`, never storage** — re-resolution and
|
|||
|
|
open-latest are *unrepresentable*. Invariants 2/3/15 hold by construction.
|
|||
|
|
- **The recovery sidecar *is* the serialized `PublishPlan`.** Phase C and
|
|||
|
|
recovery both call `plan.apply()` — a merge that bumps tables A+B can never
|
|||
|
|
roll A forward and silently drop B. The
|
|||
|
|
`iss-merge-recovery-partial-rollforward` bug class is gone by design.
|
|||
|
|
- **One CAS.** `publish` issues exactly one conditional `__manifest`
|
|||
|
|
merge-insert carrying every touched-table version + the `graph_commit` /
|
|||
|
|
`graph_head` lineage rows + the `writer_epoch` check.
|
|||
|
|
- **Verbs are thin lowerings.** `load`/`mutate`/`schema apply`/`branch merge`
|
|||
|
|
each build a `PublishPlan` and call `publish`. Four copies → one machine; the
|
|||
|
|
public `load_as`/`mutate_as` API is unchanged (it lowers internally).
|
|||
|
|
|
|||
|
|
The cost contract becomes part of `publish`'s documented API:
|
|||
|
|
|
|||
|
|
> `publish(txn, plan)` costs `opens ≤ |plan.touched_tables|` (0 warm),
|
|||
|
|
> `stages ≤ 3`, `manifest_ops = O(1)` — **invariant to history depth and table
|
|||
|
|
> count.**
|
|||
|
|
|
|||
|
|
### 4.2 Supporting mechanics (each validated this cycle)
|
|||
|
|
|
|||
|
|
| Mechanic | Design | Validation |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Open by pinned version | `from_uri(location).with_version(N)` + shared `Session` + warm handle cache — the O(1) opener *reads* already use (`instrumentation::open_table_dataset:112`, `SubTableEntry::open` `db/manifest.rs:200`). **NOT** the write path's `from_namespace` builder (`namespace.rs:174`), whose `describe_table` + `.load()` do an O(depth) double latest-resolution (§2.2 — the dominant cost), and **NOT** `open_dataset_at_state` (opens head then checks out, `table_store.rs:232`, not O(1)). | #0 **[S]** |
|
|||
|
|
| Strict-op SI | Update/Delete/SchemaRewrite open by pinned version (consistent read base) and the publish CAS rejects a *same-table* advance. Insert/Merge rely on Lance's natural rebase. **Do not remove the open guards wholesale** — that is a silent lost-update. | #5 **[S]** |
|
|||
|
|
| Fork × pinned-version | Fork already opens source at the pinned version and creates the target from it; the live-manifest authority re-read before fork stays (not defeated by the pin). | #6 **[S]** |
|
|||
|
|
| Open-once via the direct opener (**THE dominant depth fix**) | Reuse is **intra-transaction** (open each table once, by pinned version, thread it — kills the ~13 namespace-builder opens, the O(depth) double latest-resolution / +12/depth term, §0/§2.2). A commit invalidates its own entry, so no cross-write warm cache. Thread the shared per-graph `Session` through write opens (it is *not* today — `load_table_from_namespace` attaches no session, `namespace.rs:174`). | #9 **[S]** |
|
|||
|
|
| Lineage in the manifest (Phase 7) | Publish `graph_commit` + mutable `graph_head:<branch>` rows in the same `__manifest` merge-insert with a branch-head CAS; `_graph_commits` becomes a projection. Removes the per-write `commit_graph.refresh` and closes the "manifest→commit-graph atomicity" + "commit-graph parent under concurrency" gaps. | `iss-991` **[G]**, **[S]** |
|
|||
|
|
| Bounded history (compaction **and** cleanup) | Bring the internal table(s) into the `optimize` loop AND schedule version *cleanup* of node/edge tables — compaction rewrites fragments, only cleanup prunes the version chain that §2.2's dominant term re-reads (§2.3). No blob/PK/CAS blocker (`__manifest` has no blob column, `state.rs:44-72`; the unenforced PK is orthogonal to a content-preserving Rewrite). Post-Phase-7 there is only **one** internal table to compact. | #8 **[S]** |
|
|||
|
|
| Recovery off the hot path | Move the per-write `list_dir("__recovery/")` to coordinator-open + the CAS-conflict path, guarded by a sidecar-age grace window (the sidecar carries `created_at` micros + a ULID, `recovery.rs:762`/`:1522`). | #4, `iss-856`/`iss-recovery-sweep-live-writer-rollback` **[G][S]** |
|
|||
|
|
| Epoch fence | Monotonic `writer_epoch` in `__manifest`, CAS-claimed at writer init, checked on every publish. Fences a whole zombie *writer* deterministically (no TTL); closes the multi-process exposure and the Lance-MTT TTL-lease gap. | SlateDB `FenceableTransactionalObject` **[U]** |
|
|||
|
|
| Branch create | Lance `Clone` instead of the per-table fork loop (O(tables)→O(1) sequential). | `iss-691` **[G]** |
|
|||
|
|
| Branch delete | Run the per-other-branch safety check and the per-table reclaim loops concurrently (`buffer_unordered`); read branch sets from in-memory coordinator state. | **[S]** |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. The cost contract — measurement & enforcement
|
|||
|
|
|
|||
|
|
The bug class is invisible to correctness tests, to local-FS tests, and to
|
|||
|
|
wall-clock benches. You can only prevent a regression in a quantity you **define
|
|||
|
|
precisely, measure deterministically, and bound on every PR.** The quantity is
|
|||
|
|
*sequential object-store round-trips per logical operation, as a function of
|
|||
|
|
history depth and table count.* OmniGraph already has the correct pattern for
|
|||
|
|
**reads** (`warm_read_cost.rs`, `IOTracker`, swept to depth 20); this RFC extends
|
|||
|
|
it across the write/branch/open surface. This is exactly how Lance and SlateDB
|
|||
|
|
enforce it **[U]**.
|
|||
|
|
|
|||
|
|
### 5.1 Tier 1 — deterministic IO-counted gate (every PR)
|
|||
|
|
|
|||
|
|
Ordinary `cargo test`, hermetic (in-memory / tempdir + `IOTracker`), no S3, no
|
|||
|
|
wall-clock. Two shapes:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// (A) cost-invariant-to-HISTORY — the load-bearing gate. Gate the MERGE verb (the prod path).
|
|||
|
|
for depth in [10, 100, 1000] { // REAL commit history, not row count
|
|||
|
|
build_history(depth);
|
|||
|
|
reset_counters();
|
|||
|
|
let s = measured_merge(); // --mode merge, the read-modify production path
|
|||
|
|
// PRIMARY — the dominant term (§0): the written table's data opens/reads, flat in depth.
|
|||
|
|
assert!(s.data_table_opens <= touched_tables); // open each table ONCE, by pinned version
|
|||
|
|
assert!(s.data_table_reads_per_open <= K_OPEN); // each open O(1) in the table's version count
|
|||
|
|
// SECONDARY — internal-table scans flat in depth (compaction + cleanup).
|
|||
|
|
assert!(s.manifest_ops <= K_MANIFEST); // small CONSTANT, NOT a function of depth
|
|||
|
|
assert!(s.lineage_ops <= K_LINEAGE);
|
|||
|
|
assert!(s.stages <= 3); // bounded sequential hops
|
|||
|
|
}
|
|||
|
|
assert_flat_across_depths(); // ALL terms — esp. data-table opens — flat in N
|
|||
|
|
|
|||
|
|
// (B) fitness functions — architectural invariants AS tests
|
|||
|
|
assert_eq!(validate_schema_contract_calls(write), 1); // resolve-once
|
|||
|
|
assert_eq!(coordinator_resolutions(write), 1); // O(1) resolution
|
|||
|
|
assert_eq!(recovery_listdir_calls(steady_state_write), 0);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Prerequisite, not a follow-up: route ALL opens (read + write) through the one
|
|||
|
|
instrumented opener BEFORE the gate is meaningful.** Today the write path's data
|
|||
|
|
opens bypass `table_wrapper` (the §0(a) blind spot), so a gate that asserts only
|
|||
|
|
`manifest_ops`/`lineage_ops` would **pass a still-broken build** — one that
|
|||
|
|
compacts the internal tables (§9 step 2) but keeps the dominant ~13× namespace-open
|
|||
|
|
sweep (§2.2). The gate MUST count data-table opens/reads (the dominant term), which
|
|||
|
|
requires the routing change first. The data term is **mode-independent** (append ≡
|
|||
|
|
merge ≡ +12/depth **[U]**), so either verb exercises it; gate the **merge** verb
|
|||
|
|
as the production path. **Fixture caveat [U]:** use *valid* edge endpoints — a
|
|||
|
|
write to a non-existent endpoint fails RI validation and rolls back at ~192 ops
|
|||
|
|
with **zero chain reads**, so a bad-endpoint fixture silently measures the rollback
|
|||
|
|
path and would pass falsely.
|
|||
|
|
|
|||
|
|
The load-bearing rule both Lance and SlateDB mostly miss: **assert the constant is
|
|||
|
|
flat across N, not just small at one N.** A shallow fixture cannot catch an
|
|||
|
|
O(history) cost (the §0(b) table is the red baseline). Add a `num_stages`
|
|||
|
|
(sequential-hop) assertion via a `ThrottledStore` wrapper (Lance's
|
|||
|
|
`test_commit_iops` setup) so an O(N) listing also blows a wall-time budget.
|
|||
|
|
|
|||
|
|
### 5.2 Tier 2 — wall-clock trend (post-merge / nightly, never a PR gate)
|
|||
|
|
|
|||
|
|
A `ThrottledStore` criterion bench injecting cross-region RTT (50/150 ms/op — the
|
|||
|
|
incident's regime) for single-insert and branch-op latency, with a threshold
|
|||
|
|
alert (Bencher.dev `--err` / github-action-benchmark `fail-on-alert`). Both
|
|||
|
|
reference DBs keep wall-clock out of the PR gate (too noisy on shared runners)
|
|||
|
|
and use it only as a trend.
|
|||
|
|
|
|||
|
|
### 5.3 Close the loop — production metric
|
|||
|
|
|
|||
|
|
Emit `storage.ops` and `storage.stages` per logical operation as a span/counter
|
|||
|
|
(cheap always-on atomics; the heavy per-table attributing wrapper stays
|
|||
|
|
test-only behind a `test-util`-style feature, zero release cost). The number
|
|||
|
|
asserted in CI is the number observed in prod — `iss-write-s3-roundtrip-amplification`'s
|
|||
|
|
cross-region signal becomes a direct readout.
|
|||
|
|
|
|||
|
|
### 5.4 Process discipline — test-first for performance
|
|||
|
|
|
|||
|
|
Write the depth-sweep cost-budget test **first**: it goes **red today** (§0), the
|
|||
|
|
WriteTxn + Phase-7 + compaction work turns it **green** (flat in N), and the
|
|||
|
|
red→green is the proof. This is CLAUDE.md rule 12 applied to cost, and the
|
|||
|
|
originating handoff's sequencing (§8/§9: land the tests before the fix so the win
|
|||
|
|
is measured and locked). Add the policy (extend invariant 15 + testing.md "Cost
|
|||
|
|
budget tests"): *any change touching the read/write/branch/open path MUST add or
|
|||
|
|
extend a cost-budget test asserting the metric is flat at history depth.*
|
|||
|
|
|
|||
|
|
### 5.5 The correctness contract — concurrency tests (the safety twin of the cost gate)
|
|||
|
|
|
|||
|
|
The cost gate proves *fast*; these prove *safe*. §6.5's multi-writer cliff slipped
|
|||
|
|
the suite for the same structural reason the latency bug did — **nothing runs the
|
|||
|
|
schedule that triggers it**: the suite is single-process with the in-process queue
|
|||
|
|
(the bug is cross-process), uses local/in-memory stores (no object-store
|
|||
|
|
cross-process CAS), and its recovery tests cover restart-time sweep, not
|
|||
|
|
live-writer rollback. **These four must land before `PublishPlan`/epoch merge
|
|||
|
|
(steps 5):**
|
|||
|
|
|
|||
|
|
1. **Cross-process multi-writer on a real/emulated object store** (the *corruption*
|
|||
|
|
case) — N independent engine **processes** writing the same `(table, branch)`;
|
|||
|
|
assert all commit-or-cleanly-retry (no lost updates, no stuck "needs recovery,"
|
|||
|
|
no HEAD-ahead-of-manifest). **A single-process failpoint test cannot reproduce
|
|||
|
|
the corruption** (in-process degrades to clean OCC, §6.5) — this genuinely needs
|
|||
|
|
a multi-process harness (empirically 1/12 today). State that so nobody writes a
|
|||
|
|
single-process test expecting it to fail.
|
|||
|
|
2. **Deterministic in-process interleaving (failpoint) — WRITTEN, passes [M].** Two→
|
|||
|
|
eight handles, sleep failpoint at the `commit_staged`→publish window
|
|||
|
|
(`loader/mod.rs:605`); resume losers and assert they retry cleanly. This
|
|||
|
|
demonstrates the **benign** path (N=8 → 2 commit, 6 clean OCC retries) — it is the
|
|||
|
|
regression guard for "in-process stays clean," *not* a reproduction of the
|
|||
|
|
cross-process cliff.
|
|||
|
|
3. **Live-writer recovery** (`iss-recovery-sweep-live-writer-rollback`) — a
|
|||
|
|
concurrent open must not roll back a live in-flight publish (the grace window).
|
|||
|
|
4. **Formal model** — a Quint/TLA+ model of `{two writers, interleave commit_staged
|
|||
|
|
and manifest-CAS}` (`iss-934`); it finds the §6.5 cliff immediately.
|
|||
|
|
5. **Cross-table write-skew — WRITTEN, red, and driven red→green in-process [M].**
|
|||
|
|
Failpoint `loader.post_ri_pre_stage` (between RI-validation and staging): writer B
|
|||
|
|
validates "Bob exists" and parks; writer A `overwrite`s `node:Person` dropping Bob
|
|||
|
|
(non-cascading); B commits `Knows(Bob→Alice)` → committed orphan. The red test for
|
|||
|
|
the §7.1 fix. **Acceptance is a single-process gate** — unlike the §6.5 HEAD-ahead
|
|||
|
|
corruption (which genuinely needs the multi-process harness), this skew reproduces
|
|||
|
|
*deterministically in one process*: the parked edge writer's snapshot really does
|
|||
|
|
pin `edge:Knows:1` before the overwrite commits, so the overlap is real with two
|
|||
|
|
in-process handles. The fix went red→green in-process behind a shared head row
|
|||
|
|
(§7.1). Only #1–#4 (HEAD-ahead/epoch corruption) need cross-process scheduling.
|
|||
|
|
|
|||
|
|
Plus one **disambiguating run** owed (§6.5 confound): separate-handles in-process
|
|||
|
|
on S3 — to confirm the corruption is the process boundary, not the store.
|
|||
|
|
|
|||
|
|
This mirrors the cost gate's discipline (assert across the dimension the suite
|
|||
|
|
otherwise never exercises) — there, history depth; here, concurrent cross-process
|
|||
|
|
schedules.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. What is already right vs. the deltas
|
|||
|
|
|
|||
|
|
**Already correct — do not rewrite.** The in-memory `MutationStaging` accumulator,
|
|||
|
|
the recovery sidecar mechanism, the per-(table,branch) write queue, D2, the sealed
|
|||
|
|
`TableStorage` trait, and the read-path warm-up (PR #268) all stay. This is **not**
|
|||
|
|
a substrate rewrite.
|
|||
|
|
|
|||
|
|
**One claim to soften — manifest-CAS is atomic *per publish*, not unconditionally
|
|||
|
|
cross-table-serializing [M].** The manifest CAS (the reference impl of the
|
|||
|
|
lance#7264 "Alternative A") makes each publish atomic and serializes any two writers
|
|||
|
|
whose write-sets **share a `__manifest` row** — overlapping or same-table, which is
|
|||
|
|
exactly why §6.5's same-table cases and the cascading-delete case retry cleanly. But
|
|||
|
|
two writers touching **disjoint** tables write disjoint per-`object_id` rows, so Lance
|
|||
|
|
sees no conflict and **both commit** (proven [M], §7.1). The genuinely-atomic
|
|||
|
|
cross-table commit §13 contrasts with Delta is the **target** (§4.1's single
|
|||
|
|
merge-insert over a shared head row), **not current state**. So "do not rewrite the
|
|||
|
|
CAS" holds for the *commit primitive*, but the cross-table-serialization §7.1 needs
|
|||
|
|
is a real addition (the shared `graph_head` row), not something the current CAS
|
|||
|
|
already provides.
|
|||
|
|
|
|||
|
|
**The deltas (each a validated, localized gap):**
|
|||
|
|
|
|||
|
|
| # | Delta | Mechanism | Tracking |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 1 | Snapshot re-derived per stage | capture-once `WriteTxn`, thread by ref | `iss-write-s3-roundtrip-amplification` |
|
|||
|
|
| 2 | Write opens via `from_namespace` re-resolve the data-table ~13×/write, missing the fast path (**DOMINANT, +12/depth**) | open each table **once, direct `from_uri().with_version(N)`** (bypass namespace, §2.4) + shared Session | `iss-write-s3-roundtrip-amplification`, #0 |
|
|||
|
|
| 3 | Lineage = 2nd authority, O(history) refresh (secondary) | Phase 7: lineage into `__manifest` | `iss-991` |
|
|||
|
|
| 4 | `__manifest`/`_graph_commits` excluded from optimize/cleanup (`optimize.rs:895-904`; prototype pruned "7 tables" = node/edge only **[M]**) — the +5/depth residual after step 3 | **add them to `all_table_keys`** (a code change) + scheduled compaction/cleanup | `gap-read-path-rederivation` (write twin) |
|
|||
|
|
| 5 | `list_dir("__recovery/")` per write | move to open + conflict, grace window | `iss-856`, `iss-recovery-sweep-live-writer-rollback` |
|
|||
|
|
| 6 | 4 hand-rolled writers, commit↔recovery drift | one `PublishPlan` executed by both | `iss-merge-recovery-partial-rollforward` (PR #277) |
|
|||
|
|
| 7 | No writer epoch (multi-process exposure) | `writer_epoch` in `__manifest` | — (new) |
|
|||
|
|
| 8 | branch create = O(tables) fork loop | Lance `Clone` | `iss-691` |
|
|||
|
|
| 9 | branch delete = sequential loops | concurrent `buffer_unordered` | — (new) |
|
|||
|
|
| 10 | No write/branch cost gate (must count **data-table** opens; route all opens through the instrumented opener first) | Tier-1 IO-counted tests, merge verb | — (new) |
|
|||
|
|
| 11 | Schema contract re-validated uncached per resolve (**flat 46 reads/write — 29% of depth-0 cost; constant, not depth**) | resolve/validate-once in `WriteTxn`; §5.1 `validate_schema_contract_calls==1` (the depth gate misses it) | `iss-write-s3-roundtrip-amplification` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6.5 Concurrency correctness — the multi-writer cliff (proven [M])
|
|||
|
|
|
|||
|
|
The latency fixes are about *speed*; a separate, proven finding is about *safety*.
|
|||
|
|
A multi-writer experiment **[M]** shows concurrent same-branch writers behave very
|
|||
|
|
differently by topology:
|
|||
|
|
|
|||
|
|
| topology | concurrency | outcome |
|
|||
|
|
|---|---|---|
|
|||
|
|
| single server (shared in-proc queue, `loader/mod.rs:426`) | 12 | **12 / 12 commit** (clean) |
|
|||
|
|
| in-process, separate handles, interleave failpoint at `commit_staged`→publish (`loader/mod.rs:605`) | 8 | **2 / 8 commit; the other 6 are clean retryable OCC** |
|
|||
|
|
| multi-process (separate CLIs / S3, no shared queue) | 2 / 3 / 5 / 12 | **1 / N commit; the rest CORRUPT** |
|
|||
|
|
|
|||
|
|
**Two distinct failure modes — and the corruption is strictly cross-process:**
|
|||
|
|
|
|||
|
|
- **In-process → benign.** Even with *separate handles, no shared queue, high
|
|||
|
|
contention*, losers fail with `stale view of 'edge:Knows': expected manifest table
|
|||
|
|
version 5 but current is 7 — refresh and retry` — a **clean, retryable OCC
|
|||
|
|
conflict; graph state stays consistent.** The publisher CAS is doing its job.
|
|||
|
|
- **Cross-process → corruption.** `Lance HEAD version N+1 ahead of manifest version
|
|||
|
|
N; a pending recovery sidecar requires rollback`. **Mechanism:** a losing writer
|
|||
|
|
advances the table's Lance HEAD (`commit_staged`) *before* the manifest CAS; when
|
|||
|
|
the CAS loses, HEAD is ahead of the manifest — a partial commit the per-write heal
|
|||
|
|
**defers** (`recovery.rs:978-988`; only the open-time sweep rolls back), so a
|
|||
|
|
*live* writer hitting it **fails instead of healing**. Self-heals on the next
|
|||
|
|
read-write reopen (not permanently bricked), but during a burst throughput
|
|||
|
|
collapses to one survivor. Reachable at **concurrency = 2** cross-process.
|
|||
|
|
|
|||
|
|
So in-process safety **already comes from the publisher CAS** (clean OCC); the
|
|||
|
|
corruption needs the process boundary. *(Confound, stated honestly: the in-process
|
|||
|
|
interleave ran on local-FS and the cross-process on S3-via-proxy — but
|
|||
|
|
single-server-on-S3 was also clean (12/12), giving two independent "in-process
|
|||
|
|
clean" points vs one "cross-process corrupt," triangulating on the process
|
|||
|
|
boundary, not the store. One disambiguating run — separate-handles in-process on S3
|
|||
|
|
— would move this from triangulated to proven; §5.5.)*
|
|||
|
|
|
|||
|
|
**Scoping (matters for urgency):** **single-server prod is serialized-correct, just
|
|||
|
|
slow** — the in-process `(table,branch)` queue serializes same-branch writes (all 12
|
|||
|
|
commit, no lost updates); the production incident was the *latency* (serialized
|
|||
|
|
O(depth) writes → 90 s timeout), **not** corruption. The corruption hazard is
|
|||
|
|
**latent**: it appears the moment a second writer exists (server replica,
|
|||
|
|
CLI-alongside-server, multi-writer scale-out). **So: single-server today =
|
|||
|
|
serialized-correct (slow; fixed by steps 2/3); multi-writer = UNSAFE until
|
|||
|
|
`writer_epoch` lands.**
|
|||
|
|
|
|||
|
|
**The fix is the existing RFC, no new design.** The `A`-before-`B` window
|
|||
|
|
(Lance HEAD moves before the manifest references it) is inherent to Lance's
|
|||
|
|
per-table-lineage model — you cannot eliminate it, only fence and recover it: the
|
|||
|
|
**`writer_epoch`** (delta #7) is a leader-lease via cross-process CAS so two writers
|
|||
|
|
are never in the `commit_staged`→manifest-CAS window across processes (it removes
|
|||
|
|
the concurrent-race dimension); the **`PublishPlan`=sidecar** (delta #6) makes a
|
|||
|
|
single crashed writer roll forward/back deterministically (the crash dimension); and
|
|||
|
|
**recovery off the hot path + grace window** (delta #5, Q2) is the exact reason the
|
|||
|
|
live writers failed rather than self-healed (`iss-recovery-sweep-live-writer-rollback`).
|
|||
|
|
This is the standard WAL-replay + leader-lease shape (confirmed against SlateDB's
|
|||
|
|
`FenceableTransactionalObject` and Kleppmann's fencing-token canon, §10). **This
|
|||
|
|
finding promotes #6/#7 from "nice correctness work" to the load-bearing guard that
|
|||
|
|
gates multi-writer topologies — and it is the motivating case for them.**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Invariants & deny-list check
|
|||
|
|
|
|||
|
|
Touches and *strengthens* (does not weaken) invariants in
|
|||
|
|
[invariants.md](invariants.md):
|
|||
|
|
|
|||
|
|
- **§2 (manifest-atomic visibility):** preserved; lineage now rides the same CAS
|
|||
|
|
(strengthens — closes the "manifest→commit-graph atomicity" gap).
|
|||
|
|
- **§3 (one snapshot per op):** enforced *by construction* via `&WriteTxn`.
|
|||
|
|
- **§4 (publish at one boundary):** unchanged — still one manifest publish.
|
|||
|
|
- **§5 (recovery part of the commit protocol):** preserved; the sidecar *is* the
|
|||
|
|
`PublishPlan` (strengthens — commit and recovery cannot diverge). The grace
|
|||
|
|
window addresses the documented "recovery serialized against live writers
|
|||
|
|
in-process only" gap.
|
|||
|
|
- **§7 (indexes derived) / §15 (one source of truth, cheaply derived):** this RFC
|
|||
|
|
is the write-side application of §15 — bound cost to the working set, not
|
|||
|
|
history. The commit graph becomes derived (strengthens).
|
|||
|
|
- **§5 strict-op SI:** preserved (#5 validation — open guards kept for
|
|||
|
|
read-modify-write).
|
|||
|
|
|
|||
|
|
**Deny-list:** does *not* hit "cold re-derivation on the hot path" (it removes
|
|||
|
|
two instances), "state that drifts" (lineage stops being a second authority), or
|
|||
|
|
"acks before durable persistence." The `writer_epoch` is the closing move on the
|
|||
|
|
"local `write_text_if_match` is not a cross-process CAS" / multi-process gaps —
|
|||
|
|
add it before admitting multi-process write topologies.
|
|||
|
|
|
|||
|
|
No invariant is weakened. Two Known Gaps **close** (manifest→commit-graph
|
|||
|
|
atomicity; commit-graph parent under concurrency, via Phase 7); one
|
|||
|
|
(read-path-rederivation) gets its **write twin** filed and addressed.
|
|||
|
|
|
|||
|
|
### 7.1 Scope of the correctness claims (literature review, §13)
|
|||
|
|
|
|||
|
|
The "correct by construction" framing (§3, §4.1) is **precise but bounded** — the
|
|||
|
|
DB-canon review flags three places not to over-claim:
|
|||
|
|
|
|||
|
|
- **Per-table serializability, not graph-wide — but the gap is narrow and now
|
|||
|
|
measured [M].** Three deterministic cases (failpoint `loader.post_ri_pre_stage`,
|
|||
|
|
placed between RI-validation and staging; red test in `tests/failpoints.rs`):
|
|||
|
|
- **Cross-table *disjoint* → genuine skew, VIOLATED.** A **non-cascading endpoint
|
|||
|
|
removal** — `node:Person` *overwrite* dropping Bob, touching only the node table
|
|||
|
|
— concurrent with an edge insert `Knows(Bob→Alice)`: both commit (write-set-only
|
|||
|
|
CAS, RI validated once pre-commit and never re-checked at publish) → **committed
|
|||
|
|
orphan**. (= `iss-ri-write-skew-dangling-edges` + the concurrent face of
|
|||
|
|
`iss-overwrite-orphans-committed-edges`.)
|
|||
|
|
- **Cross-table *overlapping* → incidentally protected.** `delete`-based removal
|
|||
|
|
**cascades** into `edge:Knows`, so the write-sets overlap, the per-table CAS
|
|||
|
|
engages, and the loser fails **cleanly** (stale-view OCC retry); invariant held.
|
|||
|
|
- **Same-table → NOT a separate skew.** Cardinality / `@unique(src)` have
|
|||
|
|
overlapping write-sets, so the per-table CAS holds the constraint; the loser's
|
|||
|
|
failure is the **HEAD-ahead corruption already scoped to #6/#7** (epoch +
|
|||
|
|
PublishPlan), not a consistency hole. *(This corrects an earlier
|
|||
|
|
over-generalization: cardinality/uniqueness do not share the read-set gap.)*
|
|||
|
|
|
|||
|
|
So the skew is **reachable only for the non-cascading-overwrite × disjoint-edge-insert
|
|||
|
|
shape** — operation-specific, not constraint-specific.
|
|||
|
|
|
|||
|
|
**The scoped fix alone is a no-op — proven [M], and the reason is mechanical.**
|
|||
|
|
Feeding the endpoint node-table versions into the edge's publish *expected* set
|
|||
|
|
(`check_expected_table_versions`, `publisher.rs:353`) was prototyped exactly; debug
|
|||
|
|
confirmed the pins reach the check, **and both writers still committed — the orphan
|
|||
|
|
persisted.** Every publish writes a *unique per-`object_id` row* into `__manifest`
|
|||
|
|
(merge key `object_id = version_object_id(table, version)`). Two disjoint-table
|
|||
|
|
writers (`node:Person` vs `edge:Knows`) touch **no common row**, so Lance's
|
|||
|
|
row-level merge-insert CAS commits both with **no conflict**, the publisher's retry
|
|||
|
|
loop **never fires**, and `check_expected_table_versions` — a **non-atomic
|
|||
|
|
pre-check, not part of the CAS** — is evaluated exactly once against the stale
|
|||
|
|
pre-both manifest and passes for both. The read-set pin only bites if the loser is
|
|||
|
|
**forced to retry and re-evaluate against fresh state**, which requires a *shared
|
|||
|
|
contention row* every publish touches. Adding a stand-in global head row
|
|||
|
|
(`UpdateAll`-touched by every publish) makes the disjoint writers overlap → Lance
|
|||
|
|
conflict → publisher retry → the reloaded pin (`edge:Knows:1` vs current `5`)
|
|||
|
|
rejects the stale writer → no orphan (red→green, failpoint suite 52/52). **That
|
|||
|
|
shared row is exactly Phase-7's `graph_head:<branch>`.**
|
|||
|
|
|
|||
|
|
**Consequence — §7.1 is NOT a standalone single-server PR** (correct earlier text
|
|||
|
|
that called it "single-server-live, not deferrable" — it *is* urgent and
|
|||
|
|
epoch-independent, but it cannot ship against today's per-`object_id` manifest
|
|||
|
|
without a contention point). Land it one of three ways: **(a)** with Phase 7
|
|||
|
|
(step 4), reusing `graph_head:<branch>` as the contention row; **(b)** behind a
|
|||
|
|
minimal per-branch head row ahead of Phase 7 (~15 lines, as prototyped); or
|
|||
|
|
**(c)** as commit-time re-validation — still must win a serialization point first.
|
|||
|
|
**Recommended: (c) behind a per-branch head row.** The CAS-map approach carries the
|
|||
|
|
two costs §11 anticipated — *table-granularity false conflicts* (any `Person`
|
|||
|
|
overwrite conflicts with any concurrent `edge:Knows` insert, even different rows —
|
|||
|
|
needs a row-granularity read-set) and *scope* (a global head serializes the whole
|
|||
|
|
graph; per-branch `graph_head` is the right granularity). Commit-time re-validation
|
|||
|
|
is precise (no false positives) **and** reuses the same serialization point, so once
|
|||
|
|
the head row exists it strictly dominates the CAS-map. Either way the head row
|
|||
|
|
imposes an inherent trade — same-branch writers serialize cross-process (throughput
|
|||
|
|
ceiling 1/branch, bounded by `PUBLISHER_RETRY_BUDGET`) — **now a correctness
|
|||
|
|
requirement, not just a Phase-7 side effect** (§11).
|
|||
|
|
|
|||
|
|
**Two faces, two fixes — do not bundle them.** The above addresses only the
|
|||
|
|
*concurrent* face (overlapping snapshots, `iss-ri-write-skew-dangling-edges`). The
|
|||
|
|
*sequential* face (`iss-overwrite-orphans-committed-edges`) — an overwrite drops a
|
|||
|
|
node that **already has a committed inbound edge**, with *zero* concurrency —
|
|||
|
|
**cannot** be caught by read-set-in-CAS: the later writer's snapshot legitimately
|
|||
|
|
post-dates the edge, so its pin matches and it commits. That is a pure
|
|||
|
|
**inbound-RI-validation** gap: when an overwrite/delete removes node endpoints,
|
|||
|
|
re-check that no live edge references them. A validation concern, not a CAS one;
|
|||
|
|
it needs no contention row and ships independently.
|
|||
|
|
*(Note: `iss-984` is a different bug — remote branch-merge idempotency — not this.)*
|
|||
|
|
- **Recovery: roll-forward is by-construction; roll-back is not.** "Commit and
|
|||
|
|
recovery replay the identical plan" holds for the **redo** direction (shared
|
|||
|
|
`plan.apply()`). The undo classifier (NoMovement / UnexpectedAtP1 /
|
|||
|
|
UnexpectedMultistep / IncompletePhaseB) lives *outside* the shared executor, only
|
|||
|
|
at open-time — that's where ARIES-style divergence risk concentrates and where the
|
|||
|
|
§5.5 failpoint coverage is owed.
|
|||
|
|
- **The fence and the cross-file atomicity rest on a linearizable conditional-put.**
|
|||
|
|
Kleppmann's fencing-token guarantee, the manifest CAS, and the epoch all require a
|
|||
|
|
linearizable register — true on S3/R2 (If-Match) but **not** on the local-FS path
|
|||
|
|
(`write_text_if_match` is content-token compare-then-replace, ABA-prone —
|
|||
|
|
`invariants.md` Known Gap). **Precondition to state up front: every "deterministic
|
|||
|
|
fence" / "atomic CAS" claim holds *on a store with linearizable conditional-put*;
|
|||
|
|
the epoch must not use the local-FS path.** Delta Lake §3.2.2 treats the
|
|||
|
|
object-store consistency model (read-after-write + put-if-absent) as a first-class
|
|||
|
|
design parameter; so should this RFC.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Relationship to Lance MTT (the seam, not a dependency)
|
|||
|
|
|
|||
|
|
`GraphPublishAuthority.publish(txn, plan)` is exactly the adapter to a future
|
|||
|
|
Lance `catalog.transaction()`. lance#7264 ("Multi-Table Transactions via
|
|||
|
|
Branching") is real and OmniGraph is its reference "Alternative A"
|
|||
|
|
(fast-forward-main + WAL + roll-forward recovery) **[U]**, but it is a 5-day-old
|
|||
|
|
discussion with two unbuilt dependencies (lance#7263 branch merge/rebase,
|
|||
|
|
lance#7185 UUID branch paths), an unresolved central choice (it *favors*
|
|||
|
|
pointer-swap — the opposite identity model from OmniGraph), and an open soundness
|
|||
|
|
question (TTL lease needs an epoch). **Build the seam now on its own merits; do
|
|||
|
|
not schedule around MTT landing.** When it ships, `publish`'s *body* swaps
|
|||
|
|
(stage→CAS→sidecar → `catalog.transaction()`) while `WriteTxn`/`PublishPlan` and
|
|||
|
|
every verb lowering stay. `iss-863`/`iss-864` **[G]** already scope this spike.
|
|||
|
|
|
|||
|
|
The MemWAL/LSM ingest tier (`iss-681` **[G]**, `dec-adopt-lance-v7-memwal`) is
|
|||
|
|
**complementary, not competing, and not in flight** (the `memwal-benefit-analysis`
|
|||
|
|
branch is an empty placeholder; the real analysis is commit `c9a81266`). MemWAL
|
|||
|
|
sits *below* the manifest publisher (per-table durability, opt-in, intra-table);
|
|||
|
|
`WriteTxn` owns the cross-table CAS. Build `WriteTxn` first.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. Sequencing
|
|||
|
|
|
|||
|
|
Ordered by leverage and dependency. **The dominant depth term is the redundant
|
|||
|
|
data-table opens (step 3), not the internal tables (step 2)** — §0; both must land
|
|||
|
|
to flatten the curve.
|
|||
|
|
|
|||
|
|
1. **Measure first (Tier-1 gate). ✅ LANDED (gate + harness).** *Prerequisite (1a):*
|
|||
|
|
the write opener (`open_dataset_head`) is routed through the instrumented
|
|||
|
|
`open_dataset_tracked` so the gate can count data-table opens (§5.1). The
|
|||
|
|
write cost-budget tests live in `crates/omnigraph/tests/write_cost.rs` on a
|
|||
|
|
**shared, store-agnostic harness** (`tests/helpers/cost.rs`: `measure`/`IoCounts`/
|
|||
|
|
`assert_flat`/`local_graph`/`s3_graph`) that `warm_read_cost.rs` and
|
|||
|
|
`write_cost_s3.rs` also consume — one vocabulary, no duplicated `IOTracker`
|
|||
|
|
plumbing. The local gate ships green every-PR guards + the RED `#[ignore]`'d
|
|||
|
|
internal-table LOCK (step 2's red→green acceptance). *Still owed:* the prod
|
|||
|
|
`storage.ops` span metric (§5.3) and the bucket-gated `write_cost_s3.rs` opener
|
|||
|
|
LOCK (step 3a's red→green, S3-only per the §9-3a measurement note).
|
|||
|
|
2. **Bound history — bring the INTERNAL tables into optimize/cleanup (a code
|
|||
|
|
change, not just scheduling).** Today `optimize`/`cleanup` iterate **node/edge
|
|||
|
|
keys only** (`optimize.rs:895-904`) — confirmed: the prototype's `cleanup --keep 3`
|
|||
|
|
pruned "7 tables" = the node/edge data tables; `__manifest`/`_graph_commits` were
|
|||
|
|
untouched **[M]**. So the residual +5/depth internal slope (§0b) is **not** fixed
|
|||
|
|
by today's tooling — step 2 is a real `all_table_keys` change to add the internal
|
|||
|
|
tables, then schedule compaction+cleanup (pass `--yes`; cleanup aborts on remote
|
|||
|
|
otherwise). The pruning mechanism is proven on a data table (1035→63, 16× **[M]**);
|
|||
|
|
the internal tables need the same inclusion. **Proven [M]:** compacting the
|
|||
|
|
internal tables collapsed their scans `__manifest` 285→32, `_graph_commits`
|
|||
|
|
177→11; with step 3 a depth-87 edge drops **~1720 → 198 ops** (§2.4). (Separately,
|
|||
|
|
node/edge cleanup **caps** the dominant data-table term as an interim *before*
|
|||
|
|
step 3 — after step 3 that term is flat regardless.) **HARD PREREQUISITE:** the
|
|||
|
|
Q8 boundary watermark must land **with** this step — Lance's version CAS is
|
|||
|
|
confirmed vulnerable to cleanup-resurrection (§12 Q8, a silent lost write on
|
|||
|
|
R2/S3), so scheduling cleanup without the watermark trades a latency bug for a
|
|||
|
|
correctness bug. (`gap-read-path-rederivation` write twin.)
|
|||
|
|
3. **The opener fix — a shippable lead + the structural follow-on.**
|
|||
|
|
- **3a. Opener bypass (standalone PR, THE dominant fix — [M] proven). ✅ LANDED.**
|
|||
|
|
`TableStore::open_dataset_head_for_write` now delegates to the direct
|
|||
|
|
`open_dataset_head` opener (`Dataset::open` by URI + `checkout_branch`, routed
|
|||
|
|
through `instrumentation::open_dataset_tracked` so the cost gate can count it;
|
|||
|
|
no-op in prod) instead of the `from_namespace` builder. Measured end-to-end on
|
|||
|
|
the prototype: data term `31 + 12·depth → flat 4`, total `+18 → +5/depth`,
|
|||
|
|
depth-80 **2.7×** (§2.4), functionally correct on main/branch/node.
|
|||
|
|
**Acceptance:** the full `cargo test --workspace --locked` suite passes under the
|
|||
|
|
bypass (the `tests/` integration + `merge_truth_table` + recovery/failpoint
|
|||
|
|
suites the prototype's `--lib` run didn't cover — schema-apply, branch merge,
|
|||
|
|
fork-on-first-write, overwrite). **Namespace retired to test-only:** with both
|
|||
|
|
reads (Fix 2) and now writes bypassing it, *nothing in production routes through
|
|||
|
|
the Lance namespace* — confirming §2.4's premise. The dead per-table open chain
|
|||
|
|
(`load_table_from_namespace`, `open_table_head_for_write`) was deleted and the
|
|||
|
|
`StagedTableNamespace` contract apparatus gated `#[cfg(test)]`, mirroring the
|
|||
|
|
already-`#[cfg(test)]` read namespace (`BranchManifestNamespace`). **Measurement
|
|||
|
|
note (corrected):** the opener win is **S3-only** — local FS resolves latest with
|
|||
|
|
one cheap `read_dir` regardless of opener, so the namespace-vs-direct difference
|
|||
|
|
is invisible there (the local data-table read count *does* grow with depth, but
|
|||
|
|
that is the merge-insert/RI scan over O(depth) *fragments*, a compaction term,
|
|||
|
|
not the opener; depth-100 = 92 ops identically before and after the bypass). The
|
|||
|
|
opener LOCK therefore lives in the bucket-gated `write_cost_s3.rs`, not the local
|
|||
|
|
`write_cost.rs`.
|
|||
|
|
- **3b. Full `WriteTxn` (capture-once + intra-txn handle reuse + shared Session).**
|
|||
|
|
Formalize 3a's open-once into the pinned, threaded `WriteTxn` (re-resolution
|
|||
|
|
*unrepresentable*, invariant 3) and kill the flat-46 schema-read constant
|
|||
|
|
(resolve/validate-once, §0/§6). (`iss-write-s3-roundtrip-amplification`.)
|
|||
|
|
4. **Phase 7 — lineage into the manifest.** Removes the per-write
|
|||
|
|
`commit_graph.refresh`; commit graph becomes a projection. (`iss-991`.)
|
|||
|
|
**Hard dependency: step 2 must land first (Q1, §12)** — each publisher retry
|
|||
|
|
re-runs the O(history) `load_publish_state` scan, so the `graph_head` CAS
|
|||
|
|
contention Phase 7 introduces is acceptable only once compaction bounds that
|
|||
|
|
scan. Acceptance includes the Q1 concurrent-same-branch-writer gate.
|
|||
|
|
**Carries the §7.1 concurrent write-skew fix.** The `graph_head:<branch>` row is
|
|||
|
|
the shared contention point the cross-table read-set-in-CAS needs — proven [M]
|
|||
|
|
that the read-set fix is a no-op without it (§7.1). So the concurrent face of the
|
|||
|
|
write-skew lands *with* this step (or, if §7.1 must ship earlier, behind a minimal
|
|||
|
|
per-branch head row — ~15 lines — or as commit-time re-validation). The
|
|||
|
|
*sequential* face (`iss-overwrite-orphans-committed-edges`) is independent:
|
|||
|
|
inbound-RI validation on node removal, no head row, ships anytime.
|
|||
|
|
5. **`PublishPlan` unification + recovery off the hot path + epoch fence — the
|
|||
|
|
multi-writer safety guard.** Collapse the four writers; move the `__recovery` list
|
|||
|
|
to open/conflict; add the `writer_epoch` leader-lease. **Motivated by the proven
|
|||
|
|
§6.5 cliff** (multi-process same-branch writers corrupt at concurrency = 2) — this
|
|||
|
|
is the guard that makes multi-writer topologies safe, not optional polish.
|
|||
|
|
**Gated by the §5.5 correctness contract** (the four concurrency tests must land
|
|||
|
|
with it). `writer_epoch` must be a true cross-process conditional CAS — **not**
|
|||
|
|
the local-FS `write_text_if_match` path (§7.1). (`iss-856`,
|
|||
|
|
`iss-merge-recovery-partial-rollforward`, `iss-recovery-sweep-live-writer-rollback`,
|
|||
|
|
`iss-934`.)
|
|||
|
|
6. **Branch ops.** Lance `Clone` for create (`iss-691`); concurrent delete loops.
|
|||
|
|
7. **Freeze** investment in publisher/sidecar/fork internals; pursue the MTT
|
|||
|
|
seam (`iss-863`/`iss-864`) as the strategic exit.
|
|||
|
|
|
|||
|
|
**Land PR #277 first** — it closes `iss-merge-recovery-partial-rollforward` and is
|
|||
|
|
the producer-side half of the `PublishPlan` discipline; the heal-relocation in
|
|||
|
|
step 5 must preserve its merge pre-snapshot heal (`exec/merge.rs:1084-1090`) and
|
|||
|
|
its open-time `IncompletePhaseB → RollBack` (which the per-write heal never
|
|||
|
|
performed anyway).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. Cross-reference map (the ties)
|
|||
|
|
|
|||
|
|
**Dev-graph items (modernrelay) — what this RFC ties together:**
|
|||
|
|
|
|||
|
|
- Primary: `iss-write-s3-roundtrip-amplification` (the bug).
|
|||
|
|
- Depth term / Phase 7 (commit graph → manifest-derived projection): `iss-991`
|
|||
|
|
(related: `iss-707` structured commit-graph lineage; `iss-934` Quint
|
|||
|
|
multi-table-publish verification). Read twin: `gap-read-path-rederivation`.
|
|||
|
|
- Substrate seam: `iss-863`, `iss-864`. Decision: `dec-adopt-lance-v7-memwal`
|
|||
|
|
(`iss-681`).
|
|||
|
|
- Recovery: `iss-856`, `iss-recovery-sweep-live-writer-rollback`,
|
|||
|
|
`iss-merge-recovery-partial-rollforward`, `iss-903`, `iss-load-not-crash-safe`.
|
|||
|
|
- Residual migration: `iss-950` (MR-A staged delete, retires D2), `iss-848`
|
|||
|
|
(index-coverage reconciler, owns `create_vector_index`).
|
|||
|
|
- Branch/load: `iss-691`, `iss-677`, `iss-895`, `iss-topology-cross-branch-cache`,
|
|||
|
|
`iss-841`, `iss-982`, `iss-423`, `iss-989`.
|
|||
|
|
- Concurrency correctness (survives MTT) — **two faces, two different fixes [M]**
|
|||
|
|
(§7.1): `iss-ri-write-skew-dangling-edges` (the *concurrent* face; fix =
|
|||
|
|
read-set-in-CAS **+ a shared `graph_head` contention row**, so it's coupled to
|
|||
|
|
step 4 / a minimal head row / commit-time re-validation — NOT a standalone PR) and
|
|||
|
|
`iss-overwrite-orphans-committed-edges` (the *sequential* face; fix =
|
|||
|
|
**inbound-RI validation on node removal**, ships independently, no contention row).
|
|||
|
|
*(`iss-984` — remote branch-merge idempotency — is unrelated; not a write-skew.)*
|
|||
|
|
- Blockers: `blk-lance-6658` (shipped 7.0.0), `blk-lance-6666` (open, vector
|
|||
|
|
index two-phase), `blk-lance-blob-compaction`.
|
|||
|
|
- Epics: `epc-bulk-data-plane`, `epc-lance-v7-migration`, `epc-783` (reliability
|
|||
|
|
harness), `epc-929` (Quint verification).
|
|||
|
|
|
|||
|
|
**Proposed new dev-graph wiring (not yet written):**
|
|||
|
|
|
|||
|
|
- New **Epic** `epc-write-path-latency` — owns the cluster of orphaned issues
|
|||
|
|
above (none currently has an epic).
|
|||
|
|
- New **Gap** `gap-write-path-rederivation` — the write twin of
|
|||
|
|
`gap-read-path-rederivation` (current: write re-derives snapshot + scans
|
|||
|
|
uncompacted internal tables per write; target: capture-once + bounded history).
|
|||
|
|
- New **Issues**: write-side cost-budget gate + prod metric (step 1; prereq 1a
|
|||
|
|
routes all opens through the instrumented opener); **opener bypass — open writes
|
|||
|
|
direct-by-URI, standalone (step 3a, [M] the dominant fix, completes PR #268 Fix 2
|
|||
|
|
on the write path, §2.4)**; full `WriteTxn` capture-once (step 3b); **add
|
|||
|
|
`__manifest`/`_graph_commits` to `all_table_keys`** for compaction+cleanup (step 2
|
|||
|
|
— a code change, `optimize.rs:895-904`); `PublishPlan` unification + epoch
|
|||
|
|
(step 5); branch-delete concurrency (step 6).
|
|||
|
|
- **Per-table namespace retired to test-only (step 3a landed).** With reads (Fix 2)
|
|||
|
|
and now writes (step 3a) both opening direct-by-URI, *nothing in production routes
|
|||
|
|
through the per-table `StagedTableNamespace`*. The dead open chain
|
|||
|
|
(`load_table_from_namespace`, `open_table_head_for_write`) was deleted; the
|
|||
|
|
`StagedTableNamespace` struct/impl/factory are now `#[cfg(test)]`, mirroring the
|
|||
|
|
already-`#[cfg(test)]` read namespace (`BranchManifestNamespace`). Both are retained
|
|||
|
|
only to validate the `LanceNamespace` contract in unit tests. *Production catalog /
|
|||
|
|
managed-versioning commit coordination for `__manifest` itself goes through a
|
|||
|
|
**separate** namespace (`GraphNamespacePublisher`), unaffected by this change.* The
|
|||
|
|
former follow-up to harden `StagedTableNamespace::list_table_versions`
|
|||
|
|
(`checkout_version` per version, O(depth)) is now purely a test-hygiene note — no
|
|||
|
|
prod caller can hit it; if any future version-list / time-travel feature needs
|
|||
|
|
per-table version enumeration, build `TableVersion`s from `versions()` metadata
|
|||
|
|
directly rather than resurrecting the namespace open path.
|
|||
|
|
- New **Decision** `dec-writetxn-manifest-authoritative-publish` — records this
|
|||
|
|
RFC's design choice and the MTT-seam stance.
|
|||
|
|
|
|||
|
|
**Key source locations (v0.7.0):**
|
|||
|
|
`omnigraph.rs:561-568,739-779,1317-1389`; `table_ops.rs:505-609`;
|
|||
|
|
`table_store.rs:157-280,282-341,797`; `loader/mod.rs:197,400,485,557`;
|
|||
|
|
`exec/mutation.rs:725`; `exec/merge.rs:1084-1090`;
|
|||
|
|
`db/manifest/publisher.rs:51,93-124,356-371,385,432-440,448-490`;
|
|||
|
|
`exec/mutation.rs:640-673` (D2 rule); `db/manifest/state.rs:44-72,133-141`; `db/manifest/layout.rs:22-26`;
|
|||
|
|
`db/manifest/namespace.rs:111-112` (read open, O(1)),`:357-385`/`:362` (`describe_table` → redundant `Dataset::open` — the write-path double-open),`:158-186,544-550` (write open via `from_namespace`),`:395-427` (`list_table_versions` per-version checkout — test-only O(depth), the §10 follow-up);
|
|||
|
|
`db/manifest/recovery.rs:762,978-988,1522`; `db/commit_graph.rs:136-164,213-272`;
|
|||
|
|
`db/omnigraph/optimize.rs:240,517,895-904`; `instrumentation.rs:37,112-131`;
|
|||
|
|
`runtime_cache.rs:202-283`; `tests/warm_read_cost.rs` (the read-side gate to mirror).
|
|||
|
|
|
|||
|
|
**Upstream:** lance#7264/#7263/#7185 (MTT); Lance `with_version` O(1) open
|
|||
|
|
(`from_namespace` → `describe_table`, `builder.rs:130-178`; `default_resolve_version`
|
|||
|
|
= one HEAD, `commit.rs:939-981`; version-hint PR #6752),
|
|||
|
|
`list_is_lexically_ordered = !is_s3_express` (`aws.rs:183`),
|
|||
|
|
`IOTracker`/`assert_io_*`/`num_stages`, `test_commit_iops`,
|
|||
|
|
`test_commit_uses_version_hint_on_non_lexical_store`; **lance-namespace** design
|
|||
|
|
(`namespace/index.md`, `operations/index.md` — catalog/discovery layer, resolve
|
|||
|
|
once); LanceDB `io_tracking.rs`, `test_reload_resets_consistency_timer`; SlateDB
|
|||
|
|
`FenceableTransactionalObject` (epoch fence), `InstrumentedObjectStore`,
|
|||
|
|
monotonic-ID manifest.
|
|||
|
|
|
|||
|
|
**Reproduce the §0(b) network measurement:** `rustfs` (S3-compat) on `:9000`
|
|||
|
|
behind a ~90-LoC Go counting proxy on `:9100` (adds `LATENCY_MS`, preserves the
|
|||
|
|
SigV4 `Host` header, `/__ctl/reset` + `/__ctl/stat`); an omnigraph cluster on
|
|||
|
|
`s3://…/cluster` through the proxy. Single-write breakdown: reset the proxy log,
|
|||
|
|
`load --mode merge` one edge, classify by S3 key. Depth slope: write N× to main,
|
|||
|
|
diff the per-write log at depth D vs D+20 by table. Native baseline: pylance 7.0.0
|
|||
|
|
`write_dataset(mode="append")` in a loop → flat 6 ops/append at any depth.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 11. Drawbacks, alternatives, reversibility
|
|||
|
|
|
|||
|
|
**Drawbacks.** Phase 7 makes disjoint-table same-branch writers contend on the
|
|||
|
|
`graph_head:<branch>` row (they don't today) — bounded by the Lance retry budget,
|
|||
|
|
inherent to a linear per-branch DAG, gated on a measured concurrency test and on
|
|||
|
|
step 2 landing first (§12 Q1, resolved). **Reframe [M]: this contention is
|
|||
|
|
load-bearing for correctness, not merely a throughput tax.** The §7.1 write-skew is
|
|||
|
|
*unreachable only because* the shared head row forces disjoint cross-table writers to
|
|||
|
|
overlap, conflict, retry, and re-evaluate their read-set pins against fresh state
|
|||
|
|
(proven — without it the scoped CAS fix is a no-op). So §7.1 and the head row are
|
|||
|
|
**coupled**: the "drawback" is exactly what buys the cross-table invariant, and the
|
|||
|
|
throughput ceiling (1 writer/branch, bounded by `PUBLISHER_RETRY_BUDGET`) is a
|
|||
|
|
**correctness requirement** the moment §7.1 ships, not an optional Phase-7 side
|
|||
|
|
effect. `PublishPlan` is a non-trivial refactor of four writers; it must land behind
|
|||
|
|
the cost gate and the `merge_truth_table`/recovery/failpoint suites.
|
|||
|
|
|
|||
|
|
**Alternatives.** (A) *Caching band-aid only* — memoize schema validation, cache
|
|||
|
|
opens within a request: ~30–50% fewer round-trips but leaves open-by-latest and
|
|||
|
|
the O(history) terms. Mitigation, not a fix. (B) *Opener bypass only* (open
|
|||
|
|
direct-by-URI+version, no full txn) — **kills the dominant depth term, now measured
|
|||
|
|
[M]**: a one-line patch flattened the data term `31+12·depth → flat 4` and cut a
|
|||
|
|
depth-80 edge **2.7×** (§2.4), leaving only the secondary internal-table term and
|
|||
|
|
the writer unification. (C) *Full design (this RFC)* — correctness by construction.
|
|||
|
|
(D) *Wait for Lance MTT* — future exit, not a current dependency (§8).
|
|||
|
|
**Recommend: ship B as a standalone PR first (behind the step-1 gate), then C for
|
|||
|
|
the constant-factor + correctness, then step 2 for the internal residual; D as the
|
|||
|
|
strategic end-state.** B is the demonstrated dominant fix, not a partial one.
|
|||
|
|
|
|||
|
|
**Reversibility.** The interface (`WriteTxn`/`PublishPlan`) is internal and
|
|||
|
|
reversible. Phase 7's new `__manifest` object types (`graph_commit`,
|
|||
|
|
`graph_head`) are an **on-disk format addition** — additive (old binaries skip
|
|||
|
|
unknown `object_type`s) but near-permanent; it earns its own validation pass
|
|||
|
|
(forward/back-compat, the validation checklist in the `iss-991` handoff). The
|
|||
|
|
`writer_epoch` is likewise a durable manifest field. Everything else (compaction
|
|||
|
|
scheduling, recovery relocation, branch concurrency, the cost gate) is cheap to
|
|||
|
|
undo.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 12. Resolved questions (was: unresolved)
|
|||
|
|
|
|||
|
|
All five original open questions were investigated read-only against post-#277/#284
|
|||
|
|
`origin/main`, upstream Lance 7.0.0, and the dev graph; each is resolved below. One
|
|||
|
|
new item (Q6), surfaced by peer review, remains genuinely open.
|
|||
|
|
|
|||
|
|
1. **`graph_head` CAS contention → RESOLVED, gated on step 2 + a concurrency test.**
|
|||
|
|
Retry is publisher-owned; Lance's internal rebase-retry is disabled
|
|||
|
|
(`conflict_retries(0)`, `publisher.rs:385`) → no double-retry. Row-CAS is true
|
|||
|
|
one-winner (`TooMuchWriteContention` → retryable, `publisher.rs:432-440`),
|
|||
|
|
bounded by `PUBLISHER_RETRY_BUDGET = 5`. **But each retry re-runs the O(history)
|
|||
|
|
`load_publish_state` scan (`publisher.rs:455`)**, so `graph_head` contention
|
|||
|
|
multiplies the manifest term — **step 2 (compaction) is a hard prerequisite for
|
|||
|
|
step 4 (Phase 7)**. Same-branch is the real workload (the incident is concurrent
|
|||
|
|
`main` writes). Residual: a measured gate before Phase 7 — N≈100 concurrent
|
|||
|
|
same-branch writers, assert bounded retry + O(working-set) re-scan + P99 within
|
|||
|
|
SLA. Fallback: batched-lineage, or Alternative B (defer lineage-in-manifest).
|
|||
|
|
2. **Recovery grace-window → RESOLVED.** PR #284 is **unrelated** (cluster-apply
|
|||
|
|
trap; zero `recovery.rs` changes). The dangerous rollback classifications
|
|||
|
|
(NoMovement / UnexpectedAtP1 / UnexpectedMultistep / #277's IncompletePhaseB)
|
|||
|
|
fire only at the open-time Full sweep; the per-write heal defers all rollback
|
|||
|
|
(`recovery.rs:978-988`), so moving the heal off the hot path doesn't break #277.
|
|||
|
|
A sidecar-age grace window (defer sidecars younger than T_grace, loud typed
|
|||
|
|
skip, `repair` override) on the existing `created_at`/ULID
|
|||
|
|
(`recovery.rs:762`/`:1522`) is the sound interim; the permanent fix is the
|
|||
|
|
in-process background reconciler `iss-856`. Lands step 5 with a failpoint test.
|
|||
|
|
3. **Epoch fence × publisher CAS → RESOLVED (by construction).** With Lance retry
|
|||
|
|
off (Q1), the publisher loop is the only retry layer. Model `writer_epoch` as a
|
|||
|
|
**pre-publish hard-fail gate** beside `check_expected_table_versions`
|
|||
|
|
(`publisher.rs:462`) but non-retryable (a stale epoch is a protocol violation,
|
|||
|
|
not a race). No double-retry; the epoch gate and the row-CAS loop are
|
|||
|
|
sequential. SlateDB `FenceableTransactionalObject` is the precedent.
|
|||
|
|
4. **Compaction cadence → RESOLVED.** Not `auto_cleanup` (GCs pinned versions).
|
|||
|
|
Not foreground every-N-writes (deny-list job-queue + invariant 7 + cost cliff).
|
|||
|
|
Minimum (step 2): extend `optimize`/`cleanup` to the internal tables AND node/edge
|
|||
|
|
version cleanup — no special-casing (`SidecarKind::Optimize` already covers a
|
|||
|
|
mid-compaction crash). Follow-up: an `iss-856`-shaped background reconciler
|
|||
|
|
triggered by a cheap fragment-count probe (work off the hot path; a reconciler,
|
|||
|
|
not a job queue — deny-list-clean; SlateDB's epoch-coordinated compactor is the
|
|||
|
|
precedent). CLI `omnigraph optimize` stays the operator override.
|
|||
|
|
5. **`PublishPlan` residuals → RESOLVED.** Both `delete_where` and
|
|||
|
|
`create_vector_index` are representable as `TableAction` variants with existing
|
|||
|
|
sidecar coverage (`SidecarKind::Mutation`/`EnsureIndices`) and are
|
|||
|
|
content-preserving (roll-forward safe). `TableAction::Delete` migrates to staged
|
|||
|
|
two-phase via MR-A / `iss-950` (now unblocked — `blk-lance-6658` shipped); **D2
|
|||
|
|
retires then** (`enforce_no_mixed_destructive_constructive`,
|
|||
|
|
`exec/mutation.rs:640-673`). `TableAction::CreateVectorIndex` stays inline until
|
|||
|
|
`blk-lance-6666` ships (`iss-848` reconciler path).
|
|||
|
|
|
|||
|
|
**Resolved post-review:**
|
|||
|
|
|
|||
|
|
6. **The exact mechanism of the data-table chain re-read → RESOLVED (§0, §2.4).**
|
|||
|
|
Pinned by Lance-source trace + proxy + pylance isolation **[U]**: it is **not**
|
|||
|
|
`checkout_version` (O(1)) and **not** merge-insert conflict replay. The write
|
|||
|
|
open goes through `DatasetBuilder::from_namespace` (`namespace.rs:174`), whose
|
|||
|
|
`describe_table` opens the whole dataset just to return a location
|
|||
|
|
(`namespace.rs:362`/`:112`) and whose `.load()` resolves latest **again** — a
|
|||
|
|
double latest-resolution per open, ~13× per write, nothing cached. The open
|
|||
|
|
resolves latest **without the V2 lexical / version-hint fast path** the direct
|
|||
|
|
opener uses (likely the un-threaded `Session`/store params,
|
|||
|
|
`load_table_from_namespace` `namespace.rs:174` — inferred, not traced), so it is
|
|||
|
|
O(depth) where a direct `from_uri().with_version(N)` is O(1). **The mechanism
|
|||
|
|
question is now academic for the fix:** bypassing `from_namespace` makes the open
|
|||
|
|
flat regardless of the precise sub-mechanism (un-threaded `Session` / double
|
|||
|
|
resolve / missed hint) — the bypass is the answer. (`list_table_versions` is
|
|||
|
|
**not** on this path — test-only; §10 follow-up.) `checkout_version` stays
|
|||
|
|
exonerated.
|
|||
|
|
|
|||
|
|
**Resolved end-to-end [M]:**
|
|||
|
|
|
|||
|
|
7. **End-to-end prototype of step 3 → DONE, measured (§2.4 before/after).** A
|
|||
|
|
prototype patched the opener (`open_dataset_head_for_write`, `table_store.rs:174`)
|
|||
|
|
to bypass `from_namespace` and open direct-by-URI, rebuilt v0.7.0, and re-ran the
|
|||
|
|
sweep: the data term collapsed `31 + 12·depth → flat 4`, total `+18/depth →
|
|||
|
|
+5/depth` (residual = the two internal tables, step 2), depth-80 **1618 → 593 ops
|
|||
|
|
(2.7×)** — functionally correct on main edge merge, branch create+write+read, and
|
|||
|
|
node merge. So step 3's "closes the dominant term outright" is **measured, not
|
|||
|
|
inferred**, and the opener bypass is **shippable standalone** (§9 step 3a).
|
|||
|
|
**Remaining (not blockers for step 3a's thesis):** the prototype did not cover
|
|||
|
|
schema-apply / branch merge / fork-on-first-write / overwrite / concurrent — a
|
|||
|
|
production opener change must pass the full `merge_truth_table`/recovery/failpoint
|
|||
|
|
suite; and the internal-table cleanup demo (step 2) + the concurrency
|
|||
|
|
fault-injection harness (steps 4/5) are still owed.
|
|||
|
|
|
|||
|
|
**Newly surfaced (open):**
|
|||
|
|
|
|||
|
|
8. **CAS-resurrection after cleanup → CONFIRMED VULNERABLE [S]; boundary watermark
|
|||
|
|
is a HARD PREREQUISITE for step 2.** SlateDB found this race (RFC-0026 / issue
|
|||
|
|
#352): a writer that stalls between computing manifest id `N+1` and creating it
|
|||
|
|
can, *after GC deletes `N+1`*, re-create it and observe **false success**.
|
|||
|
|
Lance 7.0.0 was traced directly and is **not immune**: version creation is a plain
|
|||
|
|
`put_opts(naming_scheme.manifest_path(base, version), PutMode::Create)` /
|
|||
|
|
`rename_if_not_exists` (`lance-table commit.rs:1421-1437`, `:1358`) on a
|
|||
|
|
version-numbered, **pruneable** path, with **no monotonic/boundary/watermark
|
|||
|
|
guard** anywhere in the manifest/commit/dataset path; `cleanup_old_versions` is
|
|||
|
|
**timestamp-based** (`cleanup.rs:1086`), so it deletes the very file the only
|
|||
|
|
guard (AlreadyExists→rebase) relies on. A stalled publisher whose target version
|
|||
|
|
was pruned by step-2 cleanup gets a `PutMode::Create` **success on a non-existent
|
|||
|
|
version → false success.** Severity by store: **R2/S3 (lexical, prod) = a silent
|
|||
|
|
lost write** (the resurrected version doesn't win V2 latest-resolution, so data
|
|||
|
|
lands on a dead branch while the publisher believes it committed); non-lexical =
|
|||
|
|
the version hint (`commit.rs:1439`) is overwritten to the stale version and
|
|||
|
|
trusted (worse, but not the prod path). **Action:** step 2 ships **only with** a
|
|||
|
|
durable **boundary/floor watermark** (GC advances it before deleting; every writer
|
|||
|
|
rejects `id <= boundary` after a "successful" create — SlateDB's fix), which also
|
|||
|
|
bounds any list-then-read-latest fallback. This was "lowest-risk earliest item";
|
|||
|
|
it is now gated (§9 step 2).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 13. External validation (subagents + literature)
|
|||
|
|
|
|||
|
|
Validated read-only against OSS prior art and the DB/distributed-systems canon:
|
|||
|
|
|
|||
|
|
- **SlateDB** (canonical object-store LSM) — tenet-by-tenet ✅ on capture-once
|
|||
|
|
snapshot, monotonic-ID manifest (no pointer file — *explicitly rejected* in their
|
|||
|
|
RFC-0001), the **epoch fence** (exact match: `FenceableTransactionalObject`,
|
|||
|
|
hard-fail, TTL-lease *explicitly rejected* — adopt as specified), background
|
|||
|
|
epoch-coordinated compaction/GC, and recovery-on-open. **Adopt-items OmniGraph is
|
|||
|
|
missing / under-specifies:** (1) the **boundary-file** CAS-resurrection guard (Q8);
|
|||
|
|
(2) **group-commit batching** — coalesce pending `PublishPlan`s into one manifest
|
|||
|
|
CAS, directly mitigating the Q1 / §6.5 contention; (3) SlateDB *peels* compaction
|
|||
|
|
state *out* of the manifest (RFC-0013) — the **opposite** of Phase 7's fold-*in*;
|
|||
|
|
§11 should defend "fold-in (lineage must be atomic with visibility) beats peel-out
|
|||
|
|
for us"; (4) **write back-pressure** when cleanup lags (`l0_max_ssts`). **Citation
|
|||
|
|
correction:** SlateDB has the per-RPC counter (`InstrumentedObjectStore`) but
|
|||
|
|
**not** the flatness-across-history gate — the depth-swept Tier-1 gate (§5.1) is
|
|||
|
|
OmniGraph-novel; cite it that way.
|
|||
|
|
- **Literature** — OCC/MVCC (Kung-Robinson 1981; DDIA ch.7), ARIES redo/undo, the
|
|||
|
|
fencing-token canon (Kleppmann — whose motivating example *is* OmniGraph's
|
|||
|
|
S3-read-modify-write-paused-past-lease scenario), and the lakehouse genre (Delta
|
|||
|
|
Lake VLDB 2020, Iceberg spec, Neon). The spine — OCC-over-MVCC + one atomic
|
|||
|
|
manifest CAS + WAL-of-intent recovery + monotonic-epoch fence — is canon-blessed,
|
|||
|
|
and OmniGraph **exceeds** Delta/Iceberg on the axis that matters (both are
|
|||
|
|
explicitly *single-table*-transactional; the manifest CAS delivers the atomic
|
|||
|
|
*cross-table* commit Delta only speculates about). The three scoping caveats are in
|
|||
|
|
§7.1.
|
|||
|
|
- **HelixDB** (embedded LMDB graph DB) — too different a substrate to validate the
|
|||
|
|
object-store machinery (LMDB's `commit()` subsumes tenets #2–#8 for free), but it
|
|||
|
|
**corroborates tenet #1** (capture-once, thread-by-reference, re-resolution
|
|||
|
|
unrepresentable — its `&mut RwTxn`-threaded traversal is the embedded twin of
|
|||
|
|
`WriteTxn`) and confirms the bug class is **substrate-induced**. Portable idea for
|
|||
|
|
the roadmapped traversal work: adjacency as a *persisted, sorted,
|
|||
|
|
label-partitioned projection* keyed by `(node, label)` (vs the cold-rebuilt
|
|||
|
|
`TypeIndex`).
|