Skip to content

fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) - #6013

Merged
qq9340100 merged 3 commits into
mainfrom
claude/issue-5749-summary-count-null-zero
Aug 7, 2026
Merged

fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749)#6013
qq9340100 merged 3 commits into
mainfrom
claude/issue-5749-summary-count-null-zero

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes#5749

前提复核(先于实现)

issue 正文的两段定位在 origin/main仍然成立,只是行号漂了(engine.ts 今天已合多个 PR),按内容定位:

  • 空集兜底(原文 :4225)现在在 packages/objectql/src/engine.tsrecomputeSummaries() 内:if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null; —— 是对的,C 态(删光子记录)拿到 0 就是靠它。
  • 父行选取(原文 :4202-4205)现在是 recomputeSummaries() 里的 for (const r of recs) ... for (const p of prevs) ... 两行:待重算的 parentId 只从本次写入的子记录(以及被删子记录的 previous)里取。
  • getSummaryDescriptors() 确实只按子对象索引,父对象自己 insert 时拿不到自己的汇总字段。

所以「坏的是父行选取、不是兜底」这个判断成立,方案 1(父行 insert 时落初值)是对的方向。

改了什么

生产端修,三处:

  1. buildSummaryIndex() 现在一次扫描产出两个视图(byChild / byParent),存放的是同一批 descriptor 对象。子对象索引的语义一个字没动 —— getSummaryDescriptors(childObject) 行为完全不变,只是把「有没有过期」的判断抽成了 ensureSummaryIndexes(),让父侧视图共用同一条过期规则(cloud#970 那条运行时发布的过期规则,父侧同样需要,已加测试)。
  2. 新增 initializeSummaryFields(object, record):按父对象取出该行自己拥有的汇总字段,把 count / sum 落成空集合的值 0
  3. insert()applyFieldDefaults 之后、beforeInsert 钩子之前调用它 —— 与 defaultValue 完全同一个挂点、同一套规则。

另外把空集函数清单提取成模块级的 summaryEmptySetValue(fn),recomputeSummaries 的兜底改为调用它。这不是改兜底逻辑:表达式逐字等价,只是让「插入初值」和「重算兜底」读同一份清单 —— 两个地方各写一份 fn === 'count' || fn === 'sum' 正是「A 态和 C 态读出两个值」这类 bug 的温床。min / max / avg 在空集上没有定义,两边都仍然是 null

边界(逐条对应 issue 的取舍)

  • 作者显式提供的值不被覆盖:判断条件是 != null,与 applyFieldDefaults 完全同口径([objectql] 字段 defaultValue 语义:显式 null 不回填、解析晚于 hook、表单不预填 current_user #2706:insert 时 undefined 与显式 null 都算「未提供」)。beforeInsert 钩子在其后运行,仍有最终决定权(两条都有测试)。
  • 落初值 ⇔ 会被重算维护,是同一个集合:buildSummaryIndex 里解析不出子->父 FK 的 descriptor 会被 continue 跳过,它不进任何一个索引,所以也不会被落初值。否则就会出现一个「没人维护的 0」—— 那比 null 更像谎言。
  • 存量数据不在本 PR 范围:这是 create-time 初始化,已经存成 null 的老父行仍然是 null,直到某次子记录写入把它重算。实测确认这不是方案 1 的强依赖:方案 1 对新数据一次性全对,存量回填是独立取舍,建议另行立单。
  • 没有碰recomputeSummaries 的父行选取逻辑。实现过程中未发现「必须同时改选取才正确」的情形:父行选取的职责是「谁被写了就重算谁」,它对「从未被写过的行」结构上就无话可说 —— 补的应该是初始化,不是把选取扩成全表扫描。

测试

packages/objectql/src/summary-rollup.test.ts 新增 8 个用例,复用文件里已有的 memory driver(没有引入新的 fake engine,check:engine-double-contract 绿):

  • issue 表格的 A/B/C 三态:A 态 insert 后即为 0(返回值与落库值都断言了),B=1,C 删光后=0,并直接断言 A === C;total_estimate(sum)同款。
  • ["task_count","=",0]["task_count","<",1] 两个筛选:A 态行进结果集,有子记录的行仍被排除。
  • avg/max 在有子记录前后都保持 null(口径 pin)。
  • 作者显式提供 task_count: 7 不被覆盖;批量 insert 每行都落初值、已提供的那行不动;beforeInsert 钩子仍能覆盖。
  • 关系解析不出来的汇总字段不落初值。
  • 索引已被前一次写入预热后再发布的父对象,照样落初值。

反向验证(方向先预测、后运行)

预测:去掉 insert 里的初始化调用 -> 恰好 4 个用例转红(A/C 一致性、=0/<1 筛选、批量、运行时发布的父对象);另外 4 个断言的是「不该发生的事」(不覆盖作者值、avg 仍为 null、无法解析不落初值、钩子优先),它们是护栏而不是本次修复的 pin,应当保持绿

实测与预测逐条一致:

× reads the SAME value for "never had a child" (A) and "had one, deleted it" (C)
-> expected undefined to be +0
× `= 0` and `< 1` filters no longer drop the parent that never had a child
-> expected [ 'ROLLUP PROBE' ] to deeply equal [ 'Legacy Sunset', 'ROLLUP PROBE' ]
× seeds every row of a batch insert, and only the unsupplied ones
× seeds a parent published AFTER the summary index was already warmed
✓ (其余 4 个 + 全部既有 summary 用例)
Tests 4 failed | 13 passed (17)

第二条的失败信息就是 issue 描述的现象本身:同一个查询只返回了 ROLLUP PROBE,Legacy Sunset 整行消失,无任何报错。

命令与结果

pnpm --filter @objectstack/objectql test -> Test Files 130 passed (130) / Tests 2155 passed (2155)
pnpm --filter @objectstack/objectql typecheck -> clean
pnpm --filter @objectstack/runtime test -> Test Files 102 passed (102) / Tests 1474 passed (1474)
node scripts/check-engine-double-contract.mjs -> OK (72 pinned, 133 DEBT, 2 exempt)
node scripts/check-query-options-erasure-ratchet.mjs -> ratchet holds(测试面 267,未增)
node scripts/check-nul-bytes.mjs -> OK

packages/runtime 那一轮是特意跑的:bulk-write-real-driver.integration.test.ts 用的是真实 SqlDriver/better-sqlite3,验证了初值 0 能正常写进真实建表出来的列(汇总列本来就是物理列 —— 重算就是靠 update 写它的)。

changeset

@objectstack/objectql patch。行为变化:新建父行的 count/sum 汇总从 null 变 0;changeset 里写明了存量数据不受本 PR 影响、回填另行处理。


Generated by Claude Code

`recomputeSummaries()` only ever visits parents named by a CHILD write
(`recs`/`prevs` -> `desc.fkField`), so a parent that has never had a child
is never visited and its summary column keeps insert's `null`. Delete the
last child and the parent IS visited (via `previous`) and lands on 0 — one
logical state, two values. The consequence is not cosmetic: `= 0` / `< 1`
filters compare in the database and silently DROP every parent that never
had a child; sorting, GROUP BY and formula fields reading it inherit the
same null.
Fixed at the producer. `buildSummaryIndex` now publishes the identical
descriptors under a second, parent-side view, and `insert` seeds the
count/sum summaries a new row OWNS with the empty-collection value right
after `applyFieldDefaults`. The empty-set function list is extracted to
`summaryEmptySetValue` so the insert seed and the recompute fallback read
ONE list — min/max/avg have no empty-set value and stay `null`, unchanged.
Boundaries: author-supplied values are never overwritten (same `!= null`
rule as `applyFieldDefaults`, #2706) and `beforeInsert` still has the final
say; a roll-up whose relationship cannot be resolved is not seeded, so
"seeded" and "maintained by recompute" stay the same set; existing rows are
untouched — this is create-time only and backfill is a separate decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 6, 2026 3:29pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

合并 main 后完整重跑(中断前的绿一律作废)

分支中途因用量上限中断过一次,恢复后按流程重来了一遍:git merge origin/main(⛔ 未 rebase),合入了 #5991 / #6000 / #6003 / #6006 / #6004 / #5973,无冲突。pnpm install --frozen-lockfile + 重建依赖(packages/spec 这一侧动过,所以是真重建不是复用产物)后,前台阻塞重跑:

pnpm --filter @objectstack/objectql test -> Test Files 130 passed (130) / Tests 2155 passed (2155)
pnpm --filter @objectstack/objectql typecheck -> tsc --noEmit,无输出
pnpm --filter @objectstack/runtime test -> Test Files 102 passed (102) / Tests 1476 passed (1476)
npx eslint packages/objectql/src/{engine.ts,summary-rollup.test.ts} -> clean
node scripts/check-nul-bytes.mjs -> OK (5780 files)
node scripts/check-engine-double-contract.mjs -> OK — 73 pinned / 133 DEBT / 2 exempt
node scripts/check-query-options-erasure-ratchet.mjs -> ratchet holds(测试面 267,未增)

(runtime 从 1474 变 1476 是 main 自己新增的用例,不是本分支的。)

反向验证也在合并后重跑了一遍,方向与首次一致

预测不变:去掉 insert 里的初始化调用 -> 恰好 4 个用例转红,另外 4 个(断言「不该发生的事」的护栏)保持绿。实测:

× reads the SAME value for "never had a child" (A) and "had one, deleted it" (C)
× `= 0` and `< 1` filters no longer drop the parent that never had a child
× seeds every row of a batch insert, and only the unsupplied ones
× seeds a parent published AFTER the summary index was already warmed
Tests 4 failed | 13 passed (17)

这次把「改 -> 跑 -> 还原」放在同一条命令里原子执行,还原后以 git diff --stat 为空 + 调用点计数为 2 作为凭据 —— 上一轮正是死在「还原」这一步上。

skip-changeset 标签不适用:本 PR 带 .changeset/summary-count-zero-on-parent-insert.md(@objectstack/objectql patch),会发版。读回当前标签集为 ["size/m"](labeler 打的),没有做任何标签写入。


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 16:18
@baozhoutao
baozhoutao added this pull request to the merge queueAug 6, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to no response for status checks Aug 6, 2026
@claude

claudeBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

队列管家:拦截(⛔ 未重投)—— 零签名踢出,本座位记录的第 2 例

事实(全部为 REST 读数):

  • 本 PR 16:19:15Z 入队,2026-08-06T22:47:28Zgithub-merge-queue[bot] 移出队列(removed_from_merge_queue);origin/main 全程停在 9e3709a4(15:14:30Z)⇒ 踢出,非落地
  • 当代队列分支 gh-readonly-queue/main/pr-6013-9e3709a4…(tip 93dfefcb)零 run(分支域查询 total_count: 0)。零命中反查(SKILL Operational notes 6):同形状查询对 pr-6029-d8746037… 返回当代 run 三条(23:08:37Z,全 success)⇒ 零命中成立,不是扫描器坏了。
  • 该分支自 ~21:29Z 链重建起在队首约 78 分钟零 run,随后被移出。本 PR 更早世代(17:06:01Z / 18:01:04Z / 18:06:41Z)的红属已被重建取代的世代,不构成当代签名。

判定:无签名可认。 四分支判例法(已知 flaky / 已修签名再现 / 基缺已合修复 / 新签名)全部以「有一个签名可认」为前提;本例连一条失败 job 都不存在,是 #5810 第 18 轮提请 ① 所提「零派发踢出」的第二例(首例 #6034)。按试点判据 2(未裁定签名一律不重投),本座位 ⛔ 不重投,仅留档。

初步判读(本轮新读数,来自队列配置本身) —— REST rules/branches/main 的 merge_queue 参数:

max_entries_to_build: 5
check_response_timeout_minutes: 60
grouping_strategy: ALLGREEN
  • check_response_timeout_minutes = 60:当代分支零 run 且在队首 ~78 分钟已越过该超时 ⇒ 本次踢出很可能是**「必需检查在 60 分钟内未回报」的队列超时驱逐**,而非任何测试失败。这给「零签名踢出」提供了第一个机械解释:被驱逐的不是 PR,是没等到结论的队列条目
  • 同一读数还证伪了此前记为「部分派发」的一半:max_entries_to_build = 5,链上第 6 位及以后本就拿不到 run,属良性;真正异常的只有队首零 run这一项。

建议动作(⛔ 本座位无授权面执行,交 Fixes objectstack-ai/objectstack#5749 所属车道 domain:engine-core):本 PR 代码侧无需改动,派发面恢复后重新入队即可。若再次在队首零 run 被踢,即为平台派发面故障的复现,请在 #5810 累计,不必逐 PR 重新诊断。

已核让行(SKILL「双向让行」):处置前读本 PR 最近 30 分钟评论,无车道 PM 动作(最近一条为 15:29:42Z)⇒ 无让行对象,本座位处置并留档。

⛔ 本座位未合并、未切 ready/draft、未撤队、未重跑、未改代码、未动认领。


Generated by Claude Code

@qq9340100
qq9340100 added this pull request to the merge queueAug 7, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31134755269 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (2/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/__tests__/datasource-pool-support.test.ts�[2m > �[22m#5714 — the driver factory rejects a pool it cannot honour�[2m > �[22msqlite WITHOUT a pool still builds exactly as b
    �[90mstderr�[2m | src/sql-driver-unique-tenancy.test.ts�[2m > �[22m�[2mSqlDriver unique × tenancy (#3696)�[2m > �[22m�[2mretires a legacy global unique index and replaces it with the composite
    �[90mstderr�[2m | src/sql-driver-unique-tenancy.test.ts�[2m > �[22m�[2mSqlDriver unique × tenancy (#3696)�[2m > �[22m�[2mretires the legacy `uniq_<table>_<col>` index left by the drift rebuild path
    �[90mstderr�[2m | src/sql-driver-unique-tenancy.test.ts�[2m > �[22m�[2mSqlDriver unique × tenancy (#3696)�[2m > �[22m�[2mbare-composite tightening + duplicate pre-flight (ADR-0120 D4)�[2m > �[22m�[2ma
    �[90mstderr�[2m | src/sql-driver-unique-tenancy.test.ts�[2m > �[22m�[2mSqlDriver unique × tenancy (#3696)�[2m > �[22m�[2mbare-composite tightening + duplicate pre-flight (ADR-0120 D4)�[2m > �[22m�[2mB
    �[22m�[39m[schema-drift] product: cannot tighten 'uniq_product_organization_id_code' as UNIQUE (COALESCE(organization_id, '__global__'), code) — existing rows already violate the NULL-safe unique cons
    �[90mstderr�[2m | src/sql-driver-unique-tenancy.test.ts�[2m > �[22m�[2mSqlDriver unique × tenancy (#3696)�[2m > �[22m�[2mbare-composite tightening + duplicate pre-flight (ADR-0120 D4)�[2m > �[22m�[2mB
    �[22m�[39m[schema-drift] REFUSING to rebuild 'uniq_product_organization_id_code' on 'product' as a NULL-safe unique — 1 duplicate group(s) violate it (e.g. organization_id="__global__", code="DUP" × 2
    

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 73 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@claude

claudeBot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

队列管家:已认签名,⛔ 暂不重投(本 PR 当前仍在队列内)

本 PR 的队列构建 CI 31134755269 于 00:40Z 判红(job 级 failure 2 条:Test Core (2/3) + 聚合 Test Corecancelled;致命 step 11 Run this shard's tests)。但两读数确认本 PR 仍在队列中(gh-readonly-queue/main/pr-6013-* 存在,timeline 自 00:25:33Z added_to_merge_queueremoved 事件)⇒ 重投的前提(已被踢出)尚未成立,本座位仅留判读,不动队列。

完整签名(取完整 job 归档判读,⛔ 未看 tail —— SKILL notes 7):

FAIL src/__tests__/datasource-pool-support.test.ts > #5714 — the driver factory rejects a pool
it cannot honour > sqlite WITHOUT a pool still builds exactly as before
Error: Test timed out in 5000ms.
❯ src/__tests__/datasource-pool-support.test.ts:122:3

台账依据:#5810 正文 objectstack 表新增行(维护者 2026-08-07 授权升级)——「service-datasourcedatasource-pool-support.test.ts > sqlite WITHOUT a pool still builds exactly as before5000ms 超时」⇒ 已知 flaky(#6044 在案),处置 原样重投

⚠️与「已修 5000ms 行」的区分已核(台账行内写明):本条根因是 packages/services/service-datasource无 vitest 配置 ⇒ 走默认 5000ms;#4856testTimeout: 60_000逐包落在各自 vitest.config.ts 里的,结构上覆盖不到该包 ⇒ 属覆盖空洞不是 notes 2 所指的「已修签名再现」。

台账行的四个条件逐条核过:①签名逐字吻合;②仅队列全量构建命中;③本 PR 自身 CI 23 个 check 全绿;④改动面仅 packages/objectql/src + 一个 changeset,service-datasource 文件数 0

下一步(本座位):若队列据此把本 PR 踢出,即按台账原样重投并追加审计评论;若队列未踢(该红不在 required 集内),本 PR 照常前进,本评论仅作留档。⛔ 无论哪种,本 PR 代码侧无需改动 —— 失败用例不在本 PR 的改动面内。

已核让行:本 PR 最近 30 分钟无车道 PM 动作。


Generated by Claude Code

Merged via the queue into main with commit d56bcdbAug 7, 2026
24 of 25 checks passed
@qq9340100
qq9340100 deleted the claude/issue-5749-summary-count-null-zero branch August 7, 2026 00:53
os-zhuang pushed a commit that referenced this pull request Aug 7, 2026
合并 main 到 dca5bd3 后再全量重测,余量在一小时内被兑付了两笔,记录如下:
- `@objectstack/objectql` 实测 339 -> **345**(+6 全是 TS2554,全在
`src/summary-rollup.test.ts`,由飞行途中落地的 #5749 / PR #6013 扩写)。
记档 349 把它静默吸收了 —— 若按精确值 339 记账,这就是同一场赛跑的第 6 次红。
按裁决「实测 +10」把记录抬到 **355**,恢复满额余量。
- `@objectstack/service-storage` 42 -> 41 -> **42**:`IStorageService.list(prefix)`
的退休被拆成两个 PR,spec 半边(#5540 / PR #5983)减 1、适配器半边
(#5541 / PR #6061)删旧测试(-1 TS7006)又新增
`storage-adapter-list-retirement.test.ts`(+2 TS2835),净 +1。上一轮我按实测
下调到 41,一小时后就被咬红 —— 正是派发令说的「非余量条目被基漂移咬住」,
按同一记档规则给这条加 +10,记 **52**,不开精确校准 lap。
一个值得写进文档块的新形状:**拆成两个 PR 的退休会让计数先降后升**,在两半之间
记下的精确值,推上去之前就已经过期。
`rest` / `lint` 两条实测未动(153 / 32),余量原样,note 补记「一小时后在
77c7c88 复测仍是该值」。
重测输出:四条记档余量各打印一行 ℹ(各 -10),无一条上漂,exit 0。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
@github-actionsgithub-actionsBot mentioned this pull request Aug 7, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@baozhoutao@qq9340100@claude