Skip to content

test: pin the deletion scope with regression tests - #6

Open
lesandie wants to merge 1 commit into
masterfrom
test/pin-deletion-scope
Open

test: pin the deletion scope with regression tests#6
lesandie wants to merge 1 commit into
masterfrom
test/pin-deletion-scope

Conversation

@lesandie

Copy link
Copy Markdown
Member

Why

The suite proved that a candidate list is deleted, batched and checkpointed correctly. It never proved the list contains only orphans.

FakeCH.query_row_block_stream ignores the anti-join SQL and returns pre-canned blocks, so the one statement that forms the entire safety boundary was never asserted on. The two tests that did read stream_query only checked ORDER BY.

Evidence

Mutation testing established the gap rather than assuming it — the repo was copied to a scratch dir, the delete scope was broken eleven ways, and the full suite was run against each.

Mutation Before After
LEFT ANTI JOINLEFT JOIN — candidates include referenced objects 65 passed caught
--dry-run performs real deletions 65 passed caught
Drop clusterAllReplicas fan-out — blobs referenced only by another replica look orphaned 65 passed caught
Point disk_name at a nonexistent disk — orphans the whole bucket 65 passed caught
Drop the remote_path = objpath join key 65 passed caught
Drop the --useage grace window 65 passed caught
Drop s3o.active=true 65 passed caught
Remove the preflight_cluster() call 65 passed caught
Preflight accepts any replica count 65 passed caught
Ignore S3 delete errors caught caught
Tombstone unconfirmed rows caught caught

2 of 11 → 11 of 11.

What this does not change

s3gc.py is deliberately untouched. The anti-join is correct today and no behaviour changes. These tests exist so that a future edit which quietly widens the deletion scope fails a test instead of reaching a customer bucket.

What is now pinned

13 tests asserting the scope directly: anti-join semantics, the remote_path = objpath join key, the disk_name predicate, the clusterAllReplicas fan-out when a cluster is configured, active=true, the --useage window, candidates being drawn only from the auxiliary table, that a dry run cannot reach S3 at all, and that the cluster/replica preflight gates the delete path.

Defects recorded, not fixed

Each needs an explicit decision, so they are documented in CHANGELOG.md and tracked in TODO.md rather than changed here:

  • USEAGE_HOURS=0 silently removes the only guard against deleting a part mid-write. ClickHouse uploads a part's blobs to S3 and registers them in system.remote_data_paths a moment later; in that window a live blob looks orphaned. There is no per-object re-check before the S3 delete, so the --useage clause is the whole safety margin — and if args.useage else "" emits no clause for 0, which render.py accepts (only negatives are rejected). Covered by test_useage_zero_disables_the_age_guard, which documents the hazard.
  • --useafter is interpolated unquoted, the only unquoted value in the anti-join WHERE clause. Recorded as a strict xfail so it flips to a failure the moment it is fixed.
  • The cluster/replica preflight is point-in-time, run once, while the anti-join is re-issued per sample over what can be hours. A replica lost mid-run is not re-detected.

Checks

pytest -v -m "not dev_cluster"     78 passed, 1 xfailed
render.py example.env              OK (94 lines)
kubeconform -strict                Valid: 1, Invalid: 0, Errors: 0

🤖 Generated with Claude Code

The suite proved a candidate list was deleted, batched and checkpointed
correctly, but never proved the list contained only orphans. FakeCH's
query_row_block_stream ignores the anti-join SQL and returns pre-canned
blocks, so the one statement that forms the entire safety boundary was
never asserted on.

Mutation testing established the gap rather than assuming it. Against the
previous 65-test suite, nine of eleven deliberate breakages of the delete
scope passed fully green, including:

  - LEFT ANTI JOIN -> LEFT JOIN, which makes every *referenced* object a
    deletion candidate
  - --dry-run performing real deletions
  - dropping the clusterAllReplicas fan-out, so blobs referenced only by
    another replica look orphaned
  - pointing disk_name at a nonexistent disk, orphaning the whole bucket
  - dropping the --useage window and the cluster/replica preflight

Only the two error-bookkeeping mutations were caught. All eleven are now
caught.

s3gc.py is deliberately untouched: the anti-join is correct today, so this
changes no behaviour. It exists so a future edit that quietly widens the
deletion scope fails a test instead of reaching a customer bucket.

Three defects found while auditing are recorded, not fixed, because each
needs an explicit decision (CHANGELOG.md, TODO.md):

  - USEAGE_HOURS=0 silently removes the only guard against deleting a part
    between its blob upload and its registration in remote_data_paths.
    render.py accepts 0; `if args.useage else ""` then emits no clause.
  - --useafter is interpolated unquoted, the only unquoted value in the
    anti-join WHERE clause. Recorded as a strict xfail.
  - The cluster/replica preflight is a point-in-time check, run once, while
    the anti-join is re-issued per sample over hours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lesandie

Copy link
Copy Markdown
Member Author

Review record — how this gap was found

Posting the method so the evidence is on the record and reproducible, not just the conclusion.

The deletion scope was audited by mutation testing: the repo was copied to a scratch directory, the delete-scoping logic was deliberately broken eleven different ways, and the full suite was run against each. A mutation that "survives" is one the tests did not notice.

Mutation Before this PR After
LEFT ANTI JOINLEFT JOIN — candidates include referenced objects 65 passed ⚠️ caught
--dry-run performs real deletions 65 passed ⚠️ caught
Drop clusterAllReplicas fan-out — blobs referenced only by another replica look orphaned 65 passed ⚠️ caught
Point disk_name at a nonexistent disk — orphans the whole bucket 65 passed ⚠️ caught
Drop the remote_path = objpath join key 65 passed ⚠️ caught
Drop the --useage grace window 65 passed ⚠️ caught
Drop s3o.active=true 65 passed ⚠️ caught
Remove the preflight_cluster() call 65 passed ⚠️ caught
Preflight accepts any replica count 65 passed ⚠️ caught
Ignore S3 delete errors caught caught
Tombstone unconfirmed rows caught caught

2 of 11 → 11 of 11.

Root cause of the gap

FakeCH.query_row_block_stream ignores the SQL and returns pre-canned blocks:

def query_row_block_stream(self, query):
    self.stream_query = query      # stored, never evaluated
    return FakeStream(self.blocks)

The candidate list was hardcoded by the fake. The suite proved "given a list of orphans, deletion, batching and checkpointing behave correctly" — which it did well — but never "that list contains only orphans". The two tests that read stream_query checked ORDER BY and nothing else.

What this PR does and does not claim

The anti-join is correct today, and there is no evidence s3gc has ever deleted referenced data. s3gc.py is untouched here and no behaviour changes. The point is narrower: a future edit that quietly widens the deletion scope now fails a test instead of reaching a customer bucket.

Findings surfaced during the audit

Three defects were found and deliberately not fixed in this PR, so each got an explicit decision rather than a drive-by change:

  1. USEAGE_HOURS=0 removes the only guard against deleting a part mid-write — decided during review, fixed in fix: enforce a 24 hour floor on the deletion age window #9 (hard floor of 24, default 24).
  2. --useafter is interpolated unquoted — recorded as a strict xfail, so it flips to a failure the day it is fixed.
  3. The cluster/replica preflight is point-in-time, run once while the anti-join is re-issued per sample over hours — tracked in TODO.md.

Anyone can reproduce the table above; the mutation driver is a short script that patches s3gc.py in a scratch copy and re-runs pytest per mutation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant