Skip to content

Commit 2cecb21

Browse files
authored
improvement(tables): cut the DB round trips a table read and write spend on protocol (#8104)
* improvement(tables): cut the DB round trips a table read and write spend on protocol The grid's first page spent more time on round trips than on work. Four of them were avoidable: - `pendingDeleteMask` probed `table_jobs` on every read, though the table a request just loaded already carries its latest non-export job, and the `one_active_per_table` unique index makes that row the running delete when one exists. Callers that hold a table across a long walk (the export stream, the snapshot builder) keep probing per page, so a delete starting mid-walk still begins masking. - The run-state sidecar was read for every table, including the ones that declare no workflow group and therefore cannot have a row — four chunked queries on a 1000-row page, all returning nothing. - The drain opened a transaction per batch. The guards are fixed for the call, so each extra batch paid `BEGIN` + `set_config` + `COMMIT` for nothing. - `setTableTxTimeouts` issued three `SET LOCAL` statements; `set_config(…, true)` is the same thing and fits in one round trip, as the read guards already do. A 1000-row page goes from 22 statements to 14, a 50-row page from 15 to 13, and every write transaction drops two. * review(tables): drop the delete-mask elision and correct the provenance snapshot doc Reading the delete job from the table a request already loaded widened a race the mask probe has always had — a job committing between the check and the row read is missed either way, but trusting the loaded fields moves the check two queries earlier. Closing it properly means evaluating the job inside the row read's own snapshot, which is a larger change than this one, so the elision is removed and `pending-delete-mask.ts` is back to what it was. The three remaining reductions are untouched: they were the bulk of the win, and each is a read this code cannot need rather than a read it takes on faith. Also updates `TableRowProvenanceReader`'s doc, which still described one repeatable-read transaction per batch.
1 parent acc6cbd commit 2cecb21

7 files changed

Lines changed: 252 additions & 90 deletions

File tree

‎apps/sim/lib/table/__tests__/service-filter-threading.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock('@/lib/table/rows/executions', () => ({
5050
})),
5151
loadExecutionsByRow: mockLoadExecutionsByRow,
5252
loadExecutionsForRow: vi.fn(async () => ({})),
53+
tableMayHaveRunState: vi.fn(() => true),
5354
writeExecutionsPatch: vi.fn(async () => 'wrote'),
5455
}))
5556

@@ -65,6 +66,7 @@ vi.mock('@/lib/table/validation', () => ({
6566
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
6667
}))
6768

69+
import { tableMayHaveRunState } from '@/lib/table/rows/executions'
6870
import {
6971
deleteRow,
7072
deleteRowsByFilter,
@@ -666,3 +668,40 @@ describe('queryRows byte budget', () => {
666668
})
667669
})
668670
})
671+
672+
/**
673+
* The run-state sidecar read the row path used to make unconditionally, one round trip (four on a
674+
* full page) for tables that cannot hold a single sidecar row. This pins that the query is
675+
* actually skipped rather than merely ignored, and that the fallback still runs.
676+
*/
677+
describe('queryRows run-state elision', () => {
678+
beforeEach(() => {
679+
vi.clearAllMocks()
680+
resetDbChainMock()
681+
vi.mocked(tableMayHaveRunState).mockReturnValue(true)
682+
})
683+
684+
it('skips the run-state read for a table that can hold none, still reporting empty executions', async () => {
685+
vi.mocked(tableMayHaveRunState).mockReturnValue(false)
686+
dbChainMockFns.limit.mockResolvedValueOnce([])
687+
dbChainMockFns.limit.mockResolvedValueOnce([
688+
{ id: 'row-1', data: {}, position: 0, orderKey: 'a0', createdAt: null, updatedAt: null },
689+
])
690+
691+
const result = await queryRows(TABLE, { limit: 5, includeTotal: false }, 'req-1')
692+
693+
expect(mockLoadExecutionsByRow).not.toHaveBeenCalled()
694+
expect(result.rows[0].executions).toEqual({})
695+
})
696+
697+
it('reads run state for a table that can hold it', async () => {
698+
dbChainMockFns.limit.mockResolvedValueOnce([])
699+
dbChainMockFns.limit.mockResolvedValueOnce([
700+
{ id: 'row-1', data: {}, position: 0, orderKey: 'a0', createdAt: null, updatedAt: null },
701+
])
702+
703+
await queryRows(TABLE, { limit: 5, includeTotal: false }, 'req-1')
704+
705+
expect(mockLoadExecutionsByRow).toHaveBeenCalledTimes(1)
706+
})
707+
})

‎apps/sim/lib/table/__tests__/update-row.test.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ function findExecutedRawSql(substring: string): string | undefined {
6868
return undefined
6969
}
7070

71+
/** Every string reachable from a drizzle `sql` fragment — its literal chunks AND its bound values. */
72+
function collectStrings(node: unknown, out: string[] = []): string[] {
73+
if (typeof node === 'string') out.push(node)
74+
else if (Array.isArray(node)) for (const entry of node) collectStrings(entry, out)
75+
else if (node && typeof node === 'object')
76+
for (const entry of Object.values(node as Record<string, unknown>)) collectStrings(entry, out)
77+
return out
78+
}
79+
80+
/**
81+
* Whether one `set_config(<setting>, '<value>', true)` guard was executed.
82+
*
83+
* The guards bind their values as parameters, so the setting name lives in the statement's
84+
* literal chunks while the duration lives in its bound values — asserting on a rendered string
85+
* would only re-check the placeholder.
86+
*/
87+
function executedTxTimeout(setting: string, value: string): boolean {
88+
return dbChainMockFns.execute.mock.calls.some(([arg]) => {
89+
const strings = collectStrings(arg)
90+
return (
91+
strings.some((entry) => entry.includes(`set_config('${setting}'`)) && strings.includes(value)
92+
)
93+
})
94+
}
95+
7196
/**
7297
* The `data` payload of the last `.set(...)` row write. `updateRow` always writes a JSONB merge
7398
* (`data = data || {changed}::jsonb`), so this is a `sql` fragment exposing `{ strings, values }`.
@@ -420,11 +445,9 @@ describe('mutation paths — SET LOCAL timeouts', () => {
420445
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
421446
).rejects.toBeDefined()
422447

423-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined()
424-
expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined()
425-
expect(
426-
findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'")
427-
).toBeDefined()
448+
expect(executedTxTimeout('statement_timeout', '10000ms')).toBe(true)
449+
expect(executedTxTimeout('lock_timeout', '3000ms')).toBe(true)
450+
expect(executedTxTimeout('idle_in_transaction_session_timeout', '5000ms')).toBe(true)
428451
})
429452

430453
it('batchInsertRows raises statement_timeout to 60s', async () => {
@@ -436,7 +459,7 @@ describe('mutation paths — SET LOCAL timeouts', () => {
436459
)
437460
).rejects.toBeDefined()
438461

439-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
462+
expect(executedTxTimeout('statement_timeout', '60000ms')).toBe(true)
440463
})
441464

442465
it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => {
@@ -450,7 +473,7 @@ describe('mutation paths — SET LOCAL timeouts', () => {
450473
)
451474

452475
// (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap
453-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined()
476+
expect(executedTxTimeout('statement_timeout', '450000ms')).toBe(true)
454477
})
455478

456479
it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => {
@@ -459,7 +482,7 @@ describe('mutation paths — SET LOCAL timeouts', () => {
459482
await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1')
460483

461484
// 10M × 3ms = 30M ms, capped at 600_000ms (10 min)
462-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
485+
expect(executedTxTimeout('statement_timeout', '600000ms')).toBe(true)
463486
})
464487

465488
it('replaceTableRows uses the 120s floor on small tables', async () => {
@@ -472,7 +495,7 @@ describe('mutation paths — SET LOCAL timeouts', () => {
472495
)
473496

474497
// 12 × 3ms = 36ms → floored at 120_000ms
475-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
498+
expect(executedTxTimeout('statement_timeout', '120000ms')).toBe(true)
476499
})
477500

478501
it('renameColumn is metadata-only — no per-row JSONB rewrite regardless of row count', async () => {
@@ -491,7 +514,7 @@ describe('mutation paths — SET LOCAL timeouts', () => {
491514
await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1')
492515

493516
// 100 × 2ms = 200ms → floored at 60_000ms
494-
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
517+
expect(executedTxTimeout('statement_timeout', '60000ms')).toBe(true)
495518
})
496519

497520
it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => {

‎apps/sim/lib/table/rows/executions.test.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import type { DbOrTx } from '@/lib/db/types'
7-
import { loadExecutionsByRow, writeExecutionsPatch } from '@/lib/table/rows/executions'
8-
import type { RowExecutionMetadata } from '@/lib/table/types'
7+
import {
8+
loadExecutionsByRow,
9+
tableMayHaveRunState,
10+
writeExecutionsPatch,
11+
} from '@/lib/table/rows/executions'
12+
import type { RowExecutionMetadata, TableSchema } from '@/lib/table/types'
913

1014
const EXECUTION_STATE: RowExecutionMetadata = {
1115
status: 'running',
@@ -258,3 +262,39 @@ describe('loadExecutionsByRow', () => {
258262
expect(byRow.size).toBe(750)
259263
})
260264
})
265+
266+
describe('tableMayHaveRunState', () => {
267+
const column = (overrides: Partial<TableSchema['columns'][number]> = {}) => ({
268+
id: 'col_1',
269+
name: 'title',
270+
type: 'string' as const,
271+
...overrides,
272+
})
273+
274+
it('is false for a schema that declares no group', () => {
275+
expect(tableMayHaveRunState({ columns: [column()] })).toBe(false)
276+
expect(tableMayHaveRunState({ columns: [column()], workflowGroups: [] })).toBe(false)
277+
})
278+
279+
it('is true once the schema declares a group', () => {
280+
expect(
281+
tableMayHaveRunState({
282+
columns: [column()],
283+
workflowGroups: [{ id: 'group-1' }] as TableSchema['workflowGroups'],
284+
})
285+
).toBe(true)
286+
})
287+
288+
/**
289+
* A column still pointing at a group is group state whatever the group list says, so an
290+
* unexpected schema shape must keep the sidecar read rather than silently drop run state.
291+
*/
292+
it('is true for a column that still names a group the list has lost', () => {
293+
expect(
294+
tableMayHaveRunState({
295+
columns: [column({ workflowGroupId: 'group-1' })],
296+
workflowGroups: [],
297+
})
298+
).toBe(true)
299+
})
300+
})

‎apps/sim/lib/table/rows/executions.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ interface LoadExecutionsOptions {
4040
budgetBytes?: number
4141
}
4242

43+
/**
44+
* Whether a table can have any run-state sidecar at all.
45+
*
46+
* `tableRowExecutions` is keyed by `(rowId, groupId)`, and every writer takes its `groupId` from
47+
* a group on the table's own schema. Group and column deletes strip the matching sidecar rows in
48+
* the same transaction that removes the group ({@link stripGroupExecutions}), so a schema that
49+
* declares no group cannot have a surviving row — the sidecar read would return nothing, and the
50+
* caller would fill in the same empty map it gets by skipping.
51+
*
52+
* Both signals are checked rather than just `workflowGroups`: a column still carrying a
53+
* `workflowGroupId` means the table has group state whatever the group list looks like, so an
54+
* unexpected schema shape keeps the query instead of silently dropping run state.
55+
*/
56+
export function tableMayHaveRunState(schema: TableSchema): boolean {
57+
if (schema.workflowGroups && schema.workflowGroups.length > 0) return true
58+
return schema.columns.some((column) => column.workflowGroupId !== undefined)
59+
}
60+
4361
/**
4462
* Loads `tableRowExecutions` rows for the given row ids and groups them into a
4563
* `Map<rowId, RowExecutions>` suitable for plugging into `TableRow.executions`.

‎apps/sim/lib/table/rows/secret-provenance.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -959,8 +959,10 @@ export async function loadTableRowSecretProvenance(
959959

960960
/**
961961
* Collects only returned row values while their database snapshot is still valid.
962-
* Readers use one repeatable-read transaction per bounded batch; writers capture
963-
* after stamping and before releasing row locks. Nothing is reloaded after commit.
962+
* A read captures inside one repeatable-read transaction spanning every batch of
963+
* its page, so a row and the sidecar captured for it always come from the same
964+
* snapshot; writers capture after stamping and before releasing row locks.
965+
* Nothing is reloaded after commit.
964966
*/
965967
export class TableRowProvenanceReader {
966968
private readonly accumulator: ResolvedSecretTraceProvenanceAccumulator

0 commit comments

Comments
 (0)