Skip to content

Commit bd69492

Browse files
committed
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 447f525 commit bd69492

8 files changed

Lines changed: 11 additions & 119 deletions

File tree

‎apps/sim/app/api/v1/tables/[tableId]/rows/route.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
185185
offset: validated.offset,
186186
includeTotal: validated.includeTotal,
187187
withExecutions: false,
188-
// `table` was loaded a few lines above, for this read.
189-
trustLoadedJob: true,
190188
},
191189
requestId
192190
)

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

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -670,66 +670,17 @@ describe('queryRows byte budget', () => {
670670
})
671671

672672
/**
673-
* Two reads the row path used to make unconditionally, each one round trip on the grid's hot
674-
* page. Both are now answered from state the caller already holds; these pin that the query is
675-
* actually skipped rather than merely ignored, and that the fallbacks still run.
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.
676676
*/
677-
describe('queryRows round-trip elision', () => {
677+
describe('queryRows run-state elision', () => {
678678
beforeEach(() => {
679679
vi.clearAllMocks()
680680
resetDbChainMock()
681681
vi.mocked(tableMayHaveRunState).mockReturnValue(true)
682682
})
683683

684-
const hydrated = (
685-
fields: Partial<Pick<TableDefinition, 'jobStatus' | 'jobType'>>
686-
): TableDefinition => ({ ...TABLE, jobStatus: null, jobType: null, ...fields })
687-
688-
it('skips the delete-job probe when the hydrated table shows no running delete', async () => {
689-
await queryRows(
690-
hydrated({}),
691-
{ limit: 5, includeTotal: false, withExecutions: false, trustLoadedJob: true },
692-
'req-1'
693-
)
694-
695-
// With no probe, the drain batch is the FIRST bounded query rather than the second.
696-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 6)
697-
})
698-
699-
it('still probes when the hydrated table shows a running delete job', async () => {
700-
await queryRows(
701-
hydrated({ jobStatus: 'running', jobType: 'delete' }),
702-
{ limit: 5, includeTotal: false, withExecutions: false, trustLoadedJob: true },
703-
'req-1'
704-
)
705-
706-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 1)
707-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(2, 6)
708-
})
709-
710-
/** An unhydrated definition cannot rule the job out, so it keeps the lookup it always had. */
711-
it('still probes when the table carries no job fields at all', async () => {
712-
await queryRows(
713-
TABLE,
714-
{ limit: 5, includeTotal: false, withExecutions: false, trustLoadedJob: true },
715-
'req-1'
716-
)
717-
718-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 1)
719-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(2, 6)
720-
})
721-
722-
/**
723-
* A caller that holds its table across a long walk (the export stream) must keep re-asking, so
724-
* a delete job starting mid-walk still begins masking its doomed rows.
725-
*/
726-
it('still probes for a caller that did not vouch for its table', async () => {
727-
await queryRows(hydrated({}), { limit: 5, includeTotal: false, withExecutions: false }, 'req-1')
728-
729-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 1)
730-
expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(2, 6)
731-
})
732-
733684
it('skips the run-state read for a table that can hold none, still reporting empty executions', async () => {
734685
vi.mocked(tableMayHaveRunState).mockReturnValue(false)
735686
dbChainMockFns.limit.mockResolvedValueOnce([])

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,6 @@ describe('row query and upsert application semantics', () => {
769769
includeTotal: false,
770770
withExecutions: false,
771771
runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES,
772-
trustLoadedJob: true,
773772
},
774773
expect.any(String)
775774
)

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,6 @@ export const listTableRows = defineAuthorizedTableUseCase({
436436
includeTotal: false,
437437
withExecutions: input.includeRunState ?? false,
438438
runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES,
439-
// `context.table` was loaded by this use case's own resolver, for this read.
440-
trustLoadedJob: true,
441439
},
442440
requestId(input)
443441
)
@@ -575,8 +573,6 @@ export const queryTableRows = defineAuthorizedTableUseCase({
575573
withExecutions: input.includeRunState ?? false,
576574
runStateBudgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES,
577575
columnIds,
578-
// `context.table` was loaded by this use case's own resolver, for this read.
579-
trustLoadedJob: true,
580576
},
581577
requestId(input),
582578
readProvenance

‎apps/sim/lib/table/rows/pending-delete-mask.ts‎

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +9,6 @@ import type { TableDefinition, TableDeleteJobPayload } from '@/lib/table/types'
99

1010
const logger = createLogger('TablePendingDeleteMask')
1111

12-
/**
13-
* Whether {@link PendingDeleteMaskOptions.trustLoadedJob} can rule a running delete job out.
14-
*
15-
* Every table loaded through `getTableById` / `listTables` already carries its latest non-export
16-
* job, folded into that same SELECT as a lateral, so a caller that loaded its table for this very
17-
* read already holds the answer and the lookup is pure overhead on the hot path.
18-
*
19-
* The derivation is exact, not a heuristic. `table_jobs_one_active_per_table` is unique on
20-
* `table_id WHERE status = 'running' AND type <> 'export'` — the same predicate the lateral
21-
* filters on — so a running delete job is the ONLY running non-export job on its table, and no
22-
* further non-export job can be inserted while it holds that slot. It is therefore the newest
23-
* non-export job by `started_at`, which is exactly the row the lateral returns.
24-
*
25-
* `jobStatus === undefined` means the fields were never hydrated (a `TableDefinition` assembled
26-
* by some other path), which is indistinguishable from "no job" in the shape alone — so that case
27-
* falls back to the query rather than assuming. A hydrated table with no job has
28-
* `jobStatus: null`.
29-
*/
30-
function hydratedJobRulesOutDelete(table: TableDefinition): boolean {
31-
if (table.jobStatus === undefined) return false
32-
return !(table.jobStatus === 'running' && table.jobType === 'delete')
33-
}
34-
35-
export interface PendingDeleteMaskOptions {
36-
/**
37-
* Answer from `table`'s own latest-job fields when they rule a running delete out, instead of
38-
* querying for one.
39-
*
40-
* Only for a caller whose `table` was loaded for this read: the fields are then as fresh as the
41-
* query would have been. A caller that loads a table once and then pages for a while — the
42-
* export runner, the snapshot builder — must NOT set this, because a delete job starting
43-
* mid-walk would never appear in its snapshot and its later pages would stop masking doomed
44-
* rows. Those callers keep re-asking per page, which is what makes the mask appear mid-walk.
45-
*/
46-
trustLoadedJob?: boolean
47-
}
48-
4912
/**
5013
* Visibility mask for a running delete job: returns a clause keeping only rows the job will NOT
5114
* delete, or `undefined` when no delete job is running. The job's persisted scope
@@ -57,11 +20,7 @@ export interface PendingDeleteMaskOptions {
5720
* `(doomed) IS NOT TRUE` rather than `NOT (doomed)`: JSONB predicates evaluate to NULL on missing
5821
* cells, and those rows are NOT selected for deletion (NULL ≠ TRUE) — they must stay visible.
5922
*/
60-
export async function pendingDeleteMask(
61-
table: TableDefinition,
62-
options?: PendingDeleteMaskOptions
63-
): Promise<SQL | undefined> {
64-
if (options?.trustLoadedJob && hydratedJobRulesOutDelete(table)) return undefined
23+
export async function pendingDeleteMask(table: TableDefinition): Promise<SQL | undefined> {
6524
const [job] = await db
6625
.select({ payload: tableJobs.payload })
6726
.from(tableJobs)

‎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

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,9 +1042,7 @@ export async function findRowMatches(
10421042
if (columnIds.length === 0) return { matches: [], truncated: false }
10431043

10441044
// Same visibility rule as queryRows: don't surface rows a running delete job will remove.
1045-
// Search is always one shot against a table its caller just loaded, so the table's own
1046-
// latest-job fields are as fresh as a lookup would be — see `PendingDeleteMaskOptions`.
1047-
const deleteMask = await pendingDeleteMask(table, { trustLoadedJob: true })
1045+
const deleteMask = await pendingDeleteMask(table)
10481046

10491047
const baseConditions = and(
10501048
eq(userTableRows.tableId, table.id),
@@ -1164,15 +1162,14 @@ export async function queryRows(
11641162
withExecutions = true,
11651163
runStateBudgetBytes,
11661164
columnIds,
1167-
trustLoadedJob = false,
11681165
} = options
11691166

11701167
const tableName = USER_TABLE_ROWS_SQL_NAME
11711168
const columns = table.schema.columns
11721169

11731170
// Hide rows a running delete job is about to remove — both the page and the count below share
11741171
// this clause, so totals stay consistent with the visible rows.
1175-
const deleteMask = await pendingDeleteMask(table, { trustLoadedJob })
1172+
const deleteMask = await pendingDeleteMask(table)
11761173

11771174
const baseConditions = and(
11781175
eq(userTableRows.tableId, table.id),

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -626,16 +626,6 @@ export interface QueryOptions {
626626
* Mutually exclusive with `sort`. May be combined with `offset` (a compound
627627
* cursor seeks the anchor, then offsets past unkeyed rows consumed after it). */
628628
after?: TableRowsCursor
629-
/**
630-
* Answer "is a delete job running?" from `table`'s own latest-job fields rather than querying
631-
* for one, saving a round trip on every read.
632-
*
633-
* Set it only when `table` was loaded for this very read — a request handler that resolved its
634-
* table and is about to return. A caller that loads a table once and then pages for a while
635-
* (the export stream) must leave it off, so a delete job starting mid-walk still begins masking
636-
* its doomed rows partway through.
637-
*/
638-
trustLoadedJob?: boolean
639629
/**
640630
* When true (default), runs a `COUNT(*)` and returns `totalCount` as a number.
641631
* Pass `false` to skip the count query (grid UI doesn't need it); `totalCount`

0 commit comments

Comments
 (0)