perf(objectql): update() 单 id 前置行门按对象判定需求(#5284),并校准 #4743 事实一的三处注释 - #5850
Merged
Conversation
…5284) The gate on `update()`'s single-id prior read asked the pooled `afterUpdate` registration list of every object, so one observed object taxed every other object's single-id update with an extra `driver.findOne`. The bulk paths in the same file already asked it per object (`hasHooksFor`, #5038). The narrowed gate counts every real consumer of `priorRecord` on that branch, and only those: * `needsPriorRecord(schema)` — validation rules plus the readonlyWhen / requiredWhen / option-visibility predicates it subsumes; * an `afterUpdate` hook on THIS object (global / `'*'` registrations still reach every object, per `hasHooksFor`'s mirror of `triggerHooks`); * a roll-up `summary` aggregating this object — `previous` carries the OLD parent id, so a repointed child recomputes both parents. That one used to work only incidentally, via some other object's hook. `beforeUpdate` is deliberately NOT counted: `update()` dispatches it before the read and binds `hookContext.previous` only after the write, so no beforeUpdate hook can read this row however the gate is written. The binding a kernel-hosted before-hook does see comes from the `sys_fetch_previous_update` builtin, which makes its own read and is untouched here (the duplication is filed as #5846). Also calibrates three stale comments in the same file (#4743 fact 1): the `readonly` narrowing in `assertReferencesResolve` now states the principle that holds on its own with the `'system'` sentinel as history, and `inspectDanglingReferences` no longer advertises a "readonly skip" that #5719 replaced with the `provenance` split or a bounded-scan story that #5718 completed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 6, 2026 08:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5284
Closes #4743
改了什么
packages/objectql/src/engine.ts,update()单 id 分支的前置行门,由改为按对象问:
前提已核实有效:origin/main 上全局门原样保留(#5754 的 update 派发重构没有动它),而同文件的批量路径早就用
hasHooksFor('afterUpdate', object)问同一个问题 —— 一个文件里两种精度,这次统一到窄的那一侧。新门的三项需求 =
priorRecord在这条分支上的全部消费方,逐个核过:needsPriorRecord(schema)—— 对象校验规则,以及它涵盖的readonlyWhen/requiredWhen/ 选项可见性字段谓词;afterUpdatehook —— handler 与声明式condition都读hookContext.previous(hasHooksFor与triggerHooks的过滤逻辑一致:不带object或写'*'的注册照样命中每个对象);summary聚合 ——recomputeSummaries(object, result, priorRecord, ...)用previous里的旧父 id 重算子记录离开的那个父。第 3 项是收窄必须带上的:没有它,「子记录改挂父记录时重算旧父」这件今天只是偶然成立(靠别的对象存在 afterUpdate hook 顺带捞到前置行)的事情,会在按对象化之后彻底失效,把一次多余的读换成一个静默过期的汇总值。带上它之后,这条路径第一次自己声明需求 —— 也顺手修好了「部署里一个 afterUpdate hook 都没有时旧父不重算」的既有缺口。
beforeUpdate不计入issue 正文(与派发要点)要求把「本对象任一 update 侧 hook,before/after 皆算」计入,理由是「只有
beforeUpdate且 condition 读previous的对象会失去前置读,按 #4775 fail-loud 打回」。实测把这条推理证伪了,证据是 engine.ts 自身的时序:triggerHooks('beforeUpdate')在 5480 行(收窄前行号),前置行读在 5533 行,hookContext.previous的赋值在 5726 行(写入之后);update()的这次读永远到不了 before 阶段。探针在未改动的 origin/main 上跑过(bare engine,对象 B 有 afterUpdate hook 让全局门为真):beforeUpdate的ctx.previous === undefined;一个previous.done != true && record.done == true的beforeUpdatecondition 今天就已经被 hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 打回(Unknown variable: previous ... not bound for this operation),与门是否为真无关。所以把
beforeUpdate计入这道门,买到的是一次没有读者的读;而且它会把hook-condition-previous-scope.test.ts里那条现存的 pin(「只有record-only before-hook condition 的对象不读任何前置行」= 0 次 findOne)直接压红 —— 那条 pin 不能删断言换绿,它现在反而更承重了:它钉的正是「beforeUpdate不计入」。delete 侧(#5272)之所以计入
beforeDelete,是因为那条路径先读、后派发、当场绑定,before 阶段是真读者。两侧不对称是时序不同,不是遗漏,已写进代码注释。那么 kernel 里的
beforeUpdatehook 为什么能读到previous? 来自另一个生产者:plugin.ts的内建sys_fetch_previous_update(object: '*',priority 5)自己做了一次ql.findOne。它不受本门约束,所以收窄拿不走任何人的绑定 —— 新测试直接驱动了这个形状来证明两者不打架。顺带发现同一行前置状态在装了 plugin-audit 的部署里被读 3 次(内建 + audit 的captureBefore+ 引擎本门),已按 PD #10 平铺记录为 #5846,未在本 PR 内修。省下多少 —— 诚实的口径
取决于 hook 怎么注册,不取决于有多少个:
object: binding.object、plugin-sharing 的按规则重算、plugin-auth 的sys_user快照刷新、所有元数据编写的 hook)从此不再向邻居收税 —— 这是本 PR 的收益面;object的(plugin-audit 的writeAudit、service-storage 的文件引用回收)本来就在每个对象上真的执行,hasHooksFor正确地答「命中」,所以在装了它们的部署里这道门依然恒真、省不下读。让这两处在注册面上表达它们 handler 里已经在做的过滤(plugin-audit 用的是SKIP_OBJECTS),是 单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846 的内容。也就是说:issue 里「plugin-audit 一旦启用,平台范围每次单记录 update 都吃到这次读」这句话在现象上成立,但归因需要修正 —— 它不是「在多对象上注册」,而是全局注册;因此本 PR 单独并不能让那类部署省下这次读,它把门修正确,#5846 负责让收益兑现。
反向验证(方向是先预测、后运行)
预测:把门改回全局形态,只有两条新 pin 变红,其余全绿(尤其两条
beforeUpdate用例必须保持绿,因为它们钉的是时序而不是门)。实测完全一致:测试
新增
packages/objectql/src/engine-update-prior-read-scope.test.ts(8 例,全部通过真实引擎 + 计数 stub driver;findOne按对象计数,因为改挂父记录时 #4441 的悬空检查会读父对象一次,总数会把两件事混在一起):previous就是库里那一行;object: '*'的注册仍然让每个对象都读(1)—— 松了只是多查一次,紧了会漏掉本要触发的 hook;needsPriorRecord仍强制读,且readonlyWhen真的按存储态把改名丢掉(次数与效果一起断言:读了却没用会过第一条、挂第二条);beforeUpdate在前置行已在手时依旧观察到previous === undefined,而同一次写的afterUpdate拿到完整行;previous.*的beforeUpdatecondition 在「读了」与「没读」两种配置下同样被拒 —— 判决权在时序不在门;sys_fetch_previous_update的形状,经ctx.ql取行)供给的previous不被收窄干扰:condition 正常求值,引擎不再追加自己的读(总计 1 次,就是 hook 自己那次)。hook-condition-previous-scope.test.ts:一条断言未删,只把描述旧门的那段文档注释校准到新门,并写明它的第一条 pin 现在更承重(它驱动的对象带一个beforeUpdatehook,正是新门刻意不计入的那类)。命令与结果(容器级验证锁串行,
--maxWorkers=2):消费半径(引擎 hook 面的下游)逐个跑过,全绿:
门禁:
node scripts/check-nul-bytes.mjsOK;node scripts/check-engine-double-contract.mjsOK(新测试用的是真引擎 + 假 driver,不构成 engine double)。Rider:#4743 事实一的三处注释校准(不改任何行为)
同文件纯注释,按 2026-08-03 裁决与其后的追记执行:
assertReferencesResolve里readonly收窄的紧邻注释:改写为陈述独立成立的原则(这个检查只回答「调用方点名的引用」;非系统调用方写进 readonly 字段的值在写入前已被stripReadonlyFields/stripReadonlyForInsert剥掉,留下的必然是平台自己写的,本就在检查的自述范围之外;删掉这个continue会开始拒绝平台自身的写入),并按 PD [WIP] Add Chinese version of the documentation #13 把决定 id 留在代码里。actor ?? 'system'哨兵降为历史引用:说明它是这条收窄被发现的方式(dogfood gate),以及sys_metadata_history.recorded_by是lookup('sys_user')却存哨兵字符串'system'——声明的类型与实际存的值不是一回事 #4556 拆掉它之后为什么隔壁巡检的整体跳过要重新定范围 —— 不再把收窄描述成绕 bug 的临时补丁。inspectDanglingReferencesdocblock 结尾的@link文案(即车道待办里记作 ~2876 与 ~2997 的同一处,行号已漂):readonly skip→ readonly SPLIT —— feat(objectql): 悬空引用巡检纳入 readonly 溯源族,单独分桶为provenance(#4743 事实二) #5719 已把 readonly 引用改为照读、findings 进provenance/provenanceUndetermined桶;并按 悬空引用巡检:行预算耗尽后剩余对象被静默丢弃 —— 报告里没有任何桶说「这些对象没被看过」 #5718 校准 "bounded-scan honesty" 半句:不完整性现在每一层都有桶(表内truncatedObjects、预算没走到的表unscannedObjects、数据源拒绝的unreadableObjects、被叫停的aborted)。本 PR 以
Closes #4743收尾该单;事实二已由 PR #5719 交付。changeset
@objectstack/objectqlpatch —— 写明性能语义(何时不再多付一次读、省下多少取决于注册面)与不变的正确性保证(previous语义、#4775 fail-loud 形态、after-hook 分发)。Generated by Claude Code