planar 0.5.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 (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import multiprocessing
3
+ import multiprocessing.connection
4
+ from multiprocessing.connection import Connection
5
+ from uuid import UUID
6
+
7
+ from planar.db import DatabaseManager, new_session
8
+ from planar.session import engine_var, get_engine, session_var
9
+ from planar.workflows.decorators import step, workflow
10
+ from planar.workflows.exceptions import LockResourceFailed
11
+ from planar.workflows.execution import (
12
+ _DEFAULT_LOCK_DURATION,
13
+ execute,
14
+ )
15
+ from planar.workflows.lock import lock_workflow
16
+ from planar.workflows.models import Workflow, WorkflowStatus
17
+
18
+ # bidirectional communication between the test process and the worker processes.
19
+ conn: Connection
20
+
21
+
22
+ @step(max_retries=0)
23
+ async def dummy_step():
24
+ conn.send("waiting")
25
+ # Wait until "proceed" is received from the queue.
26
+ if conn.recv() != "proceed":
27
+ raise Exception('Expected "proceed"')
28
+ return "success"
29
+
30
+
31
+ @workflow()
32
+ async def dummy_workflow():
33
+ # Run the dummy step and return its result.
34
+ result = await dummy_step()
35
+ return result
36
+
37
+
38
+ # copy of the resume_workflow function which allows more fine grained control from
39
+ # the test process. This is fine because our goal is to test concurrency detection
40
+ # implemented by the execute function.
41
+ async def resume_with_semaphores(workflow_id: UUID):
42
+ engine = get_engine()
43
+ async with new_session(engine) as session:
44
+ tok = session_var.set(session)
45
+ try:
46
+ async with session.begin():
47
+ workflow = await session.get(Workflow, workflow_id)
48
+ if not workflow:
49
+ raise ValueError(f"Workflow {workflow_id} not found")
50
+ conn.send("ready")
51
+ # Wait until "start" is received on stdin.
52
+ if conn.recv() != "start":
53
+ raise Exception('Expected "start"')
54
+ async with lock_workflow(
55
+ workflow,
56
+ _DEFAULT_LOCK_DURATION,
57
+ retry_count=0,
58
+ ):
59
+ await execute(workflow)
60
+ conn.send("completed")
61
+ except LockResourceFailed:
62
+ conn.send("conflict")
63
+ finally:
64
+ session_var.reset(tok)
65
+
66
+
67
+ # This worker function will be launched as a separate process.
68
+ # It takes the workflow id, db_url and a multiprocess Pipe.
69
+ def worker(wf_id: UUID, db_url: str, connection: Connection):
70
+ global conn
71
+ conn = connection
72
+ # Create a new engine for this process.
73
+ db_manager = DatabaseManager(db_url)
74
+ db_manager.connect()
75
+ engine = db_manager.get_engine()
76
+ engine_var.set(engine)
77
+ # Run the resume_with_semaphores coroutine.
78
+ # We use asyncio.run so that the worker’s event loop is independent.
79
+ asyncio.run(resume_with_semaphores(wf_id))
80
+
81
+
82
+ async def test_concurrent_workflow_execution(tmp_db_url, tmp_db_engine):
83
+ async with new_session(tmp_db_engine) as session:
84
+ session_var.set(session)
85
+ wf: Workflow = await dummy_workflow.start()
86
+ wf_id = wf.id
87
+
88
+ # Launch two separate processes that attempt to resume the workflow concurrently.
89
+ p1_parent, p1_worker = multiprocessing.Pipe(duplex=True)
90
+ p2_parent, p2_worker = multiprocessing.Pipe(duplex=True)
91
+ p1 = multiprocessing.Process(target=worker, args=(wf_id, tmp_db_url, p1_worker))
92
+ p2 = multiprocessing.Process(target=worker, args=(wf_id, tmp_db_url, p2_worker))
93
+ p1.start()
94
+ p2.start()
95
+ # wait for both workers to fetch the workflow from the database.
96
+ assert p1_parent.recv() == "ready"
97
+ assert p2_parent.recv() == "ready"
98
+ # allow worker 1 to proceed.
99
+ p1_parent.send("start")
100
+ # wait for worker 1 to start the workflow and pause in the dummy step.
101
+ assert p1_parent.recv() == "waiting"
102
+ # allow worker 2 to proceed.
103
+ p2_parent.send("start")
104
+ # worker 2 should fail and will send a "conflict" message.
105
+ assert p2_parent.recv() == "conflict"
106
+ # allow worker 1 to proceed
107
+ p1_parent.send("proceed")
108
+ # worker 1 should complete the workflow and send a "completed" message.
109
+ assert p1_parent.recv() == "completed"
110
+ # cleanup workers
111
+ p1.join()
112
+ p2.join()
113
+
114
+ await session.refresh(wf)
115
+ assert wf, f"Workflow {wf_id} not found"
116
+ # Assert that the workflow completed successfully.
117
+ assert wf.status == WorkflowStatus.SUCCEEDED, (
118
+ f"Unexpected workflow status: {wf.status}"
119
+ )
120
+ assert wf.result == "success", f"Unexpected workflow result: {wf.result}"
@@ -0,0 +1,140 @@
1
+ import asyncio
2
+ from datetime import timedelta
3
+
4
+ from planar.db import new_session
5
+ from planar.session import session_var
6
+ from planar.testing.synchronizable_tracer import SynchronizableTracer, TraceSpec
7
+ from planar.utils import utc_now
8
+ from planar.workflows.decorators import workflow
9
+ from planar.workflows.execution import execute
10
+ from planar.workflows.models import (
11
+ LockedResource,
12
+ Workflow,
13
+ WorkflowStatus,
14
+ workflow_exec_lock_key,
15
+ )
16
+ from planar.workflows.orchestrator import WorkflowOrchestrator
17
+ from planar.workflows.step_core import Suspend, suspend
18
+ from planar.workflows.tracing import tracer_var
19
+
20
+
21
+ # Define a long-running workflow.
22
+ @workflow()
23
+ async def long_running_workflow():
24
+ # Simulate a long-running operation by sleeping 1 second.
25
+ await asyncio.sleep(1)
26
+ return "finished"
27
+
28
+
29
+ async def test_lock_timer_extension(mem_db_engine):
30
+ tracer = SynchronizableTracer()
31
+ tracer_var.set(tracer)
32
+ lock_acquired = tracer.instrument(
33
+ TraceSpec(function_name="lock_resource", message="commit")
34
+ )
35
+ lock_heartbeat = tracer.instrument(
36
+ TraceSpec(function_name="lock_heartbeat", message="commit")
37
+ )
38
+
39
+ async with new_session(mem_db_engine) as session:
40
+ # This test verifies that when a workflow is executing, the heartbeat task
41
+ # (lock_heartbeat) extends the workflow's lock_until field. We run a
42
+ # long-running workflow (which sleeps for 1 second) with a short lock
43
+ # duration and heartbeat interval. While the workflow is running we query
44
+ # the stored workflow record and ensure that lock_until is updated
45
+ # (extended) by the heartbeat.
46
+
47
+ session_var.set(session)
48
+ # Start the workflow.
49
+ # Run workflow execution in the background with short durations so
50
+ # heartbeat kicks in quickly.
51
+ async with WorkflowOrchestrator.ensure_started(
52
+ lock_duration=timedelta(seconds=1)
53
+ ) as orchestrator:
54
+ wf: Workflow = await long_running_workflow.start()
55
+ wf_id = wf.id
56
+ lock_key = workflow_exec_lock_key(wf_id)
57
+
58
+ await lock_acquired.wait()
59
+
60
+ async with session.begin():
61
+ locked_resource = await session.get(LockedResource, lock_key)
62
+ assert locked_resource, "Expected a locked resource record"
63
+ lock_time_1 = locked_resource.lock_until
64
+ assert lock_time_1, "Lock time should be set"
65
+
66
+ # Wait a bit longer to allow another heartbeat cycle.
67
+ await lock_heartbeat.wait()
68
+ async with session.begin():
69
+ await session.refresh(locked_resource)
70
+ lock_time_2 = locked_resource.lock_until
71
+ assert lock_time_2, "Lock time should be set"
72
+
73
+ # The lock_time_2 should be later than lock_time_1 if the heartbeat is working.
74
+ assert lock_time_2 > lock_time_1, (
75
+ f"Expected lock_until to be extended by heartbeat: {lock_time_1} vs {lock_time_2}"
76
+ )
77
+
78
+ # Let the workflow finish.
79
+ await orchestrator.wait_for_completion(wf_id)
80
+
81
+ # Verify the workflow completed successfully.
82
+ await session.refresh(wf)
83
+ assert wf.status == WorkflowStatus.SUCCEEDED
84
+ assert wf.result == "finished"
85
+
86
+
87
+ @workflow()
88
+ async def crashed_worker_workflow():
89
+ # This workflow uses suspend() to simulate work that is paused.
90
+ # The first execution returns a Suspend object.
91
+ # When resumed it completes and returns "completed".
92
+ # First step: suspend (simulate waiting, e.g. because a worker had locked it).
93
+ await suspend(interval=timedelta(seconds=5))
94
+ # After the suspension it resumes here.
95
+ return "completed"
96
+
97
+
98
+ async def test_orchestrator_resumes_crashed_worker(mem_db_engine):
99
+ # This test simulates the scenario where a worker has “crashed” after
100
+ # locking a workflow. We start a workflow that suspends. Then we add a LockedResource
101
+ # record with an expired lock_until time to simulate a crashed
102
+
103
+ # Invoking the workflow_orchestrator (which polls for suspended workflows
104
+ # whose wakeup time is reached or that have expired locks) should cause the
105
+ # the workflow to be resumed. Finally, we verify that the workflow
106
+ # completes successfully. Start the workflow – its first execution will
107
+ # suspend.
108
+ async with new_session(mem_db_engine) as session:
109
+ session_var.set(session)
110
+ wf = await crashed_worker_workflow.start()
111
+
112
+ result = await execute(wf)
113
+ assert isinstance(result, Suspend), (
114
+ "Expected the workflow to suspend on first execution."
115
+ )
116
+ # Simulate a crashed worker by directly changing the workflow record.
117
+ await session.refresh(wf)
118
+ # Force wakeup_at and lock_until to be in the past.
119
+ past_time = utc_now() - timedelta(seconds=1)
120
+ wf.wakeup_at = past_time
121
+ session.add(LockedResource(lock_key=f"workflow:{wf.id}", lock_until=past_time))
122
+ # Ensure it is marked as running, which would not normally be picked by
123
+ # the orchestrator
124
+ await session.commit()
125
+
126
+ # Now run the orchestrator, which polls for suspended workflows with
127
+ # wakeup_at <= now.
128
+ # We use a short poll interval.
129
+ async with WorkflowOrchestrator.ensure_started(
130
+ poll_interval=0.2
131
+ ) as orchestrator:
132
+ await orchestrator.wait_for_completion(wf.id)
133
+
134
+ await session.refresh(wf)
135
+ assert wf.status == WorkflowStatus.SUCCEEDED, (
136
+ f"Expected workflow status 'success' but got {wf.status}"
137
+ )
138
+ assert wf.result == "completed", (
139
+ f"Expected workflow result 'completed' but got {wf.result}"
140
+ )