Skip to content

Commit 4eb33dd

Browse files
authored
Merge pull request #29 from AdaWorldAPI/claude/kvs-lance-timeline
feat(kvs-lance): read-only Timeline view over Lance version history (Rubicon step 1)
2 parents 08ce3d4 + 38f8df0 commit 4eb33dd

10 files changed

Lines changed: 909 additions & 68 deletions

File tree

.claude/board/AGENT_LOG.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,63 @@
7272
one work path).
7373
7474
**Commit(s):** _filled in by the committing session_
75+
76+
## 2026-05-29 — kvs-lance time-series view (SoA/Rubicon step 1)
77+
**Branch:** claude/sleepy-cori-aRK2x
78+
**Added:**
79+
- `kvs/lance/timeline.rs` (264 LOC) — `Timeline` (read-only view over Lance
80+
version history) + `TimelineView` (immutable snapshot at one version) +
81+
`VersionInfo{version:u64, timestamp_us:Option<i64>}`. Uses only confirmed
82+
Lance 6.0.0 surface: versions(), checkout_version(), version().version,
83+
scan().project()/filter(). Tombstone-aware reads.
84+
- `kvs/lance/mod.rs` — `Datastore::timeline()` accessor (shares the live
85+
dataset handle, no second open).
86+
- `kvs/mvcc_source.rs` (170 LOC) — `MvccSource` trait + `LocalGeneratedMvcc`,
87+
borrowed verbatim from reverted PR #24 (2a54a32); additive, dead_code-gated
88+
until its consumer (kv-tikv native MVCC / lance version source) lands.
89+
- `kvs/lance/tests.rs` — 2 tests: versions grow+monotone with commits; a
90+
historical TimelineView reads the SoA as it stood (present at write version,
91+
absent before).
92+
**Verify:** `cargo check -p surrealdb-core --features kv-lance` → Finished, 0
93+
errors (6m43s cold). Timeline tests: see commit (run pending at log time).
94+
**Deferred (per user):** thinking-style i4-32 `I4x32::pack/unpack` are todo!()
95+
in lance-graph-contract (carrier glitch) — NOT touched; wiring first.
96+
**Next:** ractor mailbox owns SoA → publishes link onto this timeline (kanban);
97+
EpisodicWitness64; replace BindSpace; wire deprecated→cognitive-shader-driver.
98+
99+
## 2026-05-30 — codex P1 fix: write+delete commit = ONE Lance version (PR #29)
100+
**Branch:** claude/kvs-lance-timeline
101+
**Scope:**
102+
- `kvs/lance/commit_gate.rs`, `kvs/lance/flusher.rs` — `single_lance_commit`
103+
- `kvs/lance/mod.rs` — `build_tombstone_batch_lance` helper
104+
- `kvs/config.rs` — retire dead `delete_via_tombstone_row` flag
105+
- `kvs/lance/tests.rs` — regression test
106+
**Verdict:** PASS
107+
108+
**What was done (max 5 lines):**
109+
- Codex P1 on PR #29 was VALID: a batch with BOTH writes and deletes ran
110+
`merge_insert` (writes) THEN `Dataset::delete` (deletes) = TWO Lance
111+
versions; the intermediate write-before-delete version leaked through
112+
`Timeline::versions()` as a snapshot that was never an atomic commit.
113+
- Fix: fold deletes into tombstone rows (`tombstone=true`) in the SAME
114+
`merge_insert` → exactly ONE version per commit/flush. New
115+
`Transaction::build_tombstone_batch_lance` mirrors `build_write_batch_lance`.
116+
- Read path already filters `tombstone = false` (schema.rs:145,152), so
117+
get/scan/keys stay correct; physical `Dataset::delete` fully removed.
118+
- Retired the never-read `delete_via_tombstone_row` config flag — the fix is
119+
unconditional; a toggle would only re-open the torn-timeline hole.
120+
121+
**Tests run:**
122+
- `cargo check -p surrealdb-core --features kv-lance` → Finished, 0 errors
123+
- `cargo test … kvs::lance::tests::test_timeline` → 3 passed; 0 failed (incl.
124+
new `test_timeline_write_delete_commit_is_single_atomic_version`)
125+
126+
**Open questions / follow-ups:**
127+
- Tombstone rows now accumulate (one dead row per created-then-deleted key)
128+
until compaction; the background optimizer should GC tombstones past the
129+
retention horizon — queued for the compaction pass.
130+
- NOT run through `cargo +nightly fmt`: the crate is not fmt-clean under
131+
`.rustfmt.toml`'s unstable opts (whole-crate churn across 22+ untouched
132+
files), so hand-formatted to match surrounding `lance/` style.
133+
134+
**Commit(s):** (this commit)

.claude/board/EPIPHANIES.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,60 @@ guard invariant 1; invariant 2 needs new range-scan tests once
5757
5858
**Cross-ref:** `lance/mod.rs:362-417` (get path), `lance/mod.rs:607-642`
5959
(scan_impl), `lance-backend/README.md` § "Transaction Model".
60+
61+
## 2026-05-29 — kvs-lance Timeline: Lance-native versioning IS the time-series view
62+
**Status:** FINDING
63+
**Scope:** surrealdb/core/src/kvs/lance/{timeline.rs,mod.rs}
64+
65+
The "SurrealDB-as-view-over-Lance" (Rubicon) surface needs no new storage:
66+
Lance 6.0.0 already exposes the full timeline. `Dataset::versions() ->
67+
Vec<Version{version:u64, timestamp:DateTime<Utc>, metadata}>` enumerates the
68+
history; `checkout_version(u64)` pins an immutable snapshot. Confirmed against
69+
fetched lance-6.0.0 source (dataset.rs:202 Version struct; dataset.rs:2000
70+
versions()) AND against in-org usage in lance-graph
71+
crates/lance-graph/src/graph/versioned.rs:432. The new `Timeline` /
72+
`TimelineView` types are read-only BY CONSTRUCTION (they own a checked-out
73+
snapshot, expose no set/del/commit), so "SurrealDB never mutates the leading
74+
store" is a type-system guarantee, not a convention. Per-key time-travel
75+
(`checkout_version` + tombstone-as-data) was already wired in get()/scan_impl();
76+
this only adds the timeline *enumeration* + a read-only view handle. Compiles
77+
clean under `cargo check -p surrealdb-core --features kv-lance` (Finished, 0
78+
errors; the only warnings are never-used on the not-yet-wired consumer side).
79+
80+
## 2026-05-30 — kvs-lance timeline granularity = write-path-dependent (corrects 2026-05-29)
81+
**Status:** FINDING
82+
**Scope:** surrealdb/core/src/kvs/lance/{timeline.rs,tests.rs}
83+
84+
The 2026-05-29 timeline tests wrongly assumed "1 commit = 1 Lance version".
85+
On the DEFAULT `WritePath::LsmWithWal`, commits land in WAL+memtable and the
86+
background flusher batches them into Lance asynchronously — so the timeline
87+
reflects FLUSH BOUNDARIES, not individual commits (observed: 2 commits → 1
88+
version; a single commit left latest_version unchanged). For per-commit
89+
timeline granularity (which the Rubicon kanban needs — each commit/plan/prune
90+
a distinct entry) the datastore must use `WritePath::LegacyCommitGate`, where
91+
`Transaction::commit` returns only after its own Lance commit lands. Tests
92+
fixed to construct LegacyCommitGate configs; both pass (2/2). The timeline CODE
93+
was correct; the test HARNESS used the wrong write-path. Design consequence:
94+
the ractor/kanban consumer that publishes onto the timeline must run on the
95+
gate path (or call an explicit flush) to get one timeline entry per Rubicon
96+
commit. Cross-ref: config.rs WritePath docs; writepath_legacy_commit_gate_smoke.
97+
98+
## 2026-05-30 — A SurrealDB commit with writes+deletes was TWO Lance versions, not one
99+
**Status:** FINDING
100+
**Scope:** `kvs/lance/commit_gate.rs`, `kvs/lance/flusher.rs`, `kvs/lance/mod.rs`
101+
102+
`single_lance_commit` applied writes via `MergeInsertBuilder::execute_reader`
103+
and deletes via a SEPARATE `Dataset::delete` — each its own native Lance
104+
commit. So any batch carrying both produced two versions: an intermediate
105+
(writes applied, deletes pending) and the final. The datastore write lock hid
106+
the intermediate from live readers, but `Timeline::versions()` enumerates raw
107+
`Dataset::versions()` and surfaced it, letting a replayer `view_at()` a torn
108+
state that never atomically existed. The schema was already built for the fix
109+
(a `tombstone` Boolean column + read predicates filtering `tombstone = false`):
110+
folding deletes as tombstone rows into the same `merge_insert` makes
111+
1 commit = 1 version *structurally*, not by convention. Trade-off accepted:
112+
tombstone rows accumulate until a compaction/GC pass (physical `Dataset::delete`
113+
previously reclaimed that space immediately).
114+
115+
**Cross-ref:** codex P1 on PR #29 (discussion_r3328296248); fix in this
116+
commit; regression `test_timeline_write_delete_commit_is_single_atomic_version`.

