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>
What broke
A delete phase died 78 minutes in, on the first batch it processed:
Code: 373. DB::Exception: Session … is locked by a concurrent client. (SESSION_IS_LOCKED) s3gc.py in do_use -> ch_client.insert( clickhouse_connect/.../orchestration.py -> yield QueryOp("DESCRIBE TABLE …")The objects were already deleted from S3 when the tombstone write was rejected. The job exited non-zero having removed up to --deletebatchsize objects with no record, so a resumed run could not tell they were done.
Why
connect_to_ch() built a single clickhouse_connect client. The driver gives it an auto-generated session_id, and ClickHouse permits one query at a time per session. do_use() holds that session for the entire anti-join while it consumes query_row_block_stream, and insert() issues its own DESCRIBE TABLE before 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
Tests
Both defects have regression tests that fail without the fix:
Suggested release
The delete path is unusable without this, so it warrants a published image — which per the TODO.md item above means the first one this repository has ever produced. v0.6.0 rather than v0.5.1, since USETOTAL adds deployment configuration surface.