Skip to content

feat: build gate + inert wiring for contrib Delta scans [Delta contrib split, part 2] - #4952

Open
schenksj wants to merge 1 commit into
apache:mainfrom
schenksj:pr/delta-A2-buildgate
Open

feat: build gate + inert wiring for contrib Delta scans [Delta contrib split, part 2]#4952
schenksj wants to merge 1 commit into
apache:mainfrom
schenksj:pr/delta-A2-buildgate

Conversation

@schenksj

Copy link
Copy Markdown
Contributor

Part 2 of the Delta Lake contrib PR breakup. Part 1 (#4700, the core SPI for contrib leaf scans) is merged. This part establishes the contrib-delta build gate and the inert wiring that lets a gated build compile and link end to end — while the default build stays byte-for-byte unchanged (zero Delta surface). It ships no real Delta read logic: a Delta read that reaches native returns a clean "not implemented" error and falls back to vanilla Spark. The full sequence and dependency graph live in the tracking umbrella, #4366.

The whole point of this part is that everything it adds is gated or inert, so it is safe to land on main well ahead of the read path, and reviewers can review the wire format, the build machinery, and the gate enforcement once, in isolation, before any Delta code shows up.

Changes

Build gate

  • Maven contrib-delta profile (spark/pom.xml) with a per-Spark delta.version (3.5 → 3.3.2, 4.0 → 4.0.0, 4.1 → 4.1.0) and an add-source of contrib/delta/src. Default delta.version floor in pom.xml. The default spark.version stays 4.1.2 — the delta-spark 4.1.1 pin is a separate, deferred decision (raised for later parts).
  • Cargo contrib-delta feature on core (optional path dep on comet-contrib-delta); native/Cargo.toml excludes ../contrib from the workspace so non-Delta committers never build it.
  • dev/verify-contrib-delta-gate.sh proves the default cargo tree, Maven dependency set, compiled classes, and libcomet all carry zero Delta surface, and that the gated build pulls the right deps (delta-spark per Spark profile, comet-contrib-delta in the cargo tree). Wired into a minimal delta_build_gate.yml CI job. The full test-suite and regression workflows land in later parts.

Inert wiring

  • Proto: Delta* messages and delta_scan = 118 (117 is BroadcastNestedLoopJoin). One-time wire-format review here, early.
  • Native dispatch: an OpStruct::DeltaScan arm that returns a not-compiled-in error on default builds, and a feature-gated delta_scan shim that calls the contrib; exhaustive-match arms in operator_registry / jni_api; convert_spark_types_to_arrow_schema promoted to pub(crate).
  • Stub contrib crate (contrib/delta/native): plan_delta_scan returns DataFusionError::NotImplemented — just enough to satisfy the core shim's contract so --features contrib-delta links. This makes the exact core↔contrib contract visible in a small PR.
  • JVM bridge DeltaIntegration (reflective; every lookup returns None until the contrib classes exist), the CometExecRule Delta-marker hook (the CDF hook is deferred to a later part), the CometScanRule Delta delegation + metadata-column reorder, and the leaf DeltaConf.

What this part deliberately does NOT do yet

  • No Rust read pathplan_delta_scan is a stub. Log replay, predicate pushdown, deletion vectors, and the kernel read land in parts 3a/3b.
  • No Scala claim/decline or executionDeltaIntegration's lookups resolve to None, so the marker always falls back. The claim/decline layer and native exec land in parts 4a/4b.
  • No CDF — the CometExecRule CDF hook and DeltaIntegration's CDF members are held back to part 5.

Why it is safe on default builds

Without -Pcontrib-delta: the cargo feature is off (no comet-contrib-delta, no delta_kernel in the tree), the Maven profile is inactive (no io.delta:*, no contrib/delta sources compiled), and the native DeltaScan arm is #[cfg]-stubbed to an error that is never reached because nothing emits the proto message. DeltaIntegration is the only always-present class and every one of its reflective lookups returns None. The gate script asserts all of this mechanically: default libcomet has 0 Delta symbols and is the same size as before.

The CometScanRule change reorders the metadata-column guard so V1 scans reach transformV1Scan (which delegates to any V1 contrib) before the generic metadata-column rejection — but for a non-contrib V1 scan the guard is re-applied inside transformV1Scan, so vanilla behavior is unchanged.

Verification

Run against this branch rebased on current main:

  • Default native build + gated native build (--features contrib-delta): green.
  • cargo clippy both feature states: clean.
  • dev/verify-contrib-delta-gate.sh: all checks pass (0 Delta symbols in the default dylib).
  • Gated JVM compile (spark-3.5 / Scala 2.13, -Pcontrib-delta) and default JVM compile (spark-3.4 / Scala 2.12): both compile.
  • spotless + scalastyle: clean.

Roadmap

Parts still to come: Rust driver-side planning (3a), Rust executor-side read path (3b), Scala claim/decline (4a), Scala execution — end-to-end native reads (4b), Change Data Feed (5), test battery + regression harness (6), and docs (7). Each is gated behind -Pcontrib-delta, so every intermediate state on main is safe for default builds. Tracking umbrella: #4366; part 1: #4700.


🤖 AI disclosure: this PR was prepared with assistance from Claude Code (Claude Opus 4.8), under the submitter's review and direction.

@schenksj

Copy link
Copy Markdown
Contributor Author

@parthchandra @andygrove this is part 2 of the Delta contrib split — the build gate + inert wiring — following on from part 1 (#4700, now merged). Thank you both for the reviews on part 1! 🙏

Sorry it took a while to get this next phase out there — I was heads-down getting https://github.com/capitalone/vulnhunter to market. Back on the Delta series now.

This part is deliberately inert/gated (zero Delta surface on default builds), so it should be a fairly self-contained review of the wire format, the build machinery, and the gate-enforcement script. Would appreciate your eyes when you have a chance.

* and the lookups resolve, dispatching the call into the contrib helpers.
*
* Keeping this bridge as one small file in core lets the Delta detection block in `CometScanRule`
* and the serde dispatch in `CometExecRule` stay ~10 lines each -- exactly the shape Parth's

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.

The change to CometExecRule is small but this itself is pure Delta code in core. We really need to follow the same pattern we established of discovering using the ServiceLoader mechanism. PlanDataInjector (in operators.scala:72-127, merged in Part 1 / #4700) already uses ServiceLoader discovery. PR #4633 (Lance) also uses the same pattern with CometScanContrib

I would recommend we replace this with a generic CometScanContrib trait in a new file spark/src/main/scala/org/apache/comet/rules/CometScanContrib.scala (in core). This trait can cover both V1 and V2 scans:

  trait CometScanContrib {
    /** V1 scan hook. Return Some(plan) to claim, None to pass. */
    def tryTransformV1(
        plan: SparkPlan,
        session: SparkSession,
        scanExec: FileSourceScanExec,
        relation: HadoopFsRelation): Option[SparkPlan] = None

    /** V2 scan hook. Return Some(plan) to claim, None to pass. */
    def tryTransformV2(scanExec: BatchScanExec): Option[SparkPlan] = None
  }

  object CometScanContrib extends Logging {
    private lazy val contribs: Seq[CometScanContrib] = {
      // Built-in contribs (Parquet, Iceberg) can be registered here.
      // Contrib-gated ones (Delta, Lance) discovered via ServiceLoader.
      val discovered = try {
        ServiceLoader.load(classOf[CometScanContrib], getClass.getClassLoader).asScala.toSeq
      } catch {
        case NonFatal(e) =>
          logWarning("Failed to load contrib CometScanContrib services", e)
          Seq.empty
      }
      discovered
    }

    def tryTransformV1(...): Option[SparkPlan] = {
      contribs.view.flatMap(_.tryTransformV1(...)).headOption
    }

    def tryTransformV2(...): Option[SparkPlan] = {
      contribs.view.flatMap(_.tryTransformV2(...)).headOption
    }
  }

This is format-agnostic code.

The Delta contrib then has:

  • contrib/delta/src/.../DeltaScanRuleContrib.scala implementing CometScanContrib
  • contrib/delta/resources/META-INF/services/org.apache.comet.rules.CometScanContrib naming it

This also subsumes PR #4633's CometScanContrib — the Lance PR would implement tryTransformV2 on the same trait rather than defining a separate one.

Comment thread native/proto/src/proto/operator.proto Outdated
// Delta Lake scan. Wire format used by `contrib/delta/`. Only decoded when
// core is built with `--features contrib-delta`; in default builds the
// dispatcher arm is `#[cfg]`-stubbed out so the contrib has zero runtime cost.
DeltaScan delta_scan = 118;

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.

Currently both this PR and PR #4633 claim field number 118 (delta_scan = 118 vs lance_scan = 118). More fundamentally, adding a new oneof variant for every contrib scan type means the core proto file must change every time a new format is added — violating the goal that core doesn't know about contrib implementations.

Spark Connect solves this at relations.proto:109 with google.protobuf.Any extension = 998;. We should do the same.

We can replace this change with a permanent extension point:

  // One-time addition to the oneof, never changes again:
  google.protobuf.Any contrib_scan = 200;

Then DeltaScan and LanceScan message definitions still live in operator.proto (or preferably in separate proto files under contrib/) — they're just packed into Any on the JVM side and unpacked by type_url on the Rust side.

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.

If this gets tricky to implement we can consider assigning field ids for contrib scans, but I feel that the approach outlined here is feasible.

case scan if !CometConf.COMET_NATIVE_SCAN_ENABLED.get(conf) =>
withFallbackReason(scan, "Comet Scan is not enabled")

// V1 scans go through `transformV1Scan` which itself first delegates to any

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.

With a generic CometScanContrib.tryTransformV1(), this reordering is unnecessary. The outer transformScan match keeps its current order (metadata-colum guard before FileSourceScanExec). Each contrib's tryTransformV1 can decide internally whether it can handle metadata columns.

// vanilla scan path. When the Delta classes are on the classpath, the contrib
// either claims the scan (returning a CometScanExec marker) or declines via
// its own `withFallbackReason` fallback message.
DeltaIntegration.transformV1IfDelta(plan, session, scanExec, r) match {

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.

This will change to CometScanContrib.tryTransformV1(plan, session, scanExec, r). We can also remove the re-applied metadataCols check — it's now redundant because the outer guard still runs first and the Delta implementation decides whether to handle metadata columns or not.

// activated. The marker wraps the original, link-bearing scan, so the produced exec's
// originalPlan keeps its logicalLink with no workaround. If conversion declines, the marker
// itself falls back to the vanilla Spark Delta scan, so leaving it in the plan is safe.
case scan if DeltaIntegration.isDeltaScanMarker(scan) =>

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.

We can clean this up too - Define a marker trait and Delta can implement it

 /** Marker trait for contrib scan nodes that carry their own serde handler. */
  trait CometContribScanMarker { this: SparkPlan =>
    def scanHandler: CometOperatorSerde[_ <: SparkPlan]
  }

then the match becomes

 case marker: CometContribScanMarker =>
    convertToComet(marker, marker.scanHandler).getOrElse(marker)

@schenksj

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @parthchandra — the "core must stay format-agnostic" framing is
exactly right, and following the PlanDataInjector ServiceLoader pattern made the whole thing
simpler than the reflective bridge it replaces. I've pushed a refactor addressing every thread.
A few notes below, including two places where I deviated slightly from the literal suggestion and
why.

What changed (per thread)

  • CometScanContrib SPI. DeltaIntegration.scala (the reflective bridge, ~200 lines) is
    deleted and replaced by the generic trait + ServiceLoader object you sketched, in
    spark/.../rules/CometScanContrib.scala. Discovery mirrors PlanDataInjector.injectors
    exactly (ServiceLoader.load + NonFatal fallback to empty). The Delta side is
    DeltaScanRuleContrib implements CometScanContrib plus a
    META-INF/services/org.apache.comet.rules.CometScanContrib resource, both packaged only under
    -Pcontrib-delta. Core names no contrib and carries none on a default build.

  • CometContribScanMarker. Added as you described; CometExecRule is now a plain type test
    (case marker: CometContribScanMarker => convertToComet(marker, marker.scanHandler)) with no
    reflective handler lookup.

  • Proto extension point. delta_scan = 118 is gone, replaced by a single permanent
    contrib_scan = 200 envelope (+ reserved 118), dispatched by type_url on the Rust side.
    Core's oneof never grows per-format again and the 118 collision with Add optional native Lance scan support #4633 is resolved. The
    DeltaScan messages stay in operator.proto for now (relocating them into a contrib .proto
    needs a prost build pipeline the contrib crate doesn't have yet — happy to do that as a
    follow-up).

  • CometScanRule. Outer transformScan match order restored to match main, and the
    delegation is now CometScanContrib.tryTransformV1(...).

Two deliberate deviations

1. Hand-rolled envelope instead of google.protobuf.Any. The field layout is identical to
Any (type_url + packed value), so the JVM can still populate it straight from
Any.pack(...) and the Rust side routes purely on type_url — the architecture you asked for is
unchanged. I couldn't use the well-known type directly because Comet compiles this .proto with
two toolchains: Rust prost-build handles Any fine, but the Maven protoc-jar plugin can't
resolve import "google/protobuf/any.proto" with a downloaded <protocArtifact>, and
includeStdTypes=true NPEs inside the plugin. The hand-rolled message needs no WKT import, no
prost-types dep, and no plugin workaround. If you'd prefer the real Any, I'm glad to switch —
it just means bundling any.proto locally or pinning a plugin version that doesn't hit the NPE.

2. CometContribScanMarker extends SparkPlan rather than a this: SparkPlan => self-type.
With the self-type, a value statically typed as the trait isn't a SparkPlan, so
convertToComet(marker, ...) (which takes SparkPlan) and getOrElse(marker) (which must yield
one) don't typecheck. Extending SparkPlan gives the bound pattern variable the identity the call
site needs; a contrib mixes it into its scan-exec node, which already extends SparkPlan, so
linearization stays consistent.

One subtlety on the metadata-column guard (threads at CometScanRule.scala:122 / :177)

You're right that with a generic hook the outer reorder is unnecessary and the re-applied
metadataCols check is redundant — I removed both. But keeping the outer guard strictly before
the contrib is consulted turns out to conflict with the other half of your comment ("the Delta
implementation decides whether it can handle metadata columns"): the Delta reader synthesizes
_metadata.* itself, so if the outer guard rejects a _metadata scan before the contrib ever
sees it, Delta loses that capability. This isn't visible in this PR (it's inert), which is exactly
why I want to flag it.

So I kept your outer match order, but moved the metadata bailout out of the outer match and into
the head of each built-in transform path (transformV1Scan / transformV2Scan), right after that
path's contrib hook declines. Net effect: identical behavior for default builds — isSupportedScanNode
admits only V1/V2, and both now apply the guard — while a contrib still gets first crack, which is
what your comment intended. I confirmed the exact fallback string is unchanged on the default build
(CometIcebergNativeSuite "should report unsupported metadata columns" passes).

Validation

Because this PR is inert, I didn't want to land the contract change on its own evidence — an
empty registry exercises none of it. So I carried the new contract through the rest of the Delta
split stack (the driver/executor Rust, the Scala claim/decline + serde, CDF, and the test
battery) and ran it end to end:

  • dev/verify-contrib-delta-gate.sh passes: default libcomet has 0 Delta symbols and is
    13 MB smaller; default build compiles no contrib classes and — new check — packages no
    META-INF/services contrib files (registration, not class presence, is what turns a contrib on
    now).
  • Delta contrib suites: 194 tests, 0 failures across all 33 suites, exercising the full
    ContribScan round-trip (JVM pack → type_url dispatch → native decode) on real reads.
  • CometIcebergNativeSuite on a default build: 75/75, confirming no default-path regression.
  • JVM compiles on Scala 2.12 (spark-3.5) and 2.13 (spark-4.0) with -Pcontrib-delta; clippy at
    -D warnings and cargo fmt clean on both crates.

That pass caught a couple of things the inert PR couldn't show on its own — most importantly the
metadata-guard interaction above, and that a throwing contrib needs to degrade to a decline
(the old bridge funneled InvocationTargetException → log + fall back; the direct SPI call
doesn't, so CometScanContrib now wraps each hook in NonFatal). Both are folded into this PR.

Lance (#4633) coordination

This trait is intended to subsume the CometScanContrib #4633 was defining — Lance would
implement tryTransformV2 on this same trait and pack a LanceScan into the shared
contrib_scan envelope, so the two land coherently and there's no second oneof field to collide.
Happy to coordinate the sequencing however you'd prefer.

Thanks again — this is a cleaner design than what I started with.


🤖 This reply was drafted with Claude Code.

@parthchandra

Copy link
Copy Markdown
Contributor

@schenksj did you forget to push the code ?

schenksj added a commit to schenksj/datafusion-comet that referenced this pull request Jul 28, 2026
…ack]

Addresses @parthchandra's review on apache#4952. Every thread had the same theme:
core must not name a specific contrib format. Replaces the Delta-specific core
touchpoints with generic extension points, mirroring the ServiceLoader SPI
established in part 1 (apache#4700, `PlanDataInjector`) and shared with the Lance
PR (apache#4633).

- Delete `DeltaIntegration.scala` (reflective bridge with cached `MODULE$` /
  `getMethod` lookups). Replaced by `CometScanContrib`: a `trait` with
  `tryTransformV1` / `tryTransformV2` (both defaulting to `None`) plus a
  ServiceLoader-backed object, discovered exactly like `PlanDataInjector`.
  Default builds ship no `META-INF/services` entry, so the registry is empty
  and both hooks are inert. Both hooks are wired for real -- `tryTransformV1`
  at the top of `transformV1Scan`, `tryTransformV2` at the top of
  `transformV2Scan` -- so a V2 contrib (Lance) is consulted too; this trait
  subsumes the one apache#4633 was defining.

- Add `CometContribScanMarker`, a marker trait carrying its own
  `scanHandler: CometOperatorSerde[_ <: SparkPlan]`. `CometExecRule` is now a
  plain type test instead of a class-name match plus a reflective handler
  lookup. It `extends SparkPlan` rather than using a `this: SparkPlan =>`
  self-type: a self-typed trait value is not a `SparkPlan`, so
  `convertToComet(marker, ...)` and `getOrElse(marker)` would not typecheck.

- Proto: replace the contrib-specific `DeltaScan delta_scan = 118` oneof
  variant with a single permanent `ContribScan contrib_scan = 200` envelope
  (`type_url` + packed `value`), and `reserved 118`. Core's oneof never grows
  per-contrib again, and the 118 collision with apache#4633's `lance_scan` is gone.
  The envelope is hand-rolled rather than `google.protobuf.Any` because Comet
  compiles this .proto with two toolchains and the Maven `protoc-jar` plugin
  cannot resolve the bundled well-known types (`includeStdTypes` NPEs inside
  the plugin). Field layout is identical to `Any`, so the JVM can populate it
  from `Any.pack(...)`.

- Native: `OpStruct::ContribScan` is routed by `type_url` to the gated
  `delta_scan::try_plan_contrib_scan`, which claims only its own type and
  decodes `DeltaScan` itself -- core names no contrib type. A default build
  reaching a `contrib_scan` gets a clear, `type_url`-identifying error.

- `CometScanRule`: outer `transformScan` match order restored to match main,
  and the redundant re-applied metadata-column guard dropped.

Verification: default + `contrib-delta` cargo builds, clippy both feature
states, `dev/verify-contrib-delta-gate.sh` (default libcomet: 0 Delta symbols),
JVM compile on spark-3.4/Scala 2.12 and spark-3.5/Scala 2.13, spotless and
scalastyle -- all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@schenksj

Copy link
Copy Markdown
Contributor Author

Apologies @parthchandra — you're right, that one's on me. My reply above walked through the refactor, but the push silently didn't go through on my end. It's up now: commit d1783f2 ("make core contrib-scan wiring format-agnostic") on this branch, which contains everything in that walkthrough. Thanks for the nudge.


🤖 This reply was drafted with Claude Code.

@parthchandra parthchandra 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.

Thanks Scott, this is great. I have some more comments, but we are getting close.

* than swallowing silently) keeps an unexpectedly-declining contrib diagnosable. `NonFatal`
* deliberately lets `LinkageError`/`OOM`-class failures through.
*/
private def firstClaim(hook: CometScanContrib => Option[SparkPlan]): Option[SparkPlan] =

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.

This is nice. Can we add a small test suite similar to CometScanWithPlanDataSuite?
Some cases to consider (all running on the default build) -

  • an empty registry returns None from tryTransformV1/tryTransformV2
  • a stub contrib registered through a URLClassLoader that claims a scan is returned
  • a stub that throws is logged and declined so the next contrib still gets a look

Also, can two contribs claim the same scan?

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.

There is a corner case here. A future V2 contrib that has a table name like files, snapshots etc (iceberg reserved names) would pass the isIcebergMetadataTable check and the contrib will never get called. We could either call transformV2Scan first or explicitly check for an iceberg scan in isIcebergMetadataTable ?

*
* Both hooks default to `None` so a contrib overrides only the scan kind(s) it handles.
*/
trait CometScanContrib {

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.

Can we add a doc note that an implementation MUST return None for a scan it does not own. This is to prevent two contribs from competing for the same scan.

case Some(handled) => return handled
case None => // proceed with vanilla logic
}
if (metadataCols(scanExec).nonEmpty) {

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.

Can we add a test case that covers metadata for V1 scans? say selecting _metadata.file_path for a parquet source. The default build should fall back with this reason.

// =====================================================================================

// Per-scan invariants. Lives at the head of every Delta scan operator payload.
message DeltaScanCommon {

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.

Can we log a follow up issue to move this out of core and into contrib/delta/proto? We will probably need to add a (manual) pipeline for the contribs.

// itself is unconditional so a default build that receives a contrib-shaped plan
// from a misconfigured driver gets a clear error instead of a "no match" decode
// failure.
#[cfg(feature = "contrib-delta")]

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.

I know this is based on the previous review comment but on deeper thought it may be possible to remove this from core as well by using a generic handler (similar to the jvm side).
Could you log a follow up issue for this as well? We have two paths we can consider -

  1. a true service loader type system for dynamically discovering and loading an extension. There may be dragons along this path.
  2. a statically linked version which builds each crate independently but may need some refactoring at the crate level to avoid circular dependencies.

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.

Rust dynamic loading ref: https://nullderef.com/blog/plugin-dynload/

…b split, part 2]

Part 2 of the Delta contrib split: the build gate and the inert core wiring an
out-of-tree scan contrib plugs into. Nothing here is reachable on a default
build -- no contrib is registered, no contrib class is compiled, and the native
library carries zero contrib symbols.

Core gains two format-agnostic extension points, both discovered at runtime so
core holds no compile-time reference to any contrib:

  - `CometScanContrib`, a ServiceLoader-discovered hook (mirroring
    `PlanDataInjector`) that lets a contrib claim a V1 or V2 scan before
    Comet's built-in handling runs, plus `CometContribScanMarker` so
    `CometExecRule` can route a contrib's scan node to the contrib's own serde
    handler by a plain type test.

  - `ContribScan contrib_scan = 200`, a single permanent `Any`-shaped proto
    envelope (`type_url` + packed `value`) dispatched by `type_url` on the
    native side. Core's oneof never grows per-format, so independent contrib
    PRs cannot collide on a field number -- as `main` taking field 118 for
    `Sample` has since demonstrated.

Plus the build machinery: the `contrib-delta` Maven profile and Cargo feature,
and `dev/verify-contrib-delta-gate.sh`, which asserts a default build compiles
no contrib classes, packages no contrib `META-INF/services` files, and links no
contrib symbols.

Where the hooks sit, and why. Both run *before* Comet's built-in guards for
their scan kind, because a contrib may support things the built-in scan does
not -- the Delta contrib synthesises `_metadata.*` in its own reader, and a
contrib's table name may end in `files`/`snapshots` like an Iceberg metadata
table. Applying those guards first would decline such a scan before the contrib
was ever offered it. So `transformV1Scan` consults the contrib ahead of the
metadata-column guard, and the Iceberg metadata-table check moves out of the
outer `transformScan` match into `transformV2Scan`, after its hook. Core's
per-path metadata handling is otherwise untouched: `main` serves
`fileConstantMetadataColumns` natively in V1 and the Iceberg metadata columns
in V2, and both keep doing so.

Ownership contract. An implementation MUST return `None` for a scan it does not
own: contribs are offered a scan one at a time and the first claim wins, so a
contrib claiming another format's scan hides it from the contrib that could
have read it, with the outcome depending on unspecified ServiceLoader ordering.
"Own but cannot handle" is a distinct, expressible case -- claim the scan and
terminate it with `withFallbackReason` rather than declining. Core cannot
arbitrate competing claims (a claim is opaque; the only way to know a second
contrib would also have claimed is to ask it, which is what claiming prevents),
so the contract carries it.

Tests. `CometScanContribSuite` covers the registry contract on a default build:
no contribs registered (asserted against raw ServiceLoader discovery, not just
the registry -- `contribs` swallows a ServiceConfigurationError, so "empty"
alone is ambiguous), a stub discovered through a URLClassLoader whose claim is
returned, decline-passes-through, first-claim-wins with later contribs not
consulted, throw-is-a-decline, and LinkageError still propagating.
`CometScanRuleSuite` gains a V1 case asserting the fallback *reason* for
`_metadata.row_index`; verified red with the guard removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schenksj
schenksj force-pushed the pr/delta-A2-buildgate branch from d1783f2 to eebc232 Compare August 15, 2026 23:53
@schenksj

Copy link
Copy Markdown
Contributor Author

Thanks @parthchandra — and apologies for being hard to reach lately. Hoping we can get our meeting
back on the calendar soon.

All six threads are addressed, plus a rebase onto current main that turned out to be load-bearing
— it surfaced two things this PR was silently wrong about. Details below.

The rebase (worth reading first)

This branch was 141 commits behind. Rebasing changed two substantive things:

1. Field 118 is now Sample sample = 118 on main. Exactly the collision you predicted — it
just came from core rather than from #4633. contrib_scan = 200 was already immune, but my
reserved 118 was not: reserving a number that is now in use is a protoc error. Dropped it. This
is a nice retroactive argument for the permanent-envelope design: the contrib side needed no change
at all.

2. main now supports metadata columns that this PR assumed were unsupported. transformV1Scan
serves fileConstantMetadataColumns (file_path, file_name, file_size, ...) natively and
rejects only the reader-generated ones (row_index); the V2 Iceberg path supports the columns in
CometIcebergNativeScan.MetadataFieldIds. My earlier refactor had hoisted a blanket
metadataCols(...).nonEmpty guard into each transform path, which on this base would have regressed
both. Removed both blanket guards, and deleted a duplicate metadataCols helper the rebase left
behind.

That changes the shape of the fix for the metadata thread — see below.

Per-thread

CometScanContrib.scala:49 — doc that an implementation MUST return None for a scan it does not
own.
Added, with the reasoning: first claim wins, so a contrib claiming another format's scan
doesn't merely mis-handle it — it hides the scan from the contrib that could have read it, and the
outcome depends on unspecified ServiceLoader ordering. The doc also directs implementers to decide
ownership from something definitive (the relation's fileFormat class, the table's provider, a
catalog type) rather than a path or table-name heuristic another format may also match.

I also documented "own but cannot handle" as a distinct, expressible case: return
Some(withFallbackReason(scanExec, ...)) — claim the scan and terminate it with a diagnosable
reason — rather than None, which would let Comet's built-in handling attempt a format it doesn't
understand.

Can two contribs claim the same scan? In principle yes, and core cannot detect it: a claim is
opaque, and the only way to learn that a second contrib would also have claimed is to ask it, which
is precisely what claiming is meant to prevent. So it's resolved by contract, not arbitration —
first Some wins, later contribs are not consulted, and that's now documented on firstClaim and
covered by a test. If you'd rather core were noisy about it, the cheap version is a debug-only pass
that asks every contrib and logs when more than one claims; happy to add that if you want it.

CometScanRule.scala:135 — a V2 contrib with a table named files/snapshots never reaches the
hook.
Real bug; fixed by your first option. isIcebergMetadataTable moved out of the outer
transformScan match and into transformV2Scan, directly after the contrib hook declines. A
genuine Iceberg metadata table falls back with the identical reason; a contrib that owns a
similarly-named table now gets offered it first. (I kept your case-insensitive refinement from
main intact when relocating it.)

CometScanRule.scala:181 — a V1 metadata test. Given the change above, hoisting the guard was
the wrong shape — main's guard is now nuanced per column, and moving it would have thrown that
away. Instead the contrib hook moved up: transformV1Scan offers the scan to CometScanContrib
before any built-in guard runs, then main's constant-vs-generated metadata logic proceeds
untouched. Same property you asked for (a contrib that synthesises _metadata still gets a look),
without relitigating what core supports.

Test added to CometScanRuleSuite asserting the fallback reason for _metadata.row_index over a
parquet source on a default build (main's existing test asserts the plan shape; this asserts the
message). Verified red — removing the guard fails it. Worth noting the red run also showed main's
plan-shape test still passing without the guard, so the reason assertion is doing real work.

CometScanContrib.scala:99 — a suite like CometScanWithPlanDataSuite. Added
CometScanContribSuite, 7 tests, all on the default build, covering your three cases plus three
more the contract needs:

  • empty registry → None from both hooks;
  • a stub registered through a URLClassLoader service file is discovered and its claim is returned;
  • a throwing stub is logged, declined, and the next contrib still gets a look;
  • a declining contrib passes through to the next;
  • first claim wins and later contribs are not consulted;
  • a LinkageError still propagates (the NonFatal boundary is deliberate, so it's pinned).

One thing that test found: asserting "the default build registers no contribs" against the
registry passes vacuously, because contribs swallows a ServiceConfigurationError and yields an
empty registry — so "empty" holds both when nothing is registered and when something is registered
but unloadable. (A stale service file in my target/classes from a contrib build is how I hit it.)
The test now asserts against raw ServiceLoader discovery, which fails loudly instead.

operator.proto:318 — issue to move contrib messages into contrib/delta/proto. Filed as
#5378. I also did the one part that seemed unsafe to defer: dispatch was keyed on
spark.spark_operator.DeltaScan — core's proto package — so the relocation would have changed the
identifier. It's now comet.contrib.delta.DeltaScan, naming the owner rather than the file's
current home, which makes the move a no-op on the wire.

planner.rs:1622 — issue for a generic native handler. Filed as #5379, with both paths you
outlined (dynamic plugin loading, incl. your nullderef reference; and statically-linked independent
crates with the crate-level refactor needed to avoid the core -> contrib -> core cycle) and a note
on why the priority is lower than the JVM side: the coupling is compile-time and feature-gated, and
dev/verify-contrib-delta-gate.sh asserts a default build links zero contrib symbols.

Validation

On the rebased branch:

  • CometScanContribSuite (7), CometScanRuleSuite, CometScanWithPlanDataSuite,
    PlanDataInjectorSuite, CometScanSchemeFallbackSuite24/24 green on Spark 3.5 / Scala
    2.12 and on Spark 4.0 / Scala 2.13
    , against a freshly built native lib.
  • cargo check clean on the default build and with --features contrib-delta; clippy -D warnings
    and cargo fmt clean.
  • dev/verify-contrib-delta-gate.sh: default build compiles no contrib classes, packages no contrib
    META-INF/services, links no contrib symbols.

🤖 This reply was drafted with Claude Code.

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