Skip to content

fix: make CometDiskBlockWriter spill registry per-task instead of executor-global - #5493

Open
peterxcli wants to merge 13 commits into
apache:mainfrom
peterxcli:fix-comet-disk-block-writer-per-task-spill-registry
Open

fix: make CometDiskBlockWriter spill registry per-task instead of executor-global#5493
peterxcli wants to merge 13 commits into
apache:mainfrom
peterxcli:fix-comet-disk-block-writer-per-task-spill-registry

Conversation

@peterxcli

@peterxcli peterxcli commented Aug 27, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Part of #5212 (positions 6, 7, 9, and 10). It does not close the epic.

Rationale for this change

CometDiskBlockWriter.currentWriters is declared static final, so despite its comment ("of same shuffle task") it is an executor-global registry shared by every shuffle task in the JVM. This causes several problems, all reproduced with a deterministic two-task reproducer against current main:

  • Cross-task spilling with wrong accounting (position 6): when task A cannot allocate a page, spill() sorts the global list and force-spills the largest writers regardless of owner. In the reproducer, task A force-flushed task B's 3,145,728 buffered bytes (2,751 rows written to B's file from A's thread, disk-spill metrics charged to B), counted all of it toward its own totalFreed, and spilled none of its own data. With -ea (as CI runs), task A then crashes with AssertionError in initialCurrentPage because its own active page was never freed.
  • ABBA deadlock (new finding): insertRow holds the writer monitor and then takes the registry lock inside spill(), while a concurrently spilling task holds the registry lock and requests the victim writer's monitor. A benchmark with two concurrent tasks under a shared memory cap deadlocked in 3 of 3 rounds (confirmed via ThreadMXBean.findDeadlockedThreads()).
  • ConcurrentModificationException (position 7): the spill comparator reads other tasks' allocatedPages lists unsynchronized while their owners mutate them; observed live in the same concurrent benchmark.
  • Unsynchronized cross-thread mutation (position 9): spilling and totalWritten are mutated from other tasks' threads without proper synchronization.
  • Failed-task writer leak: stop(false) frees memory but never removes writers from the static list, so they are retained for the executor lifetime (12/12 writers still strongly reachable after GC across three simulated failed tasks). The retained writers also keep row addresses that point into freed pages, so a later task spilling them would hand freed addresses to native code.
  • Dead code (position 10): spillingWriters is never populated, so its loop in freeMemory() is unreachable.

What changes are included in this PR?

1. Task-owned spill registry. Replace the static registry with a task-owned LinkedList created in CometBypassMergeSortShuffleWriter.write() and passed to each partition's CometDiskBlockWriter. Spilling now only ever touches the requesting task's writers, matching Spark's own shuffle semantics, and every writer is confined to its task's thread (which also removes the deadlock, the ConcurrentModificationException, and the cross-thread mutation of positions 7/9 structurally). Writers of a failed task become unreachable together with the task's shuffle writer, removing the leak. The dead spillingWriters field and its loop in freeMemory() are deleted.

2. Blocking allocation for the shared on-heap pool (CometBoundedShuffleMemoryAllocator.allocateBlocking). The registry change removes a task's only recourse when the shared pool is full: previously it force-spilled other tasks' buffers. In the opt-in on-heap compatibility mode all tasks share one bounded allocator, so without a replacement a task whose own writers hold nothing spillable would fail with SparkOutOfMemoryError whenever other tasks held the pool (reproduced in review with a real Spark 3.5.9 shuffle). The replacement mirrors Spark's unified memory manager: after a task has spilled everything it owns (the retry in SpillWriter.initialCurrentPage, the only caller), it waits for other tasks to free pool memory instead of failing, and free() notifies waiters.

The waiting logic keeps per-thread accounting (which thread allocated each page, how much each thread retains, and which threads are blocked waiting with what request size) in order to fail fast, before or during a wait, exactly when the wait provably cannot succeed:

  • the request does not fit next to the memory the requester itself still retains (e.g. the unsafe sorter's pointer array, which survives an empty spill()), which also covers a single request larger than the whole pool; or
  • every allocated byte is retained by threads that are themselves blocked waiting and none of their requests fits in the free pool, i.e. no thread is left that could ever free memory; or
  • (after at least one full wait interval) the memory the wait depends on is retained by threads that are parked in an untimed wait inside Spark's execution-memory pool — a cross-pool cycle, since Spark's pool may in turn only be freed by a task blocked here. The waiter unwinds with SparkOutOfMemoryError so its task releases memory and the other pool can progress; sleeping, latch-parked, or computing holders deliberately do not match.

To keep such cycles from forming in the first place, the unsafe writer also allocates its sorter lazily: open() moved from the constructor into write(), after the first records.hasNext(), so no Comet pool memory is retained while Spark evaluates or lazily materializes the shuffle input (which can block on Spark execution memory). Writer-held allocations that fatal errors would otherwise orphan (Spark only calls stop(false) for exceptions) are reclaimed on every path: the bypass writer frees its partition writers' pages on any Throwable out of write(), SpillSorter frees everything it allocated when its constructor fails part-way, and a task-completion listener backstops the unsafe writer.

A wait that can still succeed is not bounded by any deadline (a fixed deadline was shown in review to fail workloads where a healthy holder simply takes longer than the cap), but the allocator logs a warning when a wait starts and every 30 seconds while it lasts, so a stalled task is visible in executor logs. Interrupting a waiting task surfaces a NonFatal RuntimeException (not the fatal SparkOutOfMemoryError) so that an intentional task kill is classified by Spark's killed-task handler (case _: InterruptedException | NonFatal(_) if task.reasonIfKilled.isDefined in Executor.TaskRunner) as TaskKilled rather than ExceptionFailure. The off-heap (CometUnifiedShuffleMemoryAllocator) path is unchanged: it delegates to Spark's memory manager, which already arbitrates memory between tasks.

Single-task spill behavior is unchanged: in an A/B benchmark the spilled byte counts were identical in every run, and wall-time differences were within noise. The two-concurrent-task benchmark that deadlocks on main completes in all repetitions with this change.

How are these changes tested?

New CometDiskBlockWriterSuite (runs in ~7 s) covers both halves:

  • Two writers owned by two TaskMemoryManagers sharing one TestMemoryManager; one task is driven into memory pressure and must resolve it by spilling only its own writer while the other task's buffered rows, spill metrics, and file remain untouched. Fails on main (the victim task's buffer is force-flushed and the requesting task trips the initialCurrentPage assertion), passes with this change.
  • On-heap shared pool: a task with nothing to spill waits for another task to finish instead of failing; unsatisfiable requests (oversized, or not fitting next to the requester's own retained memory) fail fast instead of waiting; interrupting a blocked waiter surfaces a NonFatal error with the InterruptedException cause; and a notified waiter is not declared deadlocked while another waiter's smaller request can proceed (both waiters complete regardless of wake order).

🤖 Generated with Claude Code

…cutor-global

The static currentWriters list in CometDiskBlockWriter was shared by all
shuffle tasks in the executor, so a task under memory pressure could spill
other tasks' writers, count their memory as freed for itself, and retain
writers of failed tasks forever. It also allowed an ABBA deadlock between
the writer monitor and the registry lock when two tasks spilled
concurrently, and unsynchronized cross-thread reads could throw
ConcurrentModificationException.

Replace the static registry with a task-owned list created by
CometBypassMergeSortShuffleWriter and passed to its partition writers, and
delete the dead spillingWriters path (nothing ever added to it). All access
to a writer is now confined to its own task's thread.
@peterxcli
peterxcli force-pushed the fix-comet-disk-block-writer-per-task-spill-registry branch from 61a10fb to ccbf5b7 Compare August 27, 2026 06:07
In on-heap mode all tasks share one CometBoundedShuffleMemoryAllocator, so
with per-task spill registries a task whose own writers hold nothing
spillable failed with SparkOutOfMemoryError when other tasks held the pool.

Preserve allocation progress by letting the post-spill retry in
SpillWriter.initialCurrentPage wait (bounded by a timeout) for other tasks
to free pool memory; free() notifies waiters. This point is only reached
after the task has spilled everything it owns, so waiting tasks hold no
pool memory and the tasks holding it can always progress and free it. The
off-heap allocator keeps its non-waiting behavior since Spark's memory
manager already arbitrates between tasks.
@peterxcli
peterxcli requested a review from sunchao August 27, 2026 07:23

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Rechecked 3cee056cd1ded0240892ef53ebfef515fd6c1e4e: the existing shared-pool finding is partially fixed. An independently released 1.5-second holder now succeeds, but a real Spark 3.5.9 Comet JVM shuffle with a 1-MiB pool and 256-KiB pages still raises SparkOutOfMemoryError at the 60-second allocation deadline when another task independently pauses for 65 seconds while holding four pages. The exact base Java-class overlay and current-head 2-MiB control each return all 752 correct rows. This remains limited to opt-in on-heap compatibility; the probe allows one task attempt, so normal Spark retries may recover.

Any finite deadline fails some workload: a task holding the shared on-heap
pool longer than the cap still made waiters fail with SparkOutOfMemoryError.
Wait until memory is freed instead, mirroring how Spark's unified memory
manager blocks a task until memory becomes available. Liveness is preserved
because a waiting task has already spilled everything it owns, so pool
memory is only held by tasks that can progress and free it; interrupting
the task (e.g. task kill) still aborts the wait. Log once when a task
starts waiting so long waits are diagnosable.
…non-fatal

The unbounded wait could hang when it can never succeed: the sort-based
path retains its pointer array through an empty spill(), so a waiting task
may itself hold pool memory, and two such tasks can block each other
forever; a single request larger than the pool waited on an empty pool.
Track the pool memory retained per thread and give up before waiting when
the request cannot fit next to the requester's own retained memory, or
when all allocated memory is retained by threads that are themselves
blocked waiting (nothing can be freed anymore).

Also stop rethrowing SparkOutOfMemoryError when the wait is interrupted:
it extends OutOfMemoryError, so Spark's killed-task handler does not match
it and an intentional task kill was reported as ExceptionFailure. Throw a
RuntimeException wrapping the InterruptedException instead so kills are
classified as TaskKilled.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Follow-up to the allocation-progress thread: the new check at CometBoundedShuffleMemoryAllocator.java:163 in b8fadf58 can reject a satisfiable schedule. Notified threads remain in waitingThreads until allocation returns. With a 1-MiB pool and 256-KiB pages, after a healthy holder finishes, two unsafe-shuffle tasks each retain 32,768 pointer bytes and request 999,448 / 262,144 bytes. If the large waiter resumes first, it throws with 983,040 bytes free, although the smaller waiter can finish and release its array for it. In fresh Spark 3.5.9 opt-in on-heap tests, small-first arrival failed 10/10; exact 4c12797 Java overlays and current reversed arrival each returned three correct rows 10/10, and the matching 2-MiB control passed 3/3. Please let a satisfiable notified request proceed before declaring deadlock. This is an incremental regression against 4c12797, reproduced with local[3,1].

The deadlock check counted notified-but-not-yet-resumed waiters as
permanently blocked: after a holder freed the pool, a large waiter that
resumed first could declare deadlock even though a smaller waiter, still
registered as waiting, could satisfy its request from the free pool,
finish, and release memory for the large one.

Track each waiter's pending request size and only declare deadlock when,
in addition to all allocated memory being retained by blocked waiters,
none of their requests fits in the free pool. A waiter whose request fits
will proceed once it reacquires the monitor and eventually free what it
retains, so the wait remains live.
@peterxcli
peterxcli requested a review from sunchao August 27, 2026 14:22
@andygrove

Copy link
Copy Markdown
Member

Note on this review: this was generated by an LLM (Claude Code) at my request while I worked through a review backlog. I have not verified the individual findings myself. Please treat everything below as suggestions to evaluate rather than as authoritative review feedback, and push back on anything that is wrong or already handled.

The core of this is clearly right. A static registry that lets task A spill task B's buffers, then reads B's allocatedPages unsynchronized while B mutates it, is a genuine correctness and safety bug, and scoping the list to the task's CometBypassMergeSortShuffleWriter is the obvious fix. Deleting the dead spillingWriters field along the way is a bonus. The reproducer that shows the cross-task spill plus the AssertionError is convincing.

My concerns are all about the second half of the PR.

The description does not mention allocateBlocking

The "What changes are included in this PR?" section only describes the registry change. But this PR also adds about 80 lines of new blocking-allocation logic to CometBoundedShuffleMemoryAllocator, including a hand-rolled deadlock-avoidance check, wait/notifyAll coordination, and two new pieces of per-thread accounting. That is the riskiest code in the diff and a reader going by the description would not know it was here. Could you write up what it does and why it is needed?

I do understand the connection: once a task can no longer take memory from other tasks, it needs somewhere to go when the pool is full. But that is an argument that deserves to be made explicitly, and it may well be an argument for a second PR rather than a second half.

wait() has no timeout

allocateBlocking calls bare wait() and only ever wakes on free() or on another waiter joining. The two fail-fast checks cover the cases where the waiting set can prove progress is impossible. They do not cover a thread that holds pool memory, is not in waitingThreads, and is not going to free anything soon, for example one blocked in a long shuffle read or stuck on network I/O. In that situation allocatedMemory > retainedByWaitingThreads() holds, the check passes, and the task waits forever after a single logger.warn.

From an operator's point of view that is a hung task with one line of explanation. Would you add a bounded wait, either wait(timeoutMillis) with periodic re-logging so the stall is visible, or a configurable maximum after which it throws the original SparkOutOfMemoryError? An OOM that fails the task is much easier to diagnose than a job that never finishes.

retainedMemory holds strong references to Thread objects

Entries are removed from retainedMemory only when the retained total drops to zero through free. Any page that is never freed, which is exactly what happens on the task-failure paths this PR is partly about, leaves a permanent Thread key in an executor-lifetime map. retainedByWaitingThreads also walks the whole map under the allocator lock on every failed allocation. Would a WeakHashMap, or clearing the entry from a TaskCompletionListener, be safer here?

Interrupt is rethrown as RuntimeException

The comment says throwing a plain RuntimeException keeps an intentional task kill classified as TaskKilled rather than ExceptionFailure. I do not think the exception type is what drives that classification. Spark decides based on TaskContext.isInterrupted and the kill reason in Executor.run, and a RuntimeException surfacing out of the writer is not obviously different from any other. Could you point at where that behavior comes from, or verify it with a test that kills a task mid-wait and checks the reported failure reason?

Test flakiness

CometDiskBlockWriterSuite is 439 lines of two-thread coordination with CountDownLatch and TimeLimits, and it is now wired into both the Linux and macOS PR builds. That is the right place for it, but concurrency tests of this shape have a habit of becoming the flaky suite everyone learns to rerun. How long does it take, and are the timeouts generous enough for a loaded CI runner? If any assertion depends on one thread reaching a point before another without a latch enforcing it, that is the one to harden now rather than after the first flake.

A task blocked waiting for the shared on-heap pool logged a single warning
and then went silent, so a long stall (e.g. a holder stuck on I/O) was
invisible to operators. Use a timed wait and re-log every 30 seconds with
the elapsed time, requested size, free bytes, and number of waiting
threads. The wait itself remains unbounded (a fixed deadline was shown in
review to fail workloads whose holder legitimately outlives the cap); the
existing fail-fast checks still cover the provably-unsatisfiable cases.

Also harden the suite's thread-state polls to accept TIMED_WAITING now
that the allocator uses a timed wait.
@peterxcli

Copy link
Copy Markdown
Member Author

Thanks for the review @andygrove. Point-by-point:

PR description — agreed, that was a real gap. The description now has a dedicated section explaining allocateBlocking: why removing cross-task force-spill needs a replacement on the shared on-heap pool, how the per-thread accounting drives the two fail-fast checks, and why the off-heap path is untouched. On splitting into a second PR: the registry change alone is a regression for opt-in on-heap mode (@sunchao reproduced a real Spark 3.5.9 shuffle failing on the intermediate commit), so the two halves need to land together to keep every commit releasable.

wait() has no timeout — took your first option in cab20e3: the wait is now wait(30_000) chunks that re-log every 30 s with elapsed time, requested bytes, free bytes, and waiter count, so a stall caused by a holder stuck outside the allocator is visible in executor logs. I kept it unbounded rather than adding a deadline because an earlier revision had exactly that (60 s cap) and @sunchao demonstrated it fails a legitimate workload whose healthy holder simply outlives the cap — any finite default has that problem, and it is also how Spark's own UnifiedMemoryManager behaves (its lock.wait() loop has no deadline either).

retainedMemory Thread references — I looked at this and I believe it is already safe, so no change: entries are removed eagerly the moment a thread's retained total reaches zero (free decrements per page owner), so the map cannot accumulate entries across normally-completing tasks. A lingering entry requires a page that is never freed, and such a page pins its owner thread through pageOwners regardless, so a WeakHashMap would not reclaim anything — and if the entry did vanish while the leaked page stayed allocated, the deadlock check would lose sight of memory that is genuinely unfreeable and waiters would hang instead of failing. A TaskCompletionListener cannot free those pages either (the leak means cleanup did not run). The map walk happens only on the failed-allocation path and its size is bounded by the number of concurrent shuffle tasks.

Interrupt classification — the exception type is what drives it. Spark's killed-task clause in Executor.TaskRunner.run (Spark 3.5.9, core/src/main/scala/org/apache/spark/executor/Executor.scala) is case _: InterruptedException | NonFatal(_) if task != null && task.reasonIfKilled.isDefined => ... TaskKilled. reasonIfKilled is necessary but not sufficient — the guard only matches InterruptedException or NonFatal throwables, and SparkOutOfMemoryError extends OutOfMemoryError, which NonFatal rejects, so it falls through to the generic case t: Throwable and becomes ExceptionFailure even for a killed task. @sunchao verified this end-to-end in this thread: killTaskAttempt(id, true) on a waiter produced ExceptionFailure with the old rethrow and TaskKilled behaves correctly with the NonFatal wrapper. There is also a unit test asserting the surfaced error is not an OutOfMemoryError and carries the InterruptedException cause.

Test flakiness — the suite runs in ~8 s locally with 60 s failAfter budgets. Every cross-thread ordering the assertions depend on is enforced by a latch or a state poll (threads observed in WAITING/TIMED_WAITING before the event that releases them); the one bare Thread.sleep(500) is one-sided — if the other thread is late, the scenario degrades to a trivially-passing variant, never a failure. Your instinct was right in one concrete way: switching to a timed wait moved blocked threads from WAITING to TIMED_WAITING and the state polls caught it immediately; they now accept both states (also in cab20e3). 10/10 consecutive local runs pass.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed cab20e38 with five independent reviewers. Timed reevaluation addresses the previous missed-notification stall in source. The distinct P2 below is source-verified; no current local runtime reproduction was performed.

…fails

SpillSorter's constructor allocates the one-entry ShuffleInMemorySorter
array and then the real pointer array. When the second allocation failed,
the first was orphaned: the writer is never handed to Spark, so no cleanup
path could ever free it. Pre-existing, the orphan was only a tiny pool
leak; with blocking allocation it also broke the wait's progress
condition, since the orphaned bytes look like memory a live holder could
still free, turning a formerly prompt SparkOutOfMemoryError for a later
full-pool request into an unbounded wait.

Free the partially-constructed sorter's allocations before rethrowing, so
every allocated byte again has an owner with a cleanup path - the
invariant the blocking wait relies on.
@peterxcli
peterxcli requested a review from sunchao August 27, 2026 18:05

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] The constructor leak in the existing orphan-allocation thread is fixed at a21f3f19, but bypass failures can still orphan pages. With opt-in on-heap JVM shuffle, a 1-MiB pool, 256-KiB pages and bypass threshold 3, a three-partition task can spill its largest writer and retain 524,288 sibling bytes. Before its 512,024-byte retry, an independent four-partition unsafe task can retain a 32,768-byte pointer array and wait for 999,448 bytes. Only 491,520 bytes remain free, so the all-waiter check throws SparkOutOfMemoryError in the bypass task.

