camunda-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. camunda/__init__.py +10 -0
  2. camunda/api/__init__.py +14 -0
  3. camunda/api/app.py +80 -0
  4. camunda/api/deps.py +17 -0
  5. camunda/api/errors.py +84 -0
  6. camunda/api/pagination.py +74 -0
  7. camunda/api/routers/__init__.py +5 -0
  8. camunda/api/routers/decision.py +57 -0
  9. camunda/api/routers/deployment.py +139 -0
  10. camunda/api/routers/history.py +128 -0
  11. camunda/api/routers/process_definition.py +49 -0
  12. camunda/api/routers/process_instance.py +106 -0
  13. camunda/api/routers/task.py +92 -0
  14. camunda/api/schemas.py +200 -0
  15. camunda/common/__init__.py +19 -0
  16. camunda/common/clock.py +30 -0
  17. camunda/common/exceptions.py +34 -0
  18. camunda/common/idgen.py +23 -0
  19. camunda/common/timers.py +106 -0
  20. camunda/dmn/__init__.py +5 -0
  21. camunda/dmn/engine.py +219 -0
  22. camunda/dmn/feel.py +392 -0
  23. camunda/engine/__init__.py +9 -0
  24. camunda/engine/behavior.py +51 -0
  25. camunda/engine/expression.py +126 -0
  26. camunda/engine/process_engine.py +3237 -0
  27. camunda/job/__init__.py +9 -0
  28. camunda/job/executor.py +136 -0
  29. camunda/model/__init__.py +48 -0
  30. camunda/model/bpmn.py +326 -0
  31. camunda/model/dmn.py +101 -0
  32. camunda/model/execution.py +121 -0
  33. camunda/model/job.py +88 -0
  34. camunda/model/task.py +33 -0
  35. camunda/model/variable.py +35 -0
  36. camunda/parser/__init__.py +5 -0
  37. camunda/parser/bpmn_parser.py +646 -0
  38. camunda/parser/dmn_parser.py +225 -0
  39. camunda/persistence/__init__.py +21 -0
  40. camunda/persistence/entities.py +202 -0
  41. camunda/persistence/store.py +721 -0
  42. camunda_python-0.1.0.dist-info/METADATA +377 -0
  43. camunda_python-0.1.0.dist-info/RECORD +46 -0
  44. camunda_python-0.1.0.dist-info/WHEEL +5 -0
  45. camunda_python-0.1.0.dist-info/licenses/LICENSE +200 -0
  46. camunda_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3237 @@
1
+ """ProcessEngine 门面:流程引擎(M1 内存 / M2 可选持久化 / M3 作业)。
2
+
3
+ 架构对齐(见 docs/ARCHITECTURE.md):
4
+ - RepositoryService 语义 -> deploy() / get_process_definition()
5
+ - RuntimeService 语义 -> start_process_instance_by_key() / get_process_instance()
6
+ - TaskService 语义 -> complete_task() / create_task_query()
7
+
8
+ 执行模型(语义对齐 Camunda PVM 的核心):
9
+ - **Execution 树**:实例根 Execution 下按需挂子 Execution。并行网关 fork 时
10
+ 父 execution 转 SCOPE 停驻,每条出边 spawn 一个子 TOKEN execution。
11
+ - **队列推进**:token 到达节点产生新到达事件,入队循环 pump,天然支撑并发。
12
+ - **并行网关 join**:实例级 join_arrivals[gw_id] 登记到达 token 数;到达数 ==
13
+ 网关入边数 时汇聚:清停等 token、SCOPE 恢复沿网关出边继续。
14
+ (M1 约束:无循环回连并行网关、并行分支内不嵌套并行,M4 强化。)
15
+ - **变量作用域**:全放 ProcessInstance.variables(实例级)。
16
+ - **活动历史**:ActivityInstance 进/出痕迹(内存 + M2 落 ACT_HI_ACTINST)。
17
+
18
+ M2 持久化(事务边界同步):
19
+ - deploy / start / complete 三个命令结束后把实例状态全量同步到 ACT 表;
20
+ 进程崩溃 => 未完成命令整体丢失(等价 Camunda 单命令事务语义)。
21
+ - from_database() 从 ACT_RU_* 恢复活跃实例(execution 树/task/变量/join 等待)。
22
+
23
+ M3 作业(Job / Timer / async continuation):
24
+ - token 到达 timer 中间捕获事件 -> 停等 + 注册 timer-catch Job(duedate)-> 到期
25
+ execute_job 让 token 继续;Timer Start 为定义级 timer-start Job,触发即启动实例。
26
+ - camunda:asyncBefore 把节点行为执行拆成 async-continuation Job(Camunda async
27
+ continuation 语义);execute_job 失败 -> retries-1 -> 到期重试;retries 耗尽 =
28
+ 死信(不再 acquire,实例级失败时若启用 store 自动回滚内存到上次同步点)。
29
+
30
+ M4-1 扩展:
31
+ - timer 边界事件(timer-boundary Job):宿主 = 有等待点的活动
32
+ (userTask / asyncBefore 节点)。中断式(cancelActivity=true)等待期内到点
33
+ 触发即取消宿主、token 改走边界事件出边;非中断式(cancelActivity=false,
34
+ M4-2b4 落地)到点触发不取消宿主,spawn 并发线从边界事件出边走(单发,宿主
35
+ 继续等待,可多次触发不同边界/同一边界不同窗口——timeCycle 不支持,文档化
36
+ 差异)。宿主正常离开撤销边界 Job。
37
+ - camunda:asyncAfter 把「离开推进」拆成 async-after Job(支持 serviceTask /
38
+ exclusiveGateway;XOR 离开时重新求值出边条件;其余类型明确报错——文档化差异)。
39
+ 可与 asyncBefore 链式。
40
+ - camunda:asyncAfter 把「离开推进」拆成 async-after Job(支持 serviceTask /
41
+ exclusiveGateway;XOR 离开时重新求值出边条件;其余类型明确报错——文档化差异)。
42
+ 可与 asyncBefore 链式。
43
+
44
+ M4-2a 扩展(embedded SubProcess 容器语义):
45
+ - 容器感知流转:token 可能在内嵌子流程内部推进,节点/连线/边界作业归属一律按
46
+ token 所在容器(_container_of:沿父链找最近停驻在 SubProcess 的 SCOPE 祖先,
47
+ 取其 inner Process;否则根 Process)解析,跨容器节点 id 不串扰。
48
+ - 进入 subProcess:token 转 SCOPE 停驻(activity=subProcess id,actinst 跨整段
49
+ 内部执行期 open),spawn 内部子 token 从内部 startEvent 推进。
50
+ - 收束复活:内部全部走完(含并行分支直通 end 的逐层 SCOPE 收束)后,subProcess
51
+ SCOPE 无活跃子 -> 结算 actinst、恢复 TOKEN 沿 sub 出边继续;并行 join 汇聚后
52
+ 恢复的 SCOPE 立即复位 TOKEN,避免被收束扫描误杀。
53
+ - 边界 timer 中断子流程(M4-2a3):进入 subProcess 时注册其边界作业;触发即
54
+ 取消整段 scope(内部子树全部结束:execution ENDED、任务归档、作业删除、
55
+ actinst 结算、join 登记摘除),token 改走边界事件出边。
56
+ - 约束(文档化差异):任何容器内并行分支路径不嵌套并行网关(沿用 M1 约束);
57
+ subProcess 的 asyncAfter 不支持(asyncBefore 支持,语义 = 展开前异步窗口);
58
+ 非中断式边界(cancelActivity=false)M4-2b4 起支持普通等待活动宿主,subProcess
59
+ 宿主不支持(中断式支持,见 M4-2a3)。
60
+
61
+ M4-2b 扩展(事件子流程 + 错误传播):
62
+ - 事件子流程(triggeredByEvent=true)不参与 sequenceFlow,由内部事件 start
63
+ 触发:error start(中断式)/ timer start(中断+非中断)/ message start
64
+ (解析保留,消息投递入口未实现前订阅即明确报错——文档化差异)。
65
+ - 订阅生命周期对齐 Camunda:宿主 scope(流程实例或 subProcess)激活时建立
66
+ 触发条件(error = 冒泡匹配;timer = 注册实例级 timer-event-start Job),
67
+ 宿主 scope 结束即失效。
68
+ - error endEvent 抛出错误:沿宿主 scope 链(内到外:所在容器 -> 上级 subProcess
69
+ -> 流程实例)冒泡找匹配的 error 事件子流程;命中 -> 中断宿主 scope 其它执行
70
+ (根容器=整实例、subProcess 容器=该 subProcess 内部),事件子流程成为宿主
71
+ scope 下唯一活动,走完即宿主 scope 结束(根=实例完成 / subProcess=正常复活
72
+ 沿出边继续);无命中 -> 等同 none end(仅当前路径结束,文档化差异对齐 Camunda
73
+ error end event 默认语义)。
74
+ - 事件子流程 scope 建模:SCOPE execution 停驻在事件子流程节点(activity_id=
75
+ 事件子流程 id),与 embedded SubProcess 同一收束/容器推导路径,零表改动。
76
+ - 非中断式边界事件(cancelActivity=false,M4-2b4 落地):宿主等待期内触发
77
+ 不取消宿主,spawn 并发线从边界事件出边走;主线与并发线全收束才算实例完成
78
+ (root 到 end 不再等价实例完成——root 转 SCOPE 停驻等并发子树收束后收尾)。
79
+
80
+ 时间来自可注入时钟 camunda.common.clock(测试拨时间无需真等待)。所有命令入口
81
+ 持引擎级 RLock,JobExecutor 轮询线程与用户命令不会互踩(多进程部署锁不在范围)。
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import logging
87
+ import threading
88
+ from collections import deque
89
+ from dataclasses import dataclass
90
+ from datetime import datetime, timedelta
91
+ from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, List, Optional, Tuple
92
+
93
+ if TYPE_CHECKING: # Store 仅持久化模式实例化,纯内存模式不强依赖 sqlalchemy
94
+ from camunda.persistence.store import ProcInstSnap, Store
95
+
96
+ from camunda.common import clock
97
+ from camunda.common.exceptions import (
98
+ InvalidRequestException,
99
+ NotFoundException,
100
+ ProcessInstanceException,
101
+ )
102
+ from camunda.common.idgen import IdGenerator
103
+ from camunda.common.timers import (
104
+ format_iso,
105
+ next_trigger,
106
+ parse_iso,
107
+ parse_iso_repeat,
108
+ parse_trigger_date,
109
+ )
110
+ from camunda.engine.behavior import select_exclusive_gateway_flow
111
+ from camunda.engine.expression import evaluate_condition, evaluate_expression
112
+ from camunda.model.bpmn import (
113
+ BpmnModel,
114
+ BoundaryEvent,
115
+ EndEvent,
116
+ ExclusiveGateway,
117
+ BusinessRuleTask,
118
+ FlowNode,
119
+ IntermediateCatchEvent,
120
+ IntermediateThrowEvent,
121
+ MultiInstance,
122
+ ParallelGateway,
123
+ Process,
124
+ SequenceFlow,
125
+ ServiceTask,
126
+ StartEvent,
127
+ SubProcess,
128
+ UserTask,
129
+ )
130
+ from camunda.model.execution import (
131
+ ActivityInstance,
132
+ Execution,
133
+ ExecutionState,
134
+ ProcessInstance,
135
+ ProcessInstanceState,
136
+ )
137
+ from camunda.model.job import Job
138
+ from camunda.model.task import Task
139
+ from camunda.dmn.engine import DmnEngine
140
+ from camunda.model.dmn import DmnModel
141
+
142
+ # 到达事件:(token execution, 即将进入的节点)
143
+ _Arrival = Tuple[Execution, FlowNode]
144
+
145
+
146
+ @dataclass
147
+ class EventSubscription:
148
+ """消息/信号事件订阅(M4-2d,纯内存派生态——不落库,恢复时重推导)。
149
+
150
+ 挂载点(node_kind):
151
+ - start:事件子流程 message/signal start,execution = 宿主 scope
152
+ (activity_id = 订阅容器 subProcess id,None = 流程级);
153
+ - catch:IntermediateCatchEvent 停等 token,execution = 停等执行;
154
+ - boundary:宿主停等活动上的 message/signal 边界,execution = 宿主 token。
155
+
156
+ kind: "message" | "signal";is_interrupting:boundary 用 cancel_activity、
157
+ esc start 用 isInterrupting(catch 无中断概念恒 True 填充)。
158
+ 生命周期:scope 激活即注册;触发即消费(catch/boundary-中断/esc-中断式)
159
+ 或随宿主离开/杀灭/收束撤销;非中断式 boundary 与 esc-start 订阅常驻可再触发。
160
+ """
161
+
162
+ id: str
163
+ kind: str # "message" | "signal"
164
+ event_name: str
165
+ process_instance_id: str
166
+ execution_id: str
167
+ activity_id: Optional[str] # esc 订阅容器 subProcess id(None = 流程级)
168
+ node_id: str # 事件节点 id(esc start / catch / boundary)
169
+ node_kind: str # "start" | "catch" | "boundary"
170
+ is_interrupting: bool
171
+ created: str
172
+
173
+ # 实现委托签名:callable(variables: dict) -> None | dict(merge 更新)
174
+ Delegate = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
175
+
176
+
177
+ def _now() -> str:
178
+ """当前时间(定长 ISO,本地时区)。走可注入时钟,测试可拨快。"""
179
+ return clock.now()
180
+
181
+
182
+ # M3:单条作业执行失败不阻塞整轮轮询(对齐 Camunda JobExecutor 行为)
183
+ logger = logging.getLogger(__name__)
184
+
185
+
186
+ class ProcessEngine:
187
+ """流程引擎(M1 内存版 / M2 可选持久化 / M3 作业)。
188
+
189
+ M2/M3 用法:
190
+ from camunda.persistence.store import Store
191
+ engine = ProcessEngine(store=Store("sqlite:///camunda.db")) # 启用落库
192
+ engine = ProcessEngine.from_database("sqlite:///camunda.db") # 崩溃恢复
193
+ engine.execute_due_jobs() # 手动触发到期作业(JobExecutor 轮询也是调它)
194
+ engine.create_job_query() # 查看作业(待办/死信)
195
+
196
+ 未传 store 时行为与 M1 完全一致(纯内存,既有测试不破坏)。
197
+ 多进程部署抢锁不在 M3 范围(见 docs/ARCHITECTURE.md 风险章节)。
198
+ """
199
+
200
+ def __init__(self, store: Optional["Store"] = None) -> None:
201
+ # key -> Process(同名重复部署视为新版本,覆盖并版本+1)
202
+ self._definitions: Dict[str, Process] = {}
203
+ self._definition_versions: Dict[str, int] = {}
204
+ # M6:key -> 原始 BPMN XML(供 REST GET /process-definition/{key}/xml)
205
+ self._definition_sources: Dict[str, Optional[str]] = {}
206
+ self._instances: Dict[str, ProcessInstance] = {}
207
+ self._tasks: Dict[str, Task] = {}
208
+ # 实现注册表:serviceTask implementation_ref -> callable
209
+ self._delegates: Dict[str, Delegate] = {}
210
+ self._idgen = IdGenerator()
211
+ # M2:持久化 store(None = 纯内存模式)
212
+ self._store = store
213
+ # M3:作业池(实例级 timer-catch/async + 定义级 timer-start)
214
+ self._jobs: Dict[str, Job] = {}
215
+ # M4-2d:消息/信号订阅池(插入序 = 注册序,事件订阅检索按序取最早)
216
+ self._event_subs: Dict[str, EventSubscription] = {}
217
+ # M5:DMN 决策引擎(部署不落库,对齐 delegate 注册先例)
218
+ self._dmn = DmnEngine()
219
+ # M6:部署记录(id/name/time/keys,供 REST GET /deployment 列举)
220
+ self._deployments: List[Dict[str, Any]] = []
221
+ # 命令级互斥:JobExecutor 轮询线程与用户命令入口共用
222
+ self._lock = threading.RLock()
223
+
224
+ # ------------------------------------------------------------------
225
+ # 委托注册(对齐 Spring bean / JavaDelegate 注册语义)
226
+ # ------------------------------------------------------------------
227
+ def register_delegate(self, name: str, fn: Delegate) -> None:
228
+ """注册 serviceTask 实现。fn(variables) 原地改或返回 dict 合并。"""
229
+ with self._lock:
230
+ if not callable(fn):
231
+ raise InvalidRequestException(f"delegate {name!r} 必须可调用")
232
+ self._delegates[name] = fn
233
+
234
+ # ------------------------------------------------------------------
235
+ # RepositoryService 语义
236
+ # ------------------------------------------------------------------
237
+ def deploy(self, model: BpmnModel, name: Optional[str] = None) -> List[str]:
238
+ """部署 BpmnModel,返回部署成功的 process key 列表。
239
+
240
+ 重复部署同名 key:覆盖并版本 +1(对齐 ACT_RE_PROCDEF 多版本语义)。
241
+ M2:启用 store 时同步写 ACT_RE_*(含原始 xml 供恢复重解析)。
242
+ M3:带 timer start 的流程注册定义级 timer-start 作业(新版本覆盖旧版作业组)。
243
+ """
244
+ with self._lock:
245
+ keys: List[str] = []
246
+ for proc in model.processes:
247
+ if not proc.is_executable:
248
+ continue
249
+ self._definitions[proc.id] = proc
250
+ self._definition_versions[proc.id] = (
251
+ self._definition_versions.get(proc.id, 0) + 1
252
+ )
253
+ self._definition_sources[proc.id] = model.source_xml
254
+ keys.append(proc.id)
255
+ # 新版本取代旧版本 -> 该 key 的旧 timer-start 作业组整体移除重建
256
+ self._drop_definition_jobs(proc.id)
257
+ for start in proc.start_events:
258
+ if start.timer is not None:
259
+ job = self._make_timer_start_job(proc.id, start)
260
+ self._jobs[job.id] = job
261
+ if keys:
262
+ # 落库时复用 store 生成的部署 id;纯内存用 idgen
263
+ dep_id = (
264
+ self._store.save_deployment(model, _now())
265
+ if self._store is not None
266
+ else self._idgen.next_id()
267
+ )
268
+ if self._store is not None:
269
+ self._sync_timer_start_jobs()
270
+ self._deployments.append(
271
+ {
272
+ "id": dep_id,
273
+ "name": name,
274
+ "time": _now(),
275
+ "source": model.source_name,
276
+ "process_keys": list(keys),
277
+ "decision_keys": [],
278
+ }
279
+ )
280
+ return keys
281
+
282
+ def get_process_definition(self, key: str) -> Process:
283
+ with self._lock:
284
+ if key not in self._definitions:
285
+ raise NotFoundException(f"未部署的流程定义: {key!r}")
286
+ return self._definitions[key]
287
+
288
+ def get_definition_version(self, key: str) -> int:
289
+ return self._definition_versions.get(key, 0)
290
+
291
+ # ------------------------------------------------------------------
292
+ # DecisionService 语义(M5:DMN 决策)
293
+ # ------------------------------------------------------------------
294
+ def deploy_dmn(self, model: DmnModel, name: Optional[str] = None) -> List[str]:
295
+ """部署 DmnModel,返回 decision key 列表(重复 key 版本 +1)。
296
+
297
+ 注意:DMN 部署不落库(文档化差异,对齐 delegate 注册不落库)——
298
+ 崩溃恢复后须重新 deploy_dmn,否则 businessRuleTask 求值报未部署。
299
+ """
300
+ with self._lock:
301
+ keys = self._dmn.deploy(model)
302
+ if keys:
303
+ self._deployments.append(
304
+ {
305
+ "id": self._idgen.next_id(),
306
+ "name": name,
307
+ "time": _now(),
308
+ "source": model.source_name,
309
+ "process_keys": [],
310
+ "decision_keys": list(keys),
311
+ }
312
+ )
313
+ return keys
314
+
315
+ def evaluate_decision(self, key: str, variables: Optional[Dict[str, Any]] = None) -> Any:
316
+ """直接求值已部署决策(对齐 DecisionService.evaluateDecisionTable)。"""
317
+ with self._lock:
318
+ return self._dmn.evaluate_decision(key, variables)
319
+
320
+ def get_decision_definition(self, key: str):
321
+ """按 key 取已部署决策(未部署抛 NotFoundException)。"""
322
+ with self._lock:
323
+ return self._dmn.get_decision(key)
324
+
325
+ def get_decision_version(self, key: str) -> int:
326
+ return self._dmn.get_decision_version(key)
327
+
328
+ # ------------------------------------------------------------------
329
+ # RuntimeService 语义
330
+ # ------------------------------------------------------------------
331
+ def start_process_instance_by_key(
332
+ self,
333
+ process_key: str,
334
+ variables: Optional[Dict[str, Any]] = None,
335
+ business_key: Optional[str] = None,
336
+ ) -> ProcessInstance:
337
+ """按 key 启动实例:建树 -> startEvent token 入队 pump。
338
+
339
+ 定时启动流程(startEvent 带 timer)不可手动启动,对齐 Camunda 语义。
340
+ """
341
+ with self._lock:
342
+ proc = self.get_process_definition(process_key)
343
+ if not proc.start_events:
344
+ raise ProcessInstanceException(f"流程 {process_key!r} 没有可启动的 startEvent")
345
+ start = proc.start_events[0] # M1:取第一个 startEvent
346
+ if start.timer is not None:
347
+ raise ProcessInstanceException(
348
+ f"流程 {process_key!r} 是定时启动流程(startEvent {start.id} 带 timer),不能手动启动"
349
+ )
350
+ if start.error_code is not None or start.message_name is not None or start.signal_name is not None:
351
+ raise ProcessInstanceException(
352
+ f"流程 {process_key!r} 的 startEvent {start.id} 是事件 start"
353
+ "(error/message/signal):流程级事件启动后续里程碑落地,不能手动启动"
354
+ )
355
+ return self._start_process(proc, variables, business_key, start)
356
+
357
+ def _start_process(
358
+ self,
359
+ proc: Process,
360
+ variables: Optional[Dict[str, Any]],
361
+ business_key: Optional[str],
362
+ start: Optional[StartEvent] = None,
363
+ ) -> ProcessInstance:
364
+ """内部启动:手动与 timer-start 共用。timer 触发时传入对应 startEvent。"""
365
+ start = start or proc.start_events[0]
366
+ pi = ProcessInstance(
367
+ id=self._idgen.next_id(),
368
+ process_definition_key=proc.id,
369
+ business_key=business_key,
370
+ variables=dict(variables or {}),
371
+ start_time=_now(),
372
+ )
373
+ root = Execution(id=self._idgen.next_id(), process_instance_id=pi.id)
374
+ pi.root_execution = root
375
+ pi.executions[root.id] = root
376
+ self._instances[pi.id] = pi
377
+
378
+ self._pump(pi, [(root, start)])
379
+ # M4-2b3:流程实例 scope 激活 -> 订阅根 Process 容器内 timer 事件子流程
380
+ # (root 直通进入 sub 时同样成立——容器由注册点显式给出,不与 sub 订阅混淆)
381
+ if not pi.is_completed:
382
+ self._register_event_subprocess_timers(pi, root, proc, None)
383
+ # M4-2d:流程级容器 message/signal esc start 常驻订阅
384
+ self._register_event_subprocess_subscriptions(pi, root, proc, None)
385
+ if self._store is not None:
386
+ self._store.save_proc_inst(self._build_snap(pi))
387
+ return pi
388
+
389
+ def get_process_instance(self, instance_id: str) -> ProcessInstance:
390
+ with self._lock:
391
+ if instance_id not in self._instances:
392
+ raise NotFoundException(f"流程实例不存在: {instance_id!r}")
393
+ return self._instances[instance_id]
394
+
395
+ def list_process_instances(self) -> List[ProcessInstance]:
396
+ with self._lock:
397
+ return list(self._instances.values())
398
+
399
+ # ------------------------------------------------------------------
400
+ # M6 补充:REST 需要的门面能力(定义列表 / 删除实例 / 任务认领)
401
+ # ------------------------------------------------------------------
402
+ def list_process_definitions(self) -> List[Dict[str, Any]]:
403
+ """已部署流程定义列表(key / name / version,部署序)。"""
404
+ with self._lock:
405
+ return [
406
+ {
407
+ "key": key,
408
+ "name": proc.name,
409
+ "version": self._definition_versions.get(key, 0),
410
+ }
411
+ for key, proc in self._definitions.items()
412
+ ]
413
+
414
+ def get_process_definition_xml(self, key: str) -> Optional[str]:
415
+ """流程定义原始 XML(部署时未带 source_xml 则为 None)。"""
416
+ with self._lock:
417
+ if key not in self._definitions:
418
+ raise NotFoundException(f"未部署的流程定义: {key!r}")
419
+ return self._definition_sources.get(key)
420
+
421
+ def list_deployments(self) -> List[Dict[str, Any]]:
422
+ """部署记录列表(部署序,M6 供 REST GET /deployment 使用)。"""
423
+ with self._lock:
424
+ return list(self._deployments)
425
+
426
+ def list_decision_definitions(self) -> List[Dict[str, Any]]:
427
+ """已部署决策定义列表(key / name / version,部署序)。"""
428
+ with self._lock:
429
+ return self._dmn.list_decisions()
430
+
431
+ def delete_process_instance(
432
+ self, instance_id: str, reason: Optional[str] = None
433
+ ) -> None:
434
+ """删除流程实例(运行中亦可):清内存态 + RU 行,历史保留。
435
+
436
+ 对齐 Camunda 默认语义:不传 skipHistory 时历史行保留(HI_PROCINST 置
437
+ DELETED)。已结束实例同样可删(幂等清理运行时残渣,历史不动)。
438
+ """
439
+ with self._lock:
440
+ pi = self._instances.get(instance_id)
441
+ if pi is None:
442
+ raise NotFoundException(f"流程实例不存在: {instance_id!r}")
443
+ # 1) 该实例的任务全部下线(活跃任务不入历史归档,删实例非正常完成)
444
+ for tid in [t.id for t in self._tasks.values()
445
+ if t.process_instance_id == instance_id]:
446
+ self._tasks.pop(tid, None)
447
+ # 2) 该实例的作业与事件订阅清理(定义级 timer-start 不挂实例,不动)
448
+ for jid in [j.id for j in self._jobs.values()
449
+ if j.process_instance_id == instance_id]:
450
+ self._jobs.pop(jid, None)
451
+ for sid in [s.id for s in self._event_subs.values()
452
+ if s.process_instance_id == instance_id]:
453
+ self._event_subs.pop(sid, None)
454
+ # 3) 实例本体移除
455
+ self._instances.pop(instance_id, None)
456
+ if self._store is not None:
457
+ self._store.delete_proc_inst(instance_id, _now())
458
+
459
+ def get_task(self, task_id: str) -> Task:
460
+ """按 id 取活跃任务(不存在抛 NotFoundException)。"""
461
+ with self._lock:
462
+ task = self._tasks.get(task_id)
463
+ if task is None:
464
+ raise NotFoundException(f"任务不存在或已完成: {task_id!r}")
465
+ return task
466
+
467
+ def claim_task(self, task_id: str, user_id: str) -> Task:
468
+ """认领任务:设置 assignee。已认领给他人时报错(Camunda 语义)。"""
469
+ with self._lock:
470
+ task = self._tasks.get(task_id)
471
+ if task is None:
472
+ raise NotFoundException(f"任务不存在或已完成: {task_id!r}")
473
+ if task.assignee is not None and task.assignee != user_id:
474
+ raise InvalidRequestException(
475
+ f"任务 {task_id!r} 已指派给 {task.assignee!r},不能由 {user_id!r} 认领"
476
+ )
477
+ task.assignee = user_id
478
+ self._sync_instance(task.process_instance_id)
479
+ return task
480
+
481
+ def unclaim_task(self, task_id: str) -> Task:
482
+ """取消认领:清空 assignee(任务回到候选组池)。"""
483
+ with self._lock:
484
+ task = self._tasks.get(task_id)
485
+ if task is None:
486
+ raise NotFoundException(f"任务不存在或已完成: {task_id!r}")
487
+ task.assignee = None
488
+ self._sync_instance(task.process_instance_id)
489
+ return task
490
+
491
+ def set_assignee(self, task_id: str, user_id: Optional[str]) -> Task:
492
+ """直接指派/清空 assignee(不做「已被他人认领」校验)。"""
493
+ with self._lock:
494
+ task = self._tasks.get(task_id)
495
+ if task is None:
496
+ raise NotFoundException(f"任务不存在或已完成: {task_id!r}")
497
+ task.assignee = user_id
498
+ self._sync_instance(task.process_instance_id)
499
+ return task
500
+
501
+ def set_variable(self, instance_id: str, name: str, value: Any) -> None:
502
+ """设置实例变量(M6:PUT /process-instance/{id}/variables/{name})。
503
+
504
+ 变量为实例级(文档化差异,对齐 M4-2a 起的一贯语义),无作用域隔离。
505
+ """
506
+ with self._lock:
507
+ pi = self._instances.get(instance_id)
508
+ if pi is None:
509
+ raise NotFoundException(f"流程实例不存在: {instance_id!r}")
510
+ pi.variables[name] = value
511
+ if self._store is not None:
512
+ self._store.save_proc_inst(self._build_snap(pi))
513
+
514
+ def _sync_instance(self, process_instance_id: str) -> None:
515
+ """任务级变更后同步实例快照(assignee 需落库,否则崩溃恢复丢失)。"""
516
+ if self._store is None:
517
+ return
518
+ pi = self._instances.get(process_instance_id)
519
+ if pi is not None:
520
+ self._store.save_proc_inst(self._build_snap(pi))
521
+
522
+ # ------------------------------------------------------------------
523
+ # M4-2d:消息关联 / 信号广播(RuntimeService correlateMessage 语义)
524
+ # ------------------------------------------------------------------
525
+ def correlate_message(
526
+ self,
527
+ name: str,
528
+ process_instance_id: Optional[str] = None,
529
+ variables: Optional[Dict[str, Any]] = None,
530
+ ) -> None:
531
+ """消息关联:把消息投递给一个等待中的 message 订阅并触发。
532
+
533
+ 点对点 1:1(对齐 Camunda message 语义):命中的多个订阅里取注册序最早
534
+ 一个(Camunda 用 businessKey/processInstanceId 消歧,本引擎 v1 取最早
535
+ ——文档化差异)。process_instance_id 限定后只在实例内匹配。
536
+
537
+ 可命中的订阅形态(M4-2d):
538
+ - IntermediateCatchEvent 停等 token(catch);
539
+ - 宿主停等活动上的 message 边界事件(boundary,中断式取消宿主 / 非中断
540
+ spawn 并发线);
541
+ - 事件子流程 message start(宿主 scope 激活期常驻订阅,中断式接管 /
542
+ 非中断 spawn,可多次触发)。
543
+ variables 随消息合并进目标实例变量表。无等待订阅 -> NotFoundException。
544
+ """
545
+ with self._lock:
546
+ sub = self._find_event_subscription("message", name, process_instance_id)
547
+ if sub is None:
548
+ raise NotFoundException(
549
+ f"消息 {name!r} 没有等待中的订阅"
550
+ + (f"(流程实例 {process_instance_id!r} 内)" if process_instance_id else "")
551
+ )
552
+ pi = self._instances.get(sub.process_instance_id)
553
+ if pi is None or pi.is_completed:
554
+ # 防御:订阅指向已结束实例(清理钩子漏网时惰性回收)
555
+ self._event_subs.pop(sub.id, None)
556
+ raise ProcessInstanceException(
557
+ f"消息 {name!r} 命中的流程实例已结束: {sub.process_instance_id!r}"
558
+ )
559
+ if variables:
560
+ pi.variables.update(variables)
561
+ self._fire_subscription(pi, sub)
562
+
563
+ def throw_signal(
564
+ self,
565
+ name: str,
566
+ variables: Optional[Dict[str, Any]] = None,
567
+ ) -> int:
568
+ """信号广播:把信号投给当前全部等待中的 signal 订阅并触发。
569
+
570
+ 广播语义(对齐 Camunda signal):命中所有订阅(跨实例、每实例内多个
571
+ 订阅同时触发);无订阅命中则静默无效果(返回 0)。variables 合并到
572
+ 每个命中实例的变量表。返回触发的订阅数。
573
+ """
574
+ with self._lock:
575
+ hit = [
576
+ s
577
+ for s in self._event_subs.values()
578
+ if s.kind == "signal" and s.event_name == name
579
+ ]
580
+ for sub in hit:
581
+ pi = self._instances.get(sub.process_instance_id)
582
+ if pi is None or pi.is_completed:
583
+ self._event_subs.pop(sub.id, None) # 惰性回收
584
+ continue
585
+ if variables:
586
+ pi.variables.update(variables)
587
+ self._fire_subscription(pi, sub)
588
+ return len(hit)
589
+
590
+ def _find_event_subscription(
591
+ self,
592
+ kind: str,
593
+ name: str,
594
+ process_instance_id: Optional[str] = None,
595
+ ) -> Optional[EventSubscription]:
596
+ """按(kind, name[, pi])取注册序最早的等待订阅;过期订阅惰性剔除。"""
597
+ for sub in list(self._event_subs.values()):
598
+ if sub.kind != kind or sub.event_name != name:
599
+ continue
600
+ if (
601
+ process_instance_id is not None
602
+ and sub.process_instance_id != process_instance_id
603
+ ):
604
+ continue
605
+ pi = self._instances.get(sub.process_instance_id)
606
+ token = pi.executions.get(sub.execution_id) if pi is not None else None
607
+ if (
608
+ pi is None
609
+ or pi.is_completed
610
+ or token is None
611
+ or token.state != ExecutionState.ACTIVE
612
+ ):
613
+ self._event_subs.pop(sub.id, None) # 订阅已失效 -> 跳过并回收
614
+ continue
615
+ return sub
616
+ return None
617
+
618
+ def _fire_subscription(self, pi: ProcessInstance, sub: EventSubscription) -> None:
619
+ """订阅触发统一入口:按挂载形态分派(stale 订阅惰性剔除)。
620
+
621
+ - catch:结算停等 actinst -> token 沿出边推进;
622
+ - boundary:中断式取消宿主(复用 timer 边界取消链)/ 非中断 spawn 并发线;
623
+ - start:事件子流程接管(中断式 interrupt / 非中断直接 spawn)。
624
+ """
625
+ token = pi.executions.get(sub.execution_id)
626
+ if token is None or token.state != ExecutionState.ACTIVE or pi.is_completed:
627
+ self._event_subs.pop(sub.id, None) # 惰性回收(清理钩子兜底)
628
+ return
629
+ if sub.node_kind == "catch":
630
+ self._fire_event_catch(pi, token, sub)
631
+ return
632
+ if sub.node_kind == "boundary":
633
+ self._fire_event_boundary(pi, token, sub)
634
+ return
635
+ if sub.node_kind == "start":
636
+ self._fire_event_esc_start(pi, token, sub)
637
+ return
638
+ self._event_subs.pop(sub.id, None) # 防御:未知挂载形态
639
+
640
+ def _fire_event_catch(
641
+ self, pi: ProcessInstance, token: Execution, sub: EventSubscription
642
+ ) -> None:
643
+ """message/signal 中间捕获触发:结算停等 -> 沿出边继续。"""
644
+ proc = self._container_of(pi, token)
645
+ node = proc.flow_nodes.get(sub.node_id)
646
+ if (
647
+ not isinstance(node, IntermediateCatchEvent)
648
+ or token.activity_id != node.id
649
+ or token.open_activity is None
650
+ or token.open_activity.end_time is not None
651
+ ):
652
+ self._event_subs.pop(sub.id, None) # token 已离开 -> 过期订阅丢弃
653
+ return
654
+ self._event_subs.pop(sub.id, None) # 触发即消费
655
+ self._close_activity(pi, token, node)
656
+ arrivals: List[_Arrival] = []
657
+ self._leave(pi, token, node, arrivals)
658
+ self._pump(pi, arrivals)
659
+ if self._store is not None:
660
+ self._store.save_proc_inst(self._build_snap(pi))
661
+
662
+ def _fire_event_boundary(
663
+ self, pi: ProcessInstance, token: Execution, sub: EventSubscription
664
+ ) -> None:
665
+ """message/signal 边界触发:中断式取消宿主 / 非中断 spawn 并发线。"""
666
+ proc = self._container_of(pi, token)
667
+ boundary = proc.flow_nodes.get(sub.node_id)
668
+ host = (
669
+ proc.flow_nodes.get(boundary.attached_to)
670
+ if isinstance(boundary, BoundaryEvent) and boundary.attached_to
671
+ else None
672
+ )
673
+ if (
674
+ not isinstance(boundary, BoundaryEvent)
675
+ or host is None
676
+ or token.activity_id != host.id
677
+ or token.open_activity is None
678
+ or token.open_activity.end_time is not None
679
+ ):
680
+ self._event_subs.pop(sub.id, None) # 宿主已离开 -> 过期订阅丢弃
681
+ return
682
+ if boundary.cancel_activity:
683
+ # 中断式:取消宿主(_cancel_host_activity 撤宿主全部边界订阅含本条),
684
+ # token 改走边界事件出边(与 timer 边界中断路径同构)
685
+ self._cancel_host_activity(pi, token, host)
686
+ self._open_activity(pi, token, boundary)
687
+ self._close_activity(pi, token, boundary)
688
+ arrivals: List[_Arrival] = []
689
+ self._leave(pi, token, boundary, arrivals)
690
+ self._pump(pi, arrivals)
691
+ if self._store is not None:
692
+ self._store.save_proc_inst(self._build_snap(pi))
693
+ else:
694
+ # 非中断式:宿主保留,订阅常驻(可再次触发);spawn 并发线
695
+ self._spawn_non_interrupting_boundary(pi, token, boundary)
696
+
697
+ def _fire_event_esc_start(
698
+ self, pi: ProcessInstance, host: Execution, sub: EventSubscription
699
+ ) -> None:
700
+ """事件子流程 message/signal start 触发(中断式接管 / 非中断 spawn)。"""
701
+ root_proc = self._definitions[pi.process_definition_key]
702
+ # 容器由订阅自身携带:None = 根 Process;否则 = sub_id 对应 sub 的 inner
703
+ # (一致性校验与 timer esc 触发同构:sub 级订阅仅在 host 仍停驻同一 sub)
704
+ if sub.activity_id is None:
705
+ container = root_proc
706
+ else:
707
+ if host.role != "SCOPE" or host.activity_id != sub.activity_id:
708
+ self._event_subs.pop(sub.id, None) # 宿主已离开订阅容器
709
+ return
710
+ outer = self._container_of(pi, host)
711
+ parked = outer.flow_nodes.get(sub.activity_id)
712
+ if not isinstance(parked, SubProcess):
713
+ self._event_subs.pop(sub.id, None)
714
+ return
715
+ container = parked.process
716
+ event_sub: Optional[SubProcess] = None
717
+ start: Optional[StartEvent] = None
718
+ for esc in container.flow_nodes.values():
719
+ if not (isinstance(esc, SubProcess) and esc.triggered_by_event):
720
+ continue
721
+ inner = esc.process
722
+ if inner is None:
723
+ continue
724
+ for st in inner.start_events:
725
+ if st.id == sub.node_id:
726
+ event_sub, start = esc, st
727
+ break
728
+ if event_sub is None or start is None:
729
+ self._event_subs.pop(sub.id, None) # 防御:订阅目标不存在
730
+ return
731
+ if start.is_interrupting:
732
+ # 中断式:触发即消费(随后 _fire_esc_event 整容器订阅一并撤销)
733
+ self._event_subs.pop(sub.id, None)
734
+ # 非中断式:订阅常驻,可再次触发(每次关联/广播 spawn 一个新实例)
735
+ self._fire_esc_event(pi, host, sub.activity_id, event_sub, start)
736
+
737
+ def _fire_esc_event(
738
+ self,
739
+ pi: ProcessInstance,
740
+ host: Execution,
741
+ container_id: Optional[str],
742
+ event_sub: SubProcess,
743
+ start: StartEvent,
744
+ ) -> None:
745
+ """事件子流程 message/signal start 触发主体(中断语义与 timer esc 同构)。
746
+
747
+ 中断式:流程级 interrupt 整实例 / sub 级 kill scope 内部;随后撤订阅容器
748
+ 全部 esc 订阅(timer job + message/signal sub,宿主被接管同容器订阅失效)。
749
+ 非中断式:宿主保留,直接 spawn 事件子流程(message/signal start 订阅常驻
750
+ 可再次触发——与 timer 非中断单发的差异,文档化)。
751
+ """
752
+ if start.is_interrupting:
753
+ if container_id is None:
754
+ self._interrupt_instance(pi) # 流程级:清 root 全部实例级状态
755
+ else:
756
+ self._kill_subprocess_scope(pi, host)
757
+ self._drop_scope_event_jobs(pi, host, container_id)
758
+ self._drop_scope_event_subscriptions(pi, host, container_id)
759
+ arrivals = self._start_event_subprocess(pi, host, event_sub, start)
760
+ self._pump(pi, arrivals)
761
+ if self._store is not None:
762
+ self._store.save_proc_inst(self._build_snap(pi))
763
+
764
+ # ------------------------------------------------------------------
765
+ # TaskService 语义
766
+ # ------------------------------------------------------------------
767
+ def create_task_query(self, process_instance_id: Optional[str] = None) -> List[Task]:
768
+ """查询任务(M1:无分页,创建序)。"""
769
+ with self._lock:
770
+ tasks = list(self._tasks.values())
771
+ if process_instance_id is not None:
772
+ tasks = [t for t in tasks if t.process_instance_id == process_instance_id]
773
+ return tasks
774
+
775
+ def complete_task(
776
+ self,
777
+ task_id: str,
778
+ variables: Optional[Dict[str, Any]] = None,
779
+ ) -> None:
780
+ """完成任务:合并变量 -> token 从 userTask 离开 -> pump。"""
781
+ with self._lock:
782
+ task = self._tasks.get(task_id)
783
+ if task is None:
784
+ raise NotFoundException(f"任务不存在或已完成: {task_id!r}")
785
+ pi = self._instances[task.process_instance_id]
786
+ if pi.is_completed:
787
+ raise ProcessInstanceException(
788
+ f"流程实例 {pi.id} 已结束,任务 {task_id} 不可再完成"
789
+ )
790
+ token = pi.executions.get(task.execution_id)
791
+ if token is None or token.state != ExecutionState.ACTIVE:
792
+ raise ProcessInstanceException(f"任务 {task_id} 对应的执行已失效")
793
+
794
+ if variables:
795
+ pi.variables.update(variables)
796
+ # 归档到已完成任务(HI_TASKINST 落库),并从活跃任务表移除
797
+ task.end_time = _now()
798
+ pi.completed_tasks.append(task)
799
+ self._tasks.pop(task_id)
800
+
801
+ proc = self._container_of(pi, token)
802
+ node = proc.flow_nodes[task.task_definition_key]
803
+ self._close_activity(pi, token, node)
804
+ self._drop_boundary_jobs(pi, node) # 宿主正常离开:边界 timer 作废
805
+ # 多实例宿主(M4-2c):userTask 是某个 MI 实例的行为载体 ->
806
+ # 走实例完成路径(计数/条件/收束/续跑),不沿普通出边走。
807
+ mi_scope = self._mi_scope_of(pi, token)
808
+ if mi_scope is not None:
809
+ # 实例完成路径会返回续跑/收束推进事件(如顺序 subProcess 宿主启动
810
+ # 下一实例、容器收束沿宿主出边离开),在此统一 pump
811
+ self._pump(pi, self._complete_mi_instance(pi, token, node, mi_scope))
812
+ if self._store is not None:
813
+ self._store.save_proc_inst(self._build_snap(pi))
814
+ return
815
+ arrivals: List[_Arrival] = []
816
+ self._leave(pi, token, node, arrivals)
817
+ self._pump(pi, arrivals)
818
+ if self._store is not None:
819
+ self._store.save_proc_inst(self._build_snap(pi))
820
+
821
+ # ------------------------------------------------------------------
822
+ # 内部推进引擎
823
+ # ------------------------------------------------------------------
824
+ def _pump(self, pi: ProcessInstance, initial: List[_Arrival]) -> None:
825
+ """事件队列推进:处理 token 到达直至队列空或实例结束。"""
826
+ queue: Deque[_Arrival] = deque(initial)
827
+ while queue and not pi.is_completed:
828
+ token, node = queue.popleft()
829
+ if token.state != ExecutionState.ACTIVE:
830
+ continue # 随兄弟汇聚/取消而失效,丢弃过期事件
831
+ arrivals = self._handle_arrival(pi, token, node)
832
+ queue.extend(arrivals)
833
+
834
+ def _handle_arrival(
835
+ self, pi: ProcessInstance, token: Execution, node: FlowNode
836
+ ) -> List[_Arrival]:
837
+ """token 进入节点:asyncBefore 拆分或按类型分派行为,返回后续到达事件。"""
838
+ token.activity_id = node.id
839
+
840
+ # 多实例宿主(M4-2c):token 首次到达带 multiInstanceLoopCharacteristics
841
+ # 的活动节点 -> 进入 MI 容器语义(parallel spawn / sequential 顺序循环)。
842
+ # token.mi is None 守卫:sequential 续跑/parallel child 由 _start_mi_instance
843
+ # 直接驱动行为,不再经本入口(防重复进入 MI 容器)。
844
+ if node.multi_instance is not None and not token.is_mi_container:
845
+ return self._enter_multi_instance(pi, token, node)
846
+ # asyncBefore:节点行为执行拆成独立 job(async continuation 语义)
847
+ if node.async_before:
848
+ if self._schedule_async_before(pi, token, node):
849
+ return []
850
+ # asyncAfter 支持范围校验(M4-1:serviceTask / exclusiveGateway 行为后拆分;
851
+ # 其余类型文档化差异不支持——userTask/并行网关等 asyncAfter 在 Camunda 有特定
852
+ # 语义,M4-1 明确报错避免静默错位)
853
+ if node.async_after and not isinstance(node, (ServiceTask, ExclusiveGateway)):
854
+ raise InvalidRequestException(
855
+ f"节点 {node.id!r} 声明 camunda:asyncAfter:M4-1 仅支持 serviceTask / "
856
+ f"exclusiveGateway,{type(node).__name__} 的 asyncAfter 不支持(文档化差异)"
857
+ )
858
+ return self._dispatch_node(pi, token, node)
859
+
860
+ def _dispatch_node(
861
+ self, pi: ProcessInstance, token: Execution, node: FlowNode
862
+ ) -> List[_Arrival]:
863
+ """节点行为分派主体(async 作业执行时也直接调本方法,不再重复拆分)。"""
864
+ proc = self._container_of(pi, token)
865
+ arrivals: List[_Arrival] = []
866
+
867
+ if isinstance(node, (StartEvent, EndEvent)):
868
+ self._open_activity(pi, token, node)
869
+ self._close_activity(pi, token, node)
870
+ if isinstance(node, EndEvent):
871
+ if node.error_code:
872
+ # 错误结束事件:token 结束 + 错误冒泡找事件子流程捕获
873
+ return self._throw_error(pi, token, node)
874
+ if node.message_name is not None or node.signal_name is not None:
875
+ # M4-2d 消息/信号结束:token 结束同时实例内投递;若自身广播
876
+ # 触发的中断式订阅接管了实例(token 被杀)则不再收束
877
+ self._throw_event_in_instance(pi, token, node)
878
+ if token.state != ExecutionState.ACTIVE or pi.is_completed:
879
+ return []
880
+ arrivals.extend(self._end_token(pi, token))
881
+ else:
882
+ self._leave(pi, token, node, arrivals)
883
+ return arrivals
884
+
885
+ if isinstance(node, SubProcess):
886
+ return self._enter_subprocess(pi, token, node)
887
+
888
+ if isinstance(node, IntermediateCatchEvent):
889
+ return self._enter_event_catch(pi, token, node)
890
+
891
+ if isinstance(node, IntermediateThrowEvent):
892
+ # M4-2d:中间抛出事件(无等待窗口):结算 actinst -> 实例内投递
893
+ # message/signal -> token 沿出边继续(无出边则收束)。若投递触发
894
+ # 的中断式订阅接管了实例(token 被杀)则不再继续
895
+ self._open_activity(pi, token, node)
896
+ self._close_activity(pi, token, node)
897
+ if node.message_name is None and node.signal_name is None:
898
+ raise InvalidRequestException(
899
+ f"中间抛出事件 {node.id!r} 未实现"
900
+ "(M4-2d 仅支持 message/signal throw)"
901
+ )
902
+ self._throw_event_in_instance(pi, token, node)
903
+ if token.state != ExecutionState.ACTIVE or pi.is_completed:
904
+ return []
905
+ self._leave(pi, token, node, arrivals)
906
+ return arrivals
907
+
908
+ if isinstance(node, UserTask):
909
+ return self._enter_user_task_wait(pi, token, node)
910
+
911
+ if isinstance(node, ServiceTask):
912
+ self._open_activity(pi, token, node)
913
+ self._run_delegate(pi, token, node)
914
+ self._close_activity(pi, token, node)
915
+ if node.async_after:
916
+ # 行为已完成(actinst 结算):把「离开推进」拆成独立 async-after job
917
+ self._schedule_async_after(pi, token, node)
918
+ return arrivals
919
+ self._leave(pi, token, node, arrivals)
920
+ return arrivals
921
+
922
+ if isinstance(node, BusinessRuleTask):
923
+ # M5:同步求值 DMN 决策(无等待窗口)-> 结果写入 result_variable
924
+ self._open_activity(pi, token, node)
925
+ result = self._dmn.evaluate_decision(node.decision_ref, pi.variables)
926
+ pi.variables[node.result_variable] = result
927
+ self._close_activity(pi, token, node)
928
+ if node.async_after:
929
+ self._schedule_async_after(pi, token, node)
930
+ return arrivals
931
+ self._leave(pi, token, node, arrivals)
932
+ return arrivals
933
+
934
+ if isinstance(node, ExclusiveGateway):
935
+ self._open_activity(pi, token, node)
936
+ self._close_activity(pi, token, node)
937
+ if node.async_after:
938
+ # 网关无副作用:选路推迟到 async-after job(到期重新求值条件)
939
+ self._schedule_async_after(pi, token, node)
940
+ return arrivals
941
+ chosen = select_exclusive_gateway_flow(
942
+ node, self._outgoing(proc, node), pi.variables
943
+ )
944
+ self._take(pi, token, chosen, arrivals)
945
+ return arrivals
946
+
947
+ if isinstance(node, ParallelGateway):
948
+ return self._handle_parallel_gateway(pi, token, node)
949
+
950
+ raise InvalidRequestException(
951
+ f"不支持的节点类型: {type(node).__name__} (id={node.id})"
952
+ )
953
+
954
+ # ------------------------------------------------------------------
955
+ # M4-2a:embedded SubProcess 进入/收束(SCOPE 容器语义)
956
+ # ------------------------------------------------------------------
957
+ def _container_of(self, pi: ProcessInstance, e: Execution) -> Process:
958
+ """execution 当前所在容器(内嵌子流程展开时随树推导,无需额外存储)。
959
+
960
+ 沿父链向上找第一个「停驻在 SubProcess 上的 SCOPE 祖先」——该祖先代表
961
+ 子流程执行体,取其 inner Process 即当前容器;无则根 Process。跨容器
962
+ 节点 id 不串扰(不同容器可有同名节点,归属只按树位置解析)。
963
+ """
964
+ cur: Optional[Execution] = e
965
+ while cur.parent_id is not None:
966
+ parent = pi.executions.get(cur.parent_id)
967
+ if parent is None:
968
+ break
969
+ if parent.role == "SCOPE" and parent.activity_id:
970
+ pnode = (
971
+ self._container_of(pi, parent)
972
+ .flow_nodes.get(parent.activity_id)
973
+ )
974
+ if isinstance(pnode, SubProcess):
975
+ # M4-2c3:仅「并行 MI 容器」停驻在宿主 subProcess 节点上但
976
+ # 不进子流程(进入的是其 child 实例)——它不是执行体,跳过
977
+ # 继续向外。其余 SCOPE@sub 都是该 sub 的执行体,取其 inner:
978
+ # 常规 embedded scope / 顺序 MI 容器(token 兼实例载体)/
979
+ # 并行实例载体(mi={"index"},本身已进 sub 跑内部流转)。
980
+ if parent.is_mi_container and not parent.mi["sequential"]:
981
+ cur = parent
982
+ continue
983
+ return pnode.process
984
+ cur = parent
985
+ return self._definitions[pi.process_definition_key]
986
+
987
+ def _enter_subprocess(
988
+ self, pi: ProcessInstance, token: Execution, sub: SubProcess
989
+ ) -> List[_Arrival]:
990
+ """进入内嵌子流程:token 停驻为 SCOPE,spawn 内部子 token 从 startEvent 推进。
991
+
992
+ - subProcess 活动实例跨整段内部执行 open(exit 时结算),对齐 Camunda
993
+ HI_ACTINST 对 subProcess 的覆盖区间。
994
+ - 变量作用域沿用实例级(文档化差异,无子作用域遮蔽)。
995
+ - 边界 timer 注册:subProcess 是合法宿主,等待窗口 = 整段内部执行期
996
+ (M4-2a3;非中断式 cancelActivity=false 仍拒绝)。
997
+ - 事件子流程(triggeredByEvent)运行语义 M4-2b 落地,先明确报错。
998
+ """
999
+ if sub.triggered_by_event:
1000
+ raise InvalidRequestException(
1001
+ f"subProcess {sub.id!r} 是事件子流程(triggeredByEvent):"
1002
+ f"M4-2b 实现,当前不支持"
1003
+ )
1004
+ inner = sub.process
1005
+ if inner is None or not inner.start_events:
1006
+ raise ProcessInstanceException(
1007
+ f"subProcess {sub.id!r} 内部没有可启动的 startEvent"
1008
+ )
1009
+ start = inner.start_events[0]
1010
+ if start.timer is not None:
1011
+ raise InvalidRequestException(
1012
+ f"subProcess {sub.id!r} 内部 startEvent {start.id!r} 带 timer:"
1013
+ f"内嵌子流程不支持定时启动(文档化差异)"
1014
+ )
1015
+ if start.error_code is not None or start.message_name is not None or start.signal_name is not None:
1016
+ raise InvalidRequestException(
1017
+ f"subProcess {sub.id!r} 内部 startEvent {start.id!r} 是事件 start"
1018
+ "(error/message/signal):事件启动只属于事件子流程(文档化差异)"
1019
+ )
1020
+ self._open_activity(pi, token, sub)
1021
+ token.role = "SCOPE"
1022
+ # spawn 内部子 token:从 subProcess 内部 startEvent 开始推进
1023
+ child = Execution(
1024
+ id=self._idgen.next_id(),
1025
+ process_instance_id=pi.id,
1026
+ parent_id=token.id,
1027
+ )
1028
+ pi.executions[child.id] = child
1029
+ token.children.append(child)
1030
+ # 进入等待窗口:注册挂在 subProcess 上的边界 timer 作业
1031
+ self._register_boundary_jobs(pi, token, sub)
1032
+ # M4-2b3:subProcess scope 激活 -> 订阅该 sub 容器内事件子流程的 timer start
1033
+ self._register_event_subprocess_timers(pi, token, sub.process, sub.id)
1034
+ # M4-2d:该 sub 容器内 message/signal esc start 常驻订阅
1035
+ self._register_event_subprocess_subscriptions(pi, token, sub.process, sub.id)
1036
+ return [(child, start)]
1037
+
1038
+ # ------------------------------------------------------------------
1039
+ # M4-2b:事件子流程(triggeredByEvent)与错误传播
1040
+ # ------------------------------------------------------------------
1041
+ def _throw_error(
1042
+ self, pi: ProcessInstance, token: Execution, node: EndEvent
1043
+ ) -> List[_Arrival]:
1044
+ """error endEvent 抛出:token 结束 + 错误沿宿主链冒泡找事件子流程捕获。
1045
+
1046
+ 命中 -> 中断宿主 scope 其它执行、事件子流程接管(其结束 = 宿主 scope
1047
+ 结束:流程级 -> 实例完成;subProcess 级 -> 收束复活沿出边继续——错误由
1048
+ subProcess 自己消化)。未命中 -> 等同 none end(仅当前路径结束),对齐
1049
+ Camunda error end event 默认语义(日志 warning 留痕)。
1050
+ """
1051
+ code = node.error_code
1052
+ hit = self._find_error_catcher(pi, token, code)
1053
+ if hit is None:
1054
+ logger.warning(
1055
+ "流程实例 %s: error endEvent %s 抛出错误 %r 无事件子流程捕获,等同 none end",
1056
+ pi.id, node.id, code,
1057
+ )
1058
+ return self._end_token(pi, token)
1059
+ host, container, event_sub, start = hit
1060
+ if token.is_root or pi.root_execution is token:
1061
+ # 根执行到达 error end:实例整体被事件子流程接管。root 不结束——它
1062
+ # 转为事件子流程宿主载体(活动/子树清空由 _interrupt_instance 完成,
1063
+ # 事件子流程收束后 collapse 收尾 -> 实例完成)
1064
+ self._interrupt_instance(pi)
1065
+ else:
1066
+ # 子树内抛错:错误路径结束摘树,再中断宿主 scope 的其它执行
1067
+ token.state = ExecutionState.ENDED
1068
+ self._detach_from_parent(pi, token)
1069
+ if container is self._definitions[pi.process_definition_key]:
1070
+ self._interrupt_instance(pi)
1071
+ else:
1072
+ # subProcess(或嵌套事件子流程)级捕获:中断该容器 scope 内部,
1073
+ # 宿主本体保留(事件子流程走完后由收束链复活/上移)
1074
+ self._kill_subprocess_scope(pi, host)
1075
+ # 宿主被事件子流程接管:其容器内 timer 事件子流程订阅一并失效
1076
+ # (只撤被接管 sub 容器的订阅——host 若为 root 兼任,流程级订阅保留)
1077
+ self._drop_scope_event_jobs(pi, host, host.activity_id)
1078
+ # M4-2d:同容器 message/signal esc 订阅一并失效
1079
+ self._drop_scope_event_subscriptions(pi, host, host.activity_id)
1080
+ return self._start_event_subprocess(pi, host, event_sub, start)
1081
+
1082
+ def _throw_event_in_instance(
1083
+ self, pi: ProcessInstance, token: Execution, node: FlowNode
1084
+ ) -> None:
1085
+ """实例内 message/signal throw(M4-2d:IntermediateThrowEvent / EndEvent)。
1086
+
1087
+ - message:1:1 就近关联——本实例内匹配订阅取注册序最早(文档化差异:
1088
+ Camunda 沿 scope 链向外找最近命中;本引擎统一按注册序)。未命中 ->
1089
+ 静默丢弃(对齐 Camunda throw message 无等待订阅即丢失,等同 none end)。
1090
+ - signal:本实例内广播全部匹配订阅(文档化差异:跨实例广播由公共 API
1091
+ throw_signal 提供,throw 事件本身只作用于本实例)。
1092
+ 触发可能接管实例(中断式订阅杀死本 token)——调用方检查 token 存活性。
1093
+ """
1094
+ kind = "message" if node.message_name is not None else "signal"
1095
+ name = node.message_name or node.signal_name
1096
+ if kind == "message":
1097
+ sub = self._find_event_subscription("message", name, pi.id)
1098
+ if sub is None:
1099
+ logger.warning(
1100
+ "流程实例 %s: throw message %r 无等待订阅,消息丢弃(token 继续流转)",
1101
+ pi.id, name,
1102
+ )
1103
+ return
1104
+ self._fire_subscription(pi, sub)
1105
+ return
1106
+ for sub in [
1107
+ s
1108
+ for s in self._event_subs.values()
1109
+ if s.kind == "signal"
1110
+ and s.event_name == name
1111
+ and s.process_instance_id == pi.id
1112
+ ]:
1113
+ self._fire_subscription(pi, sub)
1114
+
1115
+ def _find_error_catcher(
1116
+ self, pi: ProcessInstance, e: Execution, code: str
1117
+ ) -> Optional[Tuple[Execution, "Process", SubProcess, StartEvent]]:
1118
+ """沿宿主 scope 链(内到外)找能捕获错误 code 的事件子流程。
1119
+
1120
+ 返回 (host_execution, container, event_sub, start):
1121
+ - host_execution:root 或停驻 SubProcess 的 SCOPE(事件子流程的宿主)
1122
+ - container:命中声明所在的容器 Process(根 Process = 流程级,中断整个
1123
+ 实例;某 subProcess 的 inner = subProcess 级,中断该 scope 内部)
1124
+ - event_sub / start:命中的事件子流程与其 error start
1125
+ 无命中返回 None(调用方按 none end 语义处理)。
1126
+ """
1127
+ root_proc = self._definitions[pi.process_definition_key]
1128
+ # 宿主链:从 e 所在容器逐层向外到根容器(先查内层声明,再冒到外层)
1129
+ chain: List[Tuple[Execution, Process]] = []
1130
+ cur = e
1131
+ while cur is not None and not (cur.is_root or pi.root_execution is cur):
1132
+ parent = pi.executions.get(cur.parent_id) if cur.parent_id else None
1133
+ if parent is None:
1134
+ break
1135
+ if parent.role == "SCOPE" and parent.activity_id:
1136
+ pnode = (
1137
+ self._container_of(pi, parent).flow_nodes.get(parent.activity_id)
1138
+ )
1139
+ if isinstance(pnode, SubProcess):
1140
+ chain.append((parent, pnode.process))
1141
+ cur = parent
1142
+ chain.append((pi.root_execution, root_proc))
1143
+ for host, container in chain:
1144
+ for sub in container.flow_nodes.values():
1145
+ if not (isinstance(sub, SubProcess) and sub.triggered_by_event):
1146
+ continue
1147
+ inner = sub.process
1148
+ if inner is None:
1149
+ continue
1150
+ for st in inner.start_events:
1151
+ if st.error_code == code and st.is_interrupting:
1152
+ return host, container, sub, st
1153
+ return None
1154
+
1155
+ def _start_event_subprocess(
1156
+ self,
1157
+ pi: ProcessInstance,
1158
+ host: Execution,
1159
+ event_sub: SubProcess,
1160
+ start: StartEvent,
1161
+ ) -> List[_Arrival]:
1162
+ """在宿主 scope 下启动事件子流程实例(中断由调用方先行完成)。
1163
+
1164
+ - 事件子流程 scope:role=SCOPE、activity_id=事件子流程 id、挂 host 下
1165
+ ——与 embedded SubProcess 共用容器推导/收束路径(零表改动)。事件子
1166
+ 流程 actinst 跨整段执行 open,收束时由 collapse 的 SubProcess 分支结算。
1167
+ - 内部子 token 从匹配的 startEvent 推进(start 的 event 槽仅用于触发,
1168
+ 运行语义 = 普通 startEvent 沿出边走)。
1169
+ - message/signal start(M4-2d):与 timer/error start 同型——订阅触发
1170
+ 由调用方(correlate_message / throw_signal)完成中断语义后进入。
1171
+ """
1172
+ inner = event_sub.process
1173
+ if inner is None:
1174
+ raise ProcessInstanceException(
1175
+ f"事件子流程 {event_sub.id!r} 内部容器缺失"
1176
+ )
1177
+ # message/signal start(M4-2d):仅经由 _fire_esc_event 进入(correlate/
1178
+ # throw_signal 触发路径已完成中断语义);timer/error 路径按事件槽匹配,
1179
+ # 不会命中 message/signal start,无需额外守卫。
1180
+ scope = Execution(
1181
+ id=self._idgen.next_id(),
1182
+ process_instance_id=pi.id,
1183
+ parent_id=host.id,
1184
+ role="SCOPE",
1185
+ activity_id=event_sub.id,
1186
+ )
1187
+ pi.executions[scope.id] = scope
1188
+ host.children.append(scope)
1189
+ child = Execution(
1190
+ id=self._idgen.next_id(),
1191
+ process_instance_id=pi.id,
1192
+ parent_id=scope.id,
1193
+ )
1194
+ pi.executions[child.id] = child
1195
+ scope.children.append(child)
1196
+ self._open_activity(pi, scope, event_sub)
1197
+ # M4-2b3:事件子流程 scope 自身激活 -> 订阅其内部嵌套事件子流程的 timer
1198
+ # start(宿主容器 = 本事件子流程的 inner)
1199
+ self._register_event_subprocess_timers(pi, scope, inner, event_sub.id)
1200
+ # M4-2d:事件子流程 scope 自身激活 -> 订阅其内部嵌套事件子流程的
1201
+ # message/signal start(宿主容器 = 本事件子流程的 inner)
1202
+ self._register_event_subprocess_subscriptions(pi, scope, inner, event_sub.id)
1203
+ return [(child, start)]
1204
+
1205
+ def _interrupt_instance(self, pi: ProcessInstance) -> None:
1206
+ """流程级中断:结束实例内全部执行,root 保留为事件子流程宿主载体。
1207
+
1208
+ 结算 root 自身活动/任务/作业,杀全部子树(含 join 登记摘除、任务归档),
1209
+ 清空 root.activity_id(事件子流程 scope 挂其下;收束后 collapse 收尾)。
1210
+ """
1211
+ now = _now()
1212
+ root = pi.root_execution
1213
+ if root is None:
1214
+ return
1215
+ if root.open_activity is not None and root.open_activity.end_time is None:
1216
+ root.open_activity.end_time = now
1217
+ root.open_activity = None
1218
+ for t in [
1219
+ t
1220
+ for t in self._tasks.values()
1221
+ if t.process_instance_id == pi.id and t.execution_id == root.id
1222
+ ]:
1223
+ self._tasks.pop(t.id)
1224
+ t.end_time = now
1225
+ pi.completed_tasks.append(t)
1226
+ for j in [
1227
+ j
1228
+ for j in self._jobs.values()
1229
+ if j.process_instance_id == pi.id and j.execution_id == root.id
1230
+ ]:
1231
+ self._jobs.pop(j.id)
1232
+ self._kill_subprocess_scope(pi, root) # 子树全杀(任务归档/作业删除/join 摘除)
1233
+ # M4-2d:root 自身承载的订阅(流程级/message/signal esc、边界)一并作废
1234
+ # ——中断式触发方已撤容器订阅,此处兜底剩余(对齐 timer 全撤语义)
1235
+ self._drop_subscriptions_for_execution(pi, root.id)
1236
+ pi.join_arrivals.clear() # 兜底清残留登记(kill 已逐棵摘除)
1237
+ root.activity_id = None
1238
+ root.role = "TOKEN" # 复位:root 作为实例级事件子流程的宿主载体
1239
+
1240
+ def _register_event_subprocess_timers(
1241
+ self,
1242
+ pi: ProcessInstance,
1243
+ host_scope: Execution,
1244
+ container: "Process",
1245
+ sub_id: Optional[str],
1246
+ ) -> None:
1247
+ """宿主 scope 激活订阅:容器内事件子流程的 timer start 注册实例级作业。
1248
+
1249
+ container/sub_id 由调用点显式给出(流程实例启动 -> 根 Process/None;
1250
+ 进入 embedded subProcess -> 该 sub 的 inner/sub.id),不反推——root 兼任
1251
+ sub 容器时(root 停驻 sub)两处订阅必须落在各自容器上、互不混淆。
1252
+
1253
+ 订阅生命周期对齐 Camunda 文档:scope(流程实例或 subProcess)创建即订阅、
1254
+ scope 结束或触发即撤销(_drop_scope_event_jobs)。每次激活单发:timer
1255
+ 事件子流程不支持 cycle(文档化差异),非中断式 timer 同样只触发一次。
1256
+ """
1257
+ for sub in container.flow_nodes.values():
1258
+ if not (isinstance(sub, SubProcess) and sub.triggered_by_event):
1259
+ continue
1260
+ inner = sub.process
1261
+ if inner is None:
1262
+ continue
1263
+ for st in inner.start_events:
1264
+ if st.timer is None:
1265
+ continue
1266
+ # 幂等:重复激活(如顺序 MI 宿主续跑再进同一 sub)不重复注册
1267
+ dup = any(
1268
+ j.job_type == "timer-event-start"
1269
+ and j.process_instance_id == pi.id
1270
+ and j.execution_id == host_scope.id
1271
+ and j.activity_id == sub_id
1272
+ and j.node_id == st.id
1273
+ for j in self._jobs.values()
1274
+ )
1275
+ if dup:
1276
+ continue
1277
+ self._register_timer_event_start_job(pi, host_scope, sub, st, sub_id)
1278
+
1279
+ def _register_timer_event_start_job(
1280
+ self,
1281
+ pi: ProcessInstance,
1282
+ host_scope: Execution,
1283
+ event_sub: SubProcess,
1284
+ start: StartEvent,
1285
+ sub_id: Optional[str],
1286
+ ) -> None:
1287
+ """为事件子流程的 timer start 注册一条 timer-event-start 作业(订阅)。"""
1288
+ timer = start.timer
1289
+ if timer.kind == "cycle":
1290
+ raise InvalidRequestException(
1291
+ f"事件子流程 {event_sub.id!r} 的 timer start {start.id!r} 不支持 "
1292
+ f"timeCycle(订阅单发,文档化差异)"
1293
+ )
1294
+ now = _now()
1295
+ if timer.kind == "duration":
1296
+ duedate = format_iso(
1297
+ parse_iso(now) + timedelta(seconds=timer.delay_seconds or 0)
1298
+ )
1299
+ else: # date:绝对时间点
1300
+ duedate = parse_trigger_date(timer.value)
1301
+ job = Job(
1302
+ id=self._idgen.next_id(),
1303
+ job_type="timer-event-start",
1304
+ duedate=duedate,
1305
+ created=now,
1306
+ process_instance_id=pi.id,
1307
+ execution_id=host_scope.id, # 宿主 scope;容器/事件子流程到期反查
1308
+ node_id=start.id, # 事件子流程 startEvent id
1309
+ activity_id=sub_id, # 订阅容器 subProcess id(None = 流程级)
1310
+ )
1311
+ self._jobs[job.id] = job
1312
+
1313
+ def _drop_scope_event_jobs(
1314
+ self,
1315
+ pi: ProcessInstance,
1316
+ host_scope: Execution,
1317
+ sub_id: Optional[str] = None,
1318
+ node_id: Optional[str] = None,
1319
+ ) -> None:
1320
+ """宿主 scope 事件子流程订阅撤销:按 (execution, 容器[, start]) 精确删除。
1321
+
1322
+ sub_id=None 只撤流程级(根 Process 容器)订阅;传 sub_id 只撤该 subProcess
1323
+ 容器上的订阅——root 兼任 sub 容器时两套订阅可独立撤销,互不误伤。
1324
+ node_id 给定则只撤该 timer start 的订阅(非中断式单发消费)。
1325
+ """
1326
+ stale = [
1327
+ jid
1328
+ for jid, j in self._jobs.items()
1329
+ if j.process_instance_id == pi.id
1330
+ and j.job_type == "timer-event-start"
1331
+ and j.execution_id == host_scope.id
1332
+ and j.activity_id == sub_id
1333
+ and (node_id is None or j.node_id == node_id)
1334
+ ]
1335
+ for jid in stale:
1336
+ self._jobs.pop(jid, None)
1337
+
1338
+ def _register_event_subprocess_subscriptions(
1339
+ self,
1340
+ pi: ProcessInstance,
1341
+ host_scope: Execution,
1342
+ container: "Process",
1343
+ sub_id: Optional[str],
1344
+ ) -> None:
1345
+ """宿主 scope 激活订阅:容器内事件子流程的 message/signal start 注册订阅。
1346
+
1347
+ 与 timer start(实例级 job)平行;message/signal 是无限期等待 + 外部
1348
+ 关联/广播触发 -> 常驻订阅表(_event_subs),宿主 scope 生命周期内有效:
1349
+ - 中断式 start:触发时撤容器全部 esc 订阅(宿主被接管);
1350
+ - 非中断式 start:常驻可多次触发(每次触发 spawn 一个新实例)。
1351
+ 幂等:同(宿主, 容器, start)重复激活(如顺序 MI 续跑再进同一 sub)
1352
+ 不重复注册。container/sub_id 由调用点显式给出(同 timer 注册约定)。
1353
+ """
1354
+ for sub in container.flow_nodes.values():
1355
+ if not (isinstance(sub, SubProcess) and sub.triggered_by_event):
1356
+ continue
1357
+ inner = sub.process
1358
+ if inner is None:
1359
+ continue
1360
+ for st in inner.start_events:
1361
+ if st.message_name is None and st.signal_name is None:
1362
+ continue
1363
+ kind = "message" if st.message_name is not None else "signal"
1364
+ name = st.message_name or st.signal_name
1365
+ dup = any(
1366
+ s.kind == kind
1367
+ and s.event_name == name
1368
+ and s.execution_id == host_scope.id
1369
+ and s.activity_id == sub_id
1370
+ and s.node_id == st.id
1371
+ for s in self._event_subs.values()
1372
+ )
1373
+ if dup:
1374
+ continue # 幂等:重复激活不重复注册
1375
+ s = EventSubscription(
1376
+ id=self._idgen.next_id(),
1377
+ kind=kind,
1378
+ event_name=name,
1379
+ process_instance_id=pi.id,
1380
+ execution_id=host_scope.id,
1381
+ activity_id=sub_id,
1382
+ node_id=st.id,
1383
+ node_kind="start",
1384
+ is_interrupting=st.is_interrupting,
1385
+ created=_now(),
1386
+ )
1387
+ self._event_subs[s.id] = s
1388
+
1389
+ def _drop_scope_event_subscriptions(
1390
+ self,
1391
+ pi: ProcessInstance,
1392
+ host_scope: Execution,
1393
+ sub_id: Optional[str] = None,
1394
+ node_id: Optional[str] = None,
1395
+ ) -> None:
1396
+ """宿主 scope 事件子流程消息/信号订阅撤销(按 execution + 容器精确删)。
1397
+
1398
+ sub_id=None 只撤流程级订阅;传 sub_id 只撤该 subProcess 容器上的订阅。
1399
+ node_id 给定则只撤该 start 的订阅(触发消费用)。
1400
+ """
1401
+ stale = [
1402
+ sid
1403
+ for sid, s in self._event_subs.items()
1404
+ if s.process_instance_id == pi.id
1405
+ and s.node_kind == "start"
1406
+ and s.execution_id == host_scope.id
1407
+ and s.activity_id == sub_id
1408
+ and (node_id is None or s.node_id == node_id)
1409
+ ]
1410
+ for sid in stale:
1411
+ self._event_subs.pop(sid, None)
1412
+
1413
+ def _drop_subscriptions_for_execution(
1414
+ self, pi: ProcessInstance, execution_id: str
1415
+ ) -> None:
1416
+ """撤销挂在某 execution 上的全部订阅(catch 停等 / 宿主等待 / esc 宿主)。
1417
+
1418
+ 供杀灭/取消路径使用(kill 树逐节点调用;宿主离开由 _drop_boundary_jobs
1419
+ 专项撤销)。
1420
+ """
1421
+ stale = [
1422
+ sid
1423
+ for sid, s in self._event_subs.items()
1424
+ if s.process_instance_id == pi.id and s.execution_id == execution_id
1425
+ ]
1426
+ for sid in stale:
1427
+ self._event_subs.pop(sid, None)
1428
+
1429
+ def _fire_timer_event_start(self, job: Job) -> None:
1430
+ """timer 事件子流程到期:宿主 scope 仍激活则触发(中断/非中断随 start)。
1431
+
1432
+ 触发即消费订阅:中断式撤该容器全部订阅(宿主被接管/取消);非中断式只撤
1433
+ 当前 timer start 的订阅(单发,同容器其它 esc 订阅保留)。宿主已失效
1434
+ (结束/离开停驻/被其它路径接管)-> 过期作业直接丢弃。
1435
+ """
1436
+ pi = self._instances.get(job.process_instance_id)
1437
+ host = pi.executions.get(job.execution_id) if pi is not None else None
1438
+ if (
1439
+ pi is None
1440
+ or pi.is_completed
1441
+ or host is None
1442
+ or host.state != ExecutionState.ACTIVE
1443
+ ):
1444
+ self._jobs.pop(job.id, None)
1445
+ return
1446
+ # 容器由订阅自身携带:None = 根 Process;否则 = sub_id 对应 sub 的 inner。
1447
+ # 一致性校验:sub 级订阅仅在 host 仍以 SCOPE 停驻同一 sub 时有效
1448
+ root_proc = self._definitions[pi.process_definition_key]
1449
+ if job.activity_id is None:
1450
+ container = root_proc
1451
+ else:
1452
+ if host.role != "SCOPE" or host.activity_id != job.activity_id:
1453
+ self._jobs.pop(job.id, None) # 宿主已离开订阅容器(收束/被接管)
1454
+ return
1455
+ outer = self._container_of(pi, host)
1456
+ parked = outer.flow_nodes.get(job.activity_id)
1457
+ if not isinstance(parked, SubProcess):
1458
+ self._jobs.pop(job.id, None)
1459
+ return
1460
+ container = parked.process
1461
+ event_sub: Optional[SubProcess] = None
1462
+ start: Optional[StartEvent] = None
1463
+ for sub in container.flow_nodes.values():
1464
+ if not (isinstance(sub, SubProcess) and sub.triggered_by_event):
1465
+ continue
1466
+ inner = sub.process
1467
+ if inner is None:
1468
+ continue
1469
+ for st in inner.start_events:
1470
+ if st.id == job.node_id and st.timer is not None:
1471
+ event_sub, start = sub, st
1472
+ break
1473
+ if event_sub is None or start is None:
1474
+ self._jobs.pop(job.id, None) # 防御:订阅目标已不存在(正常不会发生)
1475
+ return
1476
+ # 触发:中断式先取消宿主 scope 其它执行,再 spawn 事件子流程
1477
+ if start.is_interrupting:
1478
+ if job.activity_id is None:
1479
+ self._interrupt_instance(pi) # 流程级:清 root 全部实例级作业
1480
+ else:
1481
+ self._kill_subprocess_scope(pi, host)
1482
+ # 订阅消费:中断式撤容器全部(宿主被取消,同容器订阅一并失效);
1483
+ # 非中断式只撤当前 timer start(单发),同容器其它 esc 订阅保留
1484
+ if start.is_interrupting:
1485
+ self._drop_scope_event_jobs(pi, host, job.activity_id)
1486
+ # M4-2d:同容器 message/signal esc 订阅一并失效(中断语义与 timer 一致)
1487
+ self._drop_scope_event_subscriptions(pi, host, job.activity_id)
1488
+ else:
1489
+ self._drop_scope_event_jobs(pi, host, job.activity_id, job.node_id)
1490
+ arrivals = self._start_event_subprocess(pi, host, event_sub, start)
1491
+ self._pump(pi, arrivals)
1492
+
1493
+ # ------------------------------------------------------------------
1494
+ # M3:timer catch 停等 / async 拆分
1495
+ # ------------------------------------------------------------------
1496
+ def _enter_event_catch(
1497
+ self, pi: ProcessInstance, token: Execution, node: IntermediateCatchEvent
1498
+ ) -> List[_Arrival]:
1499
+ """token 到达中间捕获事件:timer -> 注册 job 停等(M3);message/signal
1500
+ -> 注册事件订阅停等(M4-2d),correlate_message / throw_signal 触发。"""
1501
+ if node.timer is not None:
1502
+ return self._handle_timer_catch(pi, token, node)
1503
+ if node.message_name is None and node.signal_name is None:
1504
+ raise InvalidRequestException(
1505
+ f"中间捕获事件 {node.id!r} 未实现(支持 timer/message/signal 事件定义)"
1506
+ )
1507
+ self._open_activity(pi, token, node)
1508
+ sub = EventSubscription(
1509
+ id=self._idgen.next_id(),
1510
+ kind="message" if node.message_name is not None else "signal",
1511
+ event_name=node.message_name or node.signal_name,
1512
+ process_instance_id=pi.id,
1513
+ execution_id=token.id,
1514
+ activity_id=None,
1515
+ node_id=node.id,
1516
+ node_kind="catch",
1517
+ is_interrupting=True, # catch 无中断概念(触发即 token 续走)
1518
+ created=_now(),
1519
+ )
1520
+ self._event_subs[sub.id] = sub
1521
+ return [] # 停等外部触发
1522
+
1523
+ def _handle_timer_catch(
1524
+ self, pi: ProcessInstance, token: Execution, node: IntermediateCatchEvent
1525
+ ) -> List[_Arrival]:
1526
+ """token 到达 timer 中间捕获事件:open actinst + 注册 timer-catch job,停等。"""
1527
+ timer = node.timer
1528
+ if timer is None:
1529
+ raise InvalidRequestException(
1530
+ f"中间捕获事件 {node.id!r} 未实现(M3 仅支持 timerEventDefinition)"
1531
+ )
1532
+ if timer.kind == "cycle":
1533
+ raise InvalidRequestException(
1534
+ f"timerCycle 仅用于 timer start;catch 事件 {node.id!r} 请用 timeDuration/timeDate"
1535
+ )
1536
+ self._open_activity(pi, token, node)
1537
+ now = _now()
1538
+ if timer.kind == "duration":
1539
+ duedate = format_iso(parse_iso(now) + timedelta(seconds=timer.delay_seconds or 0))
1540
+ else: # date:绝对时间点(归一化到本地时区定长 ISO)
1541
+ duedate = parse_trigger_date(timer.value)
1542
+ job = Job(
1543
+ id=self._idgen.next_id(),
1544
+ job_type="timer-catch",
1545
+ duedate=duedate,
1546
+ created=now,
1547
+ process_instance_id=pi.id,
1548
+ execution_id=token.id,
1549
+ node_id=node.id,
1550
+ )
1551
+ self._jobs[job.id] = job
1552
+ return [] # 停等 job 到期
1553
+
1554
+ def _schedule_async_before(self, pi: ProcessInstance, token: Execution, node: FlowNode) -> bool:
1555
+ """asyncBefore 拆分:open actinst + 立即可执行的 async-continuation job。
1556
+
1557
+ 返回 True = 已拆分停等(调用方应停止本轮推进)。actinst 保持 open,
1558
+ 待 async job 执行行为时由 _open_activity 复用、行为完成后 close。
1559
+ asyncBefore 使节点获得等待窗口 -> 同步在此注册边界 timer(若宿主挂了
1560
+ 边界事件;行为执行完成离开宿主时由调用方撤销)。
1561
+ """
1562
+ self._open_activity(pi, token, node)
1563
+ now = _now()
1564
+ job = Job(
1565
+ id=self._idgen.next_id(),
1566
+ job_type="async-continuation",
1567
+ duedate=now, # 立即到期(async continuation 无额外延迟)
1568
+ created=now,
1569
+ process_instance_id=pi.id,
1570
+ execution_id=token.id,
1571
+ node_id=node.id,
1572
+ )
1573
+ self._jobs[job.id] = job
1574
+ self._register_boundary_jobs(pi, token, node)
1575
+ return True
1576
+
1577
+ def _schedule_async_after(self, pi: ProcessInstance, token: Execution, node: FlowNode) -> None:
1578
+ """asyncAfter 拆分:节点行为已完成(actinst 结算),离开推进拆成独立 job。
1579
+
1580
+ Camunda asyncAfter 语义:行为执行与 token 沿出边流转之间插入异步作业。
1581
+ serviceTask:行为(delegate)在拆分前已执行,job 到期只做离开;XOR:网关
1582
+ 无副作用,选路本身推迟到 job 到期(此时重新求值出边条件)。asyncBefore +
1583
+ asyncAfter 链式:行为在 async-continuation job 中执行,完成后同样拆本 job。
1584
+ """
1585
+ now = _now()
1586
+ job = Job(
1587
+ id=self._idgen.next_id(),
1588
+ job_type="async-after",
1589
+ duedate=now, # 立即到期(async continuation 无额外延迟)
1590
+ created=now,
1591
+ process_instance_id=pi.id,
1592
+ execution_id=token.id,
1593
+ node_id=node.id,
1594
+ )
1595
+ self._jobs[job.id] = job
1596
+
1597
+ # ------------------------------------------------------------------
1598
+ # M4-1:timer 边界事件(中断式 interrupting;宿主 = 有等待点的活动)
1599
+ # ------------------------------------------------------------------
1600
+ def _register_boundary_jobs(
1601
+ self, pi: ProcessInstance, token: Execution, host: FlowNode
1602
+ ) -> None:
1603
+ """token 停等宿主活动(userTask / asyncBefore 拆分)时注册边界 timer 作业。
1604
+
1605
+ 作业语义:宿主等待期内到点触发——中断式(cancelActivity=true)取消宿主、
1606
+ token 改走边界事件出边;非中断式(cancelActivity=false,M4-2b4)不取消
1607
+ 宿主,spawn 并发线从边界事件出边推进(宿主与并发线全收束实例才结束)。
1608
+ 宿主正常离开(complete / async 行为完成)时由调用方 _drop_boundary_jobs
1609
+ 撤销 —— 边界事件只对「仍在等待的宿主活动」有效。
1610
+
1611
+ 支持范围(文档化差异,见 docs/ARCHITECTURE.md):
1612
+ - 中断式:userTask / asyncBefore 节点(M4-1)、subProcess(M4-2a3)
1613
+ - 非中断式(cancelActivity=false):userTask / asyncBefore 等待活动宿主
1614
+ (M4-2b4);subProcess 宿主 + 非中断仍明确报错(并发线需脱离 sub 容器
1615
+ 身份挂父 scope,root 兼任载体时存在容器推导歧义,暂缓——文档化差异)
1616
+ - 事件变体:timer(M4-1,仅 timeDuration / timeDate;timeCycle 拒绝)、
1617
+ message / signal(M4-2d——注册为常驻订阅,触发后中断式随宿主撤销、
1618
+ 非中断式保留可再触发;timer 系走 job 单发)
1619
+ - 宿主必须是有等待点的活动/容器:userTask / asyncBefore 节点
1620
+ (M4-1)、subProcess(M4-2a,等待窗口 = 整段内部执行)。同步节点
1621
+ (无 asyncBefore 的 serviceTask 等)没有等待窗口,本方法不会被调用
1622
+ —— 对齐 Camunda:同步活动在单命令内完成,边界事件无法插入中断。
1623
+ """
1624
+ proc = self._container_of(pi, token)
1625
+ existing = {
1626
+ j.node_id
1627
+ for j in self._jobs.values()
1628
+ if j.process_instance_id == pi.id and j.job_type == "timer-boundary"
1629
+ }
1630
+ existing_subs = {
1631
+ s.node_id
1632
+ for s in self._event_subs.values()
1633
+ if s.process_instance_id == pi.id
1634
+ and s.node_kind == "boundary"
1635
+ and s.execution_id == token.id
1636
+ }
1637
+ now = _now()
1638
+ for bid in host.boundary_events:
1639
+ boundary = proc.flow_nodes[bid]
1640
+ if not isinstance(boundary, BoundaryEvent):
1641
+ continue
1642
+ if not boundary.cancel_activity and isinstance(host, SubProcess):
1643
+ raise InvalidRequestException(
1644
+ f"boundaryEvent {boundary.id!r} 声明 cancelActivity=false(非中断式)"
1645
+ f"且宿主 {host.id!r} 是 subProcess:M4-2b4 支持普通等待活动宿主"
1646
+ "(userTask / asyncBefore),subProcess 宿主非中断式边界暂不支持"
1647
+ "(文档化差异)"
1648
+ )
1649
+ timer = boundary.timer
1650
+ if boundary.id in existing or boundary.id in existing_subs:
1651
+ continue # 幂等:asyncBefore 拆分已注册,行为续跑不重复注册
1652
+ if timer is not None:
1653
+ if timer.kind == "cycle":
1654
+ raise InvalidRequestException(
1655
+ f"boundaryEvent {boundary.id!r} 不支持 timerCycle,请用 timeDuration/timeDate"
1656
+ )
1657
+ if timer.kind == "duration":
1658
+ duedate = format_iso(
1659
+ parse_iso(now) + timedelta(seconds=timer.delay_seconds or 0)
1660
+ )
1661
+ else: # date:绝对时间点
1662
+ duedate = parse_trigger_date(timer.value)
1663
+ job = Job(
1664
+ id=self._idgen.next_id(),
1665
+ job_type="timer-boundary",
1666
+ duedate=duedate,
1667
+ created=now,
1668
+ process_instance_id=pi.id,
1669
+ execution_id=token.id,
1670
+ node_id=boundary.id, # 边界事件 id;宿主经 attached_to 反查
1671
+ )
1672
+ self._jobs[job.id] = job
1673
+ continue
1674
+ if boundary.message_name is not None or boundary.signal_name is not None:
1675
+ # M4-2d:message/signal 边界 -> 常驻订阅(触发由关联/广播入口驱动)
1676
+ s = EventSubscription(
1677
+ id=self._idgen.next_id(),
1678
+ kind="message" if boundary.message_name is not None else "signal",
1679
+ event_name=boundary.message_name or boundary.signal_name,
1680
+ process_instance_id=pi.id,
1681
+ execution_id=token.id,
1682
+ activity_id=None,
1683
+ node_id=boundary.id,
1684
+ node_kind="boundary",
1685
+ is_interrupting=boundary.cancel_activity,
1686
+ created=now,
1687
+ )
1688
+ self._event_subs[s.id] = s
1689
+ continue
1690
+ raise InvalidRequestException(
1691
+ f"boundaryEvent {boundary.id!r} 未实现(支持 timer/message/signal 事件定义)"
1692
+ )
1693
+
1694
+ def _drop_boundary_jobs(self, pi: ProcessInstance, host: FlowNode) -> None:
1695
+ """宿主活动正常离开/被取消:删除其全部边界 timer 作业与消息/信号订阅。
1696
+
1697
+ M4-2a:宿主对象直接传入(调用方都持有),无需按 id 反查容器。
1698
+ M4-2d:边界 message/signal 订阅同窗同步撤销(宿主不再等待即失效)。
1699
+ """
1700
+ bound = set(host.boundary_events)
1701
+ if not bound:
1702
+ return
1703
+ stale = [
1704
+ jid
1705
+ for jid, j in self._jobs.items()
1706
+ if j.process_instance_id == pi.id
1707
+ and j.job_type == "timer-boundary"
1708
+ and j.node_id in bound
1709
+ ]
1710
+ for jid in stale:
1711
+ self._jobs.pop(jid, None)
1712
+ stale_subs = [
1713
+ sid
1714
+ for sid, s in self._event_subs.items()
1715
+ if s.process_instance_id == pi.id
1716
+ and s.node_kind == "boundary"
1717
+ and s.node_id in bound
1718
+ ]
1719
+ for sid in stale_subs:
1720
+ self._event_subs.pop(sid, None)
1721
+
1722
+ def _cancel_host_activity(
1723
+ self, pi: ProcessInstance, token: Execution, host: FlowNode
1724
+ ) -> None:
1725
+ """中断式边界触发:取消宿主活动。
1726
+
1727
+ 宿主为普通活动(userTask / asyncBefore 节点):删待办任务(归档留历史)
1728
+ -> 结算 actinst -> 撤销未执行 async 行为 -> 删边界 timer 作业。
1729
+ 宿主为 subProcess(M4-2a3):整段 scope 取消 = 先结束内部全部活跃子树
1730
+ (_kill_subprocess_scope),再结算 subProcess actinst 并清理其作业。
1731
+ """
1732
+ now = _now()
1733
+ if isinstance(host, SubProcess):
1734
+ self._kill_subprocess_scope(pi, token)
1735
+ else:
1736
+ # 宿主 userTask 的待办任务归档(end_time 结算 -> HI_TASKINST 留痕)
1737
+ for tid in [
1738
+ t.id
1739
+ for t in self._tasks.values()
1740
+ if t.process_instance_id == pi.id
1741
+ and t.execution_id == token.id
1742
+ and t.task_definition_key == host.id
1743
+ ]:
1744
+ task = self._tasks.pop(tid)
1745
+ task.end_time = now
1746
+ pi.completed_tasks.append(task)
1747
+ # 2) 结算宿主活动实例(中断 = 宿主活动结束;subProcess actinst 亦在此结算)
1748
+ self._close_activity(pi, token, host)
1749
+ # 3) 撤销宿主未执行的 asyncBefore 行为(拆分后行为还没跑,作废)
1750
+ for jid in [
1751
+ j.id
1752
+ for j in self._jobs.values()
1753
+ if j.process_instance_id == pi.id
1754
+ and j.job_type == "async-continuation"
1755
+ and j.execution_id == token.id
1756
+ and j.node_id == host.id
1757
+ ]:
1758
+ del self._jobs[jid]
1759
+ # 4) 宿主全部边界 timer 作业/消息信号订阅失效(含当前触发者自身)
1760
+ self._drop_boundary_jobs(pi, host)
1761
+ # 5) M4-2b3/M4-2d:宿主 subProcess 被边界中断 -> 其容器内 timer 事件子流程
1762
+ # 订阅及 message/signal 订阅一并失效。普通活动宿主没有容器订阅;
1763
+ # root 兼任 sub 宿主时只撤该 sub 容器的订阅,root 上流程级订阅保留
1764
+ if isinstance(host, SubProcess):
1765
+ self._drop_scope_event_jobs(pi, token, token.activity_id)
1766
+ self._drop_scope_event_subscriptions(pi, token, token.activity_id)
1767
+
1768
+ def _kill_subprocess_scope(self, pi: ProcessInstance, scope: Execution) -> None:
1769
+ """中断式 scope 取消:结束 subProcess 内部全部活跃子树(本体除外)。
1770
+
1771
+ scope 本体(subProcess SCOPE)保持 ACTIVE 由调用方结算 actinst 后沿边界
1772
+ 出边走。逐棵子树复用 _kill_execution_tree(actinst/task/job/join 全清理)。
1773
+ """
1774
+ for c in list(scope.children):
1775
+ self._kill_execution_tree(pi, c)
1776
+
1777
+ def _kill_execution_tree(self, pi: ProcessInstance, e: Execution) -> None:
1778
+ """杀灭以 e 为根的整棵执行子树(含 e 本体),全量清理。
1779
+
1780
+ 自底向上 kill 每个内部 execution:结算 open actinst、归档待办任务、
1781
+ 删除所属实例级作业、从 join_arrivals 摘除登记、detach 摘树。e 本体
1782
+ 同样结算/ENDED/detach。供中断取消(MI 条件终止实例 / 事件中断宿主)。
1783
+ """
1784
+ now = _now()
1785
+
1786
+ def kill(node: Execution) -> None:
1787
+ for c in list(node.children):
1788
+ kill(c)
1789
+ # 结算未结算的活动实例
1790
+ if node.open_activity is not None and node.open_activity.end_time is None:
1791
+ node.open_activity.end_time = now
1792
+ node.open_activity = None
1793
+ # 该 execution 的待办任务归档(中断取消 = HI_TASKINST 留痕)
1794
+ for t in [
1795
+ t
1796
+ for t in self._tasks.values()
1797
+ if t.process_instance_id == pi.id and t.execution_id == node.id
1798
+ ]:
1799
+ self._tasks.pop(t.id)
1800
+ t.end_time = now
1801
+ pi.completed_tasks.append(t)
1802
+ # 该 execution 的实例级作业全部作废
1803
+ for j in [
1804
+ j
1805
+ for j in self._jobs.values()
1806
+ if j.process_instance_id == pi.id and j.execution_id == node.id
1807
+ ]:
1808
+ self._jobs.pop(j.id)
1809
+ # M4-2d:该 execution 承载的消息/信号订阅一并作废
1810
+ # (catch 停等 token / esc 宿主 scope / 边界宿主)
1811
+ for sid in [
1812
+ sid
1813
+ for sid, s in self._event_subs.items()
1814
+ if s.process_instance_id == pi.id and s.execution_id == node.id
1815
+ ]:
1816
+ self._event_subs.pop(sid, None)
1817
+ # 从并行 join 等待登记摘除(内部登记随子树作废)
1818
+ for gw, ids in list(pi.join_arrivals.items()):
1819
+ if node.id in ids:
1820
+ rest = [i for i in ids if i != node.id]
1821
+ if rest:
1822
+ pi.join_arrivals[gw] = rest
1823
+ else:
1824
+ pi.join_arrivals.pop(gw, None)
1825
+ node.state = ExecutionState.ENDED
1826
+ self._detach_from_parent(pi, node)
1827
+
1828
+ kill(e)
1829
+
1830
+ def _fire_timer_boundary(self, job: Job) -> None:
1831
+ """timer 边界到期:中断式取消宿主 / 非中断式 spawn 并发线(M4-2b4)。
1832
+
1833
+ token 失效防御与 timer-catch 同:宿主已离开/活动已结算 = 过期作业直接
1834
+ 丢弃(宿主正常离开时边界 job 本应被撤销,此处兜底并发轮询竞态)。
1835
+ """
1836
+ pi = self._instances.get(job.process_instance_id)
1837
+ token = pi.executions.get(job.execution_id) if pi is not None else None
1838
+ if (
1839
+ pi is None
1840
+ or pi.is_completed
1841
+ or token is None
1842
+ or token.state != ExecutionState.ACTIVE
1843
+ ):
1844
+ self._jobs.pop(job.id, None) # 实例/执行已失效 -> 过期作业丢弃
1845
+ return
1846
+ proc = self._container_of(pi, token)
1847
+ boundary = proc.flow_nodes.get(job.node_id)
1848
+ host = (
1849
+ proc.flow_nodes.get(boundary.attached_to)
1850
+ if isinstance(boundary, BoundaryEvent) and boundary.attached_to
1851
+ else None
1852
+ )
1853
+ if host is None:
1854
+ self._jobs.pop(job.id, None) # 防御:异常数据(正常解析后不会发生)
1855
+ return
1856
+ # 关键校验:宿主活动仍在等待(actinst 未结算)才可触发
1857
+ if (
1858
+ token.activity_id != host.id
1859
+ or token.open_activity is None
1860
+ or token.open_activity.end_time is not None
1861
+ ):
1862
+ self._jobs.pop(job.id, None) # 宿主已离开 -> 过期作业丢弃
1863
+ return
1864
+ if boundary.cancel_activity:
1865
+ # 中断式:取消宿主活动,token 改走边界事件出边
1866
+ self._cancel_host_activity(pi, token, host)
1867
+ # 边界事件作为中断路径载体:留 actinst 痕迹后沿其出边推进
1868
+ self._open_activity(pi, token, boundary)
1869
+ self._close_activity(pi, token, boundary)
1870
+ arrivals: List[_Arrival] = []
1871
+ self._leave(pi, token, boundary, arrivals)
1872
+ self._pump(pi, arrivals)
1873
+ else:
1874
+ # 非中断式(M4-2b4):宿主不取消,spawn 并发线从边界事件出边走
1875
+ self._spawn_non_interrupting_boundary(pi, token, boundary)
1876
+
1877
+ def _spawn_non_interrupting_boundary(
1878
+ self, pi: ProcessInstance, token: Execution, boundary: BoundaryEvent
1879
+ ) -> None:
1880
+ """非中断式边界触发:宿主保留,并发线(与宿主平级)走边界事件出边。
1881
+
1882
+ 并发线是独立执行,不能挂在宿主 token 之下(否则宿主 complete/收束时被
1883
+ 携带或误判)。挂载点 = 宿主直接父 scope;root 直通宿主无父 -> 挂 root
1884
+ (root 兼任实例 scope 与并发线父载体,主线到 end 后转 SCOPE 停驻等收束,
1885
+ 由 _collapse_scopes 收尾——见 _end_token / M4-2b 收尾段)。
1886
+
1887
+ 触发即消费本 timer job(单发,无 repeat -> _reschedule_or_remove 删除),
1888
+ 宿主其余边界作业保留——宿主仍在等待,其它 timer 边界继续有效。
1889
+ """
1890
+ parent = pi.executions.get(token.parent_id) if token.parent_id else None
1891
+ anchor = parent if parent is not None else token
1892
+ line = Execution(
1893
+ id=self._idgen.next_id(),
1894
+ process_instance_id=pi.id,
1895
+ parent_id=anchor.id,
1896
+ )
1897
+ pi.executions[line.id] = line
1898
+ anchor.children.append(line)
1899
+ # 边界事件在并发线上留 actinst 痕迹后沿其出边推进(无出边即收束)
1900
+ self._open_activity(pi, line, boundary)
1901
+ self._close_activity(pi, line, boundary)
1902
+ arrivals: List[_Arrival] = []
1903
+ self._leave(pi, line, boundary, arrivals)
1904
+ self._pump(pi, arrivals)
1905
+
1906
+ # ------------------------------------------------------------------
1907
+ # M4-2c:多实例(multiInstanceLoopCharacteristics)
1908
+ # ------------------------------------------------------------------
1909
+ def _enter_user_task_wait(
1910
+ self, pi: ProcessInstance, token: Execution, node: UserTask
1911
+ ) -> List[_Arrival]:
1912
+ """userTask 宿主进入等待:open actinst + 创建任务 + 注册边界 timer。
1913
+
1914
+ MI 实例启动(_start_mi_instance)复用本方法承载宿主行为。
1915
+ """
1916
+ self._open_activity(pi, token, node)
1917
+ self._create_task(pi, token, node)
1918
+ # 宿主进入等待:注册边界 timer(asyncBefore 的 userTask 拆分时已注册,
1919
+ # 行为续跑到达此处不重复注册——幂等由 _register_boundary_jobs 保证)
1920
+ if not node.async_before:
1921
+ self._register_boundary_jobs(pi, token, node)
1922
+ return [] # 停等 complete
1923
+
1924
+ def _enter_multi_instance(
1925
+ self, pi: ProcessInstance, token: Execution, node: FlowNode
1926
+ ) -> List[_Arrival]:
1927
+ """token 到达多实例宿主活动:初始化 MI 容器并启动实例(M4-2c)。
1928
+
1929
+ 纯 MI 范围(文档化差异):宿主不允许再组合 asyncBefore/asyncAfter/
1930
+ 边界事件(组合语义后置里程碑)。实例集求值为空 -> 零实例,立即沿宿主
1931
+ 出边离开(对齐 Camunda 空集合行为)。
1932
+ - sequential:token 自身作容器与实例载体,_start_mi_instance 顺序启动;
1933
+ - parallel:token 转 SCOPE 作容器,spawn 每条实例 child execution。
1934
+ """
1935
+ mi_def = node.multi_instance
1936
+ if node.async_before or node.async_after or node.boundary_events:
1937
+ raise InvalidRequestException(
1938
+ f"多实例宿主 {node.id!r} 暂不支持 asyncBefore/asyncAfter/边界事件"
1939
+ "组合(M4-2c 纯多实例语义,组合后续里程碑落地,文档化差异)"
1940
+ )
1941
+ items, total = self._resolve_mi_set(pi, mi_def)
1942
+ if total == 0:
1943
+ # 空集合:零次实例,直接沿宿主出边离开
1944
+ arrivals: List[_Arrival] = []
1945
+ self._leave(pi, token, node, arrivals)
1946
+ self._pump(pi, arrivals)
1947
+ return []
1948
+ container = {
1949
+ "total": total,
1950
+ "active": 0,
1951
+ "completed": 0,
1952
+ "next_index": 0,
1953
+ "items": items, # None = loopCardinality 来源(无元素变量)
1954
+ "element_variable": mi_def.element_variable,
1955
+ "completion_condition": mi_def.completion_condition_expr,
1956
+ "sequential": mi_def.sequential,
1957
+ }
1958
+ token.mi = container
1959
+ if mi_def.sequential:
1960
+ # 顺序:token 自身执行第 0 个实例(userTask 建任务停等 / subProcess
1961
+ # 进内部流转 / serviceTask 同步跑完全部)
1962
+ container["active"] = 1
1963
+ self._pump(pi, self._start_mi_instance(pi, token, node))
1964
+ return []
1965
+ # 并行:token 转 SCOPE 容器,spawn N 条实例 child
1966
+ token.role = "SCOPE"
1967
+ container["active"] = total
1968
+ arrivals: List[_Arrival] = []
1969
+ for _ in range(total):
1970
+ child = Execution(
1971
+ id=self._idgen.next_id(),
1972
+ process_instance_id=pi.id,
1973
+ parent_id=token.id,
1974
+ )
1975
+ pi.executions[child.id] = child
1976
+ token.children.append(child)
1977
+ arrivals.extend(self._start_mi_instance(pi, child, node))
1978
+ if token.mi is None:
1979
+ # 同步宿主(serviceTask)实例在 spawn 期间即时完成并收束容器
1980
+ # (全完成或 completionCondition 满足)-> 剩余实例不再启动
1981
+ break
1982
+ self._pump(pi, arrivals)
1983
+ return []
1984
+
1985
+ def _resolve_mi_set(
1986
+ self, pi: ProcessInstance, mi_def: MultiInstance
1987
+ ) -> Tuple[Optional[List[Any]], int]:
1988
+ """实例集求值:collection 表达式 -> 元素列表;loopCardinality -> 数量。
1989
+
1990
+ 返回 (items, total):items=None 表示 cardinality 来源(无元素变量)。
1991
+ 求值结果非法(非集合 / 非正整数)抛 ProcessInstanceException。
1992
+ """
1993
+ if mi_def.collection_expr is not None:
1994
+ val = evaluate_expression(mi_def.collection_expr, pi.variables)
1995
+ if not isinstance(val, (list, tuple, set, frozenset)):
1996
+ raise ProcessInstanceException(
1997
+ f"多实例 collection {mi_def.collection_expr!r} 求值须得集合"
1998
+ f"(list/tuple/set),实际: {type(val).__name__}"
1999
+ )
2000
+ items = list(val)
2001
+ return items, len(items)
2002
+ if mi_def.loop_cardinality_expr is not None:
2003
+ n = evaluate_expression(mi_def.loop_cardinality_expr, pi.variables)
2004
+ if isinstance(n, bool) or not isinstance(n, int) or n < 0:
2005
+ raise ProcessInstanceException(
2006
+ f"多实例 loopCardinality {mi_def.loop_cardinality_expr!r} "
2007
+ f"求值须得非负整数,实际: {n!r}"
2008
+ )
2009
+ return None, int(n)
2010
+ return None, 0 # 解析期保证两者至少其一;双 None 防御 = 空
2011
+
2012
+ def _start_mi_instance(
2013
+ self, pi: ProcessInstance, execution: Execution, node: FlowNode
2014
+ ) -> List[_Arrival]:
2015
+ """启动下一个实例的宿主行为(容器 next_index 自增),返回后续到达事件。
2016
+
2017
+ 行为前注入 loopCounter / elementVariable 到实例变量表(行为期可读)。
2018
+ 元素变量与 loopCounter 生命周期 = MI 活动执行期(M4-2c 文档化差异:
2019
+ 不落 ACT_HI_VARINST,容器收尾统一清理)。并行 child 在此登记实例序号
2020
+ execution.mi={"index": i},收束回报计数用。
2021
+
2022
+ M4-2c3 宿主分派:
2023
+ - userTask:进入等待(建任务停等,complete_task 驱动实例完成);
2024
+ - serviceTask:同步 delegate 无等待窗口 -> 行为完成即结算实例(顺序容器
2025
+ 就地循环跑完剩余实例;并行 child 立即完成回报);
2026
+ - subProcess:进入内部流转(等待窗口 = 整段内部执行,collapse 收束链
2027
+ 驱动实例完成)。
2028
+ """
2029
+ arrivals: List[_Arrival] = []
2030
+ container = (
2031
+ execution.mi
2032
+ if execution.is_mi_container
2033
+ else pi.executions.get(execution.parent_id).mi
2034
+ )
2035
+ # 实例执行同样携带宿主活动位置(对齐 _handle_arrival 的 activity_id
2036
+ # 约定:停等/行为期 activity_id 指向当前活动节点)。顺序容器 token 已由
2037
+ # _handle_arrival 设置(幂等),并行 spawn 的 child 在此补齐。
2038
+ if not execution.is_mi_container:
2039
+ execution.activity_id = node.id
2040
+ index = container["next_index"]
2041
+ container["next_index"] += 1
2042
+ if not execution.is_mi_container:
2043
+ execution.mi = {"index": index} # parallel 实例标识
2044
+ self._inject_mi_vars(pi, container, index)
2045
+ if isinstance(node, UserTask):
2046
+ self._enter_user_task_wait(pi, execution, node)
2047
+ elif isinstance(node, ServiceTask):
2048
+ # 同步宿主:无等待窗口,行为执行完即结算实例
2049
+ if execution.is_mi_container:
2050
+ # 顺序容器:token 兼实例载体,就地循环跑完剩余实例
2051
+ arrivals = self._run_sequential_service_mi(pi, execution, node)
2052
+ else:
2053
+ self._run_sync_mi_host(pi, execution, node)
2054
+ arrivals = self._complete_mi_instance(
2055
+ pi, execution, node, pi.executions[execution.parent_id]
2056
+ )
2057
+ elif isinstance(node, SubProcess):
2058
+ # subProcess 宿主:进入内部流转(本实例的等待窗口),内部走完由
2059
+ # collapse 收束链驱动实例完成
2060
+ arrivals = self._enter_subprocess(pi, execution, node)
2061
+ else:
2062
+ raise ProcessInstanceException(
2063
+ f"多实例宿主 {node.id!r} 类型 {type(node).__name__} 不支持"
2064
+ "(M4-2c 纯 MI 范围:userTask / serviceTask / subProcess)"
2065
+ )
2066
+ return arrivals
2067
+
2068
+ def _run_sync_mi_host(
2069
+ self, pi: ProcessInstance, execution: Execution, node: ServiceTask
2070
+ ) -> None:
2071
+ """同步 MI 实例行为:open actinst -> delegate -> close(无等待窗口)。"""
2072
+ self._open_activity(pi, execution, node)
2073
+ self._run_delegate(pi, execution, node)
2074
+ self._close_activity(pi, execution, node)
2075
+
2076
+ def _run_sequential_service_mi(
2077
+ self, pi: ProcessInstance, scope: Execution, node: ServiceTask
2078
+ ) -> List[_Arrival]:
2079
+ """顺序 serviceTask 宿主:当前实例已就绪,同步就地循环跑完剩余实例。
2080
+
2081
+ 同步无等待 -> 一条调用链内依次执行各实例:跑完 delegate 即结算计数/条件;
2082
+ 全部完成或 completionCondition 满足 -> 容器收束沿宿主出边离开(返回事件)。
2083
+ """
2084
+ mi = scope.mi
2085
+ while True:
2086
+ self._run_sync_mi_host(pi, scope, node)
2087
+ mi["completed"] += 1
2088
+ mi["active"] = 0
2089
+ self._cleanup_mi_vars(pi, mi)
2090
+ if self._mi_done(pi, mi):
2091
+ return self._finish_mi_container(pi, scope, node)
2092
+ index = mi["next_index"]
2093
+ mi["next_index"] += 1
2094
+ mi["active"] = 1
2095
+ self._inject_mi_vars(pi, mi, index)
2096
+
2097
+ @staticmethod
2098
+ def _inject_mi_vars(
2099
+ pi: ProcessInstance, container: Dict[str, Any], index: int
2100
+ ) -> None:
2101
+ """把 loopCounter / elementVariable 注入实例变量表(行为期临时承载)。"""
2102
+ pi.variables["loopCounter"] = index
2103
+ ev = container["element_variable"]
2104
+ if ev:
2105
+ items = container["items"] or []
2106
+ pi.variables[ev] = items[index] if index < len(items) else None
2107
+
2108
+ @staticmethod
2109
+ def _cleanup_mi_vars(pi: ProcessInstance, container: Dict[str, Any]) -> None:
2110
+ pi.variables.pop("loopCounter", None)
2111
+ ev = container["element_variable"]
2112
+ if ev:
2113
+ pi.variables.pop(ev, None)
2114
+
2115
+ @staticmethod
2116
+ def _mi_condition_vars(
2117
+ pi: ProcessInstance, mi: Dict[str, Any]
2118
+ ) -> Dict[str, Any]:
2119
+ """completionCondition 求值环境:实例变量 + MI 内置计数器。"""
2120
+ env = dict(pi.variables)
2121
+ env["nrOfInstances"] = mi["total"]
2122
+ env["nrOfActiveInstances"] = mi["active"]
2123
+ env["nrOfCompletedInstances"] = mi["completed"]
2124
+ return env
2125
+
2126
+ def _mi_done(self, pi: ProcessInstance, mi: Dict[str, Any]) -> bool:
2127
+ """MI 活动是否应结束:全部实例完成,或 completionCondition 满足。"""
2128
+ if mi["completed"] >= mi["total"]:
2129
+ return True
2130
+ cond = mi["completion_condition"]
2131
+ if cond:
2132
+ return bool(evaluate_condition(cond, self._mi_condition_vars(pi, mi)))
2133
+ return False
2134
+
2135
+ def _mi_scope_of(
2136
+ self, pi: ProcessInstance, token: Execution
2137
+ ) -> Optional[Execution]:
2138
+ """token 完成宿主行为后,其所属 MI 容器(自身顺序容器 / 父并行容器)。"""
2139
+ if token.is_mi_container and token.mi["sequential"]:
2140
+ return token
2141
+ parent = pi.executions.get(token.parent_id) if token.parent_id else None
2142
+ if (
2143
+ parent is not None
2144
+ and parent.is_mi_container
2145
+ and not parent.mi["sequential"]
2146
+ ):
2147
+ return parent
2148
+ return None
2149
+
2150
+ def _complete_mi_instance(
2151
+ self, pi: ProcessInstance, token: Execution, node: FlowNode, scope: Execution
2152
+ ) -> List[_Arrival]:
2153
+ """MI 实例完成:计数/条件/收束/续跑,返回后续到达事件(调用方 pump)。
2154
+
2155
+ 驱动来源(M4-2c2/2c3):userTask 宿主由 complete_task;serviceTask 宿主
2156
+ delegate 同步完成(_start_mi_instance);subProcess 宿主内部流转收束
2157
+ (_collapse_scopes 复活链)。续跑/收束产生的推进事件在此回传,由调用点
2158
+ (complete_task / collapse / spawn 循环)统一 pump,避免深层嵌套 pump。
2159
+ """
2160
+ mi = scope.mi
2161
+ mi["completed"] += 1
2162
+ if mi["sequential"]:
2163
+ # 顺序:token 即容器。当前实例已完成 -> 决定续跑或收束离开
2164
+ mi["active"] = 0
2165
+ self._cleanup_mi_vars(pi, mi)
2166
+ if self._mi_done(pi, mi):
2167
+ return self._finish_mi_container(pi, scope, node)
2168
+ mi["active"] = 1
2169
+ return self._start_mi_instance(pi, scope, node) # 下一个实例
2170
+ # 并行:child 完成收束 -> 检查条件(满足则终止剩余活跃实例)
2171
+ mi["active"] = max(0, mi["active"] - 1)
2172
+ token.state = ExecutionState.ENDED
2173
+ self._detach_from_parent(pi, token)
2174
+ if mi["completed"] < mi["total"] and self._mi_done(pi, mi):
2175
+ self._kill_mi_active_children(pi, scope)
2176
+ if self._mi_done(pi, mi):
2177
+ return self._finish_mi_container(pi, scope, node)
2178
+ return []
2179
+
2180
+ def _kill_mi_active_children(self, pi: ProcessInstance, scope: Execution) -> None:
2181
+ """completionCondition 提前满足:终止 scope 下仍活跃的并行实例。
2182
+
2183
+ 对齐 Camunda:多实例活动在条件满足时结束,剩余未完成实例被取消——
2184
+ 待办任务归档(HI_TASKINST 带 end_time)、结算 actinst、清理实例级作业。
2185
+ 实例载体可能是叶子(userTask/serviceTask host child)也可能是子树
2186
+ (subProcess host 实例 scope),统一按整树杀灭处理。
2187
+ """
2188
+ mi = scope.mi
2189
+ for child in list(scope.children):
2190
+ if child.state != ExecutionState.ACTIVE:
2191
+ continue
2192
+ # 被终止实例 = 取消而非完成:仅回落 active,不动 completed
2193
+ mi["active"] = max(0, mi["active"] - 1)
2194
+ self._kill_execution_tree(pi, child)
2195
+ scope.children = [
2196
+ c for c in scope.children if c.state == ExecutionState.ACTIVE
2197
+ ]
2198
+
2199
+ def _finish_mi_container(
2200
+ self, pi: ProcessInstance, scope: Execution, node: FlowNode
2201
+ ) -> List[_Arrival]:
2202
+ """MI 活动收束:清容器状态与注入变量,恢复 TOKEN 沿宿主出边离开。
2203
+
2204
+ 离开推进事件返回调用方统一 pump(调用点可能在 pump 内也可能在
2205
+ complete_task 等外部入口——事件回传而非嵌套 pump,避免调用链加深)。
2206
+ """
2207
+ self._cleanup_mi_vars(pi, scope.mi)
2208
+ scope.mi = None
2209
+ scope.role = "TOKEN"
2210
+ arrivals: List[_Arrival] = []
2211
+ self._leave(pi, scope, node, arrivals)
2212
+ return arrivals
2213
+
2214
+ # ------------------------------------------------------------------
2215
+ # 并行网关 fork / join
2216
+ # ------------------------------------------------------------------
2217
+ def _handle_parallel_gateway(
2218
+ self, pi: ProcessInstance, token: Execution, gw: ParallelGateway
2219
+ ) -> List[_Arrival]:
2220
+ proc = self._container_of(pi, token)
2221
+ arrivals: List[_Arrival] = []
2222
+ incoming = self._incoming(proc, gw)
2223
+
2224
+ # join 分支:到达数 == 入边数 才汇聚,否则停等
2225
+ if len(incoming) > 1:
2226
+ pi.register_join_arrival(gw.id, token.id)
2227
+ if len(pi.join_arrived(gw.id)) < len(incoming):
2228
+ return arrivals # 等待其余分支
2229
+ # 汇聚完成:记录网关活动实例,SCOPE 恢复继续
2230
+ self._open_activity(pi, token, gw)
2231
+ self._close_activity(pi, token, gw)
2232
+ self._end_waiting_tokens(pi, gw)
2233
+ scope = self._find_scope(pi, token)
2234
+ actor = scope if scope is not None else token
2235
+ # M4-2a:汇聚后 SCOPE 恢复为主线角色(否则停在普通等待节点时可能被
2236
+ # 收束扫描误杀);并行 fork 多出边会再次置回 SCOPE
2237
+ actor.role = "TOKEN"
2238
+ self._leave(pi, actor, gw, arrivals)
2239
+ return arrivals
2240
+
2241
+ # fork 分支:分裂出边
2242
+ self._open_activity(pi, token, gw)
2243
+ self._close_activity(pi, token, gw)
2244
+ self._leave(pi, token, gw, arrivals)
2245
+ return arrivals
2246
+
2247
+ def _leave(
2248
+ self,
2249
+ pi: ProcessInstance,
2250
+ token: Execution,
2251
+ node: FlowNode,
2252
+ arrival_list: List[_Arrival],
2253
+ ) -> None:
2254
+ """离开节点:1 条出边直通;多条出边 fork(token 转 SCOPE,spawn 子)。"""
2255
+ proc = self._container_of(pi, token)
2256
+ flows = self._outgoing(proc, node)
2257
+ if not flows:
2258
+ # 无出边(流程末端):结束 token;可能连带触发子流程 scope 收束复活
2259
+ arrival_list.extend(self._end_token(pi, token))
2260
+ return
2261
+ if len(flows) == 1:
2262
+ self._take(pi, token, flows[0], arrival_list)
2263
+ return
2264
+ # fork
2265
+ token.role = "SCOPE"
2266
+ for flow in flows:
2267
+ child = Execution(
2268
+ id=self._idgen.next_id(),
2269
+ process_instance_id=pi.id,
2270
+ parent_id=token.id,
2271
+ )
2272
+ pi.executions[child.id] = child
2273
+ token.children.append(child)
2274
+ target = proc.flow_nodes[flow.target_ref]
2275
+ arrival_list.append((child, target))
2276
+
2277
+ def _end_waiting_tokens(self, pi: ProcessInstance, gw: ParallelGateway) -> None:
2278
+ """join 汇聚后:结束停等 token 并从父树摘除。"""
2279
+ for wid in pi.join_arrived(gw.id):
2280
+ waiting = pi.executions.get(wid)
2281
+ if waiting is None:
2282
+ continue
2283
+ waiting.state = ExecutionState.ENDED
2284
+ self._detach_from_parent(pi, waiting)
2285
+ pi.clear_join_arrivals(gw.id)
2286
+
2287
+ def _find_scope(self, pi: ProcessInstance, token: Execution) -> Optional[Execution]:
2288
+ """向上找最近的 SCOPE 父(join 汇聚后由其承担恢复推进)。"""
2289
+ cur = token
2290
+ while cur.parent_id is not None:
2291
+ parent = pi.executions.get(cur.parent_id)
2292
+ if parent is None:
2293
+ break
2294
+ if parent.role == "SCOPE":
2295
+ return parent
2296
+ cur = parent
2297
+ return None
2298
+
2299
+ def _take(
2300
+ self,
2301
+ pi: ProcessInstance,
2302
+ token: Execution,
2303
+ flow: SequenceFlow,
2304
+ arrival_list: List[_Arrival],
2305
+ ) -> None:
2306
+ proc = self._container_of(pi, token)
2307
+ target = proc.flow_nodes[flow.target_ref]
2308
+ arrival_list.append((token, target))
2309
+
2310
+ # ------------------------------------------------------------------
2311
+ # token 收束 / 活动历史
2312
+ # ------------------------------------------------------------------
2313
+ def _end_token(self, pi: ProcessInstance, token: Execution) -> List[_Arrival]:
2314
+ """token 到达 endEvent / 无出边:结束自己并尝试收束(返回复活推进事件)。
2315
+
2316
+ 根结束 = 主线走完;若 root 还有活跃子执行(非中断边界并发线 / 非中断
2317
+ 事件子流程,M4-2b4),root 转 SCOPE 停驻等子树全收束后才完成实例
2318
+ (_collapse_scopes 收尾段兜底)。子 token 结束 -> 自底向上收束 SCOPE
2319
+ (并行分支各自走完 / 子流程内部走完),期间可能复活 subProcess SCOPE
2320
+ 沿其出边推进。
2321
+ """
2322
+ if token.is_root or pi.root_execution is token:
2323
+ alive = [
2324
+ c for c in token.children if c.state == ExecutionState.ACTIVE
2325
+ ]
2326
+ if alive:
2327
+ # 主线已到 end 但并发子树未收束:root 脱离活动节点停驻(activity
2328
+ # 清空 -> collapse 不会把它当 sub/网关收,子树收束后收尾段完成)
2329
+ token.role = "SCOPE"
2330
+ token.activity_id = None
2331
+ return [] # 等子树收束;_collapse_scopes 收尾段完成实例
2332
+ token.state = ExecutionState.ENDED
2333
+ self._detach_from_parent(pi, token)
2334
+ self._complete_instance(pi)
2335
+ return []
2336
+ token.state = ExecutionState.ENDED
2337
+ self._detach_from_parent(pi, token)
2338
+ return self._collapse_scopes(pi)
2339
+
2340
+ def _detach_from_parent(self, pi: ProcessInstance, token: Execution) -> None:
2341
+ parent = pi.executions.get(token.parent_id) if token.parent_id else None
2342
+ if parent is not None and token in parent.children:
2343
+ parent.children.remove(token)
2344
+
2345
+ def _collapse_scopes(self, pi: ProcessInstance) -> List[_Arrival]:
2346
+ """自底向上收束已无活跃子的 SCOPE(M4-2a 泛化:任意层,返回复活推进事件)。
2347
+
2348
+ 叶子判定:SCOPE 且 children 已空(直接子全 ENDED 被 detach):
2349
+ - 停在 ParallelGateway(fork 停驻,分支直通 end、无 join 汇聚)-> 结束
2350
+ 自身(root 结束 = 实例完成),逐层向上继续收;
2351
+ - 停在 SubProcess(内部全部走完)-> 复活:结算 subProcess actinst、
2352
+ 恢复 TOKEN 沿 sub 出边推进,产生新到达事件交由调用方 pump;
2353
+ - 停在普通节点(如 join 汇聚后主线停在 userTask)-> 主线身份,不收
2354
+ (join 汇聚恢复时已把 role 复位 TOKEN,这里双重防御)。
2355
+ """
2356
+ root = pi.root_execution
2357
+ if root is None or root.state == ExecutionState.ENDED:
2358
+ return []
2359
+ arrivals: List[_Arrival] = []
2360
+ changed = True
2361
+ while changed:
2362
+ changed = False
2363
+ for e in list(pi.executions.values()):
2364
+ if e.state != ExecutionState.ACTIVE or e.role != "SCOPE":
2365
+ continue
2366
+ if e.children:
2367
+ continue # 仍有活跃子树(含子流程内部执行中)
2368
+ node = (
2369
+ self._container_of(pi, e).flow_nodes.get(e.activity_id)
2370
+ if e.activity_id
2371
+ else None
2372
+ )
2373
+ if isinstance(node, SubProcess):
2374
+ # 子流程内部全部收束:结算 actinst / 边界 timer / 容器订阅
2375
+ self._close_activity(pi, e, node)
2376
+ self._drop_boundary_jobs(pi, node) # 正常离开:sub 边界 timer 作废
2377
+ # M4-2b3/M4-2d:该 sub 容器 esc 订阅一并失效(timer job +
2378
+ # message/signal 订阅;root 兼任时流程级订阅保留)
2379
+ self._drop_scope_event_jobs(pi, e, node.id)
2380
+ self._drop_scope_event_subscriptions(pi, e, node.id)
2381
+ if node.multi_instance is not None:
2382
+ # M4-2c3:subProcess 宿主多实例——e 是顺序容器自身或某并行
2383
+ # 实例载体,本实例内部流转已走完 -> 走实例完成路径(计数/
2384
+ # 续跑下一实例/条件/收束容器),推进事件并入 arrivals
2385
+ mi_scope = self._mi_scope_of(pi, e)
2386
+ if mi_scope is not None:
2387
+ arrivals.extend(
2388
+ self._complete_mi_instance(pi, e, node, mi_scope)
2389
+ )
2390
+ changed = True
2391
+ continue
2392
+ e.role = "TOKEN" # 恢复主线身份(后续 fork 会再置回 SCOPE)
2393
+ self._leave(pi, e, node, arrivals)
2394
+ changed = True
2395
+ elif isinstance(node, ParallelGateway):
2396
+ # fork 停驻且分支全结束(无 join 汇聚路径)-> 结束自身向上收
2397
+ e.state = ExecutionState.ENDED
2398
+ self._detach_from_parent(pi, e)
2399
+ if e.is_root or pi.root_execution is e:
2400
+ self._complete_instance(pi)
2401
+ changed = True
2402
+ # M4-2b:root 被流程级事件子流程接管后(中断清空 activity/子树),事件
2403
+ # 子流程 scope 收束完毕 -> 宿主 scope(流程实例)结束 = 实例完成。
2404
+ # root 先置 ENDED:收尾段路径下主线早已到 end(root 转停驻 SCOPE),
2405
+ # 收束完成即实例结束,避免 ACTIVE root 残行被 _build_snap 写回 RU。
2406
+ root = pi.root_execution
2407
+ if (
2408
+ root is not None
2409
+ and root.state == ExecutionState.ACTIVE
2410
+ and not root.children
2411
+ and root.activity_id is None
2412
+ and root.open_activity is None
2413
+ ):
2414
+ root.state = ExecutionState.ENDED
2415
+ self._complete_instance(pi)
2416
+ return arrivals
2417
+
2418
+ def _complete_instance(self, pi: ProcessInstance) -> None:
2419
+ pi.state = ProcessInstanceState.COMPLETED
2420
+ pi.end_time = _now()
2421
+ # M4-2b3:实例完成 -> 该实例全部实例级作业作废(订阅/边界/catch 无残留)
2422
+ for jid in [
2423
+ j.id
2424
+ for j in self._jobs.values()
2425
+ if j.process_instance_id == pi.id
2426
+ ]:
2427
+ self._jobs.pop(jid, None)
2428
+
2429
+ # ------------------------------------------------------------------
2430
+ # 活动实例历史
2431
+ # ------------------------------------------------------------------
2432
+ def _open_activity(self, pi: ProcessInstance, token: Execution, node: FlowNode) -> None:
2433
+ # asyncBefore 拆分时已 open(token.open_activity 未结算)-> 行为续跑复用,
2434
+ # 避免 async job 执行时重复记一条 actinst
2435
+ if (
2436
+ token.open_activity is not None
2437
+ and token.open_activity.activity_id == node.id
2438
+ and token.open_activity.end_time is None
2439
+ ):
2440
+ return
2441
+ ai = ActivityInstance(
2442
+ id=self._idgen.next_id(),
2443
+ process_instance_id=pi.id,
2444
+ activity_id=node.id,
2445
+ activity_name=node.name,
2446
+ execution_id=token.id,
2447
+ start_time=_now(),
2448
+ )
2449
+ token.open_activity = ai
2450
+ pi.activity_history.append(ai)
2451
+
2452
+ def _close_activity(self, pi: ProcessInstance, token: Execution, node: FlowNode) -> None:
2453
+ ai = token.open_activity
2454
+ if ai is not None and ai.activity_id == node.id and ai.end_time is None:
2455
+ ai.end_time = _now()
2456
+ token.open_activity = None
2457
+
2458
+ def _create_task(self, pi: ProcessInstance, token: Execution, node: UserTask) -> Task:
2459
+ task = Task(
2460
+ id=self._idgen.next_id(),
2461
+ name=node.name,
2462
+ process_instance_id=pi.id,
2463
+ execution_id=token.id,
2464
+ task_definition_key=node.id,
2465
+ assignee=node.assignee,
2466
+ candidate_users=list(node.candidate_users),
2467
+ candidate_groups=list(node.candidate_groups),
2468
+ create_time=_now(),
2469
+ )
2470
+ self._tasks[task.id] = task
2471
+ return task
2472
+
2473
+ def _run_delegate(self, pi: ProcessInstance, token: Execution, node: ServiceTask) -> None:
2474
+ ref = node.implementation_ref
2475
+ if ref is None:
2476
+ return # 无实现的 serviceTask 视为 pass-through
2477
+ fn = self._delegates.get(ref)
2478
+ if fn is None:
2479
+ raise ProcessInstanceException(
2480
+ f"serviceTask {node.id!r} 引用未注册的 delegate: {ref!r}"
2481
+ )
2482
+ result = fn(pi.variables)
2483
+ if isinstance(result, dict):
2484
+ pi.variables.update(result)
2485
+
2486
+ # ------------------------------------------------------------------
2487
+ # 静态出/入边辅助
2488
+ # ------------------------------------------------------------------
2489
+ @staticmethod
2490
+ def _outgoing(proc: Process, node: FlowNode) -> List[SequenceFlow]:
2491
+ return [proc.sequence_flows[fid] for fid in node.outgoing]
2492
+
2493
+ @staticmethod
2494
+ def _incoming(proc: Process, node: FlowNode) -> List[SequenceFlow]:
2495
+ return [proc.sequence_flows[fid] for fid in node.incoming]
2496
+
2497
+ # ------------------------------------------------------------------
2498
+ # M2/M3:持久化快照 / 崩溃恢复
2499
+ # ------------------------------------------------------------------
2500
+ def _build_snap(self, pi: ProcessInstance) -> "ProcInstSnap":
2501
+ """把实例内存态转为落库快照(只含 ACTIVE execution / 活跃任务 / 实例级 job)。"""
2502
+ from camunda.persistence.store import (
2503
+ ActivitySnap,
2504
+ ExecutionSnap,
2505
+ JobSnap,
2506
+ ProcInstSnap,
2507
+ TaskSnap,
2508
+ )
2509
+
2510
+ # 按树序输出 execution(ACTIVE 才落 RU)
2511
+ active: List[Execution] = []
2512
+ stack = [pi.root_execution] if pi.root_execution else []
2513
+ while stack:
2514
+ e = stack.pop()
2515
+ if e is None:
2516
+ continue
2517
+ if e.state == ExecutionState.ACTIVE:
2518
+ active.append(e)
2519
+ stack.extend(reversed(e.children)) # 保持父->子顺序稳定
2520
+
2521
+ active_tasks = [t for t in self._tasks.values() if t.process_instance_id == pi.id]
2522
+ pi_jobs = [j for j in self._jobs.values() if j.process_instance_id == pi.id]
2523
+
2524
+ return ProcInstSnap(
2525
+ id=pi.id,
2526
+ process_definition_key=pi.process_definition_key,
2527
+ business_key=pi.business_key,
2528
+ state=pi.state.value,
2529
+ start_time=pi.start_time or "",
2530
+ end_time=pi.end_time,
2531
+ variables=dict(pi.variables),
2532
+ executions=[
2533
+ ExecutionSnap(
2534
+ id=e.id,
2535
+ parent_id=e.parent_id,
2536
+ activity_id=e.activity_id,
2537
+ role=e.role,
2538
+ mi=e.mi, # M4-2c4:MI 容器/实例状态随 execution 落库
2539
+ )
2540
+ for e in active
2541
+ ],
2542
+ tasks=[
2543
+ TaskSnap(
2544
+ id=t.id,
2545
+ name=t.name,
2546
+ execution_id=t.execution_id,
2547
+ task_definition_key=t.task_definition_key,
2548
+ assignee=t.assignee,
2549
+ create_time=t.create_time or "",
2550
+ end_time=None,
2551
+ )
2552
+ for t in active_tasks
2553
+ ],
2554
+ jobs=[
2555
+ JobSnap(
2556
+ id=j.id,
2557
+ job_type=j.job_type,
2558
+ execution_id=j.execution_id,
2559
+ node_id=j.node_id,
2560
+ duedate=j.duedate,
2561
+ created=j.created,
2562
+ retries=j.retries,
2563
+ repeat=j.repeat,
2564
+ )
2565
+ for j in pi_jobs
2566
+ ],
2567
+ activity_history=[
2568
+ ActivitySnap(
2569
+ id=a.id,
2570
+ activity_id=a.activity_id,
2571
+ activity_name=a.activity_name,
2572
+ execution_id=a.execution_id,
2573
+ start_time=a.start_time,
2574
+ end_time=a.end_time,
2575
+ )
2576
+ for a in pi.activity_history
2577
+ ],
2578
+ completed_tasks=[
2579
+ TaskSnap(
2580
+ id=t.id,
2581
+ name=t.name,
2582
+ execution_id=t.execution_id,
2583
+ task_definition_key=t.task_definition_key,
2584
+ assignee=t.assignee,
2585
+ create_time=t.create_time or "",
2586
+ end_time=t.end_time,
2587
+ )
2588
+ for t in pi.completed_tasks
2589
+ ],
2590
+ )
2591
+
2592
+ @classmethod
2593
+ def from_database(cls, url: str) -> "ProcessEngine":
2594
+ """从数据库恢复引擎:加载部署定义 + 所有运行中实例。
2595
+
2596
+ 相当于 Camunda 重启后 JobExecutor/引擎重新挂载 ACT_RU_* 状态。
2597
+ 注意:恢复后需要自行 register_delegate 同名实现(bean 注册不落库)。
2598
+ """
2599
+ from camunda.persistence.store import Store
2600
+ from camunda.parser.bpmn_parser import parse_bpmn_xml
2601
+
2602
+ store = Store(url)
2603
+ engine = cls(store=store)
2604
+
2605
+ # 1) 恢复部署:每 key 取最新版本重解析
2606
+ latest: Dict[str, dict] = {}
2607
+ for d in store.load_proc_defs():
2608
+ cur = latest.get(d["key"])
2609
+ if cur is None or d["version"] > cur["version"]:
2610
+ latest[d["key"]] = d
2611
+ for d in latest.values():
2612
+ xml = d["xml"]
2613
+ if not xml:
2614
+ continue
2615
+ model = parse_bpmn_xml(xml, source_name=d["key"])
2616
+ for proc in model.processes:
2617
+ engine._definitions[proc.id] = proc
2618
+ engine._definition_versions[proc.id] = d["version"]
2619
+
2620
+ # 2) 恢复定义级作业(timer-start:PROC_INST_ID_ IS NULL)
2621
+ for j in store.load_timer_start_jobs():
2622
+ if j.process_definition_key in engine._definitions:
2623
+ engine._jobs[j.id] = j
2624
+
2625
+ # 3) 恢复运行中实例(RU 表,含实例级 job)
2626
+ for snap in store.load_active_instances():
2627
+ engine._restore_instance(snap)
2628
+ return engine
2629
+
2630
+ def close(self) -> None:
2631
+ """释放持久化连接(store 模式);纯内存引擎为无操作。
2632
+
2633
+ 演示 / 测试在删除 db 文件前必须调用(Windows 下 SQLite 文件句柄
2634
+ 由连接池持有,不释放则 unlink 报 PermissionError);关闭后引擎
2635
+ 不应再执行任何命令。
2636
+ """
2637
+ if self._store is not None:
2638
+ self._store.close()
2639
+
2640
+ def _restore_instance(self, snap: "ProcInstSnap") -> None:
2641
+ """把库中活跃实例快照重建为内存运行时态。"""
2642
+ # 根 = parent_id 为空的 execution(RU 行父在前已保证插入顺序)
2643
+ by_id: Dict[str, Execution] = {}
2644
+ root: Optional[Execution] = None
2645
+ for ex in snap.executions:
2646
+ e = Execution(
2647
+ id=ex.id,
2648
+ process_instance_id=snap.id,
2649
+ parent_id=ex.parent_id,
2650
+ role=ex.role,
2651
+ activity_id=ex.activity_id,
2652
+ # M4-2c4:MI 容器/实例状态还原(浅拷贝防快照对象复用共享;
2653
+ # items 元素只读,容器计数/序号随实例完成在原位自增)
2654
+ mi=dict(ex.mi) if ex.mi else None,
2655
+ state=ExecutionState.ACTIVE,
2656
+ )
2657
+ by_id[e.id] = e
2658
+ if ex.parent_id is None:
2659
+ root = e
2660
+ for e in by_id.values():
2661
+ if e.parent_id and e.parent_id in by_id:
2662
+ by_id[e.parent_id].children.append(e)
2663
+
2664
+ pi = ProcessInstance(
2665
+ id=snap.id,
2666
+ process_definition_key=snap.process_definition_key,
2667
+ business_key=snap.business_key,
2668
+ state=ProcessInstanceState(snap.state),
2669
+ variables=dict(snap.variables),
2670
+ root_execution=root,
2671
+ start_time=snap.start_time,
2672
+ end_time=snap.end_time,
2673
+ executions=by_id,
2674
+ )
2675
+ # 活动历史还原(ACT_HI_ACTINST 快照)
2676
+ for a in snap.activity_history:
2677
+ ai = ActivityInstance(
2678
+ id=a.id,
2679
+ process_instance_id=snap.id,
2680
+ activity_id=a.activity_id,
2681
+ activity_name=a.activity_name,
2682
+ execution_id=a.execution_id,
2683
+ start_time=a.start_time,
2684
+ end_time=a.end_time,
2685
+ )
2686
+ pi.activity_history.append(ai)
2687
+ # 未结算的活动(如停在 userTask)挂回对应 execution,便于后续 close
2688
+ if a.end_time is None:
2689
+ owner = by_id.get(a.execution_id)
2690
+ if owner is not None:
2691
+ owner.open_activity = ai
2692
+ # join 等待状态还原:停在并行网关的 ACTIVE TOKEN 即 join 等待
2693
+ # (M4-2a:按各自所在容器解析节点类型,子流程内部 join 同样可还原)
2694
+ for e in by_id.values():
2695
+ if (
2696
+ e.state == ExecutionState.ACTIVE
2697
+ and e.activity_id
2698
+ and e.role != "SCOPE" # SCOPE 停驻 fork 网关不算 join 等待
2699
+ ):
2700
+ node = self._container_of(pi, e).flow_nodes.get(e.activity_id)
2701
+ if isinstance(node, ParallelGateway):
2702
+ pi.register_join_arrival(e.activity_id, e.id)
2703
+ # 任务还原
2704
+ for t in snap.tasks:
2705
+ task = Task(
2706
+ id=t.id,
2707
+ name=t.name,
2708
+ process_instance_id=snap.id,
2709
+ execution_id=t.execution_id,
2710
+ task_definition_key=t.task_definition_key,
2711
+ assignee=t.assignee,
2712
+ create_time=t.create_time,
2713
+ )
2714
+ self._tasks[task.id] = task
2715
+ # 已归档任务还原(HI_TASKINST 带 end_time 跨重启保留;HI 全量重写语义下
2716
+ # 缺了它会让重启后的下一次 save 抹掉历史——见 M4-2b5 修复记录)
2717
+ for t in snap.completed_tasks:
2718
+ task = Task(
2719
+ id=t.id,
2720
+ name=t.name,
2721
+ process_instance_id=snap.id,
2722
+ execution_id=t.execution_id,
2723
+ task_definition_key=t.task_definition_key,
2724
+ assignee=t.assignee,
2725
+ create_time=t.create_time,
2726
+ end_time=t.end_time,
2727
+ )
2728
+ pi.completed_tasks.append(task)
2729
+ # 实例级作业还原(timer-catch / async-continuation 停等)
2730
+ for j in snap.jobs:
2731
+ self._jobs[j.id] = Job(
2732
+ id=j.id,
2733
+ job_type=j.job_type,
2734
+ duedate=j.duedate,
2735
+ created=j.created,
2736
+ process_instance_id=snap.id,
2737
+ execution_id=j.execution_id,
2738
+ node_id=j.node_id,
2739
+ retries=j.retries,
2740
+ repeat=j.repeat,
2741
+ )
2742
+ self._rebuild_event_subscriptions(pi)
2743
+ self._instances[snap.id] = pi
2744
+
2745
+ def _rebuild_event_subscriptions(self, pi: ProcessInstance) -> None:
2746
+ """崩溃恢复:从执行树重推导消息/信号订阅(M4-2d4,纯内存派生态)。
2747
+
2748
+ 订阅不落库(对齐 join 等待「恢复时从树推导」先例):按「停驻状态 =
2749
+ 等待状态」重放注册,规则与运行期注册点一一对应:
2750
+ - root 未完成 -> 流程级容器 esc message/signal start 订阅(root 直通/
2751
+ 兼任 sub 时与运行期一致双注册);
2752
+ - 停驻 SubProcess 的 SCOPE(actinst open)-> 该 sub 容器内 esc 订阅;
2753
+ embedded sub 宿主等待窗口 = 整段内部执行 -> 边界订阅同样重放
2754
+ (事件子流程 scope 运行期不注册边界,保持一致不重放);
2755
+ - 停在 message/signal catch 的 token -> catch 订阅;
2756
+ - 停在挂边界事件的宿主(userTask / asyncBefore 节点,actinst open)->
2757
+ 边界订阅。timer 边界/esc job 已随 jobs 快照还原,幂等检查防止重放
2758
+ 时重复注册 timer(只补 message/signal 订阅)。
2759
+ """
2760
+ if pi.is_completed or pi.root_execution is None:
2761
+ return
2762
+ root_proc = self._definitions[pi.process_definition_key]
2763
+ self._register_event_subprocess_subscriptions(
2764
+ pi, pi.root_execution, root_proc, None
2765
+ )
2766
+ for e in list(pi.executions.values()):
2767
+ if e.state != ExecutionState.ACTIVE or not e.activity_id:
2768
+ continue
2769
+ waiting = e.open_activity is not None and e.open_activity.end_time is None
2770
+ if not waiting:
2771
+ continue
2772
+ node = self._container_of(pi, e).flow_nodes.get(e.activity_id)
2773
+ if node is None:
2774
+ continue
2775
+ if e.role == "SCOPE" and isinstance(node, SubProcess):
2776
+ # 容器激活(嵌入/事件子流程 scope):重放容器内 esc 订阅;
2777
+ # 嵌入子流程宿主等待窗口 = 整段内部执行 -> 边界订阅同样重放
2778
+ self._register_event_subprocess_subscriptions(
2779
+ pi, e, node.process, node.id
2780
+ )
2781
+ if not node.triggered_by_event:
2782
+ self._register_boundary_jobs(pi, e, node)
2783
+ continue
2784
+ if e.role == "SCOPE":
2785
+ continue # fork 网关等其它 SCOPE 停驻无订阅
2786
+ if isinstance(node, IntermediateCatchEvent):
2787
+ if node.message_name is not None or node.signal_name is not None:
2788
+ sub = EventSubscription(
2789
+ id=self._idgen.next_id(),
2790
+ kind="message" if node.message_name is not None else "signal",
2791
+ event_name=node.message_name or node.signal_name,
2792
+ process_instance_id=pi.id,
2793
+ execution_id=e.id,
2794
+ activity_id=None,
2795
+ node_id=node.id,
2796
+ node_kind="catch",
2797
+ is_interrupting=True,
2798
+ created=_now(),
2799
+ )
2800
+ self._event_subs[sub.id] = sub
2801
+ continue
2802
+ if isinstance(node, UserTask) or node.async_before:
2803
+ # 宿主等待活动:重放边界订阅(timer job 已还原,幂等跳过)
2804
+ self._register_boundary_jobs(pi, e, node)
2805
+
2806
+ # ------------------------------------------------------------------
2807
+ # M3:定义级 timer-start 作业(不挂实例,随部署生命周期)
2808
+ # ------------------------------------------------------------------
2809
+ def _make_timer_start_job(self, proc_key: str, start: StartEvent) -> Job:
2810
+ """按 timer kind 构造 timer-start 作业,duedate = 首次触发时刻。
2811
+
2812
+ - date : 绝对触发时间点(parse_trigger_date 归一化本地时区)
2813
+ - duration: 相对部署时刻的一次性延迟
2814
+ - cycle : 周期重复。value 为 ISO R[n]/dur 时 repeat={"kind":"interval"};
2815
+ 否则视为 quartz/cron 表达式(croniter 求值)。无限续排
2816
+ """
2817
+ timer = start.timer
2818
+ now = _now()
2819
+ if timer.kind == "date":
2820
+ duedate = parse_trigger_date(timer.value)
2821
+ repeat = None
2822
+ elif timer.kind == "duration":
2823
+ duedate = format_iso(
2824
+ parse_iso(now) + timedelta(seconds=timer.delay_seconds or 0)
2825
+ )
2826
+ repeat = None
2827
+ else: # cycle
2828
+ repeat = parse_iso_repeat(timer.value)
2829
+ if repeat is None: # 非 ISO 重复 => quartz/cron 表达式
2830
+ repeat = {"kind": "cron", "expr": timer.value.strip()}
2831
+ duedate = format_iso(next_trigger(repeat, parse_iso(now)))
2832
+ return Job(
2833
+ id=self._idgen.next_id(),
2834
+ job_type="timer-start",
2835
+ duedate=duedate,
2836
+ created=now,
2837
+ process_definition_key=proc_key,
2838
+ node_id=start.id,
2839
+ repeat=repeat,
2840
+ )
2841
+
2842
+ def _drop_definition_jobs(self, proc_key: str) -> None:
2843
+ """移除某 process key 的全部定义级作业(重部署新版本前调用)。"""
2844
+ stale = [
2845
+ j.id
2846
+ for j in self._jobs.values()
2847
+ if j.is_definition_level and j.process_definition_key == proc_key
2848
+ ]
2849
+ for jid in stale:
2850
+ del self._jobs[jid]
2851
+
2852
+ def _definition_level_jobs(self) -> List[Job]:
2853
+ """当前全部定义级作业(timer-start 组)。"""
2854
+ return [j for j in self._jobs.values() if j.is_definition_level]
2855
+
2856
+ def _sync_timer_start_jobs(self) -> None:
2857
+ """store 模式:定义级作业组全量落库(部署 / 触发续排 / 删除时调用)。"""
2858
+ if self._store is not None:
2859
+ self._store.save_timer_start_jobs(self._definition_level_jobs())
2860
+
2861
+ # ------------------------------------------------------------------
2862
+ # M3:JobExecutor 引擎侧(execute_due_jobs 即 Camunda JobExecutor 轮询)
2863
+ # ------------------------------------------------------------------
2864
+ def execute_due_jobs(
2865
+ self,
2866
+ limit: Optional[int] = None,
2867
+ *,
2868
+ lock_owner: Optional[str] = None,
2869
+ lease_seconds: int = 300,
2870
+ ) -> int:
2871
+ """执行当前到期且非死信的作业,返回执行条数。
2872
+
2873
+ JobExecutor 轮询线程与手动触发都调本方法(内部持引擎锁,与用户命令
2874
+ 互斥)。续排出的新作业不在本次快照内,留待下一轮询 tick —— 周期作业
2875
+ 不补触发(错过即错过,interval 按计划链续排不漂移)。
2876
+
2877
+ lock_owner is not None 且 self._store 不为空时(M7):走 DB CAS lease
2878
+ 抢锁路径,多 JobExecutor / 多进程场景下保证同一作业只被一个节点执行。
2879
+ 否则走原内存路径(单进程兼容)。
2880
+ """
2881
+ if lock_owner is not None and self._store is not None:
2882
+ return self._execute_due_jobs_db(
2883
+ lock_owner, lease_seconds, limit if limit is not None else 50
2884
+ )
2885
+ with self._lock:
2886
+ due = sorted(
2887
+ (
2888
+ j
2889
+ for j in self._jobs.values()
2890
+ if j.is_due(_now()) and not j.is_dead()
2891
+ ),
2892
+ key=lambda j: j.duedate,
2893
+ )
2894
+ if limit is not None:
2895
+ due = due[:limit]
2896
+ executed = 0
2897
+ for job in due:
2898
+ if self._jobs.get(job.id) is None:
2899
+ continue # 前序作业执行已连带删除(防御)
2900
+ try:
2901
+ self._execute_job(self._jobs[job.id])
2902
+ except Exception: # pragma: no cover - _execute_job 内部已兜底
2903
+ logger.exception("job %s 执行出现未预期异常", job.id)
2904
+ executed += 1
2905
+ return executed
2906
+
2907
+ def _execute_due_jobs_db(
2908
+ self, lock_owner: str, lease_seconds: int, batch_size: int
2909
+ ) -> int:
2910
+ """DB 抢锁路径(M7):用 store.acquire_due_jobs 拿到一批 due job,
2911
+ 对每个 CAS 抢到的作业复用内存 _run_job_body 执行,结果用
2912
+ complete_job_cas / reschedule_job_cas 写回(不调 _persist_job_state
2913
+ 全量重写,避免与并发 JobExecutor 争 LOCK 列)。
2914
+
2915
+ 防御要点:
2916
+ - 内存里没有该 job(被别的节点推进 / 删了)-> 直接 CAS complete
2917
+ - CAS 写回失败(owner 已变更 = lease 过期被抢)-> 跳过(防御),
2918
+ 但内存里的 _reschedule_or_remove / _degrade_after_failure 已改了
2919
+ mem_job,需要再次 save_proc_inst 落库让 DB 与内存一致
2920
+ - 失败回滚会重建内存 job(LOCK 列从 DB 读回)-> 用函数参数 owner
2921
+ 而非 mem_job.lock_owner 做 CAS,保证身份一致
2922
+ """
2923
+ acquired = self._store.acquire_due_jobs(
2924
+ lock_owner, lease_seconds, _now(), batch_size
2925
+ )
2926
+ if not acquired:
2927
+ return 0
2928
+ executed = 0
2929
+ for snap_job in acquired:
2930
+ mem_job = self._jobs.get(snap_job.id)
2931
+ if mem_job is None:
2932
+ # 内存没有(实例被别节点推进 / 删了),DB 行直接清掉
2933
+ self._store.complete_job_cas(snap_job.id, lock_owner)
2934
+ continue
2935
+ try:
2936
+ self._run_job_body(mem_job)
2937
+ except Exception:
2938
+ logger.warning(
2939
+ "db-locked job %s (%s@%s) 执行失败,剩余重试 %d",
2940
+ mem_job.id,
2941
+ mem_job.job_type,
2942
+ mem_job.node_id,
2943
+ mem_job.retries - 1,
2944
+ exc_info=True,
2945
+ )
2946
+ # 实例级失败:rollback 到上次同步点(DB LOCK 保留,内存重建后
2947
+ # lock_owner 是 None,CAS 必须用函数参数 owner)
2948
+ if mem_job.process_instance_id is not None:
2949
+ self._rollback_instance(mem_job.process_instance_id)
2950
+ mem_job = self._jobs.get(mem_job.id) or mem_job
2951
+ self._degrade_after_failure(mem_job)
2952
+ # CAS 写回(clear_lock=True:失败后让其他 JobExecutor 能看到
2953
+ # 新的 retries / duedate;duedate 已推到 retry_delay 之后,
2954
+ # 不会立刻被重抢)
2955
+ self._store.reschedule_job_cas(
2956
+ mem_job.id,
2957
+ lock_owner,
2958
+ mem_job.duedate,
2959
+ mem_job.retries,
2960
+ clear_lock=True,
2961
+ )
2962
+ # 实例级还要把实例快照全量落库(save_proc_inst 走原路径,
2963
+ # 不带 LOCK 列,与 CAS 写回的 LOCK 清掉语义吻合)
2964
+ if (
2965
+ mem_job.process_instance_id is not None
2966
+ and self._instances.get(mem_job.process_instance_id) is not None
2967
+ ):
2968
+ pi = self._instances[mem_job.process_instance_id]
2969
+ self._store.save_proc_inst(self._build_snap(pi))
2970
+ elif mem_job.is_definition_level:
2971
+ self._sync_timer_start_jobs()
2972
+ executed += 1
2973
+ continue
2974
+ # 成功:一次性 / 续排
2975
+ self._reschedule_or_remove(mem_job)
2976
+ if mem_job.id not in self._jobs:
2977
+ # 一次性作业:从 DB 删除(CAS 防御 owner 不匹配)
2978
+ self._store.complete_job_cas(mem_job.id, lock_owner)
2979
+ else:
2980
+ # 续排(timer-start cycle):CAS 更新 duedate + 清 LOCK
2981
+ self._store.reschedule_job_cas(
2982
+ mem_job.id,
2983
+ lock_owner,
2984
+ mem_job.duedate,
2985
+ mem_job.retries,
2986
+ clear_lock=True,
2987
+ )
2988
+ # 同步实例级状态(Execution / Task / Variable)到 DB:
2989
+ # 一次性作业执行时可能推进 token(创建/删除 execution、新增 task、
2990
+ # 改变量),必须 save_proc_inst 全量重写该实例的 RU 行,
2991
+ # 否则重启后这些变更丢失。timer-start 的实例未变,仍在 _definitions;
2992
+ # _sync_timer_start_jobs 由 reschedule_job_cas 已处理(清 LOCK)。
2993
+ if (
2994
+ mem_job.process_instance_id is not None
2995
+ and self._instances.get(mem_job.process_instance_id) is not None
2996
+ ):
2997
+ pi = self._instances[mem_job.process_instance_id]
2998
+ self._store.save_proc_inst(self._build_snap(pi))
2999
+ elif mem_job.is_definition_level:
3000
+ # 防御:续例 + 续排路径下也再 sync 一次(reschedule_job_cas
3001
+ # 已写 ACT_RU_JOB 行,但 LOCK 列状态以那里为准)
3002
+ self._sync_timer_start_jobs()
3003
+ executed += 1
3004
+ return executed
3005
+
3006
+ def create_job_query(self, process_instance_id: Optional[str] = None) -> List[Job]:
3007
+ """查看作业(对齐 createJobQuery,按 duedate 升序;死信 retries==0 可见)。"""
3008
+ with self._lock:
3009
+ jobs = list(self._jobs.values())
3010
+ if process_instance_id is not None:
3011
+ jobs = [j for j in jobs if j.process_instance_id == process_instance_id]
3012
+ return sorted(jobs, key=lambda j: j.duedate)
3013
+
3014
+ def delete_job(self, job_id: str) -> None:
3015
+ """手动删除作业(对齐 ManagementService.deleteJob)。
3016
+
3017
+ 注意:删除 timer-catch/async 作业后对应 token 将永久停驻 —— Camunda
3018
+ 语义相同(删 job 即放弃该次调度),M3 不做额外护栏。
3019
+ """
3020
+ with self._lock:
3021
+ job = self._jobs.get(job_id)
3022
+ if job is None:
3023
+ raise NotFoundException(f"作业不存在: {job_id!r}")
3024
+ del self._jobs[job_id]
3025
+ if self._store is not None:
3026
+ if job.is_definition_level:
3027
+ self._sync_timer_start_jobs()
3028
+ else:
3029
+ pi = self._instances.get(job.process_instance_id)
3030
+ if pi is not None:
3031
+ self._store.save_proc_inst(self._build_snap(pi))
3032
+
3033
+ # ------------------------------------------------------------------
3034
+ # M3:单条作业执行
3035
+ # ------------------------------------------------------------------
3036
+ def _execute_job(self, job: Job) -> None:
3037
+ """执行一条作业:成功 -> 删除/续排 + 落库;失败 -> 重试降级 + 落库。
3038
+
3039
+ 失败不向外抛(避免单条失败打断整轮轮询)。持久化模式实例级失败会先
3040
+ 回滚到上次同步点再降级,避免「内存已推进、库未写」的半执行不一致。
3041
+ """
3042
+ try:
3043
+ self._run_job_body(job)
3044
+ except Exception:
3045
+ logger.warning(
3046
+ "job %s (%s@%s) 执行失败,剩余重试 %d",
3047
+ job.id,
3048
+ job.job_type,
3049
+ job.node_id,
3050
+ job.retries - 1,
3051
+ exc_info=True,
3052
+ )
3053
+ if job.process_instance_id is not None and self._store is not None:
3054
+ # 实例级 + 持久化:整体回滚到上次同步点(RU/HI 未动,内存重建)
3055
+ self._rollback_instance(job.process_instance_id)
3056
+ job = self._jobs.get(job.id) or job # rollback 重建了 job 对象
3057
+ self._degrade_after_failure(job)
3058
+ if self._store is not None:
3059
+ self._persist_job_state(job)
3060
+ else:
3061
+ self._reschedule_or_remove(job)
3062
+ if self._store is not None:
3063
+ self._persist_job_state(job)
3064
+
3065
+ def _run_job_body(self, job: Job) -> None:
3066
+ """纯执行单条作业(不落库)。失败向上抛,调用方决定降级 / 续排策略。
3067
+
3068
+ 拆出来供 DB 抢锁路径(M7)复用:CAS 写回而非全量重写,避免与
3069
+ 其他 JobExecutor 产生 LOCK 列竞态。
3070
+ """
3071
+ if job.job_type == "timer-start":
3072
+ self._fire_timer_start(job)
3073
+ elif job.job_type == "timer-catch":
3074
+ self._fire_timer_catch(job)
3075
+ elif job.job_type == "timer-boundary":
3076
+ self._fire_timer_boundary(job)
3077
+ elif job.job_type == "timer-event-start":
3078
+ self._fire_timer_event_start(job)
3079
+ elif job.job_type == "async-continuation":
3080
+ self._run_async_continuation(job)
3081
+ elif job.job_type == "async-after":
3082
+ self._run_async_after(job)
3083
+ else:
3084
+ raise InvalidRequestException(f"未知作业类型: {job.job_type!r}")
3085
+
3086
+ def _fire_timer_start(self, job: Job) -> None:
3087
+ """timer-start 触发:启动一个流程实例(cycle 续排由 _reschedule_or_remove 处理)。"""
3088
+ proc = self._definitions[job.process_definition_key]
3089
+ start = proc.flow_nodes[job.node_id]
3090
+ if not isinstance(start, StartEvent):
3091
+ raise ProcessInstanceException(
3092
+ f"timer-start job {job.id} 指向非 startEvent 节点: {job.node_id!r}"
3093
+ )
3094
+ # _start_process 内部已落库实例(store 模式);定时启动不带用户变量
3095
+ self._start_process(proc, None, None, start)
3096
+
3097
+ def _fire_timer_catch(self, job: Job) -> None:
3098
+ """timer-catch 到期:结算停等 actinst,token 沿出边继续推进。"""
3099
+ pi = self._instances.get(job.process_instance_id)
3100
+ token = pi.executions.get(job.execution_id) if pi is not None else None
3101
+ if (
3102
+ pi is None
3103
+ or token is None
3104
+ or token.state != ExecutionState.ACTIVE
3105
+ or token.activity_id != job.node_id
3106
+ ):
3107
+ self._jobs.pop(job.id, None) # token 已失效/推进 -> 过期作业直接丢弃
3108
+ return
3109
+ proc = self._container_of(pi, token)
3110
+ node = proc.flow_nodes[job.node_id]
3111
+ self._close_activity(pi, token, node)
3112
+ arrivals: List[_Arrival] = []
3113
+ self._leave(pi, token, node, arrivals)
3114
+ self._pump(pi, arrivals)
3115
+
3116
+ def _run_async_continuation(self, job: Job) -> None:
3117
+ """async-continuation 到期:直接执行节点行为(_dispatch_node 不再拆 async)。
3118
+
3119
+ token 在 asyncBefore 拆分时已 open actinst 停等;job 执行 = 行为主体
3120
+ (open 复用避免重复记 actinst),完成后继续推进。
3121
+ """
3122
+ pi = self._instances.get(job.process_instance_id)
3123
+ token = pi.executions.get(job.execution_id) if pi is not None else None
3124
+ if (
3125
+ pi is None
3126
+ or pi.is_completed
3127
+ or token is None
3128
+ or token.state != ExecutionState.ACTIVE
3129
+ ):
3130
+ self._jobs.pop(job.id, None)
3131
+ return
3132
+ proc = self._container_of(pi, token)
3133
+ node = proc.flow_nodes[job.node_id]
3134
+ arrivals = self._dispatch_node(pi, token, node)
3135
+ self._pump(pi, arrivals)
3136
+ # 宿主(asyncBefore 节点)活动是否仍在等待:仅当行为后 token 仍停在同一
3137
+ # 节点且活动未结算(asyncBefore + userTask 组合停等)时边界 timer 继续
3138
+ # 有效;已离开 / 停在 join 等待(并行网关无活动等待窗口)即作废
3139
+ still_waiting = (
3140
+ token.state == ExecutionState.ACTIVE
3141
+ and token.activity_id == node.id
3142
+ and token.open_activity is not None
3143
+ and token.open_activity.end_time is None
3144
+ and token.id not in pi.join_arrived(node.id)
3145
+ )
3146
+ if not still_waiting:
3147
+ self._drop_boundary_jobs(pi, node)
3148
+
3149
+ def _run_async_after(self, job: Job) -> None:
3150
+ """async-after 到期:执行「离开推进」。
3151
+
3152
+ serviceTask 沿出边离开(多出边 fork);XOR 此时重新求值条件选路后 take
3153
+ —— 行为与离开之间的异步窗口内变量可能已变化,条件以 job 到期时刻为准。
3154
+ token 已推进到别处(activity_id != job.node_id)= 过期作业,直接丢弃。
3155
+ """
3156
+ pi = self._instances.get(job.process_instance_id)
3157
+ token = pi.executions.get(job.execution_id) if pi is not None else None
3158
+ if (
3159
+ pi is None
3160
+ or pi.is_completed
3161
+ or token is None
3162
+ or token.state != ExecutionState.ACTIVE
3163
+ or token.activity_id != job.node_id
3164
+ ):
3165
+ self._jobs.pop(job.id, None) # token 已推进 -> 过期作业丢弃
3166
+ return
3167
+ proc = self._container_of(pi, token)
3168
+ node = proc.flow_nodes[job.node_id]
3169
+ arrivals: List[_Arrival] = []
3170
+ if isinstance(node, ServiceTask):
3171
+ self._leave(pi, token, node, arrivals)
3172
+ elif isinstance(node, ExclusiveGateway):
3173
+ chosen = select_exclusive_gateway_flow(
3174
+ node, self._outgoing(proc, node), pi.variables
3175
+ )
3176
+ self._take(pi, token, chosen, arrivals)
3177
+ else: # 防御:异常宿主类型 -> 丢弃(正常解析 + _handle_arrival 校验后不会发生)
3178
+ self._jobs.pop(job.id, None)
3179
+ return
3180
+ self._pump(pi, arrivals)
3181
+
3182
+ def _reschedule_or_remove(self, job: Job) -> None:
3183
+ """作业成功后处理:timer-start 按 repeat 续排下一 duedate;其余删除。
3184
+
3185
+ interval 按「计划 duedate 链式 + 周期」续排(执行延迟不累积漂移);
3186
+ cron 从当前时刻求下一未来触发。count 递减到 0 即停排(R3/PT.. = 触发 3 次)。
3187
+ """
3188
+ if not job.repeat:
3189
+ self._jobs.pop(job.id, None)
3190
+ return
3191
+ rep = dict(job.repeat)
3192
+ if rep["kind"] == "interval":
3193
+ if rep.get("count") is not None:
3194
+ rep["count"] -= 1
3195
+ if rep["count"] <= 0:
3196
+ self._jobs.pop(job.id, None)
3197
+ return
3198
+ job.repeat = rep
3199
+ job.duedate = format_iso(
3200
+ parse_iso(job.duedate) + timedelta(seconds=rep["seconds"])
3201
+ )
3202
+ else: # cron
3203
+ job.duedate = format_iso(next_trigger(rep, parse_iso(_now())))
3204
+ job.repeat = rep
3205
+
3206
+ def _degrade_after_failure(self, job: Job) -> None:
3207
+ """失败降级:retries-1;未耗尽则按 retry_delay_seconds 顺延 duedate。"""
3208
+ job.retries -= 1
3209
+ if job.retries > 0:
3210
+ job.duedate = format_iso(
3211
+ parse_iso(_now()) + timedelta(seconds=job.retry_delay_seconds)
3212
+ )
3213
+ # retries 耗尽 = 死信:保留记录(可 create_job_query 查看),不再被 acquire
3214
+
3215
+ def _rollback_instance(self, proc_inst_id: str) -> None:
3216
+ """把实例内存态回滚到上次同步点(从 RU/HI 重读重建,store 未动无需回写)。"""
3217
+ snap = next(
3218
+ (s for s in self._store.load_active_instances() if s.id == proc_inst_id),
3219
+ None,
3220
+ )
3221
+ if snap is None:
3222
+ return
3223
+ for tid in [t.id for t in self._tasks.values() if t.process_instance_id == proc_inst_id]:
3224
+ self._tasks.pop(tid, None)
3225
+ for jid in [j.id for j in self._jobs.values() if j.process_instance_id == proc_inst_id]:
3226
+ self._jobs.pop(jid, None)
3227
+ self._instances.pop(proc_inst_id, None)
3228
+ self._restore_instance(snap)
3229
+
3230
+ def _persist_job_state(self, job: Job) -> None:
3231
+ """作业执行成功/失败后的事务边界同步(store 模式)。"""
3232
+ if job.is_definition_level:
3233
+ self._sync_timer_start_jobs()
3234
+ return
3235
+ pi = self._instances.get(job.process_instance_id)
3236
+ if pi is not None:
3237
+ self._store.save_proc_inst(self._build_snap(pi))