Skip to content

Commit 9861887

Browse files
committed
fix(tables): keep an emptied view terminated, and count masked reads off the seq scan
1 parent 9159d1e commit 9861887

7 files changed

Lines changed: 66 additions & 16 deletions

File tree

apps/sim/hooks/queries/tables.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,14 @@ export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationCon
13001300
...page,
13011301
rows: page.rows.filter((r) => keep.has(r.id)),
13021302
...(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,
13031311
})),
13041312
}
13051313
: old

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,24 @@ describe('hasMoreTableRows', () => {
7575
expect(hasMoreTableRows(pages)).toBe(false)
7676
})
7777

78-
it('falls back to the count rules for pages cached before the cursor was threaded through', () => {
78+
it('falls back to the count rules when a page carries no cursor', () => {
7979
expect(hasMoreTableRows([makePage(36, 100)])).toBe(true)
8080
expect(hasMoreTableRows([makePage(3, 3)])).toBe(false)
8181
})
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+
})
8296
})
8397
})
8498

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ interface TableRowsPageLike {
1010
rows: ReadonlyArray<{ id: string; orderKey?: string }>
1111
totalCount: number | null
1212
/**
13-
* Optional because pages cached before this field was threaded through predate it; those fall
14-
* back to the count rules below.
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.
1517
*/
1618
nextCursor?: string | null
1719
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/con
77
/**
88
* `requestJson` parses the query through this schema on the CLIENT before building the URL, so
99
* 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`, page 0 came back with
11-
* `totalCount: null`, and `hasMoreTableRows` — which treats a null total as "more may exist" —
12-
* reported `hasNextPage` forever. Every table then paid a wasted extra page fetch on mount and
13-
* before every row insert.
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.
1418
*/
1519
describe('tableRowsQuerySchema includeTotal', () => {
1620
it('accepts a real boolean, which is what the client passes', () => {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,11 @@ export const tableRowsQueryBaseSchema = z.object({
806806
* gets its count. Everything else goes to {@link booleanQueryFlagSchema}, which accepts a real
807807
* boolean as well as the URL strings — `requestJson` parses this schema on the CLIENT before
808808
* 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`.
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.
810814
*/
811815
includeTotal: z
812816
.preprocess(

apps/sim/lib/table/planner.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ const READ_STATEMENT_TIMEOUT_MS = 15_000
1616
const READ_LOCK_TIMEOUT_MS = 3_000
1717

1818
/**
19-
* Applies every guard in ONE round-trip. Each `trx.execute` is its own serial round-trip (the
20-
* driver runs `prepare: false`), and every user-table read opens a transaction, so issuing these
21-
* separately cost 2–3 round-trips on every page, count, and drain batch.
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.
2222
*
2323
* `set_config(name, value, is_local => true)` is exactly `SET LOCAL` — transaction-scoped, dying
24-
* with the commit — but it is a function call, so several fit in a single `SELECT`. Semicolon-
25-
* joining `SET LOCAL` statements would not work here: the driver sends this over the extended
26-
* protocol, which rejects multiple commands in one message.
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.
2731
*/
2832
async function setReadGuards(trx: DbTransaction, seqscanOff: boolean): Promise<void> {
2933
/**

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)