Lance¶
Lance is a columnar format with fast random access,
wrapped in a versioned table format. A Lance dataset is a directory, not a flat collection
of shards: it holds its data files under data/, and one immutable manifest per version
under _versions/. The manifest lists every fragment — a horizontal slice of the
dataset with its own data file and row count.
How Lance maps onto shards. One fragment is one Zephon shard. The mapping is forced in
both directions: a fragment cannot be split across shards, because two shards would then
resolve the same cached file, and two fragments cannot be merged into one shard, because
they are two separate files. You control shard size when you write the dataset, through
max_rows_per_file.
Because the manifest already records each fragment’s file, byte size and row count, it
is the index: Zephon discovers a Lance dataset in two range reads, with no index.json
to build and without opening a single data file. python -m zephon.build_index therefore
has no Lance mode.
Preparing Lance datasets. Install Zephon with the lance extra, which adds the
pylance dependency:
# Install Zephon with the lance extra.
pip install "zephon[lance]"
"""Write a small Lance dataset, one fragment per shard."""
import lance
import pyarrow as pa
table = pa.table(
{
"id": pa.array(range(8), pa.int64()),
"text": pa.array([f"record {i}" for i in range(8)]),
}
)
# Each fragment becomes one Zephon shard, so max_rows_per_file sets the shard size.
lance.write_dataset(table, "data/lance_demo", max_rows_per_file=4)
Reading Lance datasets in Zephon. Pass the dataset directory to Dataset.from_path,
with or without fmt="lance":
"""Define and inspect a Lance Dataset."""
from zephon.debug import DatasetInspector
from zephon.io import Dataset
# Discovery reads the dataset's newest manifest and pins to it, so dataset.path
# names that version: later appends do not change this run's shards.
dataset = Dataset.from_path("lance_demo", "data/lance_demo", fmt="lance")
print(dataset.path)
with DatasetInspector(dataset) as inspector:
print(inspector.read(shard_id=0, sample_index=0))
Sample payloads are dictionaries keyed by column name, following the same conversion rules as Parquet.
Versions are pinned at discovery. A Lance dataset directory is mutable — an append
creates a new version — while each manifest is immutable. Dataset.from_path resolves the
directory to its newest manifest and makes that manifest the dataset’s path, so every rank
and every worker in the run plans against exactly one version, however the directory
changes underneath. Keep dataset.path if you want a later run to resume against the same
shards; pass a manifest directly to read an older version:
"""Pin a Lance dataset to an earlier version by naming its manifest."""
from zephon.io import Dataset
# Any committed manifest under _versions/ is a valid dataset root. Passing one
# reads that version, which is what resuming a run against the same shards needs.
dataset = Dataset.from_path(
"lance_demo", "data/lance_demo/_versions/18446744073709551614.manifest"
)
print(dataset.shard_count(), dataset.total())
Deleted rows. Lance deletes rows by recording tombstones rather than rewriting data
files. Zephon reports the surviving row count per shard and maps each row it reads past
the deleted ones, so a dataset you have called delete() on reads correctly and needs no
compaction.
What Zephon refuses. Every unsupported case is rejected during discovery, with the reason and the remedy in the message, so a run never fails halfway through:
Case |
Why |
Remedy |
|---|---|---|
A fragment whose columns span several data files, which |
A Zephon shard resolves exactly one file through the shard cache |
Rewrite the dataset: |
A data file still holding a column that |
A drop rewrites only the manifest, which then no longer says where the remaining columns sit in the file |
Rewrite the dataset |
Blob columns ( |
Their values live outside the fragment’s data files |
Store the payloads as ordinary binary columns |
Data overlay files |
Reading only the base values would return rows a later update has already replaced |
Compact the dataset |
Legacy v1 data files |
Zephon reads the v2 file format |
Rewrite with |
Files reached through another dataset’s base path (shallow clones, imported files) |
The file lies outside the dataset root Zephon resolves against |
Rewrite the dataset in place |
Fragments stored in an external fragment tree |
The fragment list is not in the manifest |
— |
A manifest setting a reader feature flag this Zephon does not know |
Lance’s own compatibility rule: an unknown flag may change how the data decodes |
Upgrade Zephon |
Caching reads. Zephon retains opened fragments — a projected Lance reader plus that
fragment’s decoded tombstones — per fetch replica, separately from the on-disk shard
cache. The retention limit is metadata_cache_entries, additionally capped well below a
typical open-file limit because each entry holds an open file handle. Set it to zero to
disable retention.
pipeline.options(io_options={"metadata_cache_entries": 256})