Skip to content

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

Closed
mkreyman wants to merge 3 commits into
masterfrom
test/us-32.1-plan-guard
Closed

mkreyman wants to merge 3 commits into
masterfrom
test/us-32.1-plan-guard

Conversation

@mkreyman

@mkreyman mkreyman commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #932, which removed the default suite's plan-choice assertion for AC-32.1.2. This PR brings that guarantee back where the planner's statistics can be controlled.

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

  • It commits a production-shaped dispatches table: 20k revoked history rows and 50 live rows. It then runs ANALYZE.
  • It captures the query RevokeExpiredDispatchesWorker.perform/1 actually issues, through repo telemetry.
  • The default planner (no enable_seqscan = off) must read dispatches exactly once, by an Index Scan on dispatches_expires_at_active_index with an Index Cond on expires_at.

Why it runs in the scale job:

Seed shape: it mirrors a table that has been running for a while: mostly revoked history, a small live set, and a small expired backlog. On a clean table the index is used at every live fraction tried, from 50 up to 20,000 live rows (half the table).

Docs: the migration comment, the worker test's moduledoc, AC-32.1.2 and the epic 35 README now point at this test.

Evidence:

  • Mutations (mutate.sh), all exit 0:
    • worker predicate made non-implying (coalesce)
    • live fraction raised to 10%
    • the ANALYZE removed
    • the asserted index name swapped
  • Gate: 12002 tests, 0 failures. The scale test itself passes locally under SCALE_TESTS=true.

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

  • PlanAssertions.assert_index_range_scan/4 replaces the test's own plan walker.
  • on_exit is registered before seeding.
  • setup :verify_on_exit! is added.
  • An expired backlog is added to the seed.
  • The migration comment describes what CI asserts.
  • The live-fraction concern is answered by measurement (above).

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

  • Local-run guard replaced: round 1's database-name guard refused the main tree but let a worktree's own test database through. It and its local recipe are gone; the module is skipped unless CI=true.
  • Helper: it reuses relation_scan_nodes, accepts a bitmap scan driven by the index, and refuses a table that was never ANALYZEd.
  • Docs: seed sizes are no longer restated, and the accepted scan types are named.
  • The flip measurement was wrong: the round-1 claim that the planner seq-scans near 2% live was measured on a polluted table. The test's own cleanup had been timing out (four foreign keys reference dispatches, one unindexed, so deleting 20k rows scanned the table per row). About 200k rows had leaked. The cleanup now deletes with FK triggers off inside its own transaction, and it is measured to leave nothing.

Mutations (mutate.sh, CI=true), each exit 0:

  • worker predicate made non-implying
  • index name swapped
  • Index Cond regex swapped
  • the helper's scan-type check replaced

Raising the live set and removing the ANALYZE both pass now, which matches the measurement: neither is load-bearing on a clean table.

Review round 3 is running.

… job

RevokeExpiredDispatchesPlanScaleTest commits a production-shaped dispatches table (20k
revoked history, 50 live), ANALYZEs it, captures the query RevokeExpiredDispatchesWorker's
perform/1 issues through repo telemetry, and requires the default planner to read
dispatches exactly once, by an index range scan on dispatches_expires_at_active_index with
an Index Cond on expires_at. It runs in CI's scale job, not the default suite, because an
ANALYZE writes pg_class reltuples in place and would skew other tests' plans (#932). The
rows are deleted and the table re-ANALYZEd on exit.

The migration comment, the worker test, the story and the epic 35 README point at it.

Mutations (bin/mutate.sh, all exit 0): worker predicate made non-implying (coalesce);
live fraction raised to 10 percent; the ANALYZE removed; the asserted index name swapped.
… it off the shared DB

- The moduledoc says what the guard covers and where it stops, with a measurement: at
  this seed the default planner picks the partial index at 500 live of 20,500 (2.4
  percent) and seq-scans at 1,000 of 21,000 (4.8 percent), because it multiplies the
  selectivities of revoked_at IS NULL and expires_at < $1 as if independent.
- The seed adds a 20-row expired, unswept backlog, the steady-state shape.
- PlanAssertions.assert_index_range_scan/4: exactly one scan of the relation, an Index or
  Index Only Scan on the named index, with a matching Index Cond; the plan is printed on
  every failure. The test's own EXPLAIN walker is gone.
- on_exit is registered before seeding, so a failed seed is still removed.
- The test refuses the shared loopctl_test database outside CI; the moduledoc gives the
  MIX_TEST_PARTITION commands.
- setup :verify_on_exit!.
- The migration comment says both captures are by hand and describes what CI asserts.

Mutations (bin/mutate.sh, exit 0 each): worker predicate made non-implying; live fraction
10 percent; ANALYZE removed; index name swapped; Index Cond regex swapped.
…op the flip claim

- The test's own cleanup never finished: four foreign keys reference dispatches, the
  self-reference among them unindexed, so deleting 20k rows scanned the table per row and
  on_exit timed out. Every local run leaked its seed, about 200k rows by the time it was
  found. The delete now runs with session_replication_role = replica inside its own
  transaction (the rows are inserted raw and nothing references them), and the slug is a
  UUID so a leaked tenant can never collide with the next run's.
- The live-fraction flip recorded in round 1 was measured on that polluted table. On a
  clean table the index is sought at every live fraction tried, 50 up to 20,000 live rows
  (half the table), so the claim and the migration comment's pointer to it are gone.
- The module is skipped unless CI=true, replacing the database-name guard that refused the
  main tree but passed a worktree's own test database, and the local recipe with it.
- assert_index_range_scan builds on relation_scan_nodes (which now carries the raw node),
  also accepts a Bitmap Heap Scan driven by a single Bitmap Index Scan on the index, and
  refuses a relation pg_stat_user_tables shows was never ANALYZEd.
- The migration comment no longer restates seed sizes and names the accepted scan types.

Mutations (bin/mutate.sh, CI=true): worker predicate made non-implying, exit 0; index
name swapped, exit 0; Index Cond regex swapped, exit 0; helper's scan-type check
replaced, exit 0. Raising the live set to 2,000 and removing the ANALYZE both pass now,
consistent with the measurement above: neither is load-bearing on a clean table.
@mkreyman

Copy link
Copy Markdown
Owner Author

Replaced by #935. Round 3 here still found material defects, so under the review ceiling the change was rewritten rather than given a fourth round; #935's body says what changed.

@mkreyman mkreyman closed this Sep 28, 2026
@mkreyman
mkreyman deleted the test/us-32.1-plan-guard branch September 28, 2026 19:49
mkreyman added a commit that referenced this pull request Sep 28, 2026
* US-32.1 AC-32.1.2: guard the revoke sweep's plan in the scale job (replaces #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.

* Review round 1 on #935: index the foreign keys into dispatches, roll 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.

* Review round 2 on #935: exact index checks, reconciled migration, hour-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.

* Review round 3 on #935: key-column and btree-only FK check, column-statistics 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.
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