Skip to content

GH-906: Do not revive a released buffer on retain or transfer - #1362

Open
nhooey wants to merge 3 commits into
apache:mainfrom
nhooey:gh-906-retain-closed-buffer
Open

nhooey wants to merge 3 commits into
apache:mainfrom
nhooey:gh-906-retain-closed-buffer

Conversation

@nhooey

@nhooey nhooey commented Oct 15, 2026 •

Copy link
Copy Markdown

What's Changed

BufferLedger.retain now uses a compareAndSet loop that never moves the count off 0, so a retain on a released buffer keeps throwing instead of reviving freed memory. The second commit closes the same hole in retain(ArrowBuf, BufferAllocator) and transferOwnership, which returned a usable buffer over freed memory. release is unchanged: once nothing can lift a count off 0, it can't free the memory twice.

lidavidm suggested on #906 resetting the count before throwing. That only narrows the window, because another thread's retain can see the positive count before the reset (the Tests table). The Benchmark section measures the loop's cost.

Fix from XTDB

The retain fix is @jarohen's: XTDB has shipped it in its patched BufferLedger since xtdb/xtdb@5576310, after the bug crashed its JVM with a SIGSEGV (xtdb/xtdb#5013). This PR brings it upstream with tests and a benchmark, and differs from XTDB's retain only where main has moved on since XTDB's copy: it uses the AtomicIntegerFieldUpdater from GH-1038, and keeps the historical-log call before the loop, so a failed retain still shows when BaseAllocator.DEBUG is on. The cross-allocator fix is new.

Tests

The first commit's tests, repeated locally with @RepeatedTest:

Test main reset before throw this PR
testRetainAfterCloseThrowsEveryTime 50/50 fail pass 500/500 pass
testFailedRetainDoesNotReviveReleasedMemory 50/50 fail pass 500/500 pass
testRetainRacingFinalReleaseNeverRevivesBuffer 50/50 fail 48/500 fail 500/500 pass

Reset-before-throw's 48 failures were 26 retains succeeding after the buffer was freed, and 22 second releases to 0 failing in AllocationManager.release with Expecting a mapping for allocator and reference manager. The race test takes about 0.1 to 0.3 s. The second commit's two closed-buffer tests fail without its check.

Benchmark

BufferLedgerBenchmarks, average ns per operation: JMH 1.37 with -wi 5 -w 1s -i 5 -r 1s and 10 forks (5 for 4 and 8 threads), on an Apple M2 Pro with Zulu JDK 21; ± is JMH's 99.9% confidence interval.

Benchmark main this PR
retainReleaseUncontended 16.36 ± 0.13 18.00 ± 0.19
retainReleaseContended2 82.25 ± 4.92 110.01 ± 14.81
retainReleaseContended4 243.54 ± 19.57 247.06 ± 16.14
retainReleaseContended8 470.41 ± 38.87 497.64 ± 32.62
retainToAllocatorClose 63.25 ± 3.28 62.51 ± 1.74

Uncontended, the loop adds about 1.6 ns per retain + release, against about 115 ns for allocateClose. At 2 threads a retain that loses the race retries, so the pair is slower and noisier; many threads updating one buffer's count at once isn't a pattern Arrow code is expected to have. From 4 threads up, release's lock dominates. The cross-allocator check costs nothing measurable.

AI assistance (Claude Code) was used during development; the complete diff and test results were reviewed by the author.

Closes #906.

🤖 Generated with Claude Code

nhooey and others added 3 commits October 15, 2026 14:17
BufferLedger.retain added to the reference count with getAndAdd and only
then checked that the old count was positive. A retain on a released
buffer (count 0) threw but left the count at the increment, so the next
retain succeeded on memory that had already been freed and possibly
reused.

Increment with a compareAndSet loop that refuses to move the count off
0, keeping the existing IllegalArgumentException. Resetting the count
before throwing would only narrow the window: between the failed add
and the reset another thread's retain sees a positive count and
succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BufferLedger.retain(ArrowBuf, BufferAllocator) and transferOwnership
associated the memory with the target allocator without checking that
it was still allocated. On a closed buffer whose memory had been freed,
they created a ledger with a count of 1 over the freed memory and
returned a usable buffer; closing it then failed in
AllocationManager.release.

AllocationManager.associate now refuses to add a reference once the
memory has been freed (no owning ledger), checked under the lock the
final release holds while freeing it. A buffer whose own ledger is
released can still be retained while another allocator's ledger keeps
the memory alive, as TestBaseAllocator.testAllocator_shareOwnership
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measure what the fix costs: the compareAndSet loop in retain, which
retries when threads contend on one buffer, and the freed-memory check
in AllocationManager.associate, against buffer allocation and close
for scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thank you for opening a pull request!

Please label the PR with one or more of:

  • bug-fix
  • chore
  • dependencies
  • documentation
  • enhancement

Also, add the 'breaking-change' label if appropriate.

See CONTRIBUTING.md for details.

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.

Calling retain on a closed ArrowBuf revives it, into an undefined state

1 participant