Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

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

Closed

Description

@baozhoutao

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:

  1. Every per-row dataSource.update() refetches and re-renders the whole bound list. ObjectStackAdapter.update() emits onMutation for every row. ListView (and ObjectView) bump refreshKey on each event, so a 100-row bulk run produces roughly 100 refetches of the 100-row page (with all lookup populates) and 100 full re-renders of the grid. useBulkExecutor awaits the rows one by one, so it can't send the next request until the main thread is free.
  2. bulkUpdate throws away per-row results. The server's POST /data/:object/updateMany (continueOnError: true) already runs hooks per row, isolates failures, and returns results[].errors[].message for each failed row. ObjectStackAdapter.bulkUpdate() reads only succeeded. 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 into batchSize: 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(): calls emitMutation per call.
  • packages/data-objectstack/src/index.ts ~L4386, bulkUpdate(): return emitBulk(res.succeeded). res.results[] is ignored, and the same applies to bulkDelete().
  • 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-renderListView views, plus notifyDataChanged per event.
  • packages/plugin-grid/src/hooks/useBulkExecutor.ts + packages/core/src/actions/bulkFastPath.ts: perRow goes through dataSource.update() for each row. When bulkCall resolves with a count below the batch size, the shortfall is reported as one batch_${offset} error.

The published @objectstack/console@18.1.0 bundle has the same behaviour.

Suggested fix

  1. Return per-row results from bulkUpdate / bulkDelete and surface them in the executor. For example, bulkUpdate could return { succeeded, errors: [{ id, error }] } built from results[].errors[].message (keeping the plain-number return for adapters without per-row results). runBulkWithFastPath / useBulkExecutor would then push those into errors, so Retry and "Download error CSV" work per record on the bulk path. With that in place, apps can drop batchSize: 1 and 100 rows should take a few seconds.
  2. 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/batch option on update/delete, or a dataSource.batchMutations(fn) scope), then emit one mutation (or rely on the existing RelatedCountStore.invalidate + final refresh) when the run finishes. Alternatively, have ListView / ObjectView coalesce onMutation refreshes (debounce or requestIdleCallback). 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

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Oct 24, 2026
  2. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    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.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1
    Session: session_01432GBvPzfZ6CsLanp9xzkZ
    Account: sunhaolin
    Branch: claude/issue-13442-bulk-per-row-results
    Worktree: objectui-issue-13442
    Domain: domain:ui
    Seat: domain:ui#1
    File 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 (the DataSource bulk 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: xhigh
    Clause-②: 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 core and types with objectui#13450 in this batch, on disjoint files)

    Clause-②: yes — 拿不准 ⇒ 按 yes: the likely route widens the public DataSource bulk 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/main a3c0da9: return emitBulk((res as any).succeeded as number); at packages/data-objectstack/src/index.ts:4410; setRefreshKey(k => k + 1); inside the mutation subscription at packages/plugin-list/src/ListView.tsx:1853.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    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 pin expected 22 to be 1 plus 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 a bulkUpdate: async () => 3 implementor 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 got tsc -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

  5. objectstack-fleet commented on Oct 25, 2026

    @objectstack-fleet
    Contributor

    ACCEPT → PR #13474 comment 6392970692 · contract review PASS → PR #13474 comment 6392965643 · head 90d28e7 · ready + auto-merge armed 2026-10-25T03:30Z


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 25, 2026

    @objectstack-fleet
    Contributor

    LANDED — PR #13474

    domain:ui seat 1 · session_01432GBvPzfZ6CsLanp9xzkZ · 2026-10-25T04:01Z

    probe reading
    PR #13474 merged: true, merge commit ee4f1ef16, through the merge queue (added_to_merge_queue on the timeline)
    origin/main tip ee4f1ef16 fix(bulk): keep per-row bulk results and refresh bound views once per run (#13474)
    content on origin/main git grep -c batchMutationEvents: packages/types/src/data.ts 1, packages/data-objectstack/src/index.ts 6, packages/plugin-grid/src/hooks/useBulkExecutor.ts 6; bulkWriteResultFrom in the adapter 4

    State: the card closed completed through Fixes #13442. In the same act pm:dispatched was removed (it is a state label; domain:ui, area:records and priority:p1 stay). 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-sha bump.


    Generated by Claude Code

  7. added a commit that references this issue on Oct 28, 2026
    ee4f1ef
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions