ergon-framework-python 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.
- ergon/__init__.py +13 -0
- ergon/bootstrap/src/__project__/__init__.py +0 -0
- ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
- ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
- ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
- ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
- ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
- ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
- ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- ergon/bootstrap/src/__project__/main.py +9 -0
- ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
- ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
- ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
- ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
- ergon/cli.py +174 -0
- ergon/connector/__init__.py +64 -0
- ergon/connector/connector.py +97 -0
- ergon/connector/excel/__init__.py +18 -0
- ergon/connector/excel/connector.py +175 -0
- ergon/connector/excel/models.py +24 -0
- ergon/connector/excel/service.py +98 -0
- ergon/connector/pipefy/__init__.py +21 -0
- ergon/connector/pipefy/async_connector.py +48 -0
- ergon/connector/pipefy/async_service.py +907 -0
- ergon/connector/pipefy/connector.py +36 -0
- ergon/connector/pipefy/models.py +48 -0
- ergon/connector/pipefy/service.py +1016 -0
- ergon/connector/pipefy/version.py +1 -0
- ergon/connector/postgres/__init__.py +11 -0
- ergon/connector/postgres/async_connector.py +119 -0
- ergon/connector/postgres/async_service.py +116 -0
- ergon/connector/postgres/models.py +34 -0
- ergon/connector/rabbitmq/__init__.py +25 -0
- ergon/connector/rabbitmq/async_connector.py +120 -0
- ergon/connector/rabbitmq/async_service.py +417 -0
- ergon/connector/rabbitmq/connector.py +54 -0
- ergon/connector/rabbitmq/helper.py +14 -0
- ergon/connector/rabbitmq/models.py +92 -0
- ergon/connector/rabbitmq/service.py +199 -0
- ergon/connector/sqs/__init__.py +15 -0
- ergon/connector/sqs/async_connector.py +120 -0
- ergon/connector/sqs/async_service.py +246 -0
- ergon/connector/sqs/connector.py +120 -0
- ergon/connector/sqs/models.py +36 -0
- ergon/connector/sqs/service.py +219 -0
- ergon/connector/transaction.py +14 -0
- ergon/py.typed +0 -0
- ergon/service/__init__.py +5 -0
- ergon/service/service.py +17 -0
- ergon/task/__init__.py +13 -0
- ergon/task/base.py +222 -0
- ergon/task/exceptions.py +217 -0
- ergon/task/helpers.py +691 -0
- ergon/task/manager.py +85 -0
- ergon/task/mixins/__init__.py +13 -0
- ergon/task/mixins/consumer.py +858 -0
- ergon/task/mixins/metrics.py +457 -0
- ergon/task/mixins/producer.py +486 -0
- ergon/task/policies.py +229 -0
- ergon/task/runner.py +386 -0
- ergon/task/utils.py +64 -0
- ergon/telemetry/__init__.py +7 -0
- ergon/telemetry/_resource.py +13 -0
- ergon/telemetry/logging.py +370 -0
- ergon/telemetry/metrics.py +101 -0
- ergon/telemetry/tracing.py +152 -0
- ergon/utils/__init__.py +5 -0
- ergon/utils/env.py +26 -0
- ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
- ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
- ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
- ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
- ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
ergon/task/helpers.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# helper.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from concurrent import futures
|
|
7
|
+
from typing import Any, Callable, Iterable, Optional, overload
|
|
8
|
+
|
|
9
|
+
from opentelemetry import context
|
|
10
|
+
|
|
11
|
+
from .. import telemetry
|
|
12
|
+
from . import exceptions, policies, utils
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
tracer = telemetry.tracing.get_tracer(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ============================================================
|
|
19
|
+
# SYNC CONTEXT WRAPPER
|
|
20
|
+
# ============================================================
|
|
21
|
+
def with_context(
|
|
22
|
+
*args,
|
|
23
|
+
fn: Callable,
|
|
24
|
+
ctx: context.Context | None = None,
|
|
25
|
+
trace_name: str | None = None,
|
|
26
|
+
trace_attrs: dict = {},
|
|
27
|
+
**kwargs,
|
|
28
|
+
):
|
|
29
|
+
"""Sync OTEL context attach/detach wrapper."""
|
|
30
|
+
|
|
31
|
+
if ctx is None:
|
|
32
|
+
ctx = context.get_current()
|
|
33
|
+
|
|
34
|
+
token = context.attach(ctx)
|
|
35
|
+
try:
|
|
36
|
+
with tracer.start_as_current_span(trace_name or "", attributes=trace_attrs):
|
|
37
|
+
return fn(*args, **kwargs)
|
|
38
|
+
finally:
|
|
39
|
+
context.detach(token)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_current_context():
|
|
43
|
+
return context.get_current()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============================================================
|
|
47
|
+
# RUN WITH CONTEXT (SYNC) WRAPPER
|
|
48
|
+
# ============================================================
|
|
49
|
+
def run_with_context(
|
|
50
|
+
*args,
|
|
51
|
+
fn: Callable,
|
|
52
|
+
ctx: context.Context | None = None,
|
|
53
|
+
trace_name: str | None = None,
|
|
54
|
+
trace_attrs: dict = {},
|
|
55
|
+
**kwargs,
|
|
56
|
+
):
|
|
57
|
+
if ctx is None:
|
|
58
|
+
ctx = context.get_current()
|
|
59
|
+
|
|
60
|
+
return with_context(*args, fn=fn, ctx=ctx, trace_name=trace_name, trace_attrs=trace_attrs, **kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ============================================================
|
|
64
|
+
# RUN FN (SYNC EXECUTION FACTORY) - Works as function AND decorator
|
|
65
|
+
# ============================================================
|
|
66
|
+
@overload
|
|
67
|
+
def run_fn(
|
|
68
|
+
*args: Any,
|
|
69
|
+
fn: None = None,
|
|
70
|
+
retry: Optional[policies.RetryPolicy] = ...,
|
|
71
|
+
ctx: Optional[context.Context] = ...,
|
|
72
|
+
trace_name: Optional[str] = ...,
|
|
73
|
+
trace_attrs: dict = ...,
|
|
74
|
+
executor: Optional[futures.Executor] = ...,
|
|
75
|
+
**kwargs: Any,
|
|
76
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def run_fn(
|
|
81
|
+
*args: Any,
|
|
82
|
+
fn: Callable[..., Any],
|
|
83
|
+
retry: Optional[policies.RetryPolicy] = ...,
|
|
84
|
+
ctx: Optional[context.Context] = ...,
|
|
85
|
+
trace_name: Optional[str] = ...,
|
|
86
|
+
trace_attrs: dict = ...,
|
|
87
|
+
executor: None = ...,
|
|
88
|
+
**kwargs: Any,
|
|
89
|
+
) -> tuple[bool, Any]: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@overload
|
|
93
|
+
def run_fn(
|
|
94
|
+
*args: Any,
|
|
95
|
+
fn: Callable[..., Any],
|
|
96
|
+
retry: Optional[policies.RetryPolicy] = ...,
|
|
97
|
+
ctx: Optional[context.Context] = ...,
|
|
98
|
+
trace_name: Optional[str] = ...,
|
|
99
|
+
trace_attrs: dict = ...,
|
|
100
|
+
executor: futures.Executor = ...,
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> futures.Future[tuple[bool, Any]]: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_fn(
|
|
106
|
+
*args: Any,
|
|
107
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
108
|
+
retry: Optional[policies.RetryPolicy] = None,
|
|
109
|
+
ctx: Optional[context.Context] = None,
|
|
110
|
+
trace_name: Optional[str] = None,
|
|
111
|
+
trace_attrs: dict[str, Any] = {},
|
|
112
|
+
executor: Optional[futures.Executor] = None,
|
|
113
|
+
**kwargs: Any,
|
|
114
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]] | tuple[bool, Any] | futures.Future[tuple[bool, Any]]:
|
|
115
|
+
"""
|
|
116
|
+
Execute a callable inside Ergon's execution envelope.
|
|
117
|
+
|
|
118
|
+
This function is a *foundational execution primitive* and provides a
|
|
119
|
+
consistent, observable, policy-driven way to run synchronous code with:
|
|
120
|
+
|
|
121
|
+
- OpenTelemetry context propagation
|
|
122
|
+
- Structured tracing
|
|
123
|
+
- Retry semantics
|
|
124
|
+
- Optional per-attempt timeouts
|
|
125
|
+
|
|
126
|
+
It can be used either as a normal function or as a decorator.
|
|
127
|
+
|
|
128
|
+
--------------------------------------------------------------------
|
|
129
|
+
EXECUTION MODES
|
|
130
|
+
--------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
1) DIRECT EXECUTION (executor is None)
|
|
133
|
+
|
|
134
|
+
The callable is executed *immediately* in the current thread.
|
|
135
|
+
The function returns a tuple:
|
|
136
|
+
|
|
137
|
+
(success: bool, result: Any | Exception)
|
|
138
|
+
|
|
139
|
+
This mode is used when:
|
|
140
|
+
- You are already inside an execution engine (e.g. Consumer loop)
|
|
141
|
+
- You want deterministic, inline execution
|
|
142
|
+
- You need explicit control over error handling
|
|
143
|
+
- You are composing higher-level execution semantics
|
|
144
|
+
|
|
145
|
+
This is the most common mode inside the framework itself.
|
|
146
|
+
|
|
147
|
+
2) SUBMITTED EXECUTION (executor is provided)
|
|
148
|
+
|
|
149
|
+
The callable is submitted to the provided Executor and executed
|
|
150
|
+
asynchronously. In this case, `run_fn` returns a `Future` whose
|
|
151
|
+
result will be:
|
|
152
|
+
|
|
153
|
+
(success: bool, result: Any | Exception)
|
|
154
|
+
|
|
155
|
+
This mode is used when:
|
|
156
|
+
- You are *orchestrating concurrency*, not business logic
|
|
157
|
+
- You want to parallelize independent executions
|
|
158
|
+
- You are building a runner, scheduler, or dispatcher
|
|
159
|
+
- You explicitly want execution to escape the current thread
|
|
160
|
+
|
|
161
|
+
The executor defines *where* execution happens, but **not how**.
|
|
162
|
+
All retries, timeouts, tracing, and context propagation still occur
|
|
163
|
+
inside the execution envelope.
|
|
164
|
+
|
|
165
|
+
--------------------------------------------------------------------
|
|
166
|
+
DECORATOR MODE
|
|
167
|
+
--------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
When used as a decorator:
|
|
170
|
+
|
|
171
|
+
@run_fn(retry=...)
|
|
172
|
+
def my_function(...):
|
|
173
|
+
|
|
174
|
+
The decorator is syntactic sugar over direct execution:
|
|
175
|
+
|
|
176
|
+
- The wrapped function is executed inline
|
|
177
|
+
- Failures raise exceptions instead of returning (success, result)
|
|
178
|
+
- `executor` is intentionally ignored
|
|
179
|
+
|
|
180
|
+
Decorator mode is intended for:
|
|
181
|
+
- Service methods
|
|
182
|
+
- Task internals
|
|
183
|
+
- Domain-level logic
|
|
184
|
+
|
|
185
|
+
It must NOT be used to introduce concurrency.
|
|
186
|
+
|
|
187
|
+
--------------------------------------------------------------------
|
|
188
|
+
IMPORTANT INVARIANTS
|
|
189
|
+
--------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
- `run_fn` never decides *whether* retries or timeouts apply
|
|
192
|
+
— policies define behavior, not control flow.
|
|
193
|
+
|
|
194
|
+
- Passing an executor controls *where* execution happens,
|
|
195
|
+
never *how* it behaves.
|
|
196
|
+
|
|
197
|
+
- The execution envelope (context, tracing, retries, timeouts)
|
|
198
|
+
is identical in all modes.
|
|
199
|
+
|
|
200
|
+
In short:
|
|
201
|
+
- Use `executor` at orchestration boundaries
|
|
202
|
+
- Do NOT use `executor` inside domain or business logic
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# ============================================================
|
|
206
|
+
# DECORATOR MODE
|
|
207
|
+
# ============================================================
|
|
208
|
+
if fn is None:
|
|
209
|
+
|
|
210
|
+
def decorator(func: Callable):
|
|
211
|
+
def wrapper(*wrapper_args, **wrapper_kwargs):
|
|
212
|
+
success, result = run_fn(
|
|
213
|
+
*wrapper_args,
|
|
214
|
+
fn=func,
|
|
215
|
+
retry=retry,
|
|
216
|
+
ctx=ctx,
|
|
217
|
+
trace_name=trace_name or func.__qualname__,
|
|
218
|
+
trace_attrs=trace_attrs,
|
|
219
|
+
executor=None, # decorators always execute inline
|
|
220
|
+
**wrapper_kwargs,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if not success:
|
|
224
|
+
if isinstance(result, BaseException):
|
|
225
|
+
raise result
|
|
226
|
+
raise RuntimeError(f"Function {func.__qualname__} failed: {result}")
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
return wrapper
|
|
231
|
+
|
|
232
|
+
return decorator
|
|
233
|
+
|
|
234
|
+
# ============================================================
|
|
235
|
+
# EXECUTION MODE
|
|
236
|
+
# ============================================================
|
|
237
|
+
ctx = ctx or context.get_current()
|
|
238
|
+
trace_name = trace_name or fn.__qualname__
|
|
239
|
+
retry = retry or policies.RetryPolicy(max_attempts=1)
|
|
240
|
+
|
|
241
|
+
def attempt(attempt_no: int):
|
|
242
|
+
return run_with_context(
|
|
243
|
+
*args,
|
|
244
|
+
fn=fn,
|
|
245
|
+
ctx=ctx,
|
|
246
|
+
trace_name=f"{trace_name}.attempt.{attempt_no}",
|
|
247
|
+
trace_attrs={**trace_attrs, "attempt": attempt_no},
|
|
248
|
+
**kwargs,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def run():
|
|
252
|
+
last_exc = None
|
|
253
|
+
|
|
254
|
+
for attempt_no in range(1, retry.max_attempts + 1):
|
|
255
|
+
logger.debug(f"Attempt {attempt_no} to run function {fn.__qualname__} started")
|
|
256
|
+
try:
|
|
257
|
+
if retry.timeout:
|
|
258
|
+
with futures.ThreadPoolExecutor(max_workers=1) as ex:
|
|
259
|
+
future = ex.submit(attempt, attempt_no)
|
|
260
|
+
result = future.result(timeout=retry.timeout)
|
|
261
|
+
logger.debug(
|
|
262
|
+
f"Attempt {attempt_no} to run function {fn.__qualname__} completed with outcome: 'ok'"
|
|
263
|
+
)
|
|
264
|
+
return True, result
|
|
265
|
+
else:
|
|
266
|
+
result = attempt(attempt_no)
|
|
267
|
+
logger.debug(f"Attempt {attempt_no} to run function {fn.__qualname__} completed with outcome: 'ok'")
|
|
268
|
+
return True, result
|
|
269
|
+
|
|
270
|
+
except exceptions.NonRetryableException as e:
|
|
271
|
+
return False, e
|
|
272
|
+
except futures.TimeoutError as e:
|
|
273
|
+
logger.exception(
|
|
274
|
+
"Attempt %d to run function %s failed with timeout: %r",
|
|
275
|
+
attempt_no,
|
|
276
|
+
fn.__qualname__,
|
|
277
|
+
e,
|
|
278
|
+
)
|
|
279
|
+
last_exc = e
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.exception(
|
|
282
|
+
"Attempt %d to run function %s failed with exception: %r",
|
|
283
|
+
attempt_no,
|
|
284
|
+
fn.__qualname__,
|
|
285
|
+
e,
|
|
286
|
+
)
|
|
287
|
+
last_exc = e
|
|
288
|
+
|
|
289
|
+
if attempt_no < retry.max_attempts:
|
|
290
|
+
logger.warning(
|
|
291
|
+
"Attempt %d to run function %s failed with exception: %r. Calling backoff.",
|
|
292
|
+
attempt_no,
|
|
293
|
+
fn.__qualname__,
|
|
294
|
+
last_exc,
|
|
295
|
+
)
|
|
296
|
+
utils.backoff(retry.backoff, retry.backoff_multiplier, retry.backoff_cap, attempt_no - 1)
|
|
297
|
+
|
|
298
|
+
logger.warning(
|
|
299
|
+
"Attempt %d to run function %s failed with exception: %r",
|
|
300
|
+
retry.max_attempts,
|
|
301
|
+
fn.__qualname__,
|
|
302
|
+
last_exc,
|
|
303
|
+
)
|
|
304
|
+
return False, last_exc
|
|
305
|
+
|
|
306
|
+
if executor:
|
|
307
|
+
return executor.submit(run)
|
|
308
|
+
|
|
309
|
+
return run()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def multithread_execute(
|
|
313
|
+
submissions: Iterable[Callable[[], futures.Future]],
|
|
314
|
+
*,
|
|
315
|
+
concurrency: int,
|
|
316
|
+
limit: Optional[int] = None,
|
|
317
|
+
timeout: Optional[float] = None,
|
|
318
|
+
) -> int:
|
|
319
|
+
"""
|
|
320
|
+
Execute submitted callables with bounded concurrency and refill.
|
|
321
|
+
|
|
322
|
+
- submissions yields callables that RETURN a Future when called
|
|
323
|
+
- this function manages in-flight futures only
|
|
324
|
+
- no domain semantics, no retries, no tracing
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Number of completed submissions
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
in_flight: dict[futures.Future, float] = {}
|
|
331
|
+
completed = 0
|
|
332
|
+
|
|
333
|
+
submit_iter = iter(submissions)
|
|
334
|
+
|
|
335
|
+
logger.debug(f"Multithread execute called with concurrency={concurrency}, limit={limit}, timeout={timeout}")
|
|
336
|
+
|
|
337
|
+
# ============================================================
|
|
338
|
+
# INITIAL FILL
|
|
339
|
+
# ============================================================
|
|
340
|
+
while len(in_flight) < concurrency:
|
|
341
|
+
if limit is not None and completed + len(in_flight) >= limit:
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
submit = next(submit_iter)
|
|
346
|
+
except StopIteration:
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
fut = submit()
|
|
351
|
+
in_flight[fut] = time.perf_counter()
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f"Submission failed before execution: {e}")
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
# ============================================================
|
|
357
|
+
# MAIN LOOP
|
|
358
|
+
# ============================================================
|
|
359
|
+
while in_flight:
|
|
360
|
+
now = time.perf_counter()
|
|
361
|
+
|
|
362
|
+
# Wait briefly for *any* completion
|
|
363
|
+
done, _ = futures.wait(
|
|
364
|
+
in_flight.keys(),
|
|
365
|
+
return_when=futures.FIRST_COMPLETED,
|
|
366
|
+
timeout=timeout,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ----------------------------
|
|
370
|
+
# HANDLE COMPLETED FUTURES
|
|
371
|
+
# ----------------------------
|
|
372
|
+
for fut in done:
|
|
373
|
+
start = in_flight.pop(fut, None)
|
|
374
|
+
try:
|
|
375
|
+
fut.result()
|
|
376
|
+
logger.debug("Execution completed")
|
|
377
|
+
except futures.TimeoutError:
|
|
378
|
+
logger.error("Execution timeout (future)")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Execution error: {e}")
|
|
381
|
+
finally:
|
|
382
|
+
completed += 1
|
|
383
|
+
|
|
384
|
+
# ----------------------------
|
|
385
|
+
# EVICT TIMED-OUT FUTURES
|
|
386
|
+
# ----------------------------
|
|
387
|
+
if timeout is not None:
|
|
388
|
+
for fut, start in list(in_flight.items()):
|
|
389
|
+
if now - start > timeout:
|
|
390
|
+
logger.error("Execution timeout (scheduler eviction)")
|
|
391
|
+
in_flight.pop(fut)
|
|
392
|
+
completed += 1
|
|
393
|
+
|
|
394
|
+
# ============================================================
|
|
395
|
+
# REFILL
|
|
396
|
+
# ============================================================
|
|
397
|
+
while len(in_flight) < concurrency:
|
|
398
|
+
if limit is not None and completed + len(in_flight) >= limit:
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
submit = next(submit_iter)
|
|
403
|
+
except StopIteration:
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
fut = submit()
|
|
408
|
+
in_flight[fut] = time.perf_counter()
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"Submission failed: {e}")
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
logger.debug(f"Multithread execute finished with {completed} completed submissions")
|
|
414
|
+
return completed
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ============================================================
|
|
418
|
+
# ASYNC CONTEXT WRAPPER
|
|
419
|
+
# ============================================================
|
|
420
|
+
async def with_context_async(
|
|
421
|
+
*args,
|
|
422
|
+
fn: Callable,
|
|
423
|
+
ctx: context.Context | None = None,
|
|
424
|
+
trace_name: str | None = None,
|
|
425
|
+
trace_attrs: dict = {},
|
|
426
|
+
**kwargs,
|
|
427
|
+
):
|
|
428
|
+
"""
|
|
429
|
+
Async OTEL context attach/detach wrapper.
|
|
430
|
+
Mirrors with_context (sync).
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
if ctx is None:
|
|
434
|
+
ctx = context.get_current()
|
|
435
|
+
|
|
436
|
+
token = context.attach(ctx)
|
|
437
|
+
try:
|
|
438
|
+
with tracer.start_as_current_span(trace_name or "", attributes=trace_attrs):
|
|
439
|
+
return await fn(*args, **kwargs)
|
|
440
|
+
finally:
|
|
441
|
+
context.detach(token)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ============================================================
|
|
445
|
+
# RUN WITH CONTEXT (ASYNC) WRAPPER
|
|
446
|
+
# ============================================================
|
|
447
|
+
async def run_with_context_async(
|
|
448
|
+
*args,
|
|
449
|
+
fn: Callable,
|
|
450
|
+
ctx: context.Context | None = None,
|
|
451
|
+
trace_name: str | None = None,
|
|
452
|
+
trace_attrs: dict = {},
|
|
453
|
+
**kwargs,
|
|
454
|
+
):
|
|
455
|
+
"""
|
|
456
|
+
Smallest async execution primitive.
|
|
457
|
+
Mirrors run_with_context (sync).
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
if ctx is None:
|
|
461
|
+
ctx = context.get_current()
|
|
462
|
+
|
|
463
|
+
return await with_context_async(
|
|
464
|
+
*args,
|
|
465
|
+
fn=fn,
|
|
466
|
+
ctx=ctx,
|
|
467
|
+
trace_name=trace_name,
|
|
468
|
+
trace_attrs=trace_attrs,
|
|
469
|
+
**kwargs,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ============================================================
|
|
474
|
+
# RUN FN (ASYNC EXECUTION FACTORY) — FUNCTION + DECORATOR
|
|
475
|
+
# ============================================================
|
|
476
|
+
@overload
|
|
477
|
+
def run_fn_async(
|
|
478
|
+
*args: Any,
|
|
479
|
+
fn: None = None,
|
|
480
|
+
retry: Optional[policies.RetryPolicy] = ...,
|
|
481
|
+
ctx: Optional[context.Context] = ...,
|
|
482
|
+
trace_name: Optional[str] = ...,
|
|
483
|
+
trace_attrs: dict = ...,
|
|
484
|
+
**kwargs: Any,
|
|
485
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Coroutine[Any, Any, Any]]]: ...
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@overload
|
|
489
|
+
def run_fn_async(
|
|
490
|
+
*args: Any,
|
|
491
|
+
fn: Callable[..., Any],
|
|
492
|
+
retry: Optional[policies.RetryPolicy] = ...,
|
|
493
|
+
ctx: Optional[context.Context] = ...,
|
|
494
|
+
trace_name: Optional[str] = ...,
|
|
495
|
+
trace_attrs: dict = ...,
|
|
496
|
+
**kwargs: Any,
|
|
497
|
+
) -> Coroutine[Any, Any, tuple[bool, Any]]: ...
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def run_fn_async(
|
|
501
|
+
*args: Any,
|
|
502
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
503
|
+
retry: Optional[policies.RetryPolicy] = None,
|
|
504
|
+
ctx: Optional[context.Context] = None,
|
|
505
|
+
trace_name: Optional[str] = None,
|
|
506
|
+
trace_attrs: dict[str, Any] = {},
|
|
507
|
+
**kwargs: Any,
|
|
508
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Coroutine[Any, Any, Any]]] | Coroutine[Any, Any, tuple[bool, Any]]:
|
|
509
|
+
"""
|
|
510
|
+
Async equivalent of run_fn.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
(success: bool, result: Any | Exception)
|
|
514
|
+
|
|
515
|
+
Same invariants as sync:
|
|
516
|
+
- retries decided by policy
|
|
517
|
+
- no concurrency here
|
|
518
|
+
- no scheduling
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
# ============================================================
|
|
522
|
+
# DECORATOR MODE
|
|
523
|
+
# ============================================================
|
|
524
|
+
if fn is None:
|
|
525
|
+
|
|
526
|
+
def decorator(func: Callable):
|
|
527
|
+
async def wrapper(*wrapper_args, **wrapper_kwargs):
|
|
528
|
+
success, result = await run_fn_async(
|
|
529
|
+
*wrapper_args,
|
|
530
|
+
fn=func,
|
|
531
|
+
retry=retry,
|
|
532
|
+
ctx=ctx,
|
|
533
|
+
trace_name=trace_name or func.__qualname__,
|
|
534
|
+
trace_attrs=trace_attrs,
|
|
535
|
+
**wrapper_kwargs,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if not success:
|
|
539
|
+
if isinstance(result, BaseException):
|
|
540
|
+
raise result
|
|
541
|
+
raise RuntimeError(f"Function {func.__qualname__} failed: {result}")
|
|
542
|
+
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
return wrapper
|
|
546
|
+
|
|
547
|
+
return decorator
|
|
548
|
+
|
|
549
|
+
# ============================================================
|
|
550
|
+
# EXECUTION MODE
|
|
551
|
+
# ============================================================
|
|
552
|
+
ctx = ctx or context.get_current()
|
|
553
|
+
trace_name = trace_name or fn.__qualname__
|
|
554
|
+
retry = retry or policies.RetryPolicy(max_attempts=1)
|
|
555
|
+
|
|
556
|
+
async def attempt(attempt_no: int):
|
|
557
|
+
return await run_with_context_async(
|
|
558
|
+
*args,
|
|
559
|
+
fn=fn,
|
|
560
|
+
ctx=ctx,
|
|
561
|
+
trace_name=f"{trace_name}.attempt.{attempt_no}",
|
|
562
|
+
trace_attrs={**trace_attrs, "attempt": attempt_no},
|
|
563
|
+
**kwargs,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
async def run():
|
|
567
|
+
last_exc = None
|
|
568
|
+
|
|
569
|
+
for attempt_no in range(1, retry.max_attempts + 1):
|
|
570
|
+
logger.debug(f"[async] Attempt {attempt_no} → {fn.__qualname__}")
|
|
571
|
+
try:
|
|
572
|
+
if retry.timeout:
|
|
573
|
+
result = await asyncio.wait_for(
|
|
574
|
+
attempt(attempt_no),
|
|
575
|
+
timeout=retry.timeout,
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
result = await attempt(attempt_no)
|
|
579
|
+
|
|
580
|
+
return True, result
|
|
581
|
+
|
|
582
|
+
except exceptions.NonRetryableException as e:
|
|
583
|
+
return False, e
|
|
584
|
+
|
|
585
|
+
except asyncio.TimeoutError as e:
|
|
586
|
+
last_exc = e
|
|
587
|
+
logger.exception(
|
|
588
|
+
"[async] Timeout on attempt %d for function %s: %r",
|
|
589
|
+
attempt_no,
|
|
590
|
+
fn.__qualname__,
|
|
591
|
+
e,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
except Exception as e:
|
|
595
|
+
last_exc = e
|
|
596
|
+
logger.exception(
|
|
597
|
+
"[async] Error on attempt %d for function %s: %r",
|
|
598
|
+
attempt_no,
|
|
599
|
+
fn.__qualname__,
|
|
600
|
+
e,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if attempt_no < retry.max_attempts:
|
|
604
|
+
await utils.backoff_async(
|
|
605
|
+
retry.backoff,
|
|
606
|
+
retry.backoff_multiplier,
|
|
607
|
+
retry.backoff_cap,
|
|
608
|
+
attempt_no - 1,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
return False, last_exc
|
|
612
|
+
|
|
613
|
+
return run()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ============================================================
|
|
617
|
+
# ASYNC CONCURRENT EXECUTION WITH REFILL
|
|
618
|
+
# ============================================================
|
|
619
|
+
async def async_execute(
|
|
620
|
+
submissions: Iterable[Callable[[], asyncio.Task]],
|
|
621
|
+
*,
|
|
622
|
+
concurrency: int,
|
|
623
|
+
limit: Optional[int] = None,
|
|
624
|
+
timeout: Optional[float] = None,
|
|
625
|
+
) -> int:
|
|
626
|
+
"""
|
|
627
|
+
Async equivalent of multithread_execute.
|
|
628
|
+
|
|
629
|
+
- submissions yield callables that RETURN an asyncio.Task
|
|
630
|
+
- manages in-flight tasks only
|
|
631
|
+
- no retries
|
|
632
|
+
- no tracing
|
|
633
|
+
- no domain semantics
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Number of completed tasks
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
in_flight: set[asyncio.Task] = set()
|
|
640
|
+
completed = 0
|
|
641
|
+
submit_iter = iter(submissions)
|
|
642
|
+
|
|
643
|
+
# ============================================================
|
|
644
|
+
# INITIAL FILL
|
|
645
|
+
# ============================================================
|
|
646
|
+
while len(in_flight) < concurrency:
|
|
647
|
+
if limit is not None and completed + len(in_flight) >= limit:
|
|
648
|
+
break
|
|
649
|
+
try:
|
|
650
|
+
submit = next(submit_iter)
|
|
651
|
+
in_flight.add(submit())
|
|
652
|
+
except StopIteration:
|
|
653
|
+
break
|
|
654
|
+
except Exception as e:
|
|
655
|
+
logger.error(f"[async] Submission failed: {e}")
|
|
656
|
+
|
|
657
|
+
# ============================================================
|
|
658
|
+
# MAIN LOOP
|
|
659
|
+
# ============================================================
|
|
660
|
+
while in_flight:
|
|
661
|
+
done, in_flight = await asyncio.wait(
|
|
662
|
+
in_flight,
|
|
663
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
664
|
+
timeout=timeout,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
for task in done:
|
|
668
|
+
try:
|
|
669
|
+
await task
|
|
670
|
+
except asyncio.TimeoutError:
|
|
671
|
+
logger.error("[async] Execution timeout")
|
|
672
|
+
except Exception as e:
|
|
673
|
+
logger.error(f"[async] Execution error: {e}")
|
|
674
|
+
finally:
|
|
675
|
+
completed += 1
|
|
676
|
+
|
|
677
|
+
# ============================================================
|
|
678
|
+
# REFILL
|
|
679
|
+
# ============================================================
|
|
680
|
+
while len(in_flight) < concurrency:
|
|
681
|
+
if limit is not None and completed + len(in_flight) >= limit:
|
|
682
|
+
break
|
|
683
|
+
try:
|
|
684
|
+
submit = next(submit_iter)
|
|
685
|
+
in_flight.add(submit())
|
|
686
|
+
except StopIteration:
|
|
687
|
+
break
|
|
688
|
+
except Exception as e:
|
|
689
|
+
logger.error(f"[async] Submission failed: {e}")
|
|
690
|
+
|
|
691
|
+
return completed
|