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
|
@@ -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
|