improvement(tables): cut the DB round trips a table read and write spend on protocol - #8104
Merged
Merged
Conversation
…end 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
|
…ce 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.
Collaborator
Author
Collaborator
Author
|
@cubic-dev-ai review this PR |
Contributor
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
This was referenced Sep 21, 2026
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The tables read path spent more time on DB round trips than on work. Three of them were avoidable, and none of them changes what a caller gets back:
tableRowExecutionsis keyed by(rowId, groupId), every writer takes itsgroupIdfrom a group on the table's own schema, and group/column deletes strip the sidecar in the same transaction that removes the group. A table declaring no group was still paying a chunked query per 250 rows — four on a 1000-row page — each returning nothing. Both signals are checked (workflowGroupsand any column still carrying aworkflowGroupId), so an unexpected schema shape keeps the query rather than silently dropping run state.seqscanOffandrepeatableReadare fixed for the call, so every batch past the first paidBEGIN+set_config+COMMITfor nothing. A 1000-row page drains in two batches, so that was three round trips of pure protocol on the grid's first read.setTableTxTimeoutsissued threeSET LOCALstatements.set_config(name, value, true)is exactlySET LOCALand all three fit in oneSELECT— which is what the read guards already do. Every table write transaction begins with this call, so this lands on insert, delete, import and column changes alike.Statements per request, measured from the Postgres statement log:
GET rows?limit=1000(the grid's first page)GET rows?limit=50POST rows(insert)DELETE rowsRow visibility is unchanged. Under READ COMMITTED each statement still takes its own snapshot, so a batch sees exactly what a separate transaction would have. Under REPEATABLE READ the drain's batches now share one snapshot instead of taking one each, which is strictly more consistent — a row and the secret-provenance sidecar captured for it can no longer come from different points in time across a batch boundary.
Response equivalence
Rather than reason about it, the two trees were diffed on the wire. Every table endpoint the UI touches was captured against both, across four table sizes, including a full keyset page-walk: 76 endpoints / 96 HTTP responses / 88,538,429 bytes of response body, byte-identical (only the per-request
requestId, random by design, normalized).Benchmarks
Production build, real HTTP, session-authenticated, against seeded tables of 100 / 1k / 10k / 100k rows (12 columns, mixed types). Interleaved A/B between two prebuilt trees — same machine, same DB, alternating — p50 over 2–3 rounds × 10–15 iterations. A loopback TCP shim adds fixed one-way delay so a DB round trip costs what a real one does; 0ms is the direct connection, which isolates the CPU side.
Grid first page (
limit=1000), before → after:Opening a table — the three requests the page actually fires in parallel — at 2ms: 104.0 → 64.0ms (−39%), and −40% at 10ms.
Other paths at 2ms:
limit=100−23 to −28%, second page −40%, sorted −8 to −14%,limit=50−3 to −5%. Writes (insert + update + delete) −1 to −4%, which is the two round trips the timeout fix removes.The gain is there at 0ms and grows with latency because it is structural — fewer round trips, not less work — so it carries to a managed Postgres hop rather than depending on this machine. Endpoints the change does not touch (
GET /api/table/[id], views, tables list) are flat within noise at every latency, which is the regression check.Type of Change
Testing
vitest runfull app suite: 54,564 passed, 1 pre-existing load-flake (cli-tools-boundary, a module-graph walk that times out under full-suite load and passes in isolation — unrelated to tables)bun run check:audits(47 audits),bun run lint,bun run type-check,docs-manifest:check, block-registry check — all passFollow-ups (not in this PR)
max(order_key),max(position)) inside the row-order lock that could be one statement.Checklist