Skip to content

improvement(tables): stop re-reading values the request already holds - #8107

Merged
waleedlatif1 merged 1 commit into
stagingfrom
perf/tables-api-round2
Sep 21, 2026
Merged

waleedlatif1 merged 1 commit into
stagingfrom
perf/tables-api-round2

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #8104. Two more round trips a table request was paying for data it already held:

  • checkAccess read the workspace twice. It resolves the caller's workspace access, then asks the capability check whether the group withholds tables.use — without telling it which organization owns the workspace. resolvePermissionGroupConfig looks that up itself when the argument is omitted (its own docs: "Everything else holds the organization id already and should pass it, rather than paying for a second lookup of a value it has"), so the workspace was read a second time on every raw /api/table/** route. It now resolves through checkWorkspaceAccess — which getUserEntityPermissions already delegates to, so it is the same single resolution — and passes the organization that comes back with it. Both arms of the resolver end in the same resolveUserAccessControlContextForOrganization call and agree on every branch, including the non-hosted short-circuit.
  • An appending insert asked for its two anchors separately. max(order_key) and max(position) were two statements, both issued inside the row-order advisory lock that every other inserting request on that table is queued behind. Postgres plans each max() as its own InitPlan, so one statement still serves each aggregate from its own index — verified identical plans, two index-only backward scans, 4 buffers each — in one round trip instead of two.

Statements per request, from the Postgres statement log (two runs, deterministic):

request before after
GET views 7 6
GET dispatches 8 7
GET export 14 13
POST rows (append insert) 14 13

Two things I did NOT do, and why

Both were on the follow-up list in #8104. Measuring them killed them:

  • Folding the delete-job probe into the drain's guard statement. It does not pay. The probe is one round trip shared by the count and the page, which run as parallel transactions. Moving it inside the drain makes the count wait on the drain's guard, so the critical path is unchanged; letting each transaction probe independently saves the round trip but breaks the invariant that the page and its total are masked by the same clause. The version that would work — reading the job inside the row read's own snapshot — is a larger restructure than this.
  • count(*) OVER () to fold the total into the page query. Actively harmful. The window is evaluated before LIMIT, so it forces the whole match set through a sort. Measured on a 100k-row table: an index scan reading 11 buffers in 0.23ms becomes a sequential scan + sort of all 100,000 rows in 144ms, spilling 20,864 temp buffers. A 630× regression on the module's hottest read.

Benchmarks

Same method as #8104: production build, real HTTP, interleaved A/B between two prebuilt trees, p50 over 3 rounds × 20 iterations, loopback shim adding a fixed 2ms DB round trip.

scenario 1k rows 100k rows
views 47.9 → 42.0ms (−12%) 50.1 → 45.4ms (−9%)
dispatches 54.7 → 50.5ms (−8%) 56.8 → 49.8ms (−12%)
export csv 81.7 → 74.5ms (−9%) 80.8 → 74.1ms (−8%)
insert row 148.5 → 141.6ms (−5%) 148.8 → 138.5ms (−7%)

Untouched endpoints (rows, table, tables-list) are flat within noise, which is the regression check. Modest by design — this is one round trip each, not a structural change.

Behavioural equivalence

Captured every table endpoint's exact body against both trees across four table sizes — 59 entries, 84,571,541 bytes — plus a row-ordering exercise driving append, afterRowId, beforeRowId, explicit position, and both rejection cases, then reading the resulting visual order back. Identical apart from generated row ids and one updatedAt that the capture's own inserts bumped. Ordering came back the same on both: appends take consecutive positions, anchored inserts land beside their anchor, positional inserts land at the requested slot, conflicting anchors are still 400, an unknown anchor is still 404.

Type of Change

  • Performance improvement

Testing

  • vitest run full app suite: 54,564 passed, 1 pre-existing load-flake (an unrelated UI test that passes in isolation)
  • Table suites: 1,972 passed across 144 files
  • New tests pin both changes — that the capability resolver receives the organization (and null for a personal workspace) rather than undefined, and that the append reads both anchors in a single select — and each was verified to fail when its change is reverted
  • bun run check:audits (47 audits), bun run lint, bun run type-check, docs-manifest:check, block-registry check — all pass
  • Query plans for the combined aggregate compared against the two separate ones with EXPLAIN (ANALYZE, BUFFERS)

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Two round trips a table request paid for data it was already carrying:

- `checkAccess` — the gate every raw `/api/table/**` route shares — resolved the
  caller's workspace access, then asked the capability check for `tables.use`
  without telling it which organization owns the workspace. The resolver looks
  that up itself when it is omitted, so the workspace was read twice per
  request. It now goes through `checkWorkspaceAccess`, which `getUserEntityPermissions`
  already delegates to, and passes the organization it hands back. Same single
  resolution, one fewer read, on views, dispatches, export, import, columns,
  metadata and the rest.
- An appending insert asked for `max(order_key)` and `max(position)` as two
  statements, both inside the row-order advisory lock every other inserting
  request is queued behind. Postgres plans each `max()` as its own InitPlan, so
  one statement still serves each from its own index — the same two index-only
  backward scans, in one round trip instead of two.

Statements per request: views 7→6, dispatches 8→7, export 14→13, insert 14→13.
@vercel

vercel Bot commented Sep 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Sep 21, 2026 5:05pm UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge with authorization and row-ordering behavior preserved.

Summary

This PR removes one redundant workspace lookup from table authorization and combines two append-anchor aggregate reads into one database statement.

  • Reuses the workspace and organization returned by checkWorkspaceAccess when evaluating tables.use.
  • Reads the maximum order key and position together for unanchored row appends.
  • Adds focused coverage for organization propagation, personal workspaces, and combined append-anchor reads.

Reviews (1) · Last reviewed commit: "improvement(tables): stop re-reading val..."

@waleedlatif1
waleedlatif1 merged commit 98db661 into staging Sep 21, 2026
34 checks passed
@waleedlatif1
waleedlatif1 deleted the perf/tables-api-round2 branch September 21, 2026 17:11

This branch was previously deployed

1 inactive deployment
Preview 474fb331 Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant