Skip to content

Fix startup hang when max_thread_pool_size is too small - #112928

Open
alexey-milovidov wants to merge 62 commits into
masterfrom
fix-startup-hang-small-thread-pool
Open

Fix startup hang when max_thread_pool_size is too small#112928
alexey-milovidov wants to merge 62 commits into
masterfrom
fix-startup-hang-small-thread-pool

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 1, 2026

Copy link
Copy Markdown
Member

Setting max_thread_pool_size to a value that is too small made clickhouse-server hang forever at startup instead of reporting the problem.

A ThreadFromGlobalPool created while all the threads of the global thread pool are already taken by other such jobs was silently put into the pool's queue instead of getting a thread. The queue is much larger than the number of threads by default (thread_pool_queue_size is 10000 against max_thread_pool_size), so scheduling succeeded, but nothing ever picked the job up, because the threads are held by jobs that never return. Its creator got a seemingly running thread whose function was never called, and any code that then waited for that thread to make progress hung forever.

An idle server permanently occupies about 200 threads of the pool for the workers of the background pools and of the background schedule pools, and for the flush threads of the system logs, so a max_thread_pool_size below that ran out of threads while starting up:

  • With max_thread_pool_size of 128 the starved job was the loader worker of the system database, and startup hung forever right after Wait load job 'startup Atomic database system'.
  • With 100 it hung in the constructor of the merge/mutate background executor instead.
  • Values between roughly 130 and 200 appeared to start, but silently left some background threads unstarted - for example the flush threads of the system logs, so those tables never received any data.

Jobs that occupy their worker for the whole lifetime of that worker are now scheduled through ThreadPoolImpl::scheduleThreadOrThrow, which hands them one of the max_threads slots at scheduling time and throws CANNOT_SCHEDULE_TASK naming max_thread_pool_size when there is none left, rather than queueing a thread that can never start.

Two components started threads in their constructor and stopped them only in their destructor, so the resulting exception left those threads running while the destructor of the member thread pool blocked forever joining them. Both now stop their workers before letting the exception out, following the pattern CacheDictionaryUpdateQueue already uses:

  • MergeTreeBackgroundExecutor - this was the second hang above. Its increaseThreadsAndMaxTasksCount now starts the additional workers on a best-effort basis, so a configuration reload does not fail when the pool is saturated, and it reports the number of workers that actually started.
  • BackgroundSchedulePool - its handler used to LOG_FATAL and abort, turning a misconfiguration into signal 6 and a crash report. It now logs the same hint and rethrows, so the server exits cleanly.

Note that the hint in that handler ("Please make sure max_thread_pool_size is considerably bigger than background_schedule_pool_size") was previously unreachable, because saturating the global pool never produced CANNOT_SCHEDULE_TASK.

Behaviour change. Values of max_thread_pool_size that cannot hold the permanent threads of the server now make it refuse to start with a clear error naming the setting, where some of them used to start in a silently degraded state. This looks like the intended contract: max_thread_pool_size is documented as the maximum number of threads in the pool, and the documentation of thread_pool_queue_size already recommends keeping it equal to max_thread_pool_size, which makes saturation an error today. The requirement is now documented on the setting itself. No test configuration is affected: tests/casa_del_dolor/properties.py randomises max_thread_pool_size only down to 700.

Tested by starting a server with values of 16, 32, 64, 100, 128 and 160 (all fail with the error, no abort, no crash report) and 192, 256 and the default (all start normally), plus a check for slot accounting leaks under concurrent load with max_thread_pool_size of 400. The full unit_tests_dbms suite passes, including FunctionsStress.

The pre-existing ThreadPool.GlobalFull1 and ThreadPool.GlobalFull2 tests did not catch this because they give the global pool a queue as small as its thread count, which makes saturation fail on queue fullness instead. The new ThreadPool.GlobalFullLargeQueue covers the shape of the real pool, and the test_thread_pool_size_too_small integration test asserts that the server terminates with the error rather than hanging.

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fixed a hang at server startup when max_thread_pool_size was set too small to hold the threads that the server occupies permanently. Such a configuration is now reported with an error naming the setting, instead of hanging forever or starting with some background threads missing.


Workflow [PR]
Sync PR [sync-upstream/pr/112928]

A `ThreadFromGlobalPool` created while all the threads of the global thread pool are already taken by
other such jobs was silently put into the pool's queue instead of getting a thread. The queue is much
larger than the number of threads by default (`thread_pool_queue_size` is 10000 against
`max_thread_pool_size`), so scheduling succeeded, but nothing ever picked the job up: the threads are
held by jobs that never return. Its creator got a seemingly running thread whose function was never
called, and any code that then waited for that thread to make progress hung forever.

An idle server permanently occupies about 200 threads of the pool for the workers of the background
pools and of the background schedule pools and for the flush threads of the system logs, so a
`max_thread_pool_size` below that ran out of threads while starting up. With 128 the starved job was
the loader worker of the `system` database, and the server hung forever at `Wait load job 'startup
Atomic database system'`. Larger values below 200 appeared to start, but silently left some background
threads unstarted - for example the flush threads of the system logs, so those tables never got any
data.

Jobs that occupy their worker for the whole lifetime of that worker are now scheduled through
`ThreadPoolImpl::scheduleThreadOrThrow`, which hands them one of the `max_threads` slots at scheduling
time and throws `CANNOT_SCHEDULE_TASK` naming `max_thread_pool_size` when there is none left, rather
than queueing a thread that can never start.

Two components started threads in their constructor and stopped them only in their destructor, so the
resulting exception left those threads running while the destructor of the member thread pool blocked
forever joining them. Both now stop their workers before letting the exception out:
- `MergeTreeBackgroundExecutor`, which was a second hang, reachable with `max_thread_pool_size` of 100.
  Its `increaseThreadsAndMaxTasksCount` now starts the additional workers on a best-effort basis, so a
  configuration reload does not fail when the pool is saturated, and reports the count that did start.
- `BackgroundSchedulePool`, whose handler used to `LOG_FATAL` and `abort`, turning a misconfiguration
  into signal 6 and a crash report. It now logs the same hint and rethrows, so the server exits cleanly.

The pre-existing `ThreadPool.GlobalFull1` and `ThreadPool.GlobalFull2` tests did not catch this because
they give the global pool a queue as small as its thread count, which makes saturation fail on queue
fullness instead. `ThreadPool.GlobalFullLargeQueue` covers the shape of the real pool, and the
`test_thread_pool_size_too_small` integration test asserts that the server terminates with the error
instead of hanging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [27385f5]

Summary:


AI Review

Summary

This PR changes ThreadFromGlobalPool startup so long-lived workers fail fast instead of being silently queued when max_thread_pool_size is too small, and it audits many permanent-worker call sites to unwind cleanly on the new synchronous failure mode. The caller-side cleanup is much better now, but ThreadPoolImpl::scheduleThreadOrThrow still has one concurrency hole in its idle-worker path, so I do not think the PR is merge-ready yet.

Findings

❌ Blockers

  • [src/Common/ThreadPool.cpp:617] scheduleThreadOrThrow still queues the long-lived job when it plans to reuse an existing idle worker, so a concurrent higher-priority ordinary scheduleOrThrow can still overtake that worker before it grabs the queue entry. In that interleaving the caller gets a "running" ThreadFromGlobalPool, but its function has not started and can still deadlock behind ordinary work. The idle-worker path needs the same direct handoff as the fresh-worker path instead of pushing the job through the shared priority queue.
Final Verdict

❌ Changes requested.

LLVM Coverage Report

Measured on commit 27385f5.

Metric Baseline Current Δ
Lines 88.90% 88.90% +0.00%
Functions 91.70% 91.70% +0.00%
Branches 81.30% 81.30% +0.00%

