Skip to content

Expiry checks inside locked transactions read the transaction's start time, before the lock wait #911

Description

@nedtwigg

Code: locked in hosted/server/relay-auth.ts runs BEGIN, then pg_advisory_xact_lock. admit in hosted/server/relay-api.ts guards its insert with WHERE <expiry> > now(), and the enrollment poll's redeem UPDATE … WHERE "expiresAt" > now() runs inside locked too. In Postgres, now() is the transaction's start, which here is the BEGIN before the lock wait. The after() helper's comment in relay-api.ts already records this for TTL inserts and switched those to clock_timestamp(). The instant-expiry branch and the redeem did not get the same change.

Failure path (restored setup token): finish is refused and restoreSetupToken passes its JS check (expiresAt > Date.now()). admit then waits on setup_tokens:<burrowId> while another write holds the lock. The token's expiry passes during the wait. The statement's WHERE $expiry > now() still compares against the BEGIN time, so the dead token is reinserted. Because the trimmed CTE always runs, a Burrow at MAX_TOKENS_PER_BURROW also loses its oldest live token to make room for it. This breaks hosted.md's "Must restore a token … never once that expiry has passed" and the function's own "never evicts a live one". The window is only the length of a lock wait that straddles the expiry, and every spend rechecks liveness in its own statement, so the dead row itself is never accepted.

Failure path (poll): an approval that expires while its poll waits on burrows:<userId> is still redeemed. The impact is negligible, but this has the same cause.

Suggested fix: use clock_timestamp() instead of now() in every comparison that runs after pg_advisory_xact_lock, for both admit's instant-expiry WHERE and its pruned/trimmed CTEs, and for the poll's redeem UPDATE. Better still, give locked a single helper for "now, after the lock". To test it, hold the advisory lock from a second connection across a token's expiry, then assert that the restore inserts nothing and evicts nothing.

Found while trimming #904, which had parked this as a spec "Known gap".

🤖 Generated with Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions