Commit 53fdcab
feat(tables): background jobs (delete/export/backfill on trigger.dev) + tenant-scoped query performance (#4915)
* feat(tables): paginated background row-delete jobs via table_jobs
* fix(tables): address review on async row-delete (filtered count, scoped optimistic clear, Cmd+A select-all, hide delete from tray)
* improvement(tables): filter-aware select-all runs, delete-job read mask, keyset index + autovacuum tuning
* feat(tables): run import/delete/export/backfill jobs on trigger.dev with in-process fallback
* improvement(tables): raise delete page to 10k and export batch to 5k
* improvement(tables): raise CSV import batch to 5k rows (param-cap bounded)
* feat(tables): surface export jobs in the header tray with progress, cancel, and download
* improvement(tables): surface exports as derived tables-scoped toasts instead of the import tray
* Revert "improvement(tables): surface exports as derived tables-scoped toasts instead of the import tray"
This reverts commit 1ea5871.
* fix(tables): preserve export storage key (NoSuchKey) and unify jobs in one spinner tray
* improvement(tables): jobs tray icon reflects aggregate state (spinner/check/alert)
* fix(tables): restore jobs tray on the tables list (dropped in staging merge)
* improvement(tables): keyset-paginate export row reads (offset paging was O(n^2) over large tables)
* perf(tables): keyset pagination for grid infinite scroll
Default-order row pages now cursor on (order_key, id) instead of OFFSET —
each page is an index seek on tableOrderKeyIdx, where OFFSET re-scans and
discards every prior row (O(N²) across deep scrolls and full drains like
select-all/export-to-clipboard). Sorted views keep offset paging; the
contract refines after+sort as mutually exclusive. v1 public rows API is
unchanged (extends the unrefined base, omits after).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): show export in job tray immediately on kickoff
The export-jobs query's poll only self-sustains once a running job is
already in the cache, so a freshly kicked export stayed invisible until
an SSE event or page refresh. Invalidate the tray query on kickoff
success so the icon appears right away.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): surface real row/column write errors in toasts
Drizzle wraps DB errors in DrizzleQueryError whose message is the failed
SQL — the real cause (e.g. the row-limit trigger's RAISE) sits on .cause,
so the routes' substring classification never matched and everything fell
through to generic 500s ("Failed to insert row"). Add rootErrorMessage
(cause-chain unwrap) and a shared rowWriteErrorResponse classifier that
consolidates the per-route pattern lists and rewrites the trigger message
into a friendly "Row limit exceeded — capped at N rows". Applied across
the app and v1 row-write routes and the columns route.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-bound filtered row counts (12.7s -> 0.6s)
JSONB filter predicates (->> ILIKE / range casts) are opaque to the
planner: it estimates a handful of matches and picks a parallel seq scan
over the entire shared user_table_rows relation — every tenant's rows —
for the page-0 COUNT(*), so any non-equality filter on a large table cost
10s+ regardless of how few rows matched. Run filtered counts in a
transaction with SET LOCAL enable_seqscan = off, forcing the
tenant-bounded bitmap plan. Unfiltered counts keep their index-only scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-bound Cmd+F search and stream its window (75s -> 2s)
Same planner trap as the filtered count, compounded: the lateral
jsonb_each_text ILIKE is unestimatable, so findRowMatches on a 1M-row
table seq-scanned the whole 12M-row shared relation and disk-sorted
~120MB of window input (75s measured). SET LOCAL enable_seqscan=off
bounds the scan to the tenant; on the default order, additionally
penalizing bitmap/sort/parallel steers the planner onto the already-
sorted (table_id, order_key, id) index walk so row_number() streams
with no sort at all (2s measured). Flags only penalize plan shapes —
a custom sort still sorts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-bound sorted pages and filtered write selections
Extends the seqscan fix to every remaining jsonb-predicate path, all
measured on a 1M-row table in a 12M-row shared relation:
- sorted page query (ORDER BY data->>'col'): 9.7s/page -> 0.76s, and deep
pages stop spilling ~130MB sorts to disk
- updateRowsByFilter / deleteRowsByFilter row selection: 14.4s -> bounded
- delete-job worker selectRowIdPage with a filter: 12.6s/page -> bounded
- dispatcher filtered-scope window walk: same shape, same fix
Shared withSeqscanOff helper moves to lib/table/planner.ts (service +
dispatcher both consume it; dispatcher can't import service).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-scoped containment index (migration 0232)
The plain GIN on user_table_rows.data matched @> candidates across every
tenant sharing the relation — a hot value in someone else's table
inflated everyone's equality filters (1.07M candidates fetched for a
33k-row match, lossy bitmap, 1.1s). Replace it with btree_gin
(table_id, data jsonb_path_ops): the tenant intersection happens inside
the index and paths are single hashed entries. Rare-equality probe
326ms -> 17ms with zero wasted candidates; unique-constraint checks and
upsert conflict lookups ride the same index. The new index is smaller
than the one it replaces (529MB vs 694MB on the 12M-row dev relation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-bound unique-constraint checks (3.5s -> <1s per write)
The unique check runs lower(data->>'col') = $1 LIMIT 1 on every insert
and cell edit touching a unique column. The predicate is unestimatable
and a unique (non-conflicting) value never exits early, so the planner
seq-scanned all 12.3M shared-relation rows per check — 3.5s measured.
Tenant-bound both the single and batch variants; the batch path sets the
flag on the caller's transaction when one is supplied (SET LOCAL dies at
its commit, and the statements that follow are tenant-scoped writes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tables): tenant-bound upsert conflict lookup
Same unestimatable data->>key predicate as the unique checks; an
insert-path upsert has no existing match so the lookup can't exit early
and seq-scans the whole shared relation. The upsert already runs in a
transaction — set the planner flag on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(tables): consolidate executor types onto planner exports
service.ts kept a private DbTransaction alias and two inline
typeof db | DbTransaction unions after planner.ts began exporting the
canonical DbTransaction/DbExecutor — import those instead. From the
/simplify review of the perf series; no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tests): drop narrow schema mock override in process-contents test
The local vi.mock('@sim/db/schema') stubbed only document/knowledgeBase,
but the file's import graph reaches lib/table/service whose module scope
now references tableJobs. The global schema mock already covers all of
it — rely on it per the testing rules instead of re-mocking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): scope cancels and counts to the filtered selection (review)
Addresses the open Bugbot/Greptile findings on filtered select-all:
- Filtered runs no longer cancel the whole table: cancelWorkflowGroupRuns
takes a filter — it stops only dispatches with that exact filter scope
and only in-flight cells on matching rows (semi-join); whole-table and
differently-scoped dispatches keep running, their cancelled cells
skipped via cancelledAt > requestedAt.
- Stop on a filtered select-all sends the filter through cancel-runs
(contract + route + mutation) instead of a table-wide cancel.
- runColumnBodySchema rejects rowIds + filter together (mirrors
deleteTableRowsBodySchema).
- Select-all delete clears the selection in onSuccess, not at click, so
a failed kickoff restores both rows and selection.
- Clipboard copy/cut estimates use the filter-aware total (rowTotal)
instead of the whole-table rowCount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: retrigger CI (Actions dropped the previous push events)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: bump api-validation route baseline to 807 (staging route + merge)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): release job claim when trigger.dev dispatch fails
If tasks.trigger (or its dynamic imports) throws after
markTableJobRunning, the ghost running row held the table's
one-write-job slot until the stale-job janitor fired (~15-20 min of
409s). All four kickoff routes now release the claim and rethrow; the
backfill runner releases and warns (a failed backfill never fails the
schema change). Greptile P1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(db): squash 0232 into 0231 (one migration for the PR)
Both are branch-only — no environment has applied them through the
migration ledger yet — so the tenant-scoped GIN (btree_gin extension,
index swap) folds into 0231_table_jobs_and_keyset. Snapshot chain
re-pointed; drizzle-kit generate confirms zero drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): context-menu delete label shows the true select-all count
Under select-all the context menu counted only the loaded page ("Delete
1000 rows" on a 999k-row table) while the action correctly deletes every
matching row via the background job. Delete now gets its own count from
the filter-aware total minus deselections; the run-action labels keep the
loaded-row count since those actions act on loaded rows only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): context-menu bulk actions act on the full select-all scope
Follow-up to the label fix: under select-all the context menu's Run /
Re-run / Stop only acted on the loaded page of rows. They now route
through the same scopes as the action bar — runs dispatch by filter
(whole table when unfiltered), Stop uses the filter-scoped cancel — and
all labels share one true count (filter-aware total minus deselections,
locale-formatted). Like the action bar, filter-scoped runs ignore
deselections (the run API has no exclusion set).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(tables): exclusion set for select-all runs and stops
Select-all minus deselected rows now means exactly that for every bulk
action, not just delete. runColumnBodySchema and cancelTableRunsBodySchema
accept excludeRowIds (bounded by MAX_EXCLUDE_ROW_IDS, select-all scope
only); the dispatch scope persists it and the dispatcher window walk,
eager bulk-clear, pre-run cancel, and filter/table-scoped cancel all skip
excluded rows. Client threads exclusions from the selection through the
action bar and the grid context menu, including the optimistic stamps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): spare excluded-row dispatches on Stop; no orphan placeholder table
Two Bugbot findings on the exclusion work:
- Select-all-minus-deselections Stop (no filter) cancelled every active
dispatch table-wide, killing row-scoped runs on deselected rows.
markActiveDispatchesCancelled now spares dispatches whose scope.rowIds
are fully contained in the exclusion set (coalesce(false) keeps
table-wide dispatches cancellable).
- Create-mode import: a failed trigger.dev dispatch released the job
claim but left the just-created placeholder table in the workspace.
Archive it on the failure path (no hard-delete surface exists).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): row counts reflect a running delete everywhere
A mid-delete refresh resurrected the old counts: the optimistic update
stripped cached rows but left page-0 totalCount (footer / select-all
label) at the old total, and list/detail counts reported raw row_count
including doomed-but-not-yet-deleted rows.
- onMutate now sets the active view's totalCount to the kept rows and
decrements the cached detail rowCount by the doomed estimate
- the kickoff persists that estimate on the job (payload.doomedCount,
clamped server-side); getTableById/listTables subtract the
not-yet-deleted remainder (doomedCount - rows_processed) while the
delete runs, so refetched counts match the read path's delete mask
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* copy(tables): drop background mention from delete confirm
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): clear select-all immediately when a delete kicks off
The header checkbox lingered as a minus over the optimistically-emptied
grid: rowSelectionCoversAll treats zero rows as not-covered, and the
selection clear waited for the kickoff's onSuccess. Clear at click
(failed kickoffs visibly restore rows + toast; re-selecting is cheap)
and render an empty grid's header checkbox unchecked regardless — a
selection over zero rows is vacuous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tables): export takes the async path while a delete job runs
The sync/async export choice reads rowCount, which is a doomed-estimate-
adjusted number during a running delete (and the estimate is client-
supplied) — an overstated estimate could route a still-large masked set
through the synchronous stream. Mid-delete exports now always run as a
job: safe at any size, and exports bypass the one-job-per-table gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(build): stop uploads setup from sweeping the project into route graphs
next build (Turbopack) failed with "Two or more assets with different
content were emitted to the same output path" on the server-root chunk.
Root cause: setup.server.ts's unscoped path.resolve(process.cwd()) made
node-file-tracing sweep the entire project — next.config.ts included —
into every route graph reaching lib/uploads (the files/upload route and,
since the export job, the export-async path). Two producers emitted the
swept config into same-named chunks; staging's latest commits made their
contents diverge and the names collided. Annotate the path derivation
with turbopackIgnore per the NFT warning's own remediation — the build
passes and all ~390 "unexpected file in NFT list" warnings disappear.
Also inline the releaseJobClaim dynamic imports in the kickoff routes to
plain static imports — service is already statically imported there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>1 parent 75b3efa commit 53fdcab
74 files changed
Lines changed: 20902 additions & 992 deletions
File tree
- apps/sim
- app
- api
- cron/cleanup-stale-executions
- table
- [tableId]
- cancel-runs
- columns
- run
- delete-async
- export-async
- export/download
- import-async
- import
- job/cancel
- rows
- [rowId]
- upsert
- import-async
- jobs
- v1/tables/[tableId]/rows
- workspace/[workspaceId]/tables
- [tableId]
- components
- table-grid
- hooks
- components
- import-csv-dialog
- background
- hooks/queries
- lib
- api/contracts
- v1/tables
- copilot/chat
- execution/sandbox/bundles
- table
- __tests__
- uploads/core
- stores/table/import-tray
- packages
- db
- migrations
- meta
- testing/src/mocks
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| 18 | + | |
| 19 | + | |
17 | 20 | | |
18 | 21 | | |
19 | 22 | | |
| |||
110 | 113 | | |
111 | 114 | | |
112 | 115 | | |
113 | | - | |
114 | | - | |
115 | | - | |
116 | | - | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
117 | 121 | | |
118 | 122 | | |
| 123 | + | |
119 | 124 | | |
120 | | - | |
| 125 | + | |
121 | 126 | | |
122 | | - | |
123 | | - | |
124 | | - | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
125 | 131 | | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
126 | 143 | | |
127 | 144 | | |
128 | | - | |
129 | | - | |
| 145 | + | |
| 146 | + | |
130 | 147 | | |
131 | 148 | | |
132 | | - | |
133 | | - | |
134 | | - | |
135 | | - | |
136 | | - | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
137 | 163 | | |
138 | 164 | | |
139 | | - | |
| 165 | + | |
140 | 166 | | |
141 | 167 | | |
142 | 168 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
46 | 52 | | |
47 | 53 | | |
48 | 54 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
73 | 77 | | |
74 | 78 | | |
75 | 79 | | |
| |||
146 | 150 | | |
147 | 151 | | |
148 | 152 | | |
149 | | - | |
150 | | - | |
151 | | - | |
152 | | - | |
153 | | - | |
154 | | - | |
155 | | - | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | | - | |
161 | | - | |
162 | | - | |
163 | | - | |
164 | | - | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
165 | 168 | | |
166 | 169 | | |
167 | 170 | | |
| |||
211 | 214 | | |
212 | 215 | | |
213 | 216 | | |
214 | | - | |
215 | | - | |
216 | | - | |
217 | | - | |
218 | | - | |
219 | | - | |
220 | | - | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
221 | 223 | | |
222 | 224 | | |
223 | 225 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
| 28 | + | |
| 29 | + | |
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
32 | 37 | | |
33 | 38 | | |
34 | 39 | | |
35 | 40 | | |
36 | 41 | | |
37 | 42 | | |
| 43 | + | |
| 44 | + | |
38 | 45 | | |
39 | 46 | | |
40 | 47 | | |
| |||
0 commit comments