ergon-framework-python 0.3.0__tar.gz → 0.3.1__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 (133) hide show
  1. {ergon_framework_python-0.3.0/src/ergon_framework_python.egg-info → ergon_framework_python-0.3.1}/PKG-INFO +1 -1
  2. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/pyproject.toml +1 -1
  3. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/__init__.py +1 -1
  4. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/connector.py +4 -0
  5. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/connector.py +7 -0
  6. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/connector.py +5 -0
  7. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/connector.py +5 -0
  8. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/helpers.py +20 -1
  9. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/mixins/consumer.py +71 -2
  10. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/runner.py +114 -70
  11. ergon_framework_python-0.3.1/src/ergon/task/shutdown.py +275 -0
  12. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/utils.py +14 -3
  13. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1/src/ergon_framework_python.egg-info}/PKG-INFO +1 -1
  14. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon_framework_python.egg-info/SOURCES.txt +1 -0
  15. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/tests/test_smoke.py +1 -1
  16. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/LICENSE +0 -0
  17. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/README.md +0 -0
  18. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/setup.cfg +0 -0
  19. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  20. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  21. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  22. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  23. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  24. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  25. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  26. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  27. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  28. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  29. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  30. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  31. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  32. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  33. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  34. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  35. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  36. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  37. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  38. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  39. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  40. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/cli.py +0 -0
  41. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/__init__.py +0 -0
  42. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/_lazy.py +0 -0
  43. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/connector.py +0 -0
  44. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/__init__.py +0 -0
  45. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/_client.py +0 -0
  46. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/async_connector.py +0 -0
  47. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/__init__.py +0 -0
  48. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/adapters.py +0 -0
  49. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/async_connector.py +0 -0
  50. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/models.py +0 -0
  51. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/__init__.py +0 -0
  52. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/activity.py +0 -0
  53. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/addresses.py +0 -0
  54. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/attachments.py +0 -0
  55. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/messages.py +0 -0
  56. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/records.py +0 -0
  57. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/channels/services/service.py +0 -0
  58. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/connector.py +0 -0
  59. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/models.py +0 -0
  60. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/utils.py +0 -0
  61. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/__init__.py +0 -0
  62. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/_mode.py +0 -0
  63. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/_operations.py +0 -0
  64. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/async_connector.py +0 -0
  65. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/models.py +0 -0
  66. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/ergon_platform/workflows/utils.py +0 -0
  67. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/excel/__init__.py +0 -0
  68. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/excel/connector.py +0 -0
  69. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/excel/models.py +0 -0
  70. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/excel/service.py +0 -0
  71. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/__init__.py +0 -0
  72. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/async_auth_service.py +0 -0
  73. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/async_connector.py +0 -0
  74. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/async_service.py +0 -0
  75. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/auth_service.py +0 -0
  76. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/models.py +0 -0
  77. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/service.py +0 -0
  78. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/nylas/utils.py +0 -0
  79. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/__init__.py +0 -0
  80. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/async_connector.py +0 -0
  81. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/async_service.py +0 -0
  82. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/models.py +0 -0
  83. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/service.py +0 -0
  84. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/outlook/utils.py +0 -0
  85. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/__init__.py +0 -0
  86. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/async_connector.py +0 -0
  87. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/async_service.py +0 -0
  88. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/connector.py +0 -0
  89. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/models.py +0 -0
  90. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/service.py +0 -0
  91. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/pipefy/version.py +0 -0
  92. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/postgres/__init__.py +0 -0
  93. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/postgres/async_connector.py +0 -0
  94. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/postgres/async_service.py +0 -0
  95. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/postgres/models.py +0 -0
  96. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  97. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/async_connector.py +0 -0
  98. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/async_service.py +0 -0
  99. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/connector.py +0 -0
  100. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/helper.py +0 -0
  101. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/models.py +0 -0
  102. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/rabbitmq/service.py +0 -0
  103. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/__init__.py +0 -0
  104. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/async_connector.py +0 -0
  105. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/async_service.py +0 -0
  106. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/connector.py +0 -0
  107. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/models.py +0 -0
  108. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/sqs/service.py +0 -0
  109. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/connector/transaction.py +0 -0
  110. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/py.typed +0 -0
  111. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/service/__init__.py +0 -0
  112. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/service/service.py +0 -0
  113. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/__init__.py +0 -0
  114. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/base.py +0 -0
  115. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/exceptions.py +0 -0
  116. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/liveness.py +0 -0
  117. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/manager.py +0 -0
  118. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/mixins/__init__.py +0 -0
  119. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/mixins/metrics.py +0 -0
  120. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/mixins/producer.py +0 -0
  121. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/task/policies.py +0 -0
  122. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/telemetry/__init__.py +0 -0
  123. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/telemetry/_resource.py +0 -0
  124. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/telemetry/logging.py +0 -0
  125. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/telemetry/metrics.py +0 -0
  126. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/telemetry/tracing.py +0 -0
  127. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/utils/__init__.py +0 -0
  128. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon/utils/env.py +0 -0
  129. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  130. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  131. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  132. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
  133. {ergon_framework_python-0.3.0 → ergon_framework_python-0.3.1}/tests/test_optional_connectors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.3.0"
3
+ version = "0.3.1"
4
4
  description = "Ergon internal task-oriented project bootstrapper"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.3.0"
13
+ __version__ = "0.3.1"
@@ -208,6 +208,10 @@ class ErgonPlatformChannelsConnector(Connector):
208
208
  if requeue and transaction.id:
209
209
  self._seen_event_ids.discard(transaction.id)
210
210
 
211
+ def release_transaction(self, transaction: Transaction) -> None:
212
+ """Give back a fetched but unstarted inbox event (worker shutdown): requeued with no delay."""
213
+ self.nack_transaction(transaction, requeue=True, delay_seconds=0)
214
+
211
215
  def download_attachments(
212
216
  self,
213
217
  transaction: Transaction,
@@ -159,6 +159,13 @@ class ErgonPlatformConnector(Connector):
159
159
  **fields,
160
160
  )
161
161
 
162
+ def release_transaction(self, transaction: Transaction) -> None:
163
+ """Give back a transaction that was fetched but never started (worker shutdown).
164
+
165
+ A plain release: no failure reason, no cooldown, so it is picked up again right away.
166
+ """
167
+ self.release_item(transaction.id)
168
+
162
169
  def nack_transaction(
163
170
  self,
164
171
  transaction: Transaction,
@@ -119,5 +119,10 @@ class NylasConnector(Connector):
119
119
  return
120
120
  logger.debug("nack_transaction is a no-op for Nylas; message %s remains available for refetch", transaction.id)
121
121
 
122
+ def release_transaction(self, transaction: Transaction) -> None:
123
+ """Give back a fetched but unstarted message (worker shutdown): unchanged, refetchable."""
124
+ self.service.reset_pagination()
125
+ logger.debug("Nylas message %s released unchanged", transaction.id)
126
+
122
127
  def close(self) -> None:
123
128
  self.service.close()
@@ -194,5 +194,10 @@ class OutlookGraphConnector(Connector):
194
194
  self.service.reset_pagination()
195
195
  logger.debug("Outlook message %s was made available for refetch", transaction.id)
196
196
 
197
+ def release_transaction(self, transaction: Transaction) -> None:
198
+ """Give back a fetched but unstarted message (worker shutdown): unchanged, refetchable."""
199
+ self.service.reset_pagination()
200
+ logger.debug("Outlook message %s released unchanged", transaction.id)
201
+
197
202
  def close(self) -> None:
198
203
  self.service.close()
@@ -309,12 +309,17 @@ def run_fn(
309
309
  return run()
310
310
 
311
311
 
312
+ _STOP_POLL_SECONDS = 0.25
313
+
314
+
312
315
  def multithread_execute(
313
316
  submissions: Iterable[Callable[[], futures.Future]],
314
317
  *,
315
318
  concurrency: int,
316
319
  limit: Optional[int] = None,
317
320
  timeout: Optional[float] = None,
321
+ stop: Optional[Callable[[], bool]] = None,
322
+ on_stop: Optional[Callable[[], None]] = None,
318
323
  ) -> int:
319
324
  """
320
325
  Execute submitted callables with bounded concurrency and refill.
@@ -322,6 +327,8 @@ def multithread_execute(
322
327
  - submissions yields callables that RETURN a Future when called
323
328
  - this function manages in-flight futures only
324
329
  - no domain semantics, no retries, no tracing
330
+ - ``stop`` is polled while futures are in flight; once it returns True nothing more is
331
+ submitted, ``on_stop`` runs once right away, and the in-flight futures are still awaited
325
332
 
326
333
  Returns:
327
334
  Number of completed submissions
@@ -329,6 +336,10 @@ def multithread_execute(
329
336
 
330
337
  in_flight: dict[futures.Future, float] = {}
331
338
  completed = 0
339
+ stopping = False
340
+ wait_timeout = timeout
341
+ if stop is not None:
342
+ wait_timeout = _STOP_POLL_SECONDS if timeout is None else min(timeout, _STOP_POLL_SECONDS)
332
343
 
333
344
  submit_iter = iter(submissions)
334
345
 
@@ -363,7 +374,7 @@ def multithread_execute(
363
374
  done, _ = futures.wait(
364
375
  in_flight.keys(),
365
376
  return_when=futures.FIRST_COMPLETED,
366
- timeout=timeout,
377
+ timeout=wait_timeout,
367
378
  )
368
379
 
369
380
  # ----------------------------
@@ -391,6 +402,14 @@ def multithread_execute(
391
402
  in_flight.pop(fut)
392
403
  completed += 1
393
404
 
405
+ if not stopping and stop is not None and stop():
406
+ stopping = True
407
+ logger.info("Stop requested; waiting for %d in-flight submission(s), submitting no more", len(in_flight))
408
+ if on_stop is not None:
409
+ on_stop()
410
+ if stopping:
411
+ continue
412
+
394
413
  # ============================================================
395
414
  # REFILL
396
415
  # ============================================================
@@ -10,7 +10,7 @@ from async_timeout import timeout as timeout_after
10
10
  from opentelemetry import context as otel_context
11
11
 
12
12
  from ... import connector, telemetry
13
- from .. import base, exceptions, helpers, liveness, policies, utils
13
+ from .. import base, exceptions, helpers, liveness, policies, shutdown, utils
14
14
  from . import metrics as mixin_metrics
15
15
  from . import producer
16
16
 
@@ -304,6 +304,38 @@ class ConsumerMixin(ABC):
304
304
  return next(iter(self.connectors.values()))
305
305
  raise ValueError("Multiple connectors configured; specify one in policy")
306
306
 
307
+ # =====================================================================
308
+ # SHUTDOWN HAND-BACK
309
+ # =====================================================================
310
+ def _hand_back_unstarted(self, conn, transactions: List[connector.Transaction]) -> None:
311
+ """Give back fetched transactions that were never started because the process is stopping.
312
+
313
+ ``release_transaction`` (no verdict) when the connector has it, else
314
+ ``nack_transaction(requeue=True)``. Never raises: the stop must go on, and a
315
+ transaction that cannot be handed back returns when its claim or lease expires.
316
+ """
317
+ release = getattr(conn, "release_transaction", None)
318
+ nack = getattr(conn, "nack_transaction", None)
319
+ for tr in transactions:
320
+ try:
321
+ if callable(release):
322
+ release(tr)
323
+ elif callable(nack):
324
+ nack(tr, requeue=True)
325
+ else:
326
+ logger.warning(
327
+ "Connector %s cannot hand back transaction %s; it returns when its claim or lease expires",
328
+ conn.__class__.__name__,
329
+ tr.id,
330
+ )
331
+ continue
332
+ logger.info("Handed back unstarted transaction %s", tr.id)
333
+ except Exception:
334
+ logger.exception(
335
+ "Could not hand back transaction %s; it returns when its claim or lease expires",
336
+ tr.id,
337
+ )
338
+
307
339
  # =====================================================================
308
340
  # PUBLIC CONSUME LOOP
309
341
  # =====================================================================
@@ -338,6 +370,10 @@ class ConsumerMixin(ABC):
338
370
  )
339
371
 
340
372
  while True:
373
+ if shutdown.is_requested():
374
+ logger.info("Shutdown requested; consume loop stops before the next fetch")
375
+ break
376
+
341
377
  batch_number += 1
342
378
 
343
379
  # -------------------------
@@ -345,6 +381,9 @@ class ConsumerMixin(ABC):
345
381
  # -------------------------
346
382
  logger.debug(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
347
383
  success, result = self._handle_fetch(conn, policy.fetch)
384
+ if not success and shutdown.is_requested():
385
+ logger.warning(f"Fetch failed while stopping; nothing was fetched, stopping → {result}")
386
+ break
348
387
  if not success:
349
388
  logger.error(f"Fetch failed → {result}")
350
389
  executor.shutdown(wait=False)
@@ -354,6 +393,14 @@ class ConsumerMixin(ABC):
354
393
 
355
394
  transactions = result
356
395
 
396
+ if transactions and shutdown.is_requested():
397
+ logger.info(
398
+ "Shutdown requested during fetch; handing back %d unstarted transaction(s)",
399
+ len(transactions),
400
+ )
401
+ self._hand_back_unstarted(conn, list(transactions))
402
+ break
403
+
357
404
  # -------------------------
358
405
  # EMPTY QUEUE HANDLING
359
406
  # -------------------------
@@ -375,6 +422,7 @@ class ConsumerMixin(ABC):
375
422
  policy.fetch.empty.backoff_multiplier,
376
423
  policy.fetch.empty.backoff_cap,
377
424
  empty_count,
425
+ interruptible=True,
378
426
  )
379
427
  empty_count += 1
380
428
  continue
@@ -417,11 +465,24 @@ class ConsumerMixin(ABC):
417
465
  "streaming": policy.loop.streaming,
418
466
  },
419
467
  ):
468
+ not_started = list(transactions)
420
469
 
421
470
  def submissions():
422
- for tr in transactions:
471
+ while not_started and not shutdown.is_requested():
472
+ tr = not_started.pop(0)
423
473
  yield lambda tr=tr: submit_start_processing(tr, policy)
424
474
 
475
+ def hand_back_not_started():
476
+ remaining = list(not_started)
477
+ not_started.clear()
478
+ if remaining:
479
+ logger.info(
480
+ "Shutdown requested; handing back %d unstarted transaction(s) of this batch, "
481
+ "in-flight ones will finish",
482
+ len(remaining),
483
+ )
484
+ self._hand_back_unstarted(conn, remaining)
485
+
425
486
  logger.debug(
426
487
  f"Submitting {len(transactions)} transactions for processing "
427
488
  f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
@@ -432,10 +493,17 @@ class ConsumerMixin(ABC):
432
493
  concurrency=policy.loop.concurrency.value,
433
494
  limit=policy.loop.limit,
434
495
  timeout=policy.transaction_runtime.timeout,
496
+ stop=shutdown.is_requested,
497
+ on_stop=hand_back_not_started,
435
498
  )
436
499
 
437
500
  processed += count
438
501
 
502
+ if shutdown.is_requested():
503
+ hand_back_not_started()
504
+ logger.info("Shutdown requested; consume loop stops after the in-flight transactions")
505
+ break
506
+
439
507
  if policy.loop.limit and processed >= policy.loop.limit:
440
508
  break
441
509
 
@@ -446,6 +514,7 @@ class ConsumerMixin(ABC):
446
514
  multiplier=policy.fetch.batch.interval.backoff_multiplier,
447
515
  cap=policy.fetch.batch.interval.backoff_cap,
448
516
  attempt=0,
517
+ interruptible=True,
449
518
  )
450
519
 
451
520
  executor.shutdown()
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging as stdlib_logging
4
+ import multiprocessing
5
+ import multiprocessing.connection
4
6
  import os
5
7
  import signal
6
8
  import threading
@@ -8,13 +10,13 @@ import time
8
10
  import traceback
9
11
  import uuid
10
12
  from collections.abc import Callable
11
- from concurrent.futures import ProcessPoolExecutor
12
13
  from datetime import datetime
13
14
  from enum import IntEnum
14
15
  from typing import Any, Literal
15
16
 
16
17
  from ..connector import Transaction
17
18
  from ..telemetry import logging, metrics, tracing
19
+ from . import shutdown
18
20
  from .base import (
19
21
  BaseAsyncTask,
20
22
  BaseTask,
@@ -38,63 +40,24 @@ class ExitCode(IntEnum):
38
40
 
39
41
 
40
42
  # =============================================================
41
- # SHUTDOWN STATE (PROCESS-LOCAL)
43
+ # SHUTDOWN STATE (PROCESS-LOCAL, see ``shutdown``)
42
44
  # =============================================================
43
45
 
44
- _shutdown_event = threading.Event()
45
- _shutdown_signal: int | None = None
46
- _shutdown_callbacks: set[Callable[[], None]] = set()
47
- _shutdown_callbacks_lock = threading.Lock()
48
46
  _supervisor_logger = stdlib_logging.getLogger(__name__)
49
47
 
50
-
51
- def _signal_handler(signum, frame):
52
- global _shutdown_signal
53
- _shutdown_signal = signum
54
- _shutdown_event.set()
55
- with _shutdown_callbacks_lock:
56
- callbacks = tuple(_shutdown_callbacks)
57
- for callback in callbacks:
58
- try:
59
- callback()
60
- except Exception:
61
- _supervisor_logger.exception("Async shutdown callback failed")
62
-
63
-
64
- def _install_signal_handlers():
65
- signal.signal(signal.SIGINT, _signal_handler)
66
- signal.signal(signal.SIGTERM, _signal_handler)
67
-
68
-
69
- def _reset_shutdown_state() -> None:
70
- global _shutdown_signal
71
- _shutdown_event.clear()
72
- _shutdown_signal = None
73
- with _shutdown_callbacks_lock:
74
- _shutdown_callbacks.clear()
75
-
76
-
77
- def _register_shutdown_callback(callback: Callable[[], None]) -> None:
78
- with _shutdown_callbacks_lock:
79
- _shutdown_callbacks.add(callback)
80
- shutdown_requested = _shutdown_event.is_set()
81
- if shutdown_requested:
82
- callback()
83
-
84
-
85
- def _unregister_shutdown_callback(callback: Callable[[], None]) -> None:
86
- with _shutdown_callbacks_lock:
87
- _shutdown_callbacks.discard(callback)
88
-
89
-
90
- def is_shutdown_requested() -> bool:
91
- return _shutdown_event.is_set()
48
+ _signal_handler = shutdown._handle_signal
49
+ _install_signal_handlers = shutdown.install_handlers
50
+ _reset_shutdown_state = shutdown.reset
51
+ _register_shutdown_callback = shutdown.register_callback
52
+ _unregister_shutdown_callback = shutdown.unregister_callback
53
+ is_shutdown_requested = shutdown.is_requested
92
54
 
93
55
 
94
56
  def get_shutdown_exit_code() -> ExitCode:
95
- if _shutdown_signal == signal.SIGINT:
57
+ requested = shutdown.requested_signal()
58
+ if requested == signal.SIGINT:
96
59
  return ExitCode.SIGINT
97
- if _shutdown_signal == signal.SIGTERM:
60
+ if requested == signal.SIGTERM:
98
61
  return ExitCode.SIGTERM
99
62
  return ExitCode.ERROR
100
63
 
@@ -497,6 +460,103 @@ def __run_task_sync(
497
460
  instance.exit()
498
461
 
499
462
 
463
+ def _run_worker_sync(config: TaskConfig, mode: Literal["task", "transaction"] = "task", *args, **kwargs):
464
+ """Worker-process entrypoint: cooperative handlers, no escalation, keeps an early stop request."""
465
+ shutdown.prepare_worker()
466
+ if shutdown.is_requested():
467
+ _supervisor_logger.warning("Stop requested before worker %s started; not running it", kwargs.get("worker_id"))
468
+ return None
469
+ return __run_task_sync(config, mode, *args, **kwargs)
470
+
471
+
472
+ def _worker_signal_relays(worker_pids: Callable[[], list[int]]) -> tuple[Callable[[], None], Callable[[], None]]:
473
+ """(forward the parent's signal to the workers, SIGKILL the workers).
474
+
475
+ ``worker_pids`` is read on every call, so workers started after registration count.
476
+ ``kill_workers`` runs right before the process exits, so it does not log.
477
+ """
478
+
479
+ def _send(signum: int) -> list[tuple[int, OSError]]:
480
+ failures = []
481
+ for pid in worker_pids():
482
+ try:
483
+ os.kill(pid, signum)
484
+ except ProcessLookupError:
485
+ continue
486
+ except OSError as exc:
487
+ failures.append((pid, exc))
488
+ return failures
489
+
490
+ def forward_shutdown() -> None:
491
+ for pid, exc in _send(shutdown.requested_signal() or signal.SIGTERM):
492
+ _supervisor_logger.error("Could not signal worker process %s: %s", pid, exc)
493
+
494
+ def kill_workers() -> None:
495
+ _send(signal.SIGKILL)
496
+
497
+ return forward_shutdown, kill_workers
498
+
499
+
500
+ def _start_worker(config: TaskConfig, mode: Literal["task", "transaction"], args: tuple, worker_kwargs: dict):
501
+ process = multiprocessing.Process(
502
+ target=_run_worker_sync,
503
+ args=(config, mode, *args),
504
+ kwargs=worker_kwargs,
505
+ name=f"{config.name}-worker-{worker_kwargs['worker_id']}",
506
+ )
507
+ process.start()
508
+ return process
509
+
510
+
511
+ def _run_workers(config: TaskConfig, mode: Literal["task", "transaction"], args: tuple, kwargs: dict) -> bool:
512
+ """Run ``max_workers`` worker processes to completion; True when one of them failed.
513
+
514
+ Plain processes, not a pool: a worker whose task returns simply exits. A worker that
515
+ fails or dies stops its siblings cooperatively, as a broken pool used to.
516
+ """
517
+ processes: list[multiprocessing.Process] = []
518
+ has_error = False
519
+
520
+ def alive_pids() -> list[int]:
521
+ return [p.pid for p in processes if p.pid is not None and p.exitcode is None]
522
+
523
+ forward_shutdown, kill_workers = _worker_signal_relays(alive_pids)
524
+ shutdown.register_callback(forward_shutdown)
525
+ shutdown.register_escalation_callback(kill_workers)
526
+ try:
527
+ # Workers inherit the blocked mask, so a signal that arrives while they start
528
+ # stays pending until ``prepare_worker`` installed their handlers.
529
+ with shutdown.blocked_signals():
530
+ for worker_id in range(config.max_workers):
531
+ if is_shutdown_requested():
532
+ break
533
+ worker_kwargs = {**kwargs, "worker_id": worker_id, "total_workers": config.max_workers}
534
+ processes.append(_start_worker(config, mode, args, worker_kwargs))
535
+ if is_shutdown_requested():
536
+ forward_shutdown()
537
+
538
+ running = list(processes)
539
+ while running:
540
+ multiprocessing.connection.wait([p.sentinel for p in running], timeout=1.0)
541
+ for p in [p for p in running if p.exitcode is not None]:
542
+ running.remove(p)
543
+ p.join()
544
+ if p.exitcode == 0:
545
+ continue
546
+ has_error = True
547
+ _supervisor_logger.error("Worker process %s (pid %s) exited with %s", p.name, p.pid, p.exitcode)
548
+ for sibling in running:
549
+ if sibling.pid is not None:
550
+ try:
551
+ os.kill(sibling.pid, signal.SIGTERM)
552
+ except ProcessLookupError:
553
+ pass
554
+ finally:
555
+ shutdown.unregister_callback(forward_shutdown)
556
+ shutdown.unregister_escalation_callback(kill_workers)
557
+ return has_error
558
+
559
+
500
560
  # =============================================================
501
561
  # PUBLIC API — RUNNER
502
562
  # =============================================================
@@ -515,9 +575,10 @@ def run_task(
515
575
  Returns POSIX-compatible exit code.
516
576
  """
517
577
 
518
- _reset_shutdown_state()
519
- _install_signal_handlers()
520
578
  is_async = issubclass(config.task, BaseAsyncTask) # type: ignore[arg-type]
579
+ _reset_shutdown_state()
580
+ # Async: every signal re-cancels and the liveness supervisor owns the hard deadline.
581
+ _install_signal_handlers(escalate=not is_async)
521
582
 
522
583
  # ---------------------------------------------------------
523
584
  # SINGLE PROCESS
@@ -552,24 +613,7 @@ def run_task(
552
613
  if is_async:
553
614
  raise RuntimeError("Async tasks cannot be executed with multiple processes. Use debug=True or max_workers=1.")
554
615
 
555
- has_error = False
556
-
557
- with ProcessPoolExecutor(max_workers=config.max_workers) as executor:
558
- futures = []
559
- for worker_id in range(config.max_workers):
560
- worker_kwargs = {
561
- **kwargs,
562
- "worker_id": worker_id,
563
- "total_workers": config.max_workers,
564
- }
565
- futures.append(executor.submit(__run_task_sync, config, mode, *args, **worker_kwargs))
566
-
567
- for f in futures:
568
- try:
569
- f.result()
570
- except Exception:
571
- traceback.print_exc()
572
- has_error = True
616
+ has_error = _run_workers(config, mode, args, kwargs)
573
617
 
574
618
  if is_shutdown_requested():
575
619
  return int(get_shutdown_exit_code())