Skip to content

feat: add native Delta Lake scan contrib module (page/row-group pruning) - #5365

Open
dwsmith1983 wants to merge 1 commit into
apache:mainfrom
dwsmith1983:feature/delta-native-scan
Open

feat: add native Delta Lake scan contrib module (page/row-group pruning)#5365
dwsmith1983 wants to merge 1 commit into
apache:mainfrom
dwsmith1983:feature/delta-native-scan

Conversation

@dwsmith1983

@dwsmith1983 dwsmith1983 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of #174. This PR does not close it: the delta-kernel contrib and the convergence discussion in #5411 are tracked there as well.

Rationale for this change

Adds an optional contrib module that plans Delta Lake table scans on the JVM and executes them natively, including deletion vector application inside the native scan. delta-spark has already done log replay, snapshot resolution, and partition pruning by the time CometScanRule sees the FileSourceScanExec, so there is no Delta planning to do natively: the scan reuses the existing ParquetSource path and gets row group pruning, page index pruning, and filter pushdown for free, with deletion vectors composed into the ParquetAccessPlan so DV skips and page skips intersect rather than filtering after the read.

The module is explicit opt in: the -Pdelta Maven profile builds a separate comet-contrib-delta jar that is never bundled into comet-spark, and spark.comet.scan.delta.enabled defaults to false. The delta cargo feature (DV decoding plus the planner hand-off, no delta-kernel dependency, about 82 KB of dylib) stays in the default native build so trying the contrib needs only the jar and the config, not a custom native binary; this was agreed in review and is recorded in the Cargo.toml comment. The adjacent contrib-delta feature is unrelated: it gates the delta-kernel integration and default builds carry no kernel surface.

Restructured after review

Core changes that previously traveled with this PR now live elsewhere:

Two core-generic capabilities remain in this PR because the native read path does not have them yet and the Delta scan needs them for correctness; both are candidates to lift into core, tracked in #5662 (S3 configuration divergence for the regular native scan) and #5010 (calendar rebasing for the regular native scan):

  • S3 configuration divergence gating: Comet's native object store client resolves S3 configuration differently from Hadoop's S3AFileSystem in several ways (bucket precedence in lookupPassword, JCEKS credential aliases, clear text fallback, assumed role session policies, provider class semantics). DeltaScanSupport models each consumer's real resolution, verified against hadoop-aws 3.3.4 and 3.4.1 bytecode, and declines to Spark whenever native would read under a different identity or endpoint. Assumed role session policies (fs.s3a.assumed.role.policy) decline outright since Hadoop sends them in the AssumeRole request and native does not.
  • Per file calendar rebasing: the regular native scan ignores legacy calendar metadata (Datetime rebase: track the documented scan limitation, and spark.comet.exceptionOnDatetimeRebase is dead code #5010). The Delta arm resolves date and timestamp rebase policy per file from the parquet writer metadata, mirroring Spark's DataSourceUtils.getRebaseSpec, with the effective session read modes carried in the scan for files without Spark metadata and INT64 and INT96 timestamp columns each attributed to their own spec from the footer's physical types. Dates rebase exactly (Spark's Julian to Gregorian table), UTC writer timestamps rebase exactly, nested struct, list, and map leaves are handled recursively with only the requested leaves checked (an unrequested ancient sibling never blocks a projection), and EXCEPTION mode uses Spark's cutoffs (1582-10-15 for dates, 1900-01-01T00:00:00Z for timestamps). Only two inputs still fail at execution time instead of reading: a LEGACY policy file with a non UTC or unrecorded writer zone when a timestamp before 1900-01-01Z actually appears, and an EXCEPTION policy file (or one whose two legacy flags disagree without physical type attribution) when an ancient value actually appears. Everything else reads natively with Spark's values. Pruning is lost on every column that receives a policy wrapper, including modern only LEGACY files and check only EXCEPTION files, not just values that need conversion. This is gated to the Delta arm so the regular scan's documented behavior is unchanged.

What changes are included in this PR?

  • contrib/delta-spark: DeltaScanSupport (scan eligibility, S3 divergence gating, DV descriptor extraction), CometDeltaNativeScan serde, service registration via the contrib scan SPI, documentation.
  • Native: delta_dv.rs (deletion vector decode with a full malformed input matrix, and access plan construction), delta_spark_scan.rs planner arm, datetime_rebase.rs, proto messages for the Delta scan envelope, S3 object store helper.
  • Shared refactors the module needs: build_parquet_scan_plan/prepare_scan_store_and_files extraction in the planner, object_store_url_key/prepare_object_store_with_config_hash, buildNativeScanCommon extraction, reportScanInputMetrics, hasScanInput widening, contrib LinkageError containment.
  • CI: a dedicated delta contrib workflow running the suite on Spark 3.5 and 4.0.

Follow-up work from review is tracked in #5655 (DV file splitting), #5656 (compressed DV decoding), #5657 (overlapping bitmap and footer reads), #5658 (shared cloud compatibility helper), #5659 (credential scoping), #5660 (v2 checkpoint coverage), #5661 (capability table), and #5662.

How are these changes tested?

  • The contrib suite (CometDeltaNativeScanSuite, CometDeltaS3Suite against MinIO, CometDeltaDmlReproSuite, DeltaScanContribSuite) passes on both the Spark 3.5 and 4.0 profiles: 236 tests each at the current head, MinIO suite live.
  • Native tests pass under --features delta (343 in the core crate), including the DV malformed input matrix (truncation at every boundary, CRC and magic corruption, size and cardinality lies, bit flip sweeps), the calendar rebase unit tests against Spark's own anchors, and end to end scan pins for per file metadata resolution; clippy and fmt clean.
  • Regressions from review are pinned: legacy written ancient dates and INT96 timestamps, metadata-free files under each read mode, nested columns with mixed policies, assumed role session policies, column mapping name collisions with and without DVs, and S3 bucket precedence.

Benchmarks at the current head

Apple M5, JDK 17, Spark 3.5 profile, local filesystem, 120M rows in 6 files of about 490 MB (zstd), full table aggregate touching every surviving row, medians of 5 warm runs per fresh session. Results are bit identical across all modes and verified against closed form expectations.

deletion pattern deleted stock Spark Comet fallback native Delta scan
none 0 5.31s 5.25s 1.13s
sparse (0.1 percent scattered) 120K 7.76s 5.35s 1.52s
contiguous (20 percent) 24M 5.76s 2.77s 1.22s
alternating (50 percent) 60M 4.76s 2.53s 2.51s

DV decoding is negligible in every pattern; the cost center is selector expansion for alternating deletes (61 to 93 ms and about 400 MB peak per file). The default spark.comet.scan.delta.dv.maxDeletedRowsPerFile cap (1M) declines the contiguous and alternating tables up front and falls back cleanly, which the numbers show is the better path for alternating; raising the cap without sizing the off heap pool fails tasks at the reservation by design.

The calendar rebase wrapper costs 0.7 to 2.2 ns per row and is noise at scan level, but it is opaque to pruning: a selective predicate on a rebased column decoded 65x more rows than with pruning live on a sorted table. That is the tradeoff of the legacy path and only applies to files that need rebasing.

An independent run on public data (NYC taxi with a DV delete) is in the PR discussion and confirmed exact DV row removal with timing parity.

@dwsmith1983

dwsmith1983 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Update: pushed two follow-up commits extending the scan's pruning and object-store behavior.

perf: fetch Delta deletion vectors and footers concurrently DV blob and footer reads were sequential: two serial round-trips per DV'd file before the scan could start, which scales badly on object stores. They now fetch with a bounded fan-out of 8, preserving file order and fail-fast error semantics. Covered by a new end-to-end unit test (inline DVs, on-disk DVs, pass-through files, exact row selections, output ordering).

feat: push resolved scalar-subquery filters into the native Delta scan predicates like id >= (SELECT max(ts) FROM checkpoint) previously contributed nothing to the native scan: subquery results don't exist at planning, so the scan
decoded the full table and Spark's covering FilterExec did all the filtering. They are now resolved at execution time and appended as pushed filters, so row-group and page-index pruning fire the same as for literal bounds. Three version-specific traps handled:

  1. Spark 3.x strips subquery predicates from a scan's dataFilters (FileSourceStrategy); Spark 4.x keeps them. The contrib harvests them from the covering FilterExec at claim time and dedups, so both behaviors converge.
  2. The DV plan shape interposes nodes between the filter and the scan, so the harvest matches the nearest filter above the scan, guarded by references scan output.
  3. MergeScalarSubqueries fuses multiple scalar subqueries into one struct-returning subquery accessed via GetStructField; that subtree is folded to a literal before serialization.

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from 888e4a7 to 7fd81aa Compare August 15, 2026 16:00
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

HI @andygrove,

Can you review this as it adds Delta functionality?

@sunchao

sunchao commented Aug 18, 2026

Copy link
Copy Markdown
Member

Hi @dwsmith1983 Thanks for putting this together! We are also actively looking at Delta support for Comet, and it'd be great if we can collaborate on this effort!

Since #4952 is already approved and close to landing, what do you think about using it as the shared foundation for this work? Ideally, the same contrib infrastructure could support both JVM-planned Delta scans and the Rust Kernel-based approach, with this PR providing the JVM-planned path. We have related work in progress, so it would be good to converge on one implementation.

In addition, would it also make sense to land this in smaller pieces, for easier review and iterating? For example:

  • Basic native Delta reads, including time travel and fallback for unsupported features
  • Column mapping and schema evolution
  • Deletion vectors
  • Row tracking
  • Change Data Feed

Starting to support this in Spark 4 & Delta 4 would be a useful first milestone. Curious how you see the relationship between the two PRs and whether that direction makes sense to you. Thanks.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Hi @sunchao,

On #4952 as the foundation: we already share more than it might look like. This PR builds on part 1 of that same breakup (#4700's CometScanWithPlanData / PlanDataInjector SPI) and keeps #4366's contrib shape, decline-gate philosophy, and test catalog, with co-authored-by credit to both earlier efforts. The remaining overlap is contrib infrastructure, and I'm glad to reconcile it once #4952 lands: adopt its contrib-delta profile and feature naming, the per-Spark delta.version matrix, the verify-gate script, and unify the proto slot (this PR is at 119, #4952 at 118). For the claim hook I'd suggest the generic CometScanRuleExtension SPI from this PR, since it keeps core free of Delta-specific code and the kernel path can register through it the same way.

I do see the two read paths as different layers rather than one thing to converge on. By the time CometScanRule sees the scan, delta-spark has already done log replay, time travel, and partition pruning, so this path reuses Comet's existing native parquet scan and gets row-group pruning, page-index pruning, and filter pushdown for free. DVs become ParquetAccessPlans that DataFusion intersects with page-index pruning, so DV skips and page skips compose in one scan. As far as I know no vectorized Delta reader does all of that today, including kernel's, which has no page-index pruning. I'd want convergence to keep this as the default read path, with the kernel path covering what JVM planning can't reach (DSv2, non-Spark frontends, likely CDF and row tracking).

On splitting: I'd push back on slicing by feature, for two reasons. First, the features aren't independent. Several decline gates only exist because DVs, column mapping, and Delta's own suites ran together. For example, Delta's findTouchedFiles scan looks like a plain read, and if a basic-reads slice claims it, DELETE silently rewrites files instead of writing DVs. Second, the proof is holistic: this branch runs Delta's own suites at 1156/1156 and the contrib suites at 39/39 on Spark 3.5, 4.0, and 4.1. Feature slices would decline most tables and couldn't run that meaningfully. What I can do is split along review surfaces instead: core SPI additions, native DV decode with its unit tests, the contrib module and read path, and the regression harness and CI, keeping the read path itself (DVs, column mapping, gates) as one reviewable unit. If it lands whole, Comet ships the only vectorized Delta reader with complete skipping.

The Spark 4 milestone is already met, the suites are green on 4.0 and 4.1 today. Row tracking and CDF are out of scope here and seem like a natural place for the kernel work to lead. Happy to set up a chat with you and @schenksj to work out the details.

Comment thread .github/workflows/delta_contrib_test.yml Fixed
Comment thread .github/workflows/delta_contrib_test.yml Fixed
Comment thread .github/workflows/delta_contrib_test.yml Fixed
@sunchao

sunchao commented Aug 18, 2026

Copy link
Copy Markdown
Member

Thanks @dwsmith1983 Your proposed split by review surface sounds reasonable. I agree that the reader, its safety gates, and the essential DML/fallback tests should stay together. Thanks also for being open to aligning with #4952 once it lands. We can leave row tracking and CDF for later discussions rather than expand this PR’s scope. The main additional point I’d like us to settle is keeping experimental Delta support explicitly opt-in.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Thanks @dwsmith1983 Your proposed split by review surface sounds reasonable. I agree that the reader, its safety gates, and the essential DML/fallback tests should stay together. Thanks also for being open to aligning with #4952 once it lands. We can leave row tracking and CDF for later discussions rather than expand this PR’s scope. The main additional point I’d like us to settle is keeping experimental Delta support explicitly opt-in.

@sunchao

Yeah, agreed on explicit opt-in. It's mostly already set up that way. All the Delta code lives in a separate comet-contrib-delta jar that never gets bundled into comet-spark, so a stock Comet install has no Delta surface at all. If we publish that jar with releases, trying it out is just --packages and a conf, nobody has to build from source. Right now the conf defaults to on when the jar is present though, so I'll flip spark.comet.scan.delta.enabled to default false to make the opt-in explicit.

The one spot where I'd differ from #4952's gate is the native binary. The Delta bits in libcomet are tiny (DV decoding plus a hand-off to the existing parquet scan, no delta-kernel dependency) and can't be reached without the jar and the conf. I'd rather keep them in the default build than make people compile their own native binary to try an experimental feature. Sound reasonable?

Comment thread .github/workflows/ci.yml Fixed
@sunchao

sunchao commented Aug 19, 2026

Copy link
Copy Markdown
Member

Thanks @dwsmith1983. This makes sense to me! #4952 has just been merged. Could you rebase this PR and adapt to it? Thanks!

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

@sunchao
Merged main to pick up #4952 and reconciled the two Delta efforts as discussed. The JVM-planned scan now rides the generic ContribScan envelope with its own type_url (comet.contrib.delta_spark.DeltaSparkScan), so the dedicated oneof slot is gone (removed and reserved). The native handler is now a sibling of the kernel path's handler, dispatched by type_url, and the module moved to contrib/delta-spark so it no longer overlaps contrib/delta's source root. Our proto messages are renamed DeltaSpark* so both message sets coexist, and nothing from #4952 was reverted or modified; verify-contrib-delta-gate.sh passes unchanged. Both contribs' suites are green side by side (contrib 40/40, CometScanContribSuite and the injector suites 29/29, native 172/172).

A few things I deliberately left for discussion rather than deciding unilaterally: unifying the two claim hooks in CometScanRule (CometScanContrib vs the CometScanRuleExtension SPI), conf naming (spark.comet.scan.delta.* vs spark.comet.scan.deltaNative.*), and Maven packaging (the -Pcontrib-delta add-source vs this module's separate jar, which is what keeps the opt-in story build-free).

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for reconciling this with #4952. The generic envelope, separate source roots, and explicit runtime opt-in look like useful progress. I reviewed 9b393c15 and left eight concrete correctness and compatibility comments. The main concerns are unsafe scalar-subquery pushdown, mixed-authority file routing, and unbounded deletion-vector row-selection memory.

I checked these against Spark/Delta source and used bounded stock Spark 4.0.3 / Delta 4.0.0 and isolated Rust probes. I have not built this PR's full JNI library or run cloud-backed end-to-end tests. The Delta CI suites are green on Spark 3.5, 4.0, and 4.1. I am leaving the already-acknowledged claim-hook, naming, and packaging choices for the existing design discussion.

Comment thread native/core/src/execution/planner/delta_spark_scan.rs
Comment thread native/core/src/execution/delta_dv.rs
Comment thread native/core/src/execution/delta_dv.rs Outdated
Comment on lines +276 to +280
let (dv_url, dv_store_path) = prepare_object_store_with_configs(
Arc::clone(&runtime_env),
dv_path.clone(),
object_store_options,
)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Avoid constructing a cold S3 store inside the DV runtime

Could we resolve the required stores before entering attach_access_plans, or make their initialization async-safe? The caller enters get_runtime().block_on(...), but an uncached S3 sidecar reaches this synchronous helper and then objectstore/s3.rs calls get_runtime().block_on(build_credential_provider(...)) again. Tokio rejects that nested Handle::block_on with a panic. A fresh executor reading a shallow clone whose data is in bucket A and whose new DV is in bucket B reaches a cold cache entry. Same-bucket tests hide the problem because the data store was created before the outer block_on. Explicit endpoint/region or static Hadoop credentials do not avoid the inner credential-provider call. Please add a test with distinct data-file and DV buckets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by pre-resolution: all stores (data and DV authorities) are resolved on the JNI thread before entering the runtime, and attach_access_plans no longer takes an options map or imports the store builder at all, so the async path structurally can't construct one. Your distinct data/DV bucket scenario is encoded in a new MinIO suite (CometDeltaS3Suite), but heads up that it's docker-gated and hasn't run against a live daemon yet, the contrib CI job has no docker socket so those tests cancel. First live signal needs a Docker environment.

Comment thread native/core/src/execution/delta_dv.rs
Comment thread native/core/src/execution/delta_dv.rs
@sunchao

sunchao commented Aug 20, 2026

Copy link
Copy Markdown
Member

Thanks @dwsmith1983. On the design topics you flagged, I’d prefer using CometScanContrib as the shared interface and agreeing on consistent configuration naming. The separate optional JAR sounds reasonable if it lets users try the feature without rebuilding Comet. We can discuss the packaging details separately.

@dwsmith1983
dwsmith1983 requested a review from sunchao August 21, 2026 00:12

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rechecked 7e09e04f with five independent review scopes. One additional P2 is inline; I also followed up in the existing threads on the remaining scalar-pushdown, Azure DV store, and DV-memory issues. Verification used exact-source Spark/Delta physical-plan probes and locked-dependency Rust probes, not a full Comet/JNI or live cloud run.

@schenksj

Copy link
Copy Markdown
Contributor

Hey @sunchao / @parthchandra — are you looking to move this work over to @dwsmith1983’s series?

We’re 2 PRs into the 10-PR series now that the contrib modules have been merged. The series fully implements all of the Delta protocols, with all 10k+ Delta test cases passing.

I’m fine either way. I won’t have a huge amount of time to work on this over the next couple of months, so it could go faster with a different attendant, but the PRs are ready to roll.

You can see the series here: https://github.com/schenksj/datafusion-comet/pulls (PRs #5–13).

@sunchao

sunchao commented Aug 21, 2026

Copy link
Copy Markdown
Member

Hi @schenksj , I think your series implements Delta native scan based on the delta-kernel-rs while the PR here uses the JVM based delta-spark for planning, so they are different while both are based on the same contrib groundwork.

I think your series is pretty valuable and should be continued to push forward. At some point we should compare feature coverage and performance between the two.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

CI notes for this push: the S3 test base built its client without a region, which aborted CometDeltaS3Suite in CI's empty AWS environment before any test ran; fixed, and since GitHub mounts the Docker socket into job containers the MinIO scenarios will actually execute in CI now. They've been run live locally with all AWS env vars unset (first executions ever, both pass on Spark 3.5 and 4.1; that surfaced a missing spark-hadoop-cloud test dependency, also fixed). Heads up that inside the job container the MinIO endpoint may resolve as unreachable sibling-container networking; the suite now fails soft to canceled rather than aborting the build, and logs the resolved endpoint so the first CI run tells us whether a testcontainers host override is needed. The Spark 4.0 cell wasn't rerun locally, so CI is its first pass over these changes. The Rust 1.98 clippy fix I'd pushed got dropped in favor of #5400 from main during rebase.

@dwsmith1983
dwsmith1983 requested a review from sunchao August 21, 2026 14:54
s.split(",").map(_.trim.toLowerCase(Locale.ROOT)).filter(_.nonEmpty).toSet
case None => Set("hdfs")
}
val unsupportedFsSchemes = scanExec.relation.location.rootPaths

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Check selected-file schemes before claiming a shallow clone

Could we apply this filesystem gate to the selected data-file URIs, not just the table's rootPaths? A valid Delta shallow clone can have a supported file: table root while its data files still reference viewfs://review-mount/source/table/.... With the default libhdfs scheme set (hdfs only), both authority checks accept these same-authority files, and the ordinary LongType scan serializes successfully, so the contrib claims it. Native store preparation then fails with Generic URL error: Unable to recognise URL "viewfs://..." instead of leaving the scan with Spark.

At bc98657f, a local-only Spark 4.0.2 / Delta 4.0.0 probe wrote a Delta table through Hadoop's built-in viewfs mount, shallow-cloned it to a local directory, and successfully read [0, 1, 2]. Its actual scan had a file: root and viewfs: selected files. The exact-current authority helpers accepted those files, while the exact native store-preparation helper rejected their URI. This was a stock-engine/exact-helper probe, not a full Comet/JNI run. Checking the schemes of the files actually selected before claiming would preserve Spark fallback for this valid table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The scheme gate now runs over the selected data-file URIs and the DV absolute paths, the same sequences the later authority gates already collect, using the exact predicate the root-paths gate had (lowercased, null tolerant, libhdfs exemption honored at the new call site). It sits ahead of the multi-store gate since an unreadable scheme is the stronger and more actionable reason, and both authority gates presume the URIs are natively resolvable. s3a is recognized by the native scheme parser so the MinIO coverage is untouched. Your probe is now a CI test: the suite mounts viewfs over a local directory, writes through it, shallow-clones to a file: root, and asserts the scan falls back with the scheme reason while answers match, including a mixed-scheme shape that pins the gate ordering end to end.

@sunchao

sunchao commented Aug 21, 2026

Copy link
Copy Markdown
Member

Reposting the two remaining P2 findings here for visibility. Both remain present at 95125623; these are the existing findings, not additional issues.

[P2] Check selected-file schemes before claiming a shallow clone

The filesystem gate checks only the table's rootPaths. A valid Delta shallow clone can have a supported file: root while its selected data files still reference viewfs:. With the default libhdfs configuration (hdfs only), both authority checks accept those files and the contrib claims the scan. Native store preparation then fails with Unable to recognise URL "viewfs://..." instead of falling back to Spark.

This was verified with a real Spark 4.0.2 / Delta 4.0.0 shallow clone that Spark successfully reads, plus the exact native store-preparation helper. Please apply the supported-scheme check to the selected data-file URIs before claiming the scan.

Code · Existing discussion and reproduction details

[P2] Account for the DV reader's combined-selection allocation

Construction admission and the initial reader clone are now covered. However, DataFusion 54.1 subsequently calls into_overall_row_selection, which allocates another selector buffer while the attached original and the consumed clone's backing vector remain live. The reservation has already been reduced to twice the retained selector bytes.

With the default-permitted 1,000,000 alternating deletions across 2,000,000 rows, the current attachment reserves 64,000,000 bytes, but the attached selectors plus reader-normalization allocations peak at 97,554,457 bytes and retain 65,554,432 bytes afterward. Please account for normalization and vector capacity, or avoid the additional allocation through ownership transfer. Simply changing the factor to 3 would still fall below this measured peak.

This was reproduced using the unchanged attachment code and the real locked dependency conversion. These are allocator-requested bytes, not RSS or a reproduced executor OOM. Both findings were checked with focused probes and source tracing, not a full Comet/JNI integration run.

Code · Existing discussion and reproduction details

@parthchandra

Copy link
Copy Markdown
Contributor

Hey @sunchao / @parthchandra — are you looking to move this work over to @dwsmith1983’s series?

We’re 2 PRs into the 10-PR series now that the contrib modules have been merged. The series fully implements all of the Delta protocols, with all 10k+ Delta test cases passing.

I’m fine either way. I won’t have a huge amount of time to work on this over the next couple of months, so it could go faster with a different attendant, but the PRs are ready to roll.

You can see the series here: https://github.com/schenksj/datafusion-comet/pulls (PRs #5–13).

I see value in both (even though it is extra work to maintain both paths) and in principle agree with @sunchao. Ideally, we want to converge these two. Logged an issue based on an AI generated convergence path - #5411

@schenksj

Copy link
Copy Markdown
Contributor

Hey @sunchao / @parthchandra — are you looking to move this work over to @dwsmith1983’s series?
We’re 2 PRs into the 10-PR series now that the contrib modules have been merged. The series fully implements all of the Delta protocols, with all 10k+ Delta test cases passing.
I’m fine either way. I won’t have a huge amount of time to work on this over the next couple of months, so it could go faster with a different attendant, but the PRs are ready to roll.
You can see the series here: https://github.com/schenksj/datafusion-comet/pulls (PRs #5–13).

I see value in both (even though it is extra work to maintain both paths) and in principle agree with @sunchao. Ideally, we want to converge these two. Logged an issue based on an AI generated convergence path - #5411

Thanks guys. I'm concerned that having 2 will create a lot of confusion when it comes to support.. Even enabling and disabling various scan features is too much to understand for most of the expert data engineers I work with every day.

I'm happy to move forward initially in parallel, though like I mentioned before my time to work with this is going to be pretty sparse for the next couple of months.

@sunchao

sunchao commented Aug 22, 2026

Copy link
Copy Markdown
Member

@schenksj Let’s see how it goes. For now, I see the delta-spark-based implementation as the most practical approach: it builds on mature Delta planning while allowing Comet to reuse its optimized native Parquet reader. Longer term, I’m also excited about delta-kernel-rs as a shared foundation for native Delta integrations, and I've also heard that the Delta community is also converging on the Rust implementation.

In terms of your concern, I think we should aim to keep the user-facing configuration simple, perhaps with one flag to enable Delta scans and another to opt into an experimental Rust-kernel-backed path. Ideally, both approaches would share as much integration and testing infrastructure as possible.

Really appreciate all your work on this! We’re planning to move quickly with the current delta-spark integration and evaluate it against some very large-scale production workloads. We also plan to evaluate the delta-kernel-rs-based approach in the future, and I’d love to collaborate on your series and take on some work to move the Rust-based reader forward.

@dwsmith1983

dwsmith1983 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

On the macOS scans failure: pulled the hs_err from the run artifact. The crashing thread is a native thread (not a Java thread) that was exiting: the stack is pthread_start into pthread_exit into pthread TSD cleanup, then a jump through a corrupted destructor slot whose value is ASCII string bytes, at 119s elapsed, immediately after ParquetReadFromFakeHadoopFsSuite, the only suite in the group that exercises the libhdfs bridge and its JNI-attached native threads. The Delta code in this PR is structurally unreachable in those suites (native side is dispatch-gated on an operator those plans never emit, and the contrib jar is not on that build's classpath), and the Linux scans group passed on the same commit. My guess is a teardown race in the libhdfs bridge or a runner flake rather than anything this PR executes; the falsifying experiment would be rebuilding the dylib without the delta feature and re-running, since the same crash would exonerate it by construction. Could someone re-run the job? Happy to file the hs_err as an issue either way.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for addressing the earlier findings. I think the larger file-selection refactor, shared admission/schema cleanup, packaging changes, and broader deployment coverage can be tracked in follow-up PRs. I'd keep the remaining [P1] Azure safety guard, [P2] S3-authentication and AQE lifecycle fixes, and their focused regressions in this PR.

Could we replace spark.comet.scan.deltaNative.enabled with spark.comet.scan.delta.enabled consistently across both Delta contributions, keeping the default false? Please update the config definitions, tests, documentation, and dev scripts together, and use the spark.comet.scan.delta.* prefix for related settings. The intent is one consistent configuration namespace, not another enable flag.

This rename does not depend on changing the separate-JAR packaging. Broader reader-selection behavior can be discussed separately.

Comment on lines +72 to +73
override lazy val outputPartitioning: Partitioning =
UnknownPartitioning(perPartitionData.length)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Avoid executing adaptive pruning while inspecting partitioning

This getter forces perPartitionData, which calls InSubqueryExec.updateResult(). During AQE, that subquery can still be a non-executable adaptive broadcast placeholder.

A reduced Spark 4.0.2 / Delta 4.0.0 planning harness reproduced this through Spark's normal AQE validation: a DPP join in one UNION ALL branch and a coalescible shuffle in another caused validation to inspect this partitioning before the custom DPP rewrite. It then failed with CometSubqueryAdaptiveBroadcastExec ... does not support the execute() code path. Other operators remained on Spark, and no native Comet reader executed.

Could we return UnknownPartitioning(0) while adaptive placeholders remain and make this a non-lazy def, so the temporary value is not cached? A regression with a query-time dimension filter would help. The current DPP test filters the dimension before writing it, so it does not require dynamic pruning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed as described: outputPartitioning is now a plain def returning UnknownPartitioning(0) while any runtime filter still holds an adaptive broadcast placeholder, so AQE validation never forces perPartitionData. Rewrote the DPP test to filter at query time and added your UNION ALL shape as a regression. That shape didn't reproduce the crash pre-fix on my Spark 3.5.9 / Delta 3.3.2 profile, so it likely needs your Spark 4.0.2 harness, but the guard matches your analysis.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Agreed on keeping it simple. The conf is now spark.comet.scan.delta.enabled (plus spark.comet.scan.delta.dv.maxDeletedRowsPerFile), so there's one flag to enable Delta scans, and the kernel path can add its own experimental key later. Docs updated. Fixes for the three open threads are pushed as well.

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from ec2ad9b to 92ae71b Compare August 22, 2026 09:50
@dwsmith1983
dwsmith1983 requested a review from sunchao August 22, 2026 09:52
@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from a4bd616 to 48e5d29 Compare September 3, 2026 06:22
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Thanks for the breadth here. Two of these were defects worth fixing in the PR, done and pushed in 48e5d29; the rest are filed as tracking issues per your suggestion. Point by point:

Calendar handling: fixed in-PR. Your repro was real and it led somewhere interesting: the regular native scan has no rebase handling at all today (that is documented as a known limitation and tracked in #5010, ParquetRebaseDatetimeSuite is disabled for Comet), so there was nothing to inherit. The Delta arm now resolves calendar policy per file on the native side: the footer's writer metadata (spark version, legacyDateTime, legacyINT96, timeZone) survives into the Arrow schema the expression adapter sees per file, and it resolves date and timestamp policies the way DataSourceUtils.getRebaseSpec does. Dates rebase exactly (Spark's julianGregDiffs table ported verbatim, with calendar arithmetic before its range). Timestamps rebase exactly for UTC writer zones. Anything it cannot rebase faithfully (non UTC writer zone, missing metadata, contradictory flags) refuses ancient values with an error naming the column instead of returning shifted data; modern values always pass. Your exact 1500-01-01 and ancient INT96 cases are regression tests now, RED before the fix with your 1500-01-10 shift reproduced, plus mixed writer metadata and a temporal predicate case. The mechanism is gated to the Delta arm so the regular scan's documented behavior is unchanged, pinned by a control test.

Assumed-role session policies: fixed in-PR. fs.s3a.assumed.role.policy now declines admission, global and bucket scoped, resolved exactly like its consumer (AssumedRoleCredentialProvider reads it with getTrimmed on the propagated conf and sends it in the AssumeRole request, verified against 3.3.4 and 3.4.1 bytecode; native never forwards it). The reason text names the key without echoing the policy document. Unset stays admitted, pinned.

Tracking issues filed for the rest: DV file splitting #5655, compressed DV decoding #5656 (a candidate patch preserving sub bitmap containers already exists from a community contributor and can be adapted once this lands), overlapping bitmap and footer reads #5657, shared cloud compatibility helper #5658, credential scoping #5659, v2 checkpoint depth #5660, capability table #5661.

Benchmark on this head (48e5d29 exactly, no extra commits; Apple M5, JDK 17, spark-3.5 profile, local FS, 120M rows, 6 files of ~490MB, zstd, medians of 5 warm runs per fresh session; results bit identical across all modes and verified against closed form expectations):

pattern deleted stock comet fallback native
none 0 5.31s 5.25s 1.13s
sparse (0.1% scattered) 120K 7.76s 5.35s 1.52s
contiguous (20%) 24M 5.76s 2.77s 1.22s
alternating (50%) 60M 4.76s 2.53s 2.51s

Native output_rows confirms DV application inside the scan (119,880,000 on sparse). Preparation cost measured on the real exported DVs through the PR's own decode and access plan code: decode is negligible everywhere (CRC plus roaring, up to 0.36ms per file on the 2.5MB alternating DVs); the cost center is selector expansion for alternating deletes, 61 to 93ms and ~400MB true peak per file, about 0.5s CPU across the table. Contiguous deletes cost near zero allocation since fully deleted row groups become skips.

Two operational notes the alternating case surfaced. First, the default dv.maxDeletedRowsPerFile cap (1M) declines contiguous and alternating up front and falls back cleanly; the numbers show that guard picks the better path for alternating (native only ties fallback there while needing a ~1.6GB reservation per file). Second, raising the cap without sizing the off heap pool fails tasks hard at the reservation, which is the documented reserve before build behavior doing its job; 18g admits it. The reservation is about 4x the measured true peak, deliberately pessimistic.

Suite batteries at this head pass 227/227 on both spark-3.5 and spark-4.0, MinIO suite live. The remaining gap from your list is live object store validation beyond MinIO, which I do not have infrastructure for here.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed 48e5d29021dd4bec7cc8e100eb63fdb7de92026d, including the latest update. The AssumeRole session-policy P1 is addressed by the new fallback gate. Two [P2] cases remain in the calendar discussion:

  • [P2] Without writer-version metadata, native policy resolution ignores the configured read mode. Spark 4.0 defaults to CORRECTED, but the new expression rejects corrected 1500-01-01 DATE values and a UTC timestamp one microsecond before 1970 when the file expression is rewritten. Please preserve the effective datetime and INT96 read modes, or decline the unsupported scan before execution.
  • [P2] The nested-type branch rejects corrected DATE leaves when only the unrelated INT96 policy needs handling. With corrected DATE mode and legacy INT96 mode, rewriting a STRUCT<d: DATE> column errors even for modern or null values. Please check the applicable leaf policy before rejecting the column and cover this metadata combination.

The exact-module component probes reproduced these rejections and passed the scalar/calendar controls. All 22 component tests passed, but I did not run a full Spark/Delta query. Please add Spark/native regressions for these cases and a matched benchmark for the new rebase expression, including selective predicates. The new DV benchmark does not measure this path. GitHub workflows still require authorization.

@dwsmith1983
dwsmith1983 requested a review from sunchao September 3, 2026 07:01

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The implementation quality here is high, and I want to say that clearly before raising concerns. Scope note so this review isn't read as broader than it is: I went through the contrib Scala, the deletion-vector decode path, the planner wiring, the CI gating, and — after @sunchao's latest reviewdatetime_rebase.rs, which I had not covered in my first pass.

CometScanContrib keeps core free of any compile-time reference to a contrib, and the DV → ParquetAccessPlan translation (so DV skips intersect with page-index skips rather than filtering after the read) is the right design. I checked the deletion-vector decode path in detail — the row-group boundary arithmetic, the out-of-range guard at delta_dv.rs:203, validate_cardinality covering both the inline and on-disk branches, and the DeletionVectorDescriptor.EMPTY early return requiring both cardinality == 0 and size_in_bytes == 0 — and found no wrong-results path there. The malformed-input matrix (truncation at every boundary, CRC/magic corruption, size and cardinality lies, single-bit-flip sweep) is real coverage, not a token test.

The fix record since @sunchao's earlier rounds is also strong: the column-mapping name collision is fixed by routing partitionSchema through toPhysical as well, with a rename-collision test, a collision+DV test, and a no-collision control test — that last one is the part reviewers usually don't get. The S3 long-vs-short bucket precedence fix goes further and corrects a previously-wrong assumption in the code's own comments, with the hadoop-aws 3.3.4 decompilation recorded.

The decline gate is conservative about what it checks — but the calendar-rebase path is not among those things, and I think that's the most substantive issue open on this PR.

I independently reproduced both of @sunchao's remaining [P2]s by reading the code, and want to add one observation that I think raises their severity.

resolve_spec (datetime_rebase.rs:201) returns CheckAncient when a file carries no org.apache.spark.version metadata. The comment states the reason plainly — the session read conf "is not plumbed to the native side" — so a table read under Spark 4.0's default CORRECTED mode still rejects a corrected 1500-01-01 DATE (:436) or a pre-epoch UTC timestamp (:377). And wrap_datetime_rebase (:279) rejects a whole nested column whenever its type structurally contains a rebase target, without consulting the applicable leaf policy, so STRUCT<d: DATE> fails under corrected-DATE + legacy-INT96 metadata even for modern or null values.

The part I'd flag beyond the two cases themselves: DeltaScanSupport.declineReason contains no rebase-related gate at all, so neither of these is a planning-time decline — they surface as execution-time query failures. That means this class of unsupported input bypasses the decline/fallback mechanism that is otherwise this PR's main safety property: instead of quietly falling back to Spark's reader, the user gets a failed query mid-execution. The 17 unit tests in that file don't cover either case. I'd treat "decline before execution, or preserve the effective read modes" as the requirement here rather than just fixing the two expressions.

Separately — my main architectural concern. Two findings, pointing in opposite directions at the same missing thing.

First, the concrete one. About 1,060 of DeltaScanSupport.scala's 1,742 lines (roughly 540–1600) are not Delta logic at all. They model Hadoop S3A configuration semantics: S3AUtils#lookupPassword's long-then-short bucket precedence, propagateBucketOptions, JCEKS credential-provider aliases, SSE-C, proxy, assumed-role policy, provider-class allowlisting. Every occurrence of "Delta" in that range is either the private[delta] scope or an error-message string — none of it touches the Delta protocol. It exists because Comet's native object_store client resolves S3 credentials differently from Hadoop, so the scan must detect divergence and decline rather than read under the wrong identity.

That gap is not Delta-specific, and core does not handle it today: NativeConfig.extractObjectStoreOptions forwards fs.s3a.* prefixes to native with no divergence check, so a plain Parquet table read through the native scan is exposed to the same credential mismatch, silently.

datetime_rebase.rs is the same pattern, and arguably a sharper case: 862 lines implementing Spark's hybrid-Julian calendar rebase, mirroring DataSourceUtils.datetimeRebaseSpec, wired up only by the Delta arms via rebase_from_file_metadata while the plain NativeScan keeps its documented no-rebase behavior (#5010). The isolation itself is correct — I verified false at parquet_support.rs:126,143 and planner.rs:1779, true only at delta_spark_scan.rs:252, so no non-Delta scan is affected. But it also means the gap it closes stays open for the plain path: a native Parquet read of a file written in the hybrid calendar — Spark 2.4 or earlier, Spark 3.0 for INT96 timestamps (the INT96 spec's min version is 3.1.0), or any version writing with datetimeRebaseModeInWrite=LEGACY, since a present legacyDateTime/legacyINT96 flag decides the policy regardless of writer version — still silently returns pre-1582 dates shifted by up to ten days, and correspondingly shifted ancient timestamps. That is pre-existing behavior tracked in #5010, not something this PR introduced; the point is that the code which would fix it now exists, and sits behind a Delta flag.

So this PR is paying, inside a Delta contrib, for two holes in Comet's own read path. Both belong in core, where every native scan benefits and the next format doesn't have to choose between reimplementing them or ignoring them. At minimum each deserves its own issue — the findings are valuable independently of this PR.

Second, the longer-term direction. The PR also lands a substantial amount of genuine Delta protocol logic: 2,096 lines of DV binary-format handling (unframing, z85, magic, CRC, roaring decode) and the physical/logical schema splice for column mapping. Each piece is individually justified, but the pattern is what concerns me — as Comet adds table formats, it takes on reimplementing each format's low-level semantics one at a time, and every protocol evolution becomes Comet's tracking burden. The more sustainable division is for table-read concerns to be delegated to the format's own implementation, with Comet owning execution: reading bytes, pruning, vectorized execution.

I'll be honest that this is not a settled question, and not something this PR can reasonably be asked to solve. #4366's own design docs record that it started with the delegating shape (kernel for planning, ParquetSource for bytes) and moved away from it, deliberately, because the engine-side reimplementation of DV application, synthetic columns, and column-mapping physicalisation was the bulk of its reviewable surface and where its data-corruption fixes clustered. So delegation trades one maintenance burden for another rather than eliminating it. I raise it because this PR will become the template for the next format, and the boundary is worth deciding on purpose rather than by accumulation.

Taken together: there's no settled line for what a format contrib owns versus what core owns. Delta protocol parsing landed in Comet; generic object-store and calendar-rebase correctness landed in a Delta contrib. Same missing boundary, opposite directions.

One smaller thing:

delta is in the default cargo feature set (default = ["hdfs-opendal", "delta"]), which puts roaring, crc32fast, and the DV decoder in every default build, while the adjacent contrib-delta feature documents "Default builds carry zero kernel surface". I understand the motivation (the contrib jar should work against stock Comet binaries without a custom native build) and it's a legitimate one, but it's a project-level policy call rather than something to settle by default in a PR — especially with #5411 open and explicitly asking for the opposite. Either move it out of default, or get explicit agreement on #5411 and record that agreement in the Cargo.toml comment so the two adjacent features stop stating opposite philosophies.

On sequencing: this PR leaves Comet with two Delta read paths coexisting, and of the four core-surface cleanups #5411 asks for, only the SPI reuse is done. I don't think that should block merging — #4366 is still a draft and its native read path on main is a NotImplemented stub, so refusing this PR on convergence grounds would block working code on unlanded work. But "merge then clean up" versus "settle #5411 first" is a maintainer decision that deserves to be made explicitly rather than by default.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Both P2s addressed in 053ec14, plus one related bug your cases flushed out.

Session read modes: verified the exact getRebaseSpec semantics against the 3.5.9 and 4.0.4 sources first. The conf applies only when org.apache.spark.version is absent from the footer; with a version present, policy is purely the version comparison plus the legacy flag. The serde now resolves the effective modes the way ParquetFileFormat does (ParquetOptions over relation options and session conf, so the 3.5 EXCEPTION and 4.0 CORRECTED defaults come from the session rather than being hardcoded), carries them in two new DeltaSparkScanCommon fields, and the native resolver uses them in the version-absent arm. Your two cases are regression tests built on raw parquet-mr files with no Spark metadata, converted to Delta: corrected 1500-01-01 and the pre-1970 microsecond timestamp now read verbatim natively under CORRECTED, rebase under LEGACY, and fail loudly under EXCEPTION. One nuance mirrored deliberately: conf LEGACY resolves the writer zone from file metadata; Spark falls back to the JVM default zone, which native cannot replicate, so non UTC zones keep the refuse-ancient posture for timestamps while dates rebase fully since they are zone free.

Nested types: the wrapper now checks the applicable leaf policies before rejecting, recursing through struct, list, map, and dictionary. Your STRUCT with a DATE leaf under corrected dates plus legacy INT96 stays native and correct, tested with modern and null values; a nested column with a genuinely affected leaf still declines.

The bonus bug: with no pushed predicate and identical logical and physical schemas, the DataFusion opener skips the expression adapter entirely, which is exactly the shape of a metadata-free file, so even the refusal path was silently bypassed. Rebase-enabled scans now stamp a schema marker that keeps the adapter engaged, with a test pinning the ancient-value refusal on that exact shape.

Rebase expression benchmark as requested (M5, release, real module code): per row the wrapper costs 0.7 to 2.2ns depending on type and policy, 0.26 to 0.29ns for the check-only path, against effectively zero unwrapped. End to end on 20M rows the full-scan cost is noise (0.225 to 0.235s across corrected, legacy modern, legacy ancient; Spark fallback 0.36 to 0.47s). The honest cost is selective predicates: the opaque wrapper disables pruning on wrapped columns, and on a sorted table a point predicate decoded 2,627,592 rows against 40,000 with pruning live, a 65x read amplification that warm cache hides but cold or remote storage will not. That is the operational tradeoff of the legacy path; correct results either way, counts identical across all modes.

Batteries at this head: 231/231 on both spark-3.5 and spark-4.0, 327 native lib tests, clippy and fmt clean.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Thanks for the depth, and for the independent verification of the DV decode path.

Timing note first: your review and my last push crossed. 053ec14 (pushed about an hour after your review) plumbs the effective session read modes into the scan, so the resolve_spec comment you quoted is gone: metadata-free files now follow the resolved datetimeRebaseModeInRead and int96RebaseModeInRead exactly as ParquetFileFormat resolves them (CORRECTED reads verbatim, LEGACY rebases, EXCEPTION refuses ancient), and the nested branch now checks the applicable leaf policy, so STRUCT with a DATE leaf under corrected dates plus legacy INT96 stays native. Both of your reproductions are regression tests on raw parquet-mr files with no Spark metadata.

On the sharper point that refusals bypass the decline mechanism: after the modes fix the residual execution-time refusal class is much narrower, but it is not empty and I want to be precise about what remains. It fires only when ancient values actually appear in a file whose policy resolved LEGACY with a non-UTC or unrecorded writer zone, or with contradictory legacy flags. Under EXCEPTION mode an execution-time failure matches Spark's own behavior for those values, so the true fallback-bypass is the LEGACY non-UTC sliver, where Spark succeeds using zone tables native does not have. Declining at planning time requires knowing the file's writer zone, which the JVM side cannot see without reading footers; the conservative alternative is declining every scan whenever an effective mode is LEGACY, which over-declines tables that are entirely modern or UTC-written and would give up the native path for data that reads correctly. That is a genuine tradeoff rather than an oversight, and I am happy to implement the planning-time decline for LEGACY modes if you and @sunchao prefer safety-of-mechanism over coverage here; it is a small change to the existing gate.

Core-path gaps: agreed on both, and they deserve to exist independently of this PR. The shared comparator extraction was already filed as #5658; I have now filed #5662 for the concrete correctness gap you named (the regular native scan forwarding S3 options with no divergence check). The calendar gap for the plain path is #5010, and the rebase module here was built so it can be lifted wholesale when core wants it; the delta-arm flag is the isolation seam, not a design commitment.

On delta in default cargo features: the motivation is exactly what you inferred, letting the contrib jar work against stock Comet binaries so opting in is a Spark-side decision rather than a custom native build. I agree the philosophy conflict with the adjacent contrib-delta comment and #5411 should be settled explicitly rather than by default; I am fine either way and will move it out of default if that is the call. Same for sequencing against #4366, which is yours and sunchao's decision to make; happy to record whatever is agreed in the Cargo.toml comment and the PR description.

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from 053ec14 to 70c5be8 Compare September 3, 2026 09:29
@dwsmith1983

dwsmith1983 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

One correction on the default cargo feature, since it has now come up twice: this was proposed and accepted earlier in this thread (Aug 19), and sunchao clarified on Aug 27 that the adjacent contrib-delta comment means zero kernel surface, not zero Delta surface, so the two features are not in conflict. The cost is about 82 KB in the dylib and the code is unreachable without the contrib jar plus the config. I have added a cross-reference to that decision in the Cargo.toml comment and reworded the contrib-delta one to say delta-kernel explicitly (70c5be8).

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from 70c5be8 to fedc36e Compare September 3, 2026 09:37
@dwsmith1983
dwsmith1983 requested a review from viirya September 3, 2026 09:40

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed fedc36e161404d726367b67d9edb1ca020dde0ab. The calendar fixes address the two earlier examples. Three P2 cases remain:

  • [P2] The timestamp check rejects every negative value. With metadata-free INT64 TIMESTAMP_MICROS and both read modes EXCEPTION, even one microsecond before 1970 fails. Spark 3.5 and 4.0 preserve timestamps at or after 1900-01-01 UTC. Could you use Spark's unit-adjusted cutoff and cover both sides of that boundary?
  • [P2] Merging datetime and INT96 modes turns datetime CORRECTED plus INT96 EXCEPTION into CheckAncient even for an INT64-only file, rejecting corrected 1500-01-01. Spark selects the datetime spec for INT64 MICROS/MILLIS independently of INT96. Could you preserve physical-type attribution or decline unsupported reads before execution?
  • [P2] The nested branch rejects metadata-free STRUCT<d: DATE> under EXCEPTION before checking values, including modern/all-null data. LEGACY footer metadata can also override CORRECTED session modes, so declining only session-LEGACY scans misses unsupported non-UTC files. Could you support the applicable recursive/per-file behavior or arrange Spark fallback before native admission, with Spark/native tests for these cases?

These cases also mean the description's claim that modern values always pass needs narrowing. Pruning loss follows the installed policy wrapper, including modern-only LEGACY and check-only EXCEPTION files, not only values that actually need conversion. The matched benchmark answers the earlier general request. Its approximately 65x result measures decoded rows, not cold/remote bytes or latency.

I reran five diagnostic/control probes against the retained exact module. Its source and relevant dependencies are unchanged at this head. The probes reproduce the native errors and controls, not full Spark compatibility. No full Spark/Delta query or cold-storage benchmark was run for this re-review. All six current-head workflow runs still require authorization, with no executed check results reported.

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from fedc36e to 52efcfe Compare September 3, 2026 12:09
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

All three addressed in 52efcfe, with the semantics checked against the 3.5.9 sources first.

Timestamp cutoff: createTimestampRebaseFuncInRead under EXCEPTION throws only below RebaseDateTime.lastSwitchJulianTs, which computes to 1900-01-01T00:00:00Z from the rebase json (the latest zone switch across all 602 zones; UTC's own is the 1582 cutover). MILLIS columns convert to micros before the check. The native check now uses that cutoff in whichever unit the column carries, dates keep lastSwitchJulianDay, and there are tests on both sides of the boundary per unit plus the Scala regressions you asked for (a metadata-free INT64 at 1969-12-31T23:59:59.999999Z reads verbatim under EXCEPTION, one before 1900 fails loudly).

INT64 vs INT96 attribution: you were right that merging the two specs was wrong, and the arrow schema alone cannot fix it since the opener runs Int96Coercer before the adapter sees anything, so INT96 and INT64 MICROS both arrive as Timestamp(us, UTC). The honest source is the footer's SchemaDescriptor physical types, so our metadata reader factory now records the INT96 leaf ordinals into the file's in-memory key-value metadata when it loads the footer, and the adapter attributes each timestamp leaf by depth-first ordinal, validated against the leaf count and replacing any pre-existing key rather than trusting it. INT64 columns follow the datetime spec and INT96 columns follow the INT96 spec independently; with attribution available, files whose two legacy flags disagree now read correctly instead of refusing. Tests cover both directions at the Rust and Spark levels, including an INT96 column written through the low-level writer alongside an INT64 one, and the old mixed-flags refusal test became a four-combination round-trip against Spark's own read.

Nested types: rather than declining, the wrapper now rebuilds struct, list, large list, fixed size list, map, and dictionary arrays with each leaf transformed under its own policy, preserving nulls and offsets, so STRUCT with a DATE leaf under EXCEPTION reads modern and null data natively and only an actual ancient leaf value fails. Tests cover modern and null leaves under every policy, an ancient leaf rebasing exactly under LEGACY UTC and erroring under EXCEPTION, and list of struct of date round trips, at both levels.

On the narrowing: the description now states exactly what still fails at execution time instead of reading, which after these changes is a LEGACY file with a non UTC or unrecorded writer zone when a timestamp before 1900 actually appears, and an EXCEPTION file when an ancient value actually appears. It also notes that pruning is lost on every column that receives a policy wrapper, modern only LEGACY and check only EXCEPTION files included, and that the 65x figure is decoded rows rather than cold bytes or latency.

Battery at this head: 235/235 on both spark-3.5 and spark-4.0, 339 native tests, clippy and fmt clean. An independent pass over the change also caught an i64 overflow in the nanosecond identity check for the LEGACY UTC path, fixed by comparing in days, with a per-unit test.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed 52efcfe28 against ef62b463. The three earlier examples are addressed by the new cutoff, INT64/INT96 attribution, and recursive handling of modern and null nested values.

[P2] Rebase only the requested nested leaves

Following the calendar update, the struct traversal still processes every physical child before the schema adapter narrows the struct. For a converted metadata-free file containing s.d = DATE '2024-06-01' and s.ts = TIMESTAMP '1500-01-01', with both rebase read modes EXCEPTION, requesting only s.d still checks the unrequested s.ts and raises. Spark's requested nested schema excludes that timestamp.

I independently reproduced this in a component probe with real in-memory Parquet decoding and the exact calendar module. Ordinary narrowing returns the modern date, placing the wrapper beneath that narrowing raises, and reading only the requested Parquet leaf succeeds. The probe uses DataFusion's cast for narrowing. The exact Comet cast and complete adapter/opener path were source-traced, not executed as a full Spark/Delta query.

Could we restrict rebasing to requested leaves while retaining the footer's INT96 ordinal attribution, and add a Spark/native regression for this projection?

The five current-head workflows remain action_required, with no head or merge check results. No full Spark/Delta/JNI run or new benchmark was performed.

@ErikBPF

ErikBPF commented Sep 3, 2026

Copy link
Copy Markdown

@andygrove @sunchao @ErikBPF @comphead do you all have a Slack channel we can use to plan?

Sorry. Posted before from work account. Original message:

No, but I am open to using one if you guys have one. Also open to using discord, since free slack caps message history

@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from 52efcfe to 4a57686 Compare September 3, 2026 15:33
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Fixed in 4a57686. The per file policies are still computed against the physical struct so the footer's INT96 ordinal attribution stays valid, but the wrapper now derives a requested mask by pairing the physical type with the requested logical type through exactly the pairings the struct conversion narrows (struct children by name under the adapter's case rules, list elements, map entries with matching key ordering); every physical leaf outside that mask is marked corrected and never checked. Anything the conversion does not narrow (large lists, fixed size lists, dictionaries, mismatched map ordering) keeps every leaf, so the mask is a superset of what the reader materializes. Your probe is the Spark regression: a converted metadata-free file with s.d modern and s.ts ancient under EXCEPTION read modes, select s.d reads natively and matches Spark, while selecting s.ts still fails loudly. Rust unit tests cover the same at the wrapper level plus a list of struct variant and the non narrowed shapes.

Batteries at this head: 236/236 on both spark-3.5 and spark-4.0, 343 native tests, clippy and fmt clean. Independent review of the change caught that the first cut recursed through list and dictionary pairings the conversion never narrows and a first-match tie-break mismatch on folded name collisions; both fixed before this push.

@dwsmith1983
dwsmith1983 requested a review from sunchao September 3, 2026 16:46
@sunchao

sunchao commented Sep 3, 2026

Copy link
Copy Markdown
Member

@dwsmith1983 @ErikBPF we have a Slack channel in the ASF workspace. Could you share your email address? I can add both of you there.

@sunchao

sunchao commented Sep 3, 2026

Copy link
Copy Markdown
Member

invite sent

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The requested-leaf mask addresses my nested-projection P2. At 4a57686cc against ef62b463, selecting modern s.d now succeeds beside an unrequested ancient s.ts, while requesting s.ts or the whole struct still raises under EXCEPTION. The mask preserves physical leaf ordinals, and the INT96/INT64 attribution controls pass. I found no remaining verified P1/P2 in the reviewed scope.

I independently reran the 39 current calendar-module tests and four diagnostic/control probes, all 43 passed. These use real in-memory Parquet decoding and the exact calendar module, with component-level narrowing. The complete Comet adapter/JNI/Spark/Delta path, non-ASCII JVM folding and cloud reads were not executed. The added Spark regression was inspected, not run. No new benchmark was performed.

At the fresh CI snapshot, 21 checks were successful, 7 skipped and 12 still running, with no failures reported. CI and PyArrow workflows are still in progress.

Adds an optional contrib/delta-spark module that claims delta-spark DSv1
scans through CometScanContrib and runs them on Comet's shared native
parquet path, including main's JVM-exact field-name folding for
case-insensitive footer matching. Deletion vectors are decoded natively
into per-file ParquetAccessPlans that DataFusion intersects with
row-group and page-index pruning, so DV skips and page skips compose in
a single scan. Scans the native path cannot serve safely (DML row-index
reads, unsupported filesystem schemes, userinfo-bearing authorities,
credential-provider-only auth, S3 config divergence, multi-store shapes)
fall back to Spark with an explained reason.

Co-authored-by: Scott Schenkein <schenksj@yahoo.com>
Co-authored-by: Aditya Vaish <adivaish@microsoft.com>
@dwsmith1983
dwsmith1983 force-pushed the feature/delta-native-scan branch from 4a57686 to 79b7b78 Compare September 4, 2026 03:24
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #5653 has merged. The branch is back to the single Delta commit (79b7b78) and the description no longer lists the base commits. No code changes beyond the rebase.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

@andygrove could you take another look when you have a chance? Since your last review the PR was restructured along the lines you asked for: the case folding is gone in favor of #5602, the core fixes went out as #5653 (now merged), and the field id semantics are in #5654. The branch is a single commit on current main, sunchao approved at the previous head, and CI is green apart from the runs still waiting on authorization. Your changes-requested predates the restructure, so I wanted to make sure it is not holding things up by accident.

@dwsmith1983
dwsmith1983 requested a review from sunchao September 4, 2026 04:24

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed 79b7b785c against 55ae4f20. The rebased Delta feature patch is unchanged after excluding Git blob headers, including the requested-leaf calendar fix. I checked the intersecting dependency and shared-scan boundaries and found no remaining P1/P2.

No product tests or benchmarks were rerun. The previous 43 calendar-component checks are historical evidence, reused only after matching the exercised source and dependency identities. At 04:52 UTC, CI had 57 successful checks, 19 running and 7 skipped, with no failures reported. This is not full current-head Spark/Delta/JNI validation.

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.

9 participants