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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|