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

claude added 2 commits August 6, 2026 14:48
`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

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 3:29pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

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.

Copy link
Copy Markdown
Contributor Author

合并 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 queue Aug 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 6, 2026
@claude

claude Bot 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 queue Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

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

claude Bot 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 before 5000ms 超时」⇒ 已知 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 d56bcdb Aug 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants