Repository navigation
fix(cleanup): only delete settled executions when their TTL lapses - #1173
Conversation
The cleanup job deleted every purchase_executions row whose expires_at had passed, whatever its status. expires_at is written once at insert and never rewritten, so the first change that sets a TTL on live rows would have let the job delete rows an executor or scheduler still needs, and it would also purge failed and canceled rows the plan health score is still counting. deleteExpired and dryRunCount now share one predicate: a lapsed TTL deletes a row only when its status is in TerminalExecutionStatuses (an allowlist, so a new status is kept until classified) and not in HealthScoredExecutionStatuses, which CleanupOldExecutions retains for the health score's window. Both lists come from the config package. Real-Postgres tests cover every status the schema allows, NULL and future TTLs, the dry-run count, and a writer that turns a settled row live while the DELETE waits for its lock. The test file is force-added: the .gitignore entry for the cleanup-lambda binary matches this directory too. Closes LeanerCloud#998 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 3 billable files and costs up to $0.75. Or wait 59 minutes for your next included review. View limit details
Comment |
|
VERDICT: APPROVE 78fdd72 PR #1173 (closes #998), one commit, base origin/main cb25d99. Findings
The issue's premise note in the PR body is accurate: CleanupOldExecutions branch 3 excludes only HealthScoredExecutionStatuses. Adding the TerminalExecutionStatuses allowlist on top is the right call, because copying branch 3 alone would fail the issue's acceptance test. Attacked:
Local results (fresh scratch export of 78fdd72, Postgres testcontainer, under lockf heavy.lock):
|
|
Independent review 2 (claude-fleet, cold): APPROVE 78fdd72 Findings (none blocking):
Against the issue: it asks for "the same predicate as CleanupOldExecutions". That predicate's branch 3 (internal/config/store_postgres.go:2264-2268) excludes only health-scored statuses and would still delete Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at |
Resolve the known_issues/35_legacy_guard_followups.md audit-status conflict with LeanerCloud#1173: both entries (LeanerCloud#998 and LeanerCloud#999) are now resolved, so the line reads "0 needs triage · 2 resolved" and the doc moves to known_issues/resolved/, as the PR body said the second of the two to merge would do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pattern (#1272) clear-rate-limit built its DELETE pattern by concatenating RATE_LIMIT_DOMAIN into "EMAIL#%@<domain>#ENDPOINT#forgot_password". The query was parameterized, but % and _ inside the domain (and the _ in "forgot_password") are LIKE wildcards, so RATE_LIMIT_DOMAIN=% cleared the forgot_password rate limits of every domain, and a domain with an _ matched any character in that position. The domain and endpoint are now escaped and the DELETE uses ESCAPE '\', so only the email's local part is a wildcard. The query moved into clearForgotPasswordLimits so a real-Postgres test can call it. The cleanup-lambda half of the same review finding is not changed here: its status guard already landed in #1173, and its retention rule is left for the maintainer to decide (see the PR body). Refs #357 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…, capped list reads (#120 slice) (#1552) * fix(config): delete only settled executions from CleanupOldExecutions' expiry branch The expires_at branch deleted a row in any status the plan health score does not count, so an approved or running row stranded by a dead executor was deleted once its TTL lapsed, before GetStaleApprovedExecutions or ListStuckExecutions could resolve it. Gate the branch on the allowlist cmd/cleanup-lambda already uses since #1173: status in TerminalExecutionStatuses and not in HealthScoredExecutionStatuses. Pending and notified rows now wait for ExpireExecutionAtomic to move them to expired, which the branch then reaps. Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): add range CHECKs for the RI-exchange limits in global_config Migration 000149 adds CHECKs that each RI-exchange USD cap is a finite amount >= 0 and the utilization threshold is in [0, 100], matching GlobalConfig.validateRIExchangeLimits. Each is added NOT VALID and then validated only if no existing row violates it; a violating row is kept, its constraint stays NOT VALID (still checked on every write) and the migration raises a WARNING with the fix. The header gives a read-only query for violating rows. Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): cap registration, ladder config and suppression list reads ListAccountRegistrations and GetLadderConfigs now clamp to MaxListLimit like the package's other list reads, with id as a tie-break so the cut is deterministic. ListActiveSuppressions returns an error above the cap instead of truncating: a partial list would under-suppress, and the money path (ListRecommendationsStrict) would offer to re-buy the dropped rows. Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(deps): bump golang.org/x/net to v0.62.0 (GO-2026-6860, GO-2026-6866) govulncheck in the Docker image build now fails on these two advisories, both fixed in x/net v0.62.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): keep ListActiveSuppressions unbounded One purchase can write up to 1000 suppression rows that live 7-90 days, so normal use can exceed MaxListLimit. Erroring above the cap disabled the suppression index for every caller (automated plan steps refuse; the read-only pages over-show just-bought capacity). Restore main's unbounded read and pin it with a test. Raised in review. Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
cmd/cleanup-lambda/main.go(deleteExpired, and the dry-rundryRunCount) selectedpurchase_executionsrows with onlyexpires_at < now.expires_atis written once at insert bySavePurchaseExecutionand never rewritten on a status change, so a lapsed TTL says nothing about whether the row is still live. It is latent today, as the issue says (no production code setsPurchaseExecution.TTL, and migration 000124's trigger keeps capacity-holding rows of unfinished ramps), but the first TTL on a live row would let the job delete it. It would also purge failed and canceled rows the plan health score is still counting.Fix
deleteExpiredanddryRunCountnow share one predicate,expiredExecutionsWhere:TerminalExecutionStatusesis the existing allowlist of rows nothing will act on again (the oneDeleteCloudAccountuses). Because it is an allowlist, a status added later is kept until someone classifies it.HealthScoredExecutionStatuses(failed, canceled, cancelled) is excluded the same wayCleanupOldExecutionsexcludes it from its expiry branch, so those rows stay with that sweep's retention windows.completed,partially_completed,expiredandrevocation_requestedrows. Both lists come frominternal/config, so the job can't drift from the store. That adds 4 packages to the binary's dependency graph.Concurrency: the job's DELETE runs under READ COMMITTED. A row it has to wait for is re-checked against the full predicate once the other transaction commits, so a row that stops being settled during the wait survives. A test covers this.
Note on the issue's premise
The issue says
CleanupOldExecutions"excludes live statuses". On current main it doesn't. Its branch 3 (status <> ALL(HealthScoredExecutionStatuses) AND expires_at < NOW() - retention) excludes only failed and canceled rows, so it still deletes apendingrow with a lapsed TTL.TestPruneRechecksRowAfterCommandCancelsIt("prune first") asserts exactly that. Copying its predicate as-is would not meet the issue's acceptance test ("a live row with a pastexpires_atsurvives"). That is why this PR adds the terminal-status allowlist on top. I leftCleanupOldExecutionsalone because changing it is outside this issue's scope and would change #1014's race test. It has the same latent gap, so a maintainer may want a follow-up issue for it.Verification
All local, against a real Postgres 16 testcontainer with every migration applied. This is DB integration evidence, not real-cloud: no Lambda was deployed or invoked, and no AWS API was called.
New file
cmd/cleanup-lambda/main_integration_test.go(//go:build integration, so CI's integration job runs it):TestCleanupStatusListsCoverSchema: every status in thepurchase_executionsstatus CHECK is classified exactly once as deletable or kept, so a new status fails this test until someone decides.TestDeleteExpiredKeepsLiveAndHealthScoredRows: one row per status with a lapsed TTL, plus future-TTL and NULL-TTL rows for each deletable status. The dry run counts exactly 4, the delete removes exactly those 4, and every other row survives.TestDeleteExpiredRechecksRowAfterConcurrentWriter: both orders. "writer first": a transaction turns a lapsedcompletedrow back topendingand holds the lock. The cleanup DELETE is shown waiting on it viapg_stat_activity, then the writer commits, and the row survives with 0 deleted. "cleanup first": the row is deleted and the writer's UPDATE affects 0 rows. The writer is a stand-in:SavePurchaseExecution's upsert rewrites status unconditionally, but no current flow moves a settled row back to live.ok ... 7.765s).main.gowith the new test):TestDeleteExpiredKeepsLiveAndHealthScoredRowsfails (expected: int(4) actual: int64(13)), andTestDeleteExpiredRechecksRowAfterConcurrentWriter/writer_firstfails (Should be zero, but was 1).cleanup_firstand the schema-coverage test pass, as expected.status = ANY($2)fails both behavior tests;status <> ALL($3)fails the status test;Other checks run locally:
go build ./...go vet ./cmd/cleanup-lambdaandgo vet -tags integration ./cmd/cleanup-lambda./cmd/cleanup-lambda/..., with and without--build-tags integration: 0 issuesgocyclo -over 10 -ignore "_test\.go" cmd/cleanup-lambda: silentgo test ./internal/mocks/ ./internal/database/postgres/testhelpers/ -count=1: okscripts/gosec-hook.sh(gosec v2.28.0) on the changed files: cleanmarkdownlint-cli@0.47.0on the known_issues doc: cleanscripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAILAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. The tests don't touch AWS.Also in this PR / out of scope
known_issues/35_legacy_guard_followups.md: the fix(cleanup): give the cleanup lambda the live-status guard CleanupOldExecutions has #998 entry is marked resolved in place. The file stays where it is because feat(plans): settle a failed-with-submission row so a managed plan can be deleted #999 is still open..gitignoreline 90 (cleanup-lambda, meant for the compiled binary) also matches thecmd/cleanup-lambda/directory, so the new test file had to be force-added, asmain.goevidently was. I didn't change.gitignore; anchoring it (e.g./cleanup-lambda) would be a separate tidy-up.CleanupOldExecutionsbranch 3 still deletes live rows with a lapsed TTL; see the note above.Maintainer: please mirror the issue's triage labels onto this PR:
triaged,priority/p3,severity/low,urgency/eventually,impact/few,effort/s,type/bug.Closes #998
🤖 Generated with Claude Code