stitch-durable 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.
- stitch_durable/__init__.py +65 -0
- stitch_durable/alerts.py +15 -0
- stitch_durable/critical.py +46 -0
- stitch_durable/errors.py +76 -0
- stitch_durable/execution/__init__.py +0 -0
- stitch_durable/execution/batch_runner.py +200 -0
- stitch_durable/execution/context.py +98 -0
- stitch_durable/execution/item_processor.py +169 -0
- stitch_durable/execution/reporting.py +39 -0
- stitch_durable/execution/run_session.py +67 -0
- stitch_durable/execution/step_decorator.py +89 -0
- stitch_durable/execution/step_definition.py +15 -0
- stitch_durable/execution/step_executor.py +307 -0
- stitch_durable/providers/__init__.py +0 -0
- stitch_durable/providers/breaker_tuning.py +10 -0
- stitch_durable/providers/provider_pool.py +274 -0
- stitch_durable/py.typed +0 -0
- stitch_durable/records.py +70 -0
- stitch_durable/retry_policy.py +25 -0
- stitch_durable/serialization.py +15 -0
- stitch_durable/storage/__init__.py +0 -0
- stitch_durable/storage/audit_repository.py +65 -0
- stitch_durable/storage/breaker_repository.py +42 -0
- stitch_durable/storage/checkpoint_store.py +32 -0
- stitch_durable/storage/database.py +112 -0
- stitch_durable/storage/item_repository.py +114 -0
- stitch_durable/storage/provider_repository.py +43 -0
- stitch_durable/storage/run_repository.py +50 -0
- stitch_durable/storage/step_repository.py +123 -0
- stitch_durable/tracing.py +141 -0
- stitch_durable/workflows/__init__.py +0 -0
- stitch_durable/workflows/recovery.py +112 -0
- stitch_durable/workflows/registry.py +20 -0
- stitch_durable/workflows/workflow.py +97 -0
- stitch_durable-0.1.0.dist-info/METADATA +665 -0
- stitch_durable-0.1.0.dist-info/RECORD +39 -0
- stitch_durable-0.1.0.dist-info/WHEEL +5 -0
- stitch_durable-0.1.0.dist-info/licenses/LICENSE +21 -0
- stitch_durable-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from .alerts import Alert, Alerter
|
|
2
|
+
from .critical import CriticalPolicy, CriticalRegistry
|
|
3
|
+
from .errors import (
|
|
4
|
+
CriticalStepFailed,
|
|
5
|
+
HealthCheckFailed,
|
|
6
|
+
NoProviderAvailable,
|
|
7
|
+
Park,
|
|
8
|
+
PermanentError,
|
|
9
|
+
PipelineHalted,
|
|
10
|
+
ProviderDown,
|
|
11
|
+
ProviderError,
|
|
12
|
+
QuotaExhausted,
|
|
13
|
+
RateLimited,
|
|
14
|
+
StepFailed,
|
|
15
|
+
TransientError,
|
|
16
|
+
)
|
|
17
|
+
from .execution.context import ExecutionContext
|
|
18
|
+
from .execution.reporting import Reporter, RunStatistics
|
|
19
|
+
from .execution.step_decorator import step
|
|
20
|
+
from .providers.breaker_tuning import BreakerTuning
|
|
21
|
+
from .providers.provider_pool import ProviderPool
|
|
22
|
+
from .records import AuditRecord
|
|
23
|
+
from .retry_policy import DEFAULT_RETRY_POLICY, NO_RETRY, RetryPolicy
|
|
24
|
+
from .storage.checkpoint_store import CheckpointStore
|
|
25
|
+
from .tracing import TraceRenderer
|
|
26
|
+
from .workflows.recovery import WorkflowRecovery
|
|
27
|
+
from .workflows.registry import WorkflowRegistry
|
|
28
|
+
from .workflows.workflow import Workflow, workflow
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Alert",
|
|
34
|
+
"Alerter",
|
|
35
|
+
"CriticalPolicy",
|
|
36
|
+
"CriticalRegistry",
|
|
37
|
+
"CriticalStepFailed",
|
|
38
|
+
"HealthCheckFailed",
|
|
39
|
+
"PipelineHalted",
|
|
40
|
+
"AuditRecord",
|
|
41
|
+
"BreakerTuning",
|
|
42
|
+
"CheckpointStore",
|
|
43
|
+
"DEFAULT_RETRY_POLICY",
|
|
44
|
+
"ExecutionContext",
|
|
45
|
+
"NO_RETRY",
|
|
46
|
+
"NoProviderAvailable",
|
|
47
|
+
"Park",
|
|
48
|
+
"PermanentError",
|
|
49
|
+
"ProviderDown",
|
|
50
|
+
"ProviderError",
|
|
51
|
+
"ProviderPool",
|
|
52
|
+
"QuotaExhausted",
|
|
53
|
+
"RateLimited",
|
|
54
|
+
"Reporter",
|
|
55
|
+
"RetryPolicy",
|
|
56
|
+
"RunStatistics",
|
|
57
|
+
"StepFailed",
|
|
58
|
+
"TraceRenderer",
|
|
59
|
+
"TransientError",
|
|
60
|
+
"Workflow",
|
|
61
|
+
"WorkflowRecovery",
|
|
62
|
+
"WorkflowRegistry",
|
|
63
|
+
"step",
|
|
64
|
+
"workflow",
|
|
65
|
+
]
|
stitch_durable/alerts.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from .errors import describe_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CriticalPolicy:
|
|
11
|
+
health_check: Callable[[], Any] | None = None
|
|
12
|
+
health_check_timeout_seconds: float = 10.0
|
|
13
|
+
|
|
14
|
+
async def verify(self) -> str | None:
|
|
15
|
+
if self.health_check is None:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
async with asyncio.timeout(self.health_check_timeout_seconds):
|
|
19
|
+
outcome = await self._call_health_check()
|
|
20
|
+
except Exception as error:
|
|
21
|
+
return describe_error(error)
|
|
22
|
+
return "the health check returned False" if outcome is False else None
|
|
23
|
+
|
|
24
|
+
async def _call_health_check(self) -> Any:
|
|
25
|
+
if inspect.iscoroutinefunction(self.health_check):
|
|
26
|
+
return await self.health_check()
|
|
27
|
+
return await asyncio.to_thread(self.health_check)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CriticalRegistry:
|
|
31
|
+
_default = None
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self._policies: dict[str, CriticalPolicy] = {}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def default(cls) -> "CriticalRegistry":
|
|
38
|
+
if cls._default is None:
|
|
39
|
+
cls._default = cls()
|
|
40
|
+
return cls._default
|
|
41
|
+
|
|
42
|
+
def register(self, step_name: str, policy: CriticalPolicy) -> None:
|
|
43
|
+
self._policies[step_name] = policy
|
|
44
|
+
|
|
45
|
+
def find(self, step_name: str) -> CriticalPolicy | None:
|
|
46
|
+
return self._policies.get(step_name)
|
stitch_durable/errors.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
class TransientError(Exception):
|
|
2
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
3
|
+
super().__init__(message)
|
|
4
|
+
self.retry_after = retry_after
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PermanentError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProviderError(Exception):
|
|
12
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.retry_after = retry_after
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class QuotaExhausted(ProviderError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimited(ProviderError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderDown(ProviderError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NoProviderAvailable(Exception):
|
|
30
|
+
def __init__(self, message: str, retry_at: float | None = None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.retry_at = retry_at
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Park(Exception):
|
|
36
|
+
def __init__(self, until: float, reason: str = ""):
|
|
37
|
+
super().__init__(reason or "parked")
|
|
38
|
+
self.until = until
|
|
39
|
+
self.reason = reason
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StepFailed(Exception):
|
|
43
|
+
def __init__(self, step_name: str, cause: BaseException, transient: bool):
|
|
44
|
+
super().__init__(f"{step_name}: {describe_error(cause)}")
|
|
45
|
+
self.step_name = step_name
|
|
46
|
+
self.cause = cause
|
|
47
|
+
self.transient = transient
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PipelineHalted(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CriticalStepFailed(PipelineHalted):
|
|
55
|
+
def __init__(self, step_name: str, item_key: str, cause: BaseException):
|
|
56
|
+
super().__init__(
|
|
57
|
+
f"critical step {step_name} failed for {item_key}: {describe_error(cause)}"
|
|
58
|
+
)
|
|
59
|
+
self.step_name = step_name
|
|
60
|
+
self.item_key = item_key
|
|
61
|
+
self.cause = cause
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HealthCheckFailed(PipelineHalted):
|
|
65
|
+
def __init__(self, step_name: str, reason: str):
|
|
66
|
+
super().__init__(
|
|
67
|
+
f"the run is halted and the resource behind {step_name} is still unhealthy: {reason}"
|
|
68
|
+
)
|
|
69
|
+
self.step_name = step_name
|
|
70
|
+
self.reason = reason
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe_error(error: BaseException) -> str:
|
|
74
|
+
if isinstance(error, TimeoutError):
|
|
75
|
+
return "timed out"
|
|
76
|
+
return str(error) or type(error).__name__
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, AsyncIterable, AsyncIterator, Awaitable, Callable, Iterable
|
|
5
|
+
|
|
6
|
+
from ..alerts import Alert, Alerter
|
|
7
|
+
from ..critical import CriticalRegistry
|
|
8
|
+
from ..errors import CriticalStepFailed, HealthCheckFailed
|
|
9
|
+
from ..retry_policy import NO_RETRY, RetryPolicy
|
|
10
|
+
from ..storage.checkpoint_store import CheckpointStore
|
|
11
|
+
from .context import ExecutionContext
|
|
12
|
+
from .item_processor import ItemProcessor
|
|
13
|
+
from .reporting import Reporter, RunStatistics
|
|
14
|
+
from .run_session import RunSession
|
|
15
|
+
|
|
16
|
+
PARKED_WAKE_UP_MARGIN_SECONDS = 0.05
|
|
17
|
+
WORKER_STOP_SIGNAL = object()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class BatchOptions:
|
|
22
|
+
concurrency: int = 1
|
|
23
|
+
item_retry_policy: RetryPolicy = NO_RETRY
|
|
24
|
+
wait_for_parked: bool = False
|
|
25
|
+
maximum_parked_wait_seconds: float | None = None
|
|
26
|
+
record_audit: bool = True
|
|
27
|
+
parameters: dict | None = None
|
|
28
|
+
alerter: Alerter | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BatchRunner:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
workflow_name: str,
|
|
35
|
+
batch_label: str,
|
|
36
|
+
handler: Callable[[Any], Awaitable[Any]],
|
|
37
|
+
key_function: Callable[[Any], str],
|
|
38
|
+
store: CheckpointStore,
|
|
39
|
+
reporter: Reporter | None = None,
|
|
40
|
+
options: BatchOptions = BatchOptions(),
|
|
41
|
+
):
|
|
42
|
+
self.workflow_name = workflow_name
|
|
43
|
+
self.options = options
|
|
44
|
+
self.store = store
|
|
45
|
+
run_id = f"{workflow_name}:{batch_label}"
|
|
46
|
+
self.session = RunSession(
|
|
47
|
+
run_id, store, reporter or Reporter(), options.record_audit, options.alerter
|
|
48
|
+
)
|
|
49
|
+
self.processor = ItemProcessor(
|
|
50
|
+
self.session, handler, key_function, options.item_retry_policy
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def run(self, items: Iterable[Any] | AsyncIterable[Any]) -> RunStatistics:
|
|
54
|
+
await self._require_healthy_resource()
|
|
55
|
+
previous_status = self.store.runs.open_run(
|
|
56
|
+
self.session.run_id, self.workflow_name, self.options.parameters or {}
|
|
57
|
+
)
|
|
58
|
+
self.session.emit(
|
|
59
|
+
"run_started",
|
|
60
|
+
run_id=self.session.run_id,
|
|
61
|
+
concurrency=self.options.concurrency,
|
|
62
|
+
resume=self._summarize_previous_run(previous_status),
|
|
63
|
+
)
|
|
64
|
+
token = ExecutionContext.bind_run(self.session)
|
|
65
|
+
try:
|
|
66
|
+
await self._process_items(items)
|
|
67
|
+
if self.options.wait_for_parked:
|
|
68
|
+
await self._retry_parked_items()
|
|
69
|
+
except CriticalStepFailed as failure:
|
|
70
|
+
self._record_halt(failure)
|
|
71
|
+
raise
|
|
72
|
+
finally:
|
|
73
|
+
ExecutionContext.release_run(token)
|
|
74
|
+
return self._finish_run()
|
|
75
|
+
|
|
76
|
+
async def _require_healthy_resource(self) -> None:
|
|
77
|
+
halt = self.store.runs.get_halt(self.session.run_id)
|
|
78
|
+
if halt is None:
|
|
79
|
+
return
|
|
80
|
+
step_name = halt[0]
|
|
81
|
+
policy = CriticalRegistry.default().find(step_name)
|
|
82
|
+
problem = await policy.verify() if policy else None
|
|
83
|
+
if problem is not None:
|
|
84
|
+
self.session.emit("run_blocked", step=step_name, reason=problem)
|
|
85
|
+
raise HealthCheckFailed(step_name, problem)
|
|
86
|
+
self.session.emit("run_recovered", step=step_name)
|
|
87
|
+
title = f"{step_name} is healthy again, the run resumes"
|
|
88
|
+
self.session.send_alert(
|
|
89
|
+
Alert("recovered", title, self.session.run_id, step_name, {})
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _record_halt(self, failure: CriticalStepFailed) -> None:
|
|
93
|
+
self.store.runs.mark_halted(
|
|
94
|
+
self.session.run_id, failure.step_name, str(failure)
|
|
95
|
+
)
|
|
96
|
+
self.session.emit(
|
|
97
|
+
"run_halted",
|
|
98
|
+
step=failure.step_name,
|
|
99
|
+
item=failure.item_key,
|
|
100
|
+
error=str(failure),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def _process_items(self, items: Iterable[Any] | AsyncIterable[Any]) -> None:
|
|
104
|
+
try:
|
|
105
|
+
await self._feed_workers(items)
|
|
106
|
+
except BaseExceptionGroup as group:
|
|
107
|
+
critical, _ = group.split(CriticalStepFailed)
|
|
108
|
+
if critical is None:
|
|
109
|
+
raise
|
|
110
|
+
raise critical.exceptions[0] from None
|
|
111
|
+
|
|
112
|
+
async def _feed_workers(self, items: Iterable[Any] | AsyncIterable[Any]) -> None:
|
|
113
|
+
concurrency = self.options.concurrency
|
|
114
|
+
queue: asyncio.Queue = asyncio.Queue(maxsize=concurrency)
|
|
115
|
+
async with asyncio.TaskGroup() as workers:
|
|
116
|
+
for _ in range(concurrency):
|
|
117
|
+
workers.create_task(self._work_through(queue))
|
|
118
|
+
async for item in self._as_async_iterator(items):
|
|
119
|
+
await queue.put(item)
|
|
120
|
+
for _ in range(concurrency):
|
|
121
|
+
await queue.put(WORKER_STOP_SIGNAL)
|
|
122
|
+
|
|
123
|
+
async def _work_through(self, queue: asyncio.Queue) -> None:
|
|
124
|
+
while True:
|
|
125
|
+
item = await queue.get()
|
|
126
|
+
if item is WORKER_STOP_SIGNAL:
|
|
127
|
+
return
|
|
128
|
+
await self.processor.process(item)
|
|
129
|
+
|
|
130
|
+
async def _retry_parked_items(self) -> None:
|
|
131
|
+
deadline = self._parked_wait_deadline()
|
|
132
|
+
while True:
|
|
133
|
+
parked_items = self.store.items.list_parked(self.session.run_id)
|
|
134
|
+
if not parked_items:
|
|
135
|
+
return
|
|
136
|
+
due_at = min(parked.retry_at for parked in parked_items)
|
|
137
|
+
delay_seconds = max(0.0, due_at - time.time())
|
|
138
|
+
if deadline is not None and time.time() + delay_seconds > deadline:
|
|
139
|
+
return
|
|
140
|
+
self.session.emit(
|
|
141
|
+
"cooldown_wait",
|
|
142
|
+
count=len(parked_items),
|
|
143
|
+
delay=delay_seconds,
|
|
144
|
+
until=due_at,
|
|
145
|
+
)
|
|
146
|
+
await asyncio.sleep(delay_seconds + PARKED_WAKE_UP_MARGIN_SECONDS)
|
|
147
|
+
await self._process_due_items(parked_items)
|
|
148
|
+
|
|
149
|
+
async def _process_due_items(self, parked_items: list) -> None:
|
|
150
|
+
cutoff = time.time() + PARKED_WAKE_UP_MARGIN_SECONDS
|
|
151
|
+
due_values = [
|
|
152
|
+
parked.input_value for parked in parked_items if parked.retry_at <= cutoff
|
|
153
|
+
]
|
|
154
|
+
self.session.emit("cooldown_over", count=len(due_values))
|
|
155
|
+
await self._process_items(due_values)
|
|
156
|
+
|
|
157
|
+
def _parked_wait_deadline(self) -> float | None:
|
|
158
|
+
limit = self.options.maximum_parked_wait_seconds
|
|
159
|
+
if limit is None:
|
|
160
|
+
return None
|
|
161
|
+
return time.time() + limit
|
|
162
|
+
|
|
163
|
+
def _finish_run(self) -> RunStatistics:
|
|
164
|
+
statistics = self.session.statistics
|
|
165
|
+
statistics.parked_remaining = len(
|
|
166
|
+
self.store.items.list_parked(self.session.run_id)
|
|
167
|
+
)
|
|
168
|
+
if statistics.items_failed == 0 and statistics.parked_remaining == 0:
|
|
169
|
+
self.store.runs.finish_run(self.session.run_id)
|
|
170
|
+
statistics.elapsed_seconds = time.monotonic() - statistics.started_at
|
|
171
|
+
self.session.emit("run_finished", stats=statistics)
|
|
172
|
+
return statistics
|
|
173
|
+
|
|
174
|
+
def _summarize_previous_run(self, previous_status: str | None) -> dict | None:
|
|
175
|
+
if previous_status is None:
|
|
176
|
+
return None
|
|
177
|
+
run_id = self.session.run_id
|
|
178
|
+
statuses = [status for _, status in self.store.items.list_statuses(run_id)]
|
|
179
|
+
interrupted = [
|
|
180
|
+
(record.item_key, record.step_name)
|
|
181
|
+
for record in self.store.steps.list_for_run(run_id)
|
|
182
|
+
if record.status == "running"
|
|
183
|
+
]
|
|
184
|
+
return {
|
|
185
|
+
"previous": previous_status,
|
|
186
|
+
"done": statuses.count("done"),
|
|
187
|
+
"failed": statuses.count("failed"),
|
|
188
|
+
"parked": statuses.count("parked"),
|
|
189
|
+
"interrupted": interrupted,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async def _as_async_iterator(
|
|
193
|
+
self, items: Iterable[Any] | AsyncIterable[Any]
|
|
194
|
+
) -> AsyncIterator[Any]:
|
|
195
|
+
if isinstance(items, AsyncIterable):
|
|
196
|
+
async for item in items:
|
|
197
|
+
yield item
|
|
198
|
+
return
|
|
199
|
+
for item in items:
|
|
200
|
+
yield item
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..storage.checkpoint_store import CheckpointStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class StepIdentity:
|
|
10
|
+
item_key: str
|
|
11
|
+
step_id: str
|
|
12
|
+
sequence: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class StepContext:
|
|
17
|
+
run_id: str
|
|
18
|
+
item_key: str
|
|
19
|
+
step_name: str
|
|
20
|
+
attempt: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ItemContext:
|
|
24
|
+
def __init__(self, item_key: str):
|
|
25
|
+
self.item_key = item_key
|
|
26
|
+
self.degraded = False
|
|
27
|
+
self._sequence = 0
|
|
28
|
+
self._call_counts: dict[str, int] = {}
|
|
29
|
+
|
|
30
|
+
def register_step_call(self, step_name: str) -> StepIdentity:
|
|
31
|
+
call_count = self._call_counts.get(step_name, 0) + 1
|
|
32
|
+
self._call_counts[step_name] = call_count
|
|
33
|
+
self._sequence += 1
|
|
34
|
+
step_id = step_name if call_count == 1 else f"{step_name}#{call_count}"
|
|
35
|
+
return StepIdentity(self.item_key, step_id, self._sequence)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExecutionContext:
|
|
39
|
+
_run = contextvars.ContextVar("stitch_run", default=None)
|
|
40
|
+
_item = contextvars.ContextVar("stitch_item", default=None)
|
|
41
|
+
_step = contextvars.ContextVar("stitch_step", default=None)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def bind_run(cls, session) -> contextvars.Token:
|
|
45
|
+
return cls._run.set(session)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def release_run(cls, token: contextvars.Token) -> None:
|
|
49
|
+
cls._run.reset(token)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def bind_item(cls, item_context: ItemContext) -> contextvars.Token:
|
|
53
|
+
return cls._item.set(item_context)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def release_item(cls, token: contextvars.Token) -> None:
|
|
57
|
+
cls._item.reset(token)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def bind_step(cls, step_context: StepContext) -> contextvars.Token:
|
|
61
|
+
return cls._step.set(step_context)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def release_step(cls, token: contextvars.Token) -> None:
|
|
65
|
+
cls._step.reset(token)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def current_run(cls):
|
|
69
|
+
return cls._run.get()
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def current_item(cls) -> ItemContext | None:
|
|
73
|
+
return cls._item.get()
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def current_step(cls) -> StepContext | None:
|
|
77
|
+
return cls._step.get()
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def require_store(cls) -> CheckpointStore:
|
|
81
|
+
session = cls._run.get()
|
|
82
|
+
if session is None:
|
|
83
|
+
raise RuntimeError("this call is only valid inside a running workflow")
|
|
84
|
+
return session.store
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def report_event(cls, event: str, **data: Any) -> None:
|
|
88
|
+
session = cls._run.get()
|
|
89
|
+
if session is None:
|
|
90
|
+
return
|
|
91
|
+
item_context = cls._item.get()
|
|
92
|
+
step_context = cls._step.get()
|
|
93
|
+
if item_context is not None:
|
|
94
|
+
data.setdefault("item", item_context.item_key)
|
|
95
|
+
if step_context is not None:
|
|
96
|
+
data.setdefault("step", step_context.step_name)
|
|
97
|
+
data.setdefault("attempt", step_context.attempt)
|
|
98
|
+
session.emit(event, **data)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from ..errors import CriticalStepFailed, Park, StepFailed
|
|
7
|
+
from ..records import ItemRecord
|
|
8
|
+
from ..retry_policy import RetryPolicy
|
|
9
|
+
from ..serialization import to_json_text
|
|
10
|
+
from .context import ExecutionContext, ItemContext
|
|
11
|
+
from .run_session import RunSession
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ItemAttempt:
|
|
16
|
+
result: Any = None
|
|
17
|
+
should_retry: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ItemProcessor:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
session: RunSession,
|
|
24
|
+
handler: Callable[[Any], Awaitable[Any]],
|
|
25
|
+
key_function: Callable[[Any], str],
|
|
26
|
+
item_retry_policy: RetryPolicy,
|
|
27
|
+
):
|
|
28
|
+
self.session = session
|
|
29
|
+
self.handler = handler
|
|
30
|
+
self.key_function = key_function
|
|
31
|
+
self.item_retry_policy = item_retry_policy
|
|
32
|
+
self.store = session.store
|
|
33
|
+
self.statistics = session.statistics
|
|
34
|
+
|
|
35
|
+
async def process(self, value: Any) -> Any:
|
|
36
|
+
item_key = self.key_function(value)
|
|
37
|
+
existing = self.store.items.get(self.session.run_id, item_key)
|
|
38
|
+
if existing is not None and existing.status == "done":
|
|
39
|
+
return self._skip_finished_item(item_key, existing)
|
|
40
|
+
input_text = to_json_text(value)
|
|
41
|
+
previous_status = existing.status if existing else None
|
|
42
|
+
attempt = 0
|
|
43
|
+
while True:
|
|
44
|
+
attempt += 1
|
|
45
|
+
outcome = await self._attempt(
|
|
46
|
+
value, item_key, input_text, attempt, previous_status
|
|
47
|
+
)
|
|
48
|
+
if not outcome.should_retry:
|
|
49
|
+
return outcome.result
|
|
50
|
+
|
|
51
|
+
async def _attempt(
|
|
52
|
+
self,
|
|
53
|
+
value: Any,
|
|
54
|
+
item_key: str,
|
|
55
|
+
input_text: str | None,
|
|
56
|
+
attempt: int,
|
|
57
|
+
previous_status: str | None,
|
|
58
|
+
) -> ItemAttempt:
|
|
59
|
+
item_context = ItemContext(item_key)
|
|
60
|
+
token = ExecutionContext.bind_item(item_context)
|
|
61
|
+
self._record_item_start(item_key, input_text, attempt, previous_status)
|
|
62
|
+
started_at = time.monotonic()
|
|
63
|
+
try:
|
|
64
|
+
result = await self.handler(value)
|
|
65
|
+
except Park as error:
|
|
66
|
+
return ItemAttempt(self._park_item(item_key, input_text, error))
|
|
67
|
+
except StepFailed as error:
|
|
68
|
+
return await self._handle_step_failure(item_key, error, attempt)
|
|
69
|
+
except CriticalStepFailed:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as error:
|
|
72
|
+
return ItemAttempt(
|
|
73
|
+
self._fail_item(item_key, f"{type(error).__name__}: {error}")
|
|
74
|
+
)
|
|
75
|
+
finally:
|
|
76
|
+
ExecutionContext.release_item(token)
|
|
77
|
+
return ItemAttempt(
|
|
78
|
+
self._finish_item(
|
|
79
|
+
item_key, result, item_context, time.monotonic() - started_at
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def _handle_step_failure(
|
|
84
|
+
self, item_key: str, error: StepFailed, attempt: int
|
|
85
|
+
) -> ItemAttempt:
|
|
86
|
+
if not self._should_retry_item(error, attempt):
|
|
87
|
+
return ItemAttempt(self._fail_item(item_key, str(error)))
|
|
88
|
+
await self._wait_before_item_retry(item_key, attempt, error)
|
|
89
|
+
return ItemAttempt(should_retry=True)
|
|
90
|
+
|
|
91
|
+
def _skip_finished_item(self, item_key: str, existing: ItemRecord) -> Any:
|
|
92
|
+
self.statistics.items_skipped += 1
|
|
93
|
+
self.session.emit("item_skipped", item=item_key, degraded=existing.degraded)
|
|
94
|
+
return existing.result
|
|
95
|
+
|
|
96
|
+
def _record_item_start(
|
|
97
|
+
self,
|
|
98
|
+
item_key: str,
|
|
99
|
+
input_text: str | None,
|
|
100
|
+
attempt: int,
|
|
101
|
+
previous_status: str | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.store.items.mark_running(self.session.run_id, item_key, input_text)
|
|
104
|
+
self.session.emit(
|
|
105
|
+
"item_started",
|
|
106
|
+
item=item_key,
|
|
107
|
+
attempt=attempt,
|
|
108
|
+
resumed=previous_status is not None,
|
|
109
|
+
was=previous_status,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _should_retry_item(self, error: StepFailed, attempt: int) -> bool:
|
|
113
|
+
return error.transient and attempt < self.item_retry_policy.maximum_attempts
|
|
114
|
+
|
|
115
|
+
async def _wait_before_item_retry(
|
|
116
|
+
self, item_key: str, attempt: int, error: StepFailed
|
|
117
|
+
) -> None:
|
|
118
|
+
delay_seconds = self.item_retry_policy.delay_before_next_attempt(
|
|
119
|
+
attempt, error.cause
|
|
120
|
+
)
|
|
121
|
+
self.statistics.item_retries += 1
|
|
122
|
+
self.session.emit(
|
|
123
|
+
"item_retry",
|
|
124
|
+
item=item_key,
|
|
125
|
+
attempt=attempt,
|
|
126
|
+
attempts=self.item_retry_policy.maximum_attempts,
|
|
127
|
+
delay=delay_seconds,
|
|
128
|
+
error=str(error),
|
|
129
|
+
)
|
|
130
|
+
await asyncio.sleep(delay_seconds)
|
|
131
|
+
|
|
132
|
+
def _park_item(self, item_key: str, input_text: str | None, error: Park) -> None:
|
|
133
|
+
if input_text is None:
|
|
134
|
+
return self._fail_item(
|
|
135
|
+
item_key, f"cannot park, input is not JSON: {error.reason}"
|
|
136
|
+
)
|
|
137
|
+
self.store.items.mark_parked(
|
|
138
|
+
self.session.run_id, item_key, error.until, error.reason
|
|
139
|
+
)
|
|
140
|
+
self.statistics.items_parked += 1
|
|
141
|
+
self.session.emit(
|
|
142
|
+
"item_parked", item=item_key, until=error.until, reason=error.reason
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _fail_item(self, item_key: str, error_text: str) -> None:
|
|
146
|
+
self.store.items.mark_failed(self.session.run_id, item_key, error_text)
|
|
147
|
+
self.statistics.items_failed += 1
|
|
148
|
+
self.session.emit("item_failed", item=item_key, error=error_text)
|
|
149
|
+
|
|
150
|
+
def _finish_item(
|
|
151
|
+
self,
|
|
152
|
+
item_key: str,
|
|
153
|
+
result: Any,
|
|
154
|
+
item_context: ItemContext,
|
|
155
|
+
elapsed_seconds: float,
|
|
156
|
+
) -> Any:
|
|
157
|
+
self.store.items.mark_done(
|
|
158
|
+
self.session.run_id, item_key, to_json_text(result), item_context.degraded
|
|
159
|
+
)
|
|
160
|
+
self.statistics.items_done += 1
|
|
161
|
+
if item_context.degraded:
|
|
162
|
+
self.statistics.items_degraded += 1
|
|
163
|
+
self.session.emit(
|
|
164
|
+
"item_done",
|
|
165
|
+
item=item_key,
|
|
166
|
+
elapsed=elapsed_seconds,
|
|
167
|
+
degraded=item_context.degraded,
|
|
168
|
+
)
|
|
169
|
+
return result
|