perf(table): collapse sequential round trips on the table read and write paths - #6808
perf(table): collapse sequential round trips on the table read and write paths#6808waleedlatif1 wants to merge 11 commits into
Conversation
The single-row surface (GET/PATCH/DELETE) had no route-level tests despite being the hottest table write path. Pin the behavior it emits today so the migration onto the shared internal route builder is verifiable rather than hopeful. Covers status codes, body shapes, ISO-8601 timestamp serialization, the access level each method demands, collaborator invocation, and the dual-caller wire keying — session callers pass column ids through untouched while internal-JWT callers translate names to ids in both directions. Verified to fail: mutating the wire translator, the deleted count, and the workspace-ownership guard each turn the corresponding tests red.
… cases The row write use cases assumed every caller speaks column names. That holds for /api/v2, /api/v1 and the Copilot tools, but not for the first-party grid or the internal /api/table routes, which address cells by stable storage id. Feeding id-keyed data through the name remap drops every key it does not recognise — a storage id names no column name — so the write would store nothing and still report success. Make the wire an explicit, required property of the input rather than an assumption. `dataKeying: 'names' | 'ids'` sits alongside `strictWrite` and is required for the same reason: a new write surface must state which contract it publishes. Strictness now means the same thing on either wire — an unknown column id is refused exactly as an unknown column name already was. Single-row writes also gain optional actor attribution, so the acting tab can skip refetching its own write. It is optional and absent by default, so every existing caller keeps broadcasting to all subscribers as before. Only the single-row create, update and delete paths accept it; a batch write is not reconciled locally by the actor and must still refetch. The attribution pin moves with the behaviour: what selects the actor-scoped signal is no longer which file calls it but which surface supplies an actor, so that is now what the test pins. Verified to fail: ignoring the keying discriminator, and dropping actor attribution, each turn the corresponding tests red.
A single-cell PATCH spends far more time in sequential round trips to a remote Postgres than in the UPDATE it issues. Prepared statements are disabled for PgBouncer transaction mode, so every await is a full Parse/Bind/Execute. Two of them were avoidable. getRowById issued the row lookup and its executions sidecar in series, but the sidecar is keyed on the row id the caller already supplied, so it never depended on the lookup. Issuing both together makes it one round trip. A miss now pays one redundant sidecar read, which is the rare path and costs no extra wall time. The uniqueness probe ran whenever the table had any unique column, passing the fully merged row, so editing an unrelated cell re-probed every unique column — its own transaction plus one query per column. It is now scoped to the columns the patch actually writes. A merge cannot newly violate uniqueness on a column it leaves alone: that value is the one already stored, and it satisfied the constraint when it was written. Sized before changing: few tables declare a unique column, but write traffic concentrates in the ones that do, so this is the larger of the two savings in practice. Verified to fail: reverting the probe scoping turns the covering test red.
…space is asserted resolveActiveTableContext ran two sequential round trips: load the table, then load the workspace it turned out to live in. When the caller asserts a workspace the second input is already in hand, so both can start together. What makes that safe is unchanged: requireTable still compares the table's canonical workspaceId against the assertion and reports a mismatch as not_found. The table outcome is inspected first and unconditionally, so any path that returns has proven the assertion equal to the canonical id, and a failing workspace load can never replace the concealing not_found. A final identity check on the loaded context restates the invariant at the point of return, so even with the first check removed the function cannot hand back a foreign workspace. Promise.allSettled keeps the discarded branch from surfacing as an unhandled rejection. With no asserted workspace the path stays sequential — the table load is what reveals which workspace to load, so there is nothing to start early. One existing assertion changed: it required the workspace load not to have been issued yet on a mismatched assertion, which is internal sequencing rather than caller-observable behaviour and is definitionally untrue once the loads overlap. The observable half is kept, and two timing tests now cover the sequencing directly. Verified to fail: removing either mismatch check, reading the workspace outcome first, and swapping allSettled for bare awaits each turn the corresponding tests red.
getTableById issued the table SELECT and then awaited latestJobForTable, so every table request paid two sequential round trips. With prepared statements disabled for PgBouncer transaction mode every await is a full round trip, and this loader is on essentially every table route. The job read cannot be skipped: a table's reported rowCount is the stored count minus the job's pendingDeleteRemaining, so dropping it would overstate the count during a pending delete and could wrongly reject inserts as over capacity. An opt-out flag would have made that a caller's trap. Instead the job is read in the same statement, as a correlated jsonb subquery in the select list — the select-list form of a LEFT JOIN LATERAL, which is what drizzle can type here. Output is unchanged for every input. latestJobForTable is deleted rather than left dangling: getTableById was its only caller, and keeping it would have carried a third copy of the exports-excluded / newest-started_at / limit-one rule. mapJobRow is now exported so the batch path and the lateral share one implementation of the doomedCount and pendingDeleteRemaining logic. The batch DISTINCT ON path used by the list endpoint is untouched. Verified to fail: dropping the export filter, reversing or re-keying the sort, dropping the limit, dropping the correlation, loosening either doomedCount condition, and removing the rowCount subtraction each turn tests red. Dropping the lateral from the projection initially survived, because the shared db mock returns queued rows regardless of predicate; a projection assertion now covers it.
Authored as groundwork for migrating the internal row routes onto the shared builder, which is not in this change. An exported contract nothing consumes is dead code, so it lands with the migration that needs it instead.
assertKnownColumnIds read column.id directly, but a column id is optional — pre-backfill columns have none and are stored under their name, which is why getColumnId exists and is what every other consumer of the schema uses. Such columns still exist, so a strict id-keyed write naming one would have been refused as unknown. Latent today: no surface yet combines dataKeying 'ids' with strictWrite. Fixed before one does. Verified to fail: reading column.id turns the covering test red.
Four parallel reviews (reuse, simplification, efficiency, altitude) converged on the same set. Applied: Removed actorClientId entirely. It had no supplier anywhere in the repo, so every call reached signalTableRowsChangedByActor(id, undefined), which is byte-identical to the broadcast it replaced — three optional fields, three verbatim doc blocks and a pin test asserting the empty set, all inert. It belongs with the route migration that supplies an actor. The attribution pin is restored to its original form. The uniqueness probe was only half-narrowed: the patched column list was computed and then discarded, and the probe re-derived every unique column from the full schema. It now receives only the columns the patch touched, so a table with several unique columns runs one query instead of all of them. assertKnownColumnIds hand-rolled an id index and duplicated the sibling assert's message verbatim. It now reuses buildColumnNameById — which already keys by getColumnId, so the legacy pre-backfill column case is handled by the shared helper rather than by a special case here — and both asserts share one message builder. The job field list had become two copies, one drizzle-checked and one an unchecked sql<T> cast that could silently return undefined for a renamed field. The lateral now derives its jsonb pairs from JOB_PROJECTION, which satisfies Record<keyof LatestJobRow, Column>. That compile-time guarantee replaces the runtime drift test it makes redundant. Also: hoisted the id index out of the batch loop to match the names path, dropped a never-supplied parameter, narrowed an over-broad parameter type, removed a dead timer cleanup and its now-unused import, replaced dynamic re-imports with the static one already present, hoisted a repeated stub, and documented why filters need no keying counterpart and how laxness differs between the two wires. Verified to fail: removing a field from JOB_PROJECTION breaks the build in two places.
buildTable was copy-pasted into 13 route test files under app/api/table, each a near-identical TableDefinition literal. A required field added to that type would have failed 13 files individually. packages/testing already owned createTableColumn and createTableRow but no definition factory, and — as it turns out — did not export any of them from the barrel, so they were unreachable from @sim/testing. Adds createTableDefinition beside them and exports all three. The fixture type is a structural stand-in rather than TableDefinition itself, because packages/* must not import from apps/* — the same approach the existing factories in that file already take. No assertion changed. Call sites that varied a field pass it as an override; the four files with several call sites hoist a shared options const and spread it so each call still gets a fresh object.
Both job reads selected the whole payload jsonb, but mapJobRow reads exactly
one number out of it, and only for a running delete. The payload also carries
the delete job's filter and an unbounded excludeRowIds array, and the latest
non-export job is read on essentially every table request — a table that once
ran a large delete would ship that id list on every read, forever.
LatestJobRow.payload becomes doomedCount, extracted in SQL. Both readers share
JOB_PROJECTION so one edit reaches the batch DISTINCT ON and the correlated
subquery alike; the compile-time constraint widens to Column | SQL rather than
being dropped.
Behaviour is identical. `->` keeps the value jsonb, which postgres-js decodes
through its built-in JSON.parse handler, so it arrives as a number with no
boundary coercion. A null payload, a payload without the key, a non-object
payload and an explicit JSON null all collapse to the same `?? 0` the previous
optional chain produced.
Sized honestly before claiming a win: payloads are small in practice today, so
this is defensive rather than impactful — it removes an unbounded growth path,
not a measured cost.
Verified against a real Postgres, not just the mocked driver: the
generated correlated subquery returns doomedCount 12 for a delete job, null
for an import job, and a null row for a table with no job.
Also from the review pass: re-homes the strictWrite explanation onto
rowWriteOptions, where six {@link} references now point; records why
replaceProjectedWireRows carries no keying discriminator; notes the one case
the uniqueness-narrowing invariant does not cover; pins the lax id-wire
passthrough with a test; and renames a parameter that misled once only its
keys were read.
One restated the identifier below it (checkUniqueConstraintsDb), and one was a section-divider banner in the factories barrel, which the repo's comment convention rules out. Everything else that survives is a why the code cannot express: round-trip rationale, sql.raw input safety, the narrowing invariant and the one case it does not cover.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Reads: Writes: Row use cases require Tests/tooling: Adds characterization tests for Reviewed by Cursor Bugbot for commit 8bfaee4. Configure here. |
Greptile SummaryThis PR reduces sequential database round trips on table read and write paths while making row-data keying explicit at the application boundary.
Confidence Score: 5/5The PR appears safe to merge; the optimized queries and explicit row-keying paths preserve the existing table contracts for all current callers examined. The correlated and parallel reads retain prior filtering and return semantics, touched-column uniqueness checks do not remove validation from modified unique fields, and every current production row-write adapter supplies the keying that matches its input format.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/table/application/rows.ts | Introduces explicit row-data keying and consistently normalizes current name-keyed and storage-ID-keyed write surfaces. |
| apps/sim/lib/table/rows/service.ts | Parallelizes independent row reads and restricts uniqueness probes to patched unique columns without changing reachable validation behavior. |
| apps/sim/lib/table/service.ts | Replaces the sequential latest-job query with an equivalent correlated projection used to derive visible row counts. |
| apps/sim/lib/table/jobs/service.ts | Shares a narrow latest-job projection and preserves pending-delete calculations while avoiding retrieval of unbounded payload data. |
| apps/sim/lib/table/application/context.ts | Loads asserted workspace and table context concurrently while preserving canonical workspace matching and not-found concealment. |
| apps/sim/lib/table/column-keys.ts | Adds the stable-ID index used to validate ID-keyed writes, including legacy columns whose storage key is their name. |
| packages/testing/src/factories/table.factory.ts | Centralizes table-definition fixtures used across the expanded route tests. |
Sequence Diagram
sequenceDiagram
participant Caller
participant App as Table application use case
participant DB as PostgreSQL
Caller->>App: Read table or row
par Collapsed table read
App->>DB: Table + correlated latest-job projection
DB-->>App: Table and derived job fields
and Parallel row read
App->>DB: Row lookup
App->>DB: Execution sidecar lookup
DB-->>App: Row and executions
end
Caller->>App: Write name-keyed or ID-keyed row data
App->>App: Normalize according to dataKeying
App->>DB: Probe only touched unique columns
App->>DB: Persist row and provenance
Reviews (1): Last reviewed commit: "chore(table): drop two comments the code..." | Re-trigger Greptile
Summary
getTableByIdread the table and its latest job in two sequential round trips; the job is now read in the same statement as a correlated subquery. This loader is on essentially every table request. It can't simply be skipped — a table's reportedrowCountis the stored count minus the job'spendingDeleteRemaining, so the count and the job row are one read.getRowByIdissued the row lookup and its executions sidecar in series, though the sidecar keys on the row id the caller already supplied. Both now go out together.resolveActiveTableContextstarts the workspace load alongside the table load when the caller asserts a workspace. The mismatch check is unchanged and still inspected first, so a returning path has always proven the assertion equals the canonical workspace id.payloadjsonb, but only one number is ever read from it, and the payload carries an unboundedexcludeRowIdsarray. Now projects just that field./api/v2and the Copilot tools, but not for the first-party grid or the internal/api/tableroutes, which address cells by stable storage id — and the name remap drops keys it doesn't recognise, so id-keyed data would have stored nothing and reported success.dataKeyingmakes the wire an explicit, required part of the input.Groundwork only — no route is migrated onto the application boundary here; that's a follow-up.
Type of Change
Testing
Tested manually. Full
lib/table,lib/apiand table route suites pass (2,771 tests),type-checkclean, all 29 audits pass includingcheck:api-validation:strict,check:boundariesandcheck:block-registry. Each behavioural change was mutation-tested — the fix was reverted and the covering test confirmed to go red. The generated correlated subquery was additionally run against a real Postgres, since drizzle is globally mocked in the test suite.Checklist