Skip to content

fix(automation): keep a wait timer's one-shot armed when the shot never consumed the pause (#5529) - #5549

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5529-wait-timer-store-unavailable
Aug 5, 2026
Merged

fix(automation): keep a wait timer's one-shot armed when the shot never consumed the pause (#5529)#5549
os-zhuang merged 1 commit into
mainfrom
claude/issue-5529-wait-timer-store-unavailable

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5529

前提核对(合并 #5527 之后)

分支切自 origin/main(含 PR #5527 = f0d98e10)。核对结果:

  • waitTimerJobName 提取、onSuspensionReleased 钩子都已存在;
  • issue 描述的 finally 无条件取消仍在(合并后位于 wait-node.ts L99-115,fix(automation): release the wait node timer job when the run leaves the node (#5512) #5527 刻意保留并写明两者回答不同问题);
  • engine.ts 的两个返回码仍在:RESUME_IN_PROGRESS(L2854)、STORE_UNAVAILABLE(L2875)。AutomationResult.codepackages/spec/src/contracts/automation-service.ts L221 已宣告的公开字段,所以读它是契约内消费,不动 engine.ts

一处需要更正 issue 的归因(重要,请复核):issue 把洞定位在 wait-node.ts L87-92,也就是排班路径的回调。但 resumeInternal 只在热缓存未命中时才读持久 store(loadSuspendedRunStrict:先 this.suspendedRuns.get(runId),该缓存只在 forgetSuspendedRun 时才删),而 once 定时器就跑在排下它的那个进程里 —— 所以在排班路径上,run 一定在本进程缓存里命中,那条回调拿不到 STORE_UNAVAILABLE。真正可达的是同文件里 rearmSuspendedWaitTimers 重排的那一枪(新进程、缓存为空、真的去读 store),而它带着一模一样的无条件 finally —— issue 只把 rearmSuspendedWaitTimers 当作自愈路径提及,没注意它同时也是同一缺陷的第二个现场。

机制、修法、文件都与 issue 所述一致,所以前提成立;只是可达的那个现场是同文件的兄弟站点。两处合用一个 handler 修掉(排班路径那一支按构造是潜伏的,但留着两种「怎么收尾这一枪」的写法正是 #5512 刚收敛掉的漂移)。

前置实测:once-job 回调结束后会不会再响(不猜)

派发要求先实测再定形状。临时 harness 跑在 service-job 包里(已删除),结论:

[measure] fires after 400ms (no cancel): 1
[measure] listJobs after the shot: [ 'm1' ]
[measure] fires after 1000ms total: 1          ← 到点 +30ms,等到 1000ms 仍只响了一枪
[measure] fires after manual trigger(): 2      ← 注册还在,trigger() 能再打一枪
[measure] listJobs after cancel: []
[measure] trigger after cancel threw; fires = 1
[measure] sys_job after an uncancelled shot: {"name":"flow-wait:run1:pause","active":true,
          "schedule_expression":"2026-08-05T16:45:20.837Z","last_status":"success","run_count":1}
[measure] sys_job after cancel: active = false

onceIntervalJobAdapter 里就是一个 setTimeout(DbJobAdapter 把全部定时机制委派给它),打完一枪不会再响。所以落到派发说明里的第二个分支:「不取消」不构成自愈

但实测同时显示「不取消」保住了 cancel 会销毁的两样东西,这是选择它的实据:

  1. sys_job 行保持 active: true 且带着 deadline —— 在这里这是真话(run 确实还在等);取消后翻成 active: false,读起来是「这个唤醒已经办完了」,而 run 还挂着。
  2. 注册还留在适配器里,于是 trigger('flow-wait:{runId}:{nodeId}') 能在 store 恢复后不重启地把这一枪重打一遍。取消之后 trigger 直接报 job not found,只剩下次启动的 overdue re-arm。

所以最终形状 = 不取消 + error 日志,且日志把「这不是自动重试」和上面两条补救都写明,不暗示会自己好。

改了什么

packages/services/service-automation/src/builtin/wait-node.ts —— 抽出一个共用的 makeWaitTimerJobHandler(...)(返回 () = 大于 Promise< void >),排班路径与 re-arm 路径共用,理由与 waitTimerJobName 只有一份声明相同。按返回码分流:

按要求没有合并 onSuspensionReleasedfinally 两个机制:前者答「RUN 离开了节点」,后者答「这个 JOB 打完了它唯一的一枪,且这一枪把暂停结清了」。RearmLogger 现在 extends WaitTimerLogger,error 仍是必需(#4632)。未动 engine.ts

反向验证(方向先判后跑)

预判:把无条件取消恢复回去,应当只有 3 条新用例转红(两条「未被取消」断言 + 那条 error 日志断言),3 条「其余照旧」用例保持绿,既有用例全部不动。理由:这些 pin 断言的是旧代码总会执行的一次 cancel 之缺席,以及旧代码从不产生的一行日志 —— 没有反转,也没有「因为什么都没产生所以绿」的空壳。

实跑(把 if (result?.code === 'STORE_UNAVAILABLE') 改成 if (false))与预判一致:

× re-arm path: a STORE_UNAVAILABLE shot leaves the one-shot ARMED
  → expected [ Array(1) ] to deeply equal []
× re-arm path: the failed shot is reported at error, naming the job and the run
  → expected [] to have a length of 1 but got +0
× arming path: the same handler keeps the job armed on STORE_UNAVAILABLE
  → expected [ Array(1) ] to deeply equal []
✓ re-arm path: a shot that DOES resume still disarms the one-shot (unchanged)
✓ RESUME_IN_PROGRESS still disarms — the other resume owns the pause
✓ a thrown resume still disarms — a throw is not a store outage
  Tests  3 failed | 22 passed (25)

既有 6 条 #5512 teardown 用例全程保持绿,说明本次是纯增量,没有顶掉别人的覆盖。

测试

新增 describe「wait timer one-shot vs. a shot that never consumed the pause (#5529)」6 条。可达的那条端到端用例走 re-arm 路径:进程 1 挂起 → 进程 2 冷启动、loadconnection refused 而其余委派(于是底层行仍可检查)→ re-arm 重排 → 手动触发这一枪,断言 run 仍挂着(inner.list() 仍含该 runId、下游节点没跑)、cancelled 为空、error 日志带 jobName/runId/两条补救。

排班路径那条用例是注入返回码(改 engine.resume)而非诱发,用例注释里直说了为什么:本进程热缓存命中,引擎在这里产不出这个码;伪造一次缓存未命中去「证明」它,只会 pin 一个引擎并不存在的场景。它验证的是 handler 的分支,以及排班站点确实走了这个共用 handler 而不是自留 finally

pnpm --filter @objectstack/service-automation test
  Test Files  57 passed (57)
       Tests  695 passed (695)

tsc --noEmit(本包在 #4311 DEBT 台账内,errors 被冻结):改动前后均为 5 raw errors,且 wait-node* 相关 0 条 —— 增量为零。另跑:check:type-check-coverage OK、check:durability-log-level OK(24 seams)、check-nul-bytes OK,并按控制字符自查规则对两个改动文件做了 grep -naP 扫描(干净)。改动文件 eslint 无输出。

顺带说明:构建 spec 时 packages/spec/authorable-surface.base.jsongen:schema 重锚到当前 HEAD(与本次改动无关的生成物副作用),已 git checkout 还原,不在本 PR 内。

范围外发现

同包 #5048 / #4792 的面未触碰。


Generated by Claude Code

…er consumed the pause (#5529)

`engine.resume()` reports failure by RETURNING a code, not by throwing, so the
timer callback's unconditional `finally` could not tell a shot that consumed the
pause from one that missed. On `STORE_UNAVAILABLE` — the durable suspended-run
store unreadable, which per #4420 must never read as "no such run" — the pause
survived but its only wake-up job cancelled itself, so nothing woke that run
until the next process start's overdue re-arm.

The one-shot now settles on the return code: `STORE_UNAVAILABLE` keeps the job
armed and reports the degradation at `error` (the path was previously silent);
success, `RESUME_IN_PROGRESS`, machine-state failures and thrown errors cancel
exactly as before.

Measured, not assumed: a `once` schedule is a single `setTimeout` in
`IntervalJobAdapter`, so surviving is not a retry. It keeps the `sys_job` row
`active` with its deadline and keeps the registration `trigger()`-able, which is
a no-restart remedy the log line names; a cancel destroys both.

Both arming sites now share one handler so they cannot drift, the same reason
`waitTimerJobName` is one declaration. Separate from #5512's
`onSuspensionReleased`, which answers the RUN-side question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
@vercel

vercel Bot commented Aug 5, 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 5, 2026 5:01pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m and removed documentation Improvements or additions to documentation tests tooling labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation.

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

  • content/docs/automation/flows.mdx (via @objectstack/service-automation)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-automation)
  • content/docs/plugins/packages.mdx (via @objectstack/service-automation)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-automation)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation)

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.

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

Labels

Projects

None yet

2 participants