Skip to content

fix(api): manual RI exchange approval can race auto reservation and exceed daily cap #663

Closed

Description

@cristim

Summary

The manual-approval RI exchange path reads daily spend without the advisory lock used by ReserveRIExchange, so it can race an auto reservation and exceed the daily cap.

Current behaviour

internal/api/handler_ri_exchange.go (~2561-2566, 2612-2645) reads GetRIExchangeDailySpend without a lock and executes up to min(perCap, dailyCap - spend). Its ledger row holds only the quote. A concurrent auto reservation (platform PR #662, go#41) can take the same headroom.

Steps to verify the gap

Cap 1000, manual quote 100: manual reads spend 100, gets ceiling 900; auto reserves 900 under the lock; worst case 1800 spent against a 1000 cap.

Expected behaviour

Manual approval reserves its execution ceiling through the same lock-and-reserve step before Execute.

Proposed fix

Route manual approval through ReserveRIExchange (or equivalent). Also consider AND status='processing' guards in CompleteRIExchangeWithPayment / FailRIExchange (internal/config/store_postgres.go ~3087, ~3129).

References

Parent: LeanerCloud/cloud-commitments-go#41; PR #662 (independent review finding). Severity: medium.

Activity

  1. IAmJSD commented on Oct 16, 2026

    @IAmJSD
    Collaborator

    Released: already fixed by #690 (closing, details below)

  2. assigned and unassigned on Oct 16, 2026
  3. IAmJSD commented on Oct 16, 2026

    @IAmJSD
    Collaborator

    Closing: this was fixed by #690 (cf0b1a0, merged 2026-10-09). That PR closed #656, which describes the same race, so it never referenced this issue.

    Checked on current main:

    • The manual approve path (executeApprovedExchange, handler_ri_exchange.go around line 2686) now calls ReserveApprovedRIExchange before Execute and passes the reserved ceiling as MaxPaymentDueUSD. Nothing in internal/api reads GetRIExchangeDailySpend any more.
    • Manual (ReserveApprovedRIExchange) and auto (ReserveRIExchange) both go through reserveRIExchangeCeiling. That function takes pg_advisory_xact_lock(riExchangeDailySpendLockKey), reads spend excluding the row itself, computes min(per-exchange cap, daily cap − spend), and writes it as payment_due, all in one transaction. A request that doesn't fit is refused with ErrRIExchangeDailyCapExceeded. This holds across Lambda instances.

    Tests:

    • go test -tags integration ./internal/config -run 'PostgresStoreDB_(Reserve|DailySpend)' passed all 8 tests against real Postgres.
    • With the advisory lock swapped for a plain SELECT, two tests failed in 5 of 5 runs: ReserveApprovedRIExchange_ConcurrentApprovalsStayWithinCap (manual path) and ReserveRIExchange_SerializesIndependentStores (auto path, same lock key).

    Left for you; neither is needed to hold the cap today:

    • The optional AND status='processing' guards from this issue.

      • On FailRIExchange: safe. It stops a completed row from being flipped to failed and dropping out of the spend total.
      • On CompleteRIExchangeWithPayment: could refuse the ledger write after money has moved, which would loosen cap accounting.

      No current path calls Fail after Complete on the same row.

    • TestPostgresStoreDB_ReserveApprovedRIExchange_AutoAndApprovalShareHeadroom still passes with the lock removed. It could hold the lock from a third transaction, as the concurrent-approvals test does, to make it deterministic. That would be a small test-only follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions