Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next - #108977
Conversation
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-11:20260630-173800 |
|
cc @kssenii for review. Out-of-bounds crash in |
|
Workflow [PR], commit [6003acd] Summary: ✅
AI ReviewSummaryThis PR fixes the out-of-bounds abort in Findings
Final Verdict
|
CI finish ledger — 3ba3147Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task whose fixing-PR link will be posted here when it opens. Only
No PR-caused failures: the s3_queue OOB fix is a 1-guard change in Session id: cron:our-pr-ci-monitor:20260701-003000 |
| /// Reproduce the crash precondition: a non-processable file in the | ||
| /// batch together with a failed keeper multi that clears file_metadatas. | ||
| if (new_batch.size() > 1 && new_batch.back() && num_successful_objects > 0) |
There was a problem hiding this comment.
Can we reproduce the same in a more realistic way?
There was a problem hiding this comment.
Reworked the regression test to reproduce this through real S3Queue operation instead of a fabricated keeper failure.
The two crash preconditions are now produced the way they happen when several consumers share one keeper path:
- The failed keeper multi is real: the test pre-creates a real
processingnode in keeper for one batch file (node name =sipHash64(path), same asgetNodeName), so the engine's owntryMultifails against it with a realNode exists(realcode, realresponses, realgetFailedOpIndex). No fakedCoordination::Responses. - The non-processable file is produced by the engine's own
std::nulloptreject path (the same branch taken when another table on the path already grabbed the file). The only test-only hook is a small failpoint that flips one batch entry onto that existing path; it changes no keeper or vector state itself.
To keep it deterministic on slow CI the test waits for the ObjectStorageQueueFailedToBatchSetProcessing profile event before continuing, so the failing batch is always exercised.
Verified both directions locally: without the fix the server aborts in FileIterator::next (libc++ ... index out of bounds); with the fix it survives and the queue drains.
In the unordered hash-ring batch path of FileIterator::next, file_metadatas is resized to the batch size and populated per file. When a file in the batch is non-processable (num_successful_objects < new_batch.size()) and the keeper tryMulti that sets the batch as processing then fails, the else branch clears file_metadatas. The subsequent compaction block still wrote file_metadatas[i] = file_metadatas[batch_i] and called file_metadatas.resize() on the now-empty vector, going out of bounds and aborting the server. The trailing chassert(file_metadatas.empty() || ...) already documents that an empty file_metadatas is an expected post-state. Guard the file_metadatas compaction so it only runs when file_metadatas was not cleared; new_batch is still compacted as before. Downstream code already handles an empty file_metadatas (next() returns a null FileMetadataPtr). The regression test reproduces both crash preconditions the way they occur when several consumers share one keeper path: a real processing node is pre-created in keeper for one batch file so the engine's own keeper multi fails against it (real Node exists, no faked keeper response), and a small failpoint marks another file non-processable through the same std::nullopt path the engine takes when a file is already being processed elsewhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3ba3147 to
c5f10ed
Compare
Internal second-model review — adjudication log (click to expand)Pre-publication review by an independent model (engine: codex; 3 findings; bounded: 1 full pass + 1 fix round + 1 delta recheck).
Severity: ❌ blocker / Session id: cron:clickhouse-author-slot-1:20260721-122500 |
|
Test reworked per @kssenii's request to reproduce the crash realistically (real keeper conflict, no fabricated responses). Re-validated both directions. Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-author-slot-1:20260721-122500 |
| # All files except the one left in an in-memory Processing state by the aborted | ||
| # multi are processed; the point is that the server survived and the queue drains. | ||
| run_with_retry(lambda x: x >= files_to_generate - 1, get_count) |
There was a problem hiding this comment.
This assertion is hiding a real recovery bug in the fallback path that this PR exercises. After the batch tryMulti fails and file_metadatas is cleared, the conflicted file is replayed through next(size_t) with file_metadata == nullptr, so it falls back to trySetProcessing (ObjectStorageQueueSource.cpp:551-555). If the foreign processing node still exists, ObjectStorageQueueUnorderedFileMetadata::setProcessingImpl returns {false, Processing} and afterSetProcessing stores Processing in the shared FileStatus cache (ObjectStorageQueueIFileMetadata.cpp:313-372).
From that point on, subsequent attempts short-circuit on state == Processing and never go back to Keeper, even after this test deletes the conflict node. In other words, the queue can leave one file permanently stuck in the local metadata cache instead of draining once the conflict disappears. I think the fix needs to keep that file retryable (or clear the cached Processing state on the individual fallback failure) rather than teaching the regression test to accept >= files_to_generate - 1.
There was a problem hiding this comment.
Verified against the code, the mechanism is correct. After the batch tryMulti fails, file_metadatas is cleared (ObjectStorageQueueSource.cpp:400); the conflicted file is replayed through next(size_t) with file_metadata == nullptr -> trySetProcessing (551-555); with the foreign processing node present, ObjectStorageQueueUnorderedFileMetadata::setProcessingImpl hits the create_processing_node_idx failure branch and returns {false, Processing}, so afterSetProcessing caches Processing via updateState (ObjectStorageQueueIFileMetadata.cpp:369-372). Subsequent trySetProcessing then short-circuits on state == Processing (294-304), and reset() runs only from resetProcessing(), which needs created_processing_node == true (never set on a foreign-node conflict). So the file can stay in in-memory Processing after the conflict node is gone. Confirmed.
Two scope points:
-
It is pre-existing and independent of this PR. It needs only the failed batch multi (cond2), not the out-of-bounds compaction path this PR fixes (cond1). With a failed multi and no skipped file,
file_metadatas.clear()still runs,num_successful_objects == new_batch.size()so the compaction block is skipped, and every file including the conflicted one is replayed with null metadata (454-457). TheafterSetProcessing/trySetProcessing/updateStatelogic that produces the stuck state is untouched here, so it already reproduces on master whenever a batch multi loses a race against a foreignprocessingnode. -
In normal operation the stale cache is harmless: the owning consumer creates a
processednode when it finishes, andfilterOutProcessedAndFailedthen drops the file from future listings so it never returns to this consumer. The stuck case needs theprocessingnode to vanish without the file being processed (a crashed/abandoned consumer, or the manualzk.deletehere), a real but narrow recovery gap.
So this PR stays scoped to the crash: it asserts that the abort is gone and the queue survives and drains, and >= files_to_generate - 1 states that faithfully. Clearing or retrying the cached Processing on an individual fallback failure changes shared FileStatus cache semantics and deserves its own review, so I am tracking it as a separate follow-up rather than folding it into this crash fix.
There was a problem hiding this comment.
Fixed in a separate pull request: #112313
The mechanism you described is exactly right, and it is pre-existing on master and independent of this crash: after trySetProcessing loses the race for the processing node, afterSetProcessing caches the local FileStatus as Processing, the two non-processable checks treat Processing as terminal, and only the processed/failed node cleanup evicts local_file_statuses - never the stale processing node cleanup.
#112313 makes the cached Processing state a hint instead of a terminal state (FileStatus::onProcessingByAnotherProcessor), so the file is picked up again once the foreign processing node is gone, with an integration test that emulates a foreign processor releasing a file without committing it. It is kept out of this pull request so that the out-of-bounds fix here stays a minimal crash fix; the >= files_to_generate - 1 assertion can be tightened to equality once #112313 is merged.
The Changelog category is also changed to Critical Bug Fix as requested.
There was a problem hiding this comment.
Closing the loop on the follow-up I committed to track above: this recovery gap is now being fixed by #112313, so I am not opening a PR of my own for it.
That fix keeps the observation of a foreign processing node in the cache but records it as a timestamped hint with a TTL, and relaxes the two non-processable checks to Processing && !isProcessingRetryable(). That is a superset of what I had planned: it also avoids probing Keeper for the file on every polling pass, and it keeps the case where the Processing state belongs to a concurrent local processor distinguishable from a foreign one, which a plain "do not cache it" approach collapses. Since it changes the same block of afterSetProcessing, a second PR on those lines would only conflict with it.
One thing that survives it and is unrelated to the behaviour: the status column of system.s3queue_metadata_cache and system.azure_queue_metadata_cache documents Processed, Processing, Failed, but the value is printed straight from FileStatus::State, which also has None (the initial value of a cache entry, and what reset writes). I am tracking that one-line description fix separately, after #112313 lands.
CI finish ledger — c5f10edOur new regression test
|
There was a problem hiding this comment.
This code path is still unrealistic, need inject a thrown error in needed place if you say this happens in case of error
There was a problem hiding this comment.
The crash needs two conditions in one batch, and both are non-throwing paths, which is exactly why the pre-fix compaction mishandled them:
- cond2, the real error, already injected: the keeper multi at
ObjectStorageQueueSource.cpp:373returns a non-ZOK code (not a thrown exception), which triggersfile_metadatas.clear()at :399. The test makes this real by pre-creating an actualprocessingnode in keeper for one batch file, so the engine's owntryMultifails against it with a real code and real responses. - cond1, not an error: another file in the same batch is non-processable, so
num_successful_objects < new_batch.size()and the compaction at :402 runs over the cleared vector. In production this is the benignstd::nulloptskip fromprepareSetProcessingRequestswhen another consumer on the same server already holds the file (lock atObjectStorageQueueIFileMetadata.cpp:324, or state already Processing/Processed/exhausted-Failed at :334). It never throws.
A literal throw at either site would not reproduce this: there is no try/catch between the batch loop and the compaction (the nearest is generate() at :1015), so a throw escapes next() before the compaction runs, while the out-of-bounds is inside that compaction.
The only artificial part left is how cond1's file is made non-processable (the force_skip failpoint). I can make that realistic by having a genuine second consumer mark the file Processing first and dropping the failpoint, but that turns the non-processable window into a race, so the test would need retries and risks being flaky. Which do you prefer: the two-consumer version, or keeping the failpoint for cond1 together with the real keeper error for cond2?
There was a problem hiding this comment.
The unrealistic bit is gone now: the failpoint no longer sits in FileIterator::next forcing a std::nullopt. It was moved into ObjectStorageQueueIFileMetadata::prepareSetProcessingRequests, where it makes file_status->processing_lock.try_lock report failure — i.e. it takes exactly the production path of a file already grabbed by another consumer on the same server, and returns std::nullopt from the real code, not from a test-only branch in the iterator.
So both batch preconditions are now produced by real code paths:
- one file returns
std::nulloptfrom the genuine processing-lock conflict branch (failpoint only flips thetry_lockresult,ONCE); - another file makes the keeper
tryMultifail for real, against aprocessingnode that the test pre-creates in keeper (realcode, realresponses, realgetFailedOpIndex), sofile_metadatas.clear()happens through the engine's own error handling.
No exception is thrown on either path, which is why the pre-fix compaction indexed past the end of the cleared vector.
|
@groeneai, the |
alexey-milovidov
left a comment
There was a problem hiding this comment.
The test might be not realistic, but the fix itself is trivial, so it is good for me.
…rigin` of `toStartOfInterval` The three-argument overload of `toStartOfInterval` computed the result as `origin + offset` without an overflow check. For an origin near the lower bound of `Int64` in a time zone east of UTC the offset is negative - the interval boundary in local time lies before the origin - so the addition wrapped around and produced a bogus value instead of an error. The UBSan build reported it as `signed integer overflow: -9223372036854775807 + -7200000000000 cannot be represented in type 'Int64'` at `src/Functions/toStartOfInterval.cpp:293`, found by the AST fuzzer in the `Stress test (arm_asan_ubsan)` job. Use `common::addOverflow` and throw `DECIMAL_OVERFLOW`, matching the check already applied to the `time_arg - origin` subtraction a few lines above. Verified with an A/B build: before the fix `toStartOfInterval(reinterpret(toInt64(-9223372036854775807), 'DateTime64(9, \'Asia/Istanbul\')'), toIntervalHour(3), <same origin>)` returns the wrapped `2262-04-12 00:47:16.854775809`; after the fix it throws `DECIMAL_OVERFLOW`. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108977&sha=7dbcb13520f1a4671a9453f2fdd71a51e4f3a45f&name_0=PR&name_1=Stress%20test%20%28arm_asan_ubsan%29 Related: ClickHouse#108977
…-strip-uaf Picks up the fix for the s3_queue server abort that failed the integration tests on this branch: an out-of-bounds `vector` access in `ObjectStorageQueueSource::FileIterator::next` (PR #108977, merged as 2f335d3). CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=111192&sha=fe3e297d38e5b6974711ee5cd522baa3a90d6d99&name_0=PR&name_1=Integration%20tests%20%28arm_binary%2C%20distributed%20plan%2C%202%2F4%29 Related: #111192 Related: #108977 Related: #111659 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ueSource::FileIterator::next
Backport #108977 to 26.7: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
Backport #108977 to 26.5: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
Cherry pick #108977 to 25.8: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
…ueSource::FileIterator::next
Cherry pick #108977 to 26.3: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
…ueSource::FileIterator::next
Backport #108977 to 26.6: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
Backport #108977 to 25.8: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
Backport #108977 to 26.3: Fix out-of-bounds crash in ObjectStorageQueueSource::FileIterator::next
…StorageQueueSource::FileIterator::next
…StorageQueueSource::FileIterator::next
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix a server crash (out-of-bounds access) in
S3Queue/AzureQueuewithenable_hash_ring_filtering = 1when a batch contained a non-processable file and the Keeper request to set the batch as processing failed at the same time.Description
Reported by @ alexey-milovidov on #107740 (unrelated to that PR). Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=107740&sha=9b2ca442ced2acecfefdd7307edee38be6a5dbf1&name_0=PR&name_1=Integration%20tests%20%28amd_asan_ubsan%2C%20db%20disk%2C%20old%20analyzer%2C%202%2F6%29
In the unordered hash-ring batch path of
ObjectStorageQueueSource::FileIterator::next,file_metadatasis sized to the batch and populated per file. When the KeepertryMultithat sets the batch as processing fails, theelsebranch clearsfile_metadatas. The following compaction block, entered whenever some file in the batch was non-processable (num_successful_objects < new_batch.size()), still didfile_metadatas[i] = file_metadatas[batch_i]andfile_metadatas.resize(...)on the now-empty vector, so the subscript went out of bounds and aborted the server. The trailingchassert(file_metadatas.empty() || new_batch.size() == file_metadatas.size())already documents that an emptyfile_metadatasis an expected post-state.The compaction now writes
file_metadatasonly when it was not cleared;new_batchis compacted as before. Downstream code already handles an emptyfile_metadatas(a nullFileMetadataPtris returned).Regression test:
test_batch_set_processing_failure_does_not_crashintests/integration/test_storage_s3_queue/test_parallel_inserts.pyreproduces both preconditions the way they occur when several consumers share one Keeper path. The failed Keeper multi is real - the test pre-creates a realprocessingnode for one file of the batch (node name computed the same way asgetNodeName), so the batchtryMultifails withNode existsand clearsfile_metadatas. The non-processable file in the same batch is produced by theobject_storage_queue_skip_one_file_in_batchfailpoint, which takes the samestd::nulloptpath as a file already grabbed by another consumer. The test waits for theObjectStorageQueueTrySetProcessingFailedprofile event so the fixed path is guaranteed to have been exercised. Without the fix the server aborts inFileIterator::next; with it the queue keeps draining.Follow-up (pre-existing on
master, independent of this crash): after the per-file fallbacktrySetProcessingloses to a foreignprocessingnode, the localFileStatusis cached asProcessingand is never invalidated, so that one file can stay skipped by this table until restart or cache eviction. That is why the regression asserts>= files_to_generate - 1rather than equality. Fixed separately in #112313.Version info
26.8.1.562(included in26.8and later)26.7.2.54,26.6.2.144,26.5.6.103,26.3.17.107,25.8.29.49