Skip to content

Commit d2755e9

Browse files
committed
improvement(perf): restore the parallel file read, and close the gaps a diff audit surfaced
1 parent 140b928 commit d2755e9

12 files changed

Lines changed: 257 additions & 21 deletions

File tree

apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ describe('workspace list prefetches', () => {
527527

528528
expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
529529
maxRows: WORKSPACE_FILE_SEED_MAX,
530+
/** A failed read must reach the catch, not degrade to a cached empty list. */
531+
throwOnError: true,
530532
})
531533
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
532534
})

apps/sim/app/workspace/[workspaceId]/prefetch.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ async function seedWorkspaceFiles(queryClient: QueryClient, workspaceId: string)
130130
try {
131131
const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
132132
maxRows: WORKSPACE_FILE_SEED_MAX,
133+
/**
134+
* A failed read must reach the catch below, not degrade to an empty list: seeding
135+
* `[]` would cache "this workspace has no files" as authoritative for the entry's
136+
* lifetime, which is worse than seeding nothing and letting the client fetch.
137+
*/
138+
throwOnError: true,
133139
})
134140
if (!files) return
135141
queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,6 @@ vi.mock('@/executor/utils/start-block', () => ({
191191
coerceValue: (_type: string, value: unknown) => value,
192192
}))
193193

194-
vi.mock('@/hooks/queries/subscription', () => ({
195-
subscriptionKeys: { users: () => ['subscription', 'users'] },
196-
}))
197-
198194
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
199195
getWorkflows: () => [],
200196
}))

apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,37 @@ describe('EvaluatorBlockHandler', () => {
145145
expect(handler.canHandle(nonEvalBlock)).toBe(false)
146146
})
147147

