Skip to content

feat(spark-4): add Apache Spark 4.x connector + batch-ingestion plugin (JDK 21+) - #18261

Open
xiangfu0 wants to merge 43 commits into
apache:masterfrom
xiangfu0:claude/musing-neumann-2a8e62
Open

feat(spark-4): add Apache Spark 4.x connector + batch-ingestion plugin (JDK 21+)#18261
xiangfu0 wants to merge 43 commits into
apache:masterfrom
xiangfu0:claude/musing-neumann-2a8e62

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds first-class Apache Spark 4.x support to Pinot, alongside the existing Spark 3 connector and batch-ingestion plugin. The two new modules live next to their Spark 3 siblings:

New module Replaces (for Spark 4 users) Scope
pinot-connectors/pinot-spark-4-connector pinot-spark-3-connector Spark DataSourceV2 read + write
pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-spark-4 pinot-batch-ingestion-spark-3 Segment generation + push runners

Both modules compile against Apache Spark 4.1.1 (the latest 4.x release) and are activated only on JDK 21+ via the pinot-spark-4-connector / pinot-batch-ingestion-spark-4 profile in their respective parent poms (<jdk>[21,)</jdk>). On JDK 11/17 the reactor still builds Spark 3 and completely skips Spark 4, so existing Spark 3 users are unaffected.

The PR also deprecates pinot-spark-3-connector / pinot-batch-ingestion-spark-3: with Pinot's master branch raising the Java baseline to JDK 21, the Spark 3 modules are now JDK 21 + Spark 3.5.5+ only and slated for removal in the next minor release. New users should adopt Spark 4.

Why a separate module?

Apache Spark 4 requires JDK 17+, is Scala 2.13 only (no more 2.12), ships Jetty 12 / Jakarta Servlet 5, and made a few small DataSourceV2 API tweaks. Running the Spark 3 connector under Spark 4 doesn't work cleanly. The modules sit side-by-side so:

  • Spark 3.5.5+ users keep the existing pinot-spark-3-connector (deprecated, one release of overlap).
  • Spark 4.0/4.1 users pick up pinot-spark-4-connector.
  • The Maven coordinates, artifact names, and shaded-jar layout mirror the Spark 3 pair, so deployment automation (docker assembly, plugin discovery) doesn't need special cases beyond a JDK choice.

Netty

Spark 4.1.x depends on netty 4.2.x (the new io.netty.channel.kqueue.KQueueIoHandler / EpollIoHandler APIs). Pinot-wide netty stays at 4.1.x — only the pinot-batch-ingestion-spark-4 module locally overrides netty.version to 4.2.7.Final so its embedded-SparkContext test can construct NettyRpcEnv. The spark-4-connector module does not instantiate a real SparkContext in tests, so it inherits Pinot's netty without conflict.

End-to-end tutorial

The End-to-end Docker tutorial walks through:

  1. Starting Pinot via docker run apachepinot/pinot:latest QuickStart
  2. Building the shaded connector jar
  3. Pulling apache/spark:4.1.1-scala2.13-java21-python3-ubuntu (Spark 4.1.1 + JDK 21, no custom build needed)
  4. Reading baseballStats (97,889 rows) from Spark 4 via PySpark
  5. Creating a target table and writing a DataFrame through the connector
  6. Pushing the produced segment tars to the controller (REST or via the SparkSegment*PushJobRunner runners)
  7. Reading the written data back through the connector

Every command was executed against apachepinot/pinot:latest and apache/spark:4.0.0 before being checked in (the netty bump to 4.1.1 was applied after end-to-end validation; the Docker tutorial now references the published apache/spark:4.1.1-java21 image variants).

Quick usage (read)

from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("pinot-read").getOrCreate()

df = (spark.read.format("pinot")
      .option("table", "baseballStats")
      .option("tableType", "offline")
      .option("controller", "pinot-controller:9000")
      .option("broker", "pinot-broker:8000")
      .load())

df.filter("yearID >= 2000").groupBy("teamID").count().show()

Invoke with spark-submit (Spark 4.1.1 on JDK 21):

spark-submit \
  --jars pinot-spark-4-connector-<VER>-shaded.jar,commons-lang3-3.20.0.jar \
  --conf spark.driver.extraClassPath=commons-lang3-3.20.0.jar \
  --conf spark.executor.extraClassPath=commons-lang3-3.20.0.jar \
  my_read_job.py

The prepended commons-lang3-3.20.0.jar works around Spark 4 shipping an older version that lacks ObjectUtils.getIfNull(...). See the tutorial's Known gotchas for the full list.

Quick usage (write)

(df.write.format("pinot")
    .mode("append")
    .option("table", "myTable")
    .option("tableType", "OFFLINE")
    .option("segmentNameFormat", "{table}_{partitionId:03}")
    .option("invertedIndexColumns", "name,category")
    .option("timeColumnName", "ts")
    .option("timeFormat", "EPOCH|MILLISECONDS")
    .option("timeGranularity", "1:MILLISECONDS")
    .option("controller", "pinot-controller:9000")
    .save("s3a://my-bucket/pinot-segments/myTable"))

This produces segment tar files at the save(...) path. Then push them to the controller — either REST for one-offs:

for seg in /path/to/*.tar.gz; do
  curl -X POST -H 'UPLOAD_TYPE: SEGMENT' -F "file=@${seg}" \
    "http://pinot-controller:9000/v2/segments?tableName=myTable&tableType=OFFLINE"
done

…or in production use the runners from pinot-batch-ingestion-spark-4 via a SegmentGenerationJobSpec YAML (see the module's README):

  • SparkSegmentGenerationJobRunner — build segments from Avro/CSV/Parquet input
  • SparkSegmentTarPushJobRunner — upload tars to the controller
  • SparkSegmentUriPushJobRunner — register tars already staged in deep storage (S3/GCS/HDFS)
  • SparkSegmentMetadataPushJobRunner — metadata-only push for large segments (avoid streaming the whole tar through the controller)

⚠️ Overwrite semantics

df.write.mode("overwrite").save(...), df.writeTo(...).overwrite(...), and SQL INSERT OVERWRITE all fail fast with UnsupportedOperationException. Pinot's write path can only append new segments — it cannot atomically drop or replace rows matching a Spark predicate. To replace data, drop the table first (controller REST) or use a push runner with REFRESH / consistent-push enabled. Prior to the fixes in this PR, overwrite calls silently appended without replacement, leaving stale data queryable.

Compatibility matrix

Module Spark Scala JDK (build & runtime)
pinot-spark-3-connector / pinot-batch-ingestion-spark-3 (deprecated) 3.5.5+ 2.12 / 2.13 21 only (master baseline)
pinot-spark-4-connector / pinot-batch-ingestion-spark-4 4.1.x (compiled against 4.1.1) 2.13 only 21 only

Both connector jars are now compiled with --release 21 (class file major version 65). Deployments stuck on JDK 17 should pin to Pinot 1.5.x until they can upgrade.

Correctness fixes (in addition to the Spark 4 port)

Multi-round review surfaced numerous correctness issues — many pre-existing in the Spark 3 code that was being ported. All fixes are applied symmetrically to both modules with regression tests on each side.

Filter pushdown

  1. Compound filter soundness. Or / And / Not were marked as unconditionally supported even if a child was a filter type the connector does not recognize. Spark would remove the compound from its residual filter list, while compileFilter silently returned None, so Pinot received no filter at all and returned unfiltered rows. Fix: isFilterSupported is now recursive — compounds are accepted only when every child is supported.

  2. Null-leaf gating across all comparison operators. EqualNullSafe(_, null) and In(_, [..., null, ...]) originally rendered attr <op> null via compileValue's fallback branch; comparison operators (EqualTo / LessThan / LessThanOrEqual / GreaterThan / GreaterThanOrEqual) had the same gap. Fix: every leaf with a null literal is rejected and falls through to Spark post-scan; an In with a null value array (NPE risk) is also rejected. Compound gating ensures the rejection propagates through enclosing And/Or/Not.

  3. Collection-shaped literal values. EqualTo("attr", Seq(1, 2)) rendered as attr = List(1, 2) via value.toString — malformed SQL. Fix: new isPushableValue whitelist allows only String / Number / Boolean / Timestamp / Date; everything else (Seq / Map / Vector / Set / case classes) routes to post-scan.

  4. LIKE pushdown escaping + cross-module invariant. StringStartsWith / StringEndsWith / StringContains interpolated raw user-supplied strings into LIKE '...' without escaping %, _, \, or '. Values containing these changed the predicate meaning (or produced invalid SQL). Fix: escapeLikeLiteral escapes LIKE wildcards, backslashes, and single-quotes; each LIKE predicate carries an ESCAPE '\' clause; values containing a literal \ are rejected upstream because Pinot's RegexpPatternConverterUtils#likeToRegexpLike does not round-trip \\ correctly. A new regression test runs the emitted SQL fragment back through RegexpPatternConverterUtils.likeToRegexpLike and verifies the resulting regex (via Matcher#find, mirroring RegexpLikeConstFunctions) matches the original literal — pinning the cross-module \ escape contract.

  5. Identifier escaping. escapeAttr now wraps any column name in double-quotes and doubles inner " so SQL stays well-formed even for column names containing stray quotes.

Type translator

  1. Throw on unknown types instead of returning null. Previously translateType returned null for unsupported Spark types, with the caller checking and rethrowing — a future caller forgetting the null-check would silently skip the field. Fix: translator throws UnsupportedOperationException directly with the offending type in the message.

  2. Reject TimestampType / DateType. Spark stores TimestampType as microseconds-since-epoch, while Pinot's broker convention is milliseconds-since-epoch (TimestampUtils#toMillisSinceEpoch). The prior translator silently produced values 1000× too large. Fix: translator rejects with a hint to cast upstream (col("ts").cast("long") / 1000); same for DateType. Documented in the spark-3 README as a breaking change.

  3. Reject ArrayType(BinaryType) / ArrayType(DecimalType). Pinot has no MV BYTES / MV BIG_DECIMAL writer support. Fix: rejected at translator with a clear error rather than failing deep in the segment driver.

Writer (PinotDataWriter)

  1. SupportsOverwrite[V2] silently dropped the overwrite intent. The writer advertised SupportsOverwrite (V1) / SupportsOverwriteV2 (V2) but ignored filters/predicates and returned an append-only writer. df.write.mode("overwrite") and df.writeTo(...).overwrite(...) callers believed matching rows would be replaced. Fix: overwrite(...), truncate(), and canOverwrite(...) all now reject. truncate() is load-bearing: SupportsOverwrite[V2] extends SupportsTruncate, and Spark's V2Writes rule dispatches df.write.mode("overwrite")truncate() (which defaulted to returning this → silent append) rather than overwrite([AlwaysTrue]).

  2. Per-partition temp-dir leak. Files.createTempDirectory in generateSegment() was never deleted, so long-running executors accumulated uncompressed segment trees under the JVM tmpdir. Fix: track the temp dir on a module-private field; wrap commit() in try/finally; also delete from abort() and close() (idempotent via null-out). closeOnce() wraps the reader close in try/finally so cleanup runs even if bufferedRecordReader.close() throws.

  3. PinotWrite.commit/abort used println. Fix: switched to SLF4J. Driver-side abort(messages) now best-effort deletes leftover segment tars at savePath for every SuccessWriterCommitMessage, tolerating null entries, unknown subclasses, and malformed/empty savePath URIs.

  4. ArrayData accessor correctness. record.getArray(idx).array.map(_.asInstanceOf[T]) only works on GenericArrayData (test scaffolding) and throws on UnsafeArrayData (real workloads); for StringType arrays it would ClassCastException on every row (UTF8String → String). Fix: use type-specific ArrayData.toIntArray() / toLongArray() / … accessors and explicit getUTF8String → toString conversion for StringType.

  5. Null-aware writer + null-safe time-column tracking. Spark's primitive accessors silently return 0 / false for null cells in UnsafeRow, and getString / getDecimal NPE on null. Fix: the per-field branch in internalRowToGenericRow checks record.isNullAt(idx) first and marks the field via addNullValueField instead of synthesizing zeros. The time-column-tracking branch in write() also honors isNullAt(timeColumnIndex) (one null cell would have collapsed startTime to 0, corrupting {startTime} / {endTime} in segmentNameFormat) and dispatches on the column's actual Spark type — record.getLong(idx) on an IntegerType slot in UnsafeRow reads 8 bytes from a 4-byte field and returns garbage.

  6. segmentNameFormat validation. Width spec on a non-numeric variable ({table:N}) previously crashed with ClassCastException at commit time; an unknown placeholder ({instanceId}) crashed with NoSuchElementException. Fix: both now fail fast at job submission with a clear IllegalArgumentException listing the supported placeholder names and noting that width spec is numeric-only.

  7. Add scalar DecimalType writer support. Schema translator already mapped to BIG_DECIMAL; writer now correctly reads via record.getDecimal(idx, precision, scale).toJavaBigDecimal.

Other

  1. PinotDataSource classpath-collision guard (Spark 4 only). Both v3 and v4 register the "pinot" short name; if both jars end up on a classpath, Spark resolves non-deterministically. The Spark 4 connector now probes for the v3 class on every constructor call (Class.forName is JVM-cached), checks both the module classloader and the thread context classloader, swallows LinkageError, and throws with a clear message instructing the user to remove one jar. Escape hatch: -Dpinot.spark.connector.skip-conflict-guard=true.

  2. PinotBufferedRecordReader.next(reuse). A defensive .copy() is preserved (an earlier round of review tried removing it for performance; the more recent review correctly noted that the underlying invariant — that no TransformPipeline transformer mutates a value object in place — is undocumented at the SPI level, so the deep copy is restored to defend against silent pass-2 corruption).

Regression tests for every numbered fix above are added to both pinot-spark-3-connector and pinot-spark-4-connector.

Test plan

  • ./mvnw -pl pinot-connectors/pinot-spark-4-connector,pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-spark-4 -am test on JDK 21 with Spark 4.1.1 — 56 spark-4-connector scalatest cases plus 3 batch-ingestion-spark-4 testng cases, all green.
  • ./mvnw -pl pinot-connectors/pinot-spark-3-connector -am test on JDK 21 — 54 scalatest cases, all green.
  • ./mvnw -pl pinot-integration-tests -am test-compile — integration tests still compile unchanged.
  • spotless:apply checkstyle:check license:check apache-rat:check clean on all modified modules.
  • Live end-to-end validation against apachepinot/pinot:latest + apache/spark:4.0.0 (+JDK 21): read 97,889-row baseballStats table, write a 10-row DataFrame to a fresh OFFLINE table, push via controller REST, verify roundtrip through both the Pinot SQL broker and the connector's own read path.

Known coverage gaps (tracked for follow-up)

  • End-to-end Spark 4 integration test — A SparkSegmentMetadataPushIntegrationTest equivalent for Spark 4 was added then dropped (commit f18309c95e) because the Spark 4 runtime (Jakarta Servlet 5) conflicts with pinot-integration-tests' Spark 3 + javax stack in a shared Maven module. Restoring end-to-end integration coverage requires either a dedicated pinot-spark-4-integration-tests module or failsafe with a separate classpath realm. Noted in the pinot-batch-ingestion-spark-4 README.
  • Push-runner unit testsSparkSegmentMetadataPushJobRunner / SparkSegmentTarPushJobRunner / SparkSegmentUriPushJobRunner have no unit tests in either the Spark 3 or the Spark 4 batch-ingestion module today; only SparkSegmentGenerationJobRunnerTest exercises the in-process driver path. Adding coverage is orthogonal to this PR and would benefit both modules.
  • PinotWrite.abort(...) partial-failure leftover tars — driver-side cleanup runs only for tasks that produced a SuccessWriterCommitMessage; tasks that copied a tar to savePath and then died before commit message return leave the tar behind. The contract-level guard against silent overwrite is provided by the overwrite/truncate rejection (Add a Gitter chat badge to README.md #9 above); this runtime-level cleanup is best-effort.

Notes for reviewers

  • Commons-lang3 shading — currently users must prepend commons-lang3:3.20+ on spark.{driver,executor}.extraClassPath. A follow-up PR can relocate org.apache.commons.lang3.* in the connector's shade config to eliminate this step.
  • Scala 2.12 — Apache Spark 4 is Scala 2.13 only. The modules have requireProperty scala.compat.version=2.13 enforcer rules so -Pscala-2.12 builds fail fast with a clear message.
  • -Ppinot-fastdev — activation is JDK-based, so -Ppinot-fastdev on JDK 21 also builds the Spark 4 modules. The Spark 4 shade is disabled under -Ppinot-fastdev via each module's own profile. Explicit opt-out: -P!pinot-spark-4-connector / -P!pinot-batch-ingestion-spark-4.
  • Local netty overridepinot-batch-ingestion-spark-4/pom.xml pins netty.version=4.2.7.Final so the embedded SparkContext in SparkSegmentGenerationJobRunnerTest can construct its NettyRpcEnv (Spark 4.1+ requires netty 4.2 APIs). The override is module-scoped; the rest of Pinot stays on netty 4.1.x.

🤖 Generated with Claude Code

@codecov-commenter

codecov-commenter commented Apr 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.10%. Comparing base (899e40d) to head (bec0f7f).

Additional details and impacted files
@@            Coverage Diff            @@
##             master   #18261   +/-   ##
=========================================
  Coverage     67.10%   67.10%           
+ Complexity     1424     1389   -35     
=========================================
  Files          3468     3468           
  Lines        222353   222353           
  Branches      34999    34999           
=========================================
+ Hits         149212   149215    +3     
- Misses        61224    61226    +2     
+ Partials      11917    11912    -5     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.10% <ø> (+<0.01%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.10% <ø> (+<0.01%) ⬆️
unittests 67.10% <ø> (+<0.01%) ⬆️
unittests1 57.86% <ø> (-0.02%) ⬇️
unittests2 38.85% <ø> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0 xiangfu0 left a comment

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.

Found a few high-signal correctness issues; see inline comments.

@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch 2 times, most recently from cc8bd0a to 5021c6b Compare April 23, 2026 10:08
@Jackie-Jiang Jackie-Jiang added ingestion Related to data ingestion pipeline feature New functionality labels Apr 24, 2026
@xiangfu0 xiangfu0 changed the title feat: add Apache Spark 4.x support (JDK 21+) feat(spark-4): add Apache Spark 4.x connector + batch-ingestion plugin (JDK 21+) Apr 24, 2026
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch 9 times, most recently from f5397f1 to 42f9fef Compare May 1, 2026 06:55
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch from 42f9fef to fc505f0 Compare May 7, 2026 22:58
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch 2 times, most recently from e2210c9 to 3a64048 Compare June 4, 2026 01:05
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch 7 times, most recently from eb8aeaf to e7c5276 Compare June 30, 2026 12:09
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch 2 times, most recently from 3cf4989 to 207ca6f Compare July 7, 2026 17:01
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch from 207ca6f to 5ae0b7f Compare July 18, 2026 09:15
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch from 5ae0b7f to 8aa17f2 Compare August 2, 2026 07:18
xiangfu0 and others added 29 commits August 27, 2026 02:14
Two MINOR items from the latest code review:

1. The PinotWriteBuilder Scaladoc claimed "the default truncate()
   returns this, so without an explicit override the silent-append
   bug is reachable via mode("overwrite") alone." Inspecting the
   pinned Spark versions (3.5.8 SupportsOverwrite and 4.0.0
   SupportsOverwriteV2) shows the default truncate() actually
   delegates to overwrite([AlwaysTrue]) — so the override above
   would already throw transitively, even without our explicit
   truncate() override. The explicit override is still good
   defense (a) for a tailored error message on the
   df.write.mode("overwrite") path and (b) against future Spark
   default-implementation changes. Update the comment to reflect
   this on both spark-3 and spark-4 builders.

2. The new abort() test files declared their imports inside the
   class body under a section comment. Move them to the top of
   the file alongside the existing imports, matching Pinot Scala
   convention (CLAUDE.md "Prefer imports over fully qualified
   class names").

No behavior change. Tests still 38 / 38 in both connectors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mantics

Three MAJORs from the latest review (the CRITICAL one — stale rebase —
was already addressed by this commit's parent rebase onto current
upstream/master).

1. escapeLikeLiteral(null) used to return null, which Scala's s""
   interpolation then renders as the four-char string "null". The
   defensive comment said this was "kept defensively so this helper
   produces well-formed SQL even if called via a future code path
   that does not gate on isFilterSupported" — but the actual emitted
   SQL would be `LIKE 'null%' ESCAPE '\'`, silently matching the
   literal string "null". Replace the silent-degradation branch with
   `require(value != null, ...)` so a future caller that bypasses
   isFilterSupported fails loudly instead.

2. PinotDataWriter had no class-level Javadoc note about thread
   safety, despite adding a new mutable `segmentOutputDir` field.
   Per CLAUDE.md the new module's classes should describe behavior
   and thread-safety. Add a paragraph explaining the Spark DataWriter
   single-task ownership invariant and the commit/abort/close
   ordering contract.

3. close()-after-abort idempotence relied on
   PinotBufferedRecordReader.close() being idempotent, which it
   currently is but the contract is not declared. Add a `closed`
   flag and consolidate abort()/close() into a private `closeOnce()`
   so future changes to the buffered reader don't silently break the
   writer's lifecycle. Existing regression test
   "close() after abort() is idempotent and does not throw" still
   passes.

Symmetric in both Spark 3 and Spark 4 connectors. Tests still pass at
38 / 38 in both modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer noted the LIKE pushdowns emit `ESCAPE '\'` which Pinot's
RequestContextUtils.toFilterContext currently ignores, leaving
RegexpPatternConverterUtils.likeToRegexpLike's hardcoded `\` as the
de-facto escape character. The pushdown is correct *only because* the
connector chose `\` to match Pinot's hardcoded value.

Add an inline comment near the LIKE clause documenting this implicit
contract, so a future refactor that switches the escape character on
either side can't silently produce wrong-row matches. Symmetric in
spark-3 and spark-4. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…spath

Reviewer flagged a MAJOR collision risk: pinot-spark-3-connector and
pinot-spark-4-connector both register the same `"pinot"` data-source
short name via META-INF/services. If both jars end up on the same
Spark application classpath (fat-jar bundle, --packages misuse, etc.),
Spark's DataSource.lookupDataSource resolves the format
non-deterministically — a Spark 4 user could silently get a writer
implementing the V1 SupportsOverwrite contract (or vice versa), with
diverging overwrite/truncate semantics that the symmetric overwrite/
truncate rejection in this PR cannot itself protect against.

Fix in pinot-spark-4-connector:

1. PinotDataSource (Spark 4) probes for the v3 class by name via
   Class.forName(...) — no compile-time dep is added between the
   modules. The probe runs once per JVM, cached in the companion
   object. If the v3 class is found, the v4 connector throws
   IllegalStateException with a tailored message instructing the
   user to remove one of the two jars.
2. README documents the mutual exclusion explicitly under a new
   "Mutual exclusion with pinot-spark-3-connector" section, including
   the guidance for fat-jar packagers.
3. Three new regression tests:
   - isSpark3ConnectorOnClasspath returns false in the default v4-only
     test classpath (defends against an accidental v3 module
     dependency creeping in)
   - PinotDataSource() constructor does not throw under the v4-only
     classpath
   - The spark3ConflictMessage text contains the actionable strings
     ("pinot-spark-4-connector", "pinot-spark-3-connector", "Remove")
     so a future docstring change can't quietly weaken the diagnostic.

The guard is intentionally one-sided (Spark 4 detects Spark 3, not the
other way around) because Spark 4 is the new arrival; existing Spark 3
users picking up the new v4 jar should hit the failure on their first
spark.read.format("pinot") call rather than after a successful write
that turned out to be wrong.

Test counts: 41 in spark-4 connector (was 38), 38 in spark-3 connector
(unchanged; the guard is v4-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer flagged a MAJOR race in the new classpath-collision guard:
the previous two-flag @volatile pattern flipped `spark3Probed = true`
*before* computing `spark3Conflict`, leaving a window where a second
thread could observe `spark3Probed=true` and `spark3Conflict=false`
(default) and skip the throw — the exact failure mode the guard was
meant to prevent.

Replace with a Scala `lazy val`. The compiler emits a synchronized
initialization barrier so every caller observes the fully-computed
result; there is no half-initialized window. This also simplifies the
guard to a single read.

Tests still pass: spark-4 41 / 41, spark-3 38 / 38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer flagged that Class.forName can throw NoClassDefFoundError /
LinkageError (in addition to ClassNotFoundException) when the v3 jar
is on the classpath but a transitive dependency is missing. The probe
previously only caught ClassNotFoundException, so such an error would
escape the PinotDataSource constructor with a message that doesn't
explain the conflict scenario.

Conservatively treat LinkageError as "v3 not present" — the worst
case is the guard is bypassed and the user falls back to Spark's own
multi-source error, which is still preferable to leaking a
bytecode-resolution failure with no context.

No behavior change in the dominant case (v3 absent or v3 fully
loadable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…own + abort

Five MAJORs from this round, applied symmetrically to spark-3 and spark-4 unless noted.

1. EqualNullSafe(_, null) and IN(_, array-with-null) silently leaked the literal Java
   string "null" into the rendered SQL via compileValue's fallback branch. Pinot would
   then parse `attr != null` syntactically rather than as a NULL test. Reject both at
   isFilterSupported so Spark evaluates them post-scan with proper three-valued logic.
   Compound gating (already in place) propagates the rejection through enclosing
   And/Or/Not.

2. escapeAttr previously passed through any column name containing a `"` unmodified.
   A name like `weird"col` then flowed into the SQL as raw, broken SQL — and a
   sufficiently adversarial catalog could exploit this as a SQL injection vector.
   Replace the brittle `contains("\\"")` heuristic with a regex that recognizes the
   already-escaped dotted-quoted form (`"col"."col"`) and otherwise wraps the name in
   `"..."` while doubling embedded quotes. Existing dotted-quoted callers (`"some"."nested"."column"`)
   still pass through unchanged; the new test pins the broken-input case.

3. PinotDataSource (spark-4) classpath-conflict probe used only `getClass.getClassLoader`,
   which is the v4 jar's own loader — in `--packages`/plugin-classloader Spark deployments
   the v3 jar can be visible to Spark's `DataSource.lookupDataSource` (which uses
   Thread.currentThread.getContextClassLoader) but invisible to v4's loader. Probe both
   loaders so the conflict guard cannot be silently bypassed.

4. PinotWrite.abort log fidelity. fs.delete returns false when the path doesn't exist,
   but the previous code logged "cleaned up leftover segment tar at {} (deleted=false)"
   at WARN — making "no leftover" indistinguishable from "deleted a real one" in
   production logs. Branch on the boolean: WARN only on actual deletions; INFO when no
   leftover existed; WARN with the exception when delete throws.

5. spark-3 PinotWriteBuilder previously silently appended on
   df.write.mode("overwrite"); the symmetric fix in this PR now throws
   UnsupportedOperationException. That is a backward-incompatible behavior change for
   any existing spark-3 user that depended on the (incorrect) silent-append. Add an
   explicit "Behavior changes since the previous release" callout at the top of
   pinot-spark-3-connector/README.md so the upgrade path is documented.

Six new regression tests across both connectors:
- EqualNullSafe(_, null) → post-scan
- IN(_, array-with-null) → post-scan; all-non-null IN still pushed down
- escapeAttr quotes a name containing a stray `"` correctly

Test counts: spark-4 now 44 (was 41), spark-3 now 41 (was 38).

Branch was also rebased onto current upstream/master (was 2 commits behind);
no conflicts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bort + classpath guard

Six MAJORs from the latest review, applied symmetrically v3+v4 unless noted.

1. EqualNullSafe / IN null-leaf rejection lacked compound-context test coverage. Added
   tests pinning that the rejection propagates through enclosing And/Or/Not (including
   nested), so a future change to the gate or the compound recursion can't silently
   re-introduce the null-leak through compound predicates.

2. compileFilter EqualNullSafe branch evaluated `compileValue(value)` three times and
   `escapeAttr(attr)` four times. Bind once; same emitted SQL but no divergence risk if
   either helper ever becomes effectful (per C5.10).

3. In(_, value) gate accepted patterns where `value` itself was null because the
   conditional `value != null && value.contains(null) => false` doesn't fire on null
   `value`, then `_: In => true` accepted, then compileFilter's `value.isEmpty` would NPE.
   Tighten to `value == null || value.contains(null) => false`. Test added pinning the
   null-array rejection.

4. Classpath-collision guard had no escape hatch. Add system property
   `pinot.spark.connector.skip-conflict-guard=true` that downgrades the throw to a
   one-time WARN. Default behavior unchanged. Documented in spark-4 README under the
   existing Mutual-exclusion section.

5. PinotWrite.abort() catches `case t: Throwable` swallowed Fatal errors (OOM,
   StackOverflow). Switch to `case NonFatal(t)` per Scala best practice. Symmetric in
   both connectors.

6. Spark 4 README lacked the "Write semantics / overwrite-rejection" callout that the
   spark-3 README has. Added under a "Write semantics" section before "Features" so
   first-time spark-4 users see the contract before reading the Quick Start.

Test counts: spark-4 now 46 (was 44), spark-3 now 43 (was 41). +2 regression tests
each side (compound-null-leaf propagation + In(_, null array) rejection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment

Reviewer noted (and verified against spark-catalyst_2.13-3.5.8.jar) that
the comment "SupportsOverwrite extends SupportsTruncate" is incorrect
for Spark 3.5.x — the actual chain is SupportsOverwrite extends
SupportsOverwriteV2 extends SupportsTruncate. The transitive
relationship is the same so the override rationale stands; this is a
docstring accuracy fix only.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…close() throws

Reviewer noted that closeOnce() set the closed flag and then called
bufferedRecordReader.close() before the temp-dir delete. If the
reader's close() ever throws (today PinotBufferedRecordReader is
guaranteed not to, but the contract is not declared — exactly the
rationale the existing comment cites for the closed flag), the closed
flag is already set and subsequent close()/abort() calls become
no-ops, leaving segmentOutputDir on disk.

Wrap the reader close in try/finally so the temp-dir cleanup always
runs. Symmetric in spark-3 and spark-4. No behavior change in the
current path; this is defensive hardening that matches the same intent
the closed flag itself encodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…guard

Reviewer pointed out that `lazy val spark3Conflict` is computed exactly
once per JVM at first construction, so a v3 jar added to the classpath
later in the same session (spark-shell `:require`, custom plugin loader,
post-startup mutation) won't be detected. Document the limitation in
the companion-object Javadoc and point users at the existing escape
hatch / JVM-restart workaround.

No behavior change; pure documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ark-3

Pinot's master branch raised the Java baseline to JDK 21 (PR apache#18046),
which means the spark-3 jars now compile to class file 65 and pull in
JDK-21-only pinot-core / pinot-common transitively. Spark 3.5.x users
on JDK 17 (the previously-common Spark 3.5 deployment) cannot load the
new jars at all — UnsupportedClassVersionError at first class
resolution.

Rather than reverting the JDK 21 baseline for these modules (which
would also require pinning pinot-core / pinot-common back to JDK 17,
undoing PR apache#18046's intent), declare pinot-spark-3-connector and
pinot-batch-ingestion-spark-3 deprecated for one release cycle:

- New users should adopt pinot-spark-4-connector /
  pinot-batch-ingestion-spark-4 (Spark 4.0.x + JDK 21).
- Existing Spark 3.5.x users on JDK 21 + Spark 3.5.5+ (the first
  Spark 3.5 patch with official JDK 21 support) can keep using the
  spark-3 jars during the deprecation window.
- Existing Spark 3.5.x users on JDK 17 stay on Pinot 1.5.x for the
  connector jar until they upgrade.

The next minor release will remove both spark-3 modules.

Changes:
- pinot-connectors/pinot-spark-3-connector/README.md — top-of-file
  deprecation banner with concrete runtime requirements + migration
  path to pinot-spark-4-connector.
- pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-spark-3/
  README.md — new file (the module previously had none) with the
  same deprecation banner and migration path for the batch ingestion
  runners.
- pom.xml — added a comment on the spark3.version property block
  recording the deprecation status so a future maintainer reading
  the pom sees it before bumping the version.

No code changes; pure documentation. Tests still pass at 46 / 43 in
spark-4 / spark-3 connectors respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…R imports

Two MINOR items from the latest review.

1. SupportsOverwrite[V2]#canOverwrite defaults to true, which advertises
   overwrite support that the connector then immediately rejects in
   overwrite()/truncate(). Override to false so Spark's V2Writes
   analyzer surfaces its own rejection earlier — and defends against a
   future analyzer change that might trust canOverwrite to gate
   dispatch to overwrite()/truncate(). Belt-and-braces; the existing
   throw paths continue to fire if Spark calls them anyway. Symmetric
   in spark-3 (SupportsOverwrite, Filter[]) and spark-4
   (SupportsOverwriteV2, Predicate[]). Regression tests added on both
   sides asserting both empty-array and non-empty-array calls return
   false.

2. PinotDataSource.scala (spark-4) declared LOGGER with the
   fully-qualified `org.slf4j.Logger` and `org.slf4j.LoggerFactory`
   types, inconsistent with the project's import-then-bare-name
   convention. Hoist to a top-of-file import.

Test counts: spark-4 47 (was 46), spark-3 44 (was 43).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l literal

EqualTo / LessThan(OrEqual) / GreaterThan(OrEqual) with a `null` literal value
were not gated by `isFilterSupported` and would render as `attr <op> null` via
`compileValue`'s fallback branch — Pinot would parse the literal token `null`
syntactically rather than as a Spark NULL. Catalyst usually constant-folds
these out upstream, but the connector now defensively rejects them so the
symmetric three-valued-logic guarantee already provided for `EqualNullSafe(_,
null)` and `IN(_, [..., null, ...])` holds for every comparison operator.
Compound gating ensures the rejection propagates to enclosing And/Or/Not.

Also tighten the `EqualNullSafe` null-check to use `if v == null` rather than
the `case _(_, null)` extractor for robustness against typed-null wrappers,
mirroring the new comparison-leaf rule.

Apply the same fix symmetrically to spark-3 and spark-4 with regression tests
in both `FilterPushDownTest.scala` files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ll-leaf comment

- Spark 4 README: drop the extra `pinot-connectors/` path segment from the
  links to `pinot-spark-3-connector` and `pinot-spark-common`. The README
  lives under `pinot-connectors/` already, so `..` already resolves there.
- End-to-end Docker tutorial: replace the non-existent `{timestamp}`
  placeholder with the actual `{startTime}` / `{endTime}` placeholders that
  `PinotDataWriter.getSegmentName` resolves.
- FilterPushDown null-leaf comment (v3 + v4): drop the misleading "Pinot
  parses literal `null` syntactically" framing — Pinot's Calcite-based
  parser actually treats lowercase `null` as SQL NULL. Reframe the
  rationale: `attr = NULL` etc. are SQL-equivalent to FALSE/UNKNOWN
  regardless, so pushing them down is at best a no-op, and rejecting
  defensively keeps the symmetric three-valued-logic fallback contract
  with EqualNullSafe / IN intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r throw

Five MAJOR correctness fixes flagged by review, applied symmetrically to
spark-3 and spark-4:

1. internalRowToGenericRow array branch: replace `record.getArray(idx).array`
   with ArrayData's type-specific accessors (toIntArray, toLongArray, …).
   `.array` only works on GenericArrayData (test scaffolding) and throws on
   the UnsafeArrayData that Spark uses in real workloads. The previous
   `.array.map(_.asInstanceOf[T])` would ClassCastException at runtime, with
   a particularly bad failure for StringType (stored as UTF8String, not
   String). String arrays now go through getUTF8String + toString.

2. getSegmentName: handle non-numeric variables under width spec. The
   previous `value.asInstanceOf[Number]` cast crashed `{table:N}` with
   `String cannot be cast to java.lang.Number`, failing the whole write
   task at commit time. Numeric variables (`partitionId`, `startTime`,
   `endTime`) keep `%Nd`; non-numeric ones (`table`) use `%Ns`. The Javadoc
   advertises `{partitionId:05}` alongside `{table}`, so a user reasonably
   might try `{table:NN}`.

3. Add scalar writer cases for TimestampType, DateType, DecimalType so the
   schema translator (which already accepts them and maps to LONG / INT /
   BIG_DECIMAL) and the writer agree. Previously the schema built but every
   write task immediately failed with "Unsupported data type: TimestampType".

4. SparkToPinotTypeTranslator throws on unknown Spark types instead of
   returning null. The previous null-return + caller-side rethrow split the
   error site and risked silent propagation if a future caller forgot the
   null-check. Direct throw makes the offending type appear in the stack
   trace at the actual translation site.

5. Reject ArrayType(BinaryType) and ArrayType(DecimalType) at the translator
   — Pinot has no multi-value BYTES or BIG_DECIMAL writer support, so
   accepting these would have failed deep in the segment driver instead of
   at translation time with a clear message.

Add regression tests:
- PinotDataWriterTest: `{table:20}_{partitionId:03}` width-spec format case
- SparkToPinotTypeTranslatorTest: drop ArrayType(BinaryType) /
  ArrayType(DecimalType) from the accepted-mapping list, add explicit
  rejection tests for those plus MapType / StructType / NullType.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…be; add LIKE contract test

Five MAJOR correctness fixes flagged by review, applied symmetrically to
spark-3 and spark-4:

1. getSegmentName: reject `{table:N}` (and any width spec on a non-numeric
   variable) at job submission with an IllegalArgumentException. The
   previous fix mapped non-numeric width-spec to `%Ns`, but %Ns produces
   leading whitespace inside the segment name — path-hostile for Hadoop FS,
   controller URLs, and listings. The Javadoc never advertised width on
   `{table}`, so reject is safer than silently producing ugly filenames.

2. SparkToPinotTypeTranslator: reject TimestampType (microseconds-since-
   epoch in Spark) and DateType (days-since-epoch) instead of mapping
   straight to LONG/INT. Pinot's broker convention for time columns is
   millis-since-epoch (TimestampUtils#toMillisSinceEpoch); naively
   accepting these types would silently produce wrong-by-1000 timestamps.
   The error message instructs the user to cast upstream:
   `df.withColumn("ts", col("ts").cast("long") / 1000)`. Drop the writer
   cases for these types since they are no longer reachable from the
   translator.

3. PinotBufferedRecordReader.next(reuse): drop the per-record `.copy()` in
   the segment-build hot path. SegmentIndexCreationDriverImpl reads each
   record across two passes and does not mutate observed rows; the
   defensive deep-clone allocated 2N transient GenericRow objects for an
   N-record segment build (GC pressure on the executor) without protecting
   anything observable.

4. PinotDataSource.spark3Conflict: switch from a one-shot lazy val to a
   per-call def. Class.forName is JVM-cached after first lookup so the
   per-call cost is negligible — and probing per call detects v3 jars
   added to the classpath after the first PinotDataSource was constructed
   (e.g. via spark-shell `:require` or a custom plugin loader), which the
   lazy val explicitly couldn't.

5. FilterPushDownTest: add a regression test that takes the LIKE patterns
   emitted by FilterPushDown.compileFilter and runs them through Pinot's
   own RegexpPatternConverterUtils.likeToRegexpLike, then verifies the
   resulting regex (evaluated via Matcher#find, mirroring Pinot's runtime
   behavior in RegexpLikeConstFunctions) matches the original literal
   value. This pins the cross-module `\` escape contract that was
   previously documented only by comment, so a future change on either
   side fails the build instead of producing silently wrong WHERE-clause
   results in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lter literals

Two MAJOR correctness fixes from review (both pre-existing, hardened now):

1. PinotDataWriter.internalRowToGenericRow: honor `record.isNullAt(idx)`
   before calling typed accessors. Spark's primitive accessors silently
   return zero for null cells (`getInt`→0, `getLong`→0L, `getBoolean`→
   false), corrupting the segment with synthetic zeros. `getString` and
   `getDecimal` would NPE. Mark the field null on GenericRow via
   addNullValueField so the segment driver applies the column's
   defaultNullValue per Pinot's null-handling contract.

2. FilterPushDown: tighten leaf filters to reject literal values that
   are not in {String, Number, Boolean, Timestamp, Date, byte[]}. Previous
   compileValue catchall rendered Seq/List/Vector/Map/etc. via
   `value.toString` — for `EqualTo("a", Seq(1,2))` that produces
   `attr = List(1, 2)`, malformed SQL Pinot would either reject or
   misparse. Drop the test case `EqualTo("attr21", Seq(1, 2)) → "attr21"
   = List(1, 2)` that pinned the broken output, and add a regression test
   `Filters with collection-shaped or unrecognized literal values fall
   back to post-scan` that verifies Seq / Map / Vector / IN-with-Seq-
   elements all route to Spark's residual-filter path.

Apply symmetrically to spark-3 and spark-4. Also fix a MINOR raw-type
warning in pinot-batch-ingestion-spark-4 (`new ArrayList()` → `new
ArrayList<>()`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…regression tests

Two MAJOR fixes from review (one a follow-on to the prior null-aware writer
commit):

1. PinotDataWriter.write: time-column tracking now (a) honors
   `record.isNullAt(timeColumnIndex)` so a single null cell no longer
   silently collapses startTime to 0 and corrupts `{startTime}` /
   `{endTime}` in segmentNameFormat, and (b) dispatches on the column's
   actual Spark type — `record.getLong(idx)` on an IntegerType slot in
   UnsafeRow reads 8 bytes from a 4-byte field and returns garbage from
   the next slot. Same root cause as the writer-side synthetic-zero bug,
   but in the segment-name-tracking branch.

2. Add regression tests in PinotDataWriterTest that exercise the prior
   null-aware writer fix (null cells in nullable String/Int/Long columns
   propagate as null + addNullValueField, not synthetic zeros) and the
   new time-tracking fix (IntegerType time column with one null and one
   populated row produces the correct segment-name placeholders).

Plus two MINOR cleanups:
- FilterPushDown.isPushableValue: drop Array[Byte] from the whitelist
  since compileValue has no `bytes → X'<hex>'` rendering branch — gating
  it through would render `attr = [B@<hashcode>` SQL.
- PinotInputPartition: add a thread-safety note in the scaladoc
  documenting the immutable-after-construction contract that Spark's
  driver→executor serialization relies on.

Apply symmetrically to spark-3 and spark-4 per C8.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three MINOR cleanups from review (no CRITICAL or MAJOR):

1. PinotDataWriter.write time-column match: add a defensive `case other =>
   IllegalStateException(...)` so a future contributor extending
   `isTimeColumnNumeric` to ShortType/ByteType/TimestampType without
   updating this match gets a clear error instead of MatchError mid-task.

2. PinotDataWriter.getSegmentName: use Map#getOrElse with an explicit
   IllegalArgumentException listing the supported placeholder names,
   instead of a `NoSuchElementException: key not found` that surfaced
   only at commit() time after the segment was already built.

3. PinotDataWriterTest "Time-column tracking is null-safe and
   type-correct for IntegerType time columns": now converts the populated
   row through UnsafeProjection. Without this, the test passed even with
   only the isNullAt half of the previous fix, because GenericInternalRow
   stores boxed Integer and getLong returns the int value correctly. The
   UnsafeRow path is what actually exercises the type-dispatch fix —
   without it, getLong would read 8 bytes from a 4-byte slot and the
   segment-name placeholders would not match.

Apply symmetrically to spark-3 and spark-4 per C8.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ument type-rejection

Two MAJOR fixes from review:

1. PinotBufferedRecordReader.next(reuse): restore the deep `.copy()` that
   was removed in an earlier commit as a perf optimization. The
   optimization relied on an undocumented assumption — that no transformer
   in SegmentIndexCreationDriverImpl's TransformPipeline mutates a
   GenericRow value (Object[], byte[], List, ...) in place. The mainline
   transformers replace via putValue, but the RecordReader#next(reuse)
   SPI does not declare this precondition, so any future transformer
   change could silently corrupt pass-2 segment build via the shared
   reference into recordBuffer. The defensive copy isolates the two
   passes at the cost of 2N transient GenericRow allocations during
   segment build — acceptable given the silent-corruption risk if the
   assumption breaks.

2. spark-3 README: document the new type-translator rejections
   (TimestampType / DateType / ArrayType(BinaryType) /
   ArrayType(DecimalType)) in the "Behavior changes since the previous
   release" section. Previously only the overwrite/truncate failure was
   documented. Existing pipelines that wrote TimestampType columns will
   now fail at translation time on upgrade — surface the upstream-cast
   recipe (`col("ts").cast("long") / 1000` for micros→millis) so users
   can validate before bumping the connector jar.

Apply symmetrically to spark-3 and spark-4 per C8.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrade spark4.version from 4.0.0 to 4.1.1. Spark 4.1+ depends on
io.netty 4.2.x (introduces io.netty.channel.kqueue.KQueueIoHandler and
related APIs); the rest of Pinot stays on netty 4.1.132. Pin the
batch-ingestion-spark-4 module's local netty.version to 4.2.7.Final so
the embedded SparkContext in SparkSegmentGenerationJobRunnerTest can
construct its NettyRpcEnv on macOS (kqueue) and Linux (epoll). The
spark-4-connector module needs no override (it doesn't instantiate a
real SparkContext in tests).

Also pin the test SparkConf to 127.0.0.1 driver bind address — Spark
4.1's stricter address binding fails with "Can't assign requested
address" on dev hosts where the system hostname does not resolve back
to a local interface.

Update README compatibility matrix (4.0.x → 4.1.x), the end-to-end
Docker tutorial (apache/spark:4.1.1-scala2.13-java21-python3-ubuntu is
published with a JDK 21 variant, no custom build needed), and a
Spark-4.0.0-specific docstring in PinotWriteBuilder.

All 56 spark-4-connector tests + 3 batch-ingestion-spark-4 tests pass
against Spark 4.1.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…al classpath guard

After rebase onto upstream/master (fixes the CRITICAL "branch reverts unrelated
master commits" finding by absorbing apache#18335, apache#18285, apache#18341, apache#18340), close the
remaining MAJOR review gaps:

1. PinotDataSource classpath conflict guard, both connectors:
   - Treat LinkageError as a conflict, not as "v3 absent". A linkage failure
     means the v3 class IS resolvable to the loader but cannot run — the case
     the guard exists for. Falling back to "absent" would let the
     non-deterministic-resolution failure mode the guard is supposed to
     prevent slip through. Bias to fail-closed; users who genuinely need both
     jars can still set -Dpinot.spark.connector.skip-conflict-guard=true.
   - Add a symmetric guard to pinot-spark-3-connector that probes for the v4
     PinotDataSource class. The conflict can now be caught regardless of
     which connector Spark instantiates first.

2. PinotWriteBuilder (spark-3): keep a deprecated 2-arg constructor
   `PinotWriteBuilder(filters: Array[Filter], logicalWriteInfo: LogicalWriteInfo)`
   so the constructor-signature change does not silently break external
   embedders mid-deprecation. The `filters` parameter is ignored at build
   time and overwrite()/truncate() still throw — purely a binary-compat
   shim during the spark-3 sunset window.

3. pinot-batch-ingestion-spark-4 netty.version override: expand the property
   comment to call out that the override propagates into the production
   shaded jar (not just the test classpath), document validated
   spark4.version pairings, and add SparkVersionAlignmentTest that pins
   netty 4.2.x on the test classpath via Class.forName(KQueueIoHandler) +
   getImplementationVersion. A future spark4.version bump that shadows
   netty back to 4.1 (or forward to 4.3) now fails the build immediately.

4. FilterPushDownTest (both connectors): add an explicit regression test
   for `In(attr, Array.empty)` — the empty-IN compile path was previously
   covered only by the umbrella test and would not have surfaced a
   regression cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pinot's root pom now enforces JDK 21+ via maven-enforcer (requireJavaVersion
[21,)), so the JDK-21 profile gating that previously kept the Spark 4 modules
out of the reactor on JDK 11/17 is redundant — every build runs on a JDK that
can compile and load class file 65. Move both Spark 4 modules into their
parents' default <modules> list:

  - pinot-connectors/pom.xml: pinot-spark-4-connector now alongside the
    deprecated pinot-spark-3-connector and the rest.
  - pinot-plugins/pinot-batch-ingestion/pom.xml: pinot-batch-ingestion-spark-4
    likewise alongside its deprecated spark-3 sibling.

Drop the now-redundant local enforcer rules (root pom already enforces JDK 21)
and the local <jdk.version>21</jdk.version> property overrides. The Scala 2.13
requireProperty enforcer rule is preserved on both spark-4 modules so a build
under -Pscala-2.12 fails fast with a clear message instead of an obscure
Maven resolution error.

In pinot-distribution/pinot-assembly.xml, replace the JDK-conditional fileSet
that picked up the spark-4 batch-ingestion shaded jar with a regular <file>
entry mirroring the spark-3 layout. The fileSet was a workaround for the
module sometimes not being in the reactor; now the module always is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two MAJOR + one MINOR review finding from the default-build conversion:

1. Root pom.xml: the explanatory comment on `<spark4.version>` still claimed
   the modules were registered "only under a JDK-21+ profile activation."
   That stopped being true with f5053ab. Reword to reflect the current
   state: modules build unconditionally because the root pom enforces JDK
   21+ via requireJavaVersion.

2. pinot-distribution/pinot-assembly.xml: replacing the previous <fileSet>
   with a top-level <file> entry made the bin-dist build fail hard if the
   pinot-batch-ingestion-spark-4 jar is missing on disk — which happens
   under `-P!pinot-batch-ingestion-spark-4` (the documented opt-out path
   referenced in the parent pom). Restore the <fileSet> form so the
   bin-dist silently skips the jar when absent. The comment on the new
   <fileSet> documents the rationale explicitly so future contributors do
   not "simplify" it back to a <file>.

3. SparkVersionAlignmentTest: the trailing assertion `assertEquals(
   nettyCommon != null, true)` was unreachable — Class.forName throws on
   absence, so a successful return is already sufficient evidence.
   Replace with assertNotNull as a courtesy belt-and-suspenders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ersion test

Three review findings on the prior "default-build" commit:

1. pinot-distribution/pinot-assembly.xml: revert to <file> entry (symmetric
   with spark-3 and every other batch-ingestion plugin). The earlier <fileSet>
   form was justified by an opt-out profile (-P!pinot-batch-ingestion-spark-4)
   that does not exist in the repo — bare `-P!<module-name>` is silently
   ignored by Maven, so the citation was wrong. Singling out spark-4 with
   tolerant packaging while every sibling fails hard if absent created an
   asymmetric and worse-than-fail-loud contract: a missing plugin jar that
   silently vanishes from the bin-dist is a worse failure mode than an
   assembly error pointing at the missing path. The spark-4 entry is now
   placed inside the same Start/End "Pinot Batch Ingestion Plugins" block
   as the rest, restoring local grouping.

2. pom.xml comment: "enforced cluster-wide" → "enforced at build time" —
   requireJavaVersion runs at build, not on the runtime cluster.

3. SparkVersionAlignmentTest: the prior `assertNotNull` on a
   Class.forName-returned class was unreachable (Class.forName throws on
   absence). Replace with an actual netty-common version assertion that
   pins 4.2.x — closing the original gap the test is supposed to defend
   against (a partial mix of netty 4.1 and 4.2 jars at runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st; v3 guard tests

Four MAJOR review findings on the latest "default-build" iteration:

1. PinotDataSource classpath probe (both connectors): cache the (ownLoader,
   ctxLoader) → result mapping in a ConcurrentHashMap. The probe was running
   on every `spark.read.format("pinot")…` invocation; Spark instantiates
   PinotDataSource repeatedly during a job (driver + executors + planner)
   and a per-call probe burns ~20µs each. Caching by classloader pair keeps
   correctness under Spark's isolated-executor-classloader topology (each
   executor's first probe still runs); the only deployment we cannot detect
   is dynamic classpath mutation in a single JVM after the first probe,
   which the existing -Dpinot.spark.connector.skip-conflict-guard escape
   hatch already covers.

2. SparkVersionAlignmentTest: switch from manifest-based
   Package#getImplementationVersion (which can be null in IDE classpaths
   and silently passed the test) to io.netty.util.Version#identify(), which
   cross-checks every netty module's artifactVersion. The test now
   actually catches the failure mode it was designed for: a mixed-version
   netty classpath where some modules are 4.1 and some are 4.2.

3. pinot-batch-ingestion-spark-4 pom comment: the prior version claimed the
   shaded jar carries netty 4.2.x at runtime — wrong, because spark-core is
   `provided`-scoped and netty does not ship with the shaded plugin. Reword
   to accurately describe what the override achieves: test-classpath
   netty 4.2.x for embedded SparkContext, plus compile-time resolution of
   netty 4.2-only APIs the production sources reference; production runtime
   relies on the user's Spark 4 cluster to supply 4.2.x.

4. PinotDataSourceRegistrationTest (spark-3): add the symmetric negative-
   case probe tests that the v4 sibling already has. Without these, future
   refactors of the v3-side guard would only catch regressions on v4.

Pre-existing per-row `zipWithIndex` allocation in `internalRowToGenericRow`
flagged by review as a perf optimization is left for follow-up — review
itself classified it as non-blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's `Pinot Linter Test Set` job runs `mvn enforcer:enforce -Pdependency-verifier`,
which invokes `PinotCustomDependencyVersionRule`. The rule rejects every inline
<version> tag in non-root POMs (best practice: pin in root <dependencyManagement>
and inherit). The spark-4 modules previously declared inline versions for:

  - jakarta.servlet:jakarta.servlet-api (5.0.0 — Jakarta EE 9 namespace, Spark 4)
  - org.apache.spark:spark-core_2.13 (${spark4.version} = 4.1.1)
  - org.apache.spark:spark-launcher_2.13 (${spark4.version})
  - org.apache.spark:spark-sql_2.13 (${spark4.version}, in scala-build profile)

Fix by overriding the corresponding root-pom-managed properties locally:

  - jakarta.servlet-api.version=5.0.0  (root pom: 4.0.4 javax.* — DO NOT UPGRADE
    for the rest of the build; Spark 4 needs Jakarta EE 9 jakarta.* — module-
    scoped override is safe)
  - spark3.version=${spark4.version}   (the property name is a misnomer here,
    but the alternative — forking the root <dependencyManagement> block or
    redeclaring each spark-* artifact — is worse. Pinot's spark-base /
    spark-3 / spark-4 modules all consume spark-* via the same managed key,
    so the property override resolves the inherited <dependencyManagement>
    to 4.1.1 for these modules only)

The dependency declarations in both spark-4 module poms now omit <version>
entirely, matching the spark-3 sibling and every other Pinot module. CI's
PinotCustomDependencyVersionRule passes for both modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two minor user-facing accuracy fixes flagged by final pre-merge review:

1. pinot-spark-4-connector/README.md: drop the reference to the now-removed
   `pinot-spark-4-connector` profile in `pinot-connectors/pom.xml` — the
   module is built unconditionally as part of the default reactor since
   Pinot's root pom enforces JDK 21+ build-wide.

2. pinot-spark-3-connector/README.md: "Spark 4.0.x" → "Spark 4.1.x" to
   match the actual spark4.version=4.1.1 the connector compiles against.

Doc-only; no code, behavior, or test changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xiangfu0
xiangfu0 force-pushed the claude/musing-neumann-2a8e62 branch from 0856c8d to f51de13 Compare August 27, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

buildAndPackage Related to build system and packaging feature New functionality ingestion Related to data ingestion pipeline plugins Related to the plugin system release-notes Referenced by PRs that need attention when compiling the next release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants