Skip to content

Commit 21820e0

Browse files
icecrasher321claude
andcommitted
improvement(permissions): retry lock timeouts and audit admin upserts truthfully
Bounding the row-lock wait with `lock_timeout` made a competing writer raise 55P03, which the retry set excluded — so the bounded wait failed the request where the unbounded one would have waited. 55P03 is now retried like the other contention aborts, the timeout is shortened since each attempt releases its pooled connection, and contention that outlives every attempt answers with a busy conflict instead of a generic server error. The admin member upsert recorded MEMBER_ADDED even when the conflict branch amended an existing membership, putting a join that never happened in the workspace audit trail. It now records a role change, matching the action the response already reported. Adds the missing test coverage for withTransactionRetry. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0e6b5b4 commit 21820e0

5 files changed

Lines changed: 171 additions & 19 deletions

File tree

apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,18 +290,27 @@ export const POST = withRouteHandler(
290290
/** A returned id we did not mint means the conflict branch ran. */
291291
const wasCreated = written?.id === permissionId
292292

293-
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
294-
permissions: permissionLevel,
295-
permissionId,
296-
})
293+
logger.info(
294+
wasCreated
295+
? `Admin API: Added user ${userId} to workspace ${workspaceId}`
296+
: `Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`,
297+
{ permissions: permissionLevel, permissionId }
298+
)
297299

300+
/**
301+
* The conflict branch amended a membership that already existed, so it is
302+
* a role change rather than an addition — recording it as `MEMBER_ADDED`
303+
* would put a join that never happened in the workspace's audit trail.
304+
*/
298305
recordAudit({
299306
workspaceId,
300307
actorId: 'admin-api',
301-
action: AuditAction.MEMBER_ADDED,
308+
action: wasCreated ? AuditAction.MEMBER_ADDED : AuditAction.MEMBER_ROLE_CHANGED,
302309
resourceType: AuditResourceType.WORKSPACE,
303310
resourceId: workspaceId,
304-
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
311+
description: wasCreated
312+
? `Admin API added member to workspace with ${permissionLevel} permissions`
313+
: `Admin API changed workspace member permissions to ${permissionLevel}`,
305314
metadata: { targetUserId: userId, permissions: permissionLevel },
306315
request,
307316
})

apps/sim/app/api/workspaces/[id]/permissions/route.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,29 @@ describe('workspace permissions route', () => {
314314
expect(dbChainMockFns.set).not.toHaveBeenCalled()
315315
})
316316

