Repository navigation
Conversation
…ator resolve them A run that crashes between the provider call and its ledger write leaves an ri_exchange_history row in processing. Since LeanerCloud#690 a processing row counts against the daily cap on every later day, and nothing reconciled it, so a crashed run could block every later exchange indefinitely. - The scheduled ri_exchange_reshape run now logs an ERROR (the application error alarm channel) for every processing row unchanged for over an hour, naming the row, its held amount and how to resolve it. It runs before the enabled check and never settles or releases anything itself: whether the exchange happened at AWS is unknown, so the hold stays counted. - POST /api/ri-exchange/resolve/{id} (execute:ri-exchange, same account scope and region constraints as direct execution, signed-in user only) records an operator's decision after checking AWS: "completed" keeps the reserved ceiling counted on today's UTC day; "failed" releases the hold and needs acknowledge_unknown_outcome. - PostgresStore.ResolveStaleRIExchange locks the row and refuses unless it is still processing and unchanged for 6h on the database clock, the cloud check names the row's own account and region and postdates its last change, and (for a release) no provider exchange id is recorded. A row a late writer settled is never overwritten; identical retries replay. Closes LeanerCloud#692 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
|
VERDICT: APPROVE 89c1363 Reviewed head 89c1363 (1 commit) against origin/main b6a0649. The base is 40 commits stale; Findings (none blocking):
Attacked:
Local results (merged tree 8afef041, macOS, Docker Postgres container, AWS creds disabled): |
|
Independent review 2 (claude-fleet, cold): APPROVE 89c1363 Findings (none blocking):
Against #692:
Attacked:
Verified locally, with no cloud credentials and a throwaway Postgres 17:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
Root cause
Since #690,
riExchangeDailySpendQuerycounts everyprocessingrow regardless of day, so an in-flight reservation survives UTC midnight. When a run dies between the provider call andCompleteRIExchangeWithPayment/FailRIExchange(Lambda timeout, crash, or the AUTO path's unknown-outcome halt), the row staysprocessingand its reserved ceiling counts against the daily cap on every later day. Nothing reconciled it:RunAutoExchangeonly logs a WARN for rows older than 15 minutes, and there was no way to clear one short of hand-editing the DB.Fix
Fail-safe throughout: nothing automatic ever releases a hold, because CUDly cannot tell from its own data whether the exchange happened at AWS.
Alarm (
internal/server/handler_ri_exchange.go). The scheduledri_exchange_reshaperun now logs anERRORline for everyprocessingrow unchanged for over 1h. ERROR is the same channel the existing "ledger write failed after money moved" errors use. Each line names the record, account, region, source RIs, held amount, how long it has been processing, and the resolve endpoint. The alarm runs before theRIExchangeEnabledcheck, because a stranded hold still blocks approvals of pending exchanges while automation is off. The query is origin-agnostic, so ladder-created rows are covered too. It never settles or releases anything.Operator resolve:
POST /api/ri-exchange/resolve/{id},AuthUser, so the middleware enforces session and CSRF.execute:ri-exchange, the same deployment-account scope as approvals (out of scope returns 404), and the permission's Constraints (account, aws, ec2,checked_region), as direct execution does.transitioned_by.outcome(completed|failed),reason(10–300 chars), an optionalexchange_id(completed only), and a requiredcloud_check {checked_account_id, checked_region, checked_at}.failedalso requiresacknowledge_unknown_outcome: true.completedkeepspayment_due(the reserved ceiling, an upper bound of what AWS accepted, since Execute ran with it asMaxPaymentDueUSD) and stampscompleted_at. The row then counts on today's UTC day only, like any settled exchange.failedreleases the hold.Store (
PostgresStore.ResolveStaleRIExchange, new fileinternal/config/store_postgres_ri_exchange_resolve.go). It takesSELECT … FOR UPDATEon the row and refuses unless all of these hold:processing, so a row a late writer settled is never overwritten;RIExchangeResolveStaleAfter= 6h, measured on the database clock. That is well past every writer's budget (4m30s on the API path, the 15m default task timeout on the scheduled path), and matchesLadderFiredStaleAfter;exchange_idis recorded (AWS said it happened);checked_atfalls after the row's last change and is not in the future.The
UPDATErepeatsAND status = 'processing'. An identical retry replays (200,replayed: true). Any other request against a resolved row gets 409. No migration: the decision goes in the existingerror,transitioned_byandtransitioned_atcolumns.Concurrency. The plausible races are a late
CompleteRIExchangeWithPaymentagainst an operator release, and two operators at once. Both serialize on the row lock.completedand refuses. A release that locks first is overwritten by the queued settlement, because that UPDATE is unguarded. Either way the exchange ends up counted.Both races are tested on real Postgres with deterministic lock ordering, as listed below. The staleness window means neither race should happen in practice.
Verification
All local. No AWS calls (the handler's account resolver is injected, and runs used
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid), no purchases, no email.Real PostgreSQL 16 (
postgres:16-alpine, dedicatedissue41_DB, migrations to 130):New tests in
store_postgres_ri_exchange_resolve_integration_test.go:ErrRIExchangeDailyCapExceeded) and still counts two days later. Resolving itfailedmakes the same reservation succeed with a $1000 ceiling.completedkeeps the $700 counted today and not tomorrow, and stores the operator's exchange id.processing.exchange_idcannot be released, can be completed, and rejects a mismatched exchange id.pg_stat_activity). The final status is alwayscompleted.Unit / mock / fixture:
handler_ri_exchange_stale_test.go: a stranded row produces exactly one ERROR line, with the fields and resolve path, while automation is disabled. A failing stale query is itself an ERROR. No stale rows means no ERROR.handler_ri_exchange_resolve_test.gocovers:AuthUserPOST.Fail-before / mutation probes. Each mutation below was applied alone, then restored, and a test fails under each:
handleRIExchangeReshapewithout the alarm call (= origin/main behavior)TestHandleRIExchangeReshape_AlarmsOnStrandedProcessingExchange…_RefusesRowThatMayBeInFlight…_RefusesToReleaseRowWithExchangeID…_ReplayAndConflictchecked_atafter last change removed / account check removed…_ValidatesCloudCheckcompleted_atnot stamped…_CompletedCountsTodayOnlyTestResolveRIExchange_Authorizationacknowledge_unknown_outcomerequirement removedTestResolveRIExchange_BodyValidationThe late-settlement race is guarded four times over: the status check, the UPDATE guard, staleness (the settlement bumps
updated_at), and the exchange-id guard. The race test fails only when all four are removed; with any one left in place it still passes.On origin/main the new tests do not compile, because
ResolveStaleRIExchange,RIExchangeResolveStaleAfter,alarmStaleRIExchangesandresolveRIExchangedo not exist. The existingTestPostgresStoreDB_ReserveRIExchange_PriorDayInFlightHoldReducesHeadroomalready shows the hold persisting across days.Static checks.
go build ./...andgo vet(with and without-tags integration) pass on the changed packages.golangci-lintv2.10.1 (--build-tags integrationand without) reports 0 issues.gocyclo -over 10 -ignore "_test\.go" internal/ cmd/is silent.scripts/gosec-hook.shon the changed files passes.git diff --checkis clean.--register-aws), andscripts/test-git-secrets-allowlist.shgives 42 PASS / 0 FAIL.Real-cloud gap. This was not exercised against a real AWS account or a deployed Lambda. I also did not verify that the CloudWatch
errorsmetric filter ([time, request_id, level = ERROR*, ...]) matches thelogging.Errorfline format (YYYY/MM/DD HH:MM:SS [ERROR] …). That applies to every existing ERROR log, not just this one, and is worth a check by someone with a deployed stack.Deliberately out of scope
active, or one that isretired(it may have expired or been modified), is not proof either way. Automatic completion on strong evidence could be a follow-up.failExchangewhenExecutereturns an unknown-outcome error, which releases a hold for an exchange that may have happened. That is tracked in fix(ri-exchange): an unknown accept outcome is marked failed and retryable #1050 and I did not change it here.FailRIExchange.CompleteRIExchangeWithPayment/FailRIExchangestill update unconditionally. A writer still alive more than 6h later could flip a resolved row. I judged that out of reach, but tightening those writers toWHERE status = 'processing'is a possible follow-up.A maintainer, please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/m,type/bug,triaged.Closes #692
🤖 Generated with Claude Code