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.
Files changed (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. 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