Skip to content

单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846

Closed

Description

@baozhoutao

发现于 #5284(PR 见该单:把 update() 单 id 前置行门由全局改为按对象)。属 PD #10 的范围外发现,未在该 PR 内修改。

事实(origin/main,已按文件逐处核对)

一次单 id update() 在一个装了 plugin-audit 的普通 kernel 上,同一行的前置状态被读 3 次:

  1. packages/objectql/src/plugin.ts — 内建 hook sys_fetch_previous_update(object: '*',events: ['beforeUpdate'],priority 5)在 hookCtx.input.id 存在时无条件 ql.findOne,赋给 hookCtx.previous;
  2. packages/plugins/plugin-audit/src/audit-writers.ts — captureBefore(注册在 beforeUpdate/beforeDelete,无 object 选项 ⇒ 全局)对同一 id 再 ql.findOne 一次,赋给 ctx.__previous(注释自陈原因:「HookContext.previous 官方有类型但引擎不总是填」);
  3. packages/objectql/src/engine.ts — update() 单 id 分支自己的前置行读(update() 的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284 之后按对象判定需求:校验规则 / 本对象 afterUpdate hook / roll-up 汇总),写进 priorRecord,并在写入后覆盖 hookContext.previous。

其中 1、2 都是引擎读(ql.findOne,过完整读管线:中间件、RLS、字段掩码),3 是 driver.findOne。1、2 都不看任何需求门 —— 只要有 id 就读。

两条互相独立的后果

(a) 三读同一行。 #5272 已经在 delete 侧把这件事做对了:引擎在派发 beforeDelete 之前读一次前置行并绑定 previous,两个阶段共用一次读。update 侧仍是「先派发 beforeUpdate,写完才绑 previous」,于是 before 阶段的两个消费者只能各自去读。把 update 侧对齐 delete 侧的时序,1 和 2 都可以退休(2 的存在理由 —— 引擎不填 —— 届时不再成立)。

(b) 全局注册让「按对象」的需求门恒真。 plugin-audit 的 afterInsert/afterUpdate/afterDelete 三个 writeAudit 注册也不带 object,而 hasHooksFor 按 triggerHooks 的语义把「无 object 的注册」正确地当作命中每个对象。于是只要 plugin-audit 启用:

writeAudit 自身是在 handler 里用 SKIP_OBJECTS 过滤的,也就是说「哪些对象要审计」这件事平台已经知道,只是没有表达在注册面上。把它表达出来(注册时给出 object 列表,或提供一个「本对象是否被审计」的谓词供门查询),按对象门才真正生效。

影响与严重度

不是正确性缺陷:previous 语义处处正确,只是同一行被读多次、按对象门被架空。严重度我判断不准(没有真实负载测量数据),按 #4949 平铺记录,交 PM 分诊定级。可达性没有疑问 —— 单 id update 是最常见的写路径,plugin-audit 是默认装配的一部分。

关联

Activity

  1. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    Contributor

    分诊:pm:queue + domain:engine-core(本单收窄为 (a) 半边);(b) 半边已拆出为 #5860 ⇒ domain:identity。

    判级理由

    正文自陈「不是正确性缺陷」,但两条后果都有明确落点 + 明确完成范围 + 现成对照形状(#5272 已在 delete 侧把同一件事做对),不是观察类,故判 pm:queue 而非 finding。严重度立单者自评「判断不准」—— 按 #4949 纪律,定级归本轮:可达性无疑问(单 id update 是最常见写路径,plugin-audit 默认装配),按「同一行 3 次引擎读 + 按对象门被架空」计,属值得排期但非 P0。

    拆分理由(⛔ 不按标题词汇,按落点)

    正文自陈两条后果「互相独立」,且落点分属两个车道 —— 平级拆分,不做父子:

    半边 落点 域
    (a) update 侧对齐 #5272 的 delete 时序:引擎在派发 beforeUpdate 之前读一次前置行并绑定 previous,让 before 阶段两个消费者共用一次读;sys_fetch_previous_update 与 captureBefore 届时可退休 packages/objectql/src/plugin.ts + src/engine.ts domain:engine-core(本单)
    (b) plugin-audit 的 5 个 hook 注册补 object 表达(SKIP_OBJECTS 的知识停在 handler 内部,注册面看不见 ⇒ 按注册面计算的门只能保守判全命中) packages/plugins/plugin-audit/src/audit-writers.ts domain:identity(⇒ #5860)

    ⛔ 两半不要合成一个 PR —— 会跨两个车道的文件面。

    给 domain:engine-core 座位的两条提示(step 3 的「延后即记坑」)

    1. beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574 不是决策箱条目。 本单正文写它「needs-user-decision 中」—— 实测标签是 pm:queue + domain:engine-core,即它就在本车道队列里。两单同属 update 时序 / previous 绑定语义面,同文件(engine.ts),必须串行,且谁先落地都会改变另一条的成本 —— 派发前一单时请带上必答项「你的改动是否让另一条变简单/变难/变得不必要」,派发后一单前按该回答重定价,⛔ 不沿用立单时的估计;
    2. (a) 的收益在 (b) 落地前是打折的:只做 (a) 而 (b) 未做时,按对象门仍恒真,省下的是「同一行的第 2、3 次读」而不是「门判不需要时的全部读」。两单无依赖关系(故不打 Blocked-by),但排期上 (b) 先落 / 同期落对测量更友好。

    查重与过时前提

    origin/main 已 fetch;正文引用的三处(objectql/src/plugin.ts 的 sys_fetch_previous_update、plugin-audit/src/audit-writers.ts 的 captureBefore、engine.ts 单 id 分支)均按文件核过,前提成立。关联单 #5284 / #5038 / #5272 / #5574 / #5619 已逐条比对,无描述同一问题的重复单。

    ℹ️ 本单原先只有 domain:engine-core 而无任何 pm-state —— 那是 label discipline 禁止的半状态(座位表也记着「待分诊定级」)。本轮补齐 pm-state,状态对齐。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 8, 2026
  3. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1 (engine-core seat #6019, incoming session)
    Session: session_01MwoubC3jL271FYt9rGXwxb
    Branch: claude/issue-5574-bulk-before-per-row (shared with #5574's engine half — unified orchestration, one timing edit; recorded here because branch-name grep for 5846 will not find it)
    Worktree: objectstack-issue-5574 (mode:cloud — dedicated session/container)
    Domain: domain:engine-core
    File surface: same as #5574's claim (objectql engine.ts / hook-wrappers.ts / plugin.ts + tests; sanctioned spec touchpoints; ADR-0058 amendment). ⛔ NOT packages/plugins/plugin-audit — this claim covers the (a) half only as triaged (single-id prior-read moved ahead of beforeUpdate and bound; sys_fetch_previous_update retirement). captureBefore's now-redundant second read will be filed as a follow-up for the identity lane (adjacent to #5860), not edited here.
    Serial constraints cleared: same list as #5574's claim comment (all predecessors MERGED; no open PR on objectql).
    Scope note: one PR, Fixes #5574 + Fixes #5846. Per triage's step-3 note the two cards must serialize on engine.ts — unified dispatch satisfies it by construction.
    Container judgment: rides #5574's mode:cloud solo container.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions