feat(core): user-defined schemas via SchemaSpec and GenericStore - #218
Merged
Conversation
Part 2 of lance-format#214, on top of the consolidated StorageBase from lance-format#215-lance-format#217. Users can now declare their own schema instead of picking one of the three built-in ones, while `add` and the WAL merge behave identically to `RolloutStore` — because they are literally the same code path. Three pieces: **`schema_spec`** — a schema as a *value*, declared at creation and persisted with the store, rather than a hard-coded `fn schema()` whose field list is then repeated in five to seven places. Validation enforces what the storage layer assumes: - an `id` column is mandatory (Utf8, non-nullable, tagged `unenforced-primary-key`); it is always the LSM merge key and the indexed column - reserved names are rejected — anything starting with `_`, so future Lance internals cannot collide with a user column - vector columns carry dimension and metric, so an index can be built and the metric round-trips on open - nested lists and lists of vectors are rejected rather than silently mis-encoded **`generic_codec`** — schema-driven encode/decode between JSON rows and Arrow. Values are matched to columns by name in both directions, and an undeclared key is an error rather than being dropped, so a field-name typo fails at the write instead of becoming missing data. Projected batches decode without their missing columns, which is what lets a blob-excluding scan round-trip. **`generic_store`** — the store itself, delegating all storage behavior to `StorageBase`: resident writer, fence retry, surgical WAL drain, compaction with `defer_index_remap`, id index, LSM reads. `seal_on_add` selects rollout's deferred-seal profile (default) or read-your-write. Blobs are stored **inline**, never blob-v2 offloaded: the LSM read path has no blob-materialization step, so an offloaded column reads back as `None`. `blob: true` does not change where bytes live, it changes who pays for them — blob columns are dropped from default scan projections, so `list` never materializes them and `get(id, columns)` fetches them per row. Measured on this path, a 120 MB inline value writes in ~1.4 s and reads in ~0.8 s and survives a WAL merge; the test suite pins a 4 MB round-trip through merge. Schema is immutable after creation in v1; reopening with a different schema is rejected rather than silently reinterpreting existing data. 28 new tests. Full workspace suite passes (209 core lib tests, up from 181), clippy clean. Co-Authored-By: Claude <noreply@anthropic.com>
`mis-assigns` is flagged by typos (`mis` -> `miss`/`mist`). Reworded rather than adding a dictionary exception, since the phrasing was avoidable. Co-Authored-By: Claude <noreply@anthropic.com>
beinan
added a commit
that referenced
this pull request
Jul 28, 2026
…Python (#220) Completes #214 Part 2. `GenericStore` was core-only after #218; this wires it through every layer so users can actually declare and use their own schema. ## How it looks ```python from lance_context import GenericStore store = GenericStore.open(uri, schema={ "id": {"type": "string", "nullable": False}, # required "user": "string", # shorthand "score": "float32", "tags": {"type": "list", "item": {"type": "string"}}, "vec": {"type": "vector", "dim": 768, "metric": "cosine"}, "video": {"type": "binary", "blob": True}, }) store.add([{"id": "r1", "user": "u1", "score": 0.9}]) # nullable cols may be omitted store.list(filter="score > 0.5") # no video — never materialized store.get("r1", columns=["video"]) # fetch the blob for one row ``` Same over REST: ``` POST /api/v1/generic create with a schema GET /api/v1/generic list GET /api/v1/generic/{name} fetch identity + schema DELETE /api/v1/generic/{name} POST /api/v1/generic/{name}/rows append GET /api/v1/generic/{name}/rows list (?filter, ?limit, ?offset) GET /api/v1/generic/{name}/rows/{id} point read (?columns=video) POST /api/v1/generic/{name}/flush POST /api/v1/generic/{name}/merge-wal ``` ## `SchemaSpec` moves to the API crate A schema is part of the wire contract — the server accepts one at creation, the client needs the same type to send it — so it belongs with the other request/response types rather than in the storage crate. It depends only on `arrow-schema` and `serde`, not on Lance. Core re-exports it, so existing paths are unchanged. Its one tie to core (`DistanceMetric::parse`) becomes a local identifier check, with a comment noting the two must stay in sync. ## No per-column DTO, on purpose The schema is declared at runtime, so a static struct can't describe it. Rows are `serde_json` maps in both directions — the wire form *is* the row form, so there's no conversion layer to drift. ## Two bugs the survey turned up **Nine duplicated conversion functions, not the six #214 counted.** `dto_to_relationship` and `relationship_to_dto` each had a *third* copy in `routes/rollouts.rs`. All were byte-for-byte identical — pure copy-paste that happened to still be in sync. Now defined once in core and re-exported. **`shutdown` only closed rollout stores.** Datagen and generic stores hold resident MemWAL writers too, so an unsealed memtable was left to the best-effort detached close in `Drop` — rows could stay durable-but-invisible until the next WAL replay. All three kinds now close deterministically. This one pre-existed for datagen; adding a fourth store kind would have quietly widened it. ## Registry stores name and URI only A generic store's schema lives in its own dataset. A second writable copy in the registry is exactly the divergence this whole refactor exists to remove, so I didn't add one. The cost is that listing can't report schemas without opening each dataset — hence `GenericStoreInfo::schema` is `None` in list responses and populated for single-store lookups. Called out explicitly rather than left to be discovered. ## Verification 21 new tests — 6 server route tests (create/list/add/read round-trip, invalid schema rejected before anything is created, duplicate-create conflicts, undeclared column rejected, delete unregisters, deferred seal + WAL merge) and 15 Python tests. Full suite: **201 core + 57 server + 18 api** Rust tests, **197 Python** tests, `cargo clippy --workspace --all-targets` clean, `typos` clean. ## Follow-ups - `seal_on_add` is not persisted, so a reopened store takes the server default. Worth persisting alongside the schema if it turns out to matter. - The sweepers (`spawn_global_sweeper`, `spawn_flush_sweeper`) still only visit rollout stores — pre-existing for datagen, now also true for generic. Generic stores default to deferred seal, so they rely on an explicit flush or the `merge-wal` route until that's addressed. 🤖 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.
Part 2 of #214, on top of the consolidated
StorageBasefrom #215–#217.Users can now declare their own schema instead of picking one of the three built-in ones — while
addand the WAL merge behave identically toRolloutStore, because they are literally the same code path, not a reimplementation.Three pieces
schema_spec— schema as a valueThe built-in stores hard-code an Arrow schema and then repeat its field list across five to seven places (schema fn, builders, decoder, projection, filters, merge key, index name). A
SchemaSpecis declared once at creation and persisted with the store.Validation enforces what the storage layer actually assumes:
idis mandatory —Utf8, non-nullable, taggedunenforced-primary-key. It is always the LSM merge key and the indexed column, which is what makes a retried append idempotent and a crashed merge safe to redo._, so future Lance internals can't collide with a user column.generic_codec— schema-driven encode/decodeJSON rows ↔ Arrow, matched by name in both directions. Two deliberate choices:
This also fixes by construction a bug class the built-in stores have: they decode nested structs (
relationships,state_metadata) positionally, so reordering fields silently mis-assigns data.generic_store— the storeDelegates all storage behavior to
StorageBase: resident writer, fence retry, surgical WAL drain, compaction withdefer_index_remap, id index, LSM reads.seal_on_addpicks rollout's deferred-seal profile (default) or read-your-write.How users write it
Blobs are inline, and that's deliberate
Per your point that this project exists to store big blobs: inline
LargeBinary, never blob-v2 offload. The LSM read path has no blob-materialization step, so an offloaded column reads back asNone— which is exactly whyRolloutStorestores artifacts inline today.blob: truedoesn't change where bytes live, it changes who pays for them: blob columns are dropped from default scan projections, solistnever materializes them, andget(id, columns)fetches them per row.I measured this path directly before designing around it:
The test suite pins a 4 MB round-trip through a merge (kept small so CI stays fast).
max_memtable_sizedefaults to 256 MB, so two such rows trigger a seal. That's not breakage — the count/time merge triggers bound the resulting generations — but it does raise seal frequency.ShardWriterConfigexposes the knob; no store surfaces it yet. Follow-up if it bites.Schema is immutable in v1
Reopening with a different schema is rejected, not silently reinterpreted over existing data. Evolution needs a versioning story and is out of scope here.
Verification
28 new tests: 8 spec validation, 9 codec round-trip, 11 store behavior — including that duplicate ids dedup to the newest write (proving
idreally is the merge key), deferred seal matches rollout's visibility semantics, and generations merge into the base table.209 core lib tests pass (up from 181),
cargo clippy --workspace --all-targetsclean.Not in this PR
The server/client/Python surface —
CreateContextRequestcarrying a schema, generic record DTOs, deduping the six duplicated conversion functions, explicit column names in search/retrieve. This PR is the core engine; the API layer is the next one, and it's large enough to want its own review.🤖 Generated with Claude Code