feat(gluten): export a Lance fragment scan as an Arrow C stream - #778
Conversation
|
Addressed all three findings in e914259. Release callback (line 119). Schema contract (line 60). Construction leak (line 54).
|
|
Addressed the pushed-aggregation finding in f78423b.
Added |
|
Addressed the full-text finding in 33c2788, and generalized the admission check so this class of divergence can't recur. Rather than enumerate more column-name cases, admission now verifies the actual native scan schema against the declared partition schema. After planning the scan, Pushed aggregation stays a separate pre-plan check: an aggregate partition's declared schema can equal the native data schema, so the schema comparison alone cannot catch it.
|
|
@yanghua could you take a look when you have a chance? This is the lance-spark side of the Gluten/Velox read path — it forwards a Lance fragment scan as an Arrow C stream via CI is green and it's MERGEABLE; unit tests pass ( |
|
CI is red? |
|
The red CI isn't from this PR's changes; it's the
That's why I've split the fix into #783: it registers scoped Kryo |
Got it. Will take a look. |
| * projection, reordering row-version columns — but this zero-copy export cannot, so any mismatch | ||
| * in field names or order must fall back to the columnar reader. | ||
| */ | ||
| private static void checkNativeSchemaMatchesPartition( |
There was a problem hiding this comment.
Can we also check the arrow data type?
There was a problem hiding this comment.
Done in 235791d. checkNativeSchemaMatchesPartition now compares the declared and native schemas by Arrow ArrowType and nullability (recursing into children), not only by field name and order. The declared Spark schema is converted through LanceArrowUtils — the same adapter the read path uses — so the Arrow-specific distinctions a Spark DataType alone cannot express (LargeUtf8 vs Utf8, LargeBinary, Date(MILLISECOND), FixedSizeBinary, Float16) survive the comparison, and a column whose native type differs from the declared one (e.g. a narrower/wider int or a different time-zone timestamp) is no longer streamed as if it matched.
| * verifies the export/reader/scanner lifecycle releases cleanly. | ||
| */ | ||
| @Test | ||
| public void exportsFragmentAsArrowCStream() throws Exception { |
There was a problem hiding this comment.
Can we also check a case about an empty fragment?
There was a problem hiding this comment.
Added exportsEmptyFragmentScanPreservingSchema in 235791d: a fragment scan whose filter matches no rows (x < 0, every x is 0..3) still exports its full x, y, b, c schema up front and drains cleanly with zero batches, releasing without a leak under the leak-checking allocator.
|
@sezruby, do you have WeChat? We can contact each other more effectively. |
I don't have WeChat, but feel free to reach me on the ASF Slack (EJ Song) or LinkedIn. And I can join WeChat if there's a group chat for Lance. |
|
And I can join WeChat if there's a group chat for Lance.
|
|
I created one. @yanghua |
Add LanceArrowStreamScanner, which plans a fragment scan via the existing LanceFragmentScanner and exports it as an Arrow C Data Interface stream (ArrowArrayStream) for native consumers such as Apache Gluten / Velox. Only the ArrowArrayStream C-struct address crosses the JVM/native boundary, so the consumer's Arrow build and classloader do not need to match lance-spark's (Gluten builds Arrow 15 to match Spark 3.5 / Velox, while the Lance Java SDK is on Arrow 18). The Lance native core populates the caller-owned stream directly through LanceScanner#exportArrowStream(long), so no Arrow data is materialized on the JVM heap on this path. All scan planning (column projection, filter pushdown, limit/offset, row-id / row-address, batch size) is delegated to LanceFragmentScanner, so the exported stream yields exactly the same rows in the same order as the Spark columnar reader. LanceArrowStream owns the exported stream and the scan behind it; closing it releases the native scan (via the stream's release callback) and then the scanner and dataset handles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e schemas, close on alloc failure Addresses the three gatekeeper review findings: - Release callback (line 119): ArrowArrayStream.close() only frees the struct buffer, not the native release callback, so an abandoned or partially-consumed stream leaked the provider's private_data and record batch stream. LanceArrowStream.close() now runs the release callback via a guarded releaseStream() before freeing the struct. It is idempotent against a consumer that already imported the stream: importArrayStream snapshots the callback and closes our struct, so we skip when snapshot() reports the struct is freed or the release address is NULL. - Schema contract (line 60): the raw native scan schema diverges from the Spark partition schema for shapes the columnar reader fixes up on the JVM (synthesized _fragid, empty projection surfacing _rowid, metadata _rowid/_rowaddr/version/_score, and blob columns). export() now rejects those via checkExportableSchema() with UnsupportedOperationException so the caller falls back to the columnar reader instead of getting a wrong schema. Lance has no Hive-style partition columns, so ordinary data projections still export a stream that matches the partition schema. - Construction leak (line 54): the dataset and scanner opened by LanceFragmentScanner.create() were acquired before the cleanup try, so a throwing ArrowArrayStream.allocateNew (e.g. a bounded allocator) leaked both. The allocation now runs inside the cleanup scope, which closes the stream and the scanner on any construction failure. Tests: LanceArrowStreamScannerTest adds closeReleasesStreamThatWasNeverImported (release-callback path with no JVM import) and exportRejectsSchemasNeedingJvmPostProcessing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second gatekeeper pass: the admission check inspected only the partition schema, so a partition with a pushed aggregation (e.g. COUNT(*)) still passed. LanceScan routes such a partition to a dedicated reader (LanceCountStarPartitionReader) that returns a single count column, while LanceFragmentScanner ignores pushedAggregation and scans data rows — so the export would emit the wrong output for a valid partition. Renamed checkExportableSchema to checkExportablePartition, which now rejects a present pushedAggregation before the schema checks. Filter, limit, offset, and top-N ordering remain exportable because they are pushed faithfully into the native scan. Adds LanceArrowStreamScannerTest#exportRejectsPushedAggregation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third gatekeeper pass: a full-text query is another partition mode that diverges. LanceFragmentScanner enables it on the native scan, which auto-projects a _score column, so an export of a [id, body] partition returned [id, body, _score] — more columns than the partition declares. The read-option-driven divergence is invisible to a schema-only check. Replace the enumerated column-name checks with a generic guard: after planning the scan, compare the schema the native scan actually produces (LanceScanner#schema, now surfaced via LanceFragmentScanner#schema) against the declared partition schema and reject on any field name/order mismatch. This subsumes the full-text _score case, the synthesized _fragid, the _rowaddr added for blobs, the _rowid an empty projection surfaces, and reordered row-version columns — and any future mode — because the zero-copy export cannot re-project or reorder the way the columnar reader does. The pushed-aggregation check stays a separate pre-plan guard: an aggregate partition's declared schema can equal the native data schema, so the schema comparison cannot catch it. Tests: exportRejectsSchemasNeedingJvmPostProcessing now covers both mismatch directions (native fewer columns via _fragid; native extra column via empty projection, the same shape as full-text _score). A _rowid column the native scan produces in matching order is now legitimately exportable, so that assertion is dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
235791d to
c154f99
Compare
Address review feedback on the fragment-scan Arrow C stream export: - checkNativeSchemaMatchesPartition now compares the declared and native schemas by Arrow ArrowType and nullability (recursing into children), not only field name and order. The declared Spark schema is converted through LanceArrowUtils — the same adapter the read path uses — so the Arrow-specific distinctions a Spark DataType alone cannot express (LargeUtf8 vs Utf8, LargeBinary, Date(MILLISECOND), FixedSizeBinary, Float16) survive the comparison and a column whose native type differs from the declared one (e.g. a narrower/wider int or a different time-zone timestamp) is no longer streamed as if it matched. - Add exportsEmptyFragmentScanPreservingSchema: a scan whose filter matches no rows still exports its full declared schema up front and drains cleanly with zero batches, releasing without a leak under the leak-checking allocator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c154f99 to
bcffab2
Compare
|
@yanghua Rebased onto The |
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The author’s rebase update is verified: #783’s immutable-collection Kryo fix is in the live base, the lance-core bump is no longer part of this PR, and the formerly failing Optimize path passes.
The remaining three-file export preserves the existing scan contract by rejecting pushed aggregates and native schemas that differ in field order, Arrow type, nullability, or nested shape. Its release and cleanup path is idempotent, and focused C-stream, empty-scan, and columnar-scanner tests pass.
What
Adds
LanceArrowStreamScanner, which plans a Lance fragment scan and exports it as an Arrow C Data Interface stream (ArrowArrayStream) for native consumers such as Apache Gluten / Velox.Why
This is the read-side building block for offloading Lance scans to a native engine. Only the
ArrowArrayStreamC-struct address crosses the JVM/native boundary, so the consumer's Arrow build and classloader do not need to match lance-spark's — Gluten builds Arrow 15 to match Spark 3.5 / Velox, while the Lance Java SDK is on Arrow 18. Passing the raw struct address sidesteps that mismatch entirely.How
LanceArrowStreamScanner.export(fragmentId, inputPartition)returns aLanceArrowStreamhandle exposingstream()/streamAddress().LanceFragmentScanner, so the exported stream yields exactly the same rows in the same order as the Spark columnar reader.LanceScanner#exportArrowStream(long)(feat(java): expose ArrowArrayStream export on LanceScanner lance#7259), so no Arrow data is materialized on the JVM heap on this path.LanceArrowStreamowns the exported stream and the scan behind it; closing it releases the native scan (through the stream's release callback) and then the scanner and dataset handles.Dependency
exportArrowStream(long)first ships in lance-core11.0.0-beta.21, so this bumpslance.versionfrom11.0.0-beta.10(isolated in its own commit).Testing
LanceArrowStreamScannerTest: exports each fragment of the bundled test table, re-imports it on the JVM (standing in for a native consumer), and asserts the rows match the columnar reader — run under the leak-checking allocator to verify the export/scanner lifecycle releases cleanly.LanceFragmentColumnarBatchScannerTeststill passes (regression on the touchedLanceFragmentScanner).Related
🤖 Generated with Claude Code