fix: write delete tombstones on a separate ClickHouse client - #5
Merged
Conversation
The delete phase died on its first batch with SESSION_IS_LOCKED (373), after the objects were already removed from S3. connect_to_ch() built one clickhouse_connect client, which the driver gives an auto-generated session_id, and ClickHouse allows a single query at a time per session. do_use() holds that session for the whole anti-join while consuming query_row_block_stream, and insert() issues its own DESCRIBE TABLE before writing - a second concurrent query on the held session. The job exited non-zero with up to --deletebatchsize objects deleted and no tombstone recorded, so a resumed run could not tell they were done. Tombstone writes now go to a second client built in the same call. Every earlier delete that reclaimed data ran with --order-by-objpath, which sorts the full result server-side before streaming, and against ClickHouse 25.x. The first run without global ordering - the documented default for Kubernetes Jobs - hit the lock 78 minutes in, on the first block the anti-join produced. Which of the two masked it was not established; the fix does not depend on the answer. Also expose USETOTAL in the Kubernetes Job template. --usetotal already existed on the command line and in the environment, but was unreachable through the renderer, so the only available delete was unbounded. A bounded run exercises anti-join, deletion and tombstone write-back in minutes and would have caught this defect cheaply. The key is optional and renders no variable when empty, since S3GC_USETOTAL is parsed as an integer; existing environment files render unchanged. Tests: both defects covered by regression tests that fail without the fix. pytest -m "not dev_cluster" (65 passed), renderer, and kubeconform against the default and bounded manifests all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What broke
A delete phase died 78 minutes in, on the first batch it processed:
The objects were already deleted from S3 when the tombstone write was rejected. The job exited non-zero having removed up to
--deletebatchsizeobjects with no record, so a resumed run could not tell they were done.Why
connect_to_ch()built a singleclickhouse_connectclient. The driver gives it an auto-generatedsession_id, and ClickHouse permits one query at a time per session.do_use()holds that session for the entire anti-join while it consumesquery_row_block_stream, andinsert()issues its ownDESCRIBE TABLEbefore writing — a second concurrent query on the held session.The bug is not new. It is present in every build back to the original single-client design; it simply had never fired.
Why it had never fired
Every earlier delete that actually reclaimed data ran with
--order-by-objpath, which makes the server sort the whole result before streaming it, and ran against ClickHouse 25.x. The run that hit this was the first delete without global ordering — the documented default for Kubernetes Jobs — and against 24.8.Which of the two previously masked it was not established. The fix doesn't depend on the answer, and depending on either to keep the session free was accidental rather than designed.
Changes
connect_to_ch()builds a second client, used only for writes issued while a stream from the first is open. Tombstone writes move to it. The comment at the call site says why, because a future reader will otherwise read the second connection as redundant.USETOTALis now settable from the Job template.--usetotalalready existed on the CLI and, viaenv_prefix="S3GC", in the environment — but the renderer never emitted it, so the only delete available to a Kubernetes operator was unbounded. A bounded run exercises anti-join → deletion → tombstone write-back in minutes; one would have caught this defect for the price of minutes instead of a multi-hour run that died with objects already gone. Optional, and renders no variable at all when empty, sinceS3GC_USETOTALis parsed as an integer. Existing env files render unchanged.CHANGELOG.mdrecords both, with the evidence.TODO.mdrecords a separate defect this investigation surfaced: no release has ever been published.container.ymltriggers ontags: ['v*.*.*'], but the repository's tags arev0.5,v_0.1andv_0.2— none can match. Every deployment so far has pinned a hand-builtdev-*digest with noorg.opencontainers.image.revision, which is why identifying which commit was running here required comparing log-message formats between runs. Not fixed in this PR; it needs avX.Y.Ztag, not a code change.Tests
Both defects have regression tests that fail without the fix:
test_tombstones_are_written_off_the_streaming_session— a fake client that rejects writes while its stream is open, modelling the one-query-per-session rule.test_connect_to_ch_builds_a_separate_writer_client— the two clients must be distinct, or the lock returns.USETOTALset, unset, invalid, and absent from the file (backward compatibility).Suggested release
The delete path is unusable without this, so it warrants a published image — which per the
TODO.mditem above means the first one this repository has ever produced.v0.6.0rather thanv0.5.1, sinceUSETOTALadds deployment configuration surface.