Skip to content

feat(core): user-defined schemas via SchemaSpec and GenericStore - #218

Merged
beinan merged 2 commits into
lance-format:mainfrom
beinan:generic-schema
Jul 28, 2026
Merged

feat(core): user-defined schemas via SchemaSpec and GenericStore#218
beinan merged 2 commits into
lance-format:mainfrom
beinan:generic-schema

Conversation

@beinan

@beinan beinan commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Part 2 of #214, on top of the consolidated StorageBase from #215#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, not a reimplementation.

Three pieces

schema_spec — schema as a value

The 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 SchemaSpec is declared once at creation and persisted with the store.

Validation enforces what the storage layer actually assumes:

  • id is mandatoryUtf8, non-nullable, tagged unenforced-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.
  • Reserved names rejected — anything starting with _, so future Lance internals can't collide with a user column.
  • Vector columns carry dim + metric, so an index can be built and the metric round-trips on open without being re-specified.
  • Nested lists and lists-of-vectors rejected rather than silently mis-encoded.

generic_codec — schema-driven encode/decode

JSON rows ↔ Arrow, matched by name in both directions. Two deliberate choices:

  • An undeclared key is an error, not a silent drop — a field-name typo fails at the write instead of becoming missing data discovered much later.
  • Projected batches decode without their missing columns, which is what lets a blob-excluding scan round-trip.

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 store

Delegates all storage behavior to StorageBase: resident writer, fence retry, surgical WAL drain, compaction with defer_index_remap, id index, LSM reads. seal_on_add picks rollout's deferred-seal profile (default) or read-your-write.

How users write it

store = GenericStore.open(uri, schema={
    "id":        {"type": "string", "nullable": False},
    "user_id":   {"type": "string"},
    "score":     {"type": "float32"},
    "embedding": {"type": "vector", "dim": 768, "metric": "cosine"},
    "video":     {"type": "binary", "blob": True},
})

store.add([{"id": "r1", "user_id": "u1", "score": 0.9}])   # nullable cols may be omitted
store.list()                                  # no video — never materialized
store.get("r1", columns=["video"])            # fetch the blob for one row

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 as None — which is exactly why RolloutStore stores artifacts inline today.

blob: true doesn't 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.

I measured this path directly before designing around it:

size write read survives WAL merge
5 MB 80 ms 42 ms
40 MB 483 ms 262 ms
120 MB 1.39 s 760 ms

The test suite pins a 4 MB round-trip through a merge (kept small so CI stays fast).

⚠️ Worth knowing for the 100 MB+ case: max_memtable_size defaults 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. ShardWriterConfig exposes 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 id really 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-targets clean.

Not in this PR

The server/client/Python surface — CreateContextRequest carrying 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

beinan and others added 2 commits July 28, 2026 01:10
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
beinan merged commit dc45e59 into lance-format:main Jul 28, 2026
9 checks passed
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>
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