Changed lines: Changed C/C++ lines covered: 719/812 (88.55%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 1, 2026
Comment thread src/Common/ThreadPool.cpp Outdated
Comment thread src/Common/ThreadPool.cpp
@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 27385f543 with master dc868195c (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Object file sizes

140 object files changed (+66.48 KiB total), 0 added.

Object file Master PR Δ
src/CMakeFiles/clickhouse_common_io.dir/Common/ThreadPool.cpp.o 291.64 KiB 315.64 KiB +24.00 KiB (+8.23%)

716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

393 translation units recompiled, 3058 s compile time in total, 393 of them have a recent master baseline.

Median compile-time ratio to the baselines is ×1.07 (machine-speed difference or a change affecting every TU); per-TU deltas below are relative to that ratio.
The matched translation units cost +188.8 s (+7%) in total before that adjustment.

Job report

alexey-milovidov and others added 3 commits August 3, 2026 23:29
…tcherOld` constructor

Starting `request_thread` or `responses_thread` may now throw `CANNOT_SCHEDULE_TASK`
synchronously when the global thread pool has no free slot for a long-running job.
Destroying a still joinable `ThreadFromGlobalPool` during the unwind would abort the
process, so mark the context as shut down, finish the queues, and join whatever
workers were started before rethrowing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ackRunnerFast::operator()` fails to start a worker

`operator()` enqueued the callback and incremented `active_tasks` before
`startMoreThreadsIfNeeded`, but unlike `bulkSchedule` it did not roll either back
when `pool->scheduleOrThrow` throws. The caller saw `CANNOT_SCHEDULE_TASK` while the
callback stayed queued and could run later, when another worker eventually starts.
Add the same rollback as in `bulkSchedule`, plus a fault-injection unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Common/ThreadPool.cpp Outdated
alexey-milovidov and others added 4 commits August 4, 2026 20:26
…inary

The bugfix-validation gate runs touched unit tests on the before-binary and
expects them to fail. Without the rollback, the failed schedule leaves the
callback in `queue` while `queue_size` is never incremented, so a subsequent
schedule strands its callback forever and the test hung at
`second_done.get_future().wait()` until the 4-hour job timeout:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112928&sha=42c61b8707873ac1a0949e0261adfae93dc407d6&name_0=PR&name_1=Bugfix%20validation%20%28unit%20tests%29
Guard the second-schedule phase on `isIdle`: if the rollback did not happen,
the failure is already recorded and we bail out via `shutdown`, which is safe
in that state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r fails to start one

`Coordination::Storage::BackgroundWork::BackgroundWork` and `Changelog::Changelog`
start multiple long-lived `ThreadFromGlobalPool`s. Now that `ThreadFromGlobalPool`
construction can throw `CANNOT_SCHEDULE_TASK` synchronously, a failure on the
second or later start would unwind and destroy already-started joinable threads,
and `~ThreadFromGlobalPoolImpl` aborts before the real error is reported.
`BackgroundWork` reuses `shutdown` in the catch; `Changelog` finishes the worker
queues and joins whatever threads were started (its `shutdown` cannot be used:
it dereferences not-yet-created thread pointers). Add a fault-injection
regression test constructing `Changelog` under `CannotAllocateThreadFaultInjector`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bugfix-validation gate runs the test on the before-binary, where it aborts
mid-test by design, leaving `./logs_ctor_unwind` behind. `ChangelogDirTest`
expects the directory to not exist, so a subsequent run in the same workspace
would fail spuriously. Remove the directory up front instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Coordination/KeeperRequestDispatcherOld.cpp Outdated
alexey-milovidov and others added 3 commits August 5, 2026 15:14
…atcher constructor unwinds

`KeeperRequestDispatcherOld`'s constructor set `keeper_context->setShutdownCalled()` in its
catch block to make the already-started worker threads exit before rethrowing. That flag is
process-global and `KeeperDispatcher::shutdown` joins its own threads only on the first
transition of it, so consuming it during the unwind left `snapshot_thread` (started earlier by
`KeeperDispatcher::initialize`) never joined, and the process aborted on its still joinable
destructor instead of surfacing `CANNOT_SCHEDULE_TASK`.

The worker loops now also exit when their queue is finished, which keeps the constructor's
cleanup local: finish the queues, join the started workers, rethrow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n the unfixed binary

On the binary without the fix, `scheduleOrThrowOnError` silently queued `another_pool`'s
worker instead of throwing, and the pool's destructor then joined a "thread" whose function
never started - the very hang this test covers - so the Bugfix validation (unit tests) job ran
the test on the before-binary and hung for three hours until the 4-hour job timeout:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112928&sha=9feac815b044ee5dedff11339963464a4c05c606&name_0=PR&name_1=Bugfix%20validation%20%28unit%20tests%29
#112928

When the expected exception does not come, release the occupied threads and destroy the pool
that holds the global slots first, so the silently queued worker can run and both pools shut
down cleanly, then skip the rest of the test. Verified locally against master's `ThreadPool`:
the test now fails in milliseconds with a clean process exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 @groeneai, investigate the failure: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112928&sha=9feac815b044ee5dedff11339963464a4c05c606&name_0=PR&name_1=Stress%20test%20%28arm_asan_ubsan%2C%20s3%29 and provide a fix in a separate PR. If the fix is already in progress, link it here.

The hung check flagged a thread stuck in ReplaceRegexpImpl::checkSubstitutions inside QueryAnalyzer::resolveFunction — an expensive constant-folded replaceRegexpAll call running during query analysis, outside the cancellation machinery. This is unrelated to this PR's thread pool changes and matches the open issue:

Related: #112203

Comment thread src/Common/ThreadPool.cpp Outdated
@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

I investigated this one. The #112203 attribution does not hold for this artifact, and the actual hang is a different thing that I do not think has an owner yet.

The quoted frames are not the hung stack. In hung_check.log the thread dump spans lines 62-2011 (stacktraces: through Collecting stacktraces from system.stack_trace table). All 3 ReplaceRegexpImpl hits and all 25 resolveFunction hits are at lines 2226+, i.e. inside the Top messages not matching their format strings section that starts at line 2174. The ReplaceRegexpImpl.h:103 frame is entry #5 under the message Substitution '\{}' in replacement argument is invalid, regexp has only {} capturing groups, and its frames #0-#4 are Poco::Exception::Exception / DB::Exception::Exception constructors, so it is a thrown and handled exception being counted, not a stack. tryFoldFunctionToConstant, executeImplDryRun and processString (the frames your issue's own evidence names) appear 0 times anywhere in the file.

What actually hung. Processlist Row 1 is 04357_json_nested_prefixes_deserialization_pool.sql:

SELECT startsWith(dynamicType(data.arr), 'Array(JSON') FROM t_json_nested_pool WHERE id = 5;

elapsed: 1532.478, is_cancelled: 1, read_rows: 0, peak_threads_usage: 117. Of the 91 dumped threads, 85 sit in __lll_lock_wait: 72 on the stack-local callbacks_mutex (SerializationObject.cpp:601, entered at :604 from the safe_getter / safe_dynamic_subcolumns_callback wrappers at :605 and :611), and 13 on FileSegment::lock() from FileSegment::getInfoForLog (FileSegment.cpp:1170, :1172). The three running threads are all inside getInfoForLog or blocked on a marks future.

It was not deadlocked, it was crawling. The query emitted 20,290,878 log lines between 02:08:58.981 and 02:35:41.605, of which 20,290,743 are <Test> and 20,277,080 come from CachedOnDiskReadBufferFromFile. It was still emitting Read 50 bytes on the last line, and per-minute volume was rising (2.6M lines/min at the end vs 8k/min at the start), so there is no lost wakeup here.

The cost driver is that the read is advancing ~51 bytes at a time: 6,757,397 read events totalling 345,926,192 bytes, mean 51.2 bytes, and 1,217,090 points where the Remaining size to read counter grew again, i.e. that many separate seek/discard passes. The path is ReadBufferFromEncryptedFile::performSeekAndSetReadUntilPosition (ReadBufferFromEncryptedFile.cpp:221) -> ReadBufferFromRemoteFSGather::seek (ReadBufferFromRemoteFSGather.cpp:221), which for a short forward seek does ignore(diff) rather than a real seek, and each of those tiny reads takes the FileSegment guard again through the LOG_TEST at CachedOnDiskReadBufferFromFile.cpp:1509. MergeTreeMarksLoader ran 723 times over 708 distinct .cmrk2 files for a query that selected 2/2 granules, because the nested Array(JSON) expands to one mark stream per dynamic subpath. So: many tiny cache reads, each serialized behind a per-segment mutex, while 72 pool threads contend on one prefix-callback mutex.

Two things worth separating:

  • send_logs_level for this query was warning, but the server config for stress runs is logger_trace.xml (<level>trace</level>), and these are LOG_TEST calls. I have not established which channel actually admitted 20M <Test> lines, so I am not claiming the logging is the cause; I am claiming it is a large multiplier on an already-quadratic read, since getInfoForLog formats the whole segment description under the guard on every 51-byte read.
  • The lock convoy on callbacks_mutex is real regardless of logging. That mutex exists to serialize non-thread-safe stream callbacks across the prefix-deserialization pool, so with 117 threads and 708 mark streams it serializes essentially the whole read.

On ownership. This is not #113415 (ReadFromMerge::createChildrenPlans) or #113263 (UNDROP TABLE busy wait) - neither frame appears. It is adjacent to my open #112918, which adds cancellation checkpoints to the JSON/Object structure-prefix read, but that PR checkpoints the path-list loops in deserializeObjectStructureStatePrefix; it does not touch the per-mark cache read or the callbacks_mutex contention that dominates here, so it would not fix this. I am not going to claim it does.

I would rather not send a fix until I can reproduce the ~51-byte read pattern locally and confirm which of the two multipliers is load-bearing, because the plausible one-line fixes here (making the getInfoForLog diagnostics lazy, or raising min_bytes_for_seek on this path) are exactly the kind of change that looks right and moves nothing. I will follow up in this thread with either a PR or the measurement that says which one it is.

Separately, this artifact again lost its server-side system.stack_trace dump - the file says Cannot collect C stacktraces under ASan: debugger attach is disabled, so the 91 stacks above are the processlist ones only.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Following up on my earlier comment with the promised measurement. It identifies the load-bearing multiplier, and it turned out to be neither of the two candidates I named there. The fix already exists as an open PR of mine, so I am linking it rather than opening a second one.

Fixing PR: #112644 ("Do not read-and-discard the archive prefix when reading a packed part member").

What the multiplier is

The part in that run is in Packed storage, so every column file lives inside one data.packed archive: min_bytes_for_full_part_storage = 536870912 on the test table's CREATE (the runner randomizes it at tests/clickhouse-test:1881), and the part paths in the server log are store/8ca/.../all_1_1_0/data.packed on cached_s3_encrypted.

Two things then compound:

  1. MergeTreeMarksLoader sizes the read buffer for the virtual marks member (MergeTreeMarksLoader.cpp:179 -> adjustBufferSize(file_size), where file_size is the packed index entry, measured 47/50/54 bytes), but PackedFilesReader::readFile opens the whole archive (PackedFilesReader.cpp:125). The clamp propagates down the outer chain, so ReadBufferFromEncryptedFile gets a 47-byte buffer (DiskEncrypted.cpp:443).
  2. remote_read_min_bytes_for_seek stays at its default 4 MiB (Settings.cpp:6777; it was at default here, and the runner does not randomize it). The archive is 563,485 bytes, i.e. smaller than the threshold, so seeking to the member is judged "too short to seek" and ReadBufferFromRemoteFSGather::seek serves it with ignore(diff) instead (ReadBufferFromRemoteFSGather.cpp:251-253) - reading and discarding the whole prefix through that 47-byte buffer.

Measured on the query's own log lines and ProfileEvents:

  • 6,757,397 reads, 314,175,092 bytes, mean 51.3 B/read, for a query with SelectedMarks: 2
  • consecutive read offsets on one thread step by exactly the buffer size (54 x 12,228, 47 x 7,020, anything else x 4; monotone fraction 1.000) - a sequential prefix scan, not scattered reads
  • 563485 / 47 = 11,989 reads per open x 723 mark loads over 708 distinct .cmrk2 files (nested Array(JSON)) ~ 8.7M, matching the observed 6.76M
  • FileSegmentLockMicroseconds = 36,483,604,759; LogTest = 18,474,083; elapsed 26m43s against max_execution_time = 60

Only 1,958 seeks occur, which is why I initially read the seek path as a consequence. That inference was wrong: one ignore(diff) issues diff / buffer_size reads, so 6,757,397 / 1,958 = 3,451 reads per seek is itself the signature of an ignore-driven scan.

The two candidates I flagged earlier, both refuted

  • Making the getInfoForLog diagnostics lazy is already a no-op: LOG_IMPL short-circuits before evaluating its arguments (logger_useful.h:81), so nothing is formatted when the level is off. It would reduce a consequence (18.5M LOG_TEST lines) and leave the 6.76M reads. One nuance worth recording: chassert(..., getInfoForLog()) does evaluate in debug/ASan builds, so under ASan it is a real extra per-read cost - still a consequence, not the cause.
  • Raising min_bytes_for_seek is the wrong direction; the fix is to lower it to the effective buffer, which is what Do not read-and-discard the archive prefix when reading a packed part member #112644 does, mirroring StorageObjectStorageSource.

grep -c min_bytes_for_seek src/IO/PackedFilesReader.cpp is 0 at the failing sha and 0 on master, and 4 at #112644's head - so the fix is genuinely absent from the build that hung.

On the #112203 attribution

I could not reproduce it for this artifact. In hung_check.log the thread dump is lines 62-2011 (stacktraces: at 62, Collecting stacktraces from system.stack_trace table: at 2012), while every ReplaceRegexpImpl hit (3) is at line >= 2718 and every resolveFunction hit (25) at >= 2226 - all inside the Top messages not matching their format strings section that starts at 2017, as stacks of thrown-and-handled exceptions. tryFoldFunctionToConstant, executeImplDryRun and processString appear 0 times anywhere in the file. Of the 90 thread blocks in the dump, 84 are in __lll_lock_wait (61 on the SerializationObject prefix-callback mutex, 12 on FileSegment::lock), and every running thread is inside the marks read chain.

Also related but not a fix for this: my open #112918 makes a JSON structure-prefix read observe cancellation, which would have ended this query at the 60 s deadline instead of letting it run 26 minutes. It touches neither the packed reader nor the per-mark read path, so I am not claiming it covers this.

…rt` unwinds

If starting `cleanup_thread` throws `CANNOT_SCHEDULE_TASK` because the global
thread pool is full, `createServer` destroys the half-started listener without
calling `stop`, and the defaulted destructor aborted on the still-joinable
`server_thread`. Call `stop` before rethrowing, following the same
partial-start cleanup pattern as the other components in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Common/ThreadPool.cpp Outdated
alexey-milovidov and others added 3 commits August 7, 2026 03:56
…inds

If starting `cleanup_thread` throws `CANNOT_SCHEDULE_TASK`, the already
started `main_thread` used to keep running with a half-started worker.
Reuse `shutdown` (idempotent, joins whichever threads exist) in the catch
and rethrow, so the failure propagates fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gs` constructors unwind

Two more sites of the same class as the reviewed ones: if the Nth
`ThreadFromGlobalPool` start throws `CANNOT_SCHEDULE_TASK`,
- the `AsynchronousInsertQueue` constructor destroyed the member vector
  with joinable threads (`flushAndShutdown` is never called for an object
  that failed to construct), terminating the process;
- the `SystemLogs` constructor called `startCollect` outside its existing
  try/catch, and the implicit `PeriodicLog` destructor does not join
  `collecting_thread`, also terminating on unwind.

Both now stop and join the already started threads and rethrow, so a too
small `max_thread_pool_size` surfaces as a startup error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Common/ThreadPool.cpp Outdated
…in `FileCache::initialize`

With `load_metadata_asynchronously` enabled, a `CANNOT_SCHEDULE_TASK` failure
from `CacheMetadata::startup` was deferred into `init_exception` on the metadata
loading thread, so the server came up with a registered but permanently broken
filesystem cache. Start the permanent background threads (downloads, delayed
cleanup) in the synchronous part of `FileCache::initialize`, before the
(possibly asynchronous) metadata loading, so the exception reaches the caller
and startup fails close. `CacheMetadata::startup` now also stops and joins
partially started download threads when it unwinds.

Until the metadata is loaded, the threads only sleep on their empty queues:
cache users block in `assertInitialized` on `init_mutex` until the load
finishes, and the delayed-cleanup consumer takes the same per-bucket locks as
the loader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Common/ThreadPool.h
Comment thread src/Interpreters/ExternalLoader.cpp
Comment thread src/Server/GRPCServer.cpp
Comment thread src/Interpreters/ExternalLoader.cpp Outdated
…he loader thread

`LoadingDispatcher::startLoading` started the background load thread and only
then inserted it into `loading_threads`. A throwing `try_emplace` (allocation or
rehash) would unwind through a joinable `ThreadFromGlobalPool` local, and
`~ThreadFromGlobalPoolImpl` aborts the process instead of propagating the
exception. Reserve the map entry first and move the started thread into it; the
move assignment is `noexcept`.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Update:

  • AI review Major (src/Interpreters/ExternalLoader.cpp:1018) — confirmed real and fixed in 574a09543e15: the loading_threads entry is now reserved before the loader thread is started, and the started thread is move-assigned into it (noexcept), so no throwing operation happens while a joinable ThreadFromGlobalPool local is alive.
  • Merged master (1185 commits behind); ninja clickhouse and ninja unit_tests_dbms are clean, and the ThreadPool.*, FileCacheTest.*Init* and Changelog*ThreadStart* gtests pass locally.
  • Stateless tests (arm_binary, parallel) / 04628_and_compare_chain_index_hint — unrelated: the test asserts Granules: 1/64, which breaks under the randomized index_granularity_bytes MergeTree setting. It also failed on More settings to randomize #106734 and Constant filter folding under materialize #105987. Fix submitted separately in Fix flaky 04628_and_compare_chain_index_hint under randomized adaptive granularity #115767.
  • Bugfix validation (unit tests) — reported inconclusive (Submodule state differs between the merge-base and the checkout (contrib/llvm-project, contrib/musl)), i.e. the base branch moved submodules after the branch split. The master merge above should refresh the merge-base and let the gate run.

Comment thread src/Storages/NATS/StorageNATS.cpp
…s after starting the event loop thread

`StorageNATS` starts `event_loop_thread` and then creates the `streaming_task` and
`initialize_consumers_task` schedule-pool tasks. A destructor does not run for a constructor
which throws, so an exception from that tail destroyed a joinable `ThreadFromGlobalPool`
member directly and `~ThreadFromGlobalPoolImpl` terminated the process instead of propagating
the original exception. The tail is now wrapped in a `try`/`catch` which calls
`stopEventLoop` before rethrowing.
…all-thread-pool

Merged with an explicit merge base (`b30eb447799f`): this checkout is shallow, so
Git found a spurious second merge base and the recursive merge produced bogus
conflicts across unrelated files.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The Stress test (amd_tsan) red at 9adec037f504 is Logical error: 'Equal values are not contiguous within the range assumed to be sorted' in DistinctSortedStreamTransform / getEqualRangeEndAssumeSorted — unrelated to this pull request (it does not touch sorting or DISTINCT), and already tracked as #115455 (same STID 2508-3319).

@groeneai, please investigate #115455 and provide a fix in a separate PR; if a fix is already in progress, please link it there.

The Bugfix validation (unit tests) red was the inconclusive submodule gate (Submodule state differs between the merge-base and the checkout (contrib/silk)) — merging master refreshes the merge-base.

@groeneai

Copy link
Copy Markdown
Collaborator

It is already linked there: I posted the investigation and the deliverable on #115455 on 2026-08-19 (issuecomment-5345020545), and I answered the same question on #110084 at 02:01 UTC today (issuecomment-5389858292). Nothing has changed since, so rather than posting a third copy, the state in short:

3319 is still firing: 19 runs over 19 distinct commits in 30 days, 3 of them on master, most recently 2026-08-23 22:09 UTC.

The one step available without that ruling is to land #113242 first with 00717_merge_and_distributed temporarily adjusted for the known _table prefilter row loss, and restore it when #113735 merges. I have not done it because it weakens an existing test, but I will if you prefer that to waiting.

…all-thread-pool

# Conflicts:
#	src/Interpreters/SystemLog.cpp

@alexey-milovidov alexey-milovidov left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is good.

@alexey-milovidov alexey-milovidov self-assigned this Sep 3, 2026
@alexey-milovidov alexey-milovidov removed the submodule changed At least one submodule changed in this PR. label Sep 3, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Sep 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 3, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Sep 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 4, 2026
@clickhouse-gh clickhouse-gh Bot added the comp-query-execution Runtime execution pipeline, processors, vectorized operators, resource management during execution. label Sep 4, 2026
Comment thread src/Common/ThreadPool.cpp
}
else
{
jobs.emplace(

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.

scheduleThreadOrThrow only binds the long-lived job to a worker when adding_new_thread is true. In the existing-idle-worker path we still enqueue the job into the shared jobs heap here and only wake an idle worker afterwards.

That leaves the original contract break reachable under concurrent scheduling. Concrete trace: with max_threads = 2, let one ordinary job already be running and one worker be idle. Thread A enters scheduleThreadOrThrow, falls into this branch, queues the long-lived job, and wakes the idle worker. Before that worker reacquires mutex, thread B can scheduleOrThrow a higher-priority ordinary job onto the same pool. The woken worker then takes B from jobs.top() in ThreadFromThreadPool::worker() (src/Common/ThreadPool.cpp:1198-1199 on current head), so A's long-lived job is still left in the queue even though scheduleThreadOrThrow already returned success.

I think the idle-worker path needs the same direct handoff as the fresh-worker path: pick a specific idle worker under mutex, install this job into that worker (or another per-worker reserved slot), and only then wake it.

@serxa serxa self-assigned this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp-query-execution Runtime execution pipeline, processors, vectorized operators, resource management during execution. pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants