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.
Files changed (39) hide show
  1. stitch_durable/__init__.py +65 -0
  2. stitch_durable/alerts.py +15 -0
  3. stitch_durable/critical.py +46 -0
  4. stitch_durable/errors.py +76 -0
  5. stitch_durable/execution/__init__.py +0 -0
  6. stitch_durable/execution/batch_runner.py +200 -0
  7. stitch_durable/execution/context.py +98 -0
  8. stitch_durable/execution/item_processor.py +169 -0
  9. stitch_durable/execution/reporting.py +39 -0
  10. stitch_durable/execution/run_session.py +67 -0
  11. stitch_durable/execution/step_decorator.py +89 -0
  12. stitch_durable/execution/step_definition.py +15 -0
  13. stitch_durable/execution/step_executor.py +307 -0
  14. stitch_durable/providers/__init__.py +0 -0
  15. stitch_durable/providers/breaker_tuning.py +10 -0
  16. stitch_durable/providers/provider_pool.py +274 -0
  17. stitch_durable/py.typed +0 -0
  18. stitch_durable/records.py +70 -0
  19. stitch_durable/retry_policy.py +25 -0
  20. stitch_durable/serialization.py +15 -0
  21. stitch_durable/storage/__init__.py +0 -0
  22. stitch_durable/storage/audit_repository.py +65 -0
  23. stitch_durable/storage/breaker_repository.py +42 -0
  24. stitch_durable/storage/checkpoint_store.py +32 -0
  25. stitch_durable/storage/database.py +112 -0
  26. stitch_durable/storage/item_repository.py +114 -0
  27. stitch_durable/storage/provider_repository.py +43 -0
  28. stitch_durable/storage/run_repository.py +50 -0
  29. stitch_durable/storage/step_repository.py +123 -0
  30. stitch_durable/tracing.py +141 -0
  31. stitch_durable/workflows/__init__.py +0 -0
  32. stitch_durable/workflows/recovery.py +112 -0
  33. stitch_durable/workflows/registry.py +20 -0
  34. stitch_durable/workflows/workflow.py +97 -0
  35. stitch_durable-0.1.0.dist-info/METADATA +665 -0
  36. stitch_durable-0.1.0.dist-info/RECORD +39 -0
  37. stitch_durable-0.1.0.dist-info/WHEEL +5 -0
  38. stitch_durable-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. 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
+ ]
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Alert:
6
+ severity: str
7
+ title: str
8
+ run_id: str
9
+ step_name: str
10
+ detail: dict
11
+
12
+
13
+ class Alerter:
14
+ def send(self, alert: Alert) -> None:
15
+ return
@@ -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)
@@ -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