ergon-framework-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
ergon/task/policies.py ADDED
@@ -0,0 +1,229 @@
1
+ from typing import Optional, Union
2
+
3
+ from pydantic import BaseModel, Field, field_validator
4
+
5
+ # =====================================================================
6
+ # INTERNAL NORMALIZERS (framework-level helpers)
7
+ # =====================================================================
8
+
9
+
10
+ def _normalize_optional(v):
11
+ """
12
+ Normalize optional env-driven values.
13
+
14
+ Accepts:
15
+ - None
16
+ - ""
17
+ - "none" / "null"
18
+ - numeric strings
19
+
20
+ Lets Pydantic handle final coercion.
21
+ """
22
+ if v is None:
23
+ return None
24
+
25
+ if isinstance(v, str):
26
+ v = v.strip()
27
+ if v == "" or v.lower() in {"none", "null"}:
28
+ return None
29
+
30
+ return v
31
+
32
+
33
+ def _normalize_bool(v):
34
+ if isinstance(v, str):
35
+ return v.strip().lower() in {"1", "true", "yes", "on"}
36
+ return v
37
+
38
+
39
+ # =====================================================================
40
+ # SHARED MODELS
41
+ # =====================================================================
42
+
43
+
44
+ class ConcurrencyPolicy(BaseModel):
45
+ value: int = Field(default=1, ge=1)
46
+ headroom: int = Field(default=0, ge=0)
47
+ min: int = Field(default=1, ge=1)
48
+ max: int = Field(default=1, ge=1)
49
+
50
+ @field_validator("value", "headroom", "min", "max", mode="before")
51
+ @classmethod
52
+ def _normalize_ints(cls, v):
53
+ return _normalize_optional(v)
54
+
55
+
56
+ class BatchIntervalPolicy(BaseModel):
57
+ backoff: float = Field(default=0.0, ge=0.0)
58
+ backoff_multiplier: float = Field(default=1.0, ge=0.0)
59
+ backoff_cap: float = Field(default=0.0, ge=0.0)
60
+ interval: float = Field(default=0.0, ge=0.0)
61
+
62
+ @field_validator(
63
+ "backoff",
64
+ "backoff_multiplier",
65
+ "backoff_cap",
66
+ "interval",
67
+ mode="before",
68
+ )
69
+ @classmethod
70
+ def _normalize_numbers(cls, v):
71
+ return _normalize_optional(v)
72
+
73
+
74
+ class BatchPolicy(BaseModel):
75
+ size: int = Field(default=1, ge=1)
76
+ min_size: int = Field(default=1, ge=1)
77
+ max_size: int = Field(default=1, ge=1)
78
+ interval: BatchIntervalPolicy = Field(default_factory=BatchIntervalPolicy)
79
+
80
+ @field_validator("size", "min_size", "max_size", "interval", mode="before")
81
+ @classmethod
82
+ def _normalize_numbers(cls, v):
83
+ return _normalize_optional(v)
84
+
85
+
86
+ class RetryPolicy(BaseModel):
87
+ max_attempts: int = Field(default=1, ge=1)
88
+ timeout: Optional[float] = Field(default=None, ge=0)
89
+ backoff: float = Field(default=0.0, ge=0.0)
90
+ backoff_multiplier: float = Field(default=0.0, ge=1.0)
91
+ backoff_cap: float = Field(default=0.0, ge=0.0)
92
+
93
+ @field_validator(
94
+ "max_attempts",
95
+ "timeout",
96
+ "backoff",
97
+ "backoff_multiplier",
98
+ "backoff_cap",
99
+ mode="before",
100
+ )
101
+ @classmethod
102
+ def _normalize_numbers(cls, v):
103
+ return _normalize_optional(v)
104
+
105
+
106
+ class TransactionRuntimePolicy(BaseModel):
107
+ timeout: Optional[float] = Field(default=60.0, ge=0)
108
+
109
+ @field_validator("timeout", mode="before")
110
+ @classmethod
111
+ def _normalize_optional_numbers(cls, v):
112
+ return _normalize_optional(v)
113
+
114
+
115
+ # =====================================================================
116
+ # CONSUMER STEP POLICIES
117
+ # =====================================================================
118
+
119
+
120
+ class EmptyFetchPolicy(BaseModel):
121
+ backoff: float = Field(default=0.0, ge=0.0)
122
+ backoff_multiplier: float = Field(default=1.0, ge=0.0)
123
+ backoff_cap: float = Field(default=0.0, ge=0.0)
124
+ interval: float = Field(default=0.0, ge=0.0)
125
+
126
+ @field_validator(
127
+ "backoff",
128
+ "backoff_multiplier",
129
+ "backoff_cap",
130
+ "interval",
131
+ mode="before",
132
+ )
133
+ @classmethod
134
+ def _normalize_numbers(cls, v):
135
+ return _normalize_optional(v)
136
+
137
+
138
+ class FetchPolicy(BaseModel):
139
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
140
+ batch: BatchPolicy = Field(default_factory=BatchPolicy)
141
+ empty: EmptyFetchPolicy = Field(default_factory=EmptyFetchPolicy)
142
+ connector_name: Optional[str] = None
143
+ extra: dict = Field(default_factory=dict)
144
+
145
+
146
+ class ProcessPolicy(BaseModel):
147
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
148
+
149
+
150
+ class SuccessPolicy(BaseModel):
151
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
152
+
153
+
154
+ class ExceptionPolicy(BaseModel):
155
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
156
+
157
+
158
+ # =====================================================================
159
+ # CONSUMER LOOP POLICIES
160
+ # =====================================================================
161
+
162
+
163
+ class ConsumerLoopPolicy(BaseModel):
164
+ concurrency: ConcurrencyPolicy = Field(default_factory=ConcurrencyPolicy)
165
+ timeout: Optional[float] = Field(default=None, ge=0)
166
+ limit: Optional[int] = Field(default=None, ge=0)
167
+ streaming: bool = Field(default=False)
168
+
169
+ @field_validator("timeout", "limit", mode="before")
170
+ @classmethod
171
+ def _normalize_optional_numbers(cls, v):
172
+ return _normalize_optional(v)
173
+
174
+ @field_validator("streaming", mode="before")
175
+ @classmethod
176
+ def _normalize_streaming(cls, v):
177
+ return _normalize_bool(v)
178
+
179
+
180
+ # =====================================================================
181
+ # CONSUMER POLICY
182
+ # =====================================================================
183
+
184
+
185
+ class ConsumerPolicy(BaseModel):
186
+ name: Optional[str] = None
187
+ loop: ConsumerLoopPolicy = Field(default_factory=ConsumerLoopPolicy)
188
+ fetch: FetchPolicy = Field(default_factory=FetchPolicy)
189
+ transaction_runtime: TransactionRuntimePolicy = Field(default_factory=TransactionRuntimePolicy)
190
+ process: ProcessPolicy = Field(default_factory=ProcessPolicy)
191
+ success: SuccessPolicy = Field(default_factory=SuccessPolicy)
192
+ exception: ExceptionPolicy = Field(default_factory=ExceptionPolicy)
193
+
194
+
195
+ # =====================================================================
196
+ # PRODUCER POLICIES
197
+ # =====================================================================
198
+
199
+
200
+ class PreparePolicy(BaseModel):
201
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
202
+
203
+
204
+ class ProducerLoopPolicy(BaseModel):
205
+ concurrency: ConcurrencyPolicy = Field(default_factory=ConcurrencyPolicy)
206
+ batch: BatchPolicy = Field(default_factory=BatchPolicy)
207
+ timeout: Optional[float] = Field(default=None, ge=0)
208
+ limit: Optional[int] = Field(default=None, ge=0)
209
+
210
+ @field_validator("timeout", "limit", mode="before")
211
+ @classmethod
212
+ def _normalize_optional_numbers(cls, v):
213
+ return _normalize_optional(v)
214
+
215
+
216
+ class ProducerPolicy(BaseModel):
217
+ name: Optional[str] = None
218
+ loop: ProducerLoopPolicy = Field(default_factory=ProducerLoopPolicy)
219
+ transaction_runtime: TransactionRuntimePolicy = Field(default_factory=TransactionRuntimePolicy)
220
+ prepare: PreparePolicy = Field(default_factory=PreparePolicy)
221
+ success: SuccessPolicy = Field(default_factory=SuccessPolicy)
222
+ exception: ExceptionPolicy = Field(default_factory=ExceptionPolicy)
223
+
224
+
225
+ # =====================================================================
226
+ # UNION
227
+ # =====================================================================
228
+
229
+ ExecutionPolicy = Union[ConsumerPolicy, ProducerPolicy]
ergon/task/runner.py ADDED
@@ -0,0 +1,386 @@
1
+ import asyncio
2
+ import os
3
+ import signal
4
+ import threading
5
+ import traceback
6
+ import uuid
7
+ from concurrent.futures import ProcessPoolExecutor
8
+ from datetime import datetime
9
+ from enum import IntEnum
10
+ from typing import Any, Literal
11
+
12
+ from ..connector import Transaction
13
+ from ..telemetry import logging, metrics, tracing
14
+ from .base import (
15
+ BaseAsyncTask,
16
+ BaseTask,
17
+ TaskConfig,
18
+ TaskExecMetadata,
19
+ )
20
+
21
+ # =============================================================
22
+ # EXIT CODES (POSIX-ALIGNED)
23
+ # =============================================================
24
+
25
+
26
+ class ExitCode(IntEnum):
27
+ SUCCESS = 0
28
+ ERROR = 1
29
+ CONFIG_ERROR = 2
30
+
31
+ SIGINT = 130 # 128 + SIGINT(2)
32
+ SIGTERM = 143 # 128 + SIGTERM(15)
33
+
34
+
35
+ # =============================================================
36
+ # SHUTDOWN STATE (PROCESS-LOCAL)
37
+ # =============================================================
38
+
39
+ _shutdown_event = threading.Event()
40
+ _shutdown_signal: int | None = None
41
+
42
+
43
+ def _signal_handler(signum, frame):
44
+ global _shutdown_signal
45
+ _shutdown_signal = signum
46
+ _shutdown_event.set()
47
+
48
+
49
+ def _install_signal_handlers():
50
+ signal.signal(signal.SIGINT, _signal_handler)
51
+ signal.signal(signal.SIGTERM, _signal_handler)
52
+
53
+
54
+ def is_shutdown_requested() -> bool:
55
+ return _shutdown_event.is_set()
56
+
57
+
58
+ def get_shutdown_exit_code() -> ExitCode:
59
+ if _shutdown_signal == signal.SIGINT:
60
+ return ExitCode.SIGINT
61
+ if _shutdown_signal == signal.SIGTERM:
62
+ return ExitCode.SIGTERM
63
+ return ExitCode.ERROR
64
+
65
+
66
+ # =============================================================
67
+ # TELEMETRY INITIALIZATION
68
+ # =============================================================
69
+
70
+
71
+ def __init_telemetry(config: TaskConfig, task: object, task_exec_metadata: dict[str, Any]):
72
+ if config.logging is not None:
73
+ logging._apply_logging_config(cfg=config.logging, task=task, metadata=task_exec_metadata)
74
+
75
+ if config.tracing is not None:
76
+ tracing._apply_tracing_config(cfg=config.tracing, metadata=task_exec_metadata)
77
+
78
+ if config.metrics is not None:
79
+ metrics._apply_metrics_config(cfg=config.metrics, metadata=task_exec_metadata)
80
+
81
+
82
+ # =============================================================
83
+ # ASYNC TRANSACTION EXECUTION
84
+ # =============================================================
85
+
86
+
87
+ async def __run_transaction_async(
88
+ instance: BaseAsyncTask,
89
+ policy: str,
90
+ transaction: Transaction | None = None,
91
+ transaction_id: str | None = None,
92
+ ):
93
+ policy_obj = next((p for p in instance.policies if p.name == policy), None)
94
+ if not policy_obj:
95
+ raise ValueError(f"Policy '{policy}' not found")
96
+
97
+ if not transaction and not transaction_id:
98
+ raise ValueError("Either transaction or transaction_id must be provided")
99
+
100
+ if transaction_id:
101
+ conn = instance._resolve_connector(policy_obj.fetch.connector_name) # type: ignore[attr-defined]
102
+ transaction = await conn.fetch_transaction_by_id_async(transaction_id)
103
+
104
+ success, result = await instance._start_processing(transaction, policy_obj) # type: ignore[attr-defined]
105
+ if not success:
106
+ raise result
107
+ return result
108
+
109
+
110
+ # =============================================================
111
+ # ASYNC TASK EXECUTION
112
+ # =============================================================
113
+
114
+
115
+ async def __run_task_async(
116
+ config: TaskConfig,
117
+ mode: Literal["task", "transaction"] = "task",
118
+ *args,
119
+ **kwargs,
120
+ ):
121
+ if not issubclass(config.task, BaseAsyncTask): # type: ignore[arg-type]
122
+ raise ValueError(f"Invalid async task: {config.task}")
123
+
124
+ worker_id = kwargs.pop("worker_id", None)
125
+
126
+ task_exec_metadata = TaskExecMetadata(
127
+ task_name=config.name,
128
+ execution_id=str(uuid.uuid4()),
129
+ execution_start_time=datetime.now().isoformat(),
130
+ pid=os.getpid(),
131
+ worker_id=worker_id,
132
+ ).model_dump()
133
+
134
+ __init_telemetry(config, task=config.task, task_exec_metadata=task_exec_metadata)
135
+ tracer = tracing.get_tracer(f"task.{config.name}")
136
+
137
+ instance = None
138
+
139
+ try:
140
+ with tracer.start_as_current_span( # type: ignore[attr-defined]
141
+ f"{config.task.__name__}.run",
142
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
143
+ ):
144
+ connectors = {}
145
+ for name, cfg in config.connectors.items():
146
+ conn = cfg.connector(*cfg.args, **cfg.kwargs)
147
+ if hasattr(conn, "init_async"):
148
+ await conn.init_async() # type: ignore[attr-defined]
149
+ connectors[name] = conn
150
+
151
+ services = {name: cfg.service(*cfg.args, **cfg.kwargs) for name, cfg in config.services.items()}
152
+
153
+ instance = config.task(
154
+ connectors=connectors,
155
+ services=services,
156
+ policies=config.policies,
157
+ worker_id=worker_id,
158
+ task_config=config,
159
+ *args,
160
+ **kwargs,
161
+ )
162
+
163
+ if mode == "transaction":
164
+ await __run_transaction_async(
165
+ instance=instance,
166
+ policy=kwargs.get("policy"), # type: ignore[arg-type]
167
+ transaction=kwargs.get("transaction"),
168
+ transaction_id=kwargs.get("transaction_id"),
169
+ )
170
+ else:
171
+ await instance.execute()
172
+
173
+ finally:
174
+ if instance is not None:
175
+ await instance.exit()
176
+
177
+
178
+ # =============================================================
179
+ # SYNC TRANSACTION EXECUTION
180
+ # =============================================================
181
+
182
+
183
+ def __run_transaction_sync(
184
+ instance: BaseTask,
185
+ policy: str,
186
+ transaction: Transaction | None = None,
187
+ transaction_id: str | None = None,
188
+ ):
189
+ policy_obj = next((p for p in instance.policies if p.name == policy), None)
190
+ if not policy_obj:
191
+ raise ValueError(f"Policy '{policy}' not found")
192
+
193
+ if not transaction and not transaction_id:
194
+ raise ValueError("Either transaction or transaction_id must be provided")
195
+
196
+ if transaction_id:
197
+ conn = instance._resolve_connector(policy_obj.fetch.connector_name) # type: ignore[attr-defined]
198
+ transaction = conn.fetch_transaction_by_id(transaction_id)
199
+
200
+ success, result = instance._start_processing(transaction, policy_obj) # type: ignore[attr-defined]
201
+ if not success:
202
+ raise result
203
+ return result
204
+
205
+
206
+ # =============================================================
207
+ # SYNC TASK EXECUTION
208
+ # =============================================================
209
+
210
+
211
+ def __run_task_sync(
212
+ config: TaskConfig,
213
+ mode: Literal["task", "transaction"] = "task",
214
+ *args,
215
+ **kwargs,
216
+ ):
217
+ if not issubclass(config.task, BaseTask): # type: ignore[arg-type]
218
+ raise ValueError(f"Invalid sync task: {config.task}")
219
+
220
+ worker_id = kwargs.pop("worker_id", None)
221
+
222
+ execution_start_time = datetime.now().isoformat()
223
+ task_exec_metadata = TaskExecMetadata(
224
+ task_name=config.name,
225
+ execution_id=str(uuid.uuid4()),
226
+ execution_start_time=execution_start_time,
227
+ pid=os.getpid(),
228
+ worker_id=worker_id,
229
+ ).model_dump()
230
+
231
+ __init_telemetry(config, task=config.task, task_exec_metadata=task_exec_metadata)
232
+ tracer = tracing.get_tracer(__name__)
233
+ logger = logging.get_logger(__name__)
234
+
235
+ instance = None
236
+
237
+ logger.info(f"Task {config.name} started at {execution_start_time}.")
238
+
239
+ with tracer.start_as_current_span(
240
+ f"{config.task.__name__}.run",
241
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
242
+ ):
243
+ try:
244
+ logger.info("Initializing connectors...")
245
+ connectors = {}
246
+ with tracer.start_as_current_span(
247
+ f"{config.task.__name__}.connectors.init",
248
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
249
+ ):
250
+ for name, cfg in config.connectors.items():
251
+ with tracer.start_as_current_span(
252
+ f"{config.task.__name__}.connectors.{name}.init",
253
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
254
+ ):
255
+ connectors[name] = cfg.connector(*cfg.args, **cfg.kwargs)
256
+
257
+ logger.info("Initializing services...")
258
+ services = {}
259
+ with tracer.start_as_current_span(
260
+ f"{config.task.__name__}.services.init",
261
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
262
+ ):
263
+ for name, cfg in config.services.items():
264
+ with tracer.start_as_current_span(
265
+ f"{config.task.__name__}.services.{name}.init",
266
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
267
+ ):
268
+ services[name] = cfg.service(*cfg.args, **cfg.kwargs)
269
+
270
+ with tracer.start_as_current_span(
271
+ f"{config.task.__name__}.instance.init",
272
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
273
+ ):
274
+ logger.info("Creating task instance...")
275
+ instance = config.task(
276
+ connectors=connectors,
277
+ services=services,
278
+ policies=config.policies,
279
+ worker_id=worker_id,
280
+ task_config=config,
281
+ *args,
282
+ **kwargs,
283
+ )
284
+
285
+ if mode == "transaction":
286
+ logger.info("Running task in transaction execution mode...")
287
+ __run_transaction_sync(
288
+ instance=instance,
289
+ policy=kwargs.get("policy"), # type: ignore[arg-type]
290
+ transaction=kwargs.get("transaction", None),
291
+ transaction_id=kwargs.get("transaction_id", None),
292
+ )
293
+ else:
294
+ with tracer.start_as_current_span(
295
+ f"{config.task.__name__}.execute",
296
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
297
+ ):
298
+ logger.info("Running task in full execution mode...")
299
+ instance.execute()
300
+
301
+ finally:
302
+ if instance is not None:
303
+ with tracer.start_as_current_span(
304
+ f"{config.task.__name__}.exit",
305
+ attributes={"task.execution.id": task_exec_metadata["execution_id"]},
306
+ ):
307
+ logger.info(f"Exiting task {config.name}...")
308
+ instance.exit()
309
+
310
+
311
+ # =============================================================
312
+ # PUBLIC API — RUNNER
313
+ # =============================================================
314
+
315
+
316
+ def run_task(
317
+ config: TaskConfig,
318
+ debug: bool = False,
319
+ mode: Literal["task", "transaction"] = "task",
320
+ *args,
321
+ **kwargs,
322
+ ) -> int:
323
+ """
324
+ Process entrypoint.
325
+
326
+ Returns POSIX-compatible exit code.
327
+ """
328
+
329
+ _install_signal_handlers()
330
+ is_async = issubclass(config.task, BaseAsyncTask) # type: ignore[arg-type]
331
+
332
+ # ---------------------------------------------------------
333
+ # SINGLE PROCESS
334
+ # ---------------------------------------------------------
335
+ if debug or config.max_workers == 1:
336
+ try:
337
+ if is_async:
338
+ asyncio.run(__run_task_async(config, mode, *args, **kwargs))
339
+ else:
340
+ __run_task_sync(config, mode, *args, **kwargs)
341
+
342
+ if is_shutdown_requested():
343
+ return int(get_shutdown_exit_code())
344
+
345
+ return int(ExitCode.SUCCESS)
346
+
347
+ except ValueError:
348
+ traceback.print_exc()
349
+ return int(ExitCode.CONFIG_ERROR)
350
+
351
+ except Exception:
352
+ traceback.print_exc()
353
+ return int(ExitCode.ERROR)
354
+
355
+ # ---------------------------------------------------------
356
+ # MULTI-PROCESS (SYNC ONLY)
357
+ # ---------------------------------------------------------
358
+ if is_async:
359
+ raise RuntimeError("Async tasks cannot be executed with multiple processes. Use debug=True or max_workers=1.")
360
+
361
+ has_error = False
362
+
363
+ with ProcessPoolExecutor(max_workers=config.max_workers) as executor:
364
+ futures = []
365
+ for worker_id in range(config.max_workers):
366
+ worker_kwargs = {
367
+ **kwargs,
368
+ "worker_id": worker_id,
369
+ "total_workers": config.max_workers,
370
+ }
371
+ futures.append(executor.submit(__run_task_sync, config, mode, *args, **worker_kwargs))
372
+
373
+ for f in futures:
374
+ try:
375
+ f.result()
376
+ except Exception:
377
+ traceback.print_exc()
378
+ has_error = True
379
+
380
+ if is_shutdown_requested():
381
+ return int(get_shutdown_exit_code())
382
+
383
+ if has_error:
384
+ return int(ExitCode.ERROR)
385
+
386
+ return int(ExitCode.SUCCESS)
ergon/task/utils.py ADDED
@@ -0,0 +1,64 @@
1
+ # utils.py
2
+ import asyncio
3
+ import logging
4
+ import math
5
+ import time
6
+ from datetime import datetime
7
+
8
+ from .. import telemetry
9
+
10
+ logger = logging.getLogger(__name__)
11
+ tracer = telemetry.tracing.get_tracer(__name__)
12
+
13
+
14
+ def _get_wake_time_iso(delay: float) -> str:
15
+ return datetime.fromtimestamp(time.time() + delay).isoformat()
16
+
17
+
18
+ # ============================================================
19
+ # BACKOFF / SLEEP HELPERS
20
+ # ============================================================
21
+ def compute_backoff(backoff: float, multiplier: float, cap: float, attempt: int) -> float:
22
+ with tracer.start_as_current_span("compute_backoff"):
23
+ logger.info(
24
+ f"Computing backoff with arguments: "
25
+ f"attempt {attempt}, "
26
+ f"backoff {backoff}, "
27
+ f"multiplier {multiplier}, "
28
+ f"and cap {cap}"
29
+ )
30
+
31
+ if cap > 0 and multiplier > 1 and backoff > 0:
32
+ # max attempt that won't exceed cap
33
+ max_attempt = math.floor(math.log(cap / backoff, multiplier)) if cap > backoff else 0
34
+ safe_attempt = min(attempt, max_attempt)
35
+ else:
36
+ safe_attempt = attempt
37
+
38
+ delay = backoff * (multiplier**safe_attempt)
39
+ computed_delay = min(delay, cap) if cap > 0 else delay
40
+
41
+ logger.info(f"Computed backoff: {computed_delay} seconds")
42
+ return computed_delay
43
+
44
+
45
+ def backoff(backoff: float, multiplier: float, cap: float, attempt: int):
46
+ """Blocking sleep with computed backoff."""
47
+ delay = compute_backoff(backoff, multiplier, cap, attempt)
48
+ if delay > 0:
49
+ with tracer.start_as_current_span("sleep", attributes={"delay": delay}):
50
+ estimated_wake_time_iso = _get_wake_time_iso(delay)
51
+ logger.info(f"Sleeping for {delay} seconds until {estimated_wake_time_iso}")
52
+ time.sleep(delay)
53
+ logger.info(f"Woke up from {delay} second{'' if delay == 1 else 's'} sleep")
54
+
55
+
56
+ async def backoff_async(backoff: float, multiplier: float, cap: float, attempt: int):
57
+ """Async backoff with computed backoff."""
58
+ delay = compute_backoff(backoff, multiplier, cap, attempt)
59
+ if delay > 0:
60
+ with tracer.start_as_current_span("sleep", attributes={"delay": delay}):
61
+ estimated_wake_time_iso = _get_wake_time_iso(delay)
62
+ logger.info(f"Sleeping for {delay} seconds until {estimated_wake_time_iso}")
63
+ await asyncio.sleep(delay)
64
+ logger.info(f"Woke up from {delay} second{'' if delay == 1 else 's'} sleep")
@@ -0,0 +1,7 @@
1
+ from . import logging, metrics, tracing
2
+
3
+ __all__ = [
4
+ "logging",
5
+ "metrics",
6
+ "tracing",
7
+ ]
@@ -0,0 +1,13 @@
1
+ def _inject_otel_resource_attributes(resource: dict, metadata: dict) -> dict:
2
+ enriched = dict(resource)
3
+ enriched.update(
4
+ {
5
+ "ergon.task.name": metadata["task_name"],
6
+ "ergon.task.execution.id": metadata["execution_id"],
7
+ "ergon.task.execution.pid": metadata["pid"],
8
+ "ergon.task.execution.host.name": metadata["host_name"],
9
+ "ergon.task.execution.host.ip": metadata["host_ip"],
10
+ "ergon.task.execution.start_time": metadata["execution_start_time"],
11
+ }
12
+ )
13
+ return enriched