orchestration-engine 0.9.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.
@@ -0,0 +1,94 @@
1
+ import logging
2
+ import sqlite3
3
+
4
+ from orchestration_engine.models import Task, TaskState
5
+ from orchestration_engine.transitions import transition_task
6
+
7
+ logger = logging.getLogger("orchestration_engine")
8
+
9
+
10
+ def execute_task(task: Task, conn: sqlite3.Connection, timestamp: float) -> bool:
11
+ if task.state != TaskState.READY:
12
+ return False
13
+
14
+ task.attempt_count += 1
15
+ if not transition_task(task, TaskState.RUNNING, conn, timestamp):
16
+ task.attempt_count -= 1
17
+ return False
18
+ return True
19
+
20
+
21
+ def complete_task(
22
+ task: Task,
23
+ conn: sqlite3.Connection,
24
+ timestamp: float,
25
+ task_states: dict[str, TaskState] | None = None,
26
+ ) -> bool:
27
+ if task.state != TaskState.RUNNING:
28
+ return False
29
+
30
+ return transition_task(task, TaskState.SUCCEEDED, conn, timestamp, task_states=task_states)
31
+
32
+
33
+ def fail_task(
34
+ task: Task,
35
+ current_time: float,
36
+ conn: sqlite3.Connection,
37
+ task_states: dict[str, TaskState] | None = None,
38
+ ) -> bool:
39
+ if task.state != TaskState.RUNNING:
40
+ return False
41
+
42
+ if task.attempt_count < task.retry_policy.max_attempts:
43
+ task.next_retry_at = current_time + calculate_retry_delay(task)
44
+ logger.warning(
45
+ "Task '%s' failed attempt %s/%s; retrying at %s",
46
+ task.task_id,
47
+ task.attempt_count,
48
+ task.retry_policy.max_attempts,
49
+ task.next_retry_at,
50
+ extra={
51
+ "workflow_id": task.workflow_id,
52
+ "run_id": getattr(task, "_run_id", None),
53
+ "task_id": task.task_id,
54
+ "old_state": task.state.value,
55
+ "new_state": TaskState.RETRY_WAIT.value,
56
+ "attempt": task.attempt_count,
57
+ },
58
+ )
59
+ return transition_task(
60
+ task, TaskState.RETRY_WAIT, conn, current_time, task_states=task_states
61
+ )
62
+
63
+ return transition_task(
64
+ task, TaskState.FAILED, conn, current_time, task_states=task_states
65
+ )
66
+
67
+
68
+ def retry_task(
69
+ task: Task,
70
+ current_time: float,
71
+ conn: sqlite3.Connection,
72
+ task_states: dict[str, TaskState] | None = None,
73
+ ) -> bool:
74
+ if task.state != TaskState.RETRY_WAIT:
75
+ return False
76
+
77
+ if task.next_retry_at is None:
78
+ return False
79
+
80
+ if current_time < task.next_retry_at:
81
+ return False
82
+
83
+ return transition_task(task, TaskState.READY, conn, current_time, task_states=task_states)
84
+
85
+
86
+ def calculate_retry_delay(task: Task) -> float:
87
+ """
88
+ Calculate retry delay using exponential backoff:
89
+ delay = delay_seconds * (2 ** (attempt_count - 1))
90
+ """
91
+ base_delay = task.retry_policy.delay_seconds
92
+ attempt_count = task.attempt_count
93
+
94
+ return float(base_delay * (2 ** (attempt_count - 1)))
@@ -0,0 +1,325 @@
1
+ import json
2
+ import os
3
+ import sqlite3
4
+ from collections.abc import Callable
5
+ from typing import Any, Literal
6
+
7
+ from orchestration_engine.errors import OrchestrationError, WorkflowDefinitionError
8
+ from orchestration_engine.models import (
9
+ Hooks,
10
+ RetryPolicy,
11
+ Task,
12
+ TaskResult,
13
+ TaskState,
14
+ Workflow,
15
+ WorkflowStatus,
16
+ )
17
+ from orchestration_engine.runner import run_workflow
18
+ from orchestration_engine.status import get_workflow_status
19
+
20
+ MAX_WORKFLOW_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
21
+
22
+
23
+ def load_workflow_from_json(
24
+ json_data: dict[str, Any],
25
+ *,
26
+ collect_errors: bool = False,
27
+ ) -> Workflow:
28
+ """
29
+ Parse a workflow definition dict (e.g. loaded from JSON) into a Workflow.
30
+
31
+ Does not run the workflow or touch the database. Raises WorkflowDefinitionError
32
+ if the workflow or any task is missing a required field or has invalid types.
33
+ When collect_errors=True, all errors are accumulated and raised as a single
34
+ WorkflowDefinitionError with an .errors list containing JSON-pointer paths.
35
+ """
36
+ if collect_errors:
37
+ errors: list[str] = []
38
+ if "workflow_id" not in json_data:
39
+ errors.append("/workflow_id: missing required field")
40
+ elif not isinstance(json_data["workflow_id"], str) or not json_data["workflow_id"]:
41
+ errors.append("/workflow_id: must be a non-empty string")
42
+
43
+ if "name" not in json_data:
44
+ errors.append("/name: missing required field")
45
+ elif not isinstance(json_data["name"], str):
46
+ errors.append("/name: must be a string")
47
+
48
+ if "description" not in json_data:
49
+ errors.append("/description: missing required field")
50
+ elif not isinstance(json_data["description"], str):
51
+ errors.append("/description: must be a string")
52
+
53
+ raw_default_to = json_data.get("default_timeout_seconds")
54
+ if raw_default_to is not None:
55
+ if (
56
+ isinstance(raw_default_to, bool)
57
+ or not isinstance(raw_default_to, (int, float))
58
+ or raw_default_to < 0
59
+ ):
60
+ errors.append(
61
+ "/default_timeout_seconds: must be a non-negative number or null"
62
+ )
63
+
64
+ if "tasks" not in json_data:
65
+ errors.append("/tasks: missing required field")
66
+ elif not isinstance(json_data["tasks"], list):
67
+ errors.append("/tasks: must be a list")
68
+ else:
69
+ for index, task_data in enumerate(json_data["tasks"]):
70
+ ptr = f"/tasks/{index}"
71
+ if not isinstance(task_data, dict):
72
+ errors.append(f"{ptr}: must be an object")
73
+ continue
74
+
75
+ if "task_id" not in task_data:
76
+ errors.append(f"{ptr}/task_id: missing required field")
77
+ elif not isinstance(task_data["task_id"], str) or not task_data["task_id"]:
78
+ errors.append(f"{ptr}/task_id: must be a non-empty string")
79
+
80
+ if "name" not in task_data:
81
+ errors.append(f"{ptr}/name: missing required field")
82
+ elif not isinstance(task_data["name"], str):
83
+ errors.append(f"{ptr}/name: must be a string")
84
+
85
+ if "description" not in task_data:
86
+ errors.append(f"{ptr}/description: missing required field")
87
+ elif not isinstance(task_data["description"], str):
88
+ errors.append(f"{ptr}/description: must be a string")
89
+
90
+ if "dependencies" not in task_data:
91
+ errors.append(f"{ptr}/dependencies: missing required field")
92
+ elif not isinstance(task_data["dependencies"], list):
93
+ errors.append(f"{ptr}/dependencies: must be a list")
94
+ else:
95
+ for d_i, dep in enumerate(task_data["dependencies"]):
96
+ if not isinstance(dep, str):
97
+ errors.append(f"{ptr}/dependencies/{d_i}: must be a string")
98
+
99
+ if "retry_policy" not in task_data:
100
+ errors.append(f"{ptr}/retry_policy: missing required field")
101
+ elif not isinstance(task_data["retry_policy"], dict):
102
+ errors.append(f"{ptr}/retry_policy: must be an object")
103
+ else:
104
+ rp = task_data["retry_policy"]
105
+ if "max_attempts" not in rp:
106
+ errors.append(f"{ptr}/retry_policy/max_attempts: missing required field")
107
+ elif (
108
+ isinstance(rp["max_attempts"], bool)
109
+ or not isinstance(rp["max_attempts"], int)
110
+ or rp["max_attempts"] < 1
111
+ ):
112
+ errors.append(f"{ptr}/retry_policy/max_attempts: must be an integer >= 1")
113
+
114
+ if "delay_seconds" not in rp:
115
+ errors.append(f"{ptr}/retry_policy/delay_seconds: missing required field")
116
+ elif (
117
+ isinstance(rp["delay_seconds"], bool)
118
+ or not isinstance(rp["delay_seconds"], (int, float))
119
+ or rp["delay_seconds"] < 0
120
+ ):
121
+ errors.append(
122
+ f"{ptr}/retry_policy/delay_seconds: must be a non-negative number"
123
+ )
124
+
125
+ if "timeout_seconds" in task_data:
126
+ raw_to = task_data["timeout_seconds"]
127
+ if raw_to is not None and (
128
+ isinstance(raw_to, bool)
129
+ or not isinstance(raw_to, (int, float))
130
+ or raw_to < 0
131
+ ):
132
+ errors.append(
133
+ f"{ptr}/timeout_seconds: must be a non-negative number or null"
134
+ )
135
+
136
+ if errors:
137
+ raise WorkflowDefinitionError(
138
+ f"Workflow definition has {len(errors)} error(s): {'; '.join(errors)}",
139
+ errors=errors,
140
+ )
141
+
142
+ try:
143
+ workflow_id = json_data["workflow_id"]
144
+ name = json_data["name"]
145
+ description = json_data["description"]
146
+ except KeyError as e:
147
+ raise WorkflowDefinitionError(f"workflow missing required field: {e}") from e
148
+
149
+ default_timeout = json_data.get("default_timeout_seconds")
150
+ if default_timeout is not None:
151
+ if (
152
+ isinstance(default_timeout, bool)
153
+ or not isinstance(default_timeout, (int, float))
154
+ or default_timeout < 0
155
+ ):
156
+ raise WorkflowDefinitionError(
157
+ "workflow: default_timeout_seconds must be a non-negative number, "
158
+ f"got {default_timeout!r}"
159
+ )
160
+ default_timeout = float(default_timeout)
161
+
162
+ tasks: list[Task] = []
163
+ for index, task_data in enumerate(json_data["tasks"]):
164
+ task_id = task_data.get("task_id", f"index {index}")
165
+ try:
166
+ policy_data = task_data["retry_policy"]
167
+ max_attempts = policy_data["max_attempts"]
168
+ if (
169
+ isinstance(max_attempts, bool)
170
+ or not isinstance(max_attempts, int)
171
+ or max_attempts < 1
172
+ ):
173
+ raise WorkflowDefinitionError(
174
+ f"task {task_id}: retry_policy.max_attempts must be an integer >= 1, "
175
+ f"got {max_attempts!r}"
176
+ )
177
+
178
+ delay_seconds = policy_data["delay_seconds"]
179
+ if (
180
+ isinstance(delay_seconds, bool)
181
+ or not isinstance(delay_seconds, (int, float))
182
+ or delay_seconds < 0
183
+ ):
184
+ raise WorkflowDefinitionError(
185
+ f"task {task_id}: retry_policy.delay_seconds must be a non-negative number, "
186
+ f"got {delay_seconds!r}"
187
+ )
188
+
189
+ retry_policy = RetryPolicy(
190
+ max_attempts=max_attempts,
191
+ delay_seconds=float(delay_seconds),
192
+ )
193
+
194
+ if "timeout_seconds" in task_data:
195
+ raw_timeout = task_data["timeout_seconds"]
196
+ if raw_timeout is not None:
197
+ if (
198
+ isinstance(raw_timeout, bool)
199
+ or not isinstance(raw_timeout, (int, float))
200
+ or raw_timeout < 0
201
+ ):
202
+ raise WorkflowDefinitionError(
203
+ f"task {task_id}: timeout_seconds must be a non-negative number "
204
+ f"or null, got {raw_timeout!r}"
205
+ )
206
+ timeout_seconds = float(raw_timeout)
207
+ else:
208
+ timeout_seconds = None
209
+ elif default_timeout is not None:
210
+ timeout_seconds = default_timeout
211
+ else:
212
+ timeout_seconds = 30.0
213
+
214
+ task = Task(
215
+ task_id=task_data["task_id"],
216
+ workflow_id=workflow_id,
217
+ name=task_data["name"],
218
+ description=task_data["description"],
219
+ dependencies=task_data["dependencies"],
220
+ state=TaskState.PENDING,
221
+ retry_policy=retry_policy,
222
+ timeout_seconds=timeout_seconds,
223
+ )
224
+ except KeyError as e:
225
+ raise WorkflowDefinitionError(
226
+ f"task {index} missing required field: {e}") from e
227
+ tasks.append(task)
228
+
229
+ return Workflow(
230
+ workflow_id=workflow_id,
231
+ name=name,
232
+ description=description,
233
+ tasks=tasks,
234
+ default_timeout_seconds=default_timeout,
235
+ )
236
+
237
+
238
+ ExecutorType = Literal["thread", "process", "_inline"]
239
+
240
+
241
+ def run_workflow_from_file(
242
+ path: str,
243
+ task_runner: Callable[[Task], bool | TaskResult],
244
+ time_source: Callable[[], float],
245
+ conn: sqlite3.Connection,
246
+ *,
247
+ run_id: str | None = None,
248
+ max_concurrency: int = 1,
249
+ executor: ExecutorType = "thread",
250
+ hooks: Hooks | None = None,
251
+ ) -> WorkflowStatus:
252
+ """
253
+ Load a workflow definition from a JSON file and run it to completion.
254
+
255
+ Equivalent to load_workflow_from_json(...) followed by run_workflow(...),
256
+ then returning get_workflow_status(...) on the resulting Workflow. See
257
+ run_workflow for task_runner's contract and retry/timeout behavior.
258
+ """
259
+ if os.path.exists(path) and os.path.getsize(path) > MAX_WORKFLOW_FILE_SIZE:
260
+ raise WorkflowDefinitionError(
261
+ f"Workflow file '{path}' exceeds maximum allowed size of {MAX_WORKFLOW_FILE_SIZE} bytes"
262
+ )
263
+
264
+ with open(path) as f:
265
+ json_data = json.load(f)
266
+
267
+ workflow = load_workflow_from_json(json_data)
268
+ task_states: dict[str, TaskState] = {}
269
+ run_workflow(
270
+ workflow,
271
+ task_states,
272
+ time_source,
273
+ task_runner,
274
+ conn,
275
+ run_id=run_id,
276
+ max_concurrency=max_concurrency,
277
+ executor=executor,
278
+ hooks=hooks,
279
+ )
280
+
281
+ return get_workflow_status(workflow)
282
+
283
+
284
+ def load_workflow_from_yaml(
285
+ source: str | os.PathLike[str] | Any,
286
+ *,
287
+ collect_errors: bool = False,
288
+ ) -> Workflow:
289
+ """
290
+ Parse a YAML workflow definition (file path, YAML string, or stream) into a Workflow.
291
+
292
+ Requires the optional 'yaml' extra (PyYAML). Raises OrchestrationError if PyYAML
293
+ is not installed.
294
+ """
295
+ try:
296
+ import yaml
297
+ except ImportError as e:
298
+ raise OrchestrationError(
299
+ "PyYAML is required to load workflows from YAML. "
300
+ "Install it with: pip install 'orchestration-engine[yaml]'"
301
+ ) from e
302
+
303
+ data: Any
304
+ if isinstance(source, (str, os.PathLike)):
305
+ try:
306
+ if os.path.exists(source) and os.path.getsize(source) > MAX_WORKFLOW_FILE_SIZE:
307
+ raise WorkflowDefinitionError(
308
+ f"Workflow file '{source}' exceeds maximum allowed size "
309
+ f"of {MAX_WORKFLOW_FILE_SIZE} bytes"
310
+ )
311
+ with open(source) as f:
312
+ data = yaml.safe_load(f)
313
+ except (OSError, FileNotFoundError):
314
+ if isinstance(source, str) and ("\n" in source or ":" in source):
315
+ data = yaml.safe_load(source)
316
+ else:
317
+ raise
318
+ else:
319
+ data = yaml.safe_load(source)
320
+
321
+ if not isinstance(data, dict):
322
+ raise WorkflowDefinitionError("YAML workflow definition must be a mapping/object")
323
+
324
+ return load_workflow_from_json(data, collect_errors=collect_errors)
325
+
@@ -0,0 +1,115 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+
7
+ class TaskState(StrEnum):
8
+ """Lifecycle states a single Task can be in."""
9
+ PENDING = "pending"
10
+ READY = "ready"
11
+ RUNNING = "running"
12
+ RETRY_WAIT = "retry_wait"
13
+ SUCCEEDED = "succeeded"
14
+ FAILED = "failed"
15
+ CANCELLED = "cancelled"
16
+
17
+
18
+ @dataclass
19
+ class RetryPolicy:
20
+ """
21
+ Retry configuration for a single Task.
22
+
23
+ Attributes:
24
+ max_attempts: Total number of attempts allowed, including the first.
25
+ delay_seconds: Base delay before retries, in seconds.
26
+ """
27
+ max_attempts: int
28
+ delay_seconds: float
29
+
30
+
31
+ @dataclass
32
+ class Task:
33
+ """
34
+ A single unit of work within a Workflow.
35
+
36
+ Attributes:
37
+ task_id: Unique identifier within the workflow.
38
+ workflow_id: ID of the Workflow this task belongs to.
39
+ name: Human-readable name.
40
+ description: Human-readable description.
41
+ dependencies: task_ids that must reach TaskState.SUCCEEDED before this task becomes ready.
42
+ state: Current lifecycle state. Mutated in place as the workflow runs.
43
+ retry_policy: Retry behavior for this task.
44
+ attempt_count: Number of attempts made so far.
45
+ next_retry_at: Timestamp after which a retry may run, if in RETRY_WAIT.
46
+ timeout_seconds: Seconds allowed before the task is considered timed out and failed.
47
+ Defaults to 30.0 (None disables timeout).
48
+ last_error: Error message from the last failed attempt, if any.
49
+ """
50
+ task_id: str
51
+ workflow_id: str
52
+ name: str
53
+ description: str
54
+ dependencies: list[str]
55
+ state: TaskState
56
+ retry_policy: RetryPolicy
57
+ attempt_count: int = 0
58
+ next_retry_at: float | None = None
59
+ timeout_seconds: float | None = 30.0
60
+ last_error: str | None = None
61
+ _run_id: str | None = None
62
+ inputs: dict[str, Any] = field(default_factory=dict)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TaskResult:
67
+ """
68
+ Result returned by a task runner callable.
69
+
70
+ Attributes:
71
+ ok: Whether the task succeeded.
72
+ output: Output data produced by the task (must be JSON-serializable).
73
+ error: Optional error message if the task failed.
74
+ """
75
+ ok: bool
76
+ output: Any = None
77
+ error: str | None = None
78
+
79
+
80
+ @dataclass
81
+ class Workflow:
82
+ """
83
+ A named collection of Tasks with dependency relationships between them.
84
+
85
+ Attributes:
86
+ workflow_id: Unique identifier for this workflow.
87
+ name: Human-readable name.
88
+ description: Human-readable description.
89
+ tasks: The tasks that make up this workflow.
90
+ default_timeout_seconds: Optional default timeout in seconds for tasks in this workflow.
91
+ """
92
+ workflow_id: str
93
+ name: str
94
+ description: str
95
+ tasks: list[Task]
96
+ default_timeout_seconds: float | None = None
97
+
98
+
99
+ class WorkflowStatus(StrEnum):
100
+ """Overall status of a Workflow, derived from its tasks' states."""
101
+ RUNNING = "running"
102
+ SUCCEEDED = "succeeded"
103
+ FAILED = "failed"
104
+
105
+
106
+ @dataclass
107
+ class Hooks:
108
+ """
109
+ Lifecycle callbacks invoked after corresponding persistence steps.
110
+ """
111
+ on_task_start: Callable[[Task], None] | None = None
112
+ on_task_succeeded: Callable[[Task], None] | None = None
113
+ on_task_failed: Callable[[Task], None] | None = None
114
+ on_task_retry: Callable[[Task], None] | None = None
115
+ on_workflow_done: Callable[[Workflow, WorkflowStatus], None] | None = None