surrealdb/core/src/kvs/config.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,6 @@ pub struct LanceConfig {
336336
/// `Dataset::checkout(version)`).
337337
pub versioned: bool,
338338

339-
/// Whether to write deletions as explicit tombstone rows
340-
/// (in addition to using Lance's native deletion vectors).
341-
pub delete_via_tombstone_row: bool,
342-
343339
/// Which write-path to use. See [`WritePath`] for the two
344340
/// options and their semantics. Defaults to
345341
/// [`WritePath::LsmWithWal`] — the Sprint AA hot path.
@@ -367,7 +363,6 @@ impl Default for LanceConfig {
367363
fn default() -> Self {
368364
Self {
369365
versioned: true,
370-
delete_via_tombstone_row: false,
371366
write_path: WritePath::default(),
372367
disable_background_flusher: false,
373368
}

surrealdb/core/src/kvs/lance/commit_gate.rs

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ use tokio::sync::{RwLock, mpsc, oneshot};
5757
use tracing::{trace, warn};
5858

5959
use super::DatasetHandle;
60-
use super::schema::KvSchema;
6160
use crate::kvs::err::{Error, Result};
6261
use crate::kvs::{Key, Val};
6362

@@ -342,9 +341,23 @@ async fn execute_batch(dataset: &Arc<RwLock<DatasetHandle>>, batch: Vec<Submissi
342341
}
343342
}
344343

345-
/// Issue a single Lance `MergeInsertBuilder::execute_reader` (for writes)
346-
/// followed by a `Dataset::delete` (for deletes). One dataset version is
347-
/// produced per call — concurrent submitters in the same batch share it.
344+
/// Apply a batch's writes **and** deletes as a SINGLE Lance commit.
345+
///
346+
/// Writes become live rows (`tombstone = false`); deletes become
347+
/// tombstone rows (`tombstone = true`). Both are streamed into one
348+
/// `MergeInsertBuilder::execute_reader` keyed on `key`. `execute_batch`
349+
/// coalesces every key to exactly one of write/delete, so the merge
350+
/// source has unique keys and the upsert is well-defined - producing
351+
/// exactly ONE dataset version per call, which concurrent submitters in
352+
/// the same batch share.
353+
///
354+
/// Folding deletes in as tombstone rows (rather than a separate
355+
/// `Dataset::delete`) is what keeps the Lance version history aligned
356+
/// with SurrealDB commit boundaries. The old `merge_insert` +
357+
/// `Dataset::delete` pair produced *two* versions for a write+delete
358+
/// batch; the intermediate write-applied/delete-pending version, though
359+
/// hidden from live readers by the write lock, leaked through
360+
/// `Timeline::versions()` as a snapshot that was never an atomic commit.
348361
async fn single_lance_commit(
349362
dataset: &Arc<RwLock<DatasetHandle>>,
350363
writes: Vec<(Key, Val)>,
@@ -357,38 +370,42 @@ async fn single_lance_commit(
357370
return Ok(());
358371
}
359372

360-
let mut ds = dataset.write().await;
361-
362-
// ── writes ─────────────────────────────────────────────────────────
373+
// Build the merge source: live rows for writes, tombstone rows for
374+
// deletes. Identical schema, so both stream through one reader.
375+
let mut batches: Vec<arrow_array::RecordBatch> = Vec::with_capacity(2);
363376
if !writes.is_empty() {
364-
let batch = super::Transaction::build_write_batch_lance(&writes, version)
365-
.map_err(|e| Error::Datastore(format!("lance build batch: {e}")))?;
366-
let schema_ref = batch.schema();
367-
let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch)], schema_ref);
368-
369-
let arc_ds = Arc::new(ds.inner.clone());
370-
let (new_ds, _stats) = MergeInsertBuilder::try_new(arc_ds, vec!["key".into()])
371-
.map_err(|e| Error::Datastore(format!("lance merge builder: {e}")))?
372-
.when_matched(WhenMatched::UpdateAll)
373-
.when_not_matched(WhenNotMatched::InsertAll)
374-
.try_build()
375-
.map_err(|e| Error::Datastore(format!("lance merge build: {e}")))?
376-
.execute_reader(reader)
377-
.await
378-
.map_err(|e| Error::Datastore(format!("lance merge_insert: {e}")))?;
379-
380-
ds.inner = Arc::try_unwrap(new_ds).unwrap_or_else(|arc| (*arc).clone());
377+
batches.push(
378+
super::Transaction::build_write_batch_lance(&writes, version)
379+
.map_err(|e| Error::Datastore(format!("lance build batch: {e}")))?,
380+
);
381381
}
382-
383-
// ── deletes ────────────────────────────────────────────────────────
384382
if !deletes.is_empty() {
385-
let predicate = KvSchema::build_delete_predicate(&deletes);
386-
ds.inner
387-
.delete(&predicate)
388-
.await
389-
.map_err(|e| Error::Datastore(format!("lance delete: {e}")))?;
383+
batches.push(
384+
super::Transaction::build_tombstone_batch_lance(&deletes, version)
385+
.map_err(|e| Error::Datastore(format!("lance build tombstones: {e}")))?,
386+
);
390387
}
391388

