Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
}
}

await updateApiKeyLastUsed(result.keyId)
updateApiKeyLastUsed(result.keyId)

return {
authenticated: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/workflows/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export async function validateWorkflowAccess(
}

if (validResult.keyId) {
await updateApiKeyLastUsed(validResult.keyId)
updateApiKeyLastUsed(validResult.keyId)
}
}
return { workflow }
Expand Down
10 changes: 6 additions & 4 deletions apps/sim/lib/api-key/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { dbChainMockFns } from '@sim/testing'
import { flushMacrotask } from '@sim/testing/helpers/async'
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import {
Expand Down Expand Up @@ -99,7 +100,8 @@ describe('authenticateApiKeyFromHeader', () => {

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

expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
Expand All @@ -113,12 +115,12 @@ describe('updateApiKeyLastUsed', () => {
})
})

it('swallows database errors instead of failing the request', async () => {
it('logs database errors instead of failing the request', async () => {
dbChainMockFns.update.mockImplementationOnce(() => {
throw new Error('connection lost')
})

await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
expect(serviceLogger.error).toHaveBeenCalled()
expect(() => updateApiKeyLastUsed('key-failing')).not.toThrow()
await vi.waitFor(() => expect(serviceLogger.error).toHaveBeenCalled())
})
})
26 changes: 18 additions & 8 deletions apps/sim/lib/api-key/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger, setRequestAuth } from '@sim/logger'
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
import { createDetachedTouch } from '@/lib/core/utils/background'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'

Expand Down Expand Up @@ -136,17 +137,26 @@ export async function authenticateApiKeyFromHeader(

const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000

const touchApiKeyLastUsed = createDetachedTouch({
label: 'API key last used',
write: writeLastUsed,
debounce: { intervalMs: LAST_USED_STALENESS_WINDOW_MS, maxKeys: 10_000 },
})

/**
* Update the last used timestamp for an API key.
* Record that an API key was used, without delaying the request.
*
* `lastUsed` is display-only, so the write uses a staleness window: it only
* fires when the stored value is older than
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
* the same row on every request, serializing concurrent requests behind row
* locks. The 10-minute window matches GitLab's personal-access-token
* last-used tracking.
* `lastUsed` is display-only, so the write is detached (see {@link createDetachedTouch}): a
* commit waiting on the database must never hold up an authenticated request. Across processes
* the write only fires when the stored value is older than {@link LAST_USED_STALENESS_WINDOW_MS},
* so high-traffic keys do not rewrite the same row on every request. The 10-minute window matches
* GitLab's personal-access-token last-used tracking.
*/
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
export function updateApiKeyLastUsed(keyId: string): void {
touchApiKeyLastUsed(keyId)
}

async function writeLastUsed(keyId: string): Promise<void> {
try {
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
await db
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/server/routes/v2-api-key-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise<V2ApiKeyAuthCon
.limit(1)
const row = requireValidRow(candidate)

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

if (row.type === 'personal') {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/auth/hybrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export async function checkHybridAuth(
keyId: result.keyId,
}
}
await updateApiKeyLastUsed(result.keyId)
updateApiKeyLastUsed(result.keyId)
return {
success: true,
userId: result.userId,
Expand Down
65 changes: 64 additions & 1 deletion apps/sim/lib/core/utils/background.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { flushMacrotask } from '@sim/testing/helpers/async'
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
import { runDetached } from '@/lib/core/utils/background'
import { createDetachedTouch, runDetached } from '@/lib/core/utils/background'

describe('runDetached', () => {
it('swallows rejections so they do not surface as unhandled', async () => {
Expand All @@ -20,3 +21,65 @@ describe('runDetached', () => {
await flushMacrotask()
})
})

describe('createDetachedTouch', () => {
const pendingWrite = () => {
let settle = () => {}
const promise = new Promise<void>((resolve) => {
settle = resolve
})
return { promise, settle }
}

it('returns before the write settles and skips repeats inside the interval', () => {
const write = vi.fn(() => new Promise<void>(() => {}))
const touch = createDetachedTouch({
label: 'test',
write,
debounce: { intervalMs: 60_000, maxKeys: 10 },
})

expect(touch('a')).toBeUndefined()
touch('a')
touch('b')

return flushMacrotask().then(() => {
expect(write.mock.calls).toEqual([['a'], ['b']])
})
})

it('never starts a second write for a key while its first is still pending', async () => {
const first = pendingWrite()
const write = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(undefined)
const touch = createDetachedTouch({
label: 'test',
write,
debounce: { intervalMs: 1, maxKeys: 1 },
})

touch('a')
await sleep(5)
touch('b')
touch('a')
await flushMacrotask()
expect(write.mock.calls).toEqual([['a'], ['b']])

first.settle()
await flushMacrotask()
await sleep(5)
touch('a')
await flushMacrotask()
expect(write.mock.calls).toEqual([['a'], ['b'], ['a']])
})

it('releases a key whose write failed', async () => {
const write = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue(undefined)
const touch = createDetachedTouch({ label: 'test', write })

touch('a')
await flushMacrotask()
touch('a')
await flushMacrotask()
expect(write).toHaveBeenCalledTimes(2)
})
})
44 changes: 44 additions & 0 deletions apps/sim/lib/core/utils/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { LRUCache } from 'lru-cache'

const logger = createLogger('BackgroundTask')

Expand All @@ -24,3 +25,46 @@ export function runDetached(label: string, work: () => Promise<unknown>): void {
logger.error(`Background task failed: ${label}`, toError(error))
})
}

interface DetachedTouchOptions {
/** Identifier used in the failure log line. */
label: string
/** The write itself, usually a guarded single-row timestamp update. */
write: (key: string) => Promise<unknown>
/**
* Skip a key written by this process within `intervalMs`, remembering at most `maxKeys`. Only
* for a value nothing reads as fresh: the process-local window adds to whatever staleness the
* write's own guard allows.
*/
debounce?: { intervalMs: number; maxKeys: number }
}

/**
* A best-effort per-key write (a last-used or last-seen timestamp) that the caller never waits
* on, and that never starts while the same key's previous write is still pending: a commit
* stalled on the database would otherwise hold every caller that touches the row, and each repeat
* would queue behind its row lock holding a pool connection. In-flight keys are tracked apart
* from the optional debounce cache, so neither expiry nor eviction can start an overlapping write.
*/
export function createDetachedTouch({
label,
write,
debounce,
}: DetachedTouchOptions): (key: string) => void {
const recent = debounce
? new LRUCache<string, true>({ max: debounce.maxKeys, ttl: debounce.intervalMs })
: null
const inFlight = new Set<string>()
return (key) => {
if (inFlight.has(key) || recent?.has(key)) return
recent?.set(key, true)
inFlight.add(key)
runDetached(label, async () => {
try {
await write(key)
} finally {
inFlight.delete(key)
}
})
}
}
13 changes: 12 additions & 1 deletion apps/sim/lib/desktop/application/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id'
import { isPlainRecord, omit } from '@sim/utils/object'
import { defineOperation } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { createDetachedTouch } from '@/lib/core/utils/background'
import {
type CredentialUserAuditEntry,
defineAuthorizedCredentialUserUseCase,
Expand Down Expand Up @@ -58,6 +59,16 @@ import {

const logger = createLogger('DesktopExecutor')

/**
* An inbox poll never waits on its `lastSeenAt` write or stacks one behind another. Not debounced
* in-process: the offline check reads `lastSeenAt`, and the write's own SQL guard already bounds
* how often it lands.
*/
const touchDeviceLastSeen = createDetachedTouch({
label: 'desktop device last seen',
write: touchDesktopDevice,
})

type DeviceInput = { deviceId: string }

/** Every executor request must come from the session the device registered under. */
Expand Down Expand Up @@ -169,10 +180,10 @@ export const listDesktopInbox = defineAuthorizedCredentialUserUseCase({
input: DeviceInput
}): Promise<{ items: DesktopInboxEntry[] }> {
await requireBoundDevice(principal, input.deviceId)
touchDeviceLastSeen(input.deviceId)
const [rows] = await Promise.all([
listDesktopInboxRows({ deviceId: input.deviceId, userId: principal.userId }),
markDesktopPresent(input.deviceId),
touchDesktopDevice(input.deviceId),
])
return { items: classifyDesktopInbox(rows) }
},
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/workflows/api/route-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async function authenticateWorkflowApiKey(rawApiKey: string): Promise<WorkflowAp
if (!result.success || !result.keyId || !result.keyType) {
throw new InternalUnauthenticatedError('Unauthorized')
}
await updateApiKeyLastUsed(result.keyId)
updateApiKeyLastUsed(result.keyId)

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