Both bypass write() and Spark's ShuffleWriteProcessor catch only Exception; stop(false) is skipped, and task-memory cleanup does not own these private pages. The unrelated unsafe job can then wait indefinitely, including through every 30-second recheck. Please reclaim the bypass pages on this error path. The cleanup gap is pre-existing; the introduced regression is unbounded waiting. This is verified against current source and Spark 3.5.9 contracts, not an executed reproduction.

…rror

Spark's ShuffleWriteProcessor only calls stop(false) when the shuffle
writer throws an Exception, so a fatal error such as SparkOutOfMemoryError
(e.g. from the blocking allocator's fail-fast checks) skipped cleanup and
orphaned the task's buffered pages. Those pages live in the
executor-shared bounded pool that Spark's task-memory cleanup does not
own, so on-heap they starved other tasks' allocations - and with blocking
allocation, an unrelated waiter could wait on them indefinitely.

Catch Throwable in CometBypassMergeSortShuffleWriter.write() and free all
partition writers' memory before rethrowing (freeMemory is a no-op when
stop(false) runs afterwards on Exception paths). CometUnsafeShuffleWriter
already handles generic throwables via its success/finally pattern.
@peterxcli

Copy link
Copy Markdown
Member Author

Addressed the bypass orphan-pages finding in a1a4a84. You're right that stop(false) is skipped for fatal errors: both write() and Spark's ShuffleWriteProcessor catch only Exception, and SparkOutOfMemoryError is an Error. CometBypassMergeSortShuffleWriter.write() now catches Throwable and frees all partition writers' buffered pages before rethrowing, so a fatal failure (including the all-waiter fail-fast in the blocking allocator) can no longer orphan pool pages that an unrelated waiter would then wait on indefinitely. freeMemory() is idempotent, so the stop(false) that still runs on Exception paths is unaffected, and CometUnsafeShuffleWriter already handles generic throwables via its success/finally pattern (sorter.cleanupResources() runs in finally), so the bypass writer was the remaining gap. Added a regression test that drives the real write() (real BlockManager from a local SparkContext, stub ShuffleExecutorComponents): it buffers three pages of the 1-MiB pool, fails mid-write with a SparkOutOfMemoryError, and then asserts a full-pool 1,048,576-byte allocation succeeds — impossible if any buffered page leaked.

@peterxcli
peterxcli requested a review from sunchao August 28, 2026 01:53

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed a1a4a84. The earlier bypass-write failure and failed-constructor cleanup are addressed. [P2] One case remains under the existing orphan-allocation discussion, in Spark 3.5.9's pre-write path.

The unsafe writer allocates its pointer array before Spark evaluates rdd.iterator. Comet's eager round-robin input sort can then throw a managed SparkOutOfMemoryError. The writer's write() cleanup has not started, the processor's Exception handler skips stop(false), and Spark task-memory cleanup does not own the 32-KiB Comet allocation.

With on-heap JVM shuffle and a 1-MiB Comet pool/page size, a bypass task on another thread can then wait indefinitely on that abandoned allocation. Controlled runs with the actual 3.5.9 processor, writers and shared Spark memory manager reproduced head waiting versus base failing promptly; explicit orphan cleanup resumed native output. These used constrained managed memory, manual task contexts and a fixed CI native binary, not a full scheduler run.

Please give allocations made before write() task-completion cleanup, or defer them until the writer's cleanup scope owns them.

…ask completion

The unsafe writer's sorter allocates its pointer array at construction,
before Spark has evaluated the shuffle input iterator and called write().
A fatal error during iterator evaluation (e.g. SparkOutOfMemoryError from
an eager input sort) therefore skipped every existing cleanup path: the
writer's success/finally scope never starts, ShuffleWriteProcessor only
calls stop(false) for exceptions, and Spark's task-memory cleanup does not
own Comet's pool. The orphaned allocation could then starve other tasks'
blocking waits indefinitely.

Register a TaskCompletionListener at writer construction that runs
sorter.cleanupResources() if the sorter is still alive; listeners run in
Task.run's finally regardless of failure type, and cleanupResources() is
idempotent so this is a no-op whenever write()/stop() already cleaned up.
@peterxcli

Copy link
Copy Markdown
Member Author

Addressed the pre-write orphan finding in 844b308, taking the "give allocations task-completion cleanup" option: CometUnsafeShuffleWriter's constructor now registers a TaskCompletionListener that runs sorter.cleanupResources() if the sorter is still alive. Completion listeners run in Task.run's finally regardless of whether the failure was an Exception or an Error, so this covers the window you found — a fatal error thrown while Spark evaluates the input iterator, after the sorter allocated its pointer array but before write()'s own cleanup scope begins — as well as any other path that skips stop(false). cleanupResources() is idempotent (the pointer array free is guarded, page lists are cleared, spill-file deletion checks existence), so it is a no-op on every normal path where write() or stop() already cleaned up. I did not defer the allocation instead, since the sorter's insert paths assume the array exists and lazy allocation would move the failure into a hotter path. Added a regression test that constructs the writer exactly as Spark does pre-iterator, asserts the pointer array actually occupies the 1-MiB pool (a full-pool allocation fails), then calls markTaskCompleted without write() or stop() ever running and asserts the full-pool allocation now succeeds — which fails without the listener.

@peterxcli
peterxcli requested a review from sunchao August 28, 2026 06:47

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] The orphan-allocation issue remains partially addressed at 844b308. SpillSorter still calls serializeSchema outside the cleanup block after adopting its pointer array. A nested integer field with parquet.field.id=2147483648L passes the ordinary Spark 3.5 schema/encoder path, then throws in the Parquet ID conversion. The outer sorter is never assigned, so the new completion listener cannot release the 32 KiB array; in the opt-in on-heap JVM path that orphan can keep another job's first-page allocation waiting indefinitely. Please cover later constructor failures as well. This is verified from source; the passing pre-write test does not exercise it, and no current runtime reproduction is claimed.

The constructor cleanup only wrapped the pointer-array allocation, but
serializeSchema (and the Native library load) run after the array is
adopted and can still throw - e.g. schema serialization rejects a nested
field whose parquet.field.id exceeds the 32-bit range. The enclosing
sorter field is never assigned in that case, so not even the unsafe
writer's task-completion listener could reclaim the adopted 32 KiB array.

Widen the cleanup to the rest of the constructor and track whether the
sorter adopted the array, so exactly one owner frees it: before adoption
the array is freed directly (the sorter still owns only its initial
one-entry array), after adoption inMemSorter.free() releases it, avoiding
a double free that would trip TaskMemoryManager assertions in off-heap
mode. Also skip null entries in the bypass writer's failure cleanup, in
case a mid-loop writer construction failure left the tail of the array
unassigned.
@peterxcli
peterxcli requested a review from sunchao August 28, 2026 09:16

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Account for live owners blocked in Spark's execution-memory pool

At 4b592b9, the reported orphan allocations are reclaimed, but the allocation-progress condition still permits a live cross-pool cycle.

In opt-in on-heap JVM shuffle on Spark 3.5.9, with round-robin pre-sorting, bypass disabled and 4096-entry pointer buffers, each unsafe writer holds 32 KiB before its input iterator is built. B can finish sorting while it alone owns more than three quarters of the fully growable Spark execution pool; A then blocks below its minimum share in Spark while retaining its Comet array. For B's first nonfinal 976-KiB binary payload, its approximately 999448-byte Comet request cannot fit in a 1-MiB pool with both arrays (983040 bytes free), yet fits beside its own array (1015808 bytes). A is outside waitingThreads, so B waits too. Each task holds memory the other needs; neither can reach cleanup, and timed retries see unchanged state.

BASE fails and unwinds at this retry instead. Please avoid retaining pre-write Comet allocations across blocking input construction, or otherwise break this cross-pool cycle before waiting indefinitely. This is verified from current and matching public 3.5.9 source, not an executed reproduction.

Spark evaluates the shuffle input iterator between constructing the
shuffle writer and calling write(), and that evaluation can block on
Spark's execution-memory pool (e.g. Comet's eager round-robin input sort).
Because the writer's constructor allocated the sorter's pointer array up
front, a task blocked in that window retained Comet pool memory while
holding no place in the Comet allocator's waiting set, so a second task
blocked in the Comet pool saw it as a live holder and waited forever:
task A waits on Spark memory held by B, task B waits on Comet memory
held by A, and neither can reach cleanup.

