Skip to content

refactor(core): extract shared StorageBase from RolloutStore - #215

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:storage-base
Jul 27, 2026
Merged

refactor(core): extract shared StorageBase from RolloutStore#215
beinan merged 1 commit into
lance-format:mainfrom
beinan:storage-base

Conversation

@beinan

@beinan beinan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

First step of #214.

Lifts the schema-agnostic half of RolloutStore into a new store_base module, so ContextStore and DatagenStore can be migrated onto it in follow-ups instead of each carrying its own forked copy of the same machinery.

Rollout is the reference. This PR is a pure extraction — no behavior change. The point is to establish the shared implementation with rollout's semantics before the other two stores are moved onto it, since those migrations are where the actual behavior changes land (ContextStore gaining WAL merge and seal-on-flush, DatagenStore gaining compaction and an index).

What StorageBase owns

  • open/create — including the load_with_options / create_with_options / DatasetNotFound-fallback triad that is currently written out four times across the tree
  • resident writer lifecycle — lazy open, fence detection + single retry, identity-checked invalidation, close(), and the Drop fallback that seals an evicted store's memtable
  • put (durable append, no seal) and flush (seal + drain), with the add/flush latency and error metrics
  • WAL merge — prepare/commit split, surgical generation drain reusing the shard's current epoch, post-drain directory deletion, and both the count and time triggers
  • compaction with defer_index_remap, ZoneMap index management, MemWAL index initialization, shard-manifest discovery, and the LSM scanner

The two things the base needs to stay schema-agnostic are passed via StorageBaseOptions: key_column (the LSM merge key and indexed column) and an optional latest_schema for merge-time additive evolution. key_column is validated against the schema at open — which is also the hook Part 2 of #214 will use to enforce the required id field.

What stays in RolloutStore

The rollout schema: rollout_schema(), records_to_batch / batch_to_rollout_records, RolloutFilters, the SQL path, and the typed read APIs. Storage is delegated to its base field.

Also deduplicated

align_batch_to_schema, derive_shard_id, is_fenced_error, is_not_found_error, ListSource and PreparedMerge all move into store_base. rollout_store re-exports them so the public API is unchanged, and DatagenStore now imports them from store_base rather than reaching into rollout_store — removing the odd dependency where the datagen store pulled helpers out of the rollout store.

Verification

  • Net -1145 lines
  • Full workspace test suite passes unchanged: 178 core lib tests (45 rollout), plus the wal_merge_concurrency and wal_merge_generation_cleanup integration tests — the ones that actually exercise the epoch/fence/drain invariants
  • cargo clippy --workspace --all-targets clean
  • rustdoc warnings 13 → 7

Follow-ups (from #214)

Migrate ContextStore (biggest behavior delta: it currently never merges WAL and never seals on write) and DatagenStore (gains compaction, which it has none of today) onto this base, then build the arbitrary-schema layer on top.

🤖 Generated with Claude Code

First step of lance-format#214. Lifts the schema-agnostic half of `RolloutStore` into a
new `store_base` module so `ContextStore` and `DatagenStore` can be migrated
onto it in follow-ups, instead of each carrying its own forked copy.

`StorageBase` owns everything that is not schema-specific:

- open/create, and the `load_with_options` / `create_with_options` /
  `DatasetNotFound`-fallback triad that is currently written out four times
  across the tree
- the resident `ShardWriter` lifecycle: lazy open, fence detection and
  single retry, identity-checked invalidation, `close()`, and the `Drop`
  fallback that seals an evicted store's memtable
- `put` (durable append, no seal) and `flush` (seal + drain), with the
  add/flush latency and error metrics
- the WAL merge: prepare/commit split, surgical generation drain reusing the
  shard's current epoch, post-drain directory deletion, and both the count
  and time triggers
- compaction with `defer_index_remap`, ZoneMap index management, MemWAL
  index initialization, shard-manifest discovery, and the LSM scanner

`RolloutStore` is now the rollout *schema* plus encode/decode and the typed
read APIs, delegating storage to its `base` field. Behavior is unchanged --
this commit is a pure extraction, and rollout is the reference implementation
the other two stores will be moved onto.

Also deduplicates `align_batch_to_schema`, `derive_shard_id`,
`is_fenced_error`, `is_not_found_error`, `ListSource` and `PreparedMerge`
into `store_base`; `rollout_store` re-exports them so the public API is
unchanged. `DatagenStore` now imports them from `store_base` rather than
reaching into `rollout_store`.

Net -1145 lines. Full workspace test suite passes unchanged (178 core lib
tests, WAL merge concurrency and generation-cleanup integration tests),
clippy is clean, and rustdoc warnings drop from 13 to 7.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan merged commit e4d2f1b into lance-format:main Jul 27, 2026
9 checks passed
beinan added a commit that referenced this pull request Jul 28, 2026
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 `close`d 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 fn`s 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](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
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>
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