Skip to content

Commit d59a275

Browse files
authored
perf(audit-logs): read a listing narrowed only by time through per-workspace keyset scans (#9450)
* perf(audit-logs): read a listing narrowed only by time through per-workspace keyset scans An organization's audit listing is one OR predicate over the organization's workspaces and its organization-level rows. With no filter, or only a time range (the audit page's default view), PostgreSQL has no index that returns that OR in listing order, so it collects every row of the scope inside the range and sorts it for each page; for a busy organization that is a scan of a large part of the table. Such a listing now reads its page ids as a disjoint union: one keyset scan of audit_log_workspace_created_at_id_idx per organization workspace, the organization-level metadata rows through audit_log_org_level_created_at_id_idx, and the organization resource rows that do not name it in metadata. Each part stops after a page, and the first page of the union is exactly the first page of the scope. The time range is kept verbatim and also stated as implied bounds on the sort key, so each scan starts and stops at the range; the cursor is the same keyset position written as a row comparison so the scan seeks to it. A listing with any other filter (action, resource, actor, workspace, search) keeps the single predicate unchanged, byte for byte: per-workspace scans cannot stop early under such a filter, which is what made #9419 slow, while the single predicate lets the filter's own index drive. queryAuditLogs now takes the scope and the filters rather than prebuilt SQL so it can make that choice. list-scope.integration.ts pages every filter shape, with and without departed members, across page sizes and the byte budget, and requires exactly the single predicate's order, including sub-millisecond ties, bounds inside a millisecond and a workspace the organization no longer holds. list-plans.integration.ts pins the keyset plan for the unfiltered and time-bounded shapes (failing on the previous single-predicate listing) and the single predicate for every other one. * test(audit-logs): plan the keyset listing over a workspace written mostly by a departed member
1 parent 94b33d2 commit d59a275

9 files changed

Lines changed: 446 additions & 140 deletions

File tree

‎apps/sim/app/api/v1/audit-logs/route.test.ts‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,12 @@ import { v1LogsMetaMock, v1LogsMetaMockFns } from '@sim/testing/mocks/v1-logs-me
77
import { v1MiddlewareMock, v1MiddlewareMockFns } from '@sim/testing/mocks/v1-middleware.mock'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const {
11-
mockValidateV1EnterpriseAuditAccess,
12-
mockBuildOrgScopeCondition,
13-
mockGetOrgWorkspaceIds,
14-
mockQueryAuditLogs,
15-
mockBuildFilterConditions,
16-
} = vi.hoisted(() => ({
17-
mockValidateV1EnterpriseAuditAccess: vi.fn(),
18-
mockBuildOrgScopeCondition: vi.fn(),
19-
mockGetOrgWorkspaceIds: vi.fn(),
20-
mockQueryAuditLogs: vi.fn(),
21-
mockBuildFilterConditions: vi.fn(),
22-
}))
10+
const { mockValidateV1EnterpriseAuditAccess, mockGetOrgWorkspaceIds, mockQueryAuditLogs } =
11+
vi.hoisted(() => ({
12+
mockValidateV1EnterpriseAuditAccess: vi.fn(),
13+
mockGetOrgWorkspaceIds: vi.fn(),
14+
mockQueryAuditLogs: vi.fn(),
15+
}))
2316

2417
vi.mock('@/app/api/v1/middleware', () => v1MiddlewareMock)
2518

@@ -28,8 +21,6 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
2821
}))
2922

3023
vi.mock('@/lib/audit-logs/query', () => ({
31-
buildFilterConditions: mockBuildFilterConditions,
32-
buildOrgScopeCondition: mockBuildOrgScopeCondition,
3324
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
3425
queryAuditLogs: mockQueryAuditLogs,
3526
}))
@@ -48,7 +39,6 @@ v1LogsMetaMockFns.mockCreateApiResponse.mockImplementation((body: unknown) => ({
4839
const ORG_ID = 'org-1'
4940
const MEMBER_IDS = ['admin-1', 'member-1']
5041
const ORG_WORKSPACE_IDS = ['ws-org-1', 'ws-org-2']
51-
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
5242

5343
function makeRequest(query: string) {
5444
return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/v1/audit-logs${query}`)
@@ -63,8 +53,6 @@ describe('GET /api/v1/audit-logs', () => {
6353
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
6454
})
6555
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
66-
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
67-
mockBuildFilterConditions.mockReturnValue([])
6856
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
6957
})
7058

‎apps/sim/app/api/v1/audit-logs/route.ts‎

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,7 @@ import { generateId } from '@sim/utils/id'
2525
import { type NextRequest, NextResponse } from 'next/server'
2626
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
2727
import { parseRequest } from '@/lib/api/server'
28-
import {
29-
buildFilterConditions,
30-
buildOrgScopeCondition,
31-
getOrgWorkspaceIds,
32-
queryAuditLogs,
33-
} from '@/lib/audit-logs/query'
28+
import { getOrgWorkspaceIds, queryAuditLogs } from '@/lib/audit-logs/query'
3429
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3530
import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
3631
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
@@ -99,24 +94,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
9994
)
10095
}
10196

102-
const scopeCondition = buildOrgScopeCondition({
103-
organizationId,
104-
orgWorkspaceIds,
105-
orgMemberIds,
106-
includeDeparted: params.includeDeparted,
107-
})
108-
const filterConditions = buildFilterConditions({
109-
action: params.action,
110-
resourceType: params.resourceType,
111-
resourceId: params.resourceId,
112-
workspaceId: params.workspaceId,
113-
actorId: params.actorId,
114-
startDate: params.startDate,
115-
endDate: params.endDate,
116-
})
117-
11897
const { data, nextCursor } = await queryAuditLogs(
119-
[scopeCondition, ...filterConditions],
98+
{ organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted: params.includeDeparted },
99+
{
100+
action: params.action,
101+
resourceType: params.resourceType,
102+
resourceId: params.resourceId,
103+
workspaceId: params.workspaceId,
104+
actorId: params.actorId,
105+
startDate: params.startDate,
106+
endDate: params.endDate,
107+
},
120108
params.limit,
121109
params.cursor
122110
)

‎apps/sim/lib/audit-logs/application/export-audit-logs.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { auditLogOperations } from '@/lib/audit-logs/application/operations'
44
import { AUDIT_LOG_CSV_HEADER, toAuditLogCsvRow } from '@/lib/audit-logs/csv'
55
import {
66
type AuditLogFilterParams,
7-
buildFilterConditions,
8-
buildOrgScopeCondition,
7+
type AuditLogListScope,
98
getOrgWorkspaceIds,
109
queryAuditLogs,
1110
} from '@/lib/audit-logs/query'
@@ -37,13 +36,12 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
3736
) {
3837
throw new OrchestrationError('validation', 'actorId is not a member of your organization')
3938
}
40-
const scope = buildOrgScopeCondition({
39+
const scope: AuditLogListScope = {
4140
organizationId: context.organizationId,
4241
orgWorkspaceIds: workspaceIds,
4342
orgMemberIds: context.orgMemberIds,
4443
includeDeparted: input.includeDeparted,
45-
})
46-
const conditions = [scope, ...buildFilterConditions(input.filters)]
44+
}
4745
const lines = [AUDIT_LOG_CSV_HEADER]
4846
let rowCount = 0
4947
let cursor: string | undefined
@@ -52,7 +50,8 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
5250
pages: while (rowCount < EXPORT_MAX_ROWS && bytes < EXPORT_MAX_BYTES) {
5351
request?.signal?.throwIfAborted()
5452
const page = await queryAuditLogs(
55-
conditions,
53+
scope,
54+
input.filters,
5655
Math.min(EXPORT_PAGE_ROWS, EXPORT_MAX_ROWS - rowCount),
5756
cursor,
5857
EXPORT_MAX_BYTES

‎apps/sim/lib/audit-logs/application/list-audit-logs.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/au
22
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
33
import {
44
type AuditLogFilterParams,
5-
buildFilterConditions,
6-
buildOrgScopeCondition,
75
decodeAuditLogCursor,
86
getOrgWorkspaceIds,
97
queryAuditLogs,
@@ -32,22 +30,25 @@ export const listAuditLogs = defineAuthorizedAuditLogUseCase({
3230
if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) {
3331
throw new OrchestrationError('validation', 'workspaceId does not belong to your organization')
3432
}
35-
const scopeCondition = buildOrgScopeCondition({
36-
organizationId: context.organizationId,
37-
orgWorkspaceIds,
38-
orgMemberIds: context.orgMemberIds,
39-
includeDeparted: input.includeDeparted,
40-
})
4133
if (
4234
context.workspaceId &&
4335
input.filters.workspaceId &&
4436
context.workspaceId !== input.filters.workspaceId
4537
)
4638
throw new OrchestrationError('not_found', 'Audit logs not found in the selected workspace')
47-
const filterConditions = buildFilterConditions({
48-
...input.filters,
49-
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
50-
})
51-
return queryAuditLogs([scopeCondition, ...filterConditions], input.limit, input.cursor)
39+
return queryAuditLogs(
40+
{
41+
organizationId: context.organizationId,
42+
orgWorkspaceIds,
43+
orgMemberIds: context.orgMemberIds,
44+
includeDeparted: input.includeDeparted,
45+
},
46+
{
47+
...input.filters,
48+
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
49+
},
50+
input.limit,
51+
input.cursor
52+
)
5253
},
5354
})

‎apps/sim/lib/audit-logs/application/use-cases.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ const hoisted = vi.hoisted(() => ({
2929
resolveDefaultOrganization: vi.fn(),
3030
getOrgWorkspaceIds: vi.fn(),
3131
buildOrgScopeCondition: vi.fn(),
32-
buildFilterConditions: vi.fn(),
3332
decodeAuditLogCursor: vi.fn(),
3433
queryAuditLogs: vi.fn(),
3534
isCapabilityWithheldForUser: vi.fn(),
@@ -50,7 +49,6 @@ vi.mock('@/lib/audit-logs/authorization', () => ({
5049
vi.mock('@/lib/audit-logs/query', () => ({
5150
getOrgWorkspaceIds: hoisted.getOrgWorkspaceIds,
5251
buildOrgScopeCondition: hoisted.buildOrgScopeCondition,
53-
buildFilterConditions: hoisted.buildFilterConditions,
5452
decodeAuditLogCursor: hoisted.decodeAuditLogCursor,
5553
queryAuditLogs: hoisted.queryAuditLogs,
5654
}))
@@ -96,7 +94,6 @@ describe('audit-log application use cases', () => {
9694
})
9795
mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
9896
mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' })
99-
mocks.buildFilterConditions.mockReturnValue([])
10097
mocks.decodeAuditLogCursor.mockReturnValue({
10198
createdAt: '2026-01-01T00:00:00.000Z',
10299
id: 'audit-1',
@@ -111,7 +108,12 @@ describe('audit-log application use cases', () => {
111108
await listAuditLogs.execute({ principal, input: { ...listInput, organizationId: undefined } })
112109
expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1')
113110
expect(mocks.resolveDefaultOrganization).not.toHaveBeenCalled()
114-
expect(mocks.buildFilterConditions).toHaveBeenCalledWith({ workspaceId: 'workspace-1' })
111+
expect(mocks.queryAuditLogs).toHaveBeenCalledWith(
112+
expect.objectContaining({ organizationId: 'organization-1' }),
113+
{ workspaceId: 'workspace-1' },
114+
50,
115+
undefined
116+
)
115117
mocks.resolveAccess.mockResolvedValueOnce({
116118
success: false,
117119
code: 'FORBIDDEN',
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* The plans an organization's audit-log listing must get, proven on the exact SQL `queryAuditLogs`
3+
* sends. Narrowed by nothing but a time range, each workspace is read by a keyset scan of
4+
* `audit_log_workspace_created_at_id_idx` that a page limit stops, never by collecting and sorting
5+
* the whole scope. Any other filter keeps the single scope predicate, so its plan is the one the
6+
* filter's own index drives.
7+
*/
8+
import { db } from '@sim/db'
9+
import { auditLog, user, workspace } from '@sim/db/schema'
10+
import { explainCaptured, type PlanNode } from '@sim/db/testing/explain'
11+
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
12+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
13+
import { generateId } from '@sim/utils/id'
14+
import { eq, inArray, like, sql } from 'drizzle-orm'
15+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
16+
import {
17+
type AuditLogFilterParams,
18+
type AuditLogListScope,
19+
queryAuditLogs,
20+
} from '@/lib/audit-logs/query'
21+
22+
const organizationId = generateId()
23+
const memberId = generateId()
24+
const departedId = generateId()
25+
const workspaceId = generateId()
26+
const idPrefix = `audit_plan_${generateId()}_`
27+
const workspaceIndex = 'audit_log_workspace_created_at_id_idx'
28+
const orgLevelIndex = 'audit_log_org_level_created_at_id_idx'
29+
30+
const cursor = Buffer.from(
31+
JSON.stringify({ createdAt: '2026-01-01T03:00:00.000Z', id: `${idPrefix}~` })
32+
).toString('base64')
33+
34+
const listingPlan = (
35+
filters: AuditLogFilterParams,
36+
options: { includeDeparted?: boolean; cursor?: string } = {}
37+
) => {
38+
const scope: AuditLogListScope = {
39+
organizationId,
40+
orgWorkspaceIds: [workspaceId],
41+
orgMemberIds: [memberId],
42+
includeDeparted: options.includeDeparted ?? false,
43+
}
44+
return explainCaptured(db, () => queryAuditLogs(scope, filters, 50, options.cursor))
45+
}
46+
47+
/** The workspace part's keyset scans, each directly under the limit that stops it after a page. */
48+
const limitedWorkspaceScans = (nodes: PlanNode[]) =>
49+
nodes
50+
.filter((node) => node['Node Type'] === 'Limit')
51+
.map((node) => node.Plans?.[0])
52+
.filter((child) => child?.['Index Name'] === workspaceIndex)
53+
54+
const KEYSET_CASES: [
55+
string,
56+
AuditLogFilterParams,
57+
{ includeDeparted?: boolean; cursor?: string },
58+
][] = [
59+
['no filter, current members', {}, {}],
60+
['no filter, departed members included', {}, { includeDeparted: true }],
61+
['no filter, after a cursor', {}, { cursor }],
62+
['a start date', { startDate: '2026-01-01T01:00:00.000Z' }, {}],
63+
[
64+
'a date range, after a cursor',
65+
{ startDate: '2026-01-01T01:00:00.000Z', endDate: '2026-01-01T04:00:00.000Z' },
66+
{ cursor },
67+
],
68+
]
69+
70+
const NARROWED_CASES: [string, AuditLogFilterParams][] = [
71+
['an action', { action: 'organization.updated' }],
72+
[
73+
'an action and a start date',
74+
{ action: 'organization.updated', startDate: '2026-01-01T01:00:00.000Z' },
75+
],
76+
['a resource type', { resourceType: 'workflow' }],
77+
['a resource', { resourceType: 'workflow', resourceId: 'resource-7' }],
78+
['an actor', { actorId: memberId }],
79+
['a workspace', { workspaceId }],
80+
['a search', { search: 'fixture' }],
81+
]
82+
83+
describe('organization audit-log listing plans in PostgreSQL', () => {
84+
beforeAll(async () => {
85+
assertDisposableTestDatabaseUrl(process.env.DATABASE_URL ?? '')
86+
await db.insert(user).values(
87+
[memberId, departedId].map((id) => ({
88+
id,
89+
name: 'Audit plan fixture',
90+
email: `${id}@audit-plan.test`,
91+
emailVerified: true,
92+
createdAt: new Date(),
93+
updatedAt: new Date(),
94+
}))
95+
)
96+
await insertWorkspaceFixture(db, {
97+
id: workspaceId,
98+
name: 'Audit plan fixture',
99+
ownerId: memberId,
100+
billedAccountUserId: memberId,
101+
})
102+
// A workspace with a long history written mostly by a departed member, and workspace-less
103+
// rows of many other organizations, so collecting the scope and sorting it costs far more than
104+
// a keyset scan that stops at a page, even one that passes over departed rows to fill it.
105+
await db.execute(sql`
106+
INSERT INTO ${auditLog} (id, workspace_id, actor_id, action, resource_type, resource_id,
107+
metadata, created_at)
108+
SELECT ${idPrefix} || n, CASE WHEN n % 4 = 1 THEN ${workspaceId} END,
109+
CASE WHEN n % 20 = 1 OR n % 2 = 0 THEN ${memberId} WHEN n % 4 = 1 THEN ${departedId} END,
110+
'organization.updated',
111+
CASE WHEN n % 997 = 0 THEN 'organization' ELSE 'workflow' END,
112+
CASE WHEN n % 997 = 0 THEN ${organizationId} ELSE 'resource-' || n END,
113+
jsonb_build_object('organizationId',
114+
CASE WHEN n % 50 = 0 THEN ${organizationId} ELSE 'other-organization-' || (n % 400) END),
115+
timestamp '2026-01-01' + n * interval '1 second'
116+
FROM generate_series(1, 20000) AS n`)
117+
await db.execute(sql`VACUUM ANALYZE ${auditLog}`)
118+
})
119+
120+
afterAll(async () => {
121+
await db.delete(auditLog).where(like(auditLog.id, `${idPrefix}%`))
122+
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
123+
await db.delete(user).where(inArray(user.id, [memberId, departedId]))
124+
})
125+
126+
it.each(KEYSET_CASES)(
127+
'reads each workspace newest first through its keyset index and stops after a page (%s)',
128+
async (_label, filters, options) => {
129+
const nodes = await listingPlan(filters, options)
130+
const [scan] = limitedWorkspaceScans(nodes)
131+
expect(scan).toMatchObject({ 'Node Type': 'Index Scan', 'Scan Direction': 'Backward' })
132+
expect(scan?.['Index Cond']).toMatch(/workspace_id = org_workspace\.workspace_id/)
133+
if (filters.startDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) >= /)
134+
if (filters.endDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) <= /)
135+
if (options.cursor) expect(scan?.['Index Cond']).toMatch(/ROW\(date_trunc/)
136+
expect(nodes.filter((node) => node['Index Name'] === orgLevelIndex)).toHaveLength(1)
137+
}
138+
)
139+
140+
it.each(NARROWED_CASES)(
141+
'keeps the single scope predicate when the listing is narrowed by %s',
142+
async (_label, filters) => {
143+
const nodes = await listingPlan(filters)
144+
expect(nodes.filter((node) => node['Node Type'] === 'Function Scan')).toEqual([])
145+
expect(nodes.filter((node) => /org_workspace/.test(node['Index Cond'] ?? ''))).toEqual([])
146+
}
147+
)
148+
})

0 commit comments

Comments
 (0)