Skip to content

fix(spark): release native read resources deterministically - #11367

Merged
robert3005 merged 4 commits into
vortex-data:developfrom
quantcast:cwirt/upstream-native-read-cleanup
Nov 11, 2026
Merged

robert3005 merged 4 commits into
vortex-data:developfrom
quantcast:cwirt/upstream-native-read-cleanup

Conversation

@ChristopherWirt

@ChristopherWirt ChristopherWirt commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #11364.

Closing a Spark reader currently leaves its Java-owned native read handles and file caches alive until Cleaner processing. Make that release explicit and idempotent, while preserving the resources still needed by derived scans, returned partitions and Arrow batches. Ports the lifecycle fix from QC #103.

Changes

  • Make DataSource and Scan implement AutoCloseable, retaining Cleaner fallback. Keep native calls synchronized with explicit close; fence scan options so their expressions remain alive across JNI.
  • Release partitions prefetched by hasNext() but never returned.
  • Let Spark's owning wrappers close at EOF. Reader and footer cleanup attempts every close in ownership order and preserves the original failure.
  • Release native partition ownership when Arrow stream allocation or import fails.
  • Reject reads on closed handles with IllegalStateException. An open, exhausted scan still returns false from hasNext() and throws NoSuchElementException from next().

Testing

  • 11 JNI ReadLifecycleTest cases passed, including deterministic failures on the first and second Arrow allocations, explicit release, surviving child handles, and closed versus exhausted scans.
  • 39 Spark lifecycle and V1/V2 fallback cases passed across Spark 4.0 / Scala 2.13 and Spark 3.5 / Scala 2.13 and 2.12.
  • Rebuilt the matching JNI library; Java compilation with ErrorProne and -Werror passed.
  • Full ./gradlew spotlessApply and git diff --check passed.
  • Four independent review passes found no blocking issues; the review added the open-exhausted iterator regression case.

Workspace-wide tests and benchmarks were not run. The unrelated list-view normalization from QC #103 is excluded.

AI tools assisted with this port, review and description. Christopher Wirt is the responsible contributor.

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

I might be missing something but we are doing too much to secure things here

splitConcurrency);
return Scan.fromPointer(session, scanPtr);
} finally {
Reference.reachabilityFence(this);

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.

Suggested change
Reference.reachabilityFence(this);
Reference.reachabilityFence(options);
Reference.reachabilityFence(this);


/** Arrow schema of the data source (and of scans produced from it). */
public Schema arrowSchema(BufferAllocator allocator) {
public synchronized Schema arrowSchema(BufferAllocator allocator) {

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.

Do we need all the synchronised blocks? Reference.reachabilityFence(this) is a noop for synchronised. I think we should add Reference.reachabilityFence and remove synchronised unless I am missing something

}
if (!scan.hasNext()) {
exhausted = true;
close();

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 don' think we should close here - let spark close it for us

if (primed) {
return nextPartitionPointer != 0;
public synchronized boolean hasNext() {
if (closed) {

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.

How about making this branch throw?

public boolean hasNext() {
if (primed) {
return nextPartitionPointer != 0;
public synchronized boolean hasNext() {

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 likely shouldn't be synchronised like datasource?

scan.close();
input.source().close();
assertFalse(input.readable().refersTo(null));
// Arrow 19 allocates two 40-byte stream structs, rounded to 64 bytes each.

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.

you can do something like this instead

    AllocationListener listener = new AllocationListener() {
        @Override
        public void onPreAllocation(long size) {
            if (allocations.incrementAndGet() == failingAllocation) {
                throw new OutOfMemoryException("injected allocation failure");
            }
        }
    };

    try (BufferAllocator limited = new RootAllocator(listener, Long.MAX_VALUE)) {
      ...
    }

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

Ok, I think we can go with a bit more of synchronised here

@robert3005 robert3005 added the changelog/fix A bug fix label Nov 11, 2026
@robert3005
robert3005 enabled auto-merge (squash) November 11, 2026 12:25
@codspeed

codspeed Bot commented Nov 11, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 10.08%

2 warnings

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 1 improved benchmark
✅ 1920 untouched benchmarks
⏩ 50 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Walltime compare_u64_avx2 3.8 µs 3.4 µs +10.08%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing quantcast:cwirt/upstream-native-read-cleanup (19a2635) with develop (b2d08cc)

Open in CodSpeed

Footnotes

  1. 50 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@robert3005
robert3005 merged commit 7c1972b into vortex-data:develop Nov 11, 2026
103 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Java and Spark readers retain native file resources until Cleaner processing

2 participants