148+
/**
149+
* The admission checks the removed `/api/providers` hop owned. Mirrors the router's
150+
* coverage — both handlers reach the provider through the same shared entry point.
151+
*/
152+
const admissionInputs = {
153+
content: 'Evaluate this.',
154+
metrics: [{ name: 'score1', description: 'First score', range: { min: 0, max: 10 } }],
155+
model: 'gpt-4o',
156+
apiKey: 'test-api-key',
157+
}
158+
159+
it('refuses to reach the provider without an execution subject', async () => {
160+
mockContext.userId = undefined
161+
162+
await expect(handler.execute(mockContext, mockBlock, admissionInputs)).rejects.toThrow(
163+
'Unauthorized'
164+
)
165+
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
166+
})
167+
168+
it('refuses to reach the provider when the subject lost workspace access', async () => {
169+
mockContext.workspaceId = 'test-workspace'
170+
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false })
171+
172+
await expect(handler.execute(mockContext, mockBlock, admissionInputs)).rejects.toThrow(
173+
'Forbidden'
174+
)
175+
expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('test-workspace', 'test-user')
176+
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
177+
})
178+
148179
it('should execute evaluator block correctly with basic inputs', async () => {
149180
const inputs = {
150181
content: 'This is the content to evaluate.',

apps/sim/executor/utils/provider-request.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,32 @@ export async function executeBlockProviderRequest({
6060
* model-emitted tool calls, and the route this replaces never carried one.
6161
* Router and evaluator requests declare no tools, so passing the executor's
6262
* context here would widen the trusted surface without changing any outcome.
63+
*
64+
* The whole runtime context is omitted when there is no registry, rather than
65+
* passed carrying `undefined`. `executeProviderTool` reads a present context with
66+
* an absent registry as "provenance was expected and is missing" and fails the
67+
* call closed with no error text — unreachable while these blocks declare no
68+
* tools, but a silent failure the day one does.
6369
*/
6470
const response = await executeProviderRequest(
6571
providerId,
6672
{ ...request, userId: ctx.userId },
67-
{ resolvedSecretTraceRegistry }
73+
resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry } : undefined
6874
)
6975

70-
if (response instanceof ReadableStream || (response !== null && 'stream' in response)) {
76+
if (
77+
response instanceof ReadableStream ||
78+
(typeof response === 'object' && response !== null && 'stream' in response)
79+
) {
7180
logger.error('Provider returned a stream for a non-streaming block request', { providerId })
7281
throw new Error('Provider returned a streaming response for a non-streaming request')
7382
}
7483

84+
logger.info('Provider request completed', {
85+
providerId,
86+
model: request.model,
87+
workflowId: ctx.workflowId,
88+
})
89+
7590
return response
7691
}

apps/sim/hooks/queries/schedules.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ export function useRedeployWorkflowSchedule() {
249249
const { workflowId, blockId } = data
250250
await Promise.all([
251251
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
252+
/**
253+
* A redeploy recreates the schedule, so the id-keyed reads go stale too. They are
254+
* a separate subtree from `schedule(workflowId, blockId)`, which does not cover them.
255+
*/
256+
queryClient.invalidateQueries({ queryKey: scheduleKeys.byIds() }),
252257
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }),
253258
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }),
254259
])

apps/sim/hooks/queries/tables.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,33 @@ describe('useDeleteColumn optimistic update', () => {
129129
expect(ctx?.rowSnapshots?.length).toBeGreaterThan(0)
130130
})
131131

132+
/**
133+
* The `find` cache hangs off the same `rowsRoot` parent as the paged rows but holds
134+
* `{matches, truncated}` — no `pages`, no `rows`. A cache walk starting at the shared
135+
* parent reaches it and throws inside `onMutate`, rejecting the mutation before it ever
136+
* reaches the server: search a table, dismiss the search, then edit a cell.
137+
*/
138+
it('survives a cached search result hanging off the shared rows prefix', async () => {
139+
setCache(tableKeys.detail(TABLE_ID), {
140+
id: TABLE_ID,
141+
schema: { columns: [{ name: 'age', type: 'number' }] },
142+
})
143+
setCache(ROWS_KEY, {
144+
rows: [{ id: 'r1', data: { age: 1 } }],
145+
totalCount: 1,
146+
})
147+
setCache(tableKeys.find(TABLE_ID, 'q'), { matches: [{ rowId: 'r1', column: 'age' }] })
148+
149+
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
150+
151+
await expect(hook.onMutate?.('age')).resolves.toBeDefined()
152+
153+
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(ROWS_KEY)
154+
expect(rows?.rows[0]?.data).toEqual({})
155+
/** The find entry is match coordinates, not row values — it must be left untouched. */
156+
expect(getCache<{ matches: unknown[] }>(tableKeys.find(TABLE_ID, 'q'))?.matches).toHaveLength(1)
157+
})
158+
132159
it('rolls back schema and rows on error using snapshots', async () => {
133160
const originalDetail = {
134161
id: TABLE_ID,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { parseWorkflowStateForPersistence } from '@/lib/workflows/persistence/save-normalized-state'
6+
7+
/**
8+
* A checkpoint blob as the revert route builds it: JSONB-derived blocks and edges, plus a
9+
* real `Date` for `deployedAt`.
10+
*/
11+
function checkpointState(overrides?: Record<string, unknown>) {
12+
return {
13+
blocks: {
14+
'block-1': {
15+
id: 'block-1',
16+
type: 'starter',
17+
name: 'Start',
18+
position: { x: 0, y: 0 },
19+
subBlocks: {},
20+
outputs: {},
21+
enabled: true,
22+
},
23+
},
24+
edges: [],
25+
loops: {},
26+
parallels: {},
27+
isDeployed: false,
28+
lastSaved: 1_754_000_000_000,
29+
...overrides,
30+
}
31+
}
32+
33+
describe('parseWorkflowStateForPersistence', () => {
34+
/**
35+
* The revert used to reach this schema by POSTing the blob over HTTP, so every value
36+
* arrived JSON-serialized. In-process the blob keeps its runtime types. Both forms must
37+
* parse identically, or a checkpoint that reverted before would start failing.
38+
*/
39+
it('accepts a Date for deployedAt exactly as it accepted the serialized string', () => {
40+
const deployedAt = new Date('2026-01-02T03:04:05.678Z')
41+
42+
const fromDate = parseWorkflowStateForPersistence(checkpointState({ deployedAt }))
43+
const overTheWire = parseWorkflowStateForPersistence(
44+
JSON.parse(JSON.stringify(checkpointState({ deployedAt })))
45+
)
46+
47+
expect(fromDate.success).toBe(true)
48+
expect(overTheWire.success).toBe(true)
49+
expect(fromDate.data?.deployedAt).toEqual(deployedAt)
50+
expect(overTheWire.data?.deployedAt).toEqual(fromDate.data?.deployedAt)
51+
})
52+
53+
it('round-trips a JSONB-shaped blob without dropping blocks or edges', () => {
54+
const state = checkpointState()
55+
56+
const parsed = parseWorkflowStateForPersistence(state)
57+
58+
expect(parsed.success).toBe(true)
59+
expect(Object.keys(parsed.data?.blocks ?? {})).toEqual(['block-1'])
60+
expect(parsed.data?.lastSaved).toBe(1_754_000_000_000)
61+
})
62+
63+
it('accepts a null deployedAt, which the revert passes for a never-deployed checkpoint', () => {
64+
const parsed = parseWorkflowStateForPersistence(checkpointState({ deployedAt: null }))
65+
66+
expect(parsed.success).toBe(true)
67+
expect(parsed.data?.deployedAt).toBeNull()
68+
})
69+
70+
/** The validation the removed HTTP hop used to provide: a malformed blob must not be written. */
71+
it('rejects a blob whose blocks are malformed', () => {
72+
const parsed = parseWorkflowStateForPersistence({
73+
blocks: { 'block-1': { id: 'block-1' } },
74+
edges: [],
75+
})
76+
77+
expect(parsed.success).toBe(false)
78+
})
79+
80+
it('rejects a blob missing blocks entirely', () => {
81+
expect(parseWorkflowStateForPersistence({ edges: [] }).success).toBe(false)
82+
})
83+
})

apps/sim/lib/workspace-files/queries.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,16 @@ describe('listWorkspaceFilesWithShares', () => {
5656
/**
5757
* `maxRows` exists for a caller that will only use the list if the whole workspace fits
5858
* its payload budget, so overflow must be reported as `null` — a prefix returned here
59-
* would be presented as the workspace's complete file list.
59+
* would be presented as the workspace's complete file list. The share read still runs
60+
* concurrently and is discarded: the under-budget workspaces are the common case, and
61+
* serializing the two reads to save this one would tax every normal request.
6062
*/
61-
it('returns null without joining shares when the workspace exceeds maxRows', async () => {
63+
it('returns null when the workspace exceeds maxRows', async () => {
6264
mockListWorkspaceFiles.mockResolvedValue([STORED_FILE, STORED_FILE, STORED_FILE])
6365

6466
const result = await listWorkspaceFilesWithShares('ws-1', 'active', { maxRows: 2 })
6567

6668
expect(result).toBeNull()
67-
expect(mockGetWorkspaceShares).not.toHaveBeenCalled()
6869
expect(mockListWorkspaceFiles).toHaveBeenCalledWith('ws-1', { scope: 'active', limit: 3 })
6970
})
7071

@@ -77,6 +78,37 @@ describe('listWorkspaceFilesWithShares', () => {
7778
expect(mockGetWorkspaceShares).toHaveBeenCalledWith('file', 'ws-1')
7879
})
7980

81+
/** The boundary the `>` comparison turns on: exactly maxRows must still be the list. */
82+
it('returns the list when it sits exactly on maxRows', async () => {
83+
mockListWorkspaceFiles.mockResolvedValue([STORED_FILE, STORED_FILE])
84+
85+
const result = await listWorkspaceFilesWithShares('ws-1', 'active', { maxRows: 2 })
86+
87+
expect(result).toHaveLength(2)
88+
})
89+
90+
/**
91+
* The file read swallows errors and returns `[]` by default. A caller seeding a cache
92+
* must not receive that: an empty list would be cached as "this workspace has no files".
93+
*/
94+
it('propagates a failed read instead of degrading to an empty list', async () => {
95+
await listWorkspaceFilesWithShares('ws-1', 'active', { throwOnError: true })
96+
97+
expect(mockListWorkspaceFiles).toHaveBeenCalledWith(
98+
'ws-1',
99+
expect.objectContaining({ throwOnError: true })
100+
)
101+
})
102+
103+
it('does not ask the file read to throw unless the caller opts in', async () => {
104+
await listWorkspaceFilesWithShares('ws-1', 'active')
105+
106+
expect(mockListWorkspaceFiles).toHaveBeenCalledWith(
107+
'ws-1',
108+
expect.not.objectContaining({ throwOnError: true })
109+
)
110+
})
111+
80112
it('joins each file public share onto its row', async () => {
81113
const share = {
82114
id: 'share-1',

apps/sim/lib/workspace-files/queries.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,33 @@ import {
1616
*
1717
* Callers authorize the viewer against `workspaceId` first.
1818
*
19-
* `maxRows` bounds the work for a caller that will only use the list if the whole
19+
* `maxRows` bounds the result for a caller that will only use the list if the whole
2020
* workspace fits a payload budget: the read stops one row past the budget and returns
21-
* `null` on overflow, before the share join and the contract parse — so the workspaces
22-
* the budget exists to protect are the ones that pay least to be rejected. Returning
23-
* `null` rather than the prefix is what stops a caller presenting a truncated read as
24-
* the workspace's files.
21+
* `null` on overflow rather than the prefix, which is what stops a caller presenting a
22+
* truncated read as the workspace's files. The two reads still run concurrently — the
23+
* workspaces under the budget are the common case, and serializing them to save a share
24+
* read on the rare oversized one would tax every normal request to do it.
25+
*
26+
* `throwOnError` propagates a failed file read instead of letting it degrade to an empty
27+
* list. A caller seeding a cache needs that distinction: an empty list would be cached
28+
* as authoritative, telling the user the workspace has no files.
2529
*/
2630
export async function listWorkspaceFilesWithShares(
2731
workspaceId: string,
2832
scope: WorkspaceFileScope,
29-
options?: { maxRows?: number }
33+
options?: { maxRows?: number; throwOnError?: boolean }
3034
) {
3135
const maxRows = options?.maxRows
32-
const files = await listWorkspaceFiles(workspaceId, {
33-
scope,
34-
...(maxRows === undefined ? {} : { limit: maxRows + 1 }),
35-
})
36+
const [files, shares] = await Promise.all([
37+
listWorkspaceFiles(workspaceId, {
38+
scope,
39+
...(maxRows === undefined ? {} : { limit: maxRows + 1 }),
40+
...(options?.throwOnError ? { throwOnError: true } : {}),
41+
}),
42+
getWorkspaceShares('file', workspaceId),
43+
])
3644
if (maxRows !== undefined && files.length > maxRows) return null
3745

38-
const shares = await getWorkspaceShares('file', workspaceId)
3946
const withShares = files.map((file) => ({ ...file, share: shares.get(file.id) ?? null }))
4047
return listWorkspaceFilesContract.response.schema.shape.files.parse(withShares)
4148
}

0 commit comments

Comments
 (0)