Skip to content

perf(worker): keyset-paginate TTL expiry reaper scans - #82

Merged
mastermanas805 merged 2 commits into
masterfrom
perf/keyset-expiry-reapers
Jun 3, 2026
Merged

perf(worker): keyset-paginate TTL expiry reaper scans#82
mastermanas805 merged 2 commits into
masterfrom
perf/keyset-expiry-reapers

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

What

ExpireAnonymousWorker.Work (expire.go) and ExpireStacksWorker.Work
(expire_stacks.go) each issued ONE unbounded batch SELECT per tick,
materialising the entire expired set into a single result set/allocation
before reaping. This PR converts both to keyset pagination, mirroring the
already-merged fetchLiveStackIDs loop in orphan_sweep_reconciler.go.

Both reapers still process the WHOLE expired set every tick (the full
candidate list is assembled across pages, then every candidate is reaped) —
not a bare LIMIT that drops rows.

Coverage block

Symptom:        unbounded full-table batch SELECT in 2 TTL reaper Work() scans
Enumeration:    rg 'FROM resources r|FROM stacks' internal/jobs/expire*.go
Sites found:    2  (expire.go Work, expire_stacks.go Work)
Sites touched:  2
Coverage test:  TestExpireAnonymousWorker_KeysetPagination/_SecondPageError/
                _KeysetRowsErr + TestExpireStacksWorker_* (expire_keyset_test.go)
Live verified:  worker auto-deploys on merge to master; verify via kubectl
                image SHA + /healthz commit_id (rule 14) post-merge

Notes

  • Batch sizes small — expire.go=100, expire_stacks.go=50 — because each
    candidate triggers a real backend teardown (provisioner DeprovisionResource
    RPC / k8s namespace DELETE). Tight batches avoid a thundering herd against the
    provisioner / k8s API on a large backlog.
  • Keyset over OFFSET: rides the PK, restart-safe, no re-scan/drift.
  • Every side effect preserved: expire.go keeps the per-row FOR UPDATE
    re-confirm + idempotent deprovision + mark-deleted (MR-P0-1a / MR-P1-5 race
    guards); expire_stacks.go keeps in-cluster teardown + not-in-cluster skip +
    DELETE ordering.
  • make gate green locally.

🤖 Generated with Claude Code

ExpireAnonymousWorker.Work and ExpireStacksWorker.Work each issued ONE
unbounded batch SELECT per tick, materialising the entire expired set into a
single result set/allocation before reaping. A backlog (e.g. a provisioner
outage that stalled many teardowns, or a flood of expired anon stacks) pins a
multi-MB result set in one shot.

Both reapers now stream candidates in keyset-paginated batches, advancing the
cursor by the last id::text and stopping on a short page — exactly mirroring
orphan_sweep_reconciler's fetchLiveStackIDs. The reapers still process the
WHOLE expired set every tick (the complete candidate list is assembled across
pages, then every candidate is reaped); only the per-fetch footprint is
bounded. Keyset (id::text > $cursor ORDER BY id::text ASC) rides the PK, is
restart-safe, and never re-scans or drifts. Every side effect is preserved:
expire.go keeps the per-row FOR UPDATE re-confirm + idempotent deprovision +
mark-deleted (MR-P0-1a / MR-P1-5 race guards intact); expire_stacks.go keeps
the in-cluster namespace teardown + not-in-cluster skip + DELETE ordering.

Batch sizes are deliberately SMALL — expire.go=100, expire_stacks.go=50 —
because each candidate triggers a real backend teardown (provisioner
DeprovisionResource RPC / k8s namespace DELETE). Tight batches keep each tick's
burst of teardown calls bounded so a large backlog cannot thundering-herd the
provisioner / k8s API in one tick.

Tests: expire_keyset_test.go adds multi-page-advance, second-page-error, and
mid-stream rows.Err() coverage for both Work() scans, mirroring the
orphan_sweep keyset tests. Existing query-regex expectations are unaffected
(they match no args and return short pages). New limits exported via
export_expire_test.go to avoid magic numbers in the external test package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mastermanas805
mastermanas805 enabled auto-merge (squash) June 3, 2026 19:34
@mastermanas805
mastermanas805 merged commit 729dede into master Jun 3, 2026
10 checks passed
mastermanas805 added a commit that referenced this pull request Jun 4, 2026
…#3)

The storage_bytes scanner queried only status='active', so a quota-suspended
resource's usage was never re-measured — runUnsuspendLoop never saw it drop
under cap and the resource stayed suspended forever, breaking the suspend
email's promise that "access is restored automatically once usage drops".

Fix: scan `status IN ('active', 'suspended')`. Minimal one-line change — the
broader keyset-pagination rewrite was dropped from this PR (the other
reconciler scanners already got keyset in #81/#82; this scanner's pagination
is a separate concern and is left for its own PR to keep this fix small and
fully covered).

Test: TestUpdateStorageBytesWorker_RemeasuresSuspendedRow pins the WHERE
clause to ('active','suspended') so dropping 'suspended' reds the build, and
asserts a suspended row is re-measured + its storage_bytes updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mastermanas805 added a commit that referenced this pull request Jun 4, 2026
…#3) (#85)

* fix(quota): re-measure suspended resources so they can auto-unsuspend

Finding #3 (SWEEP-BACKLOG-2026-06-04, P1). UpdateStorageBytesWorker scanned
only status='active' resources, so a quota-suspended resource's storage_bytes
was frozen at the over-cap value forever. EnforceStorageQuotaWorker's
runUnsuspendLoop reads that persisted column (readStorageBytes) to decide
whether usage has dropped below the hysteresis threshold — with the value
frozen it never could, so a suspended resource stayed suspended permanently.
The suspend email promises "access restored automatically once usage drops";
that was a no-op.

Fix: the scanner now selects status IN ('active','suspended') so suspended
rows keep being measured. Suspend-trigger behaviour for active rows is
unchanged — runSuspendLoop independently scans status='active'; this worker
only writes the storage_bytes column both loops read.

Since this touched a previously-unbounded scan (ORDER BY created_at, no LIMIT),
it is now keyset-paginated (id::text > cursor ORDER BY id::text, batch 1000),
mirroring the quota.go reconciler scans. Scan errors stay fail-open
(CLAUDE.md #1): log + stop paginating this run (cursor can't advance without a
valid id), re-run next tick.

The api half (ElevateResourceTiersByTeam tier-upgrade rescue, finding #4) is
fixed separately in the api repo.

Tests:
- TestUpdateStorageBytesWorker_RemeasuresSuspendedRow — asserts the scan
  status args are exactly ('active','suspended') and a suspended row is
  re-measured + its storage_bytes updated (measurement half).
- TestEnforceStorageQuotaWorker_UnderQuota_UnsuspendsResource (existing) —
  once storage_bytes drops, the row is unsuspended (release half).

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

* fix(quota): scan suspended resources so they can auto-unsuspend (sweep #3)

The storage_bytes scanner queried only status='active', so a quota-suspended
resource's usage was never re-measured — runUnsuspendLoop never saw it drop
under cap and the resource stayed suspended forever, breaking the suspend
email's promise that "access is restored automatically once usage drops".

Fix: scan `status IN ('active', 'suspended')`. Minimal one-line change — the
broader keyset-pagination rewrite was dropped from this PR (the other
reconciler scanners already got keyset in #81/#82; this scanner's pagination
is a separate concern and is left for its own PR to keep this fix small and
fully covered).

Test: TestUpdateStorageBytesWorker_RemeasuresSuspendedRow pins the WHERE
clause to ('active','suspended') so dropping 'suspended' reds the build, and
asserts a suspended row is re-measured + its storage_bytes updated.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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