Skip to content

Commit 2f84984

Browse files
committed
perf(auth): stop blocking requests on best-effort last-used timestamp writes
The API key lastUsed update and the desktop device lastSeenAt update are display-only, but every request awaited them. When the primary's commits wait on synchronous replication, these single-row writes stall for the whole episode and hold the authenticated request (and the desktop inbox poll) with them. Concurrent requests on the same key also queued behind the stalled writer's row lock, each holding a pool connection. Both writes are now fire-and-forget with logged failures, following the sandbox image touch precedent. The API key write is additionally debounced per process with an LRU keyed by key id over the existing staleness window, so a hot key issues at most one in-flight write per process instead of one per request.
1 parent 558d96d commit 2f84984

8 files changed

Lines changed: 50 additions & 15 deletions

File tree

‎apps/sim/app/api/v1/auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
7474
}
7575
}
7676

77-
await updateApiKeyLastUsed(result.keyId)
77+
updateApiKeyLastUsed(result.keyId)
7878

7979
return {
8080
authenticated: true,

‎apps/sim/app/api/workflows/middleware.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export async function validateWorkflowAccess(
129129
}
130130

131131
if (validResult.keyId) {
132-
await updateApiKeyLastUsed(validResult.keyId)
132+
updateApiKeyLastUsed(validResult.keyId)
133133
}
134134
}
135135
return { workflow }

‎apps/sim/lib/api-key/service.test.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ describe('authenticateApiKeyFromHeader', () => {
9898
})
9999

100100
describe('updateApiKeyLastUsed', () => {
101-
it('only writes when the stored lastUsed is missing or stale', async () => {
102-
await updateApiKeyLastUsed('key-1')
101+
it('only writes when the stored lastUsed is missing or stale', () => {
102+
updateApiKeyLastUsed('key-1')
103103

104104
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
105105
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
@@ -113,12 +113,26 @@ describe('updateApiKeyLastUsed', () => {
113113
})
114114
})
115115

116-
it('swallows database errors instead of failing the request', async () => {
116+
it('returns without waiting for a write that has not committed', () => {
117+
dbChainMockFns.where.mockReturnValueOnce(new Promise(() => {}))
118+
119+
expect(updateApiKeyLastUsed('key-stalled')).toBeUndefined()
120+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
121+
})
122+
123+
it('writes a key at most once per staleness window in this process', () => {
124+
updateApiKeyLastUsed('key-hot')
125+
updateApiKeyLastUsed('key-hot')
126+
127+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
128+
})
129+
130+
it('logs database errors instead of failing the request', async () => {
117131
dbChainMockFns.update.mockImplementationOnce(() => {
118132
throw new Error('connection lost')
119133
})
120134

121-
await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
122-
expect(serviceLogger.error).toHaveBeenCalled()
135+
expect(() => updateApiKeyLastUsed('key-failing')).not.toThrow()
136+
await vi.waitFor(() => expect(serviceLogger.error).toHaveBeenCalled())
123137
})
124138
})

‎apps/sim/lib/api-key/service.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
33
import { createLogger, setRequestAuth } from '@sim/logger'
44
import { and, eq, isNull, lt, or } from 'drizzle-orm'
5+
import { LRUCache } from 'lru-cache'
56
import { hashApiKey } from '@/lib/api-key/crypto'
67
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
78
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -137,16 +138,34 @@ export async function authenticateApiKeyFromHeader(
137138
const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000
138139

139140
/**
140-
* Update the last used timestamp for an API key.
141+
* Keys this process has written `lastUsed` for within the staleness window. The
142+
* TTL is the debounce: a still-fresh entry means the stored value is recent, so
143+
* concurrent requests on one key never queue behind each other's row lock.
144+
*/
145+
const recentLastUsedWrites = new LRUCache<string, true>({
146+
max: 10_000,
147+
ttl: LAST_USED_STALENESS_WINDOW_MS,
148+
})
149+
150+
/**
151+
* Record that an API key was used, without delaying the request.
141152
*
142-
* `lastUsed` is display-only, so the write uses a staleness window: it only
143-
* fires when the stored value is older than
153+
* `lastUsed` is display-only, so the write is fire-and-forget: a commit that
154+
* waits on the database (for example on synchronous replication) must never
155+
* hold up an authenticated request. It is debounced per process and, across
156+
* processes, only fires when the stored value is older than
144157
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
145158
* the same row on every request, serializing concurrent requests behind row
146159
* locks. The 10-minute window matches GitLab's personal-access-token
147160
* last-used tracking.
148161
*/
149-
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
162+
export function updateApiKeyLastUsed(keyId: string): void {
163+
if (recentLastUsedWrites.has(keyId)) return
164+
recentLastUsedWrites.set(keyId, true)
165+
void writeLastUsed(keyId)
166+
}
167+
168+
async function writeLastUsed(keyId: string): Promise<void> {
150169
try {
151170
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
152171
await db

‎apps/sim/lib/api/server/routes/v2-api-key-auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise<V2ApiKeyAuthCon
120120
.limit(1)
121121
const row = requireValidRow(candidate)
122122

123-
await updateApiKeyLastUsed(row.id)
123+
updateApiKeyLastUsed(row.id)
124124
logger.debug('Authenticated v2 API key', { keyId: row.id, keyType: row.type })
125125

126126
if (row.type === 'personal') {

‎apps/sim/lib/auth/hybrid.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export async function checkHybridAuth(
230230
keyId: result.keyId,
231231
}
232232
}
233-
await updateApiKeyLastUsed(result.keyId)
233+
updateApiKeyLastUsed(result.keyId)
234234
return {
235235
success: true,
236236
userId: result.userId,

‎apps/sim/lib/desktop/application/executor.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,12 @@ export const listDesktopInbox = defineAuthorizedCredentialUserUseCase({
169169
input: DeviceInput
170170
}): Promise<{ items: DesktopInboxEntry[] }> {
171171
await requireBoundDevice(principal, input.deviceId)
172+
void touchDesktopDevice(input.deviceId).catch((error) =>
173+
logger.warn('Failed to record desktop device last seen', { deviceId: input.deviceId, error })
174+
)
172175
const [rows] = await Promise.all([
173176
listDesktopInboxRows({ deviceId: input.deviceId, userId: principal.userId }),
174177
markDesktopPresent(input.deviceId),
175-
touchDesktopDevice(input.deviceId),
176178
])
177179
return { items: classifyDesktopInbox(rows) }
178180
},

‎apps/sim/lib/workflows/api/route-policies.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async function authenticateWorkflowApiKey(rawApiKey: string): Promise<WorkflowAp
103103
if (!result.success || !result.keyId || !result.keyType) {
104104
throw new InternalUnauthenticatedError('Unauthorized')
105105
}
106-
await updateApiKeyLastUsed(result.keyId)
106+
updateApiKeyLastUsed(result.keyId)
107107

108108
if (result.keyType === 'workspace') {
109109
if (!result.workspaceId) throw new Error('Workspace API key is missing its workspace scope')

0 commit comments

Comments
 (0)