refactor(core): migrate ContextStore onto StorageBase - #216
Merged
Conversation
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>
Collaborator
Author
|
Python bindings verified — installed That covers the two things that were compile-checked only: The |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second step of #214, after #215.
ContextStorenow delegates its storage layer toStorageBaseinstead 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 asmaybe_merge_wal(count trigger),cleanup_wal(time trigger) andpending_wal_generations— the same machinery rollout uses.Compaction dropped
storage_options. Bothcompactandcreate_id_indexreloaded via a bareDataset::open(uri), silently discarding credentials — broken on any authenticated object store. Both now reload throughStorageBase::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_entriesopened,putandclosed aShardWriteron every call, paying a cold DNS resolution + TCP/TLS handshake + epoch claim each time. Now one resident writer with fence-retry.addis&self. The writer lives behind the base's mutex, so the server takes a read lock and concurrent appends stop serializing on the store'sRwLock.addvisibility is preserved, not silently weakenedThis was the one real design fork, and it's worth being explicit since the two stores genuinely differed:
ContextStore.adddidput+close, andcloseseals — so rows were visible on return, andvalidate_unique_ids,upsert_by_external_idand the tombstone path all depend on that read-your-write.RolloutStore.adddeliberately gave visibility up:putonly, with the flush sweeper (30s) or?flush=trueproviding it.Rather than pick one and break the other,
StorageBasegains aseal_on_putswitch. Rollout keepsfalse;ContextStoredefaults toseal_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 withseal_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_idis 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.
ContextStoreis no longerCloneIt owns a
StorageBase, which owns the resident writer and aDropthat seals it — so it must have exactly one owner, or two writers race for one shard. Both clone sites now open their own handle:Context.fork, which branches the in-memory context over the same datasetThe compactor's reopen needed one bit of care:
open_innercan spawn the compaction task, which reopens viaopen_inner— a mutual recursion between twoasync fns that the compiler cannot proveSend, even though it's unreachable at runtime. Broken with a boxeddyn Futurein 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 ondeferred_seal_makes_add_durable_but_invisible_until_flush— mirrors rollout's equivalent testwal_generations_merge_into_the_base_table— generations are reclaimed and rows stay readableFull workspace suite passes (180 core lib tests, up from 177),
cargo clippy --workspace --all-targetsclean.maturinisn't available in this environment, soContext.fork's signature change is compile-checked only. Worth runningpython/tests/test_context.pyandtest_compaction.pybefore merge.Follow-ups
DatagenStoreis 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