389+
let schema_ref = batches[0].schema();
390+
let reader = arrow_array::RecordBatchIterator::new(
391+
batches.into_iter().map(Ok::<_, arrow_schema::ArrowError>).collect::<Vec<_>>(),
392+
schema_ref,
393+
);
394+
395+
let mut ds = dataset.write().await;
396+
let arc_ds = Arc::new(ds.inner.clone());
397+
let (new_ds, _stats) = MergeInsertBuilder::try_new(arc_ds, vec!["key".into()])
398+
.map_err(|e| Error::Datastore(format!("lance merge builder: {e}")))?
399+
.when_matched(WhenMatched::UpdateAll)
400+
.when_not_matched(WhenNotMatched::InsertAll)
401+
.try_build()
402+
.map_err(|e| Error::Datastore(format!("lance merge build: {e}")))?
403+
.execute_reader(reader)
404+
.await
405+
.map_err(|e| Error::Datastore(format!("lance merge_insert: {e}")))?;
406+
407+
ds.inner = Arc::try_unwrap(new_ds).unwrap_or_else(|arc| (*arc).clone());
408+
392409
Ok(())
393410
}
394411

surrealdb/core/src/kvs/lance/flusher.rs

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ use tracing::{trace, warn};
3737

3838
use super::DatasetHandle;
3939
use super::memtable::{Memtable, Op};
40-
use super::schema::KvSchema;
4140
use super::wal::Wal;
4241
use crate::kvs::err::{Error, Result};
4342
use crate::kvs::{Key, Val};
@@ -244,13 +243,20 @@ async fn do_flush(
244243
Ok(snapshot.len())
245244
}
246245

247-
/// Issue a single Lance `MergeInsertBuilder::execute_reader` for the
248-
/// writes, followed by `Dataset::delete` for the deletes. One
249-
/// dataset version is produced per call.
246+
/// Apply a flush's writes **and** deletes as a SINGLE Lance commit.
250247
///
251-
/// Extracted from the prior `commit_gate::single_lance_commit` —
252-
/// same shape, no longer routed through the gate because the flusher
253-
/// is the only writer to Lance now.
248+
/// Writes become live rows (`tombstone = false`); deletes become
249+
/// tombstone rows (`tombstone = true`). Both stream into one
250+
/// `MergeInsertBuilder::execute_reader` keyed on `key`. A memtable
251+
/// snapshot holds exactly one op per key, so the merge source has unique
252+
/// keys and the upsert produces exactly ONE dataset version per flush.
253+
///
254+
/// Folding deletes in as tombstone rows (rather than a separate
255+
/// `Dataset::delete`) keeps the Lance version history aligned with flush
256+
/// boundaries: the old `merge_insert` + `Dataset::delete` pair produced
257+
/// *two* versions for a write+delete flush, and the intermediate
258+
/// write-applied/delete-pending version leaked through
259+
/// `Timeline::versions()` as a snapshot that never atomically existed.
254260
async fn single_lance_commit(
255261
dataset: &Arc<RwLock<DatasetHandle>>,
256262
writes: Vec<(Key, Val)>,
@@ -263,36 +269,42 @@ async fn single_lance_commit(
263269
return Ok(());
264270
}
265271

266-
let mut ds = dataset.write().await;
267-
272+
// Build the merge source: live rows for writes, tombstone rows for
273+
// deletes. Identical schema, so both stream through one reader.
274+
let mut batches: Vec<arrow_array::RecordBatch> = Vec::with_capacity(2);
268275
if !writes.is_empty() {
269-
let batch = super::Transaction::build_write_batch_lance(&writes, version)
270-
.map_err(|e| Error::Datastore(format!("lance build batch: {e}")))?;
271-
let schema_ref = batch.schema();
272-
let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch)], schema_ref);
273-
274-
let arc_ds = Arc::new(ds.inner.clone());
275-
let (new_ds, _stats) = MergeInsertBuilder::try_new(arc_ds, vec!["key".into()])
276-
.map_err(|e| Error::Datastore(format!("lance merge builder: {e}")))?
277-
.when_matched(WhenMatched::UpdateAll)
278-
.when_not_matched(WhenNotMatched::InsertAll)
279-
.try_build()
280-
.map_err(|e| Error::Datastore(format!("lance merge build: {e}")))?
281-
.execute_reader(reader)
282-
.await
283-
.map_err(|e| Error::Datastore(format!("lance merge_insert: {e}")))?;
284-
285-
ds.inner = Arc::try_unwrap(new_ds).unwrap_or_else(|arc| (*arc).clone());
276+
batches.push(
277+
super::Transaction::build_write_batch_lance(&writes, version)
278+
.map_err(|e| Error::Datastore(format!("lance build batch: {e}")))?,
279+
);
286280
}
287-
288281
if !deletes.is_empty() {
289-
let predicate = KvSchema::build_delete_predicate(&deletes);
290-
ds.inner
291-
.delete(&predicate)
292-
.await
293-
.map_err(|e| Error::Datastore(format!("lance delete: {e}")))?;
282+
batches.push(
283+
super::Transaction::build_tombstone_batch_lance(&deletes, version)
284+
.map_err(|e| Error::Datastore(format!("lance build tombstones: {e}")))?,
285+
);
294286
}
295287

288+
let schema_ref = batches[0].schema();
289+
let reader = arrow_array::RecordBatchIterator::new(
290+
batches.into_iter().map(Ok::<_, arrow_schema::ArrowError>).collect::<Vec<_>>(),
291+
schema_ref,
292+
);
293+
294+
let mut ds = dataset.write().await;
295+
let arc_ds = Arc::new(ds.inner.clone());
296+
let (new_ds, _stats) = MergeInsertBuilder::try_new(arc_ds, vec!["key".into()])
297+
.map_err(|e| Error::Datastore(format!("lance merge builder: {e}")))?
298+
.when_matched(WhenMatched::UpdateAll)
299+
.when_not_matched(WhenNotMatched::InsertAll)
300+
.try_build()
301+
.map_err(|e| Error::Datastore(format!("lance merge build: {e}")))?
302+
.execute_reader(reader)
303+
.await
304+
.map_err(|e| Error::Datastore(format!("lance merge_insert: {e}")))?;
305+
306+
ds.inner = Arc::try_unwrap(new_ds).unwrap_or_else(|arc| (*arc).clone());
307+
296308
Ok(())
297309
}
298310

0 commit comments

Comments
 (0)