Skip to content

tools/lance-resolver: add standalone Lance dataset resolver for warmup - #7399

Open
hiltonhe wants to merge 40 commits into
juicedata:mainfrom
hiltonhe:lance-resolver
Open

tools/lance-resolver: add standalone Lance dataset resolver for warmup#7399
hiltonhe wants to merge 40 commits into
juicedata:mainfrom
hiltonhe:lance-resolver

Conversation

@hiltonhe

Copy link
Copy Markdown
Contributor

Depends on: #7398

Motivation

Lance datasets span multiple files (manifests, data, transactions,
deletions). Users need to warm up the exact set of files for a specific
version, not blindly traverse the directory.

Changes

Add tools/lance-resolver/, a standalone CLI tool that:

  • Reads a Lance dataset and resolves all data files for a version
  • Outputs paths in juicefs warmup -f compatible format
  • Supports --version, --manifest-only, --columns flags

Usage

lance-resolver /mnt/jfs/dataset.lance > /tmp/list.txt
juicefs warmup -f /tmp/list.txt

Architecture

  • No changes to core JuiceFS code (pure tools/ addition)
  • Zero external dependencies (protobuf code is vendored)
  • Proto files from Lance project (Apache 2.0)

Test Coverage

  • resolver_test.go: 53 sub-cases covering manifest parsing, dataset
    resolution, error handling, and idempotency

@jiefenghuang

Copy link
Copy Markdown
Contributor

The warmup PR has already been merged. Please remove the changes that are no longer needed and resolve the conflicts. Also, the format supported by warmup has been adjusted.

@hiltonhe

Copy link
Copy Markdown
Contributor Author

@jiefenghuang Thanks for the review.

Done:

Could you PTAL again? Thanks.

Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver_test.go Outdated

@jiefenghuang jiefenghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the tool against synthetic Lance datasets. The byte-range plumbing in #7398 looks good, but the resolver itself has a few blocking correctness issues — most importantly it fails on real Lance manifests, which the current tests don't catch because they write a layout Lance never produces. Details inline.

Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver.go
Comment thread tools/lance-resolver/resolver.go Outdated
Comment thread tools/lance-resolver/resolver_test.go Outdated
@jiefenghuang

jiefenghuang commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The proto files can reference a specific version of the Lance project directly, so there is no need to introduce an additional copy.

The upstream go_package configuration currently has some issues. I think we can add a shell script that downloads the proto files from an explicitly pinned Lance version and then uses a specific version of protoc to generate the Go code.

Comment thread Makefile Outdated
Extract Lance format parsing logic into a standalone CLI tool under
tools/lance-resolver/. This is the external resolver approach: JuiceFS
stays format-agnostic, and this tool handles all Lance-specific format
parsing.

Usage:
  lance-resolver /mnt/jfs/data/ds.lance | juicefs warmup -f -
  lance-resolver --columns id,name /mnt/jfs/data/ds.lance | juicefs warmup -f -

Features:
- Resolve Lance dataset to file paths (data files, deletions, overlays)
- Version selection (latest or specific version via --version)
- Column-level byte range resolution (V2 format, --columns)
- Manifest-only mode (--manifest-only)
- Index file inclusion (--include-indices)
- Output format compatible with warmup --file (PR 1)

Dependencies: warmup-range-support branch (PR 1) for --file range support
Tests: 14 test functions covering manifest parsing, byte range merging,
  dataset resolution, error handling, and file system operations.
The current Lance V2 writes manifest files with a two-layer structure:
[transaction_len:4][Transaction][manifest_len:4][Manifest][footer:16]

Update readAndParseLanceManifest to detect and parse this format,
falling back to the V1 format for backward compatibility.

Also fix footer magic check offset (now at footer[36:40] instead of
footer[24:28]) and mark column-level byte range resolution as TODO
since the V2 CMO table is now protobuf-encoded instead of raw u64
entries.
The warmup -f file format uses SPACE to separate paths from byte ranges
('path start-end;start-end;...'), not TAB. Update comments and help
text to reflect the correct format.

Also fix help examples: remove '| juicefs warmup -f -' since warmup
does not support reading from stdin (-f -). Use temp file pattern
instead: lance-resolver > /tmp/list.txt; juicefs warmup -f /tmp/list.txt
- parse manifest via trailing 16-byte footer and manifest_pos instead
  of the V2 heuristic; validate with 64-bit arithmetic to avoid overflow
- fix V2 latest manifest version selection in findLatestManifestByListing
- fall back to full-file warmup when --columns is requested (column-level
  byte ranges are not implemented yet)
- use the stdlib flag package and error on unexpected arguments
- add Makefile test.tools target and extend unit tests for real manifest
  layout, non-zero manifest_pos, and V2 version selection
- add tools/lance-resolver/gen-protos.sh to download file.proto and
  table.proto from Lance v11.0.0-rc.1 and regenerate the Go code with
  protoc-gen-go v1.36.11
- remove checked-in .proto copies and the unused file2 protobuf set
- regenerate table.pb.go from the pinned version and add Makefile
  gen-lance-protos target
- generate file2.pb.go from pinned Lance v11.0.0-rc.1
- add --include-data-pages flag and implement column byte-range resolution
  for V2 data files using the file footer and column metadata offset table
- output 'path [start-end;...]' targets so juicefs warmup can warm only
  blocks referenced by the requested columns
- fall back to full-file warmup for V1 files or files without the requested
  columns
- add unit tests for field-to-column mapping, byte-range merge, column
  buffer ranges, and synthetic V2 footer parsing
cmPos and cmLen are read directly from the CMO table entry and their sum can overflow uint64, defeating the bounds check and leading to make([]byte, int(cmLen)) panicking on corrupt input. Validate with subtraction instead, and skip buffer ranges that would overflow.
The Makefile targets are unnecessary: gen-protos.sh can be run directly and the tool is already covered by go test ./tools/.... Document usage in a README under tools/lance-resolver instead.
A REPEATED (list) or PARENT (struct) column spans multiple physical columns: a list's element data lives in nested child columns that are not reachable via DataFile.ColumnIndices. Warming such a column by name only reached the outer column and silently missed the data. Detect them via the schema field type and fall back to full-file warmup with a warning.
Clarify that --columns only supports leaf (primitive) columns and that list/struct columns fall back to full-file warmup with a warning.
Record the planned direction: delegate nested (list/struct) column warmup to the official Rust lance/lance-file crates via a sidecar, rather than reimplementing the encoding walker in pure Go.
columnWarmupPath builds output paths with path.Join (forward slashes); the test expected filepath.Join (backslashes on Windows). Match the code so the test is green on all platforms.
Upstream Lance writers never populate the Field type enum on the wire
(it always decodes to PARENT), so the Field_LEAF check made --columns
fall back to full-file warmup on every real dataset while synthetic
tests passed because their builders set the enum explicitly.

Derive leaf-ness from the flat field list instead: a field is a leaf
iff no other field references it via parent_id. parent_id = -1 marks
top-level fields (upstream stores it explicitly); a non-negative
parent_id is a real reference and is passed through verbatim by
upstream readers. Test builders now mirror the real wire shape: no
type enum and explicit parent_id.
gen-fixtures.py writes the golden dataset with pylance 10.0.0 (the pin
closest to the v11.0.0-rc.1 sources used by gen-protos.sh; that version
is not on crates.io/PyPI and the footer/CMO layout is shared across V2
file versions). dump_fixture.py is an independent ground-truth extractor:
it parses the manifest footer, V2 file footer, CMO table and
ColumnMetadata straight from the fixture bytes and writes expected.json.

Regenerations are stable only after normalization: file names are random,
the manifest carries a creation timestamp, and the writer permutes which
content lands in which file (RAYON/TOKIO single-threading does not fix
it). expected.json therefore masks volatile fields and orders files by
content signature; the golden tests compare multisets accordingly.
The golden tests resolve the committed fixture end to end: full-dataset
resolution, per-column byte ranges for every leaf column in both
metadata-only and data-pages modes, and struct/unknown-column fallback
to full-file warmup. Because the writer permutes file contents across
regenerations, all assertions compare multisets of per-file values.

Verified non-self-confirming by mutation: corrupting any of footer
length, magic offset, CMO table offset, CMO entry size or num_columns
offset makes the golden tests fail.

The synthetic-byte tests in resolver_test.go are now explicitly
scoped to negative/boundary coverage (note at the top of the file);
resolver.go changes are gofmt comment reflow only.
Data-file footers now carry a (major, minor) whitelist — (2,0)/(2,1)
legacy numbering plus the (0,3) pair dispatched by the upstream v11
reader — so an unknown future grammar fails loudly (and falls back to
full-file warmup) instead of being silently misread. Manifest footer
versions are only warned about when unknown: just (0,2) is observed in
the wild so far and the semantics are not documented enough to reject.

The overflow-guard test now writes a valid version pair so it still
reaches the guard it was written for.
check-fixture-drift.sh regenerates the fixture into a temp dir with the
pinned pylance and verifies the normalized ground truth (expected.json)
is unchanged via git diff --exit-code. Raw fixture bytes cannot be
diffed: file names and the manifest timestamp are random per run and
the writer permutes content across files, but the dumper's
normalization was verified to be byte-stable across regenerations.

The workflow runs the drift check plus the golden tests on every
change under tools/lance-resolver/. README gains a testing/fixtures
section documenting the regeneration commands.
gen-protos.sh pinned protoc-gen-go but used whatever protoc happened to
be on PATH, so regeneration depended on the host environment. The script
now downloads a pinned protoc release binary for the host platform
(unzip with python3 fallback) and passes its bundled include dir, making
generation reproducible without a host protoc install.

Regenerating with protoc 36.0 changes only the version comment in the
generated files (previously v3.21.12 from PATH); descriptor bytes are
identical.
Any column projection first reads the V2 file footer (to locate the CMO
table) and the projected columns' 16-byte CMO entries (to locate their
ColumnMetadata); warming only the metadata protobuf left those reads as
guaranteed cache misses. Both are now part of every column-level warmup
line and merge with adjacent ranges, so typical output grows by one
merged range at the file tail. dump_fixture.py computes the same ranges
so expected.json stays the independent ground truth.
Fragments may store their row-id / last-updated-at / created-at version
sequences in dedicated files (ExternalFile with path relative to the
dataset root and an explicit byte range) once the sequence exceeds the
~200KB inline threshold; the resolver skipped them entirely, leaving
guaranteed cache misses on stable-row-id datasets after warmup.

They are now always emitted, with the [offset, offset+size) range when
the payload is a sub-range. Real fixtures cannot cover this yet (the
inline threshold is far beyond fixture size even at 100k rows), so the
resolution is covered by a synthetic manifest test and the dumper/golden
scaffolding (external_files in expected.json, EXTFILE tokens) is ready
for the day a fixture can produce them.
ColumnMetadata and its pages may reference their encoding via
Encoding.indirect (deferred encoding, buffer_location/buffer_length).
The upstream v11 reader rejects those outright, so there is no
reference implementation that defines how buffer_location resolves to
an actual buffer; emitting guessed ranges would risk incomplete warmup
while claiming coverage.

The resolver now detects indirect encodings on the column or any page
and falls back to full-file warmup with a warning, mirroring the
list/struct fallback. Direct and absent encodings are unaffected.
Data files, overlays and column warmup all joined paths against the
dataset's data/ dir, ignoring base_paths/base_id — shallow clones,
imported files and multi-base datasets resolved to nonexistent paths
(and --columns then failed open and fell back to the same wrong path).

Path resolution now mirrors upstream data_file_dir_for_base: no base_id
means the dataset's own data/ dir; a set base_id looks up
manifest.base_paths (distinct from unset since base_id is optional),
where dataset-root bases keep files under data/ and direct-file bases
use the base path as-is. A missing or empty base entry warns and falls
back to the dataset root instead of failing the whole run. Deletion
files keep resolving against the dataset root: upstream
deletion_file_path ignores their base_id.

