Skip to content

refactor(core): migrate ContextStore onto StorageBase - #216

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:context-store-base
Jul 28, 2026
Merged

refactor(core): migrate ContextStore onto StorageBase#216
beinan merged 1 commit into
lance-format:mainfrom
beinan:context-store-base

Conversation

@beinan

@beinan beinan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Second step of #214, after #215. ContextStore now delegates its storage layer to StorageBase instead of carrying its own forked implementation.

Bugs this closes

WAL merge, which this store never had. Flushed generations accumulated under _mem_wal/ forever and every read unioned all of them, so read cost grew without bound in the number of writes. This was the single biggest gap between the three stores. Now exposed as maybe_merge_wal (count trigger), cleanup_wal (time trigger) and pending_wal_generations — the same machinery rollout uses.

Compaction dropped storage_options. Both compact and create_id_index reloaded via a bare Dataset::open(uri), silently discarding credentials — broken on any authenticated object store. Both now reload through StorageBase::load_with_options.

Compaction didn't defer index remap. The base table carries a fieldless MemWAL index and Lance's inline remap panics on it ("An index existed with no fields").

A writer per add. write_entries opened, put and closed a ShardWriter on every call, paying a cold DNS resolution + TCP/TLS handshake + epoch claim each time. Now one resident writer with fence-retry.

add is &self. The writer lives behind the base's mutex, so the server takes a read lock and concurrent appends stop serializing on the store's RwLock.

add visibility is preserved, not silently weakened

This was the one real design fork, and it's worth being explicit since the two stores genuinely differed:

  • ContextStore.add did put + close, and close seals — so rows were visible on return, and validate_unique_ids, upsert_by_external_id and the tombstone path all depend on that read-your-write.
  • RolloutStore.add deliberately gave visibility up: put only, with the flush sweeper (30s) or ?flush=true providing it.

Rather than pick one and break the other, StorageBase gains a seal_on_put switch. Rollout keeps false; ContextStore defaults to seal_on_add: true, preserving today's behavior and its correctness. Callers appending trusted batches that don't use the read-back paths can opt into rollout's throughput profile with seal_on_add: false.

Sharding now matches rollout

Writes go to the shard owned by this writer instance (ContextStoreOptions::shard_id) rather than one derived per (bot_id, session_id). One instance owns exactly one shard, so the epoch-fencing invariant holds by construction and merge has a single well-defined shard to drain. derive_region_id is removed.

This is a data-layout change: existing rows stay readable (the read path scans all shards from the manifests), but new writes land in a different shard.

ContextStore is no longer Clone

It owns a StorageBase, which owns the resident writer and a Drop that seals it — so it must have exactly one owner, or two writers race for one shard. Both clone sites now open their own handle:

  • the background compactor, which only rewrites base-table fragments and would otherwise contend with the write path
  • Python's Context.fork, which branches the in-memory context over the same dataset

The compactor's reopen needed one bit of care: open_inner can spawn the compaction task, which reopens via open_inner — a mutual recursion between two async fns that the compiler cannot prove Send, even though it's unreachable at runtime. Broken with a boxed dyn Future in a free function (open_for_compaction), with a comment explaining why it can't just be a flag.

Verification

Three new tests pin the behavior that was at risk:

  • add_is_visible_on_return_by_default — the read-your-write contract the uniqueness/upsert/tombstone paths rely on
  • deferred_seal_makes_add_durable_but_invisible_until_flush — mirrors rollout's equivalent test
  • wal_generations_merge_into_the_base_table — generations are reclaimed and rows stay readable

Full workspace suite passes (180 core lib tests, up from 177), cargo clippy --workspace --all-targets clean.

⚠️ Not verified: the Python binding tests. maturin isn't available in this environment, so Context.fork's signature change is compile-checked only. Worth running python/tests/test_context.py and test_compaction.py before merge.

Follow-ups

DatagenStore is the last store to migrate (it has no compaction at all today), then the arbitrary-schema layer from Part 2 of #214.

🤖 Generated with Claude Code

Second step of lance-format#214, after lance-format#215. `ContextStore` now delegates its storage
layer to `StorageBase` instead of carrying its own forked implementation.

Closes the gaps that made this store diverge from rollout:

