Skip to content

Commit c6468fe

Browse files
committed
perf(db): index a workspace's live files in upload order
The live file list filtered on workspace, owner, context and liveness and sorted by uploaded_at, and no index served that filter in that order. The planner combined the context and search keyset indexes, which match the live workspace files of every workspace, filtered to one workspace on the heap, then sorted, on disk for a large workspace. A bounded read such as the SSR seed or an uploadedAt keyset page paid for the whole workspace. workspace_files_workspace_active_uploaded_idx keys on (workspace_id, uploaded_at, id) under the predicate the other live workspace-file indexes share, which the list's filter implies. The key stays narrow: carrying the name and storage key columns would bring an entry near the btree size limit and fail the write. The contract-pending note on workspace_files_workspace_active_keyset_idx now names this index as the one that takes over its workspace-scoped reads. The list now orders upload-time ties by id, so its order is deterministic. explainCaptured in @sim/db/testing/explain EXPLAINs the statement a call sends, with its bound parameters, for plan assertions in integration tests. On a prod-shaped local copy with stale statistics, the largest workspace's 301-row seed went from ~210 ms to under 1 ms and an uploadedAt keyset page from ~205 ms to under 0.1 ms, both ordered index scans with no sort. The unbounded full list keeps its bitmap plan: its estimate is about an eighth of the real row count, at which the planner prices this index above it. Lookups by key, name and id and the name-sorted folder page keep their plans.
1 parent 31f7529 commit c6468fe

9 files changed

Lines changed: 31515 additions & 4 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Real PostgreSQL reads of a workspace's whole file list: which rows each scope returns, their
3+
* order, and the plan the live list's SQL gets.
4+
*/
5+
import { db } from '@sim/db'
6+
import { user, workspace, workspaceFiles } from '@sim/db/schema'
7+
import { explainCaptured } from '@sim/db/testing/explain'
8+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
9+
import { uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock'
10+
import { generateId } from '@sim/utils/id'
11+
import { eq, inArray } from 'drizzle-orm'
12+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock)
15+
16+
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/file-manager'
17+
import { WORKSPACE_FILE_SEED_MAX } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
18+
19+
const ownerId = generateId()
20+
const workspaceId = generateId()
21+
const otherWorkspaceId = generateId()
22+
const tick = (seconds: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds))
23+
24+
type FileRow = typeof workspaceFiles.$inferInsert
25+
26+
function fileRow(name: string, values: Partial<FileRow> = {}): FileRow {
27+
const id = `${name}-${workspaceId}`
28+
return {
29+
id,
30+
key: `workspace/${workspaceId}/${id}`,
31+
userId: ownerId,
32+
workspaceId,
33+
context: 'workspace',
34+
originalName: `${name}.txt`,
35+
contentType: 'text/plain',
36+
sizeBytes: 1,
37+
...values,
38+
}
39+
}
40+
41+
describe('workspace file list in PostgreSQL', () => {
42+
/**
43+
* Rows inserted, and deleted, out of id order, with uploaded_at ties inside each scope and
44+
* across them, so an order that is not (uploaded_at, id) shows up as a reordered list.
45+
*/
46+
const live = [
47+
fileRow('live-d', { uploadedAt: tick(1) }),
48+
fileRow('live-c', { uploadedAt: tick(2) }),
49+
fileRow('live-b', { uploadedAt: tick(2) }),
50+
fileRow('live-a', { uploadedAt: tick(2) }),
51+
fileRow('live-e', { uploadedAt: tick(4) }),
52+
]
53+
const archived = [
54+
fileRow('gone-b', { uploadedAt: tick(2), deletedAt: tick(10) }),
55+
fileRow('gone-c', { uploadedAt: tick(3), deletedAt: tick(11) }),
56+
fileRow('gone-a', { uploadedAt: tick(3), deletedAt: tick(12) }),
57+
]
58+
const neverListed = [
59+
fileRow('chat-upload', { context: 'mothership', uploadedAt: tick(2) }),
60+
fileRow('execution-output', { context: 'execution', uploadedAt: tick(2) }),
61+
fileRow('other-workspace', {
62+
workspaceId: otherWorkspaceId,
63+
key: `workspace/${otherWorkspaceId}/other`,
64+
uploadedAt: tick(2),
65+
}),
66+
]
67+
const byUploadThenId = (rows: FileRow[]) =>
68+
[...rows]
69+
.sort(
70+
(a, b) =>
71+
(a.uploadedAt?.getTime() ?? 0) - (b.uploadedAt?.getTime() ?? 0) ||
72+
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
73+
)
74+
.map((row) => row.id)
75+
76+
beforeAll(async () => {
77+
await db.insert(user).values({
78+
id: ownerId,
79+
email: `${ownerId}@file-list.invalid`,
80+
name: 'File list fixture',
81+
emailVerified: true,
82+
createdAt: new Date(),
83+
updatedAt: new Date(),
84+
})
85+
await insertWorkspaceFixture(
86+
db,
87+
[workspaceId, otherWorkspaceId].map((id) => ({
88+
id,
89+
ownerId,
90+
billedAccountUserId: ownerId,
91+
name: 'File list fixture',
92+
}))
93+
)
94+
await db.insert(workspaceFiles).values([...live, ...archived, ...neverListed])
95+
})
96+
97+
afterAll(async () => {
98+
try {
99+
await db.delete(workspaceFiles).where(eq(workspaceFiles.userId, ownerId))
100+
await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId]))
101+
await db.delete(user).where(eq(user.id, ownerId))
102+
} finally {
103+
await db.$client.end()
104+
}
105+
})
106+
107+
it.each([
108+
{ scope: 'active', label: 'live files', expected: byUploadThenId(live) },
109+
{ scope: 'archived', label: 'Recently Deleted files', expected: byUploadThenId(archived) },
110+
{
111+
scope: 'all',
112+
label: 'live and deleted files together',
113+
expected: byUploadThenId([...live, ...archived]),
114+
},
115+
] as const)('lists $label by upload time, ties by id', async ({ scope, expected }) => {
116+
const files = await listWorkspaceFiles(workspaceId, { scope, throwOnError: true })
117+
expect(files.map((file) => file.id)).toEqual(expected)
118+
})
119+
120+
/**
121+
* With every sorting plan ruled out, an ordered plan exists only if an index's predicate is
122+
* implied by the list's filter and its key gives the list's order.
123+
*/
124+
it('reads the live list, whole or bounded, in order from its workspace index', async () => {
125+
for (const limit of [undefined, WORKSPACE_FILE_SEED_MAX + 1]) {
126+
const nodes = await explainCaptured(
127+
db,
128+
() => listWorkspaceFiles(workspaceId, { limit, throwOnError: true }),
129+
{ match: /from "workspace_files"/, gucs: { enable_sort: 'off' } }
130+
)
131+
expect(nodes.map((node) => node['Node Type'])).not.toContain('Sort')
132+
expect(nodes.map((node) => node['Index Name'])).toContain(
133+
'workspace_files_workspace_active_uploaded_idx'
134+
)
135+
}
136+
})
137+
})

‎apps/sim/lib/uploads/contexts/workspace/file-manager.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,7 +1794,8 @@ async function hydrateWorkspaceFilePaths(
17941794
}
17951795

17961796
/**
1797-
* List all files for a workspace
1797+
* List all files for a workspace, oldest upload first; `id` orders files uploaded at the same
1798+
* instant, so the order is stable across reads.
17981799
*/
17991800
export async function listWorkspaceFiles(
18001801
workspaceId: string,
@@ -1806,7 +1807,7 @@ export async function listWorkspaceFiles(
18061807
.select(workspaceFileListColumns)
18071808
.from(workspaceFiles)
18081809
.where(workspaceFileScopeCondition(workspaceId, scope))
1809-
.orderBy(workspaceFiles.uploadedAt)
1810+
.orderBy(workspaceFiles.uploadedAt, workspaceFiles.id)
18101811
const files = await (limit === undefined ? query : query.limit(limit))
18111812

18121813
return await hydrateWorkspaceFilePaths(files, workspaceId, options)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- A workspace's live file list, in upload order. No index served the list's filter in that
2+
-- order, so the planner matched every workspace's live files, filtered to one workspace on the
3+
-- heap and sorted them, on disk for a large workspace; a bounded read (the SSR seed, an
4+
-- uploadedAt keyset page) paid for the whole workspace.
5+
--
6+
-- The predicate is the one the other live workspace-file indexes share, which the list's filter
7+
-- implies. The list's owner conditions (project_id and organization_id IS NULL) stay heap checks
8+
-- that discard nothing: the project and organization binding CHECKs rule both out for
9+
-- workspace-context rows. The key stays narrow: carrying the listed name and storage key columns
10+
-- would bring an entry near the btree size limit and fail the write.
11+
--
12+
-- Replay-safety: workspace_files takes uploads and edits continuously, so the build is
13+
-- CONCURRENTLY, below an embedded COMMIT, and a failure there replays the whole file from the
14+
-- top. A failed concurrent build leaves an INVALID index that IF NOT EXISTS would keep: the replay
15+
-- moves only that one aside to be dropped, so a valid index built by an earlier attempt is kept.
16+
DO $$
17+
BEGIN
18+
IF EXISTS (
19+
SELECT 1 FROM pg_index WHERE indexrelid = to_regclass('workspace_files_workspace_active_uploaded_idx_failed')
20+
AND (indisvalid OR indrelid <> to_regclass('workspace_files'))
21+
) THEN
22+
RAISE EXCEPTION 'Refusing to drop unexpected recovery index workspace_files_workspace_active_uploaded_idx_failed';
23+
END IF;
24+
IF EXISTS (
25+
SELECT 1 FROM pg_index WHERE indexrelid = to_regclass('workspace_files_workspace_active_uploaded_idx')
26+
AND indrelid = to_regclass('workspace_files') AND NOT indisvalid
27+
) THEN
28+
ALTER INDEX "workspace_files_workspace_active_uploaded_idx" RENAME TO "workspace_files_workspace_active_uploaded_idx_failed";
29+
END IF;
30+
END $$;--> statement-breakpoint
31+
COMMIT;--> statement-breakpoint
32+
SET lock_timeout = 0;--> statement-breakpoint
33+
DROP INDEX CONCURRENTLY IF EXISTS "workspace_files_workspace_active_uploaded_idx_failed";--> statement-breakpoint
34+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "workspace_files_workspace_active_uploaded_idx" ON "workspace_files" USING btree ("workspace_id","uploaded_at","id") WHERE "workspace_files"."deleted_at" IS NULL AND "workspace_files"."context" = 'workspace' AND "workspace_files"."workspace_id" IS NOT NULL;--> statement-breakpoint
35+
SET lock_timeout = '5s';

0 commit comments

Comments
 (0)