Do not abort the server when a loader worker cannot be spawned - #114897
Conversation
Internal second-model review (5 rounds)Each round: cold review of the tree, then an independent second-model review of the same diff.
Noted, not blocking: the abort is narrowed, not removed. When no worker that can drain the queue |
|
Workflow [PR], commit [4a6ff09] Summary: ✅
AI ReviewSummaryThis PR narrows one important Missing context / blind spots
Findings❌ Blockers [src/Common/AsyncLoader.cpp:825-831, 933-936] Suggested fix: update the waiter classification when Final Verdict❌ Needs changes: the recoverable/non-recoverable split is still wrong after a waited job is reprioritized to another pool, so the server-abort bug survives in a reachable path. LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 340/351 (96.87%) · Uncovered code |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-review-slot-9:20260814-193600 |
|
cc @serxa @al13n321, could you review this? |
| const bool has_resuming_worker = !has_running_worker && waiting > suspended; | ||
| const bool spawn_is_required = !has_running_worker && !has_resuming_worker; | ||
|
|
||
| if (!spawn_is_required && pool.spawn_failed) |
There was a problem hiding this comment.
Once pool.spawn_failed is set, this early return suppresses every later droppable spawn() before it can call trySchedule again. That means a transient global-pool saturation can outlive the actual saturation episode for this pool: if another pool frees a thread and we later enqueue or release more ready jobs here while the current worker is still running, we still skip the respawn and keep draining serially until that worker exits or enters wait(). In other words, the memo currently coalesces failures by worker lifetime, not by global-pool saturation episode, which can turn a momentary CANNOT_SCHEDULE_TASK into a much longer table-loading throughput regression.
There was a problem hiding this comment.
Your description of the mechanism is right and I checked each part at the current head: the memo is a
Pool member (AsyncLoader.h:376), it is assigned at :971 from the attempt's own result, read at :938,
and unconditionally cleared at only two places (:838 in wait(), :1014 on worker exit), so a thread
freed by another pool does not clear it here. I disagree that this should change, and I measured the remedy the finding asks for.
Each per-pool ThreadPool is created with unlimited max_threads and queue_size (:61-63), so a
failure here always means the global pool is exhausted, and recovery is not cheaply observable:
available_threads and remaining_pool_capacity are private with no accessor (ThreadPool.h:211,218).
The only way to learn the pool recovered is to attempt a thread creation, and every attempt runs under
the held loader mutex. Re-probing per queued job is what the memo exists to prevent.
I implemented your suggestion as an arm (clear the memo at enqueue) and ran the suite:
| arm | AsyncLoaderSpawnFailures for 64 queued jobs |
suite |
|---|---|---|
| as shipped | 1 | 32/32 pass |
| memo cleared per enqueue | 64 | SpawnFailureWithRunningWorkerDoesNotTerminate fails |
So it turns one failed thread creation per saturation episode into 64 under the loader mutex, in the
state where threads cannot be created. SpawnFailureWithRunningWorkerDoesNotTerminate:1355 asserts that
delta is 1 deliberately.
The window is also bounded and already covered. The memo clears at the first of the running worker
entering wait() or exiting, and a worker re-evaluates after every job, so it lasts at most one job
execution rather than the whole saturation. SpawnFailureThenRecovers:1362 restores the limits and then
requires two jobs to run concurrently, which only happens if the pool spawns again; it passes.
Measured on bfd345b, unit test build id 773c8e7c2251fec8604b49e5fc5caf97de97c932.
Build profile diff (arm_release)Comparing ✅ No significant changes. Binary sizes
Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction. Compile time of recompiled translation units22 translation units recompiled, 201 s compile time in total, 22 of them have a recent master baseline. |
AsyncLoader::spawn wrapped scheduleOrThrowOnError in NOEXCEPT_SCOPE, whose handler is unconditionally std::terminate(). A transient CANNOT_SCHEDULE_TASK from a saturated global thread pool therefore killed the server during table loading, seen 4 times across 4 unrelated PRs and 4 build flavours in 30 days. A spawn must succeed only when nothing else will drain the pool's ready queue, so spawn() now tells three states apart: a worker that entered worker() and is not inside wait() drains the queue now; a worker inside wait() on another pool's job resumes draining once that job runs; neither of those means nobody ever will. The first two use the non-throwing trySchedule, roll back the Pool::workers increment on failure, warn and return false. Only the last keeps the fatal path, byte for byte, because the queued jobs would be stranded and schedule() marks that region non-exception-safe. Two counters are added because Pool::workers also counts a worker still queued in the global pool, which drains nothing, and one blocked in wait(), which cannot take its own pool's jobs. A saturated pool is remembered so the failing thread creation happens once per episode rather than once per queued job, counted by the new AsyncLoaderSpawnFailures event; entering wait() clears it. The regression tests assert which branch was taken rather than only that the process survived, and resolve the ProfileEvent by name so that the file also links against a tree where the event does not exist. That keeps Bugfix validation able to reach a verdict: the before-binary links, runs, and aborts in spawn() as the bug requires. A SQL test cannot reach the line, since on master the whole call sits under blockFaultInjections(). Related: ClickHouse#110174, which removed the mask that hid this exception.
bfd345b to
4a6ff09
Compare
|
Reproduced both directions locally by reverting only the non-test files to the merge base. The Also in this push, from my own review of the spawn tests:
Squashed to one commit. |
| const size_t waiting = pool.waiting_workers.load(); | ||
| const size_t suspended = pool.suspended_workers.load(); | ||
| const bool has_running_worker = pool.started_workers > waiting; | ||
| const bool has_resuming_worker = !has_running_worker && waiting > suspended; |
There was a problem hiding this comment.
has_resuming_worker stops being accurate once a same-pool waiter is reprioritized after wait() starts. suspended_workers is incremented once at entry based on worker_pool == job->pool_id (wait() lines 825-831), but prioritize can later move the awaited job to another pool while the waiter is still blocked; JobPrioritizedWhileWaited already exercises that transition (gtest_async_loader.cpp:992-1035). After that migration the waiter will resume when the other pool runs the awaited job, yet waiting == suspended still makes spawn_is_required true here. A later CANNOT_SCHEDULE_TASK in this pool therefore still takes the fatal scheduleOrThrowOnError path in a state this patch is meant to recover from. Please either reclassify the waiter when prioritize moves the awaited job, or derive the resuming-worker tier from live waited-job state, and add a regression that saturates the global pool after the reprioritization.
There was a problem hiding this comment.
You are right, and my earlier reasoning on this was wrong. I reproduced the abort.
Constructing your scenario aborts with libc++abi: terminating ... CANNOT_SCHEDULE_TASK at
AsyncLoader.cpp:969 (spawn) -> :765 (enqueue) -> :406 (schedule). An otherwise
identical run without the prioritize() call survives, dropping the spawn and recording one
AsyncLoaderSpawnFailures, so the migration selects the fatal tier, not the saturation. My
earlier claim that canSpawnWorker's priority clause makes this unreachable was wrong:
current_priority is the minimum over active pools, and the old pool stays active while it
holds the waiter's worker slot and a non-empty ready queue.
It is not a regression: the pre-fix spawn() calls scheduleOrThrowOnError unconditionally,
so it aborts here too. This narrows the fix rather than breaking something new, as you say.
I am not pushing a fix this round, because both remedies you offer measure out unsound:
- Moving the count to the new pool also removes the pool's spawn allowance
(workers < max_threads + suspended_workers), which the waiter still needs since it keeps
occupying a worker slot. Result: no spawn is attempted at all (workers=2 max=2 susp=0). - Deriving the tier from live waited-job state with a separate counter keeps the suite green,
but amax_threads = 1pool then hangs: when the only worker is the waiter, dropping the
spawn leaves nobody to drain the queue.
The unanswered part: "the waiter will resume and drain" holds only if the awaited job can
progress in its new pool, and under global exhaustion it may not. So the tier cannot be
decided from pool-local counters alone, and dropping the spawn can trade a visible abort for a
silent hang. I would rather land the current strict improvement and treat this window as a
separate question with its own regression than widen the tolerated set on reasoning I cannot
back with a test.
CI finish ledger for 4a6ff09CI is fully finished on this head and there are no failures to attribute: 152 checks succeeded, 25 skipped, 0 failed. The praktika report for this head is
Session id: cron:our-pr-ci-monitor:20260815-180000 |
…r cannot be spawned
Backport #114897 to 26.7: Do not abort the server when a loader worker cannot be spawned
|
The residual I disclosed in this PR fired on the 26.7 release branch: From that run's own The tier is correct here, which is why I am not sending a patch. The three ways out all leave your file, so I would rather ask than guess:
One occurrence in 30 days post-fix, on a release branch, none on master, so this is not urgent. I also checked #112928: it reworks this seam and drops the Which of the three would you accept? I am happy to implement whichever you name, and I will leave this alone otherwise. |
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixes a server abort that happens when the global thread pool is momentarily saturated while tables are still loading.
AsyncLoadernow keeps running if it cannot spawn an extra loader worker and some worker will still drain the queued jobs, instead of callingstd::terminate.Description
AsyncLoader::spawnwrappedscheduleOrThrowOnErrorinNOEXCEPT_SCOPE, whose handler is unconditionallystd::terminate(). A transientCANNOT_SCHEDULE_TASKfrom a saturated global thread pool therefore killed the server (Received signal 6) during table loading. Seen 4 times over 4 unrelated PRs and 4 build flavours in 30 days, e.g.Stress test (arm_tsan), whose stack isterminate_handler()->std::terminate()->AsyncLoader.cpp:908spawn()->:744enqueue().A spawn must succeed only when nothing else will drain the pool's ready queue, so it now tells three states apart:
worker()and is not insidewait()drains the queue now;wait()on another pool's job resumes draining once that job runs;In the first two cases
spawnuses the non-throwingtrySchedule, rolls back itsPool::workersincrement on failure, warns and returns false. Only the last case keeps the fatal path, byte for byte: the queued jobs would be stranded, and throwing is not an option becauseschedulemarks that region non-exception-safe. Covering it needs rollback-and-retry across the loading DAG, out of scope here. All 4 observed rows are recoverable.BackgroundSchedulePoolnarrows the same way (929227464f9736b).Two counters are added because
Pool::workersalso counts a worker still queued in the global pool, which drains nothing, and one blocked inwait(), which cannot take its own pool's jobs. A saturated pool is remembered so the failing thread creation happens once per episode, not once per queued job, counted by the newAsyncLoaderSpawnFailuresevent; enteringwait()clears the memo.Tests are gtests asserting which branch was taken, not just that the process survived. A SQL test cannot reach the line: on master the whole call sits under
blockFaultInjections().Related: #110174, which removed the mask that hid this exception.
Version info
26.7.4.53