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,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
+ )