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
@@ -0,0 +1,858 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from concurrent import futures
6
+ from datetime import datetime
7
+ from typing import Any, List
8
+
9
+ from opentelemetry import context as otel_context
10
+
11
+ from ... import connector, telemetry
12
+ from .. import base, exceptions, helpers, policies, utils
13
+ from . import metrics as mixin_metrics
14
+ from . import producer
15
+
16
+ logger = logging.getLogger(__name__)
17
+ tracer = telemetry.tracing.get_tracer(__name__)
18
+
19
+
20
+ def _wrap_handler_failure(result: Any) -> exceptions.TransactionException:
21
+ """Normalise a handler failure value into a TransactionException.
22
+
23
+ Preserves the original exception (and its traceback) on ``__cause__`` so
24
+ downstream ``logger.exception`` can render the real stack instead of
25
+ ``NoneType: None``. Uses ``repr`` of the cause to derive a diagnostic
26
+ message when the cause has an empty ``str()``.
27
+ """
28
+ if isinstance(result, exceptions.TransactionException):
29
+ return result
30
+ if isinstance(result, futures.TimeoutError):
31
+ return exceptions.TransactionException(
32
+ message=repr(result),
33
+ category=exceptions.ExceptionType.TIMEOUT,
34
+ cause=result if isinstance(result, BaseException) else None,
35
+ )
36
+ if isinstance(result, BaseException):
37
+ return exceptions.TransactionException(
38
+ message=None, # let constructor derive from cause via repr()
39
+ category=exceptions.ExceptionType.SYSTEM,
40
+ cause=result,
41
+ )
42
+ return exceptions.TransactionException(
43
+ message=repr(result),
44
+ category=exceptions.ExceptionType.SYSTEM,
45
+ )
46
+
47
+
48
+ class ConsumerMixin(ABC):
49
+ name: str
50
+ connectors: dict[str, connector.Connector]
51
+
52
+ @abstractmethod
53
+ def process_transaction(self, transaction: connector.Transaction) -> Any:
54
+ raise NotImplementedError
55
+
56
+ # User hooks
57
+ def handle_process_success(self, transaction, result):
58
+ logger.debug(f"[{self.name}] SUCCESS → {transaction.id}")
59
+
60
+ def handle_process_exception(self, transaction, exc):
61
+ logger.error(f"[{self.name}] EXCEPTION → {transaction.id}: {exc}")
62
+
63
+ # =====================================================================
64
+ # PROCESS LIFECYCLE
65
+ # =====================================================================
66
+ def _start_processing(self, transaction: connector.Transaction, policy: policies.ConsumerPolicy):
67
+ """
68
+ PROCESS → SUCCESS or EXCEPTION
69
+ """
70
+ tx_start = time.perf_counter()
71
+ final_status = "success"
72
+
73
+ try:
74
+ # -----------------------
75
+ # 1) PROCESS STEP
76
+ # -----------------------
77
+ logger.info(f"Transaction {transaction.id} processing started")
78
+ process_ok, process_result = self._handle_process(transaction, policy.process.retry)
79
+
80
+ # -----------------------
81
+ # 2) EXCEPTION HANDLER
82
+ # -----------------------
83
+ if not process_ok:
84
+ logger.error(
85
+ "Transaction %s process handler failed with outcome %r",
86
+ transaction.id,
87
+ process_result,
88
+ )
89
+ final_status = "exception"
90
+ process_exc = _wrap_handler_failure(process_result)
91
+ logger.error(
92
+ "Invoking exception handler for transaction %s with outcome: %s",
93
+ transaction.id,
94
+ process_exc,
95
+ )
96
+ exc_ok, exc_result = self._handle_exception(transaction, process_exc, policy.exception.retry)
97
+ if not exc_ok and isinstance(exc_result, exceptions.DeadChannelError):
98
+ logger.warning(
99
+ "Transaction %s exception handler hit a dead channel "
100
+ "(%s); broker will redeliver. Skipping further routing.",
101
+ transaction.id,
102
+ exc_result,
103
+ )
104
+ final_status = "redeliver"
105
+ return exc_ok, exc_result
106
+
107
+ # -----------------------
108
+ # 3) SUCCESS HANDLER
109
+ # -----------------------
110
+ logger.info(f"Invoking success handler for transaction {transaction.id} with outcome: '{process_result}'")
111
+ success_ok, success_result = self._handle_success(transaction, process_result, policy.success.retry)
112
+
113
+ if not success_ok:
114
+ # ----------------------------------------------------------
115
+ # SHORT-CIRCUIT: ack/nack against a dead broker channel.
116
+ # Routing to the exception handler would only re-fail (it
117
+ # would nack on the same dead channel). The broker will
118
+ # redeliver the message to a fresh subscriber.
119
+ # ----------------------------------------------------------
120
+ if isinstance(success_result, exceptions.DeadChannelError):
121
+ logger.warning(
122
+ "Transaction %s success handler could not ack on a dead "
123
+ "channel (%s); broker will redeliver. Skipping exception handler.",
124
+ transaction.id,
125
+ success_result,
126
+ )
127
+ final_status = "redeliver"
128
+ return False, success_result
129
+
130
+ logger.error(
131
+ "Transaction %s success handler failed with outcome %r",
132
+ transaction.id,
133
+ success_result,
134
+ )
135
+ final_status = "exception"
136
+ success_exc = _wrap_handler_failure(success_result)
137
+ logger.error(
138
+ "Invoking exception handler for transaction %s with outcome: %s",
139
+ transaction.id,
140
+ success_exc,
141
+ )
142
+ exc_ok, exc_result = self._handle_exception(transaction, success_exc, policy.exception.retry)
143
+ if not exc_ok and isinstance(exc_result, exceptions.DeadChannelError):
144
+ logger.warning(
145
+ "Transaction %s exception handler hit a dead channel (%s); broker will redeliver.",
146
+ transaction.id,
147
+ exc_result,
148
+ )
149
+ final_status = "redeliver"
150
+ return exc_ok, exc_result
151
+
152
+ return True, success_result
153
+ finally:
154
+ # Record transaction-level metrics
155
+ tx_duration = time.perf_counter() - tx_start
156
+ mixin_metrics.record_consumer_transaction(
157
+ task_name=getattr(self, "name", self.__class__.__name__),
158
+ transaction_id=transaction.id,
159
+ duration=tx_duration,
160
+ status=final_status,
161
+ )
162
+
163
+ # =====================================================================
164
+ # PROCESS HANDLER
165
+ # =====================================================================
166
+ def _handle_process(self, transaction, retry: policies.RetryPolicy):
167
+ logger.info(f"Transaction {transaction.id} process handler started")
168
+ stage_start = time.perf_counter()
169
+ success, result = helpers.run_fn(
170
+ fn=lambda: self.process_transaction(transaction),
171
+ retry=retry,
172
+ trace_name=f"{self.__class__.__name__}.process",
173
+ trace_attrs={"transaction_id": transaction.id},
174
+ )
175
+ # Record lifecycle metrics
176
+ mixin_metrics.record_consumer_lifecycle(
177
+ task_name=getattr(self, "name", self.__class__.__name__),
178
+ stage="process",
179
+ duration=time.perf_counter() - stage_start,
180
+ outcome="ok" if success else "error",
181
+ )
182
+ logger.info(
183
+ f"Transaction {transaction.id} process handler completed with status: {'success' if success else 'error'}"
184
+ )
185
+ return success, result
186
+
187
+ # =====================================================================
188
+ # SUCCESS HANDLER
189
+ # =====================================================================
190
+ def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
191
+ logger.info(f"Transaction {transaction.id} success handler started")
192
+ stage_start = time.perf_counter()
193
+ success, handler_result = helpers.run_fn(
194
+ fn=lambda: self.handle_process_success(transaction, result),
195
+ retry=retry,
196
+ trace_name=f"{self.__class__.__name__}.handle_process_success",
197
+ trace_attrs={"transaction_id": transaction.id},
198
+ )
199
+ # Record lifecycle metrics
200
+ mixin_metrics.record_consumer_lifecycle(
201
+ task_name=getattr(self, "name", self.__class__.__name__),
202
+ stage="success",
203
+ duration=time.perf_counter() - stage_start,
204
+ outcome="ok" if success else "error",
205
+ )
206
+ logger.info(
207
+ f"Transaction {transaction.id} success handler completed with status: {'success' if success else 'error'}"
208
+ )
209
+ return success, handler_result
210
+
211
+ # =====================================================================
212
+ # EXCEPTION HANDLER
213
+ # =====================================================================
214
+ def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
215
+ logger.error(f"Transaction {transaction.id} exception handler started")
216
+ stage_start = time.perf_counter()
217
+ success, result = helpers.run_fn(
218
+ fn=lambda: self.handle_process_exception(transaction, exc),
219
+ retry=retry,
220
+ trace_name=f"{self.__class__.__name__}.handle_process_exception",
221
+ trace_attrs={"transaction_id": transaction.id},
222
+ )
223
+ # Record lifecycle metrics
224
+ mixin_metrics.record_consumer_lifecycle(
225
+ task_name=getattr(self, "name", self.__class__.__name__),
226
+ stage="exception",
227
+ duration=time.perf_counter() - stage_start,
228
+ outcome="ok" if success else "error",
229
+ )
230
+ logger.info(
231
+ f"Transaction {transaction.id} exception handler completed with status: {'success' if success else 'error'}"
232
+ )
233
+ return success, result
234
+
235
+ # =====================================================================
236
+ # FETCH HANDLER
237
+ # =====================================================================
238
+ def _handle_fetch(self, conn, policy: policies.FetchPolicy):
239
+ logger.info(f"Fetch handler started for batch size {policy.batch.size}", extra=policy.extra)
240
+ fetch_start = time.perf_counter()
241
+ success, result = helpers.run_fn(
242
+ fn=lambda: conn.fetch_transactions(policy.batch.size, **policy.extra),
243
+ retry=policy.retry,
244
+ trace_name=f"{self.__class__.__name__}.fetch_transactions",
245
+ trace_attrs={"batch_size": policy.batch.size},
246
+ )
247
+ # Record fetch metrics
248
+ fetched_count = len(result) if success and result else 0
249
+ mixin_metrics.record_consumer_fetch(
250
+ task_name=getattr(self, "name", self.__class__.__name__),
251
+ connector_name=conn.__class__.__name__,
252
+ batch_size=policy.batch.size,
253
+ fetched_count=fetched_count,
254
+ duration=time.perf_counter() - fetch_start,
255
+ success=success,
256
+ )
257
+ logger.info(f"Fetch handler completed with status: {'success' if success else 'error'}")
258
+ return success, result
259
+
260
+ # =====================================================================
261
+ # CONNECTOR RESOLUTION
262
+ # =====================================================================
263
+ def _resolve_connector(self, name: str | None):
264
+ if name:
265
+ return self.connectors[name]
266
+ if len(self.connectors) == 1:
267
+ return next(iter(self.connectors.values()))
268
+ raise ValueError("Multiple connectors configured; specify one in policy")
269
+
270
+ # =====================================================================
271
+ # PUBLIC CONSUME LOOP
272
+ # =====================================================================
273
+ def consume_transactions(self, policy: policies.ConsumerPolicy | None = None):
274
+ if policy is None:
275
+ policy = policies.ConsumerPolicy()
276
+
277
+ def _consume():
278
+ start_time_iso = datetime.now().isoformat()
279
+ start_time = time.perf_counter()
280
+ processed = 0
281
+ empty_count = 0
282
+ batch_number = 0
283
+
284
+ logger.info(f"Consume loop started at {start_time_iso}")
285
+ logger.debug(f"Consume loop running with loop policy: {policy.loop.model_dump_json(indent=2)}")
286
+
287
+ conn = self._resolve_connector(policy.fetch.connector_name)
288
+ executor = futures.ThreadPoolExecutor(
289
+ max_workers=policy.loop.concurrency.value + policy.loop.concurrency.headroom
290
+ )
291
+
292
+ ctx = otel_context.Context()
293
+
294
+ def submit_start_processing(tr, pol):
295
+ return helpers.run_fn(
296
+ fn=lambda: self._start_processing(tr, pol),
297
+ ctx=ctx,
298
+ executor=executor,
299
+ trace_name=f"{self.__class__.__name__}.start_processing",
300
+ trace_attrs={"transaction_id": tr.id},
301
+ )
302
+
303
+ while True:
304
+ batch_number += 1
305
+
306
+ # -------------------------
307
+ # FETCH
308
+ # -------------------------
309
+ logger.info(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
310
+ success, result = self._handle_fetch(conn, policy.fetch)
311
+ if not success:
312
+ logger.error(f"Fetch failed → {result}")
313
+ executor.shutdown(wait=False)
314
+ if isinstance(result, futures.TimeoutError):
315
+ raise exceptions.FetchTimeoutException(str(result))
316
+ raise exceptions.FetchException(str(result))
317
+
318
+ transactions = result
319
+
320
+ # -------------------------
321
+ # EMPTY QUEUE HANDLING
322
+ # -------------------------
323
+ if not transactions:
324
+ logger.info(f"Empty fetch detected at {datetime.now().isoformat()}")
325
+ if not policy.loop.streaming:
326
+ logger.info("Non-streaming mode detected, breaking loop")
327
+ break
328
+
329
+ logger.debug(f"{empty_count} consecutive empty fetches so far")
330
+
331
+ mixin_metrics.record_consumer_empty_queue_wait(
332
+ task_name=getattr(self, "name", self.__class__.__name__),
333
+ wait_count=empty_count,
334
+ )
335
+
336
+ utils.backoff(
337
+ policy.fetch.empty.backoff,
338
+ policy.fetch.empty.backoff_multiplier,
339
+ policy.fetch.empty.backoff_cap,
340
+ empty_count,
341
+ )
342
+ empty_count += 1
343
+ continue
344
+
345
+ empty_count = 0
346
+
347
+ logger.info(
348
+ f"{len(transactions)} transaction{'' if len(transactions) == 1 else 's'}fetched from fetch handler"
349
+ )
350
+
351
+ # Record batch metric
352
+ mixin_metrics.record_consumer_batch(
353
+ task_name=getattr(self, "name", self.__class__.__name__),
354
+ batch_number=batch_number,
355
+ batch_size=len(transactions),
356
+ streaming=policy.loop.streaming,
357
+ )
358
+
359
+ # ============================================================
360
+ # RUN CONCURRENTLY WITH REFILL (with batch-level span)
361
+ # ============================================================
362
+ if policy.loop.streaming:
363
+ batch_context = ctx
364
+ else:
365
+ batch_context = None # Use current context
366
+
367
+ logger.info(
368
+ f"Starting batch processing of "
369
+ f"{len(transactions)} transaction{'' if len(transactions) == 1 else 's'} "
370
+ f"from fetch handler with "
371
+ f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
372
+ )
373
+
374
+ with tracer.start_as_current_span(
375
+ f"{self.__class__.__name__}.process_batch",
376
+ context=batch_context,
377
+ attributes={
378
+ "batch_number": batch_number,
379
+ "batch_size": len(transactions),
380
+ "streaming": policy.loop.streaming,
381
+ },
382
+ ):
383
+
384
+ def submissions():
385
+ for tr in transactions:
386
+ yield lambda tr=tr: submit_start_processing(tr, policy)
387
+
388
+ logger.debug(
389
+ f"Submitting {len(transactions)} transactions for processing "
390
+ f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
391
+ )
392
+
393
+ count = helpers.multithread_execute(
394
+ submissions=submissions(),
395
+ concurrency=policy.loop.concurrency.value,
396
+ limit=policy.loop.limit,
397
+ timeout=policy.transaction_runtime.timeout,
398
+ )
399
+
400
+ processed += count
401
+
402
+ if policy.loop.limit and processed >= policy.loop.limit:
403
+ break
404
+
405
+ if policy.fetch.batch.interval and policy.fetch.batch.interval.backoff > 0:
406
+ logger.info("Batch interval detected, triggering backoff")
407
+ utils.backoff(
408
+ backoff=policy.fetch.batch.interval.backoff,
409
+ multiplier=policy.fetch.batch.interval.backoff_multiplier,
410
+ cap=policy.fetch.batch.interval.backoff_cap,
411
+ attempt=0,
412
+ )
413
+
414
+ executor.shutdown()
415
+ elapsed_time = time.perf_counter() - start_time
416
+ logger.info(f"[Consume] Finished. Processed={processed} in {elapsed_time:.2f} seconds")
417
+ return processed
418
+
419
+ # For streaming mode, run without wrapping span (batches have their own spans)
420
+ # For non-streaming mode, wrap entire consume in a spans
421
+ if policy.loop.streaming:
422
+ try:
423
+ return _consume()
424
+ except futures.TimeoutError as e:
425
+ raise exceptions.ConsumerLoopTimeoutException(str(e))
426
+ else:
427
+ success, result = helpers.run_fn(
428
+ fn=lambda: _consume(),
429
+ retry=policies.RetryPolicy(timeout=policy.loop.timeout),
430
+ trace_name=f"{self.__class__.__name__}.consume_transactions",
431
+ trace_attrs={},
432
+ )
433
+
434
+ if not success:
435
+ if isinstance(result, futures.TimeoutError):
436
+ raise exceptions.ConsumerLoopTimeoutException(str(result))
437
+ raise result
438
+ return result
439
+
440
+
441
+ class ConsumerTask(ConsumerMixin, base.BaseTask):
442
+ """
443
+ Backwards-compatible consumer task.
444
+ You can still inherit from this if you're only a consumer.
445
+ """
446
+
447
+ pass
448
+
449
+
450
+ class HybridTask(producer.ProducerMixin, ConsumerMixin, base.BaseTask):
451
+ """
452
+ Hybrid task that can produce and consume transactions.
453
+ """
454
+
455
+ pass
456
+
457
+
458
+ # =====================================================================
459
+ # ASYNC CONSUMER MIXIN
460
+ # =====================================================================
461
+
462
+
463
+ class AsyncConsumerMixin(ABC):
464
+ name: str
465
+ connectors: dict[str, connector.AsyncConnector]
466
+
467
+ # =====================================================================
468
+ # HOOKS
469
+ # =====================================================================
470
+ @abstractmethod
471
+ async def process_transaction(self, transaction: connector.Transaction) -> Any:
472
+ raise NotImplementedError
473
+
474
+ async def handle_process_success(self, transaction, result):
475
+ logger.debug(f"[{self.name}] SUCCESS → {transaction.id}")
476
+
477
+ async def handle_process_exception(self, transaction, exc):
478
+ logger.error(f"[{self.name}] EXCEPTION → {transaction.id}: {exc}")
479
+
480
+ # =====================================================================
481
+ # FETCH HANDLER (ASYNC)
482
+ # =====================================================================
483
+ async def _handle_fetch(self, conn, policy: policies.FetchPolicy) -> tuple[bool, List[connector.Transaction]]:
484
+ logger.info(f"Fetch handler started for batch size {policy.batch.size}", extra=policy.extra)
485
+ fetch_start = time.perf_counter()
486
+ success, result = await helpers.run_fn_async(
487
+ fn=lambda: conn.fetch_transactions_async(policy.batch.size, **policy.extra),
488
+ retry=policy.retry,
489
+ trace_name=f"{self.__class__.__name__}.fetch_transactions",
490
+ trace_attrs={"batch_size": policy.batch.size},
491
+ )
492
+ # Record fetch metrics
493
+ fetched_count = len(result) if success and result else 0
494
+ mixin_metrics.record_consumer_fetch(
495
+ task_name=getattr(self, "name", self.__class__.__name__),
496
+ connector_name=conn.__class__.__name__,
497
+ batch_size=policy.batch.size,
498
+ fetched_count=fetched_count,
499
+ duration=time.perf_counter() - fetch_start,
500
+ success=success,
501
+ )
502
+ logger.info(f"Fetch handler completed with status: {'success' if success else 'error'}")
503
+ return success, result
504
+
505
+ # =====================================================================
506
+ # PROCESS OR ROUTE INTO SUCCESS / EXCEPTION
507
+ # =====================================================================
508
+ async def _start_processing(self, transaction, policy: policies.ConsumerPolicy):
509
+ """
510
+ PROCESS → SUCCESS or EXCEPTION
511
+ """
512
+ tx_start = time.perf_counter()
513
+ final_status = "success"
514
+
515
+ try:
516
+ # -----------------------
517
+ # 1) PROCESS STEP
518
+ # -----------------------
519
+ logger.info(f"Transaction {transaction.id} processing started")
520
+ process_ok, process_result = await self._handle_process(transaction, policy.process.retry)
521
+
522
+ # -----------------------
523
+ # 2) EXCEPTION HANDLER
524
+ # -----------------------
525
+ if not process_ok:
526
+ logger.error(
527
+ "Transaction %s process handler failed with outcome %r",
528
+ transaction.id,
529
+ process_result,
530
+ )
531
+ final_status = "exception"
532
+ process_exc = _wrap_handler_failure(process_result)
533
+ logger.error(
534
+ "Invoking exception handler for transaction %s with outcome: %s",
535
+ transaction.id,
536
+ process_exc,
537
+ )
538
+ exc_ok, exc_result = await self._handle_exception(transaction, process_exc, policy.exception.retry)
539
+ if not exc_ok and isinstance(exc_result, exceptions.DeadChannelError):
540
+ logger.warning(
541
+ "Transaction %s exception handler hit a dead channel "
542
+ "(%s); broker will redeliver. Skipping further routing.",
543
+ transaction.id,
544
+ exc_result,
545
+ )
546
+ final_status = "redeliver"
547
+ return exc_ok, exc_result
548
+
549
+ # -----------------------
550
+ # 3) SUCCESS HANDLER
551
+ # -----------------------
552
+ logger.info(f"Invoking success handler for transaction {transaction.id} with outcome: '{process_result}'")
553
+ success_ok, success_result = await self._handle_success(transaction, process_result, policy.success.retry)
554
+
555
+ if not success_ok:
556
+ # ----------------------------------------------------------
557
+ # SHORT-CIRCUIT: ack/nack against a dead broker channel.
558
+ # Routing to the exception handler would only re-fail (it
559
+ # would nack on the same dead channel). The broker will
560
+ # redeliver the message to a fresh subscriber.
561
+ # ----------------------------------------------------------
562
+ if isinstance(success_result, exceptions.DeadChannelError):
563
+ logger.warning(
564
+ "Transaction %s success handler could not ack on a dead "
565
+ "channel (%s); broker will redeliver. Skipping exception handler.",
566
+ transaction.id,
567
+ success_result,
568
+ )
569
+ final_status = "redeliver"
570
+ return False, success_result
571
+
572
+ logger.error(
573
+ "Transaction %s success handler failed with outcome %r",
574
+ transaction.id,
575
+ success_result,
576
+ )
577
+ final_status = "exception"
578
+ success_exc = _wrap_handler_failure(success_result)
579
+ logger.error(
580
+ "Invoking exception handler for transaction %s with outcome: %s",
581
+ transaction.id,
582
+ success_exc,
583
+ )
584
+ exc_ok, exc_result = await self._handle_exception(transaction, success_exc, policy.exception.retry)
585
+ if not exc_ok and isinstance(exc_result, exceptions.DeadChannelError):
586
+ logger.warning(
587
+ "Transaction %s exception handler hit a dead channel (%s); broker will redeliver.",
588
+ transaction.id,
589
+ exc_result,
590
+ )
591
+ final_status = "redeliver"
592
+ return exc_ok, exc_result
593
+
594
+ return True, success_result
595
+ finally:
596
+ # Record transaction-level metrics
597
+ tx_duration = time.perf_counter() - tx_start
598
+ mixin_metrics.record_consumer_transaction(
599
+ task_name=getattr(self, "name", self.__class__.__name__),
600
+ transaction_id=transaction.id,
601
+ duration=tx_duration,
602
+ status=final_status,
603
+ )
604
+
605
+ # =====================================================================
606
+ # PROCESS HANDLER WITH RETRIES
607
+ # =====================================================================
608
+ async def _handle_process(self, transaction, retry: policies.RetryPolicy):
609
+ logger.info(f"Transaction {transaction.id} process handler started")
610
+ stage_start = time.perf_counter()
611
+ success, result = await helpers.run_fn_async(
612
+ fn=lambda: self.process_transaction(transaction),
613
+ retry=retry,
614
+ trace_name=f"{self.__class__.__name__}.process",
615
+ trace_attrs={"transaction_id": transaction.id},
616
+ )
617
+ # Record lifecycle metrics
618
+ mixin_metrics.record_consumer_lifecycle(
619
+ task_name=getattr(self, "name", self.__class__.__name__),
620
+ stage="process",
621
+ duration=time.perf_counter() - stage_start,
622
+ outcome="ok" if success else "error",
623
+ )
624
+ logger.info(
625
+ f"Transaction {transaction.id} process handler completed with status: {'success' if success else 'error'}"
626
+ )
627
+ return success, result
628
+
629
+ # =====================================================================
630
+ # SUCCESS HANDLER
631
+ # =====================================================================
632
+ async def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
633
+ logger.info(f"Transaction {transaction.id} success handler started")
634
+ stage_start = time.perf_counter()
635
+ success, handler_result = await helpers.run_fn_async(
636
+ fn=lambda: self.handle_process_success(transaction, result),
637
+ retry=retry,
638
+ trace_name=f"{self.__class__.__name__}.handle_process_success",
639
+ trace_attrs={"transaction_id": transaction.id},
640
+ )
641
+ # Record lifecycle metrics
642
+ mixin_metrics.record_consumer_lifecycle(
643
+ task_name=getattr(self, "name", self.__class__.__name__),
644
+ stage="success",
645
+ duration=time.perf_counter() - stage_start,
646
+ outcome="ok" if success else "error",
647
+ )
648
+ logger.info(
649
+ f"Transaction {transaction.id} success handler completed with status: {'success' if success else 'error'}"
650
+ )
651
+ return success, handler_result
652
+
653
+ # =====================================================================
654
+ # EXCEPTION HANDLER
655
+ # =====================================================================
656
+ async def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
657
+ logger.error(f"Transaction {transaction.id} exception handler started")
658
+ stage_start = time.perf_counter()
659
+ success, result = await helpers.run_fn_async(
660
+ fn=lambda: self.handle_process_exception(transaction, exc),
661
+ retry=retry,
662
+ trace_name=f"{self.__class__.__name__}.handle_process_exception",
663
+ trace_attrs={"transaction_id": transaction.id},
664
+ )
665
+ # Record lifecycle metrics
666
+ mixin_metrics.record_consumer_lifecycle(
667
+ task_name=getattr(self, "name", self.__class__.__name__),
668
+ stage="exception",
669
+ duration=time.perf_counter() - stage_start,
670
+ outcome="ok" if success else "error",
671
+ )
672
+ logger.info(
673
+ f"Transaction {transaction.id} exception handler completed with status: {'success' if success else 'error'}"
674
+ )
675
+ return success, result
676
+
677
+ # =====================================================================
678
+ # CONNECTOR RESOLUTION
679
+ # =====================================================================
680
+ def _resolve_connector(self, name: str | None):
681
+ if name:
682
+ return self.connectors[name]
683
+ if len(self.connectors) == 1:
684
+ return next(iter(self.connectors.values()))
685
+ raise ValueError("Multiple connectors configured; specify one in policy")
686
+
687
+ # =====================================================================
688
+ # ASYNC PUBLIC CONSUME LOOP
689
+ # =====================================================================
690
+ async def consume_transactions(self, policy: policies.ConsumerPolicy | None = None):
691
+ if policy is None:
692
+ policy = policies.ConsumerPolicy()
693
+
694
+ async def _consume():
695
+ start_time_iso = datetime.now().isoformat()
696
+ start_time = time.perf_counter()
697
+ processed = 0
698
+ empty_count = 0
699
+ batch_number = 0
700
+
701
+ logger.info(f"Consume loop started at {start_time_iso}")
702
+ logger.debug(f"Consume loop running with loop policy: {policy.loop.model_dump_json(indent=2)}")
703
+
704
+ conn = self._resolve_connector(policy.fetch.connector_name)
705
+
706
+ ctx = otel_context.Context()
707
+
708
+ async def submit_start_processing(tr, pol):
709
+ return await helpers.run_fn_async(
710
+ fn=lambda: self._start_processing(tr, pol),
711
+ trace_name=f"{self.__class__.__name__}.start_processing",
712
+ trace_attrs={"transaction_id": tr.id},
713
+ )
714
+
715
+ while True:
716
+ batch_number += 1
717
+
718
+ # ============================================================
719
+ # FETCH
720
+ # ============================================================
721
+ logger.info(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
722
+ success, result = await self._handle_fetch(conn, policy.fetch)
723
+
724
+ if not success:
725
+ logger.error(f"Fetch failed → {result}")
726
+ if isinstance(result, (asyncio.TimeoutError, futures.TimeoutError)):
727
+ raise exceptions.FetchTimeoutException(str(result))
728
+ raise exceptions.FetchException(str(result))
729
+
730
+ transactions = result
731
+
732
+ # ============================================================
733
+ # EMPTY QUEUE HANDLING
734
+ # ============================================================
735
+ if not transactions:
736
+ logger.info(f"Empty fetch detected at {datetime.now().isoformat()}")
737
+ if not policy.loop.streaming:
738
+ logger.info("Non-streaming mode detected, breaking loop")
739
+ break
740
+
741
+ logger.debug(f"{empty_count} consecutive empty fetches so far")
742
+
743
+ mixin_metrics.record_consumer_empty_queue_wait(
744
+ task_name=getattr(self, "name", self.__class__.__name__),
745
+ wait_count=empty_count,
746
+ )
747
+
748
+ await utils.backoff_async(
749
+ backoff=policy.fetch.empty.backoff,
750
+ multiplier=policy.fetch.empty.backoff_multiplier,
751
+ cap=policy.fetch.empty.backoff_cap,
752
+ attempt=empty_count,
753
+ )
754
+ empty_count += 1
755
+ continue
756
+
757
+ empty_count = 0
758
+
759
+ logger.info(f"{len(transactions)} transaction(s) fetched from fetch handler")
760
+
761
+ # Record batch metric
762
+ mixin_metrics.record_consumer_batch(
763
+ task_name=getattr(self, "name", self.__class__.__name__),
764
+ batch_number=batch_number,
765
+ batch_size=len(transactions),
766
+ streaming=policy.loop.streaming,
767
+ )
768
+
769
+ # ============================================================
770
+ # RUN CONCURRENTLY WITH REFILL (with batch-level span)
771
+ # ============================================================
772
+ if policy.loop.streaming:
773
+ batch_context = ctx
774
+ else:
775
+ batch_context = None # Use current context
776
+
777
+ logger.info(
778
+ f"Starting batch processing of "
779
+ f"{len(transactions)} transaction(s) "
780
+ f"from fetch handler with "
781
+ f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
782
+ )
783
+
784
+ with tracer.start_as_current_span(
785
+ f"{self.__class__.__name__}.process_batch",
786
+ context=batch_context,
787
+ attributes={
788
+ "batch_number": batch_number,
789
+ "batch_size": len(transactions),
790
+ "streaming": policy.loop.streaming,
791
+ },
792
+ ):
793
+
794
+ def submissions():
795
+ for tr in transactions:
796
+ yield lambda tr=tr: asyncio.create_task(submit_start_processing(tr, policy))
797
+
798
+ logger.debug(
799
+ f"Submitting {len(transactions)} transactions for processing "
800
+ f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
801
+ )
802
+
803
+ count = await helpers.async_execute(
804
+ submissions=submissions(),
805
+ concurrency=policy.loop.concurrency.value,
806
+ limit=policy.loop.limit,
807
+ timeout=policy.transaction_runtime.timeout,
808
+ )
809
+
810
+ processed += count
811
+
812
+ if policy.loop.limit and processed >= policy.loop.limit:
813
+ break
814
+
815
+ if policy.fetch.batch.interval and policy.fetch.batch.interval.backoff > 0:
816
+ logger.info("Batch interval detected, triggering backoff")
817
+ await utils.backoff_async(
818
+ backoff=policy.fetch.batch.interval.backoff,
819
+ multiplier=policy.fetch.batch.interval.backoff_multiplier,
820
+ cap=policy.fetch.batch.interval.backoff_cap,
821
+ attempt=0,
822
+ )
823
+
824
+ elapsed_time = time.perf_counter() - start_time
825
+ logger.info(f"[Consume] Finished. Processed={processed} in {elapsed_time:.2f} seconds")
826
+ return processed
827
+
828
+ # For streaming mode, run without wrapping span (batches have their own spans)
829
+ # For non-streaming mode, wrap entire consume in a span
830
+ if policy.loop.streaming:
831
+ try:
832
+ return await _consume()
833
+ except asyncio.TimeoutError as e:
834
+ raise exceptions.ConsumerLoopTimeoutException(str(e))
835
+ else:
836
+ success, result = await helpers.run_fn_async(
837
+ fn=_consume,
838
+ retry=policies.RetryPolicy(timeout=policy.loop.timeout),
839
+ trace_name=f"{self.__class__.__name__}.consume_transactions",
840
+ trace_attrs={},
841
+ )
842
+ if not success:
843
+ if isinstance(result, asyncio.TimeoutError):
844
+ raise exceptions.ConsumerLoopTimeoutException(str(result))
845
+ raise result
846
+ return result
847
+
848
+
849
+ class AsyncConsumerTask(AsyncConsumerMixin, base.BaseAsyncTask):
850
+ pass
851
+
852
+
853
+ class AsyncHybridTask(producer.AsyncProducerMixin, AsyncConsumerMixin, base.BaseAsyncTask):
854
+ """
855
+ Async hybrid task that can consume and produce transactions.
856
+ """
857
+
858
+ pass