|
1 | 1 | import { createLogger } from '@sim/logger' |
2 | 2 | import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events' |
| 3 | +import { sleep } from '@sim/utils/helpers' |
3 | 4 | import type { AuthenticatedSocket } from '@/middleware/auth' |
4 | 5 | import { ROLE_REVALIDATION_TTL_MS, resolveCurrentWorkflowRole } from '@/middleware/permissions' |
5 | 6 | import type { IRoomManager } from '@/rooms' |
@@ -28,6 +29,25 @@ export const ACCESS_REVALIDATION_SWEEP_INTERVAL_MS = ROLE_REVALIDATION_TTL_MS |
28 | 29 | */ |
29 | 30 | const FALLBACK_ROLE = 'read' |
30 | 31 |
|
| 32 | +/** |
| 33 | + * Upper bound on a single socket's authorization check inside the scan. A DB |
| 34 | + * query that hangs (wedged connection, exhausted pool, network partition) must |
| 35 | + * not wedge the scan lane — on timeout the socket is skipped for this pass |
| 36 | + * (never evicted) and re-checked next pass, where the single-flighted |
| 37 | + * resolution is re-raced and acted on once it finally settles. |
| 38 | + */ |
| 39 | +const SCAN_SOCKET_TIMEOUT_MS = 5_000 |
| 40 | + |
| 41 | +/** |
| 42 | + * Hard budget for one whole scan pass, deliberately below |
| 43 | + * {@link ACCESS_REVALIDATION_SWEEP_INTERVAL_MS} so `scanRunning` can never |
| 44 | + * starve subsequent ticks: a pass that runs out of budget ends early and the |
| 45 | + * remaining sockets are evaluated on the next pass. |
| 46 | + */ |
| 47 | +const SCAN_PASS_BUDGET_MS = 20_000 |
| 48 | + |
| 49 | +const SCAN_TIMED_OUT = Symbol('scan-timed-out') |
| 50 | + |
31 | 51 | export interface AccessRevalidationSweep { |
32 | 52 | /** Stop the periodic sweep (clears the interval). */ |
33 | 53 | stop: () => void |
@@ -75,7 +95,10 @@ function collectLocalMemberships(io: IRoomManager['io']): Map<string, Authentica |
75 | 95 | * best-effort room-state cleanup (Redis presence) runs in its own lane. A Redis |
76 | 96 | * outage — including commands that hang in the client's offline queue rather |
77 | 97 | * than failing — can therefore stall only presence cleanup, never revocation |
78 | | - * enforcement on subsequent ticks. |
| 98 | + * enforcement on subsequent ticks. Within the scan, every authorization wait is |
| 99 | + * bounded ({@link SCAN_SOCKET_TIMEOUT_MS}) and the whole pass has a hard budget |
| 100 | + * below the interval ({@link SCAN_PASS_BUDGET_MS}), so a hanging DB query can |
| 101 | + * delay a socket's re-check but can never wedge the scan lane itself. |
79 | 102 | */ |
80 | 103 | export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessRevalidationSweep { |
81 | 104 | const io = roomManager.io |
@@ -177,14 +200,36 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR |
177 | 200 |
|
178 | 201 | async function scanMemberships(): Promise<void> { |
179 | 202 | const memberships = collectLocalMemberships(io) |
| 203 | + const deadline = Date.now() + SCAN_PASS_BUDGET_MS |
180 | 204 |
|
181 | 205 | for (const [workflowId, sockets] of memberships) { |
182 | 206 | for (const socket of sockets) { |
183 | 207 | const userId = socket.userId |
184 | 208 | if (!userId) continue |
185 | 209 |
|
| 210 | + const remainingBudget = deadline - Date.now() |
| 211 | + if (remainingBudget <= 0) { |
| 212 | + logger.warn( |
| 213 | + 'Access re-validation scan budget exhausted; remaining sockets defer to the next pass' |
| 214 | + ) |
| 215 | + return |
| 216 | + } |
| 217 | + |
186 | 218 | try { |
187 | | - const role = await resolveCurrentWorkflowRole(userId, workflowId, FALLBACK_ROLE) |
| 219 | + // Bounded wait: a hanging authorization query skips this socket for |
| 220 | + // the pass instead of wedging the scan lane. The single-flighted |
| 221 | + // resolution keeps running in the background and is re-raced next |
| 222 | + // pass, so it is acted on once it settles. |
| 223 | + const role = await Promise.race([ |
| 224 | + resolveCurrentWorkflowRole(userId, workflowId, FALLBACK_ROLE), |
| 225 | + sleep(Math.min(SCAN_SOCKET_TIMEOUT_MS, remainingBudget)).then(() => SCAN_TIMED_OUT), |
| 226 | + ]) |
| 227 | + if (role === SCAN_TIMED_OUT) { |
| 228 | + logger.warn( |
| 229 | + `Authorization check timed out for user ${userId} on workflow ${workflowId}; skipping this pass` |
| 230 | + ) |
| 231 | + continue |
| 232 | + } |
188 | 233 | if (role === null) { |
189 | 234 | revokeSocket(socket, workflowId) |
190 | 235 | } |
|
0 commit comments