Skip to content

Commit 70724d0

Browse files
authored
perf(db): index a workspace's live files in upload order (#9434)
Adds workspace_files_workspace_active_uploaded_idx (workspace_id, uploaded_at, id) on the shared live workspace-file predicate, built concurrently with replay-safe recovery, so the file list and its bounded SSR seed read a workspace's rows in upload order instead of matching every workspace's files and sorting. listWorkspaceFiles orders by (uploaded_at, id) for a stable order. Adds @sim/db/testing/explain for plan assertions on the SQL a call actually sends.
1 parent 31f7529 commit 70724d0

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) paid for
4+
-- 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)