Fix KeeperMap DELETE processing only the first block of matched rows - #112777
Conversation
The DELETE branch of StorageKeeperMap::mutate used `return` after successfully deleting the first block pulled from the mutation pipeline, so a DELETE matching more than max_block_size rows silently deleted only the first block while reporting success. Change it to `continue` so the loop consumes every block, and add a regression test. Closes: #112244 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Workflow [PR], commit [fe57e3c] Summary: ✅
AI ReviewSummaryThis PR fixes Final Verdict
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 46/48 (95.83%) · Uncovered code |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 3/5 (60.00%) · Uncovered code |
Address review: in `keeper_map_strict_mode`, when the version-checked `multi` request built for a block fails with `ZNONODE`, `StorageKeeperMap::mutate` fell back to removing every key of the block one by one with version `-1`. That drops the version checks captured by the mutation scan, so a row that another session updated in the meantime was deleted from a stale snapshot while the mutation reported success. It contradicts the documented guarantee that a strict-mode delete succeeds only if it can be executed atomically. In strict mode the `ZNONODE` conflict is now surfaced instead. The non-strict fallback is unchanged. The new test `04661_keeper_map_delete_strict_mode_conflict" uses the new `keeper_map_delete_pause_before_multi` failpoint to deterministically interleave a concurrent removal (and a concurrent update) between the block scan and the `multi` request.
Address review: with `keeper_map_strict_mode = 1`, `StorageKeeperMap::mutate` committed the `DELETE` one mutation block at a time. A conflict detected in a later block therefore failed the mutation *after* the earlier blocks had already been removed, which contradicts the documented guarantee that a strict-mode delete succeeds only if it can be executed atomically. In strict mode the remove requests of every block are now accumulated and sent as a single version-checked `multi` request once the scan is over, so the whole delete either applies or leaves the table untouched. Any failure of that request, `ZNONODE` included, is surfaced as is: retrying key by key would drop the version checks and skipping the failed keys would apply the delete partially. Non-strict deletes keep the previous block-by-block behaviour and its one-by-one fallback. Note that a strict-mode delete is now bounded by the maximum Keeper request size. Exceeding it fails the mutation without deleting anything, which is what the strict mode contract asks for. `04661_keeper_map_delete_strict_mode_conflict` gains a check that a multi-block strict delete issues exactly one write `multi` request while the non-strict one issues more than one.
|
🕵 Addressed the multi-block atomicity blocker in a8a52e1. In One consequence worth flagging: a strict-mode delete is now bounded by the maximum Keeper request size, and exceeding it fails the mutation without deleting anything. That is the fail-closed reading of "succeeds only if it can be executed atomically", and strict mode is opt-in and off by default, so I went with it rather than silently falling back to a non-atomic path. On the test request: a failpoint pause before a later block does not actually discriminate, because |
|
🕵 Merged the latest The previous red was not caused by this PR:
Both tests were verified locally against the freshly master-merged build (release, aarch64, embedded Keeper, @groeneai, investigate the failure: https://s3.amazonaws.com/clickhouse-test-reports/PRs/112777/a8a52e170d5655e7965e14167d0c886df00bc570/stateless_tests_arm_binary_parallel/job.log and provide a fix in a separate PR. If the fix is already in progress, link it here. |
|
I investigated that job. It is not caused by this PR, and it is not the The verdict is correct, the server was gone. "Unable to locate any ClickHouse server process" is printed on the branch where The cause is unreadable, and that is the part worth fixing. Every server-side artifact for that job 403s: Two open PRs cover the mechanics:
I would rather not open a third PR guessing at the server exit itself: with no crash text, no core and no server log anywhere in this family, anything I changed would be speculative. Once #112265 lands, the next occurrence keeps its logs and I will take the cause from there. |
Closes: #112244
Related: #112438
In the
DELETEbranch ofStorageKeeperMap::mutate, the per-block loop exited withreturnas soon as the multi-remove for the first block succeeded. As a result, aDELETEmatching more thanmax_block_sizerows (default65409) deleted only the first block of matched rows, while the remaining matched rows were silently left in the table andsystem.mutationsshowed the mutation as done with no error. BothALTER TABLE ... DELETEand the lightweightDELETE FROMare affected. The bug existed sinceDELETE/UPDATEsupport was added toKeeperMap(commit 454705b, v23.3).The fix replaces
returnwithcontinue, so the loop consumes every block pulled from the mutation pipeline — the same way theUPDATEbranch below does.The new test
04660_keeper_map_delete_multiple_blocksforces multiple blocks withSETTINGS max_block_size = 100and coversALTER TABLE ... DELETE, lightweightDELETE FROM, andkeeper_map_strict_mode = 1. Verified locally: on an unfixed build,ALTER TABLE ... DELETE WHERE key < 750on 1000 rows left 926 rows instead of 250, andDELETE FROM ... WHERE 1left 826 rows instead of 0; with the fix, the results match the reference.This reimplements #112438 (kudos to @waterWang for the fix) together with the regression test requested in its review; that pull request was closed without the test.
Additionally, the same
DELETEbranch broke thekeeper_map_strict_modecontract. When the version-checkedmultirequest built for a block failed withZNONODE, the code fell back to removing every key of the block one by one with version-1, dropping the version checks captured by the mutation scan. If the block was read ask1@v0, k2@v0, another session removedk1and updatedk2tov1, and themultifailed onk1, the fallback deleted the updatedk2from a stale snapshot while the mutation reported success — contradicting the documented guarantee that a strict-mode delete succeeds only if it can be executed atomically. In strict mode the conflict is now surfaced instead; the non-strict fallback is unchanged.Strict mode was also not atomic across mutation blocks: each block was committed on its own, so a conflict detected in a later block failed the mutation after the earlier blocks had already been removed. In strict mode the remove requests of every block are now accumulated and sent as a single version-checked
multirequest once the scan is over, so the whole delete either applies or leaves the table untouched. Non-strict deletes keep the previous block-by-block behaviour and its one-by-one fallback.Note that a strict-mode delete is now bounded by the maximum Keeper request size. Exceeding it fails the mutation without deleting anything, which is what the strict mode contract asks for.
The new test
04661_keeper_map_delete_strict_mode_conflictcovers all of this. It uses a newkeeper_map_delete_pause_before_multifailpoint to deterministically interleave a concurrent removal (and a concurrent update) between the block scan and themultirequest, and it checks that a multi-block strict delete issues exactly one writemultirequest while the non-strict one issues more than one. Verified locally against builds with each fix disabled: without the strictZNONODEcheck both race scenarios silently deleted the surviving row and reported success; with per-block strict commits the multi-block delete issued two writemultirequests instead of one. All are caught with the fixes in place.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix
DELETEonKeeperMaptables deleting only the firstmax_block_sizematched rows while reporting the mutation as successfully completed. Also fixDELETEwithkeeper_map_strict_mode = 1not being atomic: it could fall back to unversioned removals and delete rows that were concurrently updated, and it applied the matched rows block by block so a conflict could leave the delete partially applied.Version info
26.8.1.657(included in26.8and later)