fix(invites): distinguish CAS contention from a real use-cap (retry vs reject)
claimInviteUse returned null both when the cap was truly reached and when it lost the compare-and-swap 5× under load, so a legitimate joiner on a busy high-cap invite got a false 'invite has reached its use cap'. Return a discriminated result; map contention to a distinct 'try again' message. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
33b2475e67
commit
61ffc2e01f
+10
-2
@@ -182,8 +182,16 @@ export async function joinSpace(formData: {
|
||||
// (The pre-check above is a fast UX reject; this is the authoritative guard.)
|
||||
let claimedCount: number | null = null
|
||||
if (invite) {
|
||||
claimedCount = await claimInviteUse(admin, invite.id, invite.uses_count, invite.max_uses)
|
||||
if (claimedCount === null) return { error: 'This invite has reached its use cap.' }
|
||||
const claim = await claimInviteUse(admin, invite.id, invite.uses_count, invite.max_uses)
|
||||
if (!claim.ok) {
|
||||
return {
|
||||
error:
|
||||
claim.reason === 'contention'
|
||||
? 'Too many people are joining at once — please try again.'
|
||||
: 'This invite has reached its use cap.',
|
||||
}
|
||||
}
|
||||
claimedCount = claim.count
|
||||
}
|
||||
|
||||
const { data: newMember, error: memberErr } = await admin
|
||||
|
||||
+16
-8
@@ -12,20 +12,27 @@ type Admin = ReturnType<typeof createAdminClient>
|
||||
* 0-row result, re-reads, and retries while capacity remains. `uses_count` only
|
||||
* ever increases, so the CAS has no ABA hazard.
|
||||
*
|
||||
* Returns the new `uses_count` on success (the caller stores it to roll the
|
||||
* claim back if the subsequent membership insert fails), or `null` if the cap
|
||||
* is reached.
|
||||
* Returns a discriminated result: `{ ok: true, count }` with the new
|
||||
* `uses_count` on success (the caller stores it to roll the claim back if the
|
||||
* subsequent membership insert fails), or `{ ok: false, reason }` where the
|
||||
* reason distinguishes a genuinely reached cap (`'cap_reached'`) from losing
|
||||
* the compare-and-swap on every attempt under contention (`'contention'`) so
|
||||
* the caller can surface a retryable message instead of a false cap error.
|
||||
*/
|
||||
export type ClaimInviteResult =
|
||||
| { ok: true; count: number }
|
||||
| { ok: false; reason: 'cap_reached' | 'contention' }
|
||||
|
||||
export async function claimInviteUse(
|
||||
admin: Admin,
|
||||
inviteId: string,
|
||||
knownCount: number,
|
||||
maxUses: number | null,
|
||||
maxAttempts = 5,
|
||||
): Promise<number | null> {
|
||||
): Promise<ClaimInviteResult> {
|
||||
let current = knownCount
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (maxUses !== null && current >= maxUses) return null
|
||||
if (maxUses !== null && current >= maxUses) return { ok: false, reason: 'cap_reached' }
|
||||
const next = current + 1
|
||||
const { data } = await admin
|
||||
.from('space_invites')
|
||||
@@ -33,15 +40,16 @@ export async function claimInviteUse(
|
||||
.eq('id', inviteId)
|
||||
.eq('uses_count', current) // compare-and-swap guard
|
||||
.select('id')
|
||||
if (data && data.length > 0) return next
|
||||
if (data && data.length > 0) return { ok: true, count: next }
|
||||
// Lost the race (or the row changed): re-read and retry while under cap.
|
||||
const { data: fresh } = await admin
|
||||
.from('space_invites')
|
||||
.select('uses_count')
|
||||
.eq('id', inviteId)
|
||||
.maybeSingle()
|
||||
if (!fresh) return null
|
||||
if (!fresh) return { ok: false, reason: 'cap_reached' }
|
||||
current = (fresh as { uses_count: number }).uses_count
|
||||
}
|
||||
return null
|
||||
// Exhausted the attempt budget while still under cap: pure contention.
|
||||
return { ok: false, reason: 'contention' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user