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.
- camunda/__init__.py +10 -0
- camunda/api/__init__.py +14 -0
- camunda/api/app.py +80 -0
- camunda/api/deps.py +17 -0
- camunda/api/errors.py +84 -0
- camunda/api/pagination.py +74 -0
- camunda/api/routers/__init__.py +5 -0
- camunda/api/routers/decision.py +57 -0
- camunda/api/routers/deployment.py +139 -0
- camunda/api/routers/history.py +128 -0
- camunda/api/routers/process_definition.py +49 -0
- camunda/api/routers/process_instance.py +106 -0
- camunda/api/routers/task.py +92 -0
- camunda/api/schemas.py +200 -0
- camunda/common/__init__.py +19 -0
- camunda/common/clock.py +30 -0
- camunda/common/exceptions.py +34 -0
- camunda/common/idgen.py +23 -0
- camunda/common/timers.py +106 -0
- camunda/dmn/__init__.py +5 -0
- camunda/dmn/engine.py +219 -0
- camunda/dmn/feel.py +392 -0
- camunda/engine/__init__.py +9 -0
- camunda/engine/behavior.py +51 -0
- camunda/engine/expression.py +126 -0
- camunda/engine/process_engine.py +3237 -0
- camunda/job/__init__.py +9 -0
- camunda/job/executor.py +136 -0
- camunda/model/__init__.py +48 -0
- camunda/model/bpmn.py +326 -0
- camunda/model/dmn.py +101 -0
- camunda/model/execution.py +121 -0
- camunda/model/job.py +88 -0
- camunda/model/task.py +33 -0
- camunda/model/variable.py +35 -0
- camunda/parser/__init__.py +5 -0
- camunda/parser/bpmn_parser.py +646 -0
- camunda/parser/dmn_parser.py +225 -0
- camunda/persistence/__init__.py +21 -0
- camunda/persistence/entities.py +202 -0
- camunda/persistence/store.py +721 -0
- camunda_python-0.1.0.dist-info/METADATA +377 -0
- camunda_python-0.1.0.dist-info/RECORD +46 -0
- camunda_python-0.1.0.dist-info/WHEEL +5 -0
- camunda_python-0.1.0.dist-info/licenses/LICENSE +200 -0
- camunda_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""持久化存取:快照同步 + SQLite/PostgreSQL/MySQL 支持(M2)。
|
|
2
|
+
|
|
3
|
+
同步策略(事务边界同步,文档化):
|
|
4
|
+
- M1 引擎在内存推进(事件队列 pump),M2 在**每个命令边界**
|
|
5
|
+
(deploy / start_process_instance / complete_task)把状态全量同步到库。
|
|
6
|
+
崩溃发生在命令中途 => 该命令整体丢失(等价于 Camunda 单命令事务)。
|
|
7
|
+
- RU(运行时)表:全量 delete+insert 该实例当前 ACTIVE 状态
|
|
8
|
+
(Camunda 是逐行 update;数据量小,全量重写简单且一致)。
|
|
9
|
+
- HI(历史)表:PROCINST upsert 一行;ACTINST / TASKINST / VARINST 全量重写
|
|
10
|
+
该实例当前快照。M2 差异:HI_VARINST 为实例级快照(非每次变更追加版本)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import create_engine, delete, select, update
|
|
23
|
+
from sqlalchemy.orm import Session
|
|
24
|
+
|
|
25
|
+
from camunda.common.timers import format_iso, parse_iso
|
|
26
|
+
from camunda.model.bpmn import BpmnModel
|
|
27
|
+
from camunda.model.job import Job
|
|
28
|
+
from camunda.model.variable import java_type_name
|
|
29
|
+
from camunda.persistence.entities import (
|
|
30
|
+
Base,
|
|
31
|
+
DeploymentEntity,
|
|
32
|
+
ExecutionEntity,
|
|
33
|
+
HistActInstEntity,
|
|
34
|
+
HistProcInstEntity,
|
|
35
|
+
HistTaskInstEntity,
|
|
36
|
+
HistVarInstEntity,
|
|
37
|
+
JobEntity,
|
|
38
|
+
ProcDefEntity,
|
|
39
|
+
TaskEntity,
|
|
40
|
+
VariableEntity,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# 快照(引擎状态 -> 纯数据)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
@dataclass
|
|
48
|
+
class ExecutionSnap:
|
|
49
|
+
id: str
|
|
50
|
+
parent_id: Optional[str]
|
|
51
|
+
activity_id: Optional[str]
|
|
52
|
+
role: str # TOKEN | SCOPE
|
|
53
|
+
# M4-2c4:多实例状态(容器 dict 或 {"index": i} 实例标识;非 MI 为 None)
|
|
54
|
+
mi: Optional[Dict[str, Any]] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TaskSnap:
|
|
59
|
+
id: str
|
|
60
|
+
name: Optional[str]
|
|
61
|
+
execution_id: str
|
|
62
|
+
task_definition_key: str
|
|
63
|
+
assignee: Optional[str]
|
|
64
|
+
create_time: str
|
|
65
|
+
end_time: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ActivitySnap:
|
|
70
|
+
id: str
|
|
71
|
+
activity_id: str
|
|
72
|
+
activity_name: Optional[str]
|
|
73
|
+
execution_id: str
|
|
74
|
+
start_time: Optional[str]
|
|
75
|
+
end_time: Optional[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class JobSnap:
|
|
80
|
+
"""实例级作业快照(timer-catch / async-continuation,随实例 RU 全量重写)。"""
|
|
81
|
+
|
|
82
|
+
id: str
|
|
83
|
+
job_type: str
|
|
84
|
+
execution_id: Optional[str]
|
|
85
|
+
node_id: Optional[str]
|
|
86
|
+
duedate: str
|
|
87
|
+
created: str
|
|
88
|
+
retries: int = 3
|
|
89
|
+
repeat: Optional[Dict[str, Any]] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ProcInstSnap:
|
|
94
|
+
"""一次同步所需的实例状态全集。"""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
process_definition_key: str
|
|
98
|
+
business_key: Optional[str]
|
|
99
|
+
state: str # ACTIVE | COMPLETED
|
|
100
|
+
start_time: str
|
|
101
|
+
end_time: Optional[str] = None
|
|
102
|
+
variables: Dict[str, Any] = field(default_factory=dict)
|
|
103
|
+
executions: List[ExecutionSnap] = field(default_factory=list)
|
|
104
|
+
tasks: List[TaskSnap] = field(default_factory=list)
|
|
105
|
+
jobs: List[JobSnap] = field(default_factory=list)
|
|
106
|
+
activity_history: List[ActivitySnap] = field(default_factory=list)
|
|
107
|
+
completed_tasks: List[TaskSnap] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Store
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
class Store:
|
|
114
|
+
"""ACT 表存取门面。url 形如 sqlite:///abs/path、postgresql+psycopg://...、
|
|
115
|
+
mysql+pymysql://...(MySQL 需 pip install pymysql)。
|
|
116
|
+
|
|
117
|
+
兼容裸文件路径(如 /tmp/camunda.db):自动归一化为 sqlite:/// 绝对路径,
|
|
118
|
+
方便测试与命令行直接传 db 路径而不用拼 scheme。
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, url: str) -> None:
|
|
122
|
+
self.url = self._normalize_url(url)
|
|
123
|
+
self._engine = create_engine(self.url, future=True)
|
|
124
|
+
Base.metadata.create_all(self._engine) # M2:无迁移工具,建表即对齐 schema
|
|
125
|
+
|
|
126
|
+
# ---- 生命周期 ----
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""释放底层连接池(关闭所有打开的 DBAPI 连接)。
|
|
129
|
+
|
|
130
|
+
Windows 上 SQLite 文件句柄由池中连接持有,删除/移动 db 文件前
|
|
131
|
+
必须先 close(),否则报 PermissionError [WinError 32];关闭后本对象
|
|
132
|
+
不应再用于任何读写。
|
|
133
|
+
"""
|
|
134
|
+
self._engine.dispose()
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> "Store":
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc: Any) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _normalize_url(url: str) -> str:
|
|
144
|
+
"""裸路径 -> sqlite:///绝对路径;已带 scheme(xxx://)的 URL 原样放行。
|
|
145
|
+
|
|
146
|
+
"sqlite:///" + "/abs/x.db" = "sqlite:////abs/x.db"(4 斜杠 = 绝对路径)。
|
|
147
|
+
"""
|
|
148
|
+
if "://" in url:
|
|
149
|
+
return url
|
|
150
|
+
return "sqlite:///" + str(Path(url).expanduser().resolve())
|
|
151
|
+
|
|
152
|
+
# ---- session ----
|
|
153
|
+
def session(self) -> Session:
|
|
154
|
+
return Session(self._engine)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# 部署(RE)
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
def save_deployment(self, model: BpmnModel, deploy_time: str) -> str:
|
|
160
|
+
"""写部署 + 流程定义行(含原始 xml 供恢复重解析)。返回 deployment id。"""
|
|
161
|
+
deployment_id = uuid.uuid4().hex
|
|
162
|
+
with self.session() as s:
|
|
163
|
+
s.add(
|
|
164
|
+
DeploymentEntity(
|
|
165
|
+
id_=deployment_id,
|
|
166
|
+
name_=model.source_name,
|
|
167
|
+
deploy_time_=deploy_time,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
for proc in model.processes:
|
|
171
|
+
if not proc.is_executable:
|
|
172
|
+
continue
|
|
173
|
+
# 版本 = 该 key 已有最大版本 + 1(对齐 Camunda 部署新版本语义)
|
|
174
|
+
max_v = s.scalar(
|
|
175
|
+
select(ProcDefEntity.version_)
|
|
176
|
+
.where(ProcDefEntity.key_ == proc.id)
|
|
177
|
+
.order_by(ProcDefEntity.version_.desc())
|
|
178
|
+
.limit(1)
|
|
179
|
+
)
|
|
180
|
+
version = (max_v or 0) + 1
|
|
181
|
+
s.add(
|
|
182
|
+
ProcDefEntity(
|
|
183
|
+
id_=f"{proc.id}:{version}",
|
|
184
|
+
key_=proc.id,
|
|
185
|
+
name_=proc.name,
|
|
186
|
+
version_=version,
|
|
187
|
+
deployment_id_=deployment_id,
|
|
188
|
+
resource_xml_=model.source_xml or "",
|
|
189
|
+
is_executable_=True,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
s.commit()
|
|
193
|
+
return deployment_id
|
|
194
|
+
|
|
195
|
+
def load_proc_defs(self) -> List[Dict[str, Any]]:
|
|
196
|
+
"""全部流程定义行(调用方自选版本/重解析 xml)。"""
|
|
197
|
+
with self.session() as s:
|
|
198
|
+
rows = s.execute(select(ProcDefEntity)).scalars().all()
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
"key": r.key_,
|
|
202
|
+
"name": r.name_,
|
|
203
|
+
"version": r.version_,
|
|
204
|
+
"xml": r.resource_xml_,
|
|
205
|
+
}
|
|
206
|
+
for r in rows
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# 实例(RU + HI)
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
def save_proc_inst(self, snap: ProcInstSnap) -> None:
|
|
213
|
+
"""事务边界全量同步一个实例:重写 RU 活跃态 + HI 历史快照。"""
|
|
214
|
+
with self.session() as s:
|
|
215
|
+
# ---- RU:清旧写新 ----
|
|
216
|
+
self._clear_runtime(s, snap.id)
|
|
217
|
+
for ex in snap.executions:
|
|
218
|
+
s.add(
|
|
219
|
+
ExecutionEntity(
|
|
220
|
+
id_=ex.id,
|
|
221
|
+
process_instance_id_=snap.id,
|
|
222
|
+
parent_id_=ex.parent_id,
|
|
223
|
+
activity_id_=ex.activity_id,
|
|
224
|
+
role_=ex.role,
|
|
225
|
+
mi_=json.dumps(ex.mi, ensure_ascii=False) if ex.mi else None,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
for t in snap.tasks:
|
|
229
|
+
s.add(
|
|
230
|
+
TaskEntity(
|
|
231
|
+
id_=t.id,
|
|
232
|
+
name_=t.name,
|
|
233
|
+
process_instance_id_=snap.id,
|
|
234
|
+
execution_id_=t.execution_id,
|
|
235
|
+
task_definition_key_=t.task_definition_key,
|
|
236
|
+
assignee_=t.assignee,
|
|
237
|
+
create_time_=t.create_time,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
for name, value in snap.variables.items():
|
|
241
|
+
s.add(
|
|
242
|
+
VariableEntity(
|
|
243
|
+
id_=f"{snap.id}:{name}",
|
|
244
|
+
process_instance_id_=snap.id,
|
|
245
|
+
name_=name,
|
|
246
|
+
type_=java_type_name(value),
|
|
247
|
+
text_=json.dumps(value, ensure_ascii=False),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
# ---- RU:JOB(实例级作业,如停在 timerCatch / asyncBefore)----
|
|
251
|
+
for j in snap.jobs:
|
|
252
|
+
s.add(
|
|
253
|
+
JobEntity(
|
|
254
|
+
id_=j.id,
|
|
255
|
+
job_type_=j.job_type,
|
|
256
|
+
process_instance_id_=snap.id,
|
|
257
|
+
execution_id_=j.execution_id,
|
|
258
|
+
process_definition_key_=None,
|
|
259
|
+
node_id_=j.node_id,
|
|
260
|
+
duedate_=j.duedate,
|
|
261
|
+
created_=j.created,
|
|
262
|
+
retries_=j.retries,
|
|
263
|
+
repeat_=json.dumps(j.repeat, ensure_ascii=False) if j.repeat else None,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# ---- HI:PROCINST upsert ----
|
|
268
|
+
s.execute(delete(HistProcInstEntity).where(HistProcInstEntity.id_ == snap.id))
|
|
269
|
+
s.add(
|
|
270
|
+
HistProcInstEntity(
|
|
271
|
+
id_=snap.id,
|
|
272
|
+
process_definition_key_=snap.process_definition_key,
|
|
273
|
+
business_key_=snap.business_key,
|
|
274
|
+
state_=snap.state,
|
|
275
|
+
start_time_=snap.start_time,
|
|
276
|
+
end_time_=snap.end_time,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
# ---- HI:ACTINST 全量 ----
|
|
280
|
+
s.execute(
|
|
281
|
+
delete(HistActInstEntity).where(
|
|
282
|
+
HistActInstEntity.process_instance_id_ == snap.id
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
for a in snap.activity_history:
|
|
286
|
+
s.add(
|
|
287
|
+
HistActInstEntity(
|
|
288
|
+
id_=a.id,
|
|
289
|
+
process_instance_id_=snap.id,
|
|
290
|
+
activity_id_=a.activity_id,
|
|
291
|
+
activity_name_=a.activity_name,
|
|
292
|
+
execution_id_=a.execution_id,
|
|
293
|
+
start_time_=a.start_time or "",
|
|
294
|
+
end_time_=a.end_time,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
# ---- HI:TASKINST(待办无 end + 已办归档带 end_time)----
|
|
298
|
+
s.execute(
|
|
299
|
+
delete(HistTaskInstEntity).where(
|
|
300
|
+
HistTaskInstEntity.process_instance_id_ == snap.id
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
active_task_ids = {t.id for t in snap.tasks}
|
|
304
|
+
for t in snap.completed_tasks + snap.tasks:
|
|
305
|
+
s.add(
|
|
306
|
+
HistTaskInstEntity(
|
|
307
|
+
id_=t.id,
|
|
308
|
+
process_instance_id_=snap.id,
|
|
309
|
+
task_definition_key_=t.task_definition_key,
|
|
310
|
+
name_=t.name,
|
|
311
|
+
execution_id_=t.execution_id,
|
|
312
|
+
assignee_=t.assignee,
|
|
313
|
+
create_time_=t.create_time,
|
|
314
|
+
# 待办任务尚无 end_time(同一次 sync 内既在 active 又在历史,只可能刚完成时状态迁移)
|
|
315
|
+
end_time_=None if t.id in active_task_ids else t.end_time,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
# ---- HI:VARINST 实例快照 ----
|
|
319
|
+
s.execute(
|
|
320
|
+
delete(HistVarInstEntity).where(
|
|
321
|
+
HistVarInstEntity.process_instance_id_ == snap.id
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
for name, value in snap.variables.items():
|
|
325
|
+
s.add(
|
|
326
|
+
HistVarInstEntity(
|
|
327
|
+
id_=f"{snap.id}:{name}",
|
|
328
|
+
process_instance_id_=snap.id,
|
|
329
|
+
name_=name,
|
|
330
|
+
type_=java_type_name(value),
|
|
331
|
+
text_=json.dumps(value, ensure_ascii=False),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
s.commit()
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# 删除(M6:DELETE /process-instance/{id})
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
def delete_proc_inst(self, proc_inst_id: str, end_time: str) -> None:
|
|
340
|
+
"""删除运行中实例:清 RU 行,HI_PROCINST 标记 DELETED(历史保留)。
|
|
341
|
+
|
|
342
|
+
对齐 Camunda 默认语义(不传 skipHistory 时保留历史):ACTINST/TASKINST/
|
|
343
|
+
VARINST 历史行不动,仅把实例历史行置 DELETED 并写 end_time,便于
|
|
344
|
+
/history/process-instance 查到被删实例的痕迹。
|
|
345
|
+
"""
|
|
346
|
+
with self.session() as s:
|
|
347
|
+
Store._clear_runtime(s, proc_inst_id)
|
|
348
|
+
s.execute(
|
|
349
|
+
update(HistProcInstEntity)
|
|
350
|
+
.where(HistProcInstEntity.id_ == proc_inst_id)
|
|
351
|
+
.values(state_="DELETED", end_time_=end_time)
|
|
352
|
+
)
|
|
353
|
+
s.commit()
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
# 恢复读取
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
def load_active_instances(self) -> List[ProcInstSnap]:
|
|
359
|
+
"""从 RU 恢复所有运行中实例快照(按 PROC_INST_ID 聚合)。"""
|
|
360
|
+
with self.session() as s:
|
|
361
|
+
ex_rows = s.execute(select(ExecutionEntity)).scalars().all()
|
|
362
|
+
task_rows = s.execute(select(TaskEntity)).scalars().all()
|
|
363
|
+
var_rows = s.execute(select(VariableEntity)).scalars().all()
|
|
364
|
+
job_rows = s.execute(
|
|
365
|
+
select(JobEntity).where(JobEntity.process_instance_id_.is_not(None))
|
|
366
|
+
).scalars().all()
|
|
367
|
+
act_rows = s.execute(
|
|
368
|
+
select(HistActInstEntity).order_by(HistActInstEntity.start_time_)
|
|
369
|
+
).scalars().all()
|
|
370
|
+
hi_rows = s.execute(select(HistProcInstEntity)).scalars().all()
|
|
371
|
+
# 已归档任务(HI_TASKINST 带 end_time)跨重启保留:HI 表全量重写语义下,
|
|
372
|
+
# 若恢复时不带回来,重启后的下一次 save 会把历史任务抹掉。
|
|
373
|
+
hi_task_rows = s.execute(
|
|
374
|
+
select(HistTaskInstEntity).where(
|
|
375
|
+
HistTaskInstEntity.end_time_.is_not(None)
|
|
376
|
+
)
|
|
377
|
+
).scalars().all()
|
|
378
|
+
|
|
379
|
+
proc_ids = sorted({r.process_instance_id_ for r in ex_rows} | {r.process_instance_id_ for r in task_rows})
|
|
380
|
+
hi_by_id = {r.id_: r for r in hi_rows}
|
|
381
|
+
result: List[ProcInstSnap] = []
|
|
382
|
+
for pid in proc_ids:
|
|
383
|
+
hi = hi_by_id.get(pid)
|
|
384
|
+
if hi is None:
|
|
385
|
+
continue
|
|
386
|
+
snap = ProcInstSnap(
|
|
387
|
+
id=pid,
|
|
388
|
+
process_definition_key=hi.process_definition_key_,
|
|
389
|
+
business_key=hi.business_key_,
|
|
390
|
+
state=hi.state_,
|
|
391
|
+
start_time=hi.start_time_,
|
|
392
|
+
end_time=hi.end_time_,
|
|
393
|
+
)
|
|
394
|
+
snap.executions = [
|
|
395
|
+
ExecutionSnap(
|
|
396
|
+
id=r.id_,
|
|
397
|
+
parent_id=r.parent_id_,
|
|
398
|
+
activity_id=r.activity_id_,
|
|
399
|
+
role=r.role_,
|
|
400
|
+
mi=json.loads(r.mi_) if r.mi_ else None,
|
|
401
|
+
)
|
|
402
|
+
for r in ex_rows
|
|
403
|
+
if r.process_instance_id_ == pid
|
|
404
|
+
]
|
|
405
|
+
snap.tasks = [
|
|
406
|
+
TaskSnap(
|
|
407
|
+
id=r.id_,
|
|
408
|
+
name=r.name_,
|
|
409
|
+
execution_id=r.execution_id_,
|
|
410
|
+
task_definition_key=r.task_definition_key_,
|
|
411
|
+
assignee=r.assignee_,
|
|
412
|
+
create_time=r.create_time_,
|
|
413
|
+
)
|
|
414
|
+
for r in task_rows
|
|
415
|
+
if r.process_instance_id_ == pid
|
|
416
|
+
]
|
|
417
|
+
for r in var_rows:
|
|
418
|
+
if r.process_instance_id_ == pid:
|
|
419
|
+
snap.variables[r.name_] = (
|
|
420
|
+
json.loads(r.text_) if r.text_ else None
|
|
421
|
+
)
|
|
422
|
+
snap.jobs = [
|
|
423
|
+
JobSnap(
|
|
424
|
+
id=r.id_,
|
|
425
|
+
job_type=r.job_type_,
|
|
426
|
+
execution_id=r.execution_id_,
|
|
427
|
+
node_id=r.node_id_,
|
|
428
|
+
duedate=r.duedate_,
|
|
429
|
+
created=r.created_,
|
|
430
|
+
retries=r.retries_,
|
|
431
|
+
repeat=json.loads(r.repeat_) if r.repeat_ else None,
|
|
432
|
+
)
|
|
433
|
+
for r in job_rows
|
|
434
|
+
if r.process_instance_id_ == pid
|
|
435
|
+
]
|
|
436
|
+
snap.completed_tasks = [
|
|
437
|
+
TaskSnap(
|
|
438
|
+
id=r.id_,
|
|
439
|
+
name=r.name_,
|
|
440
|
+
execution_id=r.execution_id_,
|
|
441
|
+
task_definition_key=r.task_definition_key_,
|
|
442
|
+
assignee=r.assignee_,
|
|
443
|
+
create_time=r.create_time_,
|
|
444
|
+
end_time=r.end_time_,
|
|
445
|
+
)
|
|
446
|
+
for r in hi_task_rows
|
|
447
|
+
if r.process_instance_id_ == pid
|
|
448
|
+
]
|
|
449
|
+
snap.activity_history = [
|
|
450
|
+
ActivitySnap(
|
|
451
|
+
id=r.id_,
|
|
452
|
+
activity_id=r.activity_id_,
|
|
453
|
+
activity_name=r.activity_name_,
|
|
454
|
+
execution_id=r.execution_id_,
|
|
455
|
+
start_time=r.start_time_,
|
|
456
|
+
end_time=r.end_time_,
|
|
457
|
+
)
|
|
458
|
+
for r in act_rows
|
|
459
|
+
if r.process_instance_id_ == pid
|
|
460
|
+
]
|
|
461
|
+
result.append(snap)
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _clear_runtime(s: Session, proc_inst_id: str) -> None:
|
|
466
|
+
"""清掉该实例的 RU 行(全量重写前置)。"""
|
|
467
|
+
s.execute(
|
|
468
|
+
delete(ExecutionEntity).where(
|
|
469
|
+
ExecutionEntity.process_instance_id_ == proc_inst_id
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
s.execute(
|
|
473
|
+
delete(TaskEntity).where(TaskEntity.process_instance_id_ == proc_inst_id)
|
|
474
|
+
)
|
|
475
|
+
s.execute(
|
|
476
|
+
delete(VariableEntity).where(
|
|
477
|
+
VariableEntity.process_instance_id_ == proc_inst_id
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
s.execute(
|
|
481
|
+
delete(JobEntity).where(JobEntity.process_instance_id_ == proc_inst_id)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# 定义级作业(timer-start,不挂实例)
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
def save_timer_start_jobs(self, jobs: List[Job]) -> None:
|
|
488
|
+
"""全量重写定义级作业(PROC_INST_ID_ IS NULL = timer-start 组)。
|
|
489
|
+
|
|
490
|
+
部署新版本 / 恢复后引擎都会整组重算,全量重写最简单且一致。
|
|
491
|
+
"""
|
|
492
|
+
with self.session() as s:
|
|
493
|
+
s.execute(delete(JobEntity).where(JobEntity.process_instance_id_.is_(None)))
|
|
494
|
+
for j in jobs:
|
|
495
|
+
s.add(
|
|
496
|
+
JobEntity(
|
|
497
|
+
id_=j.id,
|
|
498
|
+
job_type_=j.job_type,
|
|
499
|
+
process_instance_id_=None,
|
|
500
|
+
execution_id_=None,
|
|
501
|
+
process_definition_key_=j.process_definition_key,
|
|
502
|
+
node_id_=j.node_id,
|
|
503
|
+
duedate_=j.duedate,
|
|
504
|
+
created_=j.created,
|
|
505
|
+
retries_=j.retries,
|
|
506
|
+
repeat_=json.dumps(j.repeat, ensure_ascii=False) if j.repeat else None,
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
s.commit()
|
|
510
|
+
|
|
511
|
+
def load_timer_start_jobs(self) -> List[Job]:
|
|
512
|
+
"""读回全部定义级作业(from_database 恢复 timer start 用)。"""
|
|
513
|
+
with self.session() as s:
|
|
514
|
+
rows = s.execute(
|
|
515
|
+
select(JobEntity).where(JobEntity.process_instance_id_.is_(None))
|
|
516
|
+
).scalars().all()
|
|
517
|
+
return [
|
|
518
|
+
Job(
|
|
519
|
+
id=r.id_,
|
|
520
|
+
job_type=r.job_type_,
|
|
521
|
+
duedate=r.duedate_,
|
|
522
|
+
created=r.created_,
|
|
523
|
+
process_instance_id=None,
|
|
524
|
+
process_definition_key=r.process_definition_key_,
|
|
525
|
+
node_id=r.node_id_,
|
|
526
|
+
retries=r.retries_,
|
|
527
|
+
repeat=json.loads(r.repeat_) if r.repeat_ else None,
|
|
528
|
+
)
|
|
529
|
+
for r in rows
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
# M7:多 JobExecutor 抢锁原语(CAS lease 模式,对齐 Camunda Job Acquisition
|
|
534
|
+
# Row Lock 的简化版:单条 UPDATE CAS 替代行锁)
|
|
535
|
+
# ------------------------------------------------------------------
|
|
536
|
+
def acquire_due_jobs(
|
|
537
|
+
self,
|
|
538
|
+
lock_owner: str,
|
|
539
|
+
lease_seconds: int,
|
|
540
|
+
due_before: str,
|
|
541
|
+
batch_size: int,
|
|
542
|
+
) -> List[Job]:
|
|
543
|
+
"""抢一批 due job(CAS lease)。
|
|
544
|
+
|
|
545
|
+
步骤:
|
|
546
|
+
1. SELECT 候选 ID(按 duedate 升序 + retries>0 + 未持锁/锁已过期,
|
|
547
|
+
limit = batch_size)
|
|
548
|
+
2. 逐条 UPDATE ... WHERE id=:id AND (LOCK_OWNER_ IS NULL OR
|
|
549
|
+
LOCK_EXP_TIME_ < :due_before) SET LOCK_OWNER_=:owner,
|
|
550
|
+
LOCK_EXP_TIME_=:lease_until
|
|
551
|
+
—— affected_rows > 0 即抢到
|
|
552
|
+
3. 再 SELECT WHERE LOCK_OWNER_=:owner AND LOCK_EXP_TIME_ > :due_before
|
|
553
|
+
取详情(防御:步骤 2 抢到后被另一个并发抢走的情况几乎不可能,
|
|
554
|
+
但做最终一致性确认)
|
|
555
|
+
|
|
556
|
+
返回:抢到的 Job 列表(带 lock_owner / lock_expire_time 已填充)。
|
|
557
|
+
抢不到返回 []。
|
|
558
|
+
"""
|
|
559
|
+
lease_until = format_iso(parse_iso(due_before) + timedelta(seconds=lease_seconds))
|
|
560
|
+
with self.session() as s:
|
|
561
|
+
# 1. 候选 ID(避开 SQL 方言差异:SQLite/PG 的 UPDATE LIMIT 语法不同)
|
|
562
|
+
cand_ids = [
|
|
563
|
+
r[0]
|
|
564
|
+
for r in s.execute(
|
|
565
|
+
select(JobEntity.id_)
|
|
566
|
+
.where(
|
|
567
|
+
JobEntity.duedate_ <= due_before,
|
|
568
|
+
JobEntity.retries_ > 0,
|
|
569
|
+
)
|
|
570
|
+
.where(
|
|
571
|
+
(JobEntity.lock_owner_.is_(None))
|
|
572
|
+
| (JobEntity.lock_expire_time_ < due_before)
|
|
573
|
+
)
|
|
574
|
+
.order_by(JobEntity.duedate_)
|
|
575
|
+
.limit(batch_size)
|
|
576
|
+
).all()
|
|
577
|
+
]
|
|
578
|
+
if not cand_ids:
|
|
579
|
+
return []
|
|
580
|
+
# 2. 逐条 CAS UPDATE
|
|
581
|
+
for jid in cand_ids:
|
|
582
|
+
res = s.execute(
|
|
583
|
+
update(JobEntity)
|
|
584
|
+
.where(
|
|
585
|
+
JobEntity.id_ == jid,
|
|
586
|
+
)
|
|
587
|
+
.where(
|
|
588
|
+
(JobEntity.lock_owner_.is_(None))
|
|
589
|
+
| (JobEntity.lock_expire_time_ < due_before)
|
|
590
|
+
)
|
|
591
|
+
.values(
|
|
592
|
+
lock_owner_=lock_owner,
|
|
593
|
+
lock_expire_time_=lease_until,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
if res.rowcount == 0:
|
|
597
|
+
continue # 被并发抢走
|
|
598
|
+
s.commit()
|
|
599
|
+
# 3. 取详情(owner + 未过期)
|
|
600
|
+
rows = (
|
|
601
|
+
s.execute(
|
|
602
|
+
select(JobEntity).where(
|
|
603
|
+
JobEntity.lock_owner_ == lock_owner,
|
|
604
|
+
JobEntity.lock_expire_time_ > due_before,
|
|
605
|
+
JobEntity.id_.in_(cand_ids),
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
.scalars()
|
|
609
|
+
.all()
|
|
610
|
+
)
|
|
611
|
+
return [_row_to_job(r) for r in rows]
|
|
612
|
+
|
|
613
|
+
def complete_job_cas(self, job_id: str, lock_owner: str) -> bool:
|
|
614
|
+
"""CAS 删除已成功执行的 job(防御:非 owner 不删)。
|
|
615
|
+
|
|
616
|
+
用于 timer-catch / async / async-after 的一次性作业;timer-start
|
|
617
|
+
按 repeat 续排请用 reschedule_job_cas。
|
|
618
|
+
"""
|
|
619
|
+
with self.session() as s:
|
|
620
|
+
res = s.execute(
|
|
621
|
+
delete(JobEntity).where(
|
|
622
|
+
JobEntity.id_ == job_id,
|
|
623
|
+
JobEntity.lock_owner_ == lock_owner,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
s.commit()
|
|
627
|
+
return res.rowcount > 0
|
|
628
|
+
|
|
629
|
+
def reschedule_job_cas(
|
|
630
|
+
self,
|
|
631
|
+
job_id: str,
|
|
632
|
+
lock_owner: str,
|
|
633
|
+
new_due: str,
|
|
634
|
+
new_retries: int,
|
|
635
|
+
clear_lock: bool = True,
|
|
636
|
+
) -> bool:
|
|
637
|
+
"""CAS 更新 duedate + retries(按 repeat 续排 / 失败降级顺延)。
|
|
638
|
+
|
|
639
|
+
clear_lock=True(默认):续排后清空 LOCK_OWNER_ / LOCK_EXP_TIME_,
|
|
640
|
+
让下一轮由任何 JobExecutor 抢到(对齐 Camunda:作业回到「可被获取」态)。
|
|
641
|
+
clear_lock=False:保留锁(用于同步续约场景,调用方需自己管理 lease)。
|
|
642
|
+
"""
|
|
643
|
+
values: Dict[str, Any] = {
|
|
644
|
+
"duedate_": new_due,
|
|
645
|
+
"retries_": new_retries,
|
|
646
|
+
}
|
|
647
|
+
if clear_lock:
|
|
648
|
+
values["lock_owner_"] = None
|
|
649
|
+
values["lock_expire_time_"] = None
|
|
650
|
+
with self.session() as s:
|
|
651
|
+
res = s.execute(
|
|
652
|
+
update(JobEntity)
|
|
653
|
+
.where(JobEntity.id_ == job_id, JobEntity.lock_owner_ == lock_owner)
|
|
654
|
+
.values(**values)
|
|
655
|
+
)
|
|
656
|
+
s.commit()
|
|
657
|
+
return res.rowcount > 0
|
|
658
|
+
|
|
659
|
+
def extend_lock(
|
|
660
|
+
self,
|
|
661
|
+
job_id: str,
|
|
662
|
+
lock_owner: str,
|
|
663
|
+
lease_seconds: int,
|
|
664
|
+
due_before: str,
|
|
665
|
+
) -> bool:
|
|
666
|
+
"""CAS 续约:把 lease 延后(用于长作业执行期间)。
|
|
667
|
+
|
|
668
|
+
续约失败(owner 已变更)= 锁已被别的 JobExecutor 接管,当前执行应
|
|
669
|
+
中止提交(防御:执行结果 CAS 也会失败,形成闭环保护)。
|
|
670
|
+
"""
|
|
671
|
+
new_until = format_iso(parse_iso(due_before) + timedelta(seconds=lease_seconds))
|
|
672
|
+
with self.session() as s:
|
|
673
|
+
res = s.execute(
|
|
674
|
+
update(JobEntity)
|
|
675
|
+
.where(JobEntity.id_ == job_id, JobEntity.lock_owner_ == lock_owner)
|
|
676
|
+
.values(lock_expire_time_=new_until)
|
|
677
|
+
)
|
|
678
|
+
s.commit()
|
|
679
|
+
return res.rowcount > 0
|
|
680
|
+
|
|
681
|
+
def list_locks(self, lock_owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
682
|
+
"""查看当前持锁情况(调试 + 监控用)。
|
|
683
|
+
|
|
684
|
+
lock_owner=None 返回全部带锁的作业;指定 owner 时只返回该 owner 的。
|
|
685
|
+
"""
|
|
686
|
+
with self.session() as s:
|
|
687
|
+
stmt = select(JobEntity).where(JobEntity.lock_owner_.is_not(None))
|
|
688
|
+
if lock_owner is not None:
|
|
689
|
+
stmt = stmt.where(JobEntity.lock_owner_ == lock_owner)
|
|
690
|
+
rows = s.execute(stmt.order_by(JobEntity.duedate_)).scalars().all()
|
|
691
|
+
return [
|
|
692
|
+
{
|
|
693
|
+
"id": r.id_,
|
|
694
|
+
"lock_owner": r.lock_owner_,
|
|
695
|
+
"lock_expire_time": r.lock_expire_time_,
|
|
696
|
+
"duedate": r.duedate_,
|
|
697
|
+
"job_type": r.job_type_,
|
|
698
|
+
"retries": r.retries_,
|
|
699
|
+
"process_instance_id": r.process_instance_id_,
|
|
700
|
+
"node_id": r.node_id_,
|
|
701
|
+
}
|
|
702
|
+
for r in rows
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _row_to_job(r: Any) -> Job:
|
|
707
|
+
"""JobEntity 行 -> Job 模型(统一转换器,给 acquire_due_jobs 用)。"""
|
|
708
|
+
return Job(
|
|
709
|
+
id=r.id_,
|
|
710
|
+
job_type=r.job_type_,
|
|
711
|
+
duedate=r.duedate_,
|
|
712
|
+
created=r.created_,
|
|
713
|
+
process_instance_id=r.process_instance_id_,
|
|
714
|
+
execution_id=r.execution_id_,
|
|
715
|
+
process_definition_key=r.process_definition_key_,
|
|
716
|
+
node_id=r.node_id_,
|
|
717
|
+
retries=r.retries_,
|
|
718
|
+
repeat=json.loads(r.repeat_) if r.repeat_ else None,
|
|
719
|
+
lock_owner=r.lock_owner_,
|
|
720
|
+
lock_expire_time=r.lock_expire_time_,
|
|
721
|
+
)
|