Skip to content

Commit 840080d

Browse files
icecrasher321claude
andcommitted
fix(realtime): single-flight role resolution and retry failed eviction cleanup
Coalesce concurrent role resolutions per (user, workflow) so a slow stale read can never overwrite a recorded revocation, and defer failed eviction room-state cleanups into a per-sweep retry queue so collaborators are not left with a stale presence entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d9f721d commit 840080d

5 files changed

Lines changed: 194 additions & 43 deletions

File tree

apps/realtime/src/access-revalidation.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,48 @@ describe('access-revalidation sweep', () => {
163163
expect(noRoom.leave).not.toHaveBeenCalled()
164164
})
165165

166-
it('still broadcasts presence when room-state removal fails', async () => {
166+
it('defers failed room-state cleanup and retries it on the next pass', async () => {
167167
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
168168
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
169-
manager.removeUserFromRoom.mockRejectedValue(new Error('redis down'))
169+
manager.removeUserFromRoom.mockRejectedValueOnce(new Error('redis down'))
170170
mockResolveRole.mockResolvedValue(null)
171171

172172
const sweep = startAccessRevalidationSweep(manager)
173173
await sweep.runOnce()
174-
sweep.stop()
175174

176175
expect(socket.leave).toHaveBeenCalledWith('wf-1')
176+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
177+
178+
// The evicted socket is out of the room now, so membership scans no longer
179+
// see it — the retry queue must drive the cleanup to completion.
180+
socket.rooms = new Set(['sock-1'])
181+
await sweep.runOnce()
182+
sweep.stop()
183+
184+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
177185
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
178186
})
179187

188+
it('drops a deferred cleanup when the socket legitimately re-joined the room', async () => {
189+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
190+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
191+
manager.removeUserFromRoom.mockRejectedValueOnce(new Error('redis down'))
192+
mockResolveRole.mockResolvedValueOnce(null)
193+
194+
const sweep = startAccessRevalidationSweep(manager)
195+
await sweep.runOnce()
196+
expect(socket.leave).toHaveBeenCalledWith('wf-1')
197+
198+
// Access restored and the socket re-joined (still in the room in this
199+
// fake): the retry must NOT remove the fresh presence entry.
200+
mockResolveRole.mockResolvedValue('read')
201+
await sweep.runOnce()
202+
sweep.stop()
203+
204+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(1)
205+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
206+
})
207+
180208
it('still evaluates access when presence lookup fails (falls back safely)', async () => {
181209
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
182210
const manager = makeManager([socket])

apps/realtime/src/access-revalidation.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,44 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
7272
const io = roomManager.io
7373
let running = false
7474

75+
/**
76+
* Evictions whose room-state cleanup failed transiently, keyed
77+
* `${socketId}:${workflowId}`. The evicted socket has already left the
78+
* Socket.IO room, so membership scans will never see it again — these are
79+
* retried at the start of every sweep pass until they succeed, so remaining
80+
* collaborators do not keep a stale presence entry for the evicted socket.
81+
*/
82+
const pendingCleanups = new Map<string, { socketId: string; workflowId: string }>()
83+
84+
async function cleanupEvictedSocket(socketId: string, workflowId: string): Promise<void> {
85+
const key = `${socketId}:${workflowId}`
86+
try {
87+
await roomManager.removeUserFromRoom(socketId, workflowId)
88+
await roomManager.broadcastPresenceUpdate(workflowId)
89+
pendingCleanups.delete(key)
90+
} catch (error) {
91+
pendingCleanups.set(key, { socketId, workflowId })
92+
logger.warn(
93+
`Room-state cleanup failed for evicted socket ${socketId} on ${workflowId}; will retry next sweep`,
94+
error
95+
)
96+
}
97+
}
98+
99+
async function retryPendingCleanups(): Promise<void> {
100+
for (const [key, { socketId, workflowId }] of pendingCleanups) {
101+
const liveSocket = io.sockets.sockets.get(socketId)
102+
if (liveSocket?.rooms.has(workflowId)) {
103+
// The socket re-joined legitimately after the eviction (access was
104+
// restored); that join re-added its presence entry, so there is
105+
// nothing stale left to clean and removal would erase live presence.
106+
pendingCleanups.delete(key)
107+
continue
108+
}
109+
await cleanupEvictedSocket(socketId, workflowId)
110+
}
111+
}
112+
75113
async function revokeSocket(socket: AuthenticatedSocket, workflowId: string): Promise<void> {
76114
// Security-critical, pod-local, and synchronous: stop this socket receiving
77115
// room broadcasts immediately, before any async bookkeeping that could fail.
@@ -87,27 +125,14 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
87125
`Revoked live access for user ${socket.userId} on workflow ${workflowId} (socket ${socket.id})`
88126
)
89127

90-
// Best-effort cleanups; each is independent so one failure neither restores
91-
// access nor prevents the other from running.
92-
try {
93-
await roomManager.removeUserFromRoom(socket.id, workflowId)
94-
} catch (error) {
95-
logger.warn(
96-
`Failed to remove evicted socket ${socket.id} from room state for ${workflowId}`,
97-
error
98-
)
99-
}
100-
try {
101-
await roomManager.broadcastPresenceUpdate(workflowId)
102-
} catch (error) {
103-
logger.warn(
104-
`Failed to broadcast presence after evicting socket ${socket.id} from ${workflowId}`,
105-
error
106-
)
107-
}
128+
// Cleanup failure never restores access (the socket already left the room);
129+
// it defers to pendingCleanups and is retried on subsequent passes.
130+
await cleanupEvictedSocket(socket.id, workflowId)
108131
}
109132

110133
async function runOnce(): Promise<void> {
134+
await retryPendingCleanups()
135+
111136
const memberships = collectLocalMemberships(io)
112137

113138
for (const [workflowId, sockets] of memberships) {

apps/realtime/src/handlers/workflow.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,11 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
134134
// Re-authorize immediately before joining: the access-revalidation sweep
135135
// may have evicted this socket while the awaits above were in flight, and
136136
// its eviction is recorded in the shared role cache before it runs — so a
137-
// revoked user resolves to null here. No awaits sit between this check
138-
// and socket.join, so a sweep eviction cannot interleave after it and be
139-
// reversed by this join.
137+
// revoked user resolves to null here. The resolver is single-flighted per
138+
// (user, workflow), so this read cannot race the sweep's and overwrite a
139+
// recorded revocation with a stale role; and no awaits sit between this
140+
// check and socket.join, so a sweep eviction cannot interleave after it
141+
// and be reversed by this join.
140142
const currentRole = await resolveCurrentWorkflowRole(userId, workflowId, userRole)
141143
if (currentRole === null) {
142144
logger.warn(

apps/realtime/src/middleware/permissions.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ vi.mock('@sim/platform-authz/workflow', () => ({
2323
authorizeWorkflowByWorkspacePermission: mockAuthorize,
2424
}))
2525

26-
import { checkRolePermission, checkWorkflowOperationPermission } from '@/middleware/permissions'
26+
import {
27+
checkRolePermission,
28+
checkWorkflowOperationPermission,
29+
resolveCurrentWorkflowRole,
30+
} from '@/middleware/permissions'
2731

2832
describe('checkRolePermission', () => {
2933
describe('admin role', () => {
@@ -414,3 +418,66 @@ describe('checkWorkflowOperationPermission', () => {
414418
}
415419
})
416420
})
421+
422+
describe('resolveCurrentWorkflowRole single-flight', () => {
423+
const userId = 'sf-user-1'
424+
let workflowCounter = 0
425+
let workflowId: string
426+
427+
beforeEach(() => {
428+
vi.clearAllMocks()
429+
// Unique workflowId per test so the module-level role cache never leaks across tests
430+
workflowCounter += 1
431+
workflowId = `sf-wf-${workflowCounter}`
432+
})
433+
434+
it('coalesces concurrent resolutions into a single authorization query', async () => {
435+
let resolveAuthorize!: (value: { allowed: boolean; workspacePermission: string | null }) => void
436+
mockAuthorize.mockReturnValue(
437+
new Promise((resolve) => {
438+
resolveAuthorize = resolve
439+
})
440+
)
441+
442+
// Both callers race the same expired/cold cache entry; they must share one
443+
// in-flight query so a slower duplicate can never overwrite a newer
444+
// decision (e.g. a revocation recorded by the eviction sweep).
445+
const first = resolveCurrentWorkflowRole(userId, workflowId, 'read')
446+
const second = resolveCurrentWorkflowRole(userId, workflowId, 'read')
447+
448+
resolveAuthorize({ allowed: true, workspacePermission: 'write' })
449+
450+
expect(await first).toBe('write')
451+
expect(await second).toBe('write')
452+
expect(mockAuthorize).toHaveBeenCalledTimes(1)
453+
})
454+
455+
it('does not coalesce resolutions for different workflows', async () => {
456+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
457+
458+
const [first, second] = await Promise.all([
459+
resolveCurrentWorkflowRole(userId, workflowId, 'read'),
460+
resolveCurrentWorkflowRole(userId, `${workflowId}-other`, 'read'),
461+
])
462+
463+
expect(first).toBe('read')
464+
expect(second).toBe('read')
465+
expect(mockAuthorize).toHaveBeenCalledTimes(2)
466+
})
467+
468+
it('starts a fresh query after an in-flight resolution settles and its cache entry expires', async () => {
469+
vi.useFakeTimers()
470+
try {
471+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
472+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write')
473+
474+
vi.advanceTimersByTime(31_000)
475+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
476+
477+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBeNull()
478+
expect(mockAuthorize).toHaveBeenCalledTimes(2)
479+
} finally {
480+
vi.useRealTimers()
481+
}
482+
})
483+
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ interface CachedRole {
112112
*/
113113
const roleCache = new Map<string, CachedRole>()
114114

115+
/**
116+
* In-flight resolutions keyed like {@link roleCache}. Concurrent callers for the same
117+
* (user, workflow) share one authorization query instead of racing independent ones, so
118+
* cache writes per key are serialized — a slow, stale pre-revocation read can never
119+
* overwrite a newer recorded decision (e.g. the revocation the eviction sweep just
120+
* cached before kicking the socket).
121+
*/
122+
const inFlightRoleResolutions = new Map<string, Promise<string | null>>()
123+
115124
function purgeExpiredRoles(now: number): void {
116125
for (const [key, entry] of roleCache) {
117126
if (entry.expiresAt <= now) {
@@ -120,35 +129,20 @@ function purgeExpiredRoles(now: number): void {
120129
}
121130
}
122131

123-
/**
124-
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
125-
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
126-
*
127-
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
128-
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
129-
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
130-
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
131-
* resurrects already-revoked access.
132-
*/
133-
export async function resolveCurrentWorkflowRole(
132+
async function resolveRoleUncached(
133+
key: string,
134134
userId: string,
135135
workflowId: string,
136136
fallbackRole: string
137137
): Promise<string | null> {
138-
const now = Date.now()
139-
const key = `${userId}:${workflowId}`
140-
const cached = roleCache.get(key)
141-
if (cached && cached.expiresAt > now) {
142-
return cached.role
143-
}
144-
145138
try {
146139
const authorization = await authorizeWorkflowByWorkspacePermission({
147140
workflowId,
148141
userId,
149142
action: 'read',
150143
})
151144
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
145+
const now = Date.now()
152146
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
153147
purgeExpiredRoles(now)
154148
}
@@ -168,6 +162,41 @@ export async function resolveCurrentWorkflowRole(
168162
}
169163
}
170164

165+
/**
166+
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
167+
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod. Concurrent calls for
168+
* the same (user, workflow) coalesce onto a single in-flight query (single-flight), so
169+
* out-of-order cache writes cannot resurrect revoked access.
170+
*
171+
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
172+
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
173+
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
174+
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
175+
* resurrects already-revoked access.
176+
*/
177+
export async function resolveCurrentWorkflowRole(
178+
userId: string,
179+
workflowId: string,
180+
fallbackRole: string
181+
): Promise<string | null> {
182+
const key = `${userId}:${workflowId}`
183+
const cached = roleCache.get(key)
184+
if (cached && cached.expiresAt > Date.now()) {
185+
return cached.role
186+
}
187+
188+
const inFlight = inFlightRoleResolutions.get(key)
189+
if (inFlight) {
190+
return inFlight
191+
}
192+
193+
const resolution = resolveRoleUncached(key, userId, workflowId, fallbackRole).finally(() => {
194+
inFlightRoleResolutions.delete(key)
195+
})
196+
inFlightRoleResolutions.set(key, resolution)
197+
return resolution
198+
}
199+
171200
/**
172201
* Live permission gate for mutating socket operations. Re-validates the user's workspace
173202
* role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that

0 commit comments

Comments
 (0)