Skip to content

US-32.1 AC-32.1.2: guard the revoke sweep's plan in the scale job - #935

Merged
mkreyman merged 4 commits into
masterfrom
test/us-32.1-plan-guard-v2
Sep 28, 2026
Merged

mkreyman merged 4 commits into
masterfrom
test/us-32.1-plan-guard-v2

Conversation

@mkreyman

@mkreyman mkreyman commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Replaces #934. #934's third review round still found material defects, so under the review ceiling this is a rewrite rather than a fourth round. It follows up #932, which removed the default suite's plan-choice assertion for AC-32.1.2.

What it asserts: RevokeExpiredDispatchesPlanScaleTest (:scale, run by a new step in CI's scale-test job):

  • Commits a dispatches table in the shape a running one settles into: a large revoked history, a small live set, and a small expired backlog.
  • Rebuilds the partial index and runs VACUUM ANALYZE.
  • Captures the query RevokeExpiredDispatchesWorker.perform/1 actually issues, through repo telemetry.
  • Requires the default planner to use dispatches_expires_at_active_index, through the existing PlanAssertions.assert_index_used/2.

The query reads one table, so a plan that uses the index cannot also Seq Scan it. With 50 live rows the planner uses an Index Scan. With a larger live set it uses a Bitmap Index Scan. Both seek the index. Measured on a clean table, the index is used from 50 live rows up to half the table.

What #934 got wrong, and this doesn't:

  • Leaking cleanup: US-32.1 AC-32.1.2: guard the revoke sweep's natural plan in the scale job #934's cleanup never finished. The unindexed self-reference parent_dispatch_id makes a plain delete of 20k dispatches scan the table once per row, so every local run leaked its seed. About 200k rows piled up, and that skewed the plan measurements US-32.1 AC-32.1.2: guard the revoke sweep's natural plan in the scale job #934 recorded.
    • Here the dispatches are deleted with FK triggers off, inside their own transaction.
    • The tenant is deleted afterwards, with its cascades on.
    • The slug is a UUID, so a leftover tenant can't collide with the next run's.
  • Bloated index: in a database the test had already run in, the partial index held 70 entries in 131 pages. The index scan and the Seq Scan then cost nearly the same, and the plan flipped from run to run. The seed now runs REINDEX on the index and VACUUM ANALYZE, so every run plans against the state CI's fresh database has.
  • Smaller change: no new PlanAssertions helper, no CI-only skip, and no database-name guard. It is tagged :scale like every other scale test.

Evidence:

  • Stable: 6 of 6 local runs passed, with a pause in the middle. No rows are left afterwards.
  • Mutations (mutate.sh, all exit 0):
    • worker predicate made non-implying (coalesce)
    • range on expires_at replaced by a date cast
    • asserted index name swapped
  • Gate: 12002 tests, 0 failures.

Review round 1 found 10 issues. All 10 are fixed:

  • Index at the cause: the cleanup worked around an unindexed foreign key. Migration 20260928140000 now adds concurrent partial indexes on both unindexed foreign keys into dispatches:

    • dispatches.parent_dispatch_id
    • story_acceptance_criteria.verified_by_dispatch_id

    Every bulk delete of dispatches used to scan the referencing table once per row. The test's cleanup is now a plain delete with triggers on. ForeignKeyIndexesTest fails the default suite whenever a foreign key into dispatches has no index.

  • Sweep rolled back: the sweep is cross-tenant, so it now runs in a transaction the test rolls back. Inside that transaction the test checks that exactly the backlog was revoked.

  • Composite ruled out: PlanAssertions.refute_index_used/2 rules out the tenant-leading composite index, so a bitmap scan that reads it whole fails.

  • Smaller fixes:

    • the captured query is matched by SELECT ... FROM "dispatches", not by one spelling of its predicate;
    • the index rebuild uses REINDEX CONCURRENTLY;
    • the tenant comes from fixture(:tenant);
    • the moduledoc names the async exception;
    • both independent scale-job steps run unless the job was cancelled;
    • AC-32.1.2 and the README agree on the accepted scans.

Mutations (mutate.sh, each exit 0):

  • index dropped under ForeignKeyIndexesTest
  • worker predicate made non-implying
  • asserted index swapped
  • composite check pointed at the partial index
  • the sweep's write removed

Gate: 12003 tests, 0 failures. The first attempt hit one 57014 in KnowledgeAutoExtractTest: an insert into a table that references tenants was cancelled. That is the same unconfirmed lock-wait flake recorded in #932. The retry passed.

Review round 2 found 9 issues. All 9 are fixed:

  • FK check: exact leading columns.
  • Plan check: the named index must exist and be the only index in the plan.
  • Migration: reconciles with stale?/2, so a re-run never drops a valid index.
  • Seed timing: live rows expire hours out.
  • Leftovers: invalid rebuild leftovers are dropped first.
  • CI: the gates no longer run after a failed setup.
  • Fixture and status: the seed is a fixture, and US-32.1's criteria are marked complete.

Review round 3, the last, found 10 issues. All 10 are fixed. None was in the migration or in what the guard catches; all were about how precise the helper checks are:

  • FK check: counts only btree key columns (not INCLUDE columns), and accepts a partial index only when its NOT NULL predicate is on the foreign key's own column.
  • Statistics freshness: judged on the column the planner uses, by pg_stats.null_frac against the actual NULL fraction.
  • Index validity: the index must be valid and on the named table.
  • Migration shape: the regex is anchored.
  • Backlog timing: backlog rows expire minutes in the past, clear of clock skew.
  • Leftovers: the leftover filter is exact.
  • CI: the gates also require the grant step.

Mutations (each caught):

  • FK index dropped
  • FK index replaced with one that has another column's NOT NULL predicate
  • FK index replaced with one that has the key only as an INCLUDE column
  • FK index replaced with a hash index
  • worker predicate made non-implying
  • a second index in the plan
  • the wrong table
  • a differently shaped seed without ANALYZE: stale null_frac 0.0035 against an actual 0.20
  • the sweep's write removed

Gate: 12003 tests, 0 failures.

…places #934)

RevokeExpiredDispatchesPlanScaleTest commits a dispatches table in the shape a running one
settles into (a large revoked history, a small live set, a small expired backlog),
rebuilds the partial index and VACUUM ANALYZEs, captures the query
RevokeExpiredDispatchesWorker.perform/1 issues through repo telemetry, and requires the
default planner to use dispatches_expires_at_active_index, through the existing
PlanAssertions.assert_index_used/2. A new step in CI's scale job runs it.

The seed is removed with foreign-key triggers off for the dispatches only (the
self-reference parent_dispatch_id is unindexed, so a plain delete of 20k rows scanned the
table per row and never finished), and the tenant is deleted after, with its cascades on.

The migration comment, the worker test, AC-32.1.2 and the epic 35 README point at it and
name the scans it accepts: an Index Scan, or a Bitmap Index Scan on the index.

Mutations (bin/mutate.sh, all exit 0): worker predicate made non-implying (coalesce);
range on expires_at replaced by a date cast; asserted index name swapped.
…the sweep back

- Migration 20260928140000 adds concurrent partial indexes on the two unindexed foreign
  keys into dispatches, dispatches.parent_dispatch_id and
  story_acceptance_criteria.verified_by_dispatch_id. Without them every bulk delete of
  dispatches checked each row against a table scan. The test's cleanup is now a plain
  delete with every trigger on, and ForeignKeyIndexesTest fails the default suite when a
  foreign key into dispatches has no index.
- The sweep runs inside a transaction the test rolls back, so a cross-tenant perform/1
  cannot revoke another test's dispatches; inside it the test checks the write too: exactly
  the backlog is revoked.
- The plan must use the partial index and must not use the tenant-leading composite
  (new PlanAssertions.refute_index_used/2), so a bitmap that reads the composite whole
  fails.
- The captured query is the SELECT from dispatches, not one spelling of its predicate.
- REINDEX CONCURRENTLY, the tenant from fixture(:tenant), the async exception named in the
  moduledoc, and both independent scale-job steps run unless the job was cancelled.
- AC-32.1.2 and the epic 35 README name the scans accepted and the index ruled out.

Mutations (bin/mutate.sh, exit 0 each): index dropped under the FK test; worker predicate
made non-implying; asserted index swapped; composite refute pointed at the partial index;
sweep's write removed.
…r-long live rows

- ForeignKeyIndexesTest requires a valid index whose leading columns are exactly the
  foreign key's, unconditional or conditioned only on NOT NULL; a tenant-led index or an
  unusable partial one no longer counts.
- PlanAssertions.assert_only_index_used/3 replaces refute_index_used/2: the named index
  must exist and be the only index in the plan, and the relation's statistics must be
  current (assert_stats_current!/1, pg_class.reltuples within 10 percent of the rows).
- The migration drops an index only when it exists invalid or misshapen (stale?/2, as in
  20260919100000), so a re-run never drops a valid index on the hot table.
- Live rows expire hours out, so a slow REINDEX cannot expire one mid-test; invalid
  _ccnew leftovers of an interrupted concurrent rebuild are dropped first.
- The scale-job gates run after one another fails, but not after the database failed to
  migrate.
- The seed is fixture(:dispatch_sweep_history); US-32.1's criteria are marked complete.

Mutations (bin/mutate.sh, exit 0 each): index dropped, replaced by a wrong-predicate one,
and by a tenant-led one under ForeignKeyIndexesTest; worker predicate made non-implying;
asserted index renamed away; a second index put into the plan; REINDEX and VACUUM ANALYZE
both removed (stale statistics); the sweep's write removed.
…atistics freshness

- ForeignKeyIndexesTest: leading KEY columns only (INCLUDE columns are sliced off at
  indnkeyatts), btree only, and a partial index counts only when its predicate is NOT NULL
  on one of the foreign key's own columns.
- assert_only_index_used/4 requires the index to be valid and ON the named relation, and
  judges freshness by assert_column_stats_current!/2: pg_stats.null_frac of the named
  column within 0.02 of its actual NULL fraction. reltuples, which VACUUM and index builds
  also write, no longer stands in for column statistics. One quoted, public-qualified name
  is used throughout, and a missing relation or column raises the assertion, not a
  MatchError.
- The migration's shape regex is anchored at CREATE INDEX, so a same-named UNIQUE index is
  rebuilt.
- The backlog expires minutes ago, clear of app/database clock skew.
- The test drops only invalid _ccnew/_ccold rebuild leftovers of its own index, on
  public.dispatches.
- The scale-job gates also require the grant step to have succeeded.

Mutations (exit 0 or caught, each): FK index dropped; replaced by a partial index on
another column's NOT NULL; by a tenant_id index INCLUDE-ing the key; by a hash index;
worker predicate made non-implying; a second index in the plan; the index checked against
the wrong table; a different seed shape without ANALYZE (stale null_frac 0.0035 vs 0.20);
the sweep's write removed.
@mkreyman
mkreyman enabled auto-merge (squash) September 28, 2026 20:33
@mkreyman
mkreyman merged commit 4f11c54 into master Sep 28, 2026
17 checks passed
@mkreyman
mkreyman deleted the test/us-32.1-plan-guard-v2 branch September 28, 2026 20:41
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