Skip to content

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

Merged
lesandie merged 1 commit into
masterfrom
test/pin-deletion-scope
Aug 24, 2026
Merged

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

Conversation

@lesandie

@lesandie lesandie commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Adds regression coverage for the SQL safety boundary: only collected, unreferenced objects on the configured disk and replicas can be deleted.

Validation

  • CI: passed
  • Local: 112 tests passed; rendered manifest validates

First in the stack: #6#7#9#10.

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.

@lesandie
lesandie merged commit 225c0d3 into master Aug 24, 2026
2 checks passed
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