Skip to content

Commit b2b6e55

Browse files
authored
fix(tables): stop every table paginating forever on a null totalCount (#6694)
* fix(tables): stop every table paginating forever on a null totalCount * fix(tables): keep an emptied view terminated, and count masked reads off the seq scan
1 parent b9a70e4 commit b2b6e55

7 files changed

Lines changed: 175 additions & 22 deletions

File tree

apps/sim/hooks/queries/tables.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ type TableRowsParams = Omit<TableRowsQueryInput, 'filter' | 'sort'> &
140140

141141
export type TableRowsResponse = Pick<
142142
ContractJsonResponse<typeof listTableRowsContract>['data'],
143-
'rows' | 'totalCount'
143+
'rows' | 'totalCount' | 'nextCursor'
144144
>
145145

146146
interface RowMutationContext {
@@ -195,8 +195,13 @@ async function fetchTableRows({
195195
},
196196
signal,
197197
})
198-
const { rows, totalCount } = response.data
199-
return { rows, totalCount }
198+
const { rows, totalCount, nextCursor } = response.data
199+
/**
200+
* `nextCursor` is kept because it is the only authoritative end-of-table signal: the server
201+
* sets it exactly when the drain proved an unreturned witness row, so it covers a page cut by
202+
* the byte budget as well as one cut by `limit`. See {@link hasMoreTableRows}.
203+
*/
204+
return { rows, totalCount, nextCursor }
200205
}
201206

202207
function invalidateRowCount(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {
@@ -1295,6 +1300,14 @@ export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationCon
12951300
...page,
12961301
rows: page.rows.filter((r) => keep.has(r.id)),
12971302
...(page.totalCount != null ? { totalCount: keep.size } : {}),
1303+
/**
1304+
* The view is being emptied on purpose, so it has no next page — stated
1305+
* explicitly because the server's cursor would otherwise say otherwise and
1306+
* scrolling would pull back the very rows the job is deleting. Only the
1307+
* row-count arithmetic used to carry this, which {@link hasMoreTableRows}
1308+
* no longer consults once a cursor is present.
1309+
*/
1310+
nextCursor: null,
12981311
})),
12991312
}
13001313
: old

apps/sim/hooks/queries/utils/table-rows-pagination.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,49 @@ describe('hasMoreTableRows', () => {
5151
it('returns false when a stale-low count is already exceeded', () => {
5252
expect(hasMoreTableRows([makePage(10, 5)])).toBe(false)
5353
})
54+
55+
/**
56+
* The server sets `nextCursor` exactly when the drain proved an unreturned witness row, so it
57+
* answers correctly for a page cut by the byte budget — where both page fullness and the count
58+
* mislead. It therefore wins over the count rules whenever it is present.
59+
*/
60+
describe('nextCursor', () => {
61+
it('ends the drain on a null cursor even when the count claims more rows', () => {
62+
expect(hasMoreTableRows([{ ...makePage(36, 100), nextCursor: null }])).toBe(false)
63+
})
64+
65+
it('continues on a non-null cursor even when the count is already covered', () => {
66+
// A byte-cut page: fewer rows than asked for, and the advisory count disagrees.
67+
expect(hasMoreTableRows([{ ...makePage(3, 3), nextCursor: 'c1' }])).toBe(true)
68+
})
69+
70+
it('reads the cursor from the last page, not page 0', () => {
71+
const pages = [
72+
{ ...makePage(1000, null), nextCursor: 'c1' },
73+
{ ...makePage(12, null, 1000), nextCursor: null },
74+
]
75+
expect(hasMoreTableRows(pages)).toBe(false)
76+
})
77+
78+
it('falls back to the count rules when a page carries no cursor', () => {
79+
expect(hasMoreTableRows([makePage(36, 100)])).toBe(true)
80+
expect(hasMoreTableRows([makePage(3, 3)])).toBe(false)
81+
})
82+
83+
/**
84+
* The async "select all" delete strips rows from the active view and pins `nextCursor: null`
85+
* so scrolling cannot pull back the rows the background job is still deleting. Deselecting a
86+
* few leaves kept rows on the last page, so the row-count arithmetic that used to suppress
87+
* `hasNextPage` no longer fires — only the pinned cursor does.
88+
*/
89+
it('stays terminated for a partially-emptied view whose pages pin a null cursor', () => {
90+
const pages = [
91+
{ ...makePage(2, 2), nextCursor: null },
92+
{ ...makePage(1, null, 2), nextCursor: null },
93+
]
94+
expect(hasMoreTableRows(pages)).toBe(false)
95+
})
96+
})
5497
})
5598

5699
describe('getNextTableRowsPageParam', () => {

apps/sim/hooks/queries/utils/table-rows-pagination.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ export type TableRowsPageParam = number | TableRowsCursor
99
interface TableRowsPageLike {
1010
rows: ReadonlyArray<{ id: string; orderKey?: string }>
1111
totalCount: number | null
12+
/**
13+
* Optional only so this loose page shape stays usable by callers that do not have a server
14+
* response to hand (tests, and the optimistic mappings). On the wire it is required — the
15+
* contract declares it non-optional and `requestJson` validates the response — so a real page
16+
* always carries it and the count fallback below is defensive, not a live path.
17+
*/
18+
nextCursor?: string | null
1219
}
1320

1421
/** Rows loaded across all fetched pages. */
@@ -17,18 +24,23 @@ export function countLoadedTableRows(pages: readonly TableRowsPageLike[]): numbe
1724
}
1825

1926
/**
20-
* Whether more rows may exist past the fetched pages. A page is terminal only when it is
21-
* empty or when page 0's `COUNT(*)` is already covered — never when it is merely shorter
22-
* than the requested page size, so a short server page can never be misread as end-of-table.
27+
* Whether more rows may exist past the fetched pages.
2328
*
24-
* `totalCount` is advisory (computed in a separate transaction from the page read). A
25-
* stale-high count self-corrects via the empty-page rule at the cost of one extra request;
26-
* a stale-low count (rows deleted after page 0's COUNT) stops the drain early — accepted,
27-
* since the view is already stale and the run-stream/interval invalidations refetch it.
29+
* `nextCursor` is the authoritative answer and is preferred whenever the server sent one: it is
30+
* non-null exactly when the drain proved an unreturned witness row, so it is correct for a page
31+
* cut by the byte budget as well as one cut by `limit`. Page fullness cannot answer this — a
32+
* byte-cut page is legitimately shorter than the requested size.
33+
*
34+
* The count rules remain as a fallback for pages cached before `nextCursor` was threaded through.
35+
* They are weaker: `totalCount` is advisory (computed in a separate transaction from the page
36+
* read), so a stale-high count self-corrects via the empty-page rule at the cost of one extra
37+
* request, and a stale-low count stops the drain early. A null `totalCount` is read as "unknown,
38+
* assume more" — which is why the `includeTotal` coercion bug made every table page forever.
2839
*/
2940
export function hasMoreTableRows(pages: readonly TableRowsPageLike[]): boolean {
3041
const lastPage = pages[pages.length - 1]
3142
if (!lastPage || lastPage.rows.length === 0) return false
43+
if (lastPage.nextCursor !== undefined) return lastPage.nextCursor !== null
3244
const totalCount = pages[0].totalCount
3345
return totalCount == null || countLoadedTableRows(pages) < totalCount
3446
}

apps/sim/lib/api/contracts/tables.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,46 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { tableEventStreamQuerySchema } from '@/lib/api/contracts/tables'
5+
import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/contracts/tables'
6+
7+
/**
8+
* `requestJson` parses the query through this schema on the CLIENT before building the URL, so
9+
* these values arrive as the caller's real types, not as URL strings. A string-only coercion
10+
* therefore read the grid's `includeTotal: param === 0` boolean as `false`, and page 0 came back
11+
* with `totalCount: null` on every table.
12+
*
13+
* What that broke is the **filtered** total: `rowTotal` was permanently null, so select-all and
14+
* everything downstream of it (bulk delete, run scope, the selected-count label) silently fell
15+
* back to the table's UNFILTERED `rowCount`. It also left `hasMoreTableRows` reading a null total
16+
* as "more may exist" — though that half is now answered by `nextCursor` instead, so this schema
17+
* is not what removes the wasted page fetch.
18+
*/
19+
describe('tableRowsQuerySchema includeTotal', () => {
20+
it('accepts a real boolean, which is what the client passes', () => {
21+
expect(
22+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: true }).includeTotal
23+
).toBe(true)
24+
expect(
25+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: false }).includeTotal
26+
).toBe(false)
27+
})
28+
29+
it('still accepts the URL strings a direct API caller sends', () => {
30+
expect(
31+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: 'true' }).includeTotal
32+
).toBe(true)
33+
expect(
34+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: 'false' }).includeTotal
35+
).toBe(false)
36+
})
37+
38+
it('defaults to true when absent or empty, so a bare request still gets its count', () => {
39+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1' }).includeTotal).toBe(true)
40+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: '' }).includeTotal).toBe(
41+
true
42+
)
43+
})
44+
})
645

746
describe('tableEventStreamQuerySchema', () => {
847
it('parses an explicit cursor', () => {

apps/sim/lib/api/contracts/tables.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isRecordLike } from '@sim/utils/object'
22
import { z } from 'zod'
33
import {
4+
booleanQueryFlagSchema,
45
folderIdSchema,
56
privateSecretProvenanceBundleSchema,
67
requiredFieldSchema,
@@ -800,11 +801,21 @@ export const tableRowsQueryBaseSchema = z.object({
800801
.optional()
801802
)
802803
.default(0),
804+
/**
805+
* Absent, null, and empty all fall through to the `true` default, so a bare request still
806+
* gets its count. Everything else goes to {@link booleanQueryFlagSchema}, which accepts a real
807+
* boolean as well as the URL strings — `requestJson` parses this schema on the CLIENT before
808+
* building the URL, so the value arrives as the caller's own type, and a string-only coercion
809+
* silently read the grid's `includeTotal: param === 0` as `false` — leaving `totalCount` null on
810+
* every table, and select-all falling back to the unfiltered row count.
811+
*
812+
* Unparseable values now reject rather than resolving to `false`, matching `limit` and `offset`
813+
* in this same schema, which have always thrown on garbage.
814+
*/
803815
includeTotal: z
804816
.preprocess(
805-
(value) =>
806-
value === null || value === undefined || value === '' ? undefined : value === 'true',
807-
z.boolean().optional()
817+
(value) => (value === null || value === undefined || value === '' ? undefined : value),
818+
booleanQueryFlagSchema.optional()
808819
)
809820
.default(true),
810821
})

apps/sim/lib/table/planner.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,36 @@ export type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
1515
const READ_STATEMENT_TIMEOUT_MS = 15_000
1616
const READ_LOCK_TIMEOUT_MS = 3_000
1717

18-
async function setReadTimeouts(trx: DbTransaction): Promise<void> {
19-
await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${READ_STATEMENT_TIMEOUT_MS}ms'`))
20-
await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${READ_LOCK_TIMEOUT_MS}ms'`))
18+
/**
19+
* Applies every guard in ONE round-trip. Each awaited `trx.execute` is its own round-trip, and
20+
* every user-table read opens a transaction, so issuing these separately cost 2–3 round-trips on
21+
* every page, count, and drain batch.
22+
*
23+
* `set_config(name, value, is_local => true)` is exactly `SET LOCAL` — transaction-scoped, dying
24+
* with the commit, and reverting the same way on a savepoint rollback — but it is a function call,
25+
* so several fit in one `SELECT`. It also takes the values as bound parameters, which `SET LOCAL`
26+
* cannot. That is the reason they must be one statement rather than semicolon-joined: a bound
27+
* parameter forces the extended protocol, which rejects multiple commands per message.
28+
*
29+
* The guards are the first statement in the transaction, so an invalid value aborts it before
30+
* `fn(trx)` can run — there is no path where a read proceeds unguarded.
31+
*/
32+
async function setReadGuards(trx: DbTransaction, seqscanOff: boolean): Promise<void> {
33+
/**
34+
* Only ever set to `off`, never explicitly to `on` — the unflagged path must leave whatever
35+
* the server default is, exactly as the separate `SET LOCAL enable_seqscan = off` did.
36+
*/
37+
const seqscan = seqscanOff ? sql`, set_config('enable_seqscan', 'off', true)` : sql``
38+
await trx.execute(sql`
39+
select
40+
set_config('statement_timeout', ${`${READ_STATEMENT_TIMEOUT_MS}ms`}, true),
41+
set_config('lock_timeout', ${`${READ_LOCK_TIMEOUT_MS}ms`}, true)${seqscan}
42+
`)
2143
}
2244

2345
/**
2446
* Runs a user-table read inside a transaction that always caps `statement_timeout`
25-
* / `lock_timeout` (see {@link setReadTimeouts}). Pass `seqscanOff` for queries
47+
* / `lock_timeout` (see {@link setReadGuards}). Pass `seqscanOff` for queries
2648
* with no tenant-bounded index plan — custom column sorts and filtered counts —
2749
* where the planner otherwise seq-scans the whole shared `user_table_rows`
2850
* relation (every tenant's rows); see {@link withSeqscanOff} for the measured
@@ -34,8 +56,7 @@ export async function withReadGuards<T>(
3456
opts?: { seqscanOff?: boolean }
3557
): Promise<T> {
3658
return db.transaction(async (trx) => {
37-
await setReadTimeouts(trx)
38-
if (opts?.seqscanOff) await trx.execute(sql`SET LOCAL enable_seqscan = off`)
59+
await setReadGuards(trx, opts?.seqscanOff ?? false)
3960
return fn(trx)
4061
})
4162
}

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,7 +1152,13 @@ export async function queryRows(
11521152
// unfiltered count already plans an index-only scan on the table_id prefix.
11531153
// The count uses the full-view WHERE (no cursor seek): totals cover the whole
11541154
// view, not the remaining pages.
1155-
const hasFilter = Boolean(userClause)
1155+
/**
1156+
* The delete mask counts as a filter: it injects JSONB predicates into `baseConditions`, which
1157+
* is exactly the plan shape `countRowsTenantBounded` exists to keep off a seq scan of the shared
1158+
* relation. Reading only `userClause` sent a masked-but-unfiltered count down the plain branch,
1159+
* bounded only by the statement timeout.
1160+
*/
1161+
const hasFilter = Boolean(userClause || deleteMask)
11561162
const countPromise = includeTotal
11571163
? hasFilter
11581164
? countRowsTenantBounded(whereClause)
@@ -1264,7 +1270,15 @@ interface BoundedFetchResult {
12641270
anchorOffset: number
12651271
}
12661272

1267-
/** Belt-and-braces bound on drain iterations; unreachable in practice. */
1273+
/**
1274+
* Belt-and-braces bound on drain iterations.
1275+
*
1276+
* Unreachable only because every iteration either consumes at least one row or cuts, and a bounded
1277+
* page's `limit` is capped at {@link TABLE_LIMITS.MAX_QUERY_LIMIT} — so the limit cut always fires
1278+
* first. That makes the two constants exactly tight: raising `MAX_QUERY_LIMIT` above this bound
1279+
* would let the loop exit with rows still unread and `hasMore: false`, which clients now trust as
1280+
* end-of-table (they terminate on `nextCursor`, which this decides). Raise both together.
1281+
*/
12681282
const MAX_QUERY_BATCHES = 1000
12691283

12701284
/**

0 commit comments

Comments
 (0)