aws-durable-execution-sdk-python 1.0.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.
- aws_durable_execution_sdk_python/.gitignore +0 -0
- aws_durable_execution_sdk_python/__about__.py +4 -0
- aws_durable_execution_sdk_python/__init__.py +34 -0
- aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
- aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
- aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
- aws_durable_execution_sdk_python/concurrency/models.py +469 -0
- aws_durable_execution_sdk_python/config.py +499 -0
- aws_durable_execution_sdk_python/context.py +551 -0
- aws_durable_execution_sdk_python/exceptions.py +374 -0
- aws_durable_execution_sdk_python/execution.py +428 -0
- aws_durable_execution_sdk_python/identifier.py +14 -0
- aws_durable_execution_sdk_python/lambda_service.py +1034 -0
- aws_durable_execution_sdk_python/logger.py +131 -0
- aws_durable_execution_sdk_python/operation/__init__.py +1 -0
- aws_durable_execution_sdk_python/operation/callback.py +123 -0
- aws_durable_execution_sdk_python/operation/child.py +162 -0
- aws_durable_execution_sdk_python/operation/invoke.py +119 -0
- aws_durable_execution_sdk_python/operation/map.py +137 -0
- aws_durable_execution_sdk_python/operation/parallel.py +122 -0
- aws_durable_execution_sdk_python/operation/step.py +269 -0
- aws_durable_execution_sdk_python/operation/wait.py +53 -0
- aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
- aws_durable_execution_sdk_python/py.typed +1 -0
- aws_durable_execution_sdk_python/retries.py +174 -0
- aws_durable_execution_sdk_python/serdes.py +502 -0
- aws_durable_execution_sdk_python/state.py +790 -0
- aws_durable_execution_sdk_python/suspend.py +84 -0
- aws_durable_execution_sdk_python/threading.py +222 -0
- aws_durable_execution_sdk_python/types.py +180 -0
- aws_durable_execution_sdk_python/waits.py +130 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Concurrent executor for parallel and map operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from aws_durable_execution_sdk_python.exceptions import (
|
|
14
|
+
InvalidStateError,
|
|
15
|
+
SuspendExecution,
|
|
16
|
+
)
|
|
17
|
+
from aws_durable_execution_sdk_python.lambda_service import ErrorObject
|
|
18
|
+
from aws_durable_execution_sdk_python.types import BatchResult as BatchResultProtocol
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from concurrent.futures import Future
|
|
22
|
+
|
|
23
|
+
from aws_durable_execution_sdk_python.config import CompletionConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
R = TypeVar("R")
|
|
30
|
+
|
|
31
|
+
CallableType = TypeVar("CallableType")
|
|
32
|
+
ResultType = TypeVar("ResultType")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# region Result models
|
|
36
|
+
class BatchItemStatus(Enum):
|
|
37
|
+
SUCCEEDED = "SUCCEEDED"
|
|
38
|
+
FAILED = "FAILED"
|
|
39
|
+
STARTED = "STARTED"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CompletionReason(Enum):
|
|
43
|
+
ALL_COMPLETED = "ALL_COMPLETED"
|
|
44
|
+
MIN_SUCCESSFUL_REACHED = "MIN_SUCCESSFUL_REACHED"
|
|
45
|
+
FAILURE_TOLERANCE_EXCEEDED = "FAILURE_TOLERANCE_EXCEEDED"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SuspendResult:
|
|
50
|
+
should_suspend: bool
|
|
51
|
+
exception: SuspendExecution | None = None
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def do_not_suspend() -> SuspendResult:
|
|
55
|
+
return SuspendResult(should_suspend=False)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def suspend(exception: SuspendExecution) -> SuspendResult:
|
|
59
|
+
return SuspendResult(should_suspend=True, exception=exception)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class BatchItem(Generic[R]):
|
|
64
|
+
index: int
|
|
65
|
+
status: BatchItemStatus
|
|
66
|
+
result: R | None = None
|
|
67
|
+
error: ErrorObject | None = None
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
return {
|
|
71
|
+
"index": self.index,
|
|
72
|
+
"status": self.status.value,
|
|
73
|
+
"result": self.result,
|
|
74
|
+
"error": self.error.to_dict() if self.error else None,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> BatchItem[R]:
|
|
79
|
+
return cls(
|
|
80
|
+
index=data["index"],
|
|
81
|
+
status=BatchItemStatus(data["status"]),
|
|
82
|
+
result=data.get("result"),
|
|
83
|
+
error=ErrorObject.from_dict(data["error"]) if data.get("error") else None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class BatchResult(Generic[R], BatchResultProtocol[R]): # noqa: PYI059
|
|
89
|
+
all: list[BatchItem[R]]
|
|
90
|
+
completion_reason: CompletionReason
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(
|
|
94
|
+
cls, data: dict, completion_config: CompletionConfig | None = None
|
|
95
|
+
) -> BatchResult[R]:
|
|
96
|
+
batch_items: list[BatchItem[R]] = [
|
|
97
|
+
BatchItem.from_dict(item) for item in data["all"]
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
completion_reason_value = data.get("completionReason")
|
|
101
|
+
if completion_reason_value is None:
|
|
102
|
+
# Infer completion reason from batch item statuses and completion config
|
|
103
|
+
# This aligns with the TypeScript implementation that uses completion config
|
|
104
|
+
# to accurately reconstruct the completion reason during replay
|
|
105
|
+
result = cls.from_items(batch_items, completion_config)
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Missing completionReason in BatchResult deserialization, "
|
|
108
|
+
"inferred '%s' from batch item statuses. "
|
|
109
|
+
"This may indicate incomplete serialization data.",
|
|
110
|
+
result.completion_reason.value,
|
|
111
|
+
)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
completion_reason = CompletionReason(completion_reason_value)
|
|
115
|
+
return cls(batch_items, completion_reason)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_items(
|
|
119
|
+
cls,
|
|
120
|
+
items: list[BatchItem[R]],
|
|
121
|
+
completion_config: CompletionConfig | None = None,
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Infer completion reason based on batch item statuses and completion config.
|
|
125
|
+
|
|
126
|
+
This follows the same logic as the TypeScript implementation:
|
|
127
|
+
- If all items completed: ALL_COMPLETED
|
|
128
|
+
- If minSuccessful threshold met and not all completed: MIN_SUCCESSFUL_REACHED
|
|
129
|
+
- Otherwise: FAILURE_TOLERANCE_EXCEEDED
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
statuses = (item.status for item in items)
|
|
133
|
+
counts = Counter(statuses)
|
|
134
|
+
succeeded_count = counts.get(BatchItemStatus.SUCCEEDED, 0)
|
|
135
|
+
failed_count = counts.get(BatchItemStatus.FAILED, 0)
|
|
136
|
+
started_count = counts.get(BatchItemStatus.STARTED, 0)
|
|
137
|
+
|
|
138
|
+
completed_count = succeeded_count + failed_count
|
|
139
|
+
total_count = started_count + completed_count
|
|
140
|
+
|
|
141
|
+
# If all items completed (no started items), it's ALL_COMPLETED
|
|
142
|
+
if completed_count == total_count:
|
|
143
|
+
completion_reason = CompletionReason.ALL_COMPLETED
|
|
144
|
+
elif ( # If we have completion config and minSuccessful threshold is met
|
|
145
|
+
completion_config
|
|
146
|
+
and (min_successful := completion_config.min_successful) is not None
|
|
147
|
+
and succeeded_count >= min_successful
|
|
148
|
+
):
|
|
149
|
+
completion_reason = CompletionReason.MIN_SUCCESSFUL_REACHED
|
|
150
|
+
else:
|
|
151
|
+
# Otherwise, assume failure tolerance was exceeded
|
|
152
|
+
completion_reason = CompletionReason.FAILURE_TOLERANCE_EXCEEDED
|
|
153
|
+
|
|
154
|
+
return cls(items, completion_reason)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict:
|
|
157
|
+
return {
|
|
158
|
+
"all": [item.to_dict() for item in self.all],
|
|
159
|
+
"completionReason": self.completion_reason.value,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def succeeded(self) -> list[BatchItem[R]]:
|
|
163
|
+
return [
|
|
164
|
+
item
|
|
165
|
+
for item in self.all
|
|
166
|
+
if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def failed(self) -> list[BatchItem[R]]:
|
|
170
|
+
return [
|
|
171
|
+
item
|
|
172
|
+
for item in self.all
|
|
173
|
+
if item.status is BatchItemStatus.FAILED and item.error is not None
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def started(self) -> list[BatchItem[R]]:
|
|
177
|
+
return [item for item in self.all if item.status is BatchItemStatus.STARTED]
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def status(self) -> BatchItemStatus:
|
|
181
|
+
return BatchItemStatus.FAILED if self.has_failure else BatchItemStatus.SUCCEEDED
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def has_failure(self) -> bool:
|
|
185
|
+
return any(item.status is BatchItemStatus.FAILED for item in self.all)
|
|
186
|
+
|
|
187
|
+
def throw_if_error(self) -> None:
|
|
188
|
+
first_error = next(
|
|
189
|
+
(item.error for item in self.all if item.status is BatchItemStatus.FAILED),
|
|
190
|
+
None,
|
|
191
|
+
)
|
|
192
|
+
if first_error:
|
|
193
|
+
raise first_error.to_callable_runtime_error()
|
|
194
|
+
|
|
195
|
+
def get_results(self) -> list[R]:
|
|
196
|
+
return [
|
|
197
|
+
item.result
|
|
198
|
+
for item in self.all
|
|
199
|
+
if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
def get_errors(self) -> list[ErrorObject]:
|
|
203
|
+
return [
|
|
204
|
+
item.error
|
|
205
|
+
for item in self.all
|
|
206
|
+
if item.status is BatchItemStatus.FAILED and item.error is not None
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def success_count(self) -> int:
|
|
211
|
+
return sum(1 for item in self.all if item.status is BatchItemStatus.SUCCEEDED)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def failure_count(self) -> int:
|
|
215
|
+
return sum(1 for item in self.all if item.status is BatchItemStatus.FAILED)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def started_count(self) -> int:
|
|
219
|
+
return sum(1 for item in self.all if item.status is BatchItemStatus.STARTED)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def total_count(self) -> int:
|
|
223
|
+
return len(self.all)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# endregion Result models
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# region concurrency models
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class Executable(Generic[CallableType]):
|
|
232
|
+
index: int
|
|
233
|
+
func: CallableType
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class BranchStatus(Enum):
|
|
237
|
+
PENDING = "pending"
|
|
238
|
+
RUNNING = "running"
|
|
239
|
+
COMPLETED = "completed"
|
|
240
|
+
SUSPENDED = "suspended"
|
|
241
|
+
SUSPENDED_WITH_TIMEOUT = "suspended_with_timeout"
|
|
242
|
+
FAILED = "failed"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ExecutableWithState(Generic[CallableType, ResultType]):
|
|
246
|
+
"""Manages the execution state and lifecycle of an executable."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, executable: Executable[CallableType]):
|
|
249
|
+
self.executable = executable
|
|
250
|
+
self._status = BranchStatus.PENDING
|
|
251
|
+
self._future: Future | None = None
|
|
252
|
+
self._suspend_until: float | None = None
|
|
253
|
+
self._result: ResultType = None # type: ignore[assignment]
|
|
254
|
+
self._is_result_set: bool = False
|
|
255
|
+
self._error: Exception | None = None
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def future(self) -> Future:
|
|
259
|
+
"""Get the future, raising error if not available."""
|
|
260
|
+
if self._future is None:
|
|
261
|
+
msg = f"ExecutableWithState was never started. {self.executable.index}"
|
|
262
|
+
raise InvalidStateError(msg)
|
|
263
|
+
return self._future
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def status(self) -> BranchStatus:
|
|
267
|
+
"""Get current status."""
|
|
268
|
+
return self._status
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def result(self) -> ResultType:
|
|
272
|
+
"""Get result if completed."""
|
|
273
|
+
if not self._is_result_set or self._status != BranchStatus.COMPLETED:
|
|
274
|
+
msg = f"result not available in status {self._status}"
|
|
275
|
+
raise InvalidStateError(msg)
|
|
276
|
+
return self._result
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def error(self) -> Exception:
|
|
280
|
+
"""Get error if failed."""
|
|
281
|
+
if self._error is None or self._status != BranchStatus.FAILED:
|
|
282
|
+
msg = f"error not available in status {self._status}"
|
|
283
|
+
raise InvalidStateError(msg)
|
|
284
|
+
return self._error
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def suspend_until(self) -> float | None:
|
|
288
|
+
"""Get suspend timestamp."""
|
|
289
|
+
return self._suspend_until
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def is_running(self) -> bool:
|
|
293
|
+
"""Check if currently running."""
|
|
294
|
+
return self._status is BranchStatus.RUNNING
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def can_resume(self) -> bool:
|
|
298
|
+
"""Check if can resume from suspension."""
|
|
299
|
+
return self._status is BranchStatus.SUSPENDED or (
|
|
300
|
+
self._status is BranchStatus.SUSPENDED_WITH_TIMEOUT
|
|
301
|
+
and self._suspend_until is not None
|
|
302
|
+
and time.time() >= self._suspend_until
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def index(self) -> int:
|
|
307
|
+
return self.executable.index
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def callable(self) -> CallableType:
|
|
311
|
+
return self.executable.func
|
|
312
|
+
|
|
313
|
+
# region State transitions
|
|
314
|
+
def run(self, future: Future) -> None:
|
|
315
|
+
"""Transition to RUNNING state with a future."""
|
|
316
|
+
if self._status != BranchStatus.PENDING:
|
|
317
|
+
msg = f"Cannot start running from {self._status}"
|
|
318
|
+
raise InvalidStateError(msg)
|
|
319
|
+
self._status = BranchStatus.RUNNING
|
|
320
|
+
self._future = future
|
|
321
|
+
|
|
322
|
+
def suspend(self) -> None:
|
|
323
|
+
"""Transition to SUSPENDED state (indefinite)."""
|
|
324
|
+
self._status = BranchStatus.SUSPENDED
|
|
325
|
+
self._suspend_until = None
|
|
326
|
+
|
|
327
|
+
def suspend_with_timeout(self, timestamp: float) -> None:
|
|
328
|
+
"""Transition to SUSPENDED_WITH_TIMEOUT state."""
|
|
329
|
+
self._status = BranchStatus.SUSPENDED_WITH_TIMEOUT
|
|
330
|
+
self._suspend_until = timestamp
|
|
331
|
+
|
|
332
|
+
def complete(self, result: ResultType) -> None:
|
|
333
|
+
"""Transition to COMPLETED state."""
|
|
334
|
+
self._status = BranchStatus.COMPLETED
|
|
335
|
+
self._result = result
|
|
336
|
+
self._is_result_set = True
|
|
337
|
+
|
|
338
|
+
def fail(self, error: Exception) -> None:
|
|
339
|
+
"""Transition to FAILED state."""
|
|
340
|
+
self._status = BranchStatus.FAILED
|
|
341
|
+
self._error = error
|
|
342
|
+
|
|
343
|
+
def reset_to_pending(self) -> None:
|
|
344
|
+
"""Reset to PENDING state for resubmission."""
|
|
345
|
+
self._status = BranchStatus.PENDING
|
|
346
|
+
self._future = None
|
|
347
|
+
self._suspend_until = None
|
|
348
|
+
|
|
349
|
+
# endregion State transitions
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ExecutionCounters:
|
|
353
|
+
"""Thread-safe counters for tracking execution state."""
|
|
354
|
+
|
|
355
|
+
def __init__(
|
|
356
|
+
self,
|
|
357
|
+
total_tasks: int,
|
|
358
|
+
min_successful: int,
|
|
359
|
+
tolerated_failure_count: int | None,
|
|
360
|
+
tolerated_failure_percentage: float | None,
|
|
361
|
+
):
|
|
362
|
+
self.total_tasks: int = total_tasks
|
|
363
|
+
self.min_successful: int = min_successful
|
|
364
|
+
self.tolerated_failure_count: int | None = tolerated_failure_count
|
|
365
|
+
self.tolerated_failure_percentage: float | None = tolerated_failure_percentage
|
|
366
|
+
self.success_count: int = 0
|
|
367
|
+
self.failure_count: int = 0
|
|
368
|
+
self._lock = threading.Lock()
|
|
369
|
+
|
|
370
|
+
def complete_task(self) -> None:
|
|
371
|
+
"""Task completed successfully."""
|
|
372
|
+
with self._lock:
|
|
373
|
+
self.success_count += 1
|
|
374
|
+
|
|
375
|
+
def fail_task(self) -> None:
|
|
376
|
+
"""Task failed."""
|
|
377
|
+
with self._lock:
|
|
378
|
+
self.failure_count += 1
|
|
379
|
+
|
|
380
|
+
def should_continue(self) -> bool:
|
|
381
|
+
"""
|
|
382
|
+
Check if we should continue starting new tasks (based on failure tolerance).
|
|
383
|
+
Matches TypeScript shouldContinue() logic.
|
|
384
|
+
"""
|
|
385
|
+
with self._lock:
|
|
386
|
+
# If no completion config, only continue if no failures
|
|
387
|
+
if (
|
|
388
|
+
self.tolerated_failure_count is None
|
|
389
|
+
and self.tolerated_failure_percentage is None
|
|
390
|
+
):
|
|
391
|
+
return self.failure_count == 0
|
|
392
|
+
|
|
393
|
+
# Check failure count tolerance
|
|
394
|
+
if (
|
|
395
|
+
self.tolerated_failure_count is not None
|
|
396
|
+
and self.failure_count > self.tolerated_failure_count
|
|
397
|
+
):
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
# Check failure percentage tolerance
|
|
401
|
+
if self.tolerated_failure_percentage is not None and self.total_tasks > 0:
|
|
402
|
+
failure_percentage = (self.failure_count / self.total_tasks) * 100
|
|
403
|
+
if failure_percentage > self.tolerated_failure_percentage:
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
def is_complete(self) -> bool:
|
|
409
|
+
"""
|
|
410
|
+
Check if execution should complete (based on completion criteria).
|
|
411
|
+
Matches TypeScript isComplete() logic.
|
|
412
|
+
"""
|
|
413
|
+
with self._lock:
|
|
414
|
+
completed_count = self.success_count + self.failure_count
|
|
415
|
+
|
|
416
|
+
# All tasks completed
|
|
417
|
+
if completed_count == self.total_tasks:
|
|
418
|
+
return True
|
|
419
|
+
|
|
420
|
+
# when we breach min successful, we've completed
|
|
421
|
+
return self.success_count >= self.min_successful
|
|
422
|
+
|
|
423
|
+
def should_complete(self) -> bool:
|
|
424
|
+
"""
|
|
425
|
+
Check if execution should complete.
|
|
426
|
+
Combines TypeScript shouldContinue() and isComplete() logic.
|
|
427
|
+
"""
|
|
428
|
+
return self.is_complete() or not self.should_continue()
|
|
429
|
+
|
|
430
|
+
def is_all_completed(self) -> bool:
|
|
431
|
+
"""True if all tasks completed successfully."""
|
|
432
|
+
with self._lock:
|
|
433
|
+
return self.success_count == self.total_tasks
|
|
434
|
+
|
|
435
|
+
def is_min_successful_reached(self) -> bool:
|
|
436
|
+
"""True if minimum successful tasks reached."""
|
|
437
|
+
with self._lock:
|
|
438
|
+
return self.success_count >= self.min_successful
|
|
439
|
+
|
|
440
|
+
def is_failure_tolerance_exceeded(self) -> bool:
|
|
441
|
+
"""True if failure tolerance was exceeded."""
|
|
442
|
+
with self._lock:
|
|
443
|
+
return self._is_failure_condition_reached(
|
|
444
|
+
tolerated_count=self.tolerated_failure_count,
|
|
445
|
+
tolerated_percentage=self.tolerated_failure_percentage,
|
|
446
|
+
failure_count=self.failure_count,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _is_failure_condition_reached(
|
|
450
|
+
self,
|
|
451
|
+
tolerated_count: int | None,
|
|
452
|
+
tolerated_percentage: float | None,
|
|
453
|
+
failure_count: int,
|
|
454
|
+
) -> bool:
|
|
455
|
+
"""True if failure conditions are reached (no locking - caller must lock)."""
|
|
456
|
+
# Failure count condition
|
|
457
|
+
if tolerated_count is not None and failure_count > tolerated_count:
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
# Failure percentage condition
|
|
461
|
+
if tolerated_percentage is not None and self.total_tasks > 0:
|
|
462
|
+
failure_percentage = (failure_count / self.total_tasks) * 100
|
|
463
|
+
if failure_percentage > tolerated_percentage:
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# endegion concurrency models
|