- **WAL merge, which this store never had.** Flushed generations accumulated
  under `_mem_wal/` forever and every read unioned all of them, so read cost
  grew without bound in the number of writes. It now gets the same merge as
  rollout, exposed as `maybe_merge_wal` (count trigger),
  `cleanup_wal` (time trigger) and `pending_wal_generations`.
- **Compaction now defers index remap.** The base table carries a fieldless
  MemWAL index and Lance's inline remap panics on it.
- **Compaction and index creation no longer drop `storage_options`.** Both
  reloaded via a bare `Dataset::open`, which silently discarded credentials
  and broke on any authenticated object store. Both now reload through
  `StorageBase::load_with_options`.
- **Resident writer.** `add` used to open, `put` and `close` a `ShardWriter`
  on every call; it now reuses one resident writer with fence-retry, so an
  append no longer pays a cold DNS resolution + TCP/TLS handshake + epoch
  claim.
- **`add` takes `&self`.** The writer lives behind the base's mutex, so the
  server takes a read lock and concurrent appends stop serializing on the
  store's `RwLock`.

Sharding changes to match rollout: writes go to the shard owned by this
writer instance (`ContextStoreOptions::shard_id`) rather than one derived
per `(bot_id, session_id)`. One instance owns exactly one shard, so the
epoch-fencing invariant holds by construction, and merge has a single
well-defined shard to drain. `derive_region_id` is removed.

`add` visibility is preserved, not silently weakened. `StorageBase` gains a
`seal_on_put` switch: rollout keeps its deferred seal (durable on return,
visible via the flush sweeper or `?flush=true`), while `ContextStore`
defaults to `seal_on_add: true` — required for correctness, since its
id/external-id uniqueness validation, upsert and tombstones all read the
table back. Callers appending trusted batches can opt into rollout's
throughput profile with `seal_on_add: false`.

`ContextStore` is no longer `Clone`: it owns a `StorageBase`, which owns the
resident writer and a `Drop` that seals it, so it must have exactly one
owner. The two clone sites now open their own handle instead — the
background compactor (which only rewrites base-table fragments and would
otherwise contend with the write path) and Python's `Context.fork` (which
branches the in-memory context over the same dataset).

Three new tests pin the behavior: `add` is visible on return by default,
deferred seal is durable-but-invisible until `flush`, and WAL generations
merge into the base table and stay readable. Full workspace suite passes
(180 core lib tests, up from 177), clippy clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan

beinan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Python bindings verified — installed maturin via uv and ran the suite:

$ uv run maturin develop
🛠 Installed lance-context-0.6.4

$ uv run pytest tests/ -q
182 passed, 2 skipped in 40.09s

That covers the two things that were compile-checked only: Context.fork (now opens its own handle instead of cloning) in test_context.py, and background compaction (now opens its own handle per tick) in test_compaction.py — 13 passed across those two files.

The ⚠️ caveat in the PR description is resolved; no further manual verification needed before merge.

@beinan
beinan merged commit 954592a into lance-format:main Jul 28, 2026
9 checks passed
beinan added a commit that referenced this pull request Jul 28, 2026
Third and last store of #214 Part 1, after #215 and #216. All three
fixed-schema stores now share one storage implementation.

Stacked on #216 — review that first; this diff is `datagen_store.rs`
only.

## Bugs this closes

**Compaction, which this store had none of.** Every WAL merge appends a
fragment to the base table, and datagen seals once per checkpoint batch,
so fragments grew without bound and scans degraded with them. There was
no `compact_files` call anywhere in the file. Now `compact` /
`should_compact` / `compaction_stats`, with `defer_index_remap` set —
the fieldless MemWAL index panics Lance's inline remap.

**Scalar index, which it also had none of.** `DatasetIndexExt` was
imported solely for `load_indices` in `mem_wal_index_present`.
`create_event_id_index` now builds a ZoneMap on `event_id`; point
lookups by event id previously always scanned.

**`load_with_options` dropped storage options.** It fell back to a bare
`Dataset::open` whenever no options were passed, and never attached a
session — so every flushed-generation read allocated Lance's 6 GiB
default cache, keyed per generation URI.

## Deleted duplication

`merge_own_shard` was a near-line-for-line copy of rollout's, and
buffered every flushed generation fully in memory. Gone, along with
`merge_own_shard_if_ready`, the writer lifecycle
(`write_with_resident_writer` / `put_seal_drain` /
`ensure_write_writer`), `ensure_mem_wal`, `mem_wal_index_present`,
shard-snapshot discovery, `flushed_generation_uri`,
`open_flushed_dataset`, `load_with_options`, `create_with_options`, the
`Drop` impl, and a second copy of `is_fenced_error`.

Net **-269 lines** in this file.

## Behavior preserved exactly

`seal_on_put: true`. Each append is one complete checkpoint batch that
must be durable *and* readable before the writer moves on, so a crash
cannot expose a partially checkpointed step. This is the store's
existing `put` → `force_seal_active` → `wait_for_flush_drain` behavior,
unchanged.

`key_column` is `event_id`, not `id`. Event ids are derived
deterministically (UUIDv5 over item/checkpoint/ordinal), which is what
makes a retried append idempotent under LSM dedup. The base already took
the key column as a parameter, so it needed no change to accommodate
this.

## Verification

New test `compaction_folds_merged_fragments_and_preserves_reads` pins
behavior that could not exist before: fragments accumulate across
merges, compaction reduces the count, and every event stays readable
afterward.

- 181 core lib tests pass (up from 180)
- 182 Python tests pass
- `cargo clippy --workspace --all-targets` clean

## What's left in #214

Part 1 is done after this — all three stores share `StorageBase`. Part 2
(arbitrary user-defined schemas with a required `id`) builds on it: the
base already validates `key_column` against the schema at open, which is
the enforcement point that work needs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
beinan pushed a commit that referenced this pull request Jul 28, 2026
… layers (#219)

## What

Completes the `DatagenStore` surface the datagen POC needs, wired
through all six layers (core → api → client/server/unified → PyO3 →
python wrapper) and exposed on the high-level
`lance_context.DatagenStore` wrapper so callers using the public Python
API reach every new function.

## Pieces

- **A1 — `DatagenStreamWriter`**: a client-side state machine that
stamps the bookkeeping columns (`item_seq`, `attempt`, `checkpoint_id`,
`event_id`) and returns event dicts to hand to
`append`/`append_checkpoint`. No new HTTP endpoint — works embedded and
remote. Fresh streams via `open_stream` (attempt=0, next_seq=1); resume
via `resume_stream` (attempt=`last_attempt+1`, continuing `item_seq`).
- **A3 — resume / attempt>0 fold semantics**: `resuming_writer` rebuilds
a writer from the folded item; the fold folds correctly across attempts.
- **A4 — `load_blob` by field name**: core `load_blob(folded,
field_name)` resolves the folded item's `blob_event_ids` map to bytes.
Propagated to Python via the folded dict's `blob_event_ids` +
`get_blob`, composed in the `api.py` wrapper.
- **A5 — inspection tree**: `item_tree` folds every projected descendant
to its latest state and links parent→child. Server does a thin raw
`events_for_root` dump; the client folds the tree via a single-source
`DatagenItemTree::build`.

## Python API

The high-level `lance_context.DatagenStore` wrapper gains `open_stream`
/ `resume_stream` / `item_tree` / `load_blob`, plus a
`DatagenStreamWriter` wrapper class (`step_started`, `step_completed`,
`item_terminal`, `item_failed`, `item_id`/`attempt` properties). Both
are re-exported from `lance_context`.

## Tests

- `python/tests/test_datagen.py` — 6 tests covering
open/resume/item_tree/load_blob/item_failed through the public wrapper.
- A core store integration test driving a real store: open a root
stream, complete a step with a Set field, checkpoint, terminal, spawn a
child stream, then assert `item_tree` links
roots/children/status/query_tags/fields.

All green: Rust 25 datagen core tests, Python 6 tests, `ruff`,
`pyright`, `cargo fmt --check`.

## Note

Branches from before origin/main merged the StorageBase migration
(#216/#217/#218). CI may surface conflicts against those; will
rebase/fix as needed.

---------

Co-authored-by: YangjunZ <yangjunzhang@microsoft.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant