Skip to content

修复(核心):bulkWrite 接受无效的批量响应并错误关联记录。 #2803

Description

@yinlianghui

问题

bulkWrite 使用 writeBatch 的返回数组,按位置把记录关联回输入行。如果 writeBatch 成功返回的数据违反类型、数量或顺序契约,当前实现仍可能报告成功、丢弃多余记录,或者把记录 ID 关联到错误的输入行。

复现规则

使用真实的 bulkWrite 实现进行测试。测试输入包含 1 个字段 key、2 行数据,字段值分别为 ab。为便于阅读,固定输入可等价表示为以下 CSV:

key
a
b

批次大小为 2,因此两行会通过一次 writeBatch 调用提交。以上 CSV 仅用于直观展示输入数据,实际执行方式以后面的完整复现命令为准。

四个场景只改变 writeBatch 的成功返回值。writeBatch 均正常完成,不抛出异常,也不模拟网络错误。writeOne 只用于记录是否进入逐行降级路径。执行后检查每个输入行对应的 okrecordIdwriteOneCalls

场景 writeBatch 的成功返回值 实际结果 writeOneCalls
返回记录不足 [{ id: 'id_a' }] a → id_a, ok=trueb → null, ok=true 0
返回记录过多 [{ id: 'id_a' }, { id: 'id_b' }, { id: 'id_extra' }] a → id_ab → id_bid_extra 不在最终结果中 0
返回值不是数组 { records: [{ id: 'id_a' }, { id: 'id_b' }] } a → null, ok=trueb → null, ok=true 0
返回顺序相反 [{ id: 'id_b' }, { id: 'id_a' }] a → id_b, ok=trueb → id_a, ok=true 0

完整复现命令及实际输出

返回记录少于输入记录
pnpm exec tsx -e 'import { bulkWrite } from "./packages/core/src/utils/bulk-write.ts"; void (async () => { const rows = [{ key: "a" }, { key: "b" }]; let writeOneCalls = 0; const results = await bulkWrite(rows, { batchSize: 2, writeBatch: async () => [{ id: "id_a" }], writeOne: async (row) => { writeOneCalls++; return { id: "single_" + row.key }; }, sleep: async () => {} }); console.log(JSON.stringify({ writeOneCalls, results: results.map((r) => ({ index: r.index, input: rows[r.index].key, ok: r.ok, recordId: r.record?.id ?? null })) }, null, 2)); })();'

实际输出:

{
  "writeOneCalls": 0,
  "results": [
    {
      "index": 0,
      "input": "a",
      "ok": true,
      "recordId": "id_a"
    },
    {
      "index": 1,
      "input": "b",
      "ok": true,
      "recordId": null
    }
  ]
}
返回记录多于输入记录
pnpm exec tsx -e 'import { bulkWrite } from "./packages/core/src/utils/bulk-write.ts"; void (async () => { const rows = [{ key: "a" }, { key: "b" }]; const returned = [{ id: "id_a" }, { id: "id_b" }, { id: "id_extra" }]; let writeOneCalls = 0; const results = await bulkWrite(rows, { batchSize: 2, writeBatch: async () => returned, writeOne: async (row) => { writeOneCalls++; return { id: "single_" + row.key }; }, sleep: async () => {} }); console.log(JSON.stringify({ returnedRecordIds: returned.map((r) => r.id), writeOneCalls, results: results.map((r) => ({ index: r.index, input: rows[r.index].key, ok: r.ok, recordId: r.record?.id ?? null })) }, null, 2)); })();'

实际输出:

{
  "returnedRecordIds": [
    "id_a",
    "id_b",
    "id_extra"
  ],
  "writeOneCalls": 0,
  "results": [
    {
      "index": 0,
      "input": "a",
      "ok": true,
      "recordId": "id_a"
    },
    {
      "index": 1,
      "input": "b",
      "ok": true,
      "recordId": "id_b"
    }
  ]
}
返回值不是数组
pnpm exec tsx -e 'import { bulkWrite } from "./packages/core/src/utils/bulk-write.ts"; void (async () => { const rows = [{ key: "a" }, { key: "b" }]; let writeOneCalls = 0; const results = await bulkWrite(rows, { batchSize: 2, writeBatch: async () => ({ records: [{ id: "id_a" }, { id: "id_b" }] } as any), writeOne: async (row) => { writeOneCalls++; return { id: "single_" + row.key }; }, sleep: async () => {} }); console.log(JSON.stringify({ writeOneCalls, results: results.map((r) => ({ index: r.index, input: rows[r.index].key, ok: r.ok, recordId: r.record?.id ?? null })) }, null, 2)); })();'

实际输出:

{
  "writeOneCalls": 0,
  "results": [
    {
      "index": 0,
      "input": "a",
      "ok": true,
      "recordId": null
    },
    {
      "index": 1,
      "input": "b",
      "ok": true,
      "recordId": null
    }
  ]
}
返回顺序与输入顺序不同
pnpm exec tsx -e 'import { bulkWrite } from "./packages/core/src/utils/bulk-write.ts"; void (async () => { const rows = [{ key: "a" }, { key: "b" }]; let writeOneCalls = 0; const results = await bulkWrite(rows, { batchSize: 2, writeBatch: async () => [{ id: "id_b" }, { id: "id_a" }], writeOne: async (row) => { writeOneCalls++; return { id: "single_" + row.key }; }, sleep: async () => {} }); console.log(JSON.stringify({ writeOneCalls, results: results.map((r) => ({ index: r.index, input: rows[r.index].key, ok: r.ok, recordId: r.record?.id ?? null })) }, null, 2)); })();'

实际输出:

{
  "writeOneCalls": 0,
  "results": [
    {
      "index": 0,
      "input": "a",
      "ok": true,
      "recordId": "id_b"
    },
    {
      "index": 1,
      "input": "b",
      "ok": true,
      "recordId": "id_a"
    }
  ]
}
## 实际现象
  • 返回记录不足时,缺少返回记录的输入行仍被标记为 ok=true
  • 返回记录过多时,多余的 id_extra 没有出现在最终结果中。
  • 返回值不是数组时,两行输入都被标记为 ok=true,但 recordId 均为 null。
  • 返回顺序相反时,输入行 a 被关联到 id_b,输入行 b 被关联到 id_a
  • 四个场景的 writeOneCalls 均为 0。

判定依据

因此,以上四种返回值均违反了明确的批量响应协议;当前结果仍出现 ok=true、记录缺失、结果被丢弃或记录 ID 错误关联,判定为 Bug。

影响

这可能造成 Import 错误报告成功、记录 ID 关联到错误的源数据行,或者已创建记录未被纳入 Undo 跟踪。

证据边界

以上四种情况均已通过受控的 writeBatch 返回值在真实 bulkWrite 实现上复现。该证据证明了协议违规响应的处理结果,但不能证明内置 SQL 驱动在正常运行中会自然返回这些异常或乱序响应。

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