FlowWorker¶
FlowWorker(plaita.server.flow_worker,需 server extra)把 FlowExecution.run_distributed 与 ExecutionStorage / FlowStorage / EventBus 串起来:加载流程、持久化 checkpoint、处理 start/resume 任务。
把它当成 suspend/resume 编排器,而不是 Temporal/Cadence 级「容错工作流引擎」。API 名里的 Distributed 指「可跨进程挂起/恢复」。
可靠性边界(必读)¶
| 机制 | 当前行为 | 后果 |
|---|---|---|
任务队列(RedisFlowWorker) |
Redis Stream + consumer group;成功 XACK,否则 pending 可回收;超 --max-deliveries 进 DLQ |
at-least-once(需 Redis 5+)。业务侧应幂等;毒丸进 <queue>:dlq |
| 队列残留回收(#43) | XACK 与 best-effort XDEL 之间进程被杀会留下「已 ack 未删」条目(XDEL 只出现在 ack(),残留只可能来自这个窗口);worker 启动时扫一次 + 每 300s(residue_sweep_interval_seconds)best-effort XDEL,单轮上限 256 条,只删 id ≤ 消费组 last-delivered-id 且不在本组 PEL 中的条目 |
XLEN 不再被已终结条目长期污染(否则读成假「有积压」,2026-10-07 实测误判);残留按每轮 ≤256 条 / 300s 逐轮收敛(如 1 万条约需数小时),期间 XPENDING/lag 仍如实反映真实积压;未投递积压与 pending 语义不变 |
| claim 前的本机盘预检(#49) | --min-free-disk-gib / PLAITA_WORKER_MIN_FREE_DISK_GIB > 0 时,消费循环在 XREADGROUP 之前查 --disk-guard-path(默认当前工作目录)的可用盘;低于守线就不领任务,按 15s 空转重探(stop()/draining 可即时打断),盘回线自动恢复领取;默认 0 = 关闭 |
多 worker 池下低盘机器不再「抢单白跑」(flow preflight 判盘不足 → retry-later,实测一夜 11 次):低盘机零 claim、不烧 delivery、不产生 retry-later 回评,任务留给富盘 worker;退避升档不再把瞬时低盘放大成数小时停机。探测失败(路径不存在)放行——预检自身故障不得停摆整池 |
| 中间态落盘 | FlowWorker.PERSIST_EVERY_N_STEPS(默认 1) |
连续推进每步写盘;崩溃不丢步进进度 |
| 挂起 / 结束 / 出错 | 立即 save_execution_state;返回 False(Redis 后端吞异常的失败形态)即抛 StatePersistError,消息不 ack 走重投 |
落盘失败不再静默成僵尸执行(2026-10 评审修复;start 路径此前已检查,其余调用点统一收口 _persist_state_or_raise) |
| 挂起服务任务派发 | rpush 到 plaita:{subtype}:queue 失败(有 redis 时)抛 ServiceDispatchError;suspended 状态保留、消息重投后重新执行挂起节点再派发 |
重投会重复注册订阅——EventFilter 终态 GC 只回收终态,孤儿订阅留到 TTL 过期(可接受) |
| 并发 start / resume | Redis SET NX EX lease(plaita.server.execution_lease) |
同一 execution_id 最多一个推进者——start 与 resume 同一套租约:start 从落 running 行前 acquire、处理结束 finally 释放,因此长任务的 start 消息被 XCLAIM 重派后,重派者拿不到租约、消息被 ack 释放(#23)。抢租约失败的任务不烧 delivery(ack 释放)。resume 的终态/挂起幂等短路在租约前(#33),但取得租约后必重读状态并重跑前置判定(#241):load→acquire 窗口内另一 worker 若已终态化并释放,本 worker 拿到的虽是新一代租约,也会以盘上状态为准拒绝推进,不用旧快照重跑 |
| 租户状态闸(#27) | 派发前查 plaita:tenant_status(HASH,console 发布/启动回填);停用租户的 start/resume 直接 ack 丢弃(不执行、不留 pending)。删除租户(A50-05)额外按消息体显式 tenant_id 清共享队列(含各消费组 XACK,只 XDEL 会留 PEL 幽灵);缺 tenant_id 的历史/平台消息不删(无法归属) |
「停用租户」对运行面生效:已入队的调度消息、挂起执行的 resume 不再推进。删除是同名重建前的排空点——墓碑翻回 active 后旧 start 不得重放。缺失记录 = active(fail-open),跨 Redis 部署闸门不生效——见 运维 Runbook |
| 控制面 | Registry / Control / Log / Queue / EventFilter 硬绑 Redis | 换 EventBus 后端 ≠ 换部署拓扑 |
选型含义:
- 适合:审批回调、HTTP 回调、延迟唤醒等「挂起等待外部事件」、可接受重复投递(幂等 resume)的场景。
- 不适合:把「恰好一次」「自动故障转移」「金融级幂等」当默认承诺的场景——副作用仍须幂等。
- 崩溃恢复的如实语义(2026-10 更新,#23):worker 崩溃后 pending 里的 start 任务被重投时,会创建全新执行从头重跑(新 execution_id,即从首节点起全部节点重跑——首节点必须幂等)——每步落盘的 checkpoint 不会被 start 任务消费;旧执行会停留在
running状态,需要运维侧用scripts/reap_zombie_executions.py巡检清理(租约在则跳过、条件写落盘——单看last_update_time会误杀长节点)。存活持有者不会被重派双跑:start 消息被 XCLAIM 重派后,重派者因租约被原持有者持有而拿不到租约、消息被 ack 释放(#23);只有持有者真死(租约过期)后重投才会从头重跑。resume 任务的重投是安全的:终态执行会被幂等短路(原样返回,不再推进,也不会被改写状态)。事件/超时 resume 的中间态重投(#258):resume 在流内推进并落过 checkpoint 后(PERSIST_EVERY_N_STEPS=1),瞬态StatePersistError或进程崩溃会让同一条resume_type=event/timeout消息重投;此时盘上是running+$LAST_NODE非 pending 挂起点、订阅已注销,策略层会判协议错误。worker 在 ack 前做状态感知:仍可续跑就把载体归一化为continue(从 checkpoint 续跑,已完成节点不重放;run()兜底先入队新副本再 ack 原条,入队失败/读盘瞬断则不 ack、消息留 pending);真协议错配(如suspended + continue)与陈旧 resume 打在仍有活跃订阅的挂起点上仍按 #33/#221 拒绝并 ack。
CLI:--consumer-group、--consumer-name、--claim-min-idle-ms(默认 60000)、--lease-ttl-seconds(默认 120)、--max-deliveries(默认 5)、--dlq-key。--queue-name 为 Stream 键名(与旧 List 不兼容)。
配置代际与运行期配置(A2R31 / B2R31-01/02/03)¶
worker 启动时经 plaita.runtime_config.load_runtime_config("worker") 一次性解析
queue/redis/group/dlq/预算/安全开关,并计算三个互不替代的代际:
config_family(cf1-…):queue/group/dlq/schema/namespace 的硬兼容键;config_generation(cfg1-…):全部语义字段的可观测哈希(不含 log level/metrics port);budget_generation(bg1-…):max_deliveries/lease TTL/载体年龄/DLQ 上限。
启动日志 event=config.effective 与 registry metadata / 容量块都带这三个值;
GET /api/cluster/config 与 GET /api/cluster/instances 可核对混跑。
消费规则:
- 首投与 reclaim 同判:两端都非
legacy且config_family不同 → 不执行; reclaim 用XRANGE预读,不XCLAIM、不烧 delivery,消息留 pending +event=config.family_mismatch。 budget_generation不同 → 不把新预算作用旧 PEL:留 pending +event=config.budget_stale,要求 drain 或按源预算宽限。- 重投/交接/克隆/DLQ replay 继承源信封代际;缺失按
legacy,绝不盖当前值。 - 历史消息(无字段)始终可执行;畸形字段 WARNING + legacy,不按当前值覆盖。
租户状态纪元:console 写入状态时同时刷新
plaita:tenant_status:epoch(来自 PLAITA_DEPLOYMENT_EPOCH)。worker 的
tenant_is_active(..., expected_epoch=...) 在 epoch 缺失/更旧时拒绝非 default
租户、default 保持 #237 fail-open;Redis epoch 更新时拒绝本 worker。运行中
Redis-only 恢复由 console 周期对账(默认 60s)或
POST /api/cluster/tenant-status/reconcile 纠正;PLAITA_TENANT_STATUS_EPOCH_STRICT=0
是回滚阀(启动 WARNING)。
非法值口径:identity/security/resource 类在进程入口聚合后 fail-fast; optional 类 WARNING + 安全默认;安全门未知值 fail-closed。空串 identity (queue/redis/dlq/group/instance id/delay queue/epoch)一律拒绝,不再写空键。 详见 运维 Runbook 的非法值矩阵。
长步骤与消息回收(claim_min_idle_ms / XCLAIM)¶
一条任务从被 XREADGROUP 读到 XACK 前一直留在 consumer group 的 pending
列表。单个步骤执行超过 --claim-min-idle-ms(默认 60000ms)后,这条
「仍在处理中」的消息就对其他 consumer 的 XCLAIM 回收可见——回收本身不是
故障,双跑由执行租约拦截(start 与 resume 同一套租约,#23):
- 抢到消息的 worker resume 时拿不到租约 →
ExecutionLeaseError。冲突分支 先核实持有者存活再决定 ack(#50,2026-10-08):租约值嵌有持有者的 注册表 instance id,回收方查plaita:registry:flow_worker:{instance}—— 在册且status=running(活持有者,正常竞争)→ ack 释放(否则对端每claim_min_idle_msXCLAIM 一次烧一次 delivery,超限即假死信);不在册 (持有者已死、租约键是 ≤TTL 的尾巴)→ 不 ack 留 pending,等租约过期后 下一轮回收从 checkpoint 续跑(代价 ≤TTL+60s,远小于 zombie reap);在册 但处于 draining/stopping/stopped/starting(S18-02A)→ 同样不 ack: 这些状态不保证收尾,ack 会释放执行的唯一重投载体。信号缺失 (旧格式租约 / Redis 瞬断)按「存活未知」退化 ack;未启用注册表 (--no-registry)例外(B65-03,2026-10-20):该形态下存活信号静态 不可得,把「存活未知」当「活持有者」会在持有者被冻结(SIGSTOP/GC/ 调度饥饿,租约仍有效)时 ack 掉唯一消息——冻结者恢复后lease_lost退出,执行停在running且无载体;故该分支一律不 ack,留 pending 等租约过期后 reclaim(租约在时死信守卫拒绝死信,不会回归假死信风暴);PLAITA_DISABLE_HOLDER_LIVENESS=1整体旁路回到无条件 ack; 决议载体例外(A46-04/A47-F2,2026-10-19):resume_type为event/timeout/cancel的消息不看存活信号、一律不 ack——suspend 提交窗口[订阅注册, 租约释放)内到达的它们不是重复载体,而是挂起执行 唯一的唤醒来源(#33);持有者落盘 suspended 后不会再读它。消息留 pending, 租约释放后由 reclaim(或 #242 死信守卫重入队副本)推进;代价是最多max_deliveries × claim_min_idle_ms的延迟与 XCLAIM churn。continue/retry/start无推进语义,仍按上面的存活判据 ack; - 持租约的活 worker 由看门狗每 lease TTL/3(默认 120s → 40s)续租,步骤
执行期间租约不会过期——XCLAIM 真正接手的只有已死 worker 的消息;续租成功
时顺带把在跑节点的进度写进
node_timings(长节点期间唯一的活性心跳, 见下节「在跑节点心跳」); - 退化路径:
PLAITA_DISABLE_LEASE_WATCHDOG=1且单步超过 lease TTL 时, 租约可能在步骤中途过期、接管者拿到更新的 fence 世代——旧 worker 的 下一次落盘被 fencing CAS 拒绝、步界续租失败自爆(均ExecutionLeaseError且不 ack),状态不会被双写,但当前步副作用可能重复,业务侧仍须幂等。
因此调小 claim_min_idle_ms 只加快「死 worker 消息」的回收,不会中断活
worker 的执行;调大到超过最长步骤耗时,可减少 lease_conflicts 噪音。
对 start 同样成立:无租约时代的「数小时 run 消费 60s 后被空闲 worker
重派、整条 flow 从头双跑」已被租约窗口根除(#23);持有者死亡后重派消息
照常取得租约接管——start 的接管点是从头重跑(checkpoint 由后续 resume
消费),租约只保证「同一时刻至多一个推进者」。
死信守卫:超过 --max-deliveries 的消息进 DLQ 前,先查消息体
execution_id 的执行状态与 resume 租约(键 {ns}:execution:lease:{id},ns 按消息体
tenant_id 路由:default/空 = plaita,其余 = plaita:{tenant_id})——
执行已终态直接放行;租约仍在(活 worker 正处理长步骤)则跳过死信、消息留
pending;执行仍非终态但节点重试计数已达预算(见下节)则终态化 error 后
放行(不再重入队——重入队会让 delivery 归 1 再耗尽,无限循环);租约/状态
查询失败同样保守跳过;非终态且租约空(持有者已死)重入队一份 delivery 归 1
的恢复消息后放行。
死信路径收敛(#252):run() 兜底异常分支
(ExecutionStateLoadError / StatePersistError 等瞬态失败的落点)、
_reclaim_one 的超限处置、超 max_schema_version 的信封拒收(首投与回收
同判)以及畸形 payload 处置,走同一 dead_letter_guard 判定(worker 侧经
队列层 dead_letter_if_allowed 单一入口):守卫拒绝/抛异常时不 ack,消息
留 pending 等下一轮处置并推进 dlq_guard_skipped;守卫为 None(默认)恒
放行,存量行为不变。DLQ 信封固定带 schema_version(畸形消息另带
raw_fields),审计与重放不靠猜。
超版本拒收不触发死信守卫的「克隆恢复」(A49-01):守卫在「执行非终态 +
租约空」时会重入队一份恢复副本再放行原条死信,但该恢复路径对版本拒收不
成立——副本继承同一个 schema_version(A44-02),本 build 仍然无法消费,
下一轮读到副本又会拒收并再次克隆,形成流内恒 1 条、DLQ 每轮 +1 的自持热环
(回滚 / 生产者先行 / 伪造信封)。队列在拒收时给任务打 schema_rejected 标记,
FlowWorker 的守卫对该标记不克隆、不终态化,只放行死信一次(留证 + 告警);
执行保持原状(suspended 仍可被 event/cancel/timeout 唤醒),运维升级后可经
DLQ 重放入口恢复载体。租约仍在(活 worker 正处理)时仍拒绝死信,消息留
pending 等新 build。非版本拒收(如 max_deliveries)的恢复克隆语义不变。
DLQ replay 契约(A53-S2):人工回灌统一走 plaita/server/dlq_replay.py
(console 端点 POST /api/queues/dlq/{dlq_id}/replay,平台管理员专属),
不要直接 enqueue_task:
- 以
(dlq_key, dlq_id)写一次性标记(plaita:dlq:replay:{key}:{id},7 天); 第二次回灌返回already_replayed,不发第二条消息; schema_version继承源信封(缺失 = v1);超过本 build 理解范围时在入队前 具名拒绝schema_too_new,不制造「入队即再死信」的副本;- 无
execution_id的 start 载体铸造确定性 id(uuid5(dlq_key, dlq_id))并补dedup_key:标记过期/force 重灌仍收敛到同一条执行; - 执行墓碑命中 →
execution_deleted(不复活已删除执行);缺tenant_id必须由 平台显式指定,payload 租户与显式租户冲突 →tenant_mismatch; - DLQ 原条目不删除;每次尝试写
plaita:dlq:replay:audit。保留上限PLAITA_DLQ_MAX_LEN是有损 FIFO:裁剪会丢弃未处理证据,dlq_trimmed/dlq_trim_failures指标与 WARNING 可见。
版本边界对首投与回收同判:_reclaim_one 在 XCLAIM 之后、返回
消费循环之前做与 xreadgroup(">") 相同的 schema_version > max_schema_version
校验。消费者在 schema 检查/死信 ack 前崩溃时,同一条消息不会因「重启后走
reclaim」就从死信留证变成旧语义硬跑。
worker 作为生产者的版本继承:worker 的重投/交接/归一化副本
(start 幂等命中的 resume、节点可重试失败、亲和交接、死信守卫恢复、超配额
延迟、#258 载体归一化)继承源消息的 schema_version,而不是无条件盖
当前 build 的写版本。混跑池里新实例写出的恢复副本仍能被旧实例接管;只有新
生产消息(console BFF / 调度 / event_filter)才写当前 TASK_SCHEMA_VERSION。
因此 worker 自身的灰度不再要求「整池原子升级」,但这些新生产者服务仍须按
「先升消费者、再升生产者」的顺序发布(见 Runbook)。
--max-deliveries 计的是处理尝试次数:xreadgroup(">") 交付的首投
(delivery_count=1)此前零处理尝试,绝不会被预判死信——--max-deliveries 1
仍允许这一次尝试,只有该次尝试失败/未 ack 后(worker 兜底分支或 reclaim)
才进 DLQ。
部署步骤与故障手册见 运维 Runbook;副作用设计见 幂等 Resume。
claim 前的本机盘预检(#49)¶
问题(2026-10-08 实测):多 worker 池里,「本机盘够不够」这件事此前只有
flow 内部的 preflight 节点在管(跑在领取任务的宿主上:os.statvfs(repo)
→ free_gib < 阈值 → 返回 retry-later)。而 worker 是「谁先 XREADGROUP
谁拿走」,于是一夜出现 11 次连续白跑:低盘机先抢到任务 → flow preflight 判
盘不足 → retry-later 回评 + 重排;同时刻富盘机完全空闲(队列 XLEN=0)。
更糟的是 keeper 的 retry-later 退避会升档——瞬时低盘被放大成 ≈6h 停机。
修法:把「盘够不够」的判断提到 claim 之前,由 worker 自己做:
python -m plaita.server.flow_worker \
--min-free-disk-gib 20 \ # 或 PLAITA_WORKER_MIN_FREE_DISK_GIB=20
--disk-guard-path /home/user/repo # 或 PLAITA_WORKER_DISK_GUARD_PATH(默认 .)
语义与边界:
- 阈值 0(默认)= 关闭,与
PLAITA_WORKER_DENY_REPOS同口径——存量部署 零行为变化,是否启用由运营侧显式配置; - 低于守线时根本不发
XREADGROUP(也不 XCLAIM 回收别人的 pending): 不产生 claim、不烧 delivery、不写retry-later回评,任务自然落到池内 富盘 worker 手里; - 空转重探默认 15s(
disk_guard_poll_seconds),休眠切成 ≤0.2s 小片,SIGTERM/request_drain()可即时打断;盘回线自动恢复领取,无需重启; - 预检路径要与 flow 内 preflight 节点的判据对齐(recursive 侧为
RECURSIVE_MIN_FREE_DISK_GIB,默认 20):worker 侧阈值低于 flow 侧时仍会 白跑一轮,worker 侧高于 flow 侧则会过早停领(池内可能整体闲置); - 探测失败(路径不存在 / 无权限)放行:预检自身故障不得让整池停摆, 宁可回到「可能白跑一次」的旧行为;
- 该闸只管领取:已在跑(或本进程 pending 里)的任务照常跑完/重投, draining、租约、死信守卫等语义一律不变。
配套的运营动作见 运维 Runbook · 按宿主资源路由。
节点级有界重试(2026-10 二波)¶
分布式路径上节点执行失败(LLM/HTTP 网络抖一次)不再直接废掉整个执行:
- 判别:
run_distributed把异常归一化为FlowErrorException(原始异常在__cause__)。链中出现NodeExecutionError(节点执行异常)→ 可重试; 但其__cause__里的原始节点异常按 node-F2 同一分类复核(A50-03): 1001 / 非校验ValueError是确定性错误,不重试(否则 abort 包装层会把 core 的「执行 1 次」重新判成可重试,同一错误仍被 worker 重投 5 次);#219 输出 校验异常仍可重试;1002/1003 与未知码维持可重试; 链中出现超时(NodeTimeoutError/FlowTimeoutError,含执行级ExecutionDeadlineError——它是FlowTimeoutError子类,#267)或取消 (FlowCancelledException)→ 维持现状终态化 error;deadline 到期不会重投 (_error_fields保留 code=-1 /flow_error,可从执行状态收尸); 恢复守卫类ResumeGuardError——策略层在分发到节点resume()之前 逐个标记出来的那几个位点(continue/retry 想绕过 pending 挂起节点、resume 打在 无挂起/非挂起 checkpoint 上、给出非 continue 的 resume 意图却没有 checkpoint), 语义是「调用方协议与执行状态不匹配」而非「执行失败」(#33)→ 不终态化: 执行保持原状并抛ResumeProtocolError,run() 对其 ack(重投只会重复命中同一 守卫。这类错误天然由 at-least-once 重投产生——重复投递的eventresume 打在 已推进的 checkpoint 上同族——把挂起执行终态化成 error 会永久切断 event/cancel/timeout 的唤醒路径)。裸ResumeError不在豁免面内(含未标记的Unsupported resume type for EventNode):strategies._handle_resume把current_node.resume()抛出的任何异常 (事件数据畸形、节点恢复逻辑失败…)包成的ResumeError是执行自身失败, 静默保持原状只会变成一个「无 error 记录、永远等不到决议」的哑执行——它走 下面的现状路径(可重试判据 → 终态化 error + poison ack),可观测、可人工 retry;其余图错误(NodeNotFoundError等)→ 维持现状终态化 error。 超时不重试是刻意的:确定性信号重试=再烧一次全款。 - 载体:at-least-once 消息重投本身。重试时执行不终态化(磁盘 state
停在最后成功步 checkpoint——失败节点不写 context),run() 显式重投新副本
(先入队再 ack;重投前按重试次数指数退避:
min(30s, 1s×2^(n-1)),即 1s/2s/4s/8s…封顶 30s——plaita#91:旧实现「入队即重投」无任何节奏,5 次预算 ~40ms 烧光,持续数秒的上游抖动本可自愈却被直接打成 error 终态。退避睡在消费 线程内,单 worker 会推迟同队列其他任务 ≤30s;队列级延迟投递(ZSET/延迟 Stream)是演进项。cap 30s <claim_min_idle_ms(默认 60s),睡窗内原条 pending 不会被对端 XCLAIM 抢走)。resume 从 checkpoint 自然重跑失败节点。 start 载体的重投带 resume 语义(plaita#91):盘上已有同一execution_id的状态行时,重投副本转换为{"type": "resume", "resume_type": "continue"}载体(与 start 幂等命中_handle_start_dedup_hit的转换同形状,非空params随载体透传),从 checkpoint 续跑——不从首节点整条重放、不用context={}覆写盘上 checkpoint;盘上无行(首持久化前崩溃)、flow_id 与 盘上行不一致或读盘失败时才原样重投 start。flow_id 不一致的 start 载体 由start_flow查盘后具名拒绝(ExecutionIdConflictError,台账第三十一轮 V1):同一 execution_id 已属于另一条 flow 时既不能转 resume(会被流程 ID 校验打成 poison ack),也不能按首投新建context={}行(会永久覆写原 flow 的 checkpoint)——拒绝并保持原状态,消息按畸形载体 poison ack。重投路径不依赖 pending 回收: 消息可能已被竞争者的ExecutionLeaseError分支 ack 释放,只留 pending 会导致重投永不发生(2026-10-09 plaita#52 实证)。 重投失败(Redis 抖动)才退化为不 ack 留 pending;两种情况都不计 poison。 - 预算:重试计数键
{ns}:execution:noderetry:{id}:{node_id}(INCR + 滑动 7 天 EX,租户路由与租约键同规则),默认预算 =--max-deliveries(5)。耗尽 → 终态化 error(error.node_retries记录重试次数、error.failed_node记录失败 节点 id),消息走 DLQ。刻意不用消息delivery_count判预算:回收路径上报 的是 XCLAIM 前的投递数(少计 1),且达限消息在队列层就地死信、不进处理函数 ——按它判预算永不触发。 计数语义是「当前节点的连续失败次数」,键也必须按节点维度隔离 (plaita#123 真因,2026-10-10):此前键只含execution_id(执行维度),而成功 推进时把整个执行的计数清零 ⇒ 在「前序节点稳定成功 + 某节点稳定失败」的 flow 上,前序节点每轮成功都把失败节点的计数抹平 ⇒ 永远到不了预算 ⇒ 永不终态化 ⇒ 无限重投(实测 2026-10-10 13:0x 本机 Redis db1:keeper-watch累积 667 个悬停running执行、最老 40 小时。该数字为当时快照、随时间增长, 勿当常量;复现需在同期环境用SCAN plaita:execution:*采集)。 现版:键带失败节点 id(取自异常链NodeExecutionError.node,不能用 checkpoint 的$LAST_NODE——失败节点不写 context,那是上一个成功节点),成功推进只清该 节点的键。取不到节点 id 时退化为执行维度键(保守,不比修复前差)。 存量扁平旧键(无节点维度)不迁移、不被新逻辑读取,靠 7 天 TTL 过期;代价是 升级瞬间在途执行多拿一轮预算。 - 兜底总上限(plaita#123 评审建议):
{ns}:execution:noderefetch:{id}(INCR + 7 天 EX,不含任何会漂移的段)——只按execution_id累计「本执行 一共放行过多少次节点重试」,达NODE_RETRY_TOTAL_MAX(默认 100)即终态化 error。 节点维度预算依赖「键里的节点段稳定」,任何键空间漂移(节点 id 动态变化、定义被改) 都会让单节点预算失效而退回无界重投;该计数不看键名、只看次数,是「预算机制 整体失效」时的最后一道闸。正常执行永远碰不到(100 ≫ 单节点预算 5 × 合理节点数)。 - 与 G1 retry 唤醒的组合(43828aa):预算耗尽终态化的执行仍可经人工
resume_type=retry唤醒(error 态断点续跑)——唤醒放行即清零该执行下所有节点 的计数键,人工唤醒后拿全新预算;flow 定义指纹校验先于唤醒,定义被改时执行保持 error(修复定义后仍可再 retry)。 - 唤醒/失败次数是有界的(plaita#73,2026-10-10):只清预算不限次数的循环是
自毁的(
唤醒 → 清零预算 → 跑 5 次 → 又终态化 → 再唤醒,实测某执行一天被 唤醒 5 轮、每轮烧 5 次沙箱)。两道上限都落在FlowWorker类常量上, 达限时resume_flow对retry幂等拒绝(返回already_terminal=True+g1_wakeups_exhausted/deterministic_failure_exhausted,不抛异常、不改 状态),消息层重投随之短路(仅对error态执行,见下节 running 执行的 retry): {ns}:execution:g1wakeups:{id}(G1_MAX_WAKEUPS,默认 2):同一执行被retry唤醒的次数。人工与自动共用这个额度——达限后运维点「从断点重试」 不再生效(BFF 会返回 409 并给出要删的键,见 ops-runbook);{ns}:execution:nofail:{id}(DETERMINISTIC_FAILURE_MAX,默认 12): 连续确定性失败次数(exited 1/sync_in/ 超时…)。任一节点成功推进 即清零,跨节点的偶发失败不会累计判死;G1 唤醒不清零(否则永不收敛);- 人工解封:确认失败原因已修好后
DEL对应计数键再 retry——键名与步骤见 运维 Runbook · 唤醒预算达限。 - 引擎 execution_id 必须与 worker 一致(plaita#123 配套):worker 的 resume
与步进两处
run_distributed显式传execution_id=execution_id。不传时引擎侧 execution_id 与 worker 脱钩——恢复分支走context.context = saved_context整体 替换 checkpoint,若 checkpoint 缺$EXECUTION_ID(resume 常见),引擎 execution_id 退化为空串 ⇒result.execution_id回空、重试计数键沦为空 id (运维无法从键反查执行)。注意这不会导致无限重投:旧代码_node_failure_retry_decision对空串直接return None(连键都不写、直接终态化), 属可观测性缺陷而非重投根因——重投根因见下方「节点维度预算」。 - 节点维度预算(plaita#123 真因,2026-10-10):计数键
{ns}:execution:noderetry:{id}:{node}按节点隔离,成功推进只清该节点 的键。此前键只含 execution_id(执行维度),而成功推进清整个执行的键 ⇒ 在 「前序节点稳定成功 + 某节点稳定失败」的 flow 上,同一轮内前序节点成功先清零、 失败节点随后 INCR 归 1(strategies.py:"Execute one node per call",一次调用 只推进一个节点),计数永远到不了预算 ⇒ 永不终态化 ⇒ 无限重投 (实测 2026-10-10 13:0x 本机 Redis db1:keeper-watch累积 667 个悬停running执行、最老 40 小时;该数字为当时快照、随时间增长,勿当常量; 复现需在同期环境用SCAN plaita:execution:*采集)。 另设兜底总上限{ns}:execution:noderefetch:{id}(NODE_RETRY_TOTAL_MAX,默认 100),不看键名只看次数,防未来任何键空间漂移再次退化为无界重投。
挂起执行的 continue/retry 幂等短路(#33)¶
suspended 执行收到 resume_type=continue(挂起双写窗口 crash 后重投的原消息/
start 重派竞速/运维误发)或 retry 时,策略层 pending 守卫必拒(continue 不允许
绕过挂起节点)。resume_flow 在租约之前幂等短路:返回
already_suspended=True,不推进、不改状态、不取租约——挂起执行只能被真正的
决议路径(event/cancel/timeout)唤醒。此前的行为是守卫 ResumeError 被通用
except 终态化成 error,一次重复投递就把可恢复的挂起执行永久打封(终态短路从此
拒绝一切 resume 类型,retry 也不可入,死局)。兜底路径(绕过入口短路的竞态窗口)
由 ResumeProtocolError 豁免承接:只有策略层守卫 ResumeGuardError 不终态化、
执行保持原状、消息 ack(豁免面刻意窄,见上节「判别」——节点 resume() 自身抛错
仍终态化 error)。
租约冲突窗口内的决议载体不 ack(A46-04 / A47-F2,2026-10-19):suspend
提交窗口 [订阅注册, 租约释放) 内到达的 event/timeout/cancel 消息,即使
执行推进租约被在册 worker 持有,也不得按「重复载体」ack——持有者可能正在
落盘 suspended,释放租约后不会再读这条消息,而它是挂起执行唯一的唤醒来源。
worker 对这些载体一律不 ack(信号缺失也不退化 ack),消息留 pending,租约释放
后由 reclaim / #242 死信守卫副本推进。continue/retry 不携带推进语义,保持
原有的「持有者在册即 ack」语义;#258 的协议错配状态感知(ResumeProtocolError
分支)是另一个窗口,两套判据互不影响。
取消意图优先(台账第三十四轮 ④):短路前先查取消标志键
({ns}:execution:cancel:{id})。挂起前控制面可能已在 running 阶段写下取消
意图;若此处照常早退,消息被 ack、盘上保持 suspended、flag 仍在——独立的
cancel 载体一旦丢失,执行会带着取消意图长期挂起。命中 flag 时不短路:
转正常路径取租约、在租约内复核并用 resume 入口既有的取消检查点终态化
cancelled(保留取消点前 checkpoint)。无取消意图时「不取租约」契约不变——
只有确需终态化写盘时才取租约。
running 执行的 retry 与分支未命中终态化(#53)¶
工单背景:值守实测两例条件假分支后执行停 running 88 分钟(无 error、无终态),
且对 running 执行 POST /resume {"resume_type":"retry"} 返回「已加入队列」却
一行不推进(node_timings / last_update_time 双双不动)= 假受理。
- 分支未命中必须显式失败,绝不悬死
running:if/switch全部分支未命中 且无 default 时,plaita/core/strategies.py的_get_next_from_last(distributed)与_advance_one(normal/generator)抛FlowExecutionException→ worker 把执行终态化为error(error 里点名节点 id), 不再带着$NODE中间态静默收尾、也不停在running。if无 else 的两种形态都 被这一道拦下:else_next=""(分支未命中)与else_next="false"(if 节点的 占位默认值指向不存在的节点 →NodeNotFoundError)。唯一逃生口是节点显式errorHandler.strategy=continue(降级为 warning +$NODE收尾,与 0.5.x 历史 行为一致)。升级核对:线上 worker 必须含该防御——不含该防御的版本上,条件 假分支无后继就是「不派发 / 不落终态 / 永久running」(工单「未证实」项)。 running+retry= 断点续派:running执行没有「失败节点」可重跑,retry与continue同路,从 checkpoint 派发后继节点,并留一行 WARNING 说明「按从断点续跑处理」。两道唤醒预算幂等拒绝(g1wakeups/nofail) 只对status=error生效(plaita#53):计数键寿命长于状态行(终态化写盘 失败、状态被回滚等都会留下「running + 计数达限」的组合),若把running也按「已终态」拒绝,返回的already_terminal=True就是谎报终态 + 一行不 推进——即工单里的假受理。- resume 被丢弃必须可见:
continue/retryresume 在推进租约被在册 worker 持有时按既有语义 ack 释放(#50/#52:留 pending 会烧 delivery 并产生 假死信),但会额外打 WARNING「本次 resume 未生效(不产生任何节点活动)」 并给出下一步(确认卡死后人工 cancel 再按需重开)——值守据此区分「已救活」 与「白等」。每次点「从断点重试」都返回 200 却毫无推进时,先 grep 这行日志。event/timeout/cancel决议载体不在此列:它们一律不 ack、留 pending 重投(A46-04/A47-F2,见「挂起执行的 continue/retry 幂等短路」一节)。
在跑节点心跳与 keeper 活性判据¶
执行状态的落盘只发生在节点边界(_persist_state_or_raise 的 step_persist /
终态等路径),而 sandbox_agent 这类单个长节点(实测 35 分钟)期间一次落盘
都不发生 → node_timings 停在进入该节点之前。keeper 的活性判据①「有
started_at、无 ended_at 的节点 = 活证据」于是读不到它,落到判据③「末节点
ended_at 停滞超 1800s」→ 健康 run 被误判 zombie 并 cancel(2026-10-10
27/#31/#32/#55/#62 五单事故)。¶
修法:看门狗续租成功后顺带落一次在跑节点进度(_publish_node_progress,默认
周期 = lease TTL/3 ≈ 40s)——只合并 node_timings(在跑节点写
ended_at: "")并刷新 last_update_time,不推进流程、不改 context。
写侧的两道约束(2026-10-10 评审):
- 与推进写串行:心跳是「load → 合并 → save」的整行写回,与推进线程的
_persist_state_or_raise是同一条状态行上的两个写者。若终态写落在心跳的 load 与 save 之间,心跳会把它整行回滚成running+ 旧 checkpoint + 新last_update_time——既不会被 keeper 回收(open node + 新鲜last_update_time正是它的「活着」判据),后续 resume 还会从旧 checkpoint 重放节点副作用。两者现持同一把每执行互斥锁(_state_write_lock)。 - 跨进程过 fencing:心跳跑在看门狗线程里,那里没有本执行的 fence 世代
ContextVar、也没有租户上下文——不带就会(a)写错租户 namespace(多租户下
心跳整条失效)、(b)退化成
FencedExecutionStorage的裸写分支、绕开世代 CAS(租约已被新世代接管时旧心跳仍能改写新世代的行)。现在 fence 世代与租户 随租约登记一起带上,世代不符即拒绝写(ExecutionLeaseError,日志留痕)。
keeper 侧必须给判据①配年龄界:心跳把在跑节点写进
node_timings后, 「open node」既可能是活 worker(每 ~40s 刷新一次last_update_time),也可能是 被杀的 worker 留下的最后一份心跳——单看「有started_at、无ended_at」 会把后者永远读成「活着」(keeper 判据① 原样没有年龄上限)。读法应为 「open node 且last_update_time在 N×心跳周期内(建议 N=2~3)」,再叠加 租约键 / worker 注册表在册与否兜底;否则僵尸 run 既不进 reap 路径、也没人 收尾。
运行中改定义(flow 定义指纹,2026-10 二波)¶
worker 的流程定义 TTLCache 有 300s 窗口、console engine_sync 可直接覆盖 Redis 定义——挂起执行 resume 用 latest 版本定义时,若定义自启动后被改,图遍历会 找不到节点/走错分支且无告警。现版语义:
start_flow对实际加载执行的 Flow 计算指纹 (sha256 ofmodel_dump(mode="json")+ sort_keys 规范化 JSON)写入ExecutionState.flow_hash;resume_flow取得租约后比对:不一致 → 终态化 error(message 写明「flow 定义自启动后已变更,hash 不匹配,执行无法安全续跑」,附前后指纹)+ 消息 poison ack——故意选可观测的终态而不是重投风暴(定义被改是确定性不一致, 重投 N 次结果相同);- 老状态
flow_hash为 None(旧版本 worker 写入)→ 跳过校验,零回归; - 在租约内判定是为了与活 worker 的推进写串行化:不会把他人正持有租约推进中 的执行误终态化。
运维含义:改定义前应确认没有 running/suspended 的在途执行(console 按 flow_id 查询非终态执行);确需强升时接受在途执行被终态化 error、由上游重提。
start 幂等键 dedup_key(2026-10 二波)¶
start 消息重投(worker 崩溃/保存失败)历史上会新建 execution_id 从头重跑, 首节点副作用双份。现版支持调用方显式声明幂等键:
- 消息体可选
dedup_key(console BFFPOST /executions请求体 additive 字段 同名透传):worker 在首节点执行之前以SET NX EX 7d原子认领{ns}:start-dedup:{key},映射值 = G1 预铸的 execution_id(BFF start 时 铸造随消息透传)或就地铸造的 id——认领在先行落 running 行之前,重投/ 双开在任何新行落盘前即被拦截; - 命中 → 读映射的执行状态,绝不二次 start:已终态 → 返回 already 形状; running(崩溃/重试搁浅)→ 重入队一份 resume 消息接续执行再 ack start; suspended → 只返回现状形状(挂起执行自有 delay/approval 的 resume 链路);
- 孤儿映射(认领后首次执行从未落盘,如 crash 在 claim 与先行落行之间)→ 释放后重新认领、按新启动继续(首节点可能重跑——首节点须幂等仍是既有约定); G1 先行落行后该窗口已收窄到极小;命中 running 的重入队 resume 对 context={} 的先行行同样正确(无 last_node_id → 从首节点步进); 重入队的 resume 若与原 start 持有者并发,由执行租约串行化(#23);
- 不传
dedup_key则行为与存量完全一致;键必须调用方显式提供,worker 不做 body hash 自动键——同参数定时任务(cron 每小时跑同一 flow)会被误判 为重复启动而永不执行。键按租户隔离,7 天过期。 - 调度服务是唯一的自动加键方(#23 引入,#118 修正基准):
fire_schedule(cron 循环与 console「立即触发」共用)入队的键: - cron →
sched:{schedule_id}:due:{due_ms},due_ms= 本次到期时点next_run_at(epoch 毫秒)。同一个 due 无论被触发几次(状态回写失败 + 30s 互斥锁过期重触发、消息 XCLAIM 重派)都得到同一个键,重派/重投收敛到 同一 execution,不产生平行执行;不同 due 的键必然不同,下一周期不会被误吞。 旧版键取「触发墙钟秒」,同一个 due 的两次触发落在不同秒即成两个键,双跑。 - manual →
sched:{schedule_id}:manual:{ns}-{seq},每次调用唯一,与 cron 的 键空间不相交——手动「立即触发」是显式要求再跑一次,不得与 cron 同秒互相吞并。 - cron 且既无
due_at也无可用next_run_at时降级为sched:{schedule_id}:fired:{墙钟秒}并 warning(调度循环会先自愈next_run_at,该路径只在定义缺失且调用方未传 due 时可达)。 手动POST /executions仍不自动加键。
职责¶
- 从
FlowStorage加载流程定义(带 TTL 缓存) - 用
ExecutionStorage保存/读取执行状态 - 推进 Distributed 流程:跑到挂起节点,或从断点恢复
- 与 EventFilter / 外延服务配合:外部事件入队后由 worker
resume - 贯穿所有步骤地保留用户回调(须复用同一
FlowExecution实例)
构造¶
from plaita.server.flow_worker import FlowWorker
from plaita.storage.redis import RedisExecutionStorage, RedisFlowStorage # 需 redis extra
from plaita.event.redis import RedisEventBus
worker = FlowWorker(
execution_storage=RedisExecutionStorage(client=redis_client),
flow_storage=RedisFlowStorage(client=redis_client),
event_bus=RedisEventBus(client=redis_client),
cache_size=100,
cache_ttl=300,
callback_handlers=[MyCallback()],
)
| 参数 | 说明 |
|---|---|
execution_storage |
执行状态存储(ExecutionStorage,同步契约) |
flow_storage |
流程定义存储(FlowStorage) |
event_bus |
事件总线 |
cache_size / cache_ttl |
流程定义 TTLCache 容量与过期秒数 |
callback_handlers |
贯穿所有分布式步骤的回调列表 |
流程定义缓存¶
get_flow_definition(flow_id, version) 先查 TTLCache,未命中再查 FlowStorage 并回填,避免反复反序列化流程 JSON。缓存键为 flow_id:version(version 缺省时用 latest)。
推进与恢复¶
flowchart TD
Start["队列任务 start/resume"] --> Load["加载流程定义
(缓存/存储)"]
Load --> LoadState["从 ExecutionStorage 读 context"]
LoadState --> Run["FlowExecution.run_distributed"]
Run --> Suspend{"is_suspend?"}
Suspend -- 是 --> Save["立即保存 context"]
Save --> Wait["等待下一事件入队"]
Suspend -- 否, is_end --> Done["标记 completed
立即保存"]
Suspend -- 否, 还有节点 --> Maybe["每步落中间态
PERSIST_EVERY_N_STEPS=1"]
Maybe --> Run
状态模型¶
ExecutionState(plaita.storage.base)记录一次执行的完整状态:
| 字段 | 含义 |
|---|---|
execution_id |
执行 ID |
flow_id / flow_version |
所属流程与版本 |
flow_hash |
启动时 Flow 定义指纹(可选;老状态为 None,resume 时比对防运行中改定义,见上) |
flow_hash_algo |
指纹算法标记(可选)。resume 时分级判定:算法同→严格比对;算法不同但指纹相同→直接续跑并刷新标记;算法不同且指纹不同→需显式 allow_flow_hash_change 放行。见 ops-runbook「flow_hash 兼容门」 |
engine_version |
创建该执行的引擎版本(plaita.__version__,可选)。仅观测:跨 minor resume 打 WARNING,不拦截;不随 resume 覆写(保留「创建者版本」语义) |
context |
执行上下文(即 Checkpoint) |
status |
running / suspended / completed / error |
start_time / last_update_time / end_time |
时间戳(ISO 字符串) |
error |
错误详情(status=error 时;节点重试耗尽的 error 附 node_retries) |
invoker |
发起方标识 |
node_timings |
节点级耗时(可选):node_id → {started_at, ended_at, started_ms, ended_ms, duration_ms, total_duration_ms, attempts, failed}。由 worker 按执行挂载的 NodeTimingCallback 采集、在落盘收口处写入;同节点多次执行(循环/重试)时 duration_ms 取最后一次、total_duration_ms 累计、attempts 计数。老状态/宿主未挂采集器时为 None,读取方必须按缺省处理。 |
存储后端¶
生产路径(FlowWorker CLI / create_storage_component)仅支持同步契约后端:
| 后端 | 模块 | extra | 备注 |
|---|---|---|---|
| memory | plaita.storage.memory |
— | 单测 / 本地 |
| redis | plaita.storage.redis |
redis |
推荐生产默认 |
ExecutionStorage 接口为同步方法:save_execution_state / load_execution_state / delete_execution_state / list_executions,并提供 serialize_state / deserialize_state(JSON)。
Redis 后端的 delete_execution_state 是删除即吊销:同一 pipeline 内删除状态键、列表索引成员、{ns}:execution:fence:{id} 当前世代键、{ns}:execution:lease:{id} 租约键,以及该执行的计数键(nofail / g1wakeups / noderefetch / noderetry(含 :{node} 子键)/ affinityhandover)。在途 worker 下一次落盘会因 fence 世代失配抛 ExecutionLeaseError,不会把已删执行的状态写回;计数键同删保证同一 execution_id 重建后预算从零开始。世代计数器 {ns}:execution:fencegen:{id} 刻意不删、无 TTL:try_acquire_fenced 只对它 INCR,删除后新 acquire 的世代严格大于旧世代,旧 worker 的迟到 save/delete 才会被 CAS 拒绝(A2R13 ABA 第三格)。FencedExecutionStorage.delete_execution_state 在调用方持世代号时先做 fence CAS——旧世代不能删除新世代已推进的状态。
删除墓碑(A51-01/A52-01/A69-04):同一 pipeline(fenced 档同一段 Lua)还会写平台根键 plaita:exec_tombstone:{execution_id},值含 tenant / deleted_at / reason。删除 API 返回后,任何仍在队列/PEL 里的 start 载体都不再被 start_flow 的「盘上无状态 = 全新执行」分支重建:worker 在派发闸(配额之前)、start_flow 直达调用、start 幂等命中、节点失败重投与死信守卫克隆前复核墓碑(同租户才命中;跨租户同 id 放行)。新 execution_id 不受影响;同 id 重放被 ack 丢弃并释放 dedup 映射。墓碑 TTL 独立默认 30 天:max(30d, PLAITA_EXECUTION_STATE_TTL_DAYS) 只作「不短于 state TTL」的下限,STATE_TTL=0 不再产生永久墓碑;PLAITA_EXECUTION_TOMBSTONE_TTL_SECONDS<=0 视为非法回退默认 + WARNING(不再用 0 隐式表达永久),并 clamp 到 PLAITA_TASK_MAX_AGE_SECONDS。二次删除用 SET NX + 只延长 TTL,保留首次 deleted_at(升级前的存量永久墓碑不自动缩短)。墓碑过期后的历史载体由年龄闸丢弃:年龄取 payload timestamp 与 Redis Stream ID 时间的最大值(未来时间戳或重投后的新 Stream ID 都不能洗白源年龄),两者都不可判时 fail-closed(WARNING + /metrics carrier_age_unknown_total);PLAITA_TASK_MAX_AGE_SECONDS 默认 30 天,0 关闭(启动 WARNING)。重投/交接/克隆副本保留源年龄(取 payload timestamp 与源 Stream ID 时间中更早者;payload 畸形/超前时以源 Stream ID 为准);DLQ replay 缺失 timestamp 时用 dead_lettered_at 或 DLQ Stream ID,绝不刷新成 now。租户清理(_purge_tenant_redis)不触碰平台根墓碑,因此租户删除+同名重建后旧 start 仍不会执行。残留(B52-02 horizon):攻击者在删除之后同 id 投递并压在队列/PEL 里超过墓碑 TTL,墓碑过期后仍可能重建——需租户/队列世代裁决,不在本条闭合范围。
恢复路径的墓碑/世代语义(A2R25-04/05):墓碑同样拦 resume 载体——删除后从备份只恢复状态键不会撤销删除。resume_flow 在 load 之后、取租约之前(以及租约内复核)命中墓碑即具名返回 deleted,绝不 run_distributed;派发闸对 resume 与 start 同口径丢弃。fenced save/delete 的 CAS 还要求 fence 与 fencegen 都等于本次世代:只恢复旧 state+fence 而 fencegen 仍为新值时,陈旧 worker 抛 ExecutionLeaseError(WARNING 留痕),不会重新获得写/删权。
恢复后的列表可见性(A2R25-07):load_execution_state 读到状态键后会检查索引成员,缺失即补回(恢复/外部写入只 SET 状态键的自愈路径,WARNING 留痕);需要全量兜底时用 RedisExecutionStorage.rebuild_execution_index() 或 plaita-console/scripts/reindex_executions.py --tenant <slug> 重建索引(O(N) SCAN,仅在恢复现场手工调用)。
plaita.storage.sqlalchemy仍存在,但其方法为async def,与上述同步契约及 Worker 调用方式不兼容。公开路径已拒绝db作为 execution/flow 存储;详见根目录MIGRATION.md「Storage:db 执行/流程存储从公开路径下架」。
与 ServiceManager 协作¶
FlowWorker 负责执行流程,ServiceManager 负责监听外部触发源(定时器、队列、HTTP 回调、审批系统)并 publish 事件。二者通过 EventBus 解耦。完整闭环见 外延服务。
下一步¶
- 扩展节点 —— 在流程里声明等待
- 外延服务 —— 在流程外触发恢复
- API: plaita.server.flow_worker
checkpoint 成本警示(2026-09 性能实测)¶
$NODE 保留每个节点的结果,且每步 checkpoint 对全量上下文重新 JSON
序列化——落盘成本随步数 二次方增长,随 payload 大小线性放大:
| payload(10 节点流程,每步落盘) | 单步 save 耗时 | Redis used_memory 峰值 |
|---|---|---|
| 1 MB | 6 → 35 ms(逐步递增) | 25 MB |
| 10 MB | 66 → 306 ms | 223 MB |
节点执行本身仅 2-13 ms/步:大 payload 下落盘开销是节点执行的 17-24 倍。
缓解:让大 payload 只在被消费时进入上下文(避免透传 1MB+ 的中间结果存进
$NODE);调大 PERSIST_EVERY_N_STEPS;长流程拆分为 child/子执行。
增量式 checkpoint(每节点结果独立 key)在路线图上,当前版本请按上述方式规避。
启动期服务注册失败的重试(A2R19-03)¶
服务注册(plaita:registry:flow_worker:<instance> + 心跳线程)与队列消费走的是
两条独立恢复路径:队列读对瞬时 Redis 故障退避重试、恢复后自动继续;而
register_service() 只在 run() 启动时调用一次,失败(内部吞异常、返回
False)后不会重来。结果是「worker 启动瞬间 Redis 不可达 → Redis 恢复后队列
能消费,但注册表永远为空」——/api/services、容量快照、亲和派发
(target_instance_id)与以注册表为判据的就绪探针都看不到该进程。
现在启动注册失败会进入后台有界退避重试(1s → 2s → … → 30s 封顶,直到
成功或 stop()),成功后由 register_service() 顺带启动心跳线程;
stop() 先停重试线程再注销,避免退出后又被写回。失败每次仍由注册表打
ERROR,退避封顶保证 Redis 全断时不刷屏;进程内 registry_registered
属性在成功前保持 False(成功前不宣称 ready)。测试见
tests/unit/test_issue_a2r19_registry_retry.py。
注册表状态机与派发资格(S18-02A)¶
注册键的 status 是唯一派发白名单,不是普通字段:
| status | 含义 | 可派发 | 写入 |
|---|---|---|---|
starting |
构造完成、run() 尚未注册 |
❌ | 仅构造期容量预上报 |
running |
已注册且心跳正常 | ✅ | 心跳 + 容量 |
draining |
已请求 drain,在途收尾 | ❌ | 状态立即落盘;不再刷新容量 |
stopping |
stop() 已开始注销 |
❌ | 状态立即落盘 |
stopped |
注销墓碑(TTL 5s) | ❌ | 仅注销失败时保留 |
closed |
进程内终态,不再写键 | ❌ | 任何写入 no-op |
- 只有
running参与派发:starting/draining/stopping/stopped与未知值 在容量判定之前就被拒,即使上报了完美容量;running缺容量/陈旧容量仍按 红线放行(不因上报故障停摆)。 - 启动期 drain 保留(S9):
register()只把starting迁到running; 若 SIGTERM 早于run()(已是draining),注册不会覆盖回 running。 终态stopped/closed拒绝注册(不复活)。 - 即时落盘(A45-03):
request_drain()/stop()在内存置位后立即写一次 注册键(短退避重试 2 次),不再等 10s 心跳;失败打 ERROR 并计入registry_status_flush_failed_total指标。 - closed 世代闸(A46-03):
stop()写完stopping、注销前置 closed; 之后心跳、容量上报、_bump_active(±1)、注册重试全部不再写 Redis (在跑数仍更新内存,供 metrics/join 使用)。 - 注销可观测(A46-01):先写
stopped墓碑(TTL 5s)再 delete;delete 失败时键仍是 stopped(不可派);get 失败仍继续尝试 delete;任一步失败stop()日志含registry_unregister_failed。 - 目标不可用回退(A46-02):非目标 worker 交接前复核目标注册键;目标
不存在或
status != running→ 剥掉消息体target_instance_id按广播重投 (dispatch-target-unavailable:broadcast),避免集群每台机器各交接一次后AffinityHandoverExhausted;Redis 读失败保留 target(fail-open)。
测试:tests/unit/test_r50_registry_state_machine.py、
tests/unit/test_issue42_dispatch_target.py。
drain 状态机与 ack 策略(S18-01,2026-10-19)¶
| 状态 | 触发 | 行为 |
|---|---|---|
| D0 running | 正常 | 领取并执行任务 |
| D1 drain-requested | SIGTERM/SIGINT、控制通道 stop | 关闭领取闸;注册表立即置 draining;在途步继续跑完并正常 ack |
| D2 force-stop | PLAITA_WORKER_DRAIN_TIMEOUT 到期 |
停止等待;对在途执行尽力 execution.cancel();置 _running=False 让 run() 收尾;不承诺节点在 N 秒内中止(硬中断见 B18-01,未实现) |
| D3 stopped/closed | stop() 返回后 |
注销注册表;不再产生新步;未完成消息留 pending |
不变量(与代码逐条对应):
- I1 post-read gate:D1 之后,任何「drain 前已发出、drain 后才返回」的
read/reclaim 结果都不 dispatch、不 ack、不 bump,消息留在 PEL 由其他 worker 在claim_min_idle_ms后 XCLAIM(drain-read-gateWARNING)。 - I2 advisory timeout:
PLAITA_WORKER_DRAIN_TIMEOUT是停止领取/停止等待的 上界,不是节点中止的上界。_force_stop_after_drain无论 active 是否为 0 都置_running=False(旧实现的active<=0早退会让已发出的 read 继续 dispatch)。 - I3 未完成不 ack:只有
_dispatch_task已正常返回的消息才 ack;进程在步 返回前退出 → 消息留 pending,由 XCLAIM 重放。若步在 D2 后、进程退出前完成, 允许 ack(工作已完成)。 - I4 D3 后不再产生新步:
stop()置 D2 标记后,退避/消费线程不得再 enqueue/ack。 - I5 可中断退避:节点可重试失败的指数退避按 0.2s 分片,每片检查 D2 标记;
只有强停打断(仅 D1 仍让在途步完整跑完)。打断后不 enqueue、不 ack,
消息留 pending,日志
retry-backoff-aborted-by-drain。
交接(亲和闸 TaskNotForThisWorker)同样是 enqueue→ack:先写同体新副本,
成功后才 ack 原条;enqueue 失败 → 原条留 pending(绝不先 ack 造成唯一载体消失);
ack 失败 → 新副本已在队列、原条仍 pending,重复由租约/终态短路吸收。
库模式:run(wait_for_inflight=True) / stop(wait=True) 会在停看门狗与
注销之前有界等待在途任务收尾;CLI 默认不等待,以进程退出为硬中止边界
(daemon 消费线程可能仍在跑,未完成步的消息留 pending)。
停机与运维参数(2026-09)¶
- 优雅停机:SIGTERM/SIGINT 后 worker 在当前任务完成后退出。消费阻塞窗口
上限由
--read-block-ms(默认 1000ms)控制——XREADGROUP 的 BLOCK 无法被 信号中断,窗口越大停机延迟越长;默认值保证停机延迟 ≲1s。drain 的完整状态机、 advisory 超时与 ack 策略见上节 drain 状态机与 ack 策略。 - CLI 日志:
python -m plaita.server.flow_worker默认输出 INFO 级控制台 日志(PLAITA_LOG_LEVEL可调,--quiet关闭)。 - 本机盘守线(#49):
--min-free-disk-gib(或PLAITA_WORKER_MIN_FREE_DISK_GIB)0 时,claim 前先查
--disk-guard-path(或PLAITA_WORKER_DISK_GUARD_PATH, 默认工作目录)的可用盘,低于守线就不领任务。默认 0 = 关闭; 详见 claim 前的本机盘预检。 - Langfuse 观测:
--langfuse(或PLAITA_WORKER_LANGFUSE=1)启用 LangfuseCallback (需pip install plaita[langfuse],凭据走LANGFUSE_*环境变量)。trace id = 运行时 execution_id(随 checkpoint 持久化,跨进程 resume 续写同一 trace); 依赖缺失或 SDK 初始化失败只告警降级,不影响执行。 - writefile 写入 jail(2026-10):worker 启动即注入
writefile节点的PLAITA_NODES_WORKSPACE_ROOT(未显式配置则 fail-closed 推导默认根,/不算 边界;候选落在引擎自身 checkout 内则上溯到其父目录 = 部署根,跨仓 run 的产物 才写得出去,见 plaita#51); 显式放行任意路径用PLAITA_ALLOW_UNRESTRICTED_WRITES=1(仅单机信任 部署)。见 运维 Runbook · writefile 写入 jail。 - HTTP 节点 SSRF 防护(plaita#31):
PLAITA_HTTP_BLOCK_PRIVATE=1让运营者强制 所有http节点屏蔽回环/内网/链路本地目标(节点声明的blockPrivateNetworks只能更严不能更松);策略激活时建连解析经同一策略校验并只连校验过的 IP(关闭 DNS rebinding TOCTOU)。响应体默认上限 10MiB(PLAITA_HTTP_MAX_RESPONSE_BYTES)。 见 运维 Runbook · HTTP 节点 SSRF 防护。 - code 沙箱后端白名单(plaita#22):worker 注册
code节点时施加后端白名单 (PLAITA_SANDBOX_ALLOWED_BACKENDS,未配置则docker∪ 生效后端;生效后端取PLAITA_CODE_BACKEND,默认subprocess)。流程 JSON 声明的sandbox_backend落在白名单外即解析期拒绝——否则流程作者可逐节点把后端降级为unsafe(进程内 rawexec,宿主任意代码执行)。配置后端不可用时具名拒绝注册并停止 启动(A56-01 fail-closed),不自动降级、不扩白名单;需要可用性降级须显式设置PLAITA_ALLOW_SUBPROCESS_FALLBACK=1且白名单含subprocess。见 运维 Runbook · code 沙箱后端白名单。 - code 语言白名单(plaita#29):worker 同时施加语言白名单
(
PLAITA_SANDBOX_ALLOWED_LANGUAGES,未配置则只放行python)。language: "js"此前绕开整个档位体系(无隔离 / 无超时 / 无取消),故改为须显式放行;放行后 js 仍受 后端白名单与「语言×档位」约束(restricted没有 js 实现)。见 运维 Runbook · code 语言白名单。 - 指标与告警(plaita#26):
PLAITA_METRICS_PORT(或--metrics-port)> 0 时 本进程起 Prometheus/metrics抓取端,导出队列stream_length/pending/dlq_length、全部进程内计数器与 worker 存活/心跳;死信经RedisStreamTaskQueue(on_dead_letter=...)钩子外发,PLAITA_ALERT_WEBHOOK配置后即为 JSON POST(有界队列 + 后台线程,best-effort,不反压消费)。见 运维 Runbook · 指标与告警。 - event_filter 的
--redis-url默认取PLAITA_REDIS_URL环境变量(与 flow_worker 一致)。 - 残留订阅 GC:EventFilter 匹配到已终态(completed/error)执行的订阅时, 就地注销该订阅而不是入队注定失败的 resume——error 路径泄漏的订阅不再 需要等 7 天 TTL。
Redis 日志 handler 的客户端队列上限(plaita#84)¶
RedisStreamHandler(enable_redis_logging=True 时默认挂载)把日志先放进客户端队列
再批量写 Redis Stream。该队列是有界的,边界与 Stream 侧的 max_len 无关:
| 项 | 值 / 语义 |
|---|---|
| 队列上限 | RedisStreamHandler.DEFAULT_MAX_QUEUE = 10000,可由构造参数 max_queue 覆盖 |
| 队列满时 | 丢弃最旧的一条(self._queue.popleft(),即丢弃队列头)再入队 —— 保留最新日志。队列是 collections.deque:popleft() 为 O(1),10k 上限下持续丢弃不退化成 del list[0] 的 O(n²)。方向由 tests/unit/test_issue84_log_queue_bounds.py::test_drop_direction_is_oldest_kept 用可区分消息同时钉住 queue[0]/queue[-1](改成 pop() 丢最新会立刻变红);setup_redis_logging(..., max_queue=) 亦可覆盖 |
| 丢弃计数 | 公开属性 RedisStreamHandler.dropped_count(因上限/失败回灌截断累计丢弃条数) |
| 丢弃告警 | 不静默:第 1 条与之后每 DROP_WARN_EVERY = 100 条聚合一条 warning,含累计条数与 队列 x/上限 |
| 刷新失败 | 只回灌未成功提交的部分(本批起;此前各批已 execute 成功),合并后超出上限的部分整批丢弃并计数(回灌受同一上限约束) |
| 分批提交 | 每 DEFAULT_CHUNK_SIZE = 500 条一次 pipeline.execute()(构造参数 chunk_size 可覆盖)⇒ 积压恢复时单次提交命令数 ≤ 2 × chunk_size(每条 1×xadd + 1×publish),不再一次性构造 2N 条命令的洪峰 |
| 告警路径 | 走 plaita.server.log_handler.audit(propagate=False + 自有 stderr handler)⇒ 不会被本 handler 自己收进队列(避免失败期间告警自噬、队列排不空) |
| 告警级别 | 该 logger 显式 setLevel(WARNING),不继承 root:PLAITA_LOG_LEVEL=ERROR 或嵌入方抬 root 时丢弃/回灌告警照发(否则 warning 在记录创建前就被判 disabled,静默丢弃而 dropped_count 照涨) |
| 正常流量 | 不丢弃:仅在 len(queue) >= max_queue 时丢最旧;成功刷新后队列清空 |
Stream 侧
max_len(默认DEFAULT_MAX_LEN = 10000,approximate=True)只约束 Redis 里的 Stream 长度,不约束 worker 进程内存;进程内存由上面的客户端队列上限约束。
事件回扫失败轮:游标不推进、失败不计 replayed(plaita#153)¶
EventReconciler.scan_once 回放任一事件失败时整轮作废:
| 项 | 值 / 语义 |
|---|---|
| 游标 | 停在原处(逐字节不变);失败事件留在下一轮窗口内,Redis 恢复后被真正重放 |
last_replayed |
本轮成功回放的条数。真实不变式是「失败事件永不被计成 replayed」——不是「恒 0」:失败前已成功回放的事件照算(2 事件轮首成功次失败 ⇒ (1, 1);last_replayed == 0 只在首个事件即失败时成立) |
last_rejected |
本轮失败事件数(每轮重置) |
| 异常 | 上抛给 _run_loop(记 ERROR 后下一轮重试,回扫循环不终止) |
| 超时回调(同单 #153/R1) | SubscriptionTimeoutChecker._check_timeouts 对单个回调失败只记账不中断本轮(同轮其余回调与后续订阅照常跑),整轮末尾聚合上抛 TimeoutCallbackError(携带 failures / subscription_ids,原始异常为 __cause__)——失败仍对 _check_loop 可观察,且不饿死同轮其它订阅 |
TimeoutCallbackError 的公开入口是 plaita.event(实现处 plaita.event.timeout 是内部路径):
R1 的两条已知边界(本单未修,勿当成已修):
- T1 — 回调抛
BaseException仍中断本轮、饿死其余回调:KeyboardInterrupt/SystemExit不被本链路的except Exception捕获,因此会跳过本轮剩余回调与后续 订阅。这是刻意不 catch(进程级信号不应被聚合成「某订阅的回调失败」)。 base 修订b46f78e行为完全相同——既有边界,非本单引入;本单不修, 也不为此加except BaseException。 - T2 — 回调永不返回仍饿死同轮其余回调:本单没有引入 per-callback 超时预算
(无
asyncio.wait_for/timeout=),一个 await 永不结束的回调会把同轮后续回调 与后续订阅永久挂住。要收敛须另开单设计超时预算,本单不修。
r5 变动性质的如实说明(verifier C2/C3):plaita/server/event_reconcile.py 在
r5(fc8fccf)里的改动是 docstring/注释 + 钉子测试,零可执行变更;
plaita/server/log_handler.py 的失败回灌行 pending = entries[start:](决定「只回灌
未提交批次」的那一行)未被 r5 改动——r5 对该文件其余部位只是 list → deque 的
语义等价机械适配(代码注释已声明「丢弃方向/计数/分批提交语义零变化」)。即
C2/C3 是措辞更正 + 测试,不是行为修复;上表的行为契约由 r4 的执行代码承担。
租户上下文显式声明(plaita#239 phase 2)¶
写路径(credentials/storage 的 save / delete)在租户来源为 default
(当前进程/线程从未显式或派生设置)时fail-closed,抛具名
TenantContextMissingError,不再静默写 default 租户。
- 单租户部署:服务入口显式声明一次
set_current_tenant(DEFAULT_TENANT_ID, source="explicit")。FlowWorker.run()与 consolecreate_app()/lifespan已内置该迁移行;自定义启动器/嵌入方 必须保留。 - 多租户:任务消息按
tenant_id显式 set;经 #237 传播链(线程池 / asyncio 桥 / 进程 pickle)到达的上下文来源为derived,同样放行。 - 「未设置」不再等价于「有意 default」;缺失上下文会被拒绝并从日志/探针
write_probe_snapshot()的(site, "default")计数观测到。