Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/board/AGENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,26 @@
one work path).

**Commit(s):** _filled in by the committing session_

## 2026-05-29 — kvs-lance time-series view (SoA/Rubicon step 1)
**Branch:** claude/sleepy-cori-aRK2x
**Added:**
- `kvs/lance/timeline.rs` (264 LOC) — `Timeline` (read-only view over Lance
version history) + `TimelineView` (immutable snapshot at one version) +
`VersionInfo{version:u64, timestamp_us:Option<i64>}`. Uses only confirmed
Lance 6.0.0 surface: versions(), checkout_version(), version().version,
scan().project()/filter(). Tombstone-aware reads.
- `kvs/lance/mod.rs` — `Datastore::timeline()` accessor (shares the live
dataset handle, no second open).
- `kvs/mvcc_source.rs` (170 LOC) — `MvccSource` trait + `LocalGeneratedMvcc`,
borrowed verbatim from reverted PR #24 (2a54a32); additive, dead_code-gated
until its consumer (kv-tikv native MVCC / lance version source) lands.
- `kvs/lance/tests.rs` — 2 tests: versions grow+monotone with commits; a
historical TimelineView reads the SoA as it stood (present at write version,
absent before).
**Verify:** `cargo check -p surrealdb-core --features kv-lance` → Finished, 0
errors (6m43s cold). Timeline tests: see commit (run pending at log time).
**Deferred (per user):** thinking-style i4-32 `I4x32::pack/unpack` are todo!()
in lance-graph-contract (carrier glitch) — NOT touched; wiring first.
**Next:** ractor mailbox owns SoA → publishes link onto this timeline (kanban);
EpisodicWitness64; replace BindSpace; wire deprecated→cognitive-shader-driver.
37 changes: 37 additions & 0 deletions .claude/board/EPIPHANIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,40 @@ guard invariant 1; invariant 2 needs new range-scan tests once

**Cross-ref:** `lance/mod.rs:362-417` (get path), `lance/mod.rs:607-642`
(scan_impl), `lance-backend/README.md` § "Transaction Model".

## 2026-05-29 — kvs-lance Timeline: Lance-native versioning IS the time-series view
**Status:** FINDING
**Scope:** surrealdb/core/src/kvs/lance/{timeline.rs,mod.rs}

The "SurrealDB-as-view-over-Lance" (Rubicon) surface needs no new storage:
Lance 6.0.0 already exposes the full timeline. `Dataset::versions() ->
Vec<Version{version:u64, timestamp:DateTime<Utc>, metadata}>` enumerates the
history; `checkout_version(u64)` pins an immutable snapshot. Confirmed against
fetched lance-6.0.0 source (dataset.rs:202 Version struct; dataset.rs:2000
versions()) AND against in-org usage in lance-graph
crates/lance-graph/src/graph/versioned.rs:432. The new `Timeline` /
`TimelineView` types are read-only BY CONSTRUCTION (they own a checked-out
snapshot, expose no set/del/commit), so "SurrealDB never mutates the leading
store" is a type-system guarantee, not a convention. Per-key time-travel
(`checkout_version` + tombstone-as-data) was already wired in get()/scan_impl();
this only adds the timeline *enumeration* + a read-only view handle. Compiles
clean under `cargo check -p surrealdb-core --features kv-lance` (Finished, 0
errors; the only warnings are never-used on the not-yet-wired consumer side).

## 2026-05-30 — kvs-lance timeline granularity = write-path-dependent (corrects 2026-05-29)
**Status:** FINDING
**Scope:** surrealdb/core/src/kvs/lance/{timeline.rs,tests.rs}

The 2026-05-29 timeline tests wrongly assumed "1 commit = 1 Lance version".
On the DEFAULT `WritePath::LsmWithWal`, commits land in WAL+memtable and the
background flusher batches them into Lance asynchronously — so the timeline
reflects FLUSH BOUNDARIES, not individual commits (observed: 2 commits → 1
version; a single commit left latest_version unchanged). For per-commit
timeline granularity (which the Rubicon kanban needs — each commit/plan/prune
a distinct entry) the datastore must use `WritePath::LegacyCommitGate`, where
`Transaction::commit` returns only after its own Lance commit lands. Tests
fixed to construct LegacyCommitGate configs; both pass (2/2). The timeline CODE
was correct; the test HARNESS used the wrong write-path. Design consequence:
the ractor/kanban consumer that publishes onto the timeline must run on the
gate path (or call an explicit flush) to get one timeline entry per Rubicon
commit. Cross-ref: config.rs WritePath docs; writepath_legacy_commit_gate_smoke.
19 changes: 19 additions & 0 deletions surrealdb/core/src/kvs/lance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,17 @@ mod commit_gate;
mod flusher;
mod memtable;
mod schema;
mod timeline;
mod tx_buffer;
mod wal;

// `Timeline` is consumed now (the `Datastore::timeline()` return type);
// `TimelineView` + `VersionInfo` are the read-side surface a kanban/replay
// consumer reaches for next. Re-exported crate-wide so that wiring lands
// without churn; `allow(unused_imports)` until the first in-tree consumer.
#[allow(unused_imports)]
pub(crate) use timeline::{Timeline, TimelineView, VersionInfo};

use std::ops::Range;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -400,6 +408,17 @@ impl Datastore {
self.dataset.read().await.inner.version().version
}

/// Open a read-only [`Timeline`] over this datastore's version history.
///
/// This is the "SurrealDB-as-view-over-Lance" surface (the Rubicon
/// ruling): the timeline enumerates Lance's native dataset versions and
/// hands out immutable [`TimelineView`]s. It shares the same dataset
/// handle as live transactions — no second open — and exposes reads
/// only, so it cannot mutate the leading store.
pub(crate) fn timeline(&self) -> Timeline {
Timeline::new(Arc::clone(&self.dataset))
}

/// Test-only accessor for the underlying dataset Arc.
///
/// Lets `lance::tests` exercise alternative write paths (notably
Expand Down
117 changes: 117 additions & 0 deletions surrealdb/core/src/kvs/lance/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1900,3 +1900,120 @@ async fn writepath_legacy_commit_gate_provides_snapshot_iso() {

ds.shutdown().await.expect("shutdown");
}

// ──────────────────────────────────────────────────────────────────────────
// Timeline — read-only time-series view (the Rubicon "SurrealDB-as-view")
// ──────────────────────────────────────────────────────────────────────────

/// The timeline enumerates Lance's native version history and that history
/// grows by one entry per committed transaction.
///
/// Uses `WritePath::LegacyCommitGate`: only the gate path makes every commit
/// land synchronously as its own Lance dataset version. On the default
/// `LsmWithWal` path, commits batch through the WAL+memtable and the
/// background flusher migrates them into Lance asynchronously, so the
/// timeline reflects *flush boundaries*, not individual commits — the
/// correct granularity for a per-commit Rubicon kanban is the gate path.
#[tokio::test]
async fn test_timeline_versions_grow_with_commits() {
let path = unique_tmp_path();
let path_str = path.to_str().expect("path is valid UTF-8");
let ds = Datastore::new(
path_str,
LanceConfig {
write_path: WritePath::LegacyCommitGate,
..LanceConfig::default()
},
)
.await
.expect("create");

let timeline = ds.timeline();
let v_start = timeline.versions().await.expect("versions @ start").len();

// Two committed write transactions → at least two new Lance versions.
for (k, v) in [(b"a".as_ref(), b"1".as_ref()), (b"b".as_ref(), b"2".as_ref())] {
let tx = ds.transaction(true, false).await.expect("tx");
tx.set(k.to_vec(), v.to_vec()).await.expect("set");
tx.commit().await.expect("commit");
}

let versions = timeline.versions().await.expect("versions @ end");
assert!(
versions.len() >= v_start + 2,
"expected ≥{} versions after 2 commits, got {}",
v_start + 2,
versions.len()
);
// Version numbers are monotone non-decreasing along the timeline.
for w in versions.windows(2) {
assert!(w[0].version <= w[1].version, "timeline not monotone: {:?}", versions);
}
// The latest entry matches the datastore's current version.
let latest = timeline.latest_version().await;
assert_eq!(
versions.last().map(|vi| vi.version),
Some(latest),
"timeline tail must equal current_version"
);

ds.shutdown().await.expect("shutdown");
}

/// A historical [`TimelineView`] reads the SoA as it stood at that version:
/// a key written at version N is absent from a view pinned before N and
/// present from the view at/after N.
#[tokio::test]
async fn test_timeline_view_reads_historical_soa() {
let path = unique_tmp_path();
let path_str = path.to_str().expect("path is valid UTF-8");
// LegacyCommitGate: each commit lands synchronously as its own Lance
// version, so `v_before < v_after` holds per-commit (see the companion
// test's note on the LSM flush-boundary semantics).
let ds = Datastore::new(
path_str,
LanceConfig {
write_path: WritePath::LegacyCommitGate,
..LanceConfig::default()
},
)
.await
.expect("create");

let timeline = ds.timeline();
let v_before = timeline.latest_version().await;

// Commit a single key.
{
let tx = ds.transaction(true, false).await.expect("tx");
tx.set(b"hist".to_vec(), b"present".to_vec()).await.expect("set");
tx.commit().await.expect("commit");
}
let v_after = timeline.latest_version().await;
assert!(v_after > v_before, "commit did not advance the dataset version");

// View at the latest version sees the value.
let view_after = timeline.view_at(v_after).await.expect("view @ after");
assert_eq!(view_after.version(), v_after);
assert_eq!(
view_after.get(&b"hist".to_vec()).await.expect("get @ after").as_deref(),
Some(b"present".as_ref()),
"view at the write version must see the key"
);

// View at the pre-write version must NOT see the value.
let view_before = timeline.view_at(v_before).await.expect("view @ before");
assert!(
view_before.get(&b"hist".to_vec()).await.expect("get @ before").is_none(),
"view before the write must not see the key (time-travel violated)"
);

// scan() at the latest version surfaces the live row.
let rows = view_after.scan().await.expect("scan @ after");
assert!(
rows.iter().any(|(k, v)| k == b"hist" && v == b"present"),
"timeline scan must surface the committed row; got {rows:?}"
);

ds.shutdown().await.expect("shutdown");
}
Loading