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