Repository navigation
Bulk update on a 100-row list takes ~100 s: every per-row update refetches and re-renders the whole list, and bulkUpdate drops per-row errors so apps can't avoid the per-row path #13442
Description
Summary
A list bulk action with operation: 'update' over 100 selected rows takes ~100 s in the console. The server work for those 100 rows is only ~19 s. About 80% of the wall time is main-thread long tasks in the browser, caused by two problems that compound each other:
- Every per-row
dataSource.update()refetches and re-renders the whole bound list.ObjectStackAdapter.update()emitsonMutationfor every row.ListView(andObjectView) bumprefreshKeyon each event, so a 100-row bulk run produces roughly 100 refetches of the 100-row page (with all lookuppopulates) and 100 full re-renders of the grid.useBulkExecutorawaits the rows one by one, so it can't send the next request until the main thread is free. bulkUpdatethrows away per-row results. The server'sPOST /data/:object/updateMany(continueOnError: true) already runs hooks per row, isolates failures, and returnsresults[].errors[].messagefor each failed row.ObjectStackAdapter.bulkUpdate()reads onlysucceeded. The executor then reports a single synthetic error,batch_0: N records failed in bulk update, so the error CSV has no record ids or reasons. Apps that need per-row failure reasons (business-rule rejections shown to users) are pushed intobatchSize: 1, and that path hits problem 1 at full cost.
Problem 2 is why apps end up on the slow path, and problem 1 is what makes that path slow.
Measurements
Setup: @objectstack/* 17.3.0, local dev server (SQLite), headless Chromium. A custom object has about 15 beforeUpdate/afterUpdate hooks. The list view has pageSize: 100 and about 10 lookup columns. The bulk action is { operation: 'update', patch: { push_now: true }, batchSize: 1 }. Select all 100 rows → run.
| Scenario | 100 rows |
|---|---|
Console bulk action, batchSize: 1 (current) |
102–111 s. 228 long tasks totalling 93 s (max 1.3 s). 100 PATCHes = 21.5 s of server time. 57 extra full-page list GETs. The gaps between consecutive PATCHes add up to 87.6 s. |
Same 100 PATCHes sent serially with fetch from the same page (no onMutation) |
18.9 s |
Console bulk action, batchSize: 1, with the onMutation refresh subscriptions in ListView / ObjectView stubbed out (via request interception) |
21.4 s, 2 long tasks |
POST updateMany with continueOnError: true, 102 rows (2 invalid on purpose) |
2.4 s. Hooks ran per row. Response has succeeded: 100, failed: 2 and per-row results[i].errors[0].message with the business reason. |
Console bulk action with batchSize: 100 |
4.1 s. The dialog shows batch_0: 2 records failed in bulk update, and the error CSV has no record ids or reasons. |
On a production deployment (bigger pages, more columns, real network latency) the same action takes ~7 minutes per 100 rows (~4.2 s/row).
Code pointers (objectui main @ 7981384)
packages/data-objectstack/src/index.ts,update(): callsemitMutationper call.packages/data-objectstack/src/index.ts~L4386,bulkUpdate():return emitBulk(res.succeeded).res.results[]is ignored, and the same applies tobulkDelete().packages/plugin-list/src/ListView.tsx~L1850:onMutation→setRefreshKey(k => k + 1)on every event, with no batching or deferral.packages/plugin-view/src/ObjectView.tsx~L1276: the same for non-renderListViewviews, plusnotifyDataChangedper event.packages/plugin-grid/src/hooks/useBulkExecutor.ts+packages/core/src/actions/bulkFastPath.ts:perRowgoes throughdataSource.update()for each row. WhenbulkCallresolves with a count below the batch size, the shortfall is reported as onebatch_${offset}error.
The published @objectstack/console@18.1.0 bundle has the same behaviour.
Suggested fix
- Return per-row results from
bulkUpdate/bulkDeleteand surface them in the executor. For example,bulkUpdatecould return{ succeeded, errors: [{ id, error }] }built fromresults[].errors[].message(keeping the plain-number return for adapters without per-row results).runBulkWithFastPath/useBulkExecutorwould then push those intoerrors, so Retry and "Download error CSV" work per record on the bulk path. With that in place, apps can dropbatchSize: 1and 100 rows should take a few seconds. - Don't refresh bound views per row during a bulk run. For example, let the executor run row writes with mutation events suppressed (a
silent/batchoption onupdate/delete, or adataSource.batchMutations(fn)scope), then emit one mutation (or rely on the existingRelatedCountStore.invalidate+ final refresh) when the run finishes. Alternatively, haveListView/ObjectViewcoalesceonMutationrefreshes (debounce orrequestIdleCallback). That would help any other code that writes many rows in a loop.
Either change alone helps a lot: (1) removes the reason apps use batchSize: 1, and (2) makes the per-row path cost about what the server work costs. Both together also give per-row progress without the re-render storm.
Expected
- 100-row bulk update/delete finishes in about the time of the server work, not 5× it.
- Failed rows keep their own id and server error message in the result dialog and the error CSV, whatever the
batchSize.
Downstream report: steedos-labs/os-project-titanwind-ehr#1995
Activity
objectstack-fleet commentedon Oct 24, 2026
Triage: first grade —
bug·priority:p1·domain:ui·area:records·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-24T10:55Z⛔ Not a claim, ⛔ not a dispatch.
domain:ui. Both halves are in objectui: the adapter'sbulkUpdate/bulkDelete(packages/data-objectstack), the executor (plugin-grid/core), and the per-event refresh inListView/ObjectView. The server'supdateManyalready returnsresults[].errors[], so no objectstack change is needed. If theDataSourcereturn type inpackages/typeswidens, that rides the same PR and keeps the plain-number return for other adapters.batchSize: 1. Then fix (2), coalescing the refreshes. One PR or two is the seat's call.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim: PM loop round 1
Session:
session_01432GBvPzfZ6CsLanp9xzkZAccount:
sunhaolinBranch:
claude/issue-13442-bulk-per-row-resultsWorktree:
objectui-issue-13442Domain:
domain:uiSeat:
domain:ui#1File surface:
packages/data-objectstack/src/**,packages/core/src/actions/**,packages/plugin-grid/src/hooks/**,packages/plugin-list/src/ListView.tsx,packages/plugin-view/src/ObjectView.tsx,packages/types/src(theDataSourcebulk return type only), their tests,.changeset/13442-bulk-per-row-results.md(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default (dispatch-gates --tier: no path-derived mandate),effort: xhighClause-②: yes
Metadata-protocol: none
Responsibility:
objectui's own code (data-objectstack adapter bulkUpdate/bulkDelete drop results[], ListView/ObjectView refetch per onMutation event) | the server's updateMany with continueOnError already returns per-row results[].errors[] | every console bulk action reaches it; a customer deployment reports ~7 min per 100 rows (steedos-labs/os-project-titanwind-ehr#1995)Thread-read: 6383357215
Premise: holds
Serial constraints cleared: none (no open code PR in objectui at 2026-10-24T16:50Z; shares packages
coreandtypeswith objectui#13450 in this batch, on disjoint files)Clause-②: yes— 拿不准 ⇒ 按 yes: the likely route widens the publicDataSourcebulk return type in@object-ui/types(per-row errors beside the count). If the dev's route leaves the published surface unchanged, the report says so with the built declaration as evidence; the declaration still stands for the review.Premise reading at objectui
origin/maina3c0da9:return emitBulk((res as any).succeeded as number);atpackages/data-objectstack/src/index.ts:4410;setRefreshKey(k => k + 1);inside the mutation subscription atpackages/plugin-list/src/ListView.tsx:1853.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{
"issue": 13442,
"status": "done",
"branch": "claude/issue-13442-bulk-per-row-results",
"pr": "#13474",
"session": "session_01432GBvPzfZ6CsLanp9xzkZ",
"premise_still_valid": true,
"summary": "One PR with both halves, as the seat ruled. (1) ObjectStackAdapter.bulkUpdate/bulkDelete now resolve to a BulkWriteResult { succeeded, errors: [{ id, error }] } read off the server's results[].errors[] (a failed row is attributed by its request index, else its id; a kept deleteMany row with no errors[] gets a generic reason). executeBulkBatch reports each named failure under its own id, and only unnamed failures still fold into batch_N. So the dialog, error CSV, Retry and Undo work per record whatever the batchSize. (2) New optional DataSource.batchMutationEvents(work) holds onMutation events and delivers them coalesced, one per (type, resource), when work settles; useBulkExecutor wraps every run and undo in it. H1-H3 hold. H4 was decided by measurement for source-side holding: it reaches all six onMutation subscriber paths (ListView, ObjectView, kanban, calendar, useDataRefresh, the console bridge to the bus), and end to end it measured 1 list refetch per 25-row batchSize-1 run vs 22 with the scope removed. ListView.tsx and ObjectView.tsx were therefore NOT edited. The published surface widened (Clause-2 yes holds; see the PR's Clause-2 surface section). The DataSource bulk return is the union number | BulkWriteResult, so number-returning adapters still compile, proven against the rebuilt types dist. ObjectStackAdapter.bulkUpdate/bulkDelete themselves now return the object, which breaks direct callers that read a count; there is no such caller in-tree, and the changeset states it. Card assignee sunhaolin was already set and not written. origin/main had not moved, so the instructed merge was a no-op; objectui#13450's files are disjoint.",
"tests": "Head 90d28e7. Full suites of the touched packages, under the lock:
pnpm exec vitest run --maxWorkers=2 packages/types/ packages/core/ packages/data-objectstack/ packages/plugin-grid/gives VERDICT command-exit 0, Test Files 946 passed (946), Tests 18065 passed | 27 skipped (18092). New tests: data-objectstack bulkPerRowResults-13442.test.ts (real adapter + real @objectstack/client, only fetch stubbed): a 2-failure updateMany gives 2 per-record errors with ids+messages; index attribution; joined reasons; deleteMany refused/kept rows; 6 holding cases. plugin-grid bulkRunPerRowResults-13442.test.tsx: a 100-row batchSize-100 run with 2 rejections sends one updateMany and no PATCH, and result.errors is exactly the two ids+messages; the refetch count is asserted exactly 1 for 25 per-row writes (real adapter, real executor, real ObjectGrid behind the real useMutationInvalidationBridge). Also useBulkExecutor.test.ts (+4) and bulkFastPath.test.ts (+5); bulkUpdate/bulkDelete.test.ts moved to the new shape. First ablation finding: the refetch pin was vacuous inside act() with a microtask-only fetch stub (green with the scope removed). It was reworked to run outside act on React's scheduler with timer-latency PATCHes. Ablations (objectstack scripts/ablation-replace.mjs WRAP mode, anchor 1->0, each restore proven: blob == HEAD and git diff HEAD empty; no build needed because vitest aliases @object-ui/* to src): A executor scope off gives the refetch pinexpected 22 to be 1plus the scope unit pin red. B adapter attribution off: 5 red. C core attribution off: 7 red. D adapter holding off: 5 red, incl.expected 22 to be 1. Reverse type check against the rebuilt packages/types/dist:const n: number = await ds.bulkUpdate!(...)gives TS2322 'number | BulkWriteResult' not assignable to 'number', and abulkUpdate: async () => 3implementor compiles in the same program.","gates": "Local subset, exit codes captured to a file before reading. ① build closure
pnpm --filter @object-ui/plugin-grid^... --filter @object-ui/data-objectstack^... --filter @object-ui/data-objectstack run build: VERDICT 0. type-check of types/core/data-objectstack: Done at 677b254 (their sources are unchanged since; only the adapter README changed). plugin-grid type-check (tsc --noEmit + tsconfig.test.json): exit 0 at 7609f9d. The reworked integration test gottsc -p packages/plugin-grid/tsconfig.test.json: exit 0 at head. That last run is a DECLARED NARROWING, unlocked: two full 4-package type-check attempts at head were queue-timeout (exit 99, NOT MEASURED), because the lock was held for about 20 min by sibling pid 28227 (scratchpad issue-13449/verify-all.sh). lint (4 pkgs, eslint .) exit 0, 0 errors; the repo sets no --max-warnings. Exit 0: check-changeset-presence, check-changeset-no-major, check:new-line-citations ('0 new citation(s)'), check:control-bytes, check:test-path-roots, check:pending-changeset-literals, check:changeset-claims, check:doc-fences, check:doc-json, check-governed-queue-guard --test on 14 paths (NOT GOVERNED). NOT MEASURED: check:readme-exports, exit 1 = collapsed scan on 23 unbuilt packages, a precondition not a verdict, and the README edit adds no import. NOT MEASURED: check:doc-snippets (needs a near-whole-repo build). Instead, both new guide snippets compiled --strict against the built @object-ui/types dist: exit 0. objectui has no dispatch-gates.mjs, so the list above is hand-derived from package.json check:* and the doc/changeset gates that read the changed files, and there is no --ran reconciliation. CI check-runs at 90d28e7 when read once: 35 total = 8 success, 3 skipped, 24 in_progress. Status in_progress; not waited on.","dogfood_battery": "n/a: no
!in the PR title and no (narrowing) arm on Clause-2","mcp_calls": "0",
"api_writes": "2 relay strokes (each POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, executed as POST /repos/objectstack-ai/objectui/pulls (draft) + POST /repos//issues/13474/assignees, read back as identical (8691/8691 bytes) with assignee sunhaolin; (2) this os-dev-report comment, POST /repos//issues/13442/comments. git push is not counted (not REST).",
"line_budget": "n/a: no skills/** or ratcheted ledger touched",
"deviations": [
"Landing beyond the claim's file surface: content/docs/guide/data-source.md (+34) and packages/data-objectstack/README.md (+3). Both are docs only, required by objectui AGENTS.md commandment #2 for the public API change, and held by no other claim. Not reverted; flagged here and in the PR's Acceptance notes.",
"Claimed but not edited: packages/plugin-list/src/ListView.tsx and packages/plugin-view/src/ObjectView.tsx. The H4 measurement chose source-side holding, which needs no view change.",
"Final 4-package type-check at head narrowed to plugin-grid tsconfig.test.json, unlocked, after two lock queue-timeouts (exit 99) behind sibling pid 28227. The other packages' sources are unchanged since their green type-check at 677b254.",
"check:readme-exports and check:doc-snippets are NOT MEASURED locally (unbuilt-tree precondition); left to CI, with a strict compile of the two new snippets as a substitute.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and an emoji PR footer. Commits use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude) and the PR uses os-dev's session-URL footer, per their precedence. Reported, not a conflict."
],
"files_changed": [
".changeset/13442-bulk-per-row-results.md (+18)",
"content/docs/guide/data-source.md (+34)",
"packages/core/src/actions/bulkFastPath.ts (+53 -19)",
"packages/core/src/actions/tests/bulkFastPath.test.ts (+90)",
"packages/data-objectstack/README.md (+3)",
"packages/data-objectstack/src/index.ts (+177 -45)",
"packages/data-objectstack/src/bulkPerRowResults-13442.test.ts (+254, new)",
"packages/data-objectstack/src/bulkUpdate.test.ts (+12 -12)",
"packages/data-objectstack/src/bulkDelete.test.ts (+11 -11)",
"packages/plugin-grid/src/hooks/useBulkExecutor.ts (+141 -107; +42 -8 ignoring the re-indent of the wrapped loop)",
"packages/plugin-grid/src/tests/useBulkExecutor.test.ts (+130)",
"packages/plugin-grid/src/tests/bulkRunPerRowResults-13442.test.tsx (+208, new)",
"packages/types/src/data.ts (+81 -10)",
"packages/types/src/index.ts (+2)"
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none. Noted, not filed: ValueDataSource (packages/core/src/adapters) implements onMutation but not the new optional batchMutationEvents, so a write loop against it still notifies once per write. It is optional by contract and outside the claim's file surface.",
"carrier: none. Noted, not filed (dormant): ObjectStackAdapter.bulkDelete's per-id fallback, which runs only when the client has no deleteMany, counts a { success: false } delete answer as deleted. The pinned @objectstack/client has deleteMany, so no current producer reaches it.",
"carrier: none. Noted, not filed (observation): in the console a bulk run now costs at most two list refetches, the held event's release plus ObjectGrid's existing dialog-Done refetch."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 25, 2026
ACCEPT → PR #13474 comment 6392970692 · contract review PASS → PR #13474 comment 6392965643 · head
90d28e7· ready + auto-merge armed 2026-10-25T03:30ZGenerated by Claude Code
objectstack-fleet commentedon Oct 25, 2026
LANDED — PR #13474
domain:uiseat 1 ·session_01432GBvPzfZ6CsLanp9xzkZ· 2026-10-25T04:01Zmerged: true, merge commitee4f1ef16, through the merge queue (added_to_merge_queueon the timeline)origin/maintipee4f1ef16 fix(bulk): keep per-row bulk results and refresh bound views once per run (#13474)origin/maingit grep -c batchMutationEvents:packages/types/src/data.ts1,packages/data-objectstack/src/index.ts6,packages/plugin-grid/src/hooks/useBulkExecutor.ts6;bulkWriteResultFromin the adapter 4State: the card closed
completedthroughFixes #13442. In the same actpm:dispatchedwas removed (it is a state label;domain:ui,area:recordsandpriority:p1stay). Publishing is the changesets release PR, a human action. The fix reaches users when@object-ui/*next releases, and reaches objectstack's console at its next.objectui-shabump.Generated by Claude Code
fix(bulk): keep per-row bulk results and refresh bound views once per…