feat: record durable run history in a ClickHouse table - #7
Open
lesandie wants to merge 1 commit into
Open
Conversation
Pod logs are not a record. The kubelet rotates container output (10Mi over 5
files by default), so `kubectl logs` cannot return the beginning of a long
run, and ttlSecondsAfterFinished deletes the Job and its pods along with
everything they printed. A cleanup that reclaimed terabytes left no evidence
of what it did once that window closed.
Each run now appends structured events to <COLLECTTABLEPREFIX><disk>_log,
beside the auxiliary table and NEVER truncated: phase start, throttled collect
progress, per-sample start, one row per confirmed delete batch, finish with
attempt totals, warnings and errors. Rows carry the scope the run was pointed
at -- bucket, prefix, disk, cluster, dry-run, ClickHouse host, hostname -- so a
row is self-describing evidence rather than a line of text. S3GC_RUNID defaults
to a generated timestamped id and the Job template passes JOB_NAME, so a row
traces back to the Job that wrote it.
ClickHouse is the sink rather than a volume or the bucket: the connection,
credentials and grants already exist; a readOnlyRootFilesystem container cannot
write a file and an emptyDir dies with the pod; and an object written under
S3PATH would be listed by the NEXT collect, found absent from
system.remote_data_paths, and become a deletion candidate -- s3gc would
garbage-collect its own logs.
Three constraints, each with a test, because this is bookkeeping attached to an
irreversible operation:
1. Writes go on ch_writer, never ch_client. do_use() holds ch_client's
session for the whole anti-join stream, and a second query on a held
session is SESSION_IS_LOCKED (373) -- the 0.6.0 defect, which would now
fire mid-delete at the worst possible moment.
2. A logging failure never fails the run. One failure disables the run log
for the remainder of the process rather than retrying every batch, and a
missing CREATE TABLE grant degrades to stdout only with one warning.
3. Messages are redacted through the existing LogFormatter._filter before
insert, so a credential cannot reach a table that outlives the run.
Collect also reports progress at INFO, throttled to every 100k objects. Per-
batch progress was DEBUG-only, so a multi-hour collect emitted about four lines
at --verbose, while --debug emits one line per object and on a large bucket
exceeds the kubelet's rotation limit, destroying the start of its own output.
Opt out with --runlog false / S3GC_RUNLOG_FLAG=false, or RUNLOG=false in the
renderer. Default on: durability is the point. Legacy env files render
unchanged with the run log enabled.
The deletion scope is untouched. All 11 scope mutations from the parent commit
remain caught.
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.
Why
Pod logs are not a record. The kubelet rotates container output (10Mi over 5 files by default), so
kubectl logscannot return the beginning of a long run, andttlSecondsAfterFinisheddeletes the Job and its pods along with everything they printed. A cleanup that reclaimed terabytes left no evidence of what it did once that window closed.What
Each run appends structured events to
<COLLECTTABLEPREFIX><disk>_log, beside the auxiliary table and never truncated.event_time,run_id,phase,event,message,objects,bytes, + scope: bucket, prefix, disk, cluster, dry-run, ClickHouse host, hostnameS3GC_RUNIDdefaults to a generated timestamped id; the Job template passesJOB_NAME--runlog false/S3GC_RUNLOG_FLAG=false/RUNLOG=falseRows are self-describing evidence rather than lines of text, so the questions an operator actually asks are one query:
Why ClickHouse, and not a volume or the bucket
readOnlyRootFilesystem: true; anemptyDirdies with the pod, and a PVC means provisioning, RWO scheduling and cleanup for a one-shot Job.s3:PutObjectgrant, and an object written underS3PATHwould be listed by the next collect, found absent fromsystem.remote_data_paths, and become a deletion candidate. s3gc would garbage-collect its own logs.Three constraints, each with a test
This is bookkeeping attached to an irreversible operation, so most of the tests are about what it must not do.
ch_writer, neverch_client.do_use()holdsch_client's session for the whole anti-join stream, and a second query on a held session isSESSION_IS_LOCKED(373) — the 0.6.0 defect, which would now fire mid-delete.test_run_log_writes_off_the_streaming_sessionuses the existingStreamingCHfake to prove it.CREATE TABLEgrant degrades to stdout only with one warning.LogFormatter._filterbefore insert, so a credential cannot reach a table that outlives the run.Also fixed
Collect now reports progress at INFO, throttled to every 100 000 objects. Per-batch progress was DEBUG-only, so a multi-hour collect over millions of objects emitted about four lines at
--verbose— while--debugemits one line per object, which on a large bucket exceeds the kubelet's rotation limit and destroys the beginning of its own output. The real choice was "almost nothing" or "too much to retrieve".The deletion scope is untouched
No change to the anti-join or to what gets deleted. Re-ran #6's mutation testing against this branch: 11 of 11 still caught.
Deferred
Two items added to
TODO.mdrather than fixed here:print()output is block-buffered (noPYTHONUNBUFFEREDin the image) so bare prints including the closings3gc: OKare lost whenactiveDeadlineSecondsfires — the run log covers the evidence case, but stdout fidelity is a separate change; and a possibleTTLon the run-log table, which grows without bound across many cleanups.Checks
🤖 Generated with Claude Code