Move open() from the constructor into write(), so no Comet pool memory is
retained across blocking input construction and the cross-pool cycle
cannot form. Everything that uses the sorter already runs inside write(),
and stop(), peak-memory accounting, and the task-completion listener all
null-guard it. The listener stays as a backstop for fatal errors during
write() itself.
@peterxcli
peterxcli requested a review from sunchao August 28, 2026 12:09

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Following up on the cross-pool finding: moving open() fixes eager input construction, but it still reserves the Comet pointer array before records.hasNext(). On Spark 3.5 with on-heap JVM shuffle, bypass disabled, constrained pools and large rows, ordinary lazy Sort or partial HashAggregate input can then block in Spark execution memory. A second task can retain its nonfinal Spark input pages while waiting for a Comet page that fits beside its own pointer array but not both arrays. The first task is absent from waitingThreads, so the checks keep allowing a stable two-pool wait and neither task reaches cleanup. BASE instead raised a managed allocation error and unwound. Please also prevent retention across this lazy input boundary, or make the retry unwind when that dependency cannot progress. This is a source-derived reachable schedule, not an executed reproduction.

Deferring the sorter allocation to write() still reserved the pointer
array before the first records.hasNext(), where lazy input (a sort or
partial aggregate materializing its child) can block on Spark execution
memory - and mid-write hasNext() calls can block the same way, so no
placement of the allocation removes the cross-pool cycle entirely: a task
blocked in Spark's pool retains Comet memory, while a Comet waiter holds
Spark-side input pages the first task needs.

Two changes: allocate the sorter only after the input has produced its
first record, so the common lazy-materialization window retains nothing;
and make a Comet waiter unwind (after at least one full wait interval)
when the memory it waits for is retained by threads parked in an untimed
wait inside Spark's execution-memory pool, detected from their stack
frames (ExecutionMemoryPool / UnifiedMemoryManager). That is precise for
the cycle's dependency: sleeping, latch-parked, or computing holders do
not match and keep the wait alive, matching the previously-reviewed
healthy-holder scenarios. Unwinding raises the managed
SparkOutOfMemoryError, as base did, so the task releases its memory and
tasks in the other pool can progress.
@peterxcli
peterxcli requested a review from sunchao August 28, 2026 12:55
@peterxcli

Copy link
Copy Markdown
Member Author

Addressed the lazy-input cross-pool follow-up in ab5c033 with both halves of your suggestion, since neither alone closes it: deferral cannot help once rows are buffered (mid-write hasNext() on streaming input can block on Spark memory just as the first call can), and unwinding alone would leave the common window wider than necessary.

  1. No retention across the lazy input boundary: the sorter is now allocated only after records.hasNext() has produced the first record, so a task blocked in Spark execution memory while its input materializes holds zero Comet pool bytes.
  2. The retry unwinds when the dependency cannot progress: after at least one full wait interval, a Comet waiter throws the managed SparkOutOfMemoryError (as base did) when the memory it waits for is retained by threads parked in an untimed wait whose stack is inside ExecutionMemoryPool/UnifiedMemoryManager — i.e. blocked acquiring Spark execution memory, which may in turn only be freed by tasks blocked here. The detection is deliberately narrow so the previously-reviewed healthy-holder scenarios keep waiting: a sleeping holder is TIMED_WAITING, a latch-parked holder with an independent release has no Spark-memory frames, and a computing or I/O-bound holder is RUNNABLE — none match.

Added a regression test that builds the real two-pool state: one task owns the whole Spark execution pool (a real UnifiedMemoryManager), a holder thread retains 500 KiB of the 1-MiB Comet pool and then genuinely parks inside ExecutionMemoryPool acquiring Spark memory (verified from its stack), and a Comet waiter requesting 800 KiB unwinds with SparkOutOfMemoryError instead of hanging; once Spark memory frees, the holder resumes and the pool drains fully. Also reworked the unsafe-writer test to assert nothing is allocated before the first record is consumed.

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