Skip to content

Commit 60f7996

Browse files
authored
fix(logs): stop loading execution data and snapshots that reads do not use (#9349)
* fix(logs): stop loading execution data and snapshots that reads do not use - Status polls select execution_data through a CASE only for the statuses the poll reads it for (failed; completed when output is requested; always for selected outputs), from one shared status rule. - The execution snapshot view no longer loads execution_data while resolving its context; it is read only after authorization, so a refused caller never fetches it. - The paused execution list projects execution_snapshot->'triggerIds' instead of loading whole rows with the serialized executor snapshot. - One shared terminal-status predicate replaces the copies in the HITL manager and run-file reads. * test(logs): give the recording client a connection option so its options type-check * fix(logs): read a snapshot view's execution data with the timing and cost it pairs with
1 parent 42f3409 commit 60f7996

7 files changed

Lines changed: 404 additions & 67 deletions

File tree

‎apps/sim/lib/logs/application/read-execution-snapshot.test.ts‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ const workspaceContext = {
6666
billedAccountUserId: 'billing-owner-1',
6767
}
6868

69+
const completedRun = {
70+
executionData: null,
71+
endedAt: new Date('2026-08-05T12:00:01.000Z'),
72+
totalDurationMs: 1000,
73+
costTotal: '0.75',
74+
}
75+
6976
const workflowRecord = {
7077
id: 'log-1',
7178
workflowId: 'workflow-1',
@@ -77,17 +84,19 @@ const workflowRecord = {
7784
endedAt: new Date('2026-08-05T12:00:01.000Z'),
7885
totalDurationMs: 1000,
7986
costTotal: '0.75',
80-
executionData: null,
8187
}
8288

8389
/**
8490
* Answers `db.select(...)` calls in order. The snapshot read walks the workflow
85-
* log, then (only when that missed) the job log, then the state snapshot.
91+
* log, then (only when that missed) the job log, then the state snapshot, then
92+
* the run's execution data.
8693
*/
8794
function queueSelects(...results: unknown[][]): void {
8895
for (const rows of results) {
8996
mocks.select.mockReturnValueOnce({
90-
from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }),
97+
from: () => ({
98+
where: () => Object.assign(Promise.resolve(rows), { limit: () => Promise.resolve(rows) }),
99+
}),
91100
})
92101
}
93102
}
@@ -135,7 +144,11 @@ describe('readExecutionSnapshot spend projection', () => {
135144
...DEFAULT_PERMISSION_GROUP_CONFIG,
136145
hideCostInfo: true,
137146
})
138-
queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }])
147+
queueSelects(
148+
[workflowRecord],
149+
[{ id: 'snapshot-1', stateData: { blocks: {} } }],
150+
[completedRun]
151+
)
139152

140153
const result = await read(principal, 'run-1')
141154

@@ -153,11 +166,31 @@ describe('readExecutionSnapshot spend projection', () => {
153166
...DEFAULT_PERMISSION_GROUP_CONFIG,
154167
hideCostInfo: true,
155168
})
156-
queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }])
169+
queueSelects(
170+
[workflowRecord],
171+
[{ id: 'snapshot-1', stateData: { blocks: {} } }],
172+
[completedRun]
173+
)
157174

158175
const result = await read(executorPrincipal, 'run-1')
159176

160177
expect(result.executionMetadata.cost).toEqual({ total: 0.75 })
161178
expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled()
162179
})
180+
181+
it('reports the timing and cost read with the execution data, not the earlier context', async () => {
182+
queueSelects(
183+
[{ ...workflowRecord, endedAt: null, totalDurationMs: null, costTotal: null }],
184+
[{ id: 'snapshot-1', stateData: { blocks: {} } }],
185+
[completedRun]
186+
)
187+
188+
const result = await read(executorPrincipal, 'run-1')
189+
190+
expect(result.executionMetadata).toMatchObject({
191+
endedAt: '2026-08-05T12:00:01.000Z',
192+
totalDurationMs: 1000,
193+
cost: { total: 0.75 },
194+
})
195+
})
163196
})

‎apps/sim/lib/logs/application/read-execution-snapshot.ts‎

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
resolveActiveWorkspaceApplicationContext,
2424
} from '@/lib/workspaces/application/context'
2525

26-
const CHILD_SNAPSHOT_QUERY_CHUNK_SIZE = 1_000
26+
const SNAPSHOT_QUERY_CHUNK_SIZE = 1_000
2727

2828
interface WorkflowExecutionRecord {
2929
kind: 'workflow'
@@ -37,7 +37,6 @@ interface WorkflowExecutionRecord {
3737
endedAt: Date | null
3838
totalDurationMs: number | null
3939
costTotal: string | null
40-
executionData: unknown
4140
}
4241

4342
interface JobExecutionRecord {
@@ -79,7 +78,6 @@ async function resolveExecutionSnapshotContext(
7978
endedAt: workflowExecutionLogs.endedAt,
8079
totalDurationMs: workflowExecutionLogs.totalDurationMs,
8180
costTotal: workflowExecutionLogs.costTotal,
82-
executionData: workflowExecutionLogs.executionData,
8381
})
8482
.from(workflowExecutionLogs)
8583
.where(eq(workflowExecutionLogs.executionId, executionId))
@@ -118,6 +116,47 @@ async function resolveExecutionSnapshotContext(
118116
return { ...workspace, executionId, record: { kind: 'job', ...jobRecord } }
119117
}
120118

119+
async function readSnapshotStates(
120+
ids: string[],
121+
signal: AbortSignal | undefined
122+
): Promise<Map<string, unknown>> {
123+
const states = new Map<string, unknown>()
124+
for (let index = 0; index < ids.length; index += SNAPSHOT_QUERY_CHUNK_SIZE) {
125+
signal?.throwIfAborted()
126+
const rows = await db
127+
.select({
128+
id: workflowExecutionSnapshots.id,
129+
stateData: workflowExecutionSnapshots.stateData,
130+
})
131+
.from(workflowExecutionSnapshots)
132+
.where(
133+
inArray(workflowExecutionSnapshots.id, ids.slice(index, index + SNAPSHOT_QUERY_CHUNK_SIZE))
134+
)
135+
for (const row of rows) states.set(row.id, row.stateData)
136+
}
137+
return states
138+
}
139+
140+
/**
141+
* The run's execution data with the fields a completion write changes alongside it,
142+
* read in one statement after authorization: a caller the use case refuses never
143+
* fetches the payload, and a run finishing mid-request cannot pair new data with
144+
* stale timing or cost.
145+
*/
146+
async function readRunCompletion(executionId: string) {
147+
const [row] = await db
148+
.select({
149+
executionData: workflowExecutionLogs.executionData,
150+
endedAt: workflowExecutionLogs.endedAt,
151+
totalDurationMs: workflowExecutionLogs.totalDurationMs,
152+
costTotal: workflowExecutionLogs.costTotal,
153+
})
154+
.from(workflowExecutionLogs)
155+
.where(eq(workflowExecutionLogs.executionId, executionId))
156+
.limit(1)
157+
return row
158+
}
159+
121160
function collectChildSnapshotIds(traceSpans: TraceSpan[]): string[] {
122161
const ids = new Set<string>()
123162
const pending = [...traceSpans]
@@ -175,17 +214,18 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase(
175214
}
176215
}
177216

178-
const [snapshot] = await db
179-
.select()
180-
.from(workflowExecutionSnapshots)
181-
.where(eq(workflowExecutionSnapshots.id, record.stateSnapshotId))
182-
.limit(1)
183-
if (!snapshot) {
217+
const workflowState = (await readSnapshotStates([record.stateSnapshotId], input.signal)).get(
218+
record.stateSnapshotId
219+
)
220+
if (workflowState === undefined) {
184221
throw new OrchestrationError('not_found', 'Workflow state snapshot not found')
185222
}
186223

224+
input.signal?.throwIfAborted()
225+
const completion = await readRunCompletion(record.executionId)
226+
if (!completion) throw new OrchestrationError('not_found', 'Workflow execution not found')
187227
const executionData = (await materializeExecutionData(
188-
record.executionData as Record<string, unknown> | null,
228+
completion.executionData as Record<string, unknown> | null,
189229
{
190230
workspaceId: context.workspaceId,
191231
workflowId: record.workflowId,
@@ -201,40 +241,23 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase(
201241
})
202242
}
203243

204-
const childSnapshotIds = collectChildSnapshotIds(traceSpans)
205-
const childWorkflowSnapshots: Array<{ id: string; stateData: unknown }> = []
206-
for (let index = 0; index < childSnapshotIds.length; index += CHILD_SNAPSHOT_QUERY_CHUNK_SIZE) {
207-
input.signal?.throwIfAborted()
208-
childWorkflowSnapshots.push(
209-
...(await db
210-
.select({
211-
id: workflowExecutionSnapshots.id,
212-
stateData: workflowExecutionSnapshots.stateData,
213-
})
214-
.from(workflowExecutionSnapshots)
215-
.where(
216-
inArray(
217-
workflowExecutionSnapshots.id,
218-
childSnapshotIds.slice(index, index + CHILD_SNAPSHOT_QUERY_CHUNK_SIZE)
219-
)
220-
))
221-
)
222-
}
244+
const childWorkflowSnapshots = await readSnapshotStates(
245+
collectChildSnapshotIds(traceSpans),
246+
input.signal
247+
)
223248

224249
input.signal?.throwIfAborted()
225250
return {
226251
executionId: record.executionId,
227252
workflowId: record.workflowId,
228-
workflowState: snapshot.stateData as Record<string, unknown>,
229-
childWorkflowSnapshots: Object.fromEntries(
230-
childWorkflowSnapshots.map((child) => [child.id, child.stateData])
231-
),
253+
workflowState: workflowState as Record<string, unknown>,
254+
childWorkflowSnapshots: Object.fromEntries(childWorkflowSnapshots),
232255
executionMetadata: {
233256
trigger: record.trigger,
234257
startedAt: record.startedAt.toISOString(),
235-
endedAt: record.endedAt?.toISOString(),
236-
totalDurationMs: record.totalDurationMs,
237-
cost: projectCostTotal(record.costTotal, projection),
258+
endedAt: completion.endedAt?.toISOString(),
259+
totalDurationMs: completion.totalDurationMs,
260+
cost: projectCostTotal(completion.costTotal, projection),
238261
},
239262
}
240263
},

‎apps/sim/lib/logs/types.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,21 @@ export function isPersistedWorkflowExecutionStatus(
225225
): value is PersistedWorkflowExecutionStatus {
226226
return (PERSISTED_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(value)
227227
}
228+
229+
/**
230+
* Statuses of a finished run's log. A resume can never claim a log in one of them (the
231+
* same set the resume attempt-failure `CASE` preserves).
232+
*/
233+
const TERMINAL_WORKFLOW_EXECUTION_STATUSES = [
234+
'cancelled',
235+
'failed',
236+
'completed',
237+
] as const satisfies readonly PersistedWorkflowExecutionStatus[]
238+
239+
export function isTerminalWorkflowExecutionStatus(status: string): boolean {
240+
return (TERMINAL_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(status)
241+
}
242+
228243
/**
229244
* In-flight statuses a crashed worker can strand, which the stale-execution
230245
* cron terminalizes. `pending` and `paused` are excluded: both are written as

0 commit comments

Comments
 (0)