pylance cannot produce such manifests locally, so coverage is a
synthetic test of the resolution rules; the golden fixture (no base
paths) is unaffected.
Nested requests used to fall back to full-file warmup on the assumption
that spanning multiple physical columns requires Lance's full encoding
model. Empirically that is not the case for warmup: list/struct parents
have no physical column at all (verified on real datasets — only the
terminal leaf fields carry column indices, and list offsets live inside
the item column's own buffers), so warming a subtree is just the union
of the descendant columns' ranges.

A requested column now expands transitively via parent_id to its whole
subtree, and every subtree field with a physical column in the file
contributes its footer/CMO/metadata/buffer ranges. Fallback remains for
requests that resolve to no physical columns and for indirect (deferred)
encodings. Cycle-safe expansion guards against corrupt manifests.

The golden fixture gains a list column so nested warmup is verified
against real bytes for both struct and list; dump_fixture.py exports the
matching per-name warmup map (subtree-union ranges).
Adversarial verification (zero every byte outside the emitted ranges,
then read with the official pylance reader) exposed two real gaps that
both the Go resolver and the dumper shared:

1. The official reader opens a file with ONE optimistic tail read from
   the first global buffer (the file schema) to EOF and parses ALL
   column metadata from it regardless of the projection (upstream v11
   reader.rs). Column warmup now covers [gbo[0].pos, EOF) plus any
   global buffers positioned before it, via a new globalBufferTail
   helper reading the GBO table.

2. Readers coalesce adjacent buffer reads into spans that include the
   alignment padding between buffers; ranges separated by <= 4KiB are
   now filled (free on block-granular caches like JuiceFS's).

With both fixes the verification passes for every projection: leaves,
list, struct, 3-level nesting and multi-column combinations.

verify_warmup_ranges.py is committed so the adversarial check is
repeatable; golden expectations regenerated (dumper computes the same
tail/gap rules), regen determinism re-verified.
The flag table still said --columns accepts leaf columns only, while
nested struct/list support landed in e7a0704 and the section right
below the table describes it. Align the table with the actual behavior.
fillByteRangeGaps filled gaps up to 4KiB, which chained across unrequested
columns on compact files: --columns id,name --include-data-pages covered
100% of the fixture file, degrading column-level warmup to whole-file for
a two-column projection. Readers only ever coalesce across alignment
padding, and Lance aligns buffers to 64-byte boundaries (padding <= 63
bytes; the official reader was observed to need at most 62), so the fill
threshold drops to 64.

Two of five columns now warm 50% of the fixture file instead of 100%,
and the adversarial verification still passes for every projection
(leaves, nested struct/list, multi-column, 3-level nesting). README
documents the merging behavior and the dense-projection caveat.
The GBO bounds check used gboStart+tableLen > limit, which a corrupt
footer storing gbo_start near MaxUint64 wraps past; and the extras loop
formed pos+size before validating size, so out-of-range entries could
slip through as garbage warmup ranges. Neither could panic (ReadAt
rejects the huge offsets), but the style diverged from the subtraction
guards used for CMO entries. All bounds checks now subtract.

Writing the boundary tests also exposed an inverted skip condition:
extras skipped buffers entirely BEFORE the tail (the ones that need a
separate range) and redundantly included buffers already inside the
reader's [tailStart, EOF) tail read. Skip iff pos >= tailStart now;
dump_fixture.py mirrors the same rule. No observable change on the
fixture (single global buffer), golden expectations unchanged.

Covers: happy path with extras, zero buffers, gbo_start = MaxUint64
(wrap), table past file end, schema position beyond file, corrupt
pos/size pairs, zero-sized extras, file smaller than the footer.
The golden tests covered file-kind counts and column-range multisets,
but the layout facts exported by dump_fixture.py were not load-bearing:
manifest_pos, snapshot version, manifest footer version, every data-file
footer field (column_metadata_start, cmo/gbo table starts, buffer and
column counts, footer version) and each column's CMO entry.

TestGolden_Structure decodes the fixture bytes with a raw third
implementation (no production parsing) and asserts all of them, matching
data files to expected entries by footer bijection. Verified by
mutation: corrupting any of cmo_table_start, cm_pos, manifest_pos,
manifest footer minor or the file footer version pair in expected.json
now fails the suite.
parent_id = 0 is ambiguous on the wire (proto3 cannot distinguish unset
from an explicit 0), and the earlier fix-plan note flagged writers that
omit parent_id on top-level fields. The reference tree builder
(lance-file datatypes, TryFrom<&Fields> for Schema) resolves this as a
REAL reference to field 0 — only -1 marks a root, and a missing parent
is a schema error — so such omitted-parent manifests are misread by the
official reader itself and cannot be valid ecosystem files.

fieldChildren keeps mirroring that behavior deliberately: treating 0 as
root would diverge from upstream and break legitimate schemas whose
field 0 is a struct parent. The comment now cites the upstream source,
unknown parents stay tolerated (no edge rather than a hard failure),
and a documentation test pins all three behaviors.
The workflow checked fixture drift and ran the golden tests, but range
SUFFICIENCY — the property only the adversarial check can prove — was
not exercised: zero every byte outside the emitted ranges and confirm
the official reader still returns correct data. The workflow already
installs Go, Python and the pinned pylance, so it now runs
verify_warmup_ranges.py for a leaf column, a list column, a struct
column and the full multi-column projection. The script also builds
its resolver binary with a platform-neutral name for Linux runners.
A corrupt global-buffer-offset table only made globalBufferTail return
false, so column warmup silently continued with partial ranges minus
the tail — inconsistent with corrupt CMO entries, which degrade to
full-file warmup.

globalBufferTail now returns an explicit three-state result: a file
legitimately declaring zero global buffers stays absent (upstream
rejects such files on open, but that is not a table corruption and
ranges continue), while any validation failure is corrupt and makes
columnWarmupPath warn and fall back to the full file, matching the CMO
semantics. Boundary tests updated to the three states; new test pins
the end-to-end fallback on a corrupt gbo_start.
…cation

verify_warmup_ranges.py leaked its temp dirs: the go build directory was
never removed, the dataset copy was only cleaned on the success path,
and a failing case (which is exactly when the copy matters for post-
mortem-free CI) left both behind. Everything now lives under one
TemporaryDirectory, cleaned on success and failure alike.

Files were also matched against the resolver output by basename, which
would apply the wrong ranges if a dataset ever carries same-named files
in different directories (e.g. multi-base-path imports). Matching now
uses the path relative to the dataset root, and ranges resolved outside
the dataset are reported and skipped instead of silently ignored.
The fill threshold was only covered indirectly through the golden
ranges. The new table test pins the exact boundary — a 64-byte gap
merges (Lance buffer alignment means padding never exceeds 63 bytes),
65 does not — plus chaining across several padding gaps and the
far-apart and degenerate cases, so a future threshold tweak fails a
named test instead of silently changing multi-column warmup scope.
… as local paths

Extending the adversarial verification beyond the committed fixture
surfaced a real gap: pylance registers local bases as file:/// URIs, and
the resolver emitted them collapsed (file:/C:/...), which os.Open and
'juicefs warmup -f' cannot consume — column warmup silently fell back
to full files for imported datasets. Base paths now convert file://
URIs to plain local paths (Windows drive and POSIX forms); other
schemes pass through since those bases are not warmable locally.

verify_warmup_ranges.py gains a --multi-base mode that generates a temp
dataset split across the dataset root and an imported base (proven
writable locally via initial_bases/target_all_bases), zeroes everything
outside the emitted ranges in place — over BOTH root and base files,
with an unprojected padding column so the pass actually bites — and
verifies the official reader still returns correct data. CI runs it.

The remaining coverage boundary (metadata-only ranges, external
row-id sequences, indirect encodings) is documented in the script and
README with the upstream constraint that blocks each.
Each verify_warmup_ranges.py invocation built its own resolver binary,
so the five CI cases compiled it five times. The script now honors
LANCE_RESOLVER_BIN pointing at a prebuilt binary (erroring clearly if
missing) and still builds into its temp dir when unset; the workflow
builds once into RUNNER_TEMP and exports the variable.
… file

A footer declaring zero global buffers kept producing partial column
ranges on the argument that upstream rejects such files on open anyway
("schema expected"). That was the one remaining 'structurally invalid
file but partial ranges output' case, kept as a third state (absent)
next to ok/corrupt.

Since a V2 file without a schema buffer is never a normal data path,
the distinction bought nothing: globalBufferTail collapses back to two
states (tail ranges, or corrupt -> warn + full-file fallback), and the
invariant 'any structural anomaly degrades to the full file' holds with
no exceptions. Synthetic files that expect ranges now register a
minimal schema global buffer via addGBOTable, matching what every real
V2 file carries.
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.

2 participants