ergon-framework-python 0.1.7__tar.gz → 0.1.9__tar.gz

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 (105) hide show
  1. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/PKG-INFO +1 -1
  2. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/pyproject.toml +2 -2
  3. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/__init__.py +1 -1
  4. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/async_service.py +29 -7
  5. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/mixins/consumer.py +146 -7
  6. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/policies.py +10 -1
  7. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/runner.py +39 -16
  8. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
  9. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/tests/test_smoke.py +1 -1
  10. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/LICENSE +0 -0
  11. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/README.md +0 -0
  12. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/setup.cfg +0 -0
  13. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  14. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  15. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  16. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  17. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  18. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  19. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  20. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  21. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  22. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  23. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  24. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  25. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  26. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  27. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  28. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  29. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  30. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  31. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  32. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  33. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  34. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/cli.py +0 -0
  35. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/__init__.py +0 -0
  36. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/connector.py +0 -0
  37. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/__init__.py +0 -0
  38. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/_client.py +0 -0
  39. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/_operations.py +0 -0
  40. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/async_connector.py +0 -0
  41. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/connector.py +0 -0
  42. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/models.py +0 -0
  43. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/ergon_platform/utils.py +0 -0
  44. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/excel/__init__.py +0 -0
  45. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/excel/connector.py +0 -0
  46. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/excel/models.py +0 -0
  47. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/excel/service.py +0 -0
  48. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/__init__.py +0 -0
  49. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/async_auth_service.py +0 -0
  50. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/async_connector.py +0 -0
  51. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/async_service.py +0 -0
  52. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/auth_service.py +0 -0
  53. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/connector.py +0 -0
  54. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/models.py +0 -0
  55. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/service.py +0 -0
  56. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/nylas/utils.py +0 -0
  57. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/__init__.py +0 -0
  58. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/async_connector.py +0 -0
  59. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/async_service.py +0 -0
  60. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/connector.py +0 -0
  61. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/models.py +0 -0
  62. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/service.py +0 -0
  63. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/pipefy/version.py +0 -0
  64. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/postgres/__init__.py +0 -0
  65. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/postgres/async_connector.py +0 -0
  66. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/postgres/async_service.py +0 -0
  67. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/postgres/models.py +0 -0
  68. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  69. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/async_connector.py +0 -0
  70. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/connector.py +0 -0
  71. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/helper.py +0 -0
  72. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/models.py +0 -0
  73. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/rabbitmq/service.py +0 -0
  74. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/__init__.py +0 -0
  75. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/async_connector.py +0 -0
  76. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/async_service.py +0 -0
  77. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/connector.py +0 -0
  78. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/models.py +0 -0
  79. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/sqs/service.py +0 -0
  80. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/connector/transaction.py +0 -0
  81. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/py.typed +0 -0
  82. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/service/__init__.py +0 -0
  83. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/service/service.py +0 -0
  84. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/__init__.py +0 -0
  85. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/base.py +0 -0
  86. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/exceptions.py +0 -0
  87. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/helpers.py +0 -0
  88. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/liveness.py +0 -0
  89. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/manager.py +0 -0
  90. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/mixins/__init__.py +0 -0
  91. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/mixins/metrics.py +0 -0
  92. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/mixins/producer.py +0 -0
  93. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/task/utils.py +0 -0
  94. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/telemetry/__init__.py +0 -0
  95. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/telemetry/_resource.py +0 -0
  96. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/telemetry/logging.py +0 -0
  97. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/telemetry/metrics.py +0 -0
  98. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/telemetry/tracing.py +0 -0
  99. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/utils/__init__.py +0 -0
  100. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon/utils/env.py +0 -0
  101. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
  102. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  103. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  104. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  105. {ergon_framework_python-0.1.7 → ergon_framework_python-0.1.9}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ergon-framework-python"
3
- version = "0.1.7"
3
+ version = "0.1.9"
4
4
  description = "Ergon internal task-oriented project bootstrapper"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -47,7 +47,7 @@ dev = [
47
47
  "pyright>=1.1.390",
48
48
  "pytest>=8.0.0",
49
49
  "pytest-asyncio>=0.25.0",
50
- "ruff>=0.6.0",
50
+ "ruff>=0.6.0,<0.16",
51
51
  ]
52
52
 
53
53
  [build-system]
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.1.7"
13
+ __version__ = "0.1.9"
@@ -155,11 +155,33 @@ class AsyncRabbitMQService:
155
155
 
156
156
  connection = self._connection
157
157
  self._connection = None
158
- if connection is not None and not connection.is_closed:
159
- try:
158
+ await self._close_connection_safely(connection, reason)
159
+
160
+ async def _close_connection_safely(
161
+ self,
162
+ connection: Optional[AbstractRobustConnection],
163
+ reason: str,
164
+ ) -> None:
165
+ """Stop transport and robust-reconnect ownership within a fixed deadline.
166
+
167
+ ``RobustConnection.is_closed`` only describes its current transport. Its
168
+ background reconnection task can still be alive after the broker closes
169
+ that transport, so ``close()`` must always run for an owned connection.
170
+ """
171
+ if connection is None:
172
+ return
173
+ close_timeout = min(5.0, self.client.connect_timeout)
174
+ try:
175
+ async with timeout_after(close_timeout):
160
176
  await connection.close()
161
- except Exception as exc: # noqa: BLE001 - best-effort reset
162
- logger.warning("Error closing RabbitMQ connection during reset (%s): %r", reason, exc)
177
+ except TimeoutError:
178
+ logger.error(
179
+ "Timed out closing RabbitMQ connection after %.1fs (%s)",
180
+ close_timeout,
181
+ reason,
182
+ )
183
+ except Exception as exc: # noqa: BLE001 - best-effort reset
184
+ logger.warning("Error closing RabbitMQ connection (%s): %r", reason, exc)
163
185
 
164
186
  def _invalidate_consume_channel(self, reason: str = "explicit invalidation") -> None:
165
187
  """Drop cached consume channel + queue/exchange handles.
@@ -752,9 +774,9 @@ class AsyncRabbitMQService:
752
774
  logger.warning("Error closing %s: %r", attr_name, exc)
753
775
  setattr(self, attr_name, None)
754
776
 
755
- if self._connection is not None and not self._connection.is_closed:
756
- await self._connection.close()
757
- self._connection = None
777
+ connection = self._connection
778
+ self._connection = None
779
+ await self._close_connection_safely(connection, "service close")
758
780
 
759
781
  self._active_consumer_tag = None
760
782
  self._active_consumer_tags.clear()
@@ -601,22 +601,28 @@ class AsyncConsumerMixin(ABC):
601
601
  # =====================================================================
602
602
  # FETCH HANDLER (ASYNC)
603
603
  # =====================================================================
604
- async def _handle_fetch(self, conn, policy: policies.FetchPolicy) -> tuple[bool, List[connector.Transaction]]:
604
+ async def _handle_fetch(
605
+ self,
606
+ conn,
607
+ policy: policies.FetchPolicy,
608
+ batch_size: int | None = None,
609
+ ) -> tuple[bool, List[connector.Transaction]]:
610
+ fetch_size = policy.batch.size if batch_size is None else batch_size
605
611
  self._mark_consumer_progress("fetching")
606
- logger.info(f"Fetch handler started for batch size {policy.batch.size}", extra=policy.extra)
612
+ logger.info(f"Fetch handler started for batch size {fetch_size}", extra=policy.extra)
607
613
  fetch_start = time.perf_counter()
608
614
  success, result = await helpers.run_fn_async(
609
- fn=lambda: conn.fetch_transactions_async(policy.batch.size, **policy.extra),
615
+ fn=lambda: conn.fetch_transactions_async(fetch_size, **policy.extra),
610
616
  retry=policy.retry,
611
617
  trace_name=f"{self.__class__.__name__}.fetch_transactions",
612
- trace_attrs={"batch_size": policy.batch.size},
618
+ trace_attrs={"batch_size": fetch_size},
613
619
  )
614
620
  # Record fetch metrics
615
621
  fetched_count = len(result) if success and result else 0
616
622
  mixin_metrics.record_consumer_fetch(
617
623
  task_name=getattr(self, "name", self.__class__.__name__),
618
624
  connector_name=conn.__class__.__name__,
619
- batch_size=policy.batch.size,
625
+ batch_size=fetch_size,
620
626
  fetched_count=fetched_count,
621
627
  duration=time.perf_counter() - fetch_start,
622
628
  success=success,
@@ -877,13 +883,146 @@ class AsyncConsumerMixin(ABC):
877
883
  )
878
884
  return await self._handle_exception(tr, timeout_error, pol.exception.retry)
879
885
 
886
+ async def _consume_continuous():
887
+ nonlocal batch_number, empty_count, processed
888
+
889
+ concurrency = policy.loop.concurrency.value
890
+ submitted = 0
891
+ in_flight: set[asyncio.Task] = set()
892
+
893
+ async def reap(done: set[asyncio.Task]) -> None:
894
+ nonlocal processed
895
+ for task in done:
896
+ try:
897
+ await task
898
+ except Exception as exc:
899
+ logger.error("[async] Execution error: %s", exc)
900
+ finally:
901
+ processed += 1
902
+
903
+ async def wait_for_completion() -> None:
904
+ done, _ = await asyncio.wait(
905
+ in_flight,
906
+ return_when=asyncio.FIRST_COMPLETED,
907
+ )
908
+ in_flight.difference_update(done)
909
+ await reap(done)
910
+ self._mark_consumer_progress("polling")
911
+
912
+ try:
913
+ while True:
914
+ if policy.loop.limit is not None and submitted >= policy.loop.limit:
915
+ if in_flight:
916
+ await wait_for_completion()
917
+ continue
918
+ break
919
+
920
+ free_slots = concurrency - len(in_flight)
921
+ if free_slots == 0:
922
+ await wait_for_completion()
923
+ continue
924
+
925
+ if policy.loop.limit is not None:
926
+ free_slots = min(free_slots, policy.loop.limit - submitted)
927
+
928
+ logger.debug(
929
+ "Fetching transactions for %d free continuous-consumer slot(s) with fetch policy: %s",
930
+ free_slots,
931
+ policy.fetch.model_dump_json(indent=2),
932
+ )
933
+ success, result = await self._handle_fetch(
934
+ conn,
935
+ policy.fetch,
936
+ batch_size=free_slots,
937
+ )
938
+
939
+ if not success:
940
+ logger.error("Fetch failed → %s", result)
941
+ # Unlike shutdown/cancellation, a fetch failure must not
942
+ # cancel work already in progress: let in-flight
943
+ # transactions finish (bounded by the transaction
944
+ # runtime timeout) before propagating the failure.
945
+ if in_flight:
946
+ logger.warning(
947
+ "Fetch failed with %d transaction(s) in flight; draining before raising",
948
+ len(in_flight),
949
+ )
950
+ await asyncio.gather(*in_flight, return_exceptions=True)
951
+ in_flight.clear()
952
+ if isinstance(result, (asyncio.TimeoutError, futures.TimeoutError)):
953
+ raise exceptions.FetchTimeoutException(str(result))
954
+ raise exceptions.FetchException(str(result))
955
+
956
+ transactions = result
957
+ if transactions:
958
+ empty_count = 0
959
+ batch_number += 1
960
+ self._mark_consumer_progress("processing")
961
+ logger.info(
962
+ "%d transaction(s) fetched for continuous processing",
963
+ len(transactions),
964
+ )
965
+ mixin_metrics.record_consumer_batch(
966
+ task_name=getattr(self, "name", self.__class__.__name__),
967
+ batch_number=batch_number,
968
+ batch_size=len(transactions),
969
+ streaming=policy.loop.streaming,
970
+ )
971
+ for transaction in transactions:
972
+ in_flight.add(
973
+ asyncio.create_task(
974
+ submit_start_processing(transaction, policy),
975
+ )
976
+ )
977
+ submitted += 1
978
+ continue
979
+
980
+ if in_flight:
981
+ await wait_for_completion()
982
+ continue
983
+
984
+ self._mark_consumer_progress("idle")
985
+ logger.info("Empty fetch detected at %s", datetime.now().isoformat())
986
+ if not policy.loop.streaming:
987
+ logger.info("Non-streaming mode detected, breaking loop")
988
+ break
989
+
990
+ mixin_metrics.record_consumer_empty_queue_wait(
991
+ task_name=getattr(self, "name", self.__class__.__name__),
992
+ wait_count=empty_count,
993
+ )
994
+ await utils.backoff_async(
995
+ backoff=policy.fetch.empty.backoff,
996
+ multiplier=policy.fetch.empty.backoff_multiplier,
997
+ cap=policy.fetch.empty.backoff_cap,
998
+ attempt=empty_count,
999
+ )
1000
+ empty_count += 1
1001
+ except BaseException:
1002
+ for task in in_flight:
1003
+ task.cancel()
1004
+ if in_flight:
1005
+ await asyncio.gather(*in_flight, return_exceptions=True)
1006
+ raise
1007
+
1008
+ elapsed_time = time.perf_counter() - start_time
1009
+ logger.info(
1010
+ "[Consume continuous] Finished. Processed=%d in %.2f seconds",
1011
+ processed,
1012
+ elapsed_time,
1013
+ )
1014
+ return processed
1015
+
1016
+ if policy.loop.mode == "continuous":
1017
+ return await _consume_continuous()
1018
+
880
1019
  while True:
881
1020
  batch_number += 1
882
1021
 
883
1022
  # ============================================================
884
1023
  # FETCH
885
1024
  # ============================================================
886
- logger.info(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
1025
+ logger.debug(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
887
1026
  success, result = await self._handle_fetch(conn, policy.fetch)
888
1027
 
889
1028
  if not success:
@@ -941,7 +1080,7 @@ class AsyncConsumerMixin(ABC):
941
1080
  else:
942
1081
  batch_context = None # Use current context
943
1082
 
944
- logger.info(
1083
+ logger.debug(
945
1084
  f"Starting batch processing of "
946
1085
  f"{len(transactions)} transaction(s) "
947
1086
  f"from fetch handler with "
@@ -1,4 +1,4 @@
1
- from typing import Optional, Union
1
+ from typing import Literal, Optional, Union
2
2
 
3
3
  from pydantic import BaseModel, Field, field_validator
4
4
 
@@ -161,11 +161,20 @@ class ExceptionPolicy(BaseModel):
161
161
 
162
162
 
163
163
  class ConsumerLoopPolicy(BaseModel):
164
+ mode: Literal["batch", "continuous"] = Field(default="batch")
164
165
  concurrency: ConcurrencyPolicy = Field(default_factory=ConcurrencyPolicy)
165
166
  timeout: Optional[float] = Field(default=None, ge=0)
166
167
  limit: Optional[int] = Field(default=None, ge=0)
167
168
  streaming: bool = Field(default=False)
168
169
 
170
+ @field_validator("mode", mode="before")
171
+ @classmethod
172
+ def _normalize_mode(cls, v):
173
+ v = _normalize_optional(v)
174
+ if v is None:
175
+ return "batch"
176
+ return v.strip().lower() if isinstance(v, str) else v
177
+
169
178
  @field_validator("timeout", "limit", mode="before")
170
179
  @classmethod
171
180
  def _normalize_optional_numbers(cls, v):
@@ -106,15 +106,18 @@ class _TaskLivenessSupervisor:
106
106
  self,
107
107
  provider: LivenessProvider,
108
108
  policy: TaskSupervisionPolicy,
109
- request_shutdown,
110
- hard_exit=os._exit,
109
+ request_shutdown: Callable[[str], None],
110
+ hard_exit: Callable[[int], None] | None = None,
111
111
  ) -> None:
112
112
  self.provider = provider
113
113
  self.policy = policy
114
114
  self.request_shutdown = request_shutdown
115
- self.hard_exit = hard_exit
115
+ self.hard_exit = hard_exit or os._exit
116
116
  self._stop = threading.Event()
117
117
  self._thread: threading.Thread | None = None
118
+ self._shutdown_lock = threading.Lock()
119
+ self._shutdown_requested_at: float | None = None
120
+ self._shutdown_reason: str | None = None
118
121
 
119
122
  def start(self) -> None:
120
123
  self._thread = threading.Thread(
@@ -129,13 +132,39 @@ class _TaskLivenessSupervisor:
129
132
  if self._thread is not None and self._thread is not threading.current_thread():
130
133
  self._thread.join(timeout=max(1.0, self.policy.check_interval * 2))
131
134
 
135
+ def arm_shutdown_deadline(self, reason: str) -> None:
136
+ """Force process exit if any cooperative shutdown or cleanup wedges."""
137
+ with self._shutdown_lock:
138
+ if self._shutdown_requested_at is not None:
139
+ return
140
+ self._shutdown_requested_at = time.monotonic()
141
+ self._shutdown_reason = reason
142
+ _supervisor_logger.warning(
143
+ "Task shutdown deadline armed for %.1fs (%s)",
144
+ self.policy.shutdown_grace,
145
+ reason,
146
+ )
147
+
132
148
  def _run(self) -> None:
133
149
  started_at = time.monotonic()
134
150
  unhealthy_since: float | None = None
135
- shutdown_requested_at: float | None = None
136
151
 
137
152
  while not self._stop.wait(self.policy.check_interval):
138
153
  now = time.monotonic()
154
+ with self._shutdown_lock:
155
+ shutdown_requested_at = self._shutdown_requested_at
156
+ shutdown_reason = self._shutdown_reason
157
+ if shutdown_requested_at is not None:
158
+ if now - shutdown_requested_at >= self.policy.shutdown_grace:
159
+ _supervisor_logger.critical(
160
+ "Task did not stop within %.1fs after shutdown began (%s); forcing exit",
161
+ self.policy.shutdown_grace,
162
+ shutdown_reason,
163
+ )
164
+ self._stop.set()
165
+ self.hard_exit(int(ExitCode.ERROR))
166
+ continue
167
+
139
168
  if now - started_at < self.policy.startup_grace:
140
169
  continue
141
170
 
@@ -157,22 +186,14 @@ class _TaskLivenessSupervisor:
157
186
  _supervisor_logger.error("Task liveness check failed: %s", reason)
158
187
  continue
159
188
 
160
- if shutdown_requested_at is None and now - unhealthy_since >= self.policy.unhealthy_grace:
161
- shutdown_requested_at = now
189
+ if now - unhealthy_since >= self.policy.unhealthy_grace:
162
190
  _supervisor_logger.critical(
163
191
  "Task remained unhealthy for %.1fs (%s); requesting process shutdown",
164
192
  now - unhealthy_since,
165
193
  reason,
166
194
  )
195
+ self.arm_shutdown_deadline(reason)
167
196
  self.request_shutdown(reason)
168
- continue
169
-
170
- if shutdown_requested_at is not None and now - shutdown_requested_at >= self.policy.shutdown_grace:
171
- _supervisor_logger.critical(
172
- "Task did not stop within %.1fs after liveness shutdown request; forcing exit",
173
- self.policy.shutdown_grace,
174
- )
175
- self.hard_exit(int(ExitCode.ERROR))
176
197
 
177
198
 
178
199
  # =============================================================
@@ -313,8 +334,10 @@ async def __run_task_async(
313
334
  raise
314
335
  finally:
315
336
  _unregister_shutdown_callback(request_signal_shutdown)
316
- if supervisor is not None and not liveness_shutdown_requested.is_set():
317
- supervisor.stop()
337
+ if supervisor is not None:
338
+ supervisor.arm_shutdown_deadline(
339
+ "liveness shutdown cleanup" if liveness_shutdown_requested.is_set() else "task cleanup"
340
+ )
318
341
  try:
319
342
  if instance is not None:
320
343
  await instance.exit()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -4,4 +4,4 @@ import ergon
4
4
 
5
5
 
6
6
  def test_ergon_package_is_importable() -> None:
7
- assert ergon.__version__ == "0.1.7"
7
+ assert ergon.__version__ == "0.1.9"