317+
/**
318+
* The `lock_timeout` this route sets makes Postgres abort the transaction
319+
* under contention. Retries cover the transient case; when they run out the
320+
* caller must still get something actionable, not the driver error rendered
321+
* as "Internal server error".
322+
*/
323+
it('answers contention that outlives the retries with a busy conflict', async () => {
324+
queuePersonalWorkspace([permissionRow(ADMIN_ID, 'admin'), permissionRow(MEMBER_ID, 'read')])
325+
dbChainMockFns.transaction.mockRejectedValue(
326+
Object.assign(new Error('canceling statement due to lock timeout'), { code: '55P03' })
327+
)
328+
329+
const response = await PATCH(
330+
createMockRequest('PATCH', { updates: [{ userId: MEMBER_ID, permissions: 'write' }] }),
331+
routeContext
332+
)
333+
334+
expect(response.status).toBe(409)
335+
await expect(response.json()).resolves.toMatchObject({
336+
error: 'This workspace is busy right now. Try again in a moment.',
337+
})
338+
})
339+
317340
it('aborts when the workspace leaves its organization mid-request', async () => {
318341
queuePersonalWorkspace([permissionRow(ADMIN_ID, 'admin'), permissionRow(MEMBER_ID, 'read')])
319342
permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({

apps/sim/app/api/workspaces/[id]/permissions/route.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db } from '@sim/db'
33
import { member, permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { isOrgAdminRole, ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace'
6+
import { getPostgresErrorCode } from '@sim/utils/errors'
67
import { and, eq, inArray, sql } from 'drizzle-orm'
78
import { type NextRequest, NextResponse } from 'next/server'
89
import {
@@ -14,7 +15,7 @@ import { getSession } from '@/lib/auth'
1415
import { HttpError } from '@/lib/core/utils/http-error'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
17-
import { withTransactionRetry } from '@/lib/db/transaction'
18+
import { isRetryableTransactionError, withTransactionRetry } from '@/lib/db/transaction'
1819
import type { DbOrTx } from '@/lib/db/types'
1920
import { captureServerEvent } from '@/lib/posthog/server'
2021
import {
@@ -75,11 +76,31 @@ class WorkspaceContextChangedError extends HttpError {
7576
}
7677
}
7778

79+
/**
80+
* Another writer held the rows for longer than every attempt allowed. Distinct
81+
* from the conflicts above: nothing about the request is wrong and the state is
82+
* unchanged, so the answer is to retry rather than to reload — and it must not
83+
* reach the client as a generic server error, which is what bounding the lock
84+
* wait would otherwise have produced.
85+
*/
86+
class WorkspaceBusyError extends HttpError {
87+
readonly statusCode = 409
88+
89+
constructor() {
90+
super('This workspace is busy right now. Try again in a moment.')
91+
this.name = 'WorkspaceBusyError'
92+
}
93+
}
94+
7895
/**
7996
* Bounds the wait on the row locks below so a stuck holder fails fast
8097
* (SQLSTATE 55P03) instead of parking a pooled connection indefinitely.
98+
*
99+
* Kept short because `withTransactionRetry` retries that timeout: the connection
100+
* is released between attempts, so three bounded waits contend better than one
101+
* long one and never pin a pool slot for more than this.
81102
*/
82-
const PERMISSIONS_LOCK_TIMEOUT_MS = 5000
103+
const PERMISSIONS_LOCK_TIMEOUT_MS = 3000
83104

84105
/** Organization owners/admins among `userIds`, empty for a personal workspace. */
85106
async function loadOrgAdminTargets(
@@ -315,10 +336,11 @@ export const PATCH = withRouteHandler(
315336
}
316337

317338
/**
318-
* Retried on a deadlock: the ordered locks below close this route against
339+
* Retried on contention: the ordered locks below close this route against
319340
* itself, but member removal takes the billed account before the departing
320-
* user, so a cross-path cycle remains. The victim of one is asked by the
321-
* database to try again, and answering it with a 500 would surface as
341+
* user, so a cross-path cycle remains — and the `lock_timeout` above turns a
342+
* slow competing writer into an abort of its own. Both are the database
343+
* asking for a retry, and answering either with a 500 would surface as
322344
* "Internal server error" for a click that would have worked.
323345
*/
324346
const { previousRoles, changedUserIds } = await withTransactionRetry(async (tx) => {
@@ -491,6 +513,20 @@ export const PATCH = withRouteHandler(
491513
previousRoles: lockedByUserId,
492514
changedUserIds: new Set(changedUpdates.map((update) => update.userId)),
493515
}
516+
}).catch((error) => {
517+
/**
518+
* Contention that outlived every attempt. Answering with the driver error
519+
* would render as "Internal server error" for a request that is simply
520+
* queued behind another writer.
521+
*/
522+
if (isRetryableTransactionError(error)) {
523+
logger.warn('Permission update exhausted retries under contention', {
524+
workspaceId,
525+
code: getPostgresErrorCode(error),
526+
})
527+
throw new WorkspaceBusyError()
528+
}
529+
throw error
494530
})
495531

496532
/**
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { isRetryableTransactionError, withTransactionRetry } from '@/lib/db/transaction'
7+
8+
/** Shaped like the `postgres` driver's error, which carries `code` on the error. */
9+
function pgError(code: string): Error {
10+
return Object.assign(new Error(`pg error ${code}`), { code })
11+
}
12+
13+
describe('withTransactionRetry', () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks()
16+
resetDbChainMock()
17+
})
18+
19+
describe('isRetryableTransactionError', () => {
20+
/**
21+
* `55P03` is what a caller's own `lock_timeout` raises. Excluding it would
22+
* make bounding the lock wait worse than not bounding it.
23+
*/
24+
it.each(['40001', '40P01', '55P03'])('treats %s as retryable', (code) => {
25+
expect(isRetryableTransactionError(pgError(code))).toBe(true)
26+
})
27+
28+
it.each(['23505', '23503', '42P01'])('treats %s as terminal', (code) => {
29+
expect(isRetryableTransactionError(pgError(code))).toBe(false)
30+
})
31+
32+
it('treats a non-postgres error as terminal', () => {
33+
expect(isRetryableTransactionError(new Error('boom'))).toBe(false)
34+
})
35+
})
36+
37+
it('returns the callback result without retrying when it succeeds', async () => {
38+
const fn = vi.fn().mockResolvedValue('ok')
39+
40+
await expect(withTransactionRetry(fn)).resolves.toBe('ok')
41+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
42+
})
43+
44+
it.each(['40001', '40P01', '55P03'])(
45+
'retries a %s abort and returns the later result',
46+
async (code) => {
47+
const fn = vi.fn().mockRejectedValueOnce(pgError(code)).mockResolvedValue('recovered')
48+
49+
await expect(withTransactionRetry(fn)).resolves.toBe('recovered')
50+
expect(fn).toHaveBeenCalledTimes(2)
51+
}
52+
)
53+
54+
it('gives up after the attempt cap and rethrows the driver error', async () => {
55+
const fn = vi.fn().mockRejectedValue(pgError('40P01'))
56+
57+
await expect(withTransactionRetry(fn, { attempts: 3 })).rejects.toMatchObject({ code: '40P01' })
58+
expect(fn).toHaveBeenCalledTimes(3)
59+
})
60+
61+
/**
62+
* The typed domain errors callers throw to force a rollback must not be
63+
* replayed — a second attempt would reach the same decision.
64+
*/
65+
it('does not retry an error the database did not raise', async () => {
66+
const fn = vi.fn().mockRejectedValue(new Error('membership changed'))
67+
68+
await expect(withTransactionRetry(fn)).rejects.toThrow('membership changed')
69+
expect(fn).toHaveBeenCalledTimes(1)
70+
})
71+
})

apps/sim/lib/db/transaction.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import type { DbOrTx } from '@/lib/db/types'
88
const logger = createLogger('DbTransaction')
99

1010
/**
11-
* `serialization_failure` and `deadlock_detected`. Postgres raises both only
12-
* after aborting and fully rolling back the transaction, which is what makes a
13-
* fresh attempt safe rather than a duplicate.
11+
* `serialization_failure`, `deadlock_detected`, and `lock_not_available`.
12+
* Postgres raises all three only after aborting the transaction, so the work is
13+
* fully rolled back and a fresh attempt is a retry rather than a duplicate.
14+
*
15+
* `55P03` is what a caller's own `lock_timeout` produces: it means a competing
16+
* transaction still held the row, which is the case most likely to succeed on a
17+
* second attempt. Excluding it would make bounding the wait strictly worse than
18+
* not bounding it — the request would fail instead of waiting.
1419
*/
15-
const RETRYABLE_TRANSACTION_CODES = new Set(['40001', '40P01'])
20+
const RETRYABLE_TRANSACTION_CODES = new Set(['40001', '40P01', '55P03'])
1621

1722
const DEFAULT_ATTEMPTS = 3
1823

@@ -22,15 +27,23 @@ export function isRetryableTransactionError(error: unknown): boolean {
2227
}
2328

2429
/**
25-
* Runs `fn` in a transaction, retrying when Postgres aborts it as a deadlock
26-
* victim or on a serialization failure.
30+
* Runs `fn` in a transaction, retrying when Postgres aborts it for contention —
31+
* a deadlock, a serialization failure, or a lock timeout.
2732
*
28-
* Both are the database asking the loser to try again, and neither leaves
29-
* partial work behind so without a retry they surface as a generic 500 for a
33+
* All three are the database asking the loser to try again, and none leaves
34+
* partial work behind, so without a retry they surface as a generic 500 for a
3035
* condition that would have succeeded on a second attempt. Any other error,
3136
* including the typed domain errors a caller throws to force a rollback,
3237
* propagates on the first occurrence.
3338
*
39+
* Each attempt is its own transaction, so the pooled connection is released
40+
* between them — pairing a short `lock_timeout` with a retry holds a connection
41+
* for far less time than a single unbounded wait would.
42+
*
43+
* When the attempts are exhausted the original Postgres error propagates;
44+
* callers that answer HTTP should map it with {@link isRetryableTransactionError}
45+
* rather than let it become a generic 500.
46+
*
3447
* `fn` must be free of side effects outside the transaction: it can run more
3548
* than once, and only the committing attempt is durable.
3649
*/

0 commit comments

Comments
 (0)