Skip to content

perf/reliability: seed + data-import bypass the engine's bulk-insert path → N round-trips, N summary recomputes, no transient retry #2678

Closed

Description

@os-zhuang

Summary

The two bulk-write paths — the seed loader (defineDataset replay) and the user-facing data import (CSV / bulk records) — write records one at a time through the single-record engine path, even though ObjectQL's engine already has an efficient batched branch (driver.bulkCreate + parent-deduplicated summary recompute). Over a remote DB (turso) this makes both:

  • slow — N round-trips + N summary recomputes instead of ~N/batch, and
  • fragile — each round-trip can hit a transient network error that is caught → the row is dropped → no retry.

Surfaced while debugging the 2026-07-06 HotCRM first-boot incident: a HotCRM seed's crm_contact inserts intermittently dropped rows on transient turso errors → the seed reported errors → the runtime's seed-once stamp never landed → per-env kernel churn (re-seed every boot). Non-deterministic (network-sensitive): the same seed inserted 5/5 contacts on one env and 1/5 on another.

Evidence

Seed loader — per record

packages/metadata-protocol/src/seed-loader.ts — loadDataset() loops per record calling this.engine.insert(objectName, record, opts) with a single record. No array / bulk form is ever constructed.

Data import — per record

Entry points (packages/rest/src/rest-server.ts):

  • POST /data/:object/import (sync, ≤5k rows) — ~L3524
  • POST /data/:object/import/jobs (async, 5k–50k, persists sys_import_job) — ~L3598
  • POST /data/:object/createMany (optional bulk endpoint, may be unwired) — ~L5749

Both sync + async delegate to packages/rest/src/import-runner.ts runImport(), which loops per row calling p.createData({...}) / p.updateData({...}) with a single record (~L201–274). It does not construct arrays or use the engine's bulk branch.

The engine ALREADY has the efficient bulk primitive

packages/objectql/src/engine.ts:

  • insert(object, data: any | any[]) — the Array.isArray(hookContext.input.data) branch (~L2158) routes to driver.bulkCreate(...) — one round-trip per batch.
  • recomputeSummaries() (~L1746–1782) deduplicates parent ids across the whole array and recomputes once per unique parent (1000 children of the same parent = 1 rollup recompute, not 1000). Called once per insert() call (~L2206).

Because neither seed nor import passes arrays, both get N single-record insert() calls → N round-trips + N recomputeSummaries() invocations.

No transient-error retry (silent row loss)

  • import-runner.ts ~L260: catch → results.push({ ok:false, action:'failed', ... }) → continue. No retry.
  • seed-loader.ts ~L413: catch → errors.push(...) → continue. No retry.

A transient driver/network blip (fetch failed / timeout / connection reset) on one row silently drops that row. Import returns a per-row report, so it's visible, but there's no automatic recovery.

Impact

Import 1000 rows into an object with a rollup summary, over a remote turso DB (~100ms/round-trip):

  • 1000 inserts + up to 1000 summary recomputes = 2000+ round-trips ≈ 3+ minutes, plus a handful of transient failures that drop rows.
  • With K rollup parents: 1000 inserts + K×1000 aggregate queries.

The seed variant additionally triggered kernel churn in the HotCRM incident (dropped seed rows → errs>0 → the cloud seed-once stamp never lands → re-seed every cold boot).

Proposed fix

A shared batched-write helper used by BOTH seed-loader and import-runner:

  1. Batch rows (e.g. 100–500/batch) and call the engine's array/bulk insert → driver.bulkCreate (one round-trip per batch). The engine already dedups summary recomputes for arrays.
  2. Retry TRANSIENT driver errors (network / timeout / connection reset) per batch with backoff. Do not retry logical errors (validation / constraint).
  3. On a batch that fails on a logical error, degrade to per-row within that batch so one bad row doesn't drop the whole batch (bad row reported, others succeed).
  4. (Optional) collapse summary recompute to once per parent at the end of the whole load.

Related runtime-side follow-up (cloud, tracked separately): the seed-once stamp guard is errs===0 — a single transient error blocks the stamp forever → churn; relax to stamp-on-progress + surface residual errors via reportObserved.

Acceptance criteria

  • Seed + import route through the shared bulk helper; a 1000-row load over a bulkCreate-capable driver issues ~ceil(N/batch) writes, not N.
  • Summary-recompute count is bounded by unique parents, not row count.
  • A transient driver error is retried and does not drop the row; a logical error is reported per-row without failing the batch.
  • Tests: bulk-path write-count assertion, transient-retry, logical-error per-row degradation, summary-dedup.

Refs

Activity

  1. added a commit that references this issue on Jul 8, 2026
    21420d9
  2. baozhoutao commented on Jul 17, 2026

    @baozhoutao
    Contributor

    #2678 / PR #2680 批量写入路径 — 补充测试报告(2026-07-17,main @ 021ba4c)

    由 Claude(AI 测试)执行:正式套件全量回归 + 探针场景注错 + showcase 真机浏览器 UI 实测。探针测试为一次性脚本,运行取证后已删除,未进入任何分支。发现的缺陷已按独立 issue 重新立项(见文末链接汇总,由后续评论补充)。

    一、结论摘要

    问题一(批量写入与汇总去重):通过。 批次切分、批次封顶、汇总按唯一父去重、混父批次正确性、降级收敛全部符合设计。

    问题二(暂时性错误重试):部分通过。 批量主路径、import update 路径、seed upsert 更新路径的重试均有效;但 4 个已知未修缺陷(#2803/#2804/#2805/#2806)在当前 HEAD 全部复现,另新发现 8 个问题(6 个后端 + 2 个前端 UI),合计 12 项缺陷(见第四、七节)。

    真机 UI 实测(第七节): 全新 SQLite 冷启动的 showcase 上,种子加载、导入向导、坏行隔离、服务端 dry-run、汇总重算均通过并有截图证据;#2806 在最新 framework + 最新 objectui 上 UI 级复现;另实测发现两个 objectui 缺陷——必填未映射时向导禁用无提示(缺陷 11)、旧版 console 静默降级路径把原始自然键写进 FK 且报成功(缺陷 12,数据损坏级)。

    hook / 流程自动化 / 公式 / 累积汇总与批量路径的交互: 逐行 hook 语境(#2922)、skipTriggers/skipAutomations 抑制、流程逐行触发均正确;但 beforeInsert 类 hook 在批降级时双跑(缺陷 7),汇总重算失败被静默吞掉(缺陷 10)。公式为读时虚拟计算,与写入路径无交互,无需测试。

    二、正式测试套件结果(全绿)

    包 套件数 / 用例数 结果
    @objectstack/core(含 bulk-write.test.ts) 23 / 356 ✅ 全过
    @objectstack/rest(含 import-runner-bulk.test.ts) 16 / 298 ✅ 全过
    @objectstack/objectql 67 / 904 ✅ 全过
    @objectstack/metadata-protocol 5 / 34 ✅ 全过
    @objectstack/runtime(seed-loader 41 + http-dispatcher 184) — ✅ 全过

    ⚠️ 环境陷阱(非产品缺陷):runtime 的 seed-loader 套件首跑 1 失败,根因是 worktree 内 metadata-protocol/objectql 的 dist 为 7/16 旧产物,而 #3097 修复 7/17 才合入;runtime 的 vitest 配置不为 @objectstack/objectql 设 src alias,测试吃 dist。pnpm turbo build --filter=@objectstack/objectql... 后全绿。worktree 首次跑 runtime 测试必须先 build。

    三、场景矩阵与结果

    A. bulkWrite 核心助手(packages/core)

    场景 结果
    A1 批次切分:1000 行/批 200 → 5 次 writeBatch、0 次 writeOne;非整除余批;空数组;batchSize≤0 钳 1 ✅
    A2 transient 重试:首败退避重试成功不降级;耗尽后批>1 降级逐行、批=1 直接判负不重复 writeOne ✅
    A3 逻辑错误:整批不重试、逐行隔离,坏行单独报错、好行成功,降级中每行仍享重试 ✅
    A4 错误分类:fetch failed/ETIMEDOUT/ECONNRESET/502·503·504/unavailable→transient;constraint/validation→非 transient ✅
    A4' 关键词误判边界 ❌ 缺陷 6
    A5 writeBatch 返回值契约(少/多/乱序/非数组) ❌ 缺陷 5(=#2803)

    B. import-runner(packages/rest)

    场景 结果
    B1 批次数 = ceil(N/min(progressEvery,200));progressEvery=1000+500 行 → [200,200,100] ✅
    B2 无 createManyData 回退逐行 createData 的 transient 重试 ❌ 缺陷 4
    B3 update 模式 updateData 一次 fetch failed → 重试成功不丢行 ✅
    B4 upsert 混排:匹配行即时 update、未匹配行缓冲批量 create,结果严格按输入行序 ✅
    B5 检查点先 flush 再报进度;取消时缓冲行不丢(cancelled=true、已 flush 行保留);undoLog 收齐批量 created ids ✅
    B6 同文件 lookup:行 3 引用行 2 创建的记录 ❌ 缺陷 2(=#2806)
    B6' refCache 负缓存:flush 之后同名引用仍失败 ❌ 缺陷 2 附加
    B7 dryRun:0 次写入,行级 created 判定正确 ✅
    坏行隔离(如非法数字):只废本行,批不失败 ✅

    C. seed-loader(packages/metadata-protocol)

    场景 结果
    C1 无自引用数据集批量 flush;201 行 → [200,1] 两批;externalId→id 从批量返回回填 ✅
    C2 单行批保持裸 record 形状(兼容历史) ✅(套件覆盖)
    C3 自引用数据集顺序路径解析正确,但写失败无重试 ❌ 缺陷 3(=#2804)
    C4 upsert 更新路径 withTransientRetry:一次 fetch failed → 重试成功,无 error ✅
    C5 pass-2 deferred 引用更新失败 ❌ 缺陷 1(=#2805)
    C6 不可解析引用 → 丢行但 LOUD 报错;对象包装引用守卫(#3097) ✅
    空数据集 → 0 写入、success ✅

    D. 引擎 array-insert 分支(packages/objectql)

    场景 结果
    D1 array → driver.bulkCreate 单往返;summary 按唯一父去重(前期 QA 实测 1000 行 5 批/10 父 50 次,本轮套件+探针复核) ✅
    H1 defaults→beforeInsert→校验→afterInsert 逐行执行;hook 改 ctx.input.data 派生字段落库;每行恰 1 次 ✅
    H2 批降级时 beforeInsert 双跑 ❌ 缺陷 7
    H3 驱动 commit 后报错 + 重试 → 重复插入 ❌ 缺陷 8
    H4 驱动短返回 → 静默 undefined 补位 ❌ 缺陷 9

    T. 流程自动化 × 批量路径

    场景 结果
    T1 seed {skipTriggers:true} 逐行传至 ctx.session,record-change 触发器全抑制 ✅
    T2 import {skipAutomations:true}:metadata 绑定 hook(flow)被引擎跳过,代码注册 hook(审计/安全)照常 ✅
    T3 自动化开启:流程每行恰触发 1 次 ✅
    T4 校验降级路径:afterInsert 类流程触发器只触发 1 次(失败批死在校验,未达 afterInsert)——H2 双跑仅限 beforeInsert 类 hook 与 autonumber ✅(重要澄清)

    S. 累积汇总 × 批量路径

    场景 结果
    S1 一批混两父 + 无父行:各父汇总精确、无父行不污染 ✅
    S2 父汇总 update 一次网络错 ❌ 缺陷 10
    S3 降级逐行后汇总收敛正确 ✅

    公式字段

    读时虚拟计算(find 投影时求值),插入路径不参与 → 与批量写入无交互,无测试面。

    四、缺陷清单(按严重度排序)

    # 严重度 关联 issue 摘要
    1 高 #2805(OPEN) pass-2 deferred 引用更新失败:无重试、错误不进 errors、seed 报 success。循环引用静默丢关系,seed-once stamp 照落 → 永久缺失
    2 高 #2806(被 NOT_PLANNED 关闭) 同文件前行创建的记录,后行 lookup 解析失败(缓冲未落库)。#2680 引入的确定性回归。附加:refCache 负缓存空匹配,flush 后也永远失败
    3 中高 #2804(无评论关闭) 自引用种子顺序路径 writeRecord 无 transient 重试,一次网络错即丢行(LOUD 但不重试)——HotCRM 事故形态在自引用数据集残留
    4 中高 新发现 无 createManyData 的回退逐行 createData 路径无重试(import-runner.ts L364);同处 update 路径有重试,不对称
    5 中 #2803(被 NOT_PLANNED 关闭) bulkWrite 对 writeBatch 返回值零防御:少→缺行仍 ok:true 无 record;多→静默丢;乱序→id 错配(污染 undoLog/externalId 映射);非数组→整批假成功。缓解:仓库内 3 驱动均保序保量,现实风险限第三方驱动
    6 低 #2802 边界 transient 分类按关键词匹配:column network_id…、…row 503、CHECK constraint failed: network_zone 等逻辑错误被误判 transient。后果仅多重试两轮,不丢数据
    7 中 新发现 批降级时 beforeInsert hook 对好行双跑(批尝试 + 降级重跑),autonumber 双消耗留号段空洞;数据本身只落一次。副作用型 hook(通知/计数/外调)受害。afterInsert/流程不受此路径影响(T4)
    8 高 新发现 at-least-once 重复插入:驱动 commit 后响应丢失(turso 类 HTTP 驱动真实场景)→ transient 重试整批再插一遍,2 行入 4 行、全部报成功,无幂等键;批量把重复面从 1 行放大到 200 行,重复行还会各触发一次流程、双计汇总
    9 中 新发现 驱动返回记录数不足时,engine.insert 按输入行数补 undefined 返回(engine.ts L2330),afterInsert 拿 undefined 不报错;下游 seed externalId 映射静默缺失、import 行报 created 无 id。#2803 在引擎层的镜像
    10 高 新发现 汇总重算失败被吞:recomputeSummaries 对每父 aggregate+update 包 try/catch 仅 warn,不抛不重试。实测子记录批量插入报全成功、父汇总永不落。与 #2678 初衷直接冲突:行不丢了,同样的网络抖动改为静默留下错误的父汇总,seed/import 层重试罩不到(在 engine.insert 内部)。#2805 同款"warn 即成功"模式

    共性根因归纳:① "吞错报成功"模式(缺陷 1、10,及 9 的静默补位)——失败只 warn 不上报;② 重试覆盖不全(缺陷 3、4,及 1、10 的无重试)——withTransientRetry 只包了部分写点;③ 无幂等/契约防御(缺陷 5、8、9)——重试语义是 at-least-once 但无去重手段,批量响应无校验。

    五、修复建议(按投入产出排序)

    1. 缺陷 1+10 合并修(同模式):deferred 更新与 recomputeSummaries 补 withTransientRetry;最终失败必须计入 errors/result(至少给调用方信号)。
    2. 缺陷 3+4 合并修(同模式):writeRecord 与回退 createData 补 withTransientRetry,与既有 update 路径对齐。
    3. 缺陷 2:flush 时机感知 lookup——resolveRef 未命中且 pendingCreates 非空时先 flush 再查一次;refCache 不缓存空匹配(或 flush 后失效)。
    4. 缺陷 8:批级幂等键(如 seed externalId / import 行指纹做 upsert 语义),或至少在文档标注 at-least-once 语义并建议驱动侧幂等。
    5. 缺陷 5+9:bulkWrite 校验返回数组且长度相等,不符即降级逐行;engine array 分支同样校验驱动返回。
    6. 缺陷 6:分类改为"结构化错误码优先、消息正则兜底",约束/校验类短路为非 transient。

    六、对 QA 测试资产的建议

    现有 QA 对象(qa_seed_item / qa_import_item / qa_summary_*)全为裸字段,测不出缺陷 7/8/10 类问题。建议补充:

    1. 带副作用计数 beforeInsert hook + required 字段 + autonumber 的变体对象(暴露双跑与号段空洞);
    2. 可注入 "commit 后抛错"、"父汇总 update 单点抛错"、"短返回/乱序返回"的驱动 fixture(暴露 8/10/5/9);
    3. 含 同文件父子引用 的导入 CSV 样例(锁定缺陷 2 的回归)。

    七、真机 UI 实测(第四轮,2026-07-17)

    环境:showcase 示例应用,objectstack dev --ui --seed-admin -p 3777,全新 SQLite(/tmp 一次性库),首次冷启动;console 分别用 6/29 vendored build(:3777)与 objectui 当前 main(b4ef588,HMR :5181)双版本对照。全程浏览器以 Dev Admin 真实操作,截图取证(仅留本地/对话,未进仓库)。

    通过项(带 UI 证据)

    场景 结果
    首启种子加载:启动日志 0 错误;客户 14 / 联系人 8 / 分类 4 全部落库,列表页标签/币值/自引用「上级」渲染正常 ✅
    种子后汇总基线:4 张发票 total 与行合计逐一相等(1619.96/198/299.94/450),无行发票为空 ✅
    导入向导三步流(上传→映射→预览),字段自动映射正确 ✅
    坏行隔离:4 行含 1 坏行(not-a-number)→ 预览红框高亮可改;导入结果「已导入 3 条 / 已跳过 1 条」+ 行级错误 + toast;DB 精确 14→17,坏行未入库 ✅
    服务端 dry-run(当前版「校验数据」):正确预警第 3 行同文件引用不可解析 ✅
    汇总真机:/import 导入 2 条发票行(共 200)→ INV-1001 total 1619.96→1819.96,详情页「合计」可见 ✅
    当前版导入选项齐全:匹配模式/保留未知选项/运行自动化开关/后台导入(可撤销)/下载失败行 ✅

    真机确认与新发现的缺陷(续编号)

    • 错误(导入):缓冲操作导致同一文件查找引用中断  #2806 在最新 framework + 最新 objectui 上 UI 级复现:CSV 行 3 引用行 2 创建的父记录 → 「新建 2 条 / 已跳过 1 条」,no showcase_category matches "QA-2806B-New-Parent";DB 确认引用已存在记录的控制行正确解析、同文件引用行被丢。与组件级探针结果一字不差。缓解:「校验数据」dry-run 能提前预警(但报错原因对用户不可理喻——父记录明明就在同一文件里)。
    1. 中(objectui,可用性):字段映射步骤,必填字段未在 CSV 中出现时「下一步」禁用但无任何提示(ImportWizard.tsx:2282 的 missingRequired 只用于禁用,从不渲染)。真实操作中我因缺 status 列卡住数分钟,无从得知缺哪个字段。当前 objectui main 源码属实。

    2. 高(objectui,数据损坏):6/29 版 console 的导入向导(无 /import 能力时的静默逐行 create 降级路径)不做引用解析、不做类型强转:3 行分类 CSV 全部报「已导入 3 条」,但 FK 列里写入的是原始自然键字符串(parent='QA-2806-New-Parent'),空值写成 '',连引用已存在记录的行都没解析——网格「上级」列显示"—",损坏无声无息。当前 objectui main 仍保留同一「older adapter → silently fall back」分支(ImportWizard.tsx ~L1942),任何 /import 不可达的环境(旧服务端/适配器不匹配)都会静默重现。建议:fallback 至少显式告警降级,且 lookup 列在 fallback 下应拒绝导入而非原样写入。

    真机环境坑(记录备查)

    • worktree 起 showcase 需先 pnpm install + build @objectstack/connector-*(嵌套目录,--filter './packages/*' 覆盖不到)。
    • worktree 内 console 解析不到兄弟仓 objectui(相对路径 ../../objectui),需临时符号链接(测试后已删)。
    • 端口占用时 objectstack dev 静默退避 +1 端口(3777→3778),易造成"旧实例假象",起服务后必须核对日志端口。

    八、覆盖缺口声明

    九、过程记录

    • 三轮探针测试文件均为临时文件,运行取证后立即删除,未进入任何分支;全程 git status clean。
    • 按仓库规约未提交任何测试产物、未创建 issue;缺陷上报待用户裁决(建议按一事一议拆:1+10、3+4、8 各一单,2/5 建议重开原 issue 附新证据)。
  3. baozhoutao commented on Jul 17, 2026

    @baozhoutao
    Contributor

    缺陷立项汇总(上述报告中的问题,均为独立新 issue)

    framework

    objectui

    并行修复提示:#3148 与 #3150 都改 import-runner.ts,#3147/#3151/#3152 都涉及 engine.ts——建议分波执行避免同文件冲突(第一波 #3147/#3148/#3149 + objectui 两单,第二波 #3150/#3151/#3152/#2805)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions