Skip to content

fix: don't return garbage values instead of null under arrow.enable_null_check_for_get=false#187

Merged
mkaufmann merged 6 commits into
mainfrom
fix/null-check-for-get-iceberg
May 22, 2026
Merged

fix: don't return garbage values instead of null under arrow.enable_null_check_for_get=false#187
mkaufmann merged 6 commits into
mainfrom
fix/null-check-for-get-iceberg

Conversation

@mkaufmann
Copy link
Copy Markdown
Member

@mkaufmann mkaufmann commented May 13, 2026

Summary

When Iceberg is on the classpath it sets -Darrow.enable_null_check_for_get=false on the JVM for performance. With that flag off, Arrow's VarCharVector, VarBinaryVector, FixedSizeBinaryVector, and TimeStamp*Vector getters skip the validity check and return stale buffer bytes (often empty, but not guaranteed) for null rows instead of null. A SQL NULL surfaced as "" / 0 / a sentinel timestamp, and wasNull() returned false. For example SELECT NULL::timestamp / timestamptz returned 1970-01-01T00:00:00Z (or the 4714-12-31 sentinel for getObject); now - with the fix -returns null.

Behavior under the flag's default (true) is unchanged.

Approach

Every accessor sources wasNull from vector.isNull(int) rather than from holder.isSet or a return-value sniff. isNull reads the validity buffer directly via BitVectorHelper — there's no per-row work for the flag to disable, so it is flag-proof.

This is a wider migration than the bug strictly requires. The flag-gating is currently only on VarChar/Binary/FixedSizeBinary/TimeStamp* in Arrow 17, but Arrow's contract ("skip the validity check on get") authorizes future minor versions to extend the gating to any vector type. Sourcing wasNull uniformly from isNull insulates us from any Arrow upgrade — including upgrades in consumer setups, where a tripwire test in this repository wouldn't fire.

DataCloudArray.extractDataFromVector had the same bug for VarChar/Binary/TimeStamp inner element types; it now consults dataVector.isNull(absIndex) before each getObject call.

Test plan

arrow.enable_null_check_for_get=false is set in buildSrc/src/main/kotlin/java-base-conventions.gradle.kts tasks.withType<Test>().configureEach, so every test task across the build runs under the Iceberg scenario.

  • Verified existing coverage catches the bug: with only the gradle flag and accessor sources reverted to main, 25 tests fail across 10 classesJDBCReferenceTest, VarCharVectorAccessorTest, BinaryVectorAccessorTest, TimeStampVectorAccessorTest, DataCloudArrayTest, TimeZoneIntegrationTest, plus metadata/connection paths asserting expected: null but was: "".

@mkaufmann mkaufmann force-pushed the fix/null-check-for-get-iceberg branch from bbfddc7 to e3c304c Compare May 22, 2026 06:36
Copy link
Copy Markdown
Member Author

@mkaufmann mkaufmann left a comment

Choose a reason for hiding this comment

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

Review summary — 7 findings (3 Blocking · 1 Suggested · 1 Optional summary-only; 2 inline). Findings 1–5 are summary-only because they reference files outside the diff hunks; 6 and 7 are inline.


Blocking

1. PR body claims :jdbc-core:test passes with the fix, but 10 tests still fail at PR HEAD.

Running ./gradlew :jdbc-core:test --rerun-tasks against e3c304c produces 10 failures: 3× TimeStampVectorAccessorTest (testTimeStampMilliVector, testTimeStampMilliTZVector, testGetObjectWithUnsupportedType), 3× TimeZoneIntegrationTest (testInvalidTimezoneHandling, nullTimestampTZParameterStoresSqlNull, testGetObjectEqualsGetTimestampexpected: null but was: 4714-12-31T23:52:58.000), 2× JDBCReferenceTest (select NULL::timestamp, select NULL::timestamptz), 1× DataCloudArrayTest::testGetArrayWithIndexPlusCountExceedingArraySize, 1× DataCloudConnectionFunctionalTest::testNetworkTimeoutPropagatesToServer. Either the diff is incomplete or the test-plan claim is wrong. Findings 2 and 3 break this down.

2. Timestamp accessors are still broken under flag-off — fix is incomplete.
jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/TimeStampVectorAccessor.java

The PR body states that TimeStamp and TimeStampTZ "read holder.isSet, populated by vector.get(int, holder) which always honors validity regardless of the flag". The 6 failing TimeStamp tests above contradict that — null TIMESTAMP/TIMESTAMPTZ rows surface as 1970-01-01T00:00:00Z or 4714-12-31T23:52:58.000 (sentinel). TimeStampVectorGetter.createGetter does use the holder pattern the body cites as safe, but for TimeStamp* vectors that pattern is not actually flag-independent in Arrow 17. Apply the same IntPredicate nullChecker pattern to TimeStampVectorAccessor and TimeStampTZVectorAccessor — source wasNull from vector.isNull(row) rather than holder.isSet. Tighten the body's "all holders are validity-correct" claim to "all non-TimeStamp holders".

3. DataCloudArray exposes Iceberg-mode garbage for null elements inside VarChar/VarBinary/FixedSizeBinary/TimeStamp arrays.
jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/DataCloudArray.java:61

extractDataFromVector calls dataVector.getObject(absIndex) per element. For VarCharVector / VarBinaryVector / FixedSizeBinaryVector (and the TimeStamp* variants per finding 2), getObject is also flag-gated under flag-off. DataCloudArrayTest::testGetArrayWithIndexPlusCountExceedingArraySize fails at PR HEAD with expected: null but was: "" — the bug philosophy of this PR ("source wasNull from the validity buffer") applies here too. Replace dataVector.getObject(absIndex) with a validity-aware lookup: if dataVector.isNull(absIndex) set the slot to null, otherwise call getObject.

Suggested

4. *FromNulled* accessor tests never assert wasNull() was set correctly.
jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/accessor/impl/VarCharVectorAccessorTest.java:65 (and the equivalents in BinaryVectorAccessorTest)

The fix's full contract is "getBytes / getString / getObject set wasNull=true on a null row even when arrow.enable_null_check_for_get=false". testGetStringGetObjectAndGetObjectClassFromNulledVarCharVector and the binary equivalents only assert that the returned value is null. wasNull() is set as a side effect of getBytes() in production code, but the tests never read it back — QueryJDBCAccessorAssert.wasNull() exists and is unused. A regression that fixed the return value but broke wasNull would slip past the unit tests. Append a wasNull assertion after each getter call in each *FromNulled* test — one line per test.

Optional

5. nulledOutVector reproduces a degenerate variant, not the realistic Iceberg failure mode.
jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/util/RootAllocatorTestExtension.java:150-153

nulledOutVector calls vector.clear() then setValueCount(original). clear() releases the buffers; setValueCount then allocates fresh zeroed buffers, so vector.get(row) reads start=end=0 and returns an empty byte[] — enough to make the current *FromNulled* tests fail without the fix, but not the production failure mode. The realistic case is "row N is valid (writes real bytes), row N+1 is set null via setNull(N+1) (validity bit cleared, buffer bytes left over), getter returns N's stale bytes for N+1". Add a parameterised test that builds a vector with mixed valid/null entries via setSafe + setNull and walks the accessor across all rows asserting null / wasNull()==true for the null rows. Pins the realistic Iceberg scenario.


Generated by the review-pr-tavern skill — a human did not write this comment.

Comment thread jdbc-core/build.gradle.kts Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

❌ Patch coverage is 96.96970% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.95%. Comparing base (1dd30e9) to head (6426a2c).

Files with missing lines Patch % Lines
...dbc/core/accessor/impl/BaseListVectorAccessor.java 60.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #187      +/-   ##
============================================
+ Coverage     80.78%   80.95%   +0.17%     
+ Complexity     1727     1723       -4     
============================================
  Files           123      123              
  Lines          5011     5042      +31     
  Branches        528      523       -5     
============================================
+ Hits           4048     4082      +34     
+ Misses          730      728       -2     
+ Partials        233      232       -1     
Components Coverage Δ
JDBC Core 80.98% <96.96%> (+0.22%) ⬆️
JDBC Main 57.42% <ø> (ø)
JDBC HTTP 90.55% <ø> (ø)
JDBC Utilities 65.25% <ø> (ø)
Spark Datasource ∅ <ø> (∅)
Files with missing lines Coverage Δ
...jdbc/core/accessor/impl/BaseIntVectorAccessor.java 85.45% <100.00%> (+0.54%) ⬆️
.../jdbc/core/accessor/impl/BinaryVectorAccessor.java 100.00% <100.00%> (ø)
...jdbc/core/accessor/impl/BooleanVectorAccessor.java 100.00% <100.00%> (ø)
...acloud/jdbc/core/accessor/impl/DataCloudArray.java 97.87% <100.00%> (ø)
...ud/jdbc/core/accessor/impl/DateVectorAccessor.java 92.50% <100.00%> (+1.07%) ⬆️
...jdbc/core/accessor/impl/DecimalVectorAccessor.java 100.00% <100.00%> (ø)
.../jdbc/core/accessor/impl/DoubleVectorAccessor.java 77.41% <100.00%> (+0.75%) ⬆️
...d/jdbc/core/accessor/impl/FloatVectorAccessor.java 77.41% <100.00%> (+0.75%) ⬆️
.../core/accessor/impl/TimeStampTZVectorAccessor.java 95.29% <100.00%> (+0.11%) ⬆️
...bc/core/accessor/impl/TimeStampVectorAccessor.java 93.58% <100.00%> (+0.16%) ⬆️
... and 3 more

... and 1 file with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mkaufmann mkaufmann changed the title fix: source wasNull from validity buffer in VarChar/Binary accessors fix: return null for SQL NULL under arrow.enable_null_check_for_get=false (Iceberg) May 22, 2026
@mkaufmann mkaufmann changed the title fix: return null for SQL NULL under arrow.enable_null_check_for_get=false (Iceberg) fix: don under arrow.enable_null_check_for_get=false (Iceberg) May 22, 2026
@mkaufmann mkaufmann changed the title fix: don under arrow.enable_null_check_for_get=false (Iceberg) fix: don't return garbage values instead of null under arrow.enable_null_check_for_get=false (Iceberg library default) May 22, 2026
@mkaufmann mkaufmann changed the title fix: don't return garbage values instead of null under arrow.enable_null_check_for_get=false (Iceberg library default) fix: don't return garbage values instead of null under arrow.enable_null_check_for_get=false May 22, 2026
@mkaufmann mkaufmann marked this pull request as ready for review May 22, 2026 12:30
mkaufmann added 6 commits May 22, 2026 21:23
Iceberg sets -Darrow.enable_null_check_for_get=false on the JVM for performance.
With that flag off, Arrow's VarCharVector/VarBinaryVector/FixedSizeBinaryVector
.get(int) skip the validity check and return stale buffer bytes (typically an
empty byte[]) for null rows instead of null. Both accessors were inferring
wasNull from the returned value, so a SQL NULL came through as an empty string
and wasNull() returned false.

Consult vector::isNull directly. Run :jdbc-core:test with the flag off so the
existing nulled-vector tests cover the Iceberg scenario.
…turn

The Iceberg flag (arrow.enable_null_check_for_get=false) only affects the
typed primitive vector.get(int) accessors on VarCharVector/VarBinaryVector/
FixedSizeBinaryVector. DecimalVector.getObject and ListVector/
LargeListVector.getObject unconditionally consult isSet, so the
return-value-sniff pattern is safe here. Note that contract inline so the
asymmetry with the just-fixed VarChar/Binary accessors doesn't read as a bug.
The earlier fix only covered VarChar/Binary because Arrow 17 only flag-gates
the VarChar/Binary/FixedSizeBinary/TimeStamp* paths. The contract Arrow
exposes is "skip the validity check on get", which authorises future minor
versions to extend the gating to any vector type without breaking the API.
A partner that updates Arrow on its own classpath would then silently
regress against jars built here.

Switch every accessor to source wasNull from vector.isNull(int), which
reads the validity buffer directly and is not flag-gated. Covers TimeStamp,
TimeStampTZ, BaseInt, Boolean, Float, Double, Date, Time, Decimal, the List
accessors, and DataCloudArray's per-element extraction.
Each accessor was holding an IntPredicate that always pointed at the same
vector's isNull. Inline `vector.isNull(row)` and store the vector directly,
matching how Boolean/Float/Double already did it.
Move arrow.enable_null_check_for_get=false from :jdbc-core's tasks.test to
the shared java-base-conventions tasks.withType<Test>().configureEach block,
so jdbc, integration-test, jdbc-http, spark-datasource-core, and
spark-datasource exercise the Iceberg scenario too. ./gradlew test passes
across the full stack.

Tighten the VarCharVectorAccessor comment to match BinaryVectorAccessor's
wording — the buffer bytes between offsets aren't guaranteed empty.
@mkaufmann mkaufmann force-pushed the fix/null-check-for-get-iceberg branch from 5f32a6f to 6426a2c Compare May 22, 2026 19:23
@mkaufmann mkaufmann merged commit a3f8712 into main May 22, 2026
13 checks passed
@mkaufmann mkaufmann deleted the fix/null-check-for-get-iceberg branch May 22, 2026 19:39
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.

3 participants