avtomatika-worker 1.0a2__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.
- avtomatika_worker/__init__.py +12 -0
- avtomatika_worker/config.py +110 -0
- avtomatika_worker/types.py +4 -0
- avtomatika_worker/worker.py +408 -0
- avtomatika_worker-1.0a2.dist-info/METADATA +307 -0
- avtomatika_worker-1.0a2.dist-info/RECORD +9 -0
- avtomatika_worker-1.0a2.dist-info/WHEEL +5 -0
- avtomatika_worker-1.0a2.dist-info/licenses/LICENSE +21 -0
- avtomatika_worker-1.0a2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A Python SDK for creating workers for the Py-Orchestrator."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .worker import Worker
|
|
6
|
+
|
|
7
|
+
__all__ = ["Worker"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("avtomatika-worker")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "unknown"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from _socket import gaierror, gethostbyname, gethostname
|
|
2
|
+
from json import JSONDecodeError, loads
|
|
3
|
+
from os import getenv
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WorkerConfig:
|
|
9
|
+
"""A class for centralized management of worker configuration.
|
|
10
|
+
Reads parameters from environment variables and provides default values.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
# --- Basic worker information ---
|
|
15
|
+
self.worker_id: str = getenv("WORKER_ID", f"worker-{uuid4()}")
|
|
16
|
+
self.worker_type: str = getenv("WORKER_TYPE", "generic-cpu-worker")
|
|
17
|
+
self.worker_port: int = int(getenv("WORKER_PORT", "8083"))
|
|
18
|
+
self.hostname: str = gethostname()
|
|
19
|
+
try:
|
|
20
|
+
self.ip_address: str = gethostbyname(self.hostname)
|
|
21
|
+
except gaierror:
|
|
22
|
+
self.ip_address: str = "127.0.0.1"
|
|
23
|
+
|
|
24
|
+
# --- Orchestrator settings ---
|
|
25
|
+
self.orchestrators: list[dict[str, Any]] = self._get_orchestrators_config()
|
|
26
|
+
|
|
27
|
+
# --- Security ---
|
|
28
|
+
self.worker_token: str = getenv(
|
|
29
|
+
"WORKER_INDIVIDUAL_TOKEN",
|
|
30
|
+
getenv("WORKER_TOKEN", "your-secret-worker-token"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# --- Resources and performance ---
|
|
34
|
+
self.cost_per_second: float = float(getenv("WORKER_COST_PER_SECOND", "0.01"))
|
|
35
|
+
self.max_concurrent_tasks: int = int(getenv("MAX_CONCURRENT_TASKS", "10"))
|
|
36
|
+
self.resources: dict[str, Any] = {
|
|
37
|
+
"cpu_cores": int(getenv("CPU_CORES", "4")),
|
|
38
|
+
"gpu_info": self._get_gpu_info(),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# --- Installed software and models (read as JSON strings) ---
|
|
42
|
+
self.installed_software: dict[str, str] = self._load_json_from_env(
|
|
43
|
+
"INSTALLED_SOFTWARE",
|
|
44
|
+
default={"python": "3.9"},
|
|
45
|
+
)
|
|
46
|
+
self.installed_models: list[dict[str, str]] = self._load_json_from_env(
|
|
47
|
+
"INSTALLED_MODELS",
|
|
48
|
+
default=[],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# --- Tuning parameters ---
|
|
52
|
+
self.heartbeat_interval: float = float(getenv("HEARTBEAT_INTERVAL", "15"))
|
|
53
|
+
self.result_max_retries: int = int(getenv("RESULT_MAX_RETRIES", "5"))
|
|
54
|
+
self.result_retry_initial_delay: float = float(
|
|
55
|
+
getenv("RESULT_RETRY_INITIAL_DELAY", "1.0"),
|
|
56
|
+
)
|
|
57
|
+
self.heartbeat_debounce_delay: float = float(getenv("WORKER_HEARTBEAT_DEBOUNCE_DELAY", 0.1))
|
|
58
|
+
self.task_poll_timeout: float = float(getenv("TASK_POLL_TIMEOUT", "30"))
|
|
59
|
+
self.task_poll_error_delay: float = float(
|
|
60
|
+
getenv("TASK_POLL_ERROR_DELAY", "5.0"),
|
|
61
|
+
)
|
|
62
|
+
self.idle_poll_delay: float = float(getenv("IDLE_POLL_DELAY", "0.01"))
|
|
63
|
+
self.enable_websockets: bool = getenv("WORKER_ENABLE_WEBSOCKETS", "false").lower() == "true"
|
|
64
|
+
self.multi_orchestrator_mode: str = getenv("MULTI_ORCHESTRATOR_MODE", "FAILOVER")
|
|
65
|
+
|
|
66
|
+
def _get_orchestrators_config(self) -> list[dict[str, Any]]:
|
|
67
|
+
"""
|
|
68
|
+
Loads orchestrator configuration from the ORCHESTRATORS_CONFIG environment variable.
|
|
69
|
+
For backward compatibility, if it is not set, it uses ORCHESTRATOR_URL.
|
|
70
|
+
"""
|
|
71
|
+
orchestrators_json = getenv("ORCHESTRATORS_CONFIG")
|
|
72
|
+
if orchestrators_json:
|
|
73
|
+
try:
|
|
74
|
+
orchestrators = loads(orchestrators_json)
|
|
75
|
+
for o in orchestrators:
|
|
76
|
+
if "priority" not in o:
|
|
77
|
+
o["priority"] = 10
|
|
78
|
+
orchestrators.sort(key=lambda x: (x.get("priority", 10), x.get("url")))
|
|
79
|
+
return orchestrators
|
|
80
|
+
except JSONDecodeError:
|
|
81
|
+
print("Warning: Could not decode JSON from ORCHESTRATORS_CONFIG. Falling back to default.")
|
|
82
|
+
|
|
83
|
+
orchestrator_url = getenv("ORCHESTRATOR_URL", "http://localhost:8080")
|
|
84
|
+
return [{"url": orchestrator_url, "priority": 1}]
|
|
85
|
+
|
|
86
|
+
def _get_gpu_info(self) -> dict[str, Any] | None:
|
|
87
|
+
"""Collects GPU information from environment variables.
|
|
88
|
+
Returns None if GPU is not configured.
|
|
89
|
+
"""
|
|
90
|
+
gpu_model = getenv("GPU_MODEL")
|
|
91
|
+
if not gpu_model:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"model": gpu_model,
|
|
96
|
+
"vram_gb": int(getenv("GPU_VRAM_GB", "0")),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def _load_json_from_env(self, key: str, default: Any) -> Any:
|
|
100
|
+
"""Safely loads a JSON string from an environment variable."""
|
|
101
|
+
value = getenv(key)
|
|
102
|
+
if value:
|
|
103
|
+
try:
|
|
104
|
+
return loads(value)
|
|
105
|
+
except JSONDecodeError:
|
|
106
|
+
print(
|
|
107
|
+
f"Warning: Could not decode JSON from environment variable {key}.",
|
|
108
|
+
)
|
|
109
|
+
return default
|
|
110
|
+
return default
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from asyncio import CancelledError, Event, Task, create_task, gather, run, sleep
|
|
2
|
+
from asyncio import TimeoutError as AsyncTimeoutError
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from aiohttp import ClientError, ClientSession, ClientTimeout, ClientWebSocketResponse, WSMsgType, web
|
|
8
|
+
|
|
9
|
+
from .config import WorkerConfig
|
|
10
|
+
|
|
11
|
+
# Logging setup
|
|
12
|
+
logger = getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Worker:
|
|
16
|
+
"""The main class for creating and running a worker.
|
|
17
|
+
Implements a hybrid interaction model with the Orchestrator:
|
|
18
|
+
- PULL model for fetching tasks.
|
|
19
|
+
- WebSocket for real-time commands (cancellation) and sending progress.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
worker_type: str = "generic-worker",
|
|
25
|
+
max_concurrent_tasks: int | None = None,
|
|
26
|
+
task_type_limits: dict[str, int] | None = None,
|
|
27
|
+
http_session: ClientSession | None = None,
|
|
28
|
+
skill_dependencies: dict[str, list[str]] | None = None,
|
|
29
|
+
):
|
|
30
|
+
self._config = WorkerConfig()
|
|
31
|
+
self._config.worker_type = worker_type # Allow overriding worker_type
|
|
32
|
+
if max_concurrent_tasks is not None:
|
|
33
|
+
self._config.max_concurrent_tasks = max_concurrent_tasks
|
|
34
|
+
|
|
35
|
+
self._task_type_limits = task_type_limits or {}
|
|
36
|
+
self._task_handlers: dict[str, dict[str, Any]] = {}
|
|
37
|
+
self._skill_dependencies = skill_dependencies or {}
|
|
38
|
+
|
|
39
|
+
# Worker state
|
|
40
|
+
self._current_load = 0
|
|
41
|
+
self._current_load_by_type: dict[str, int] = dict.fromkeys(self._task_type_limits, 0)
|
|
42
|
+
self._hot_cache: set[str] = set()
|
|
43
|
+
self._active_tasks: dict[str, Task] = {}
|
|
44
|
+
self._http_session = http_session
|
|
45
|
+
self._session_is_managed_externally = http_session is not None
|
|
46
|
+
self._ws_connection: ClientWebSocketResponse | None = None
|
|
47
|
+
self._headers = {"X-Worker-Token": self._config.worker_token}
|
|
48
|
+
self._shutdown_event = Event()
|
|
49
|
+
self._registered_event = Event()
|
|
50
|
+
self._round_robin_index = 0
|
|
51
|
+
self._debounce_task: Task | None = None
|
|
52
|
+
|
|
53
|
+
def _validate_config(self):
|
|
54
|
+
"""Checks for unused task type limits and warns the user."""
|
|
55
|
+
registered_task_types = {
|
|
56
|
+
handler_data["type"] for handler_data in self._task_handlers.values() if handler_data["type"]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for task_type in self._task_type_limits:
|
|
60
|
+
if task_type not in registered_task_types:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Configuration warning: A limit is defined for task type '{task_type}', "
|
|
63
|
+
"but no tasks are registered with this type."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def task(self, name: str, task_type: str | None = None) -> Callable:
|
|
67
|
+
"""Decorator to register a function as a task handler."""
|
|
68
|
+
|
|
69
|
+
def decorator(func: Callable) -> Callable:
|
|
70
|
+
logger.info(f"Registering task: '{name}' (type: {task_type or 'N/A'})")
|
|
71
|
+
if task_type and task_type not in self._task_type_limits:
|
|
72
|
+
logger.warning(
|
|
73
|
+
f"Task '{name}' has a type '{task_type}' which is not defined in 'task_type_limits'. "
|
|
74
|
+
"No concurrency limit will be applied for this type."
|
|
75
|
+
)
|
|
76
|
+
if task_type and task_type not in self._current_load_by_type:
|
|
77
|
+
self._current_load_by_type[task_type] = 0
|
|
78
|
+
self._task_handlers[name] = {"func": func, "type": task_type}
|
|
79
|
+
return func
|
|
80
|
+
|
|
81
|
+
return decorator
|
|
82
|
+
|
|
83
|
+
def add_to_hot_cache(self, model_name: str):
|
|
84
|
+
"""Adds a model to the hot cache."""
|
|
85
|
+
self._hot_cache.add(model_name)
|
|
86
|
+
self._schedule_heartbeat_debounce()
|
|
87
|
+
|
|
88
|
+
def remove_from_hot_cache(self, model_name: str):
|
|
89
|
+
"""Removes a model from the hot cache."""
|
|
90
|
+
self._hot_cache.discard(model_name)
|
|
91
|
+
self._schedule_heartbeat_debounce()
|
|
92
|
+
|
|
93
|
+
def get_hot_cache(self) -> set[str]:
|
|
94
|
+
"""Returns the hot cache."""
|
|
95
|
+
return self._hot_cache
|
|
96
|
+
|
|
97
|
+
def _get_current_state(self) -> dict[str, Any]:
|
|
98
|
+
"""
|
|
99
|
+
Calculates the current worker state including status and available tasks.
|
|
100
|
+
"""
|
|
101
|
+
if self._current_load >= self._config.max_concurrent_tasks:
|
|
102
|
+
return {"status": "busy", "supported_tasks": []}
|
|
103
|
+
|
|
104
|
+
supported_tasks = []
|
|
105
|
+
for name, handler_data in self._task_handlers.items():
|
|
106
|
+
is_available = True
|
|
107
|
+
task_type = handler_data.get("type")
|
|
108
|
+
|
|
109
|
+
if task_type and task_type in self._task_type_limits:
|
|
110
|
+
limit = self._task_type_limits[task_type]
|
|
111
|
+
current_load = self._current_load_by_type.get(task_type, 0)
|
|
112
|
+
if current_load >= limit:
|
|
113
|
+
is_available = False
|
|
114
|
+
|
|
115
|
+
if is_available:
|
|
116
|
+
supported_tasks.append(name)
|
|
117
|
+
|
|
118
|
+
status = "idle" if supported_tasks else "busy"
|
|
119
|
+
return {"status": status, "supported_tasks": supported_tasks}
|
|
120
|
+
|
|
121
|
+
async def _debounced_heartbeat_sender(self):
|
|
122
|
+
"""Waits for the debounce delay then sends a heartbeat."""
|
|
123
|
+
await sleep(self._config.heartbeat_debounce_delay)
|
|
124
|
+
await self._send_heartbeats_to_all()
|
|
125
|
+
|
|
126
|
+
def _schedule_heartbeat_debounce(self):
|
|
127
|
+
"""Schedules a debounced heartbeat, cancelling any pending one."""
|
|
128
|
+
# Cancel the previously scheduled task, if it exists and is not done.
|
|
129
|
+
if self._debounce_task and not self._debounce_task.done():
|
|
130
|
+
self._debounce_task.cancel()
|
|
131
|
+
# Schedule the new debounced call.
|
|
132
|
+
self._debounce_task = create_task(self._debounced_heartbeat_sender())
|
|
133
|
+
|
|
134
|
+
async def _poll_for_tasks(self, orchestrator_url: str):
|
|
135
|
+
"""Polls a specific Orchestrator for new tasks."""
|
|
136
|
+
url = f"{orchestrator_url}/_worker/workers/{self._config.worker_id}/tasks/next"
|
|
137
|
+
try:
|
|
138
|
+
if not self._http_session:
|
|
139
|
+
return
|
|
140
|
+
timeout = ClientTimeout(total=self._config.task_poll_timeout + 5)
|
|
141
|
+
async with self._http_session.get(url, headers=self._headers, timeout=timeout) as resp:
|
|
142
|
+
if resp.status == 200:
|
|
143
|
+
task_data = await resp.json()
|
|
144
|
+
task_data["orchestrator_url"] = orchestrator_url
|
|
145
|
+
|
|
146
|
+
self._current_load += 1
|
|
147
|
+
task_handler_info = self._task_handlers.get(task_data["type"])
|
|
148
|
+
if task_handler_info:
|
|
149
|
+
task_type_for_limit = task_handler_info.get("type")
|
|
150
|
+
if task_type_for_limit:
|
|
151
|
+
self._current_load_by_type[task_type_for_limit] += 1
|
|
152
|
+
self._schedule_heartbeat_debounce()
|
|
153
|
+
|
|
154
|
+
task = create_task(self._process_task(task_data))
|
|
155
|
+
self._active_tasks[task_data["task_id"]] = task
|
|
156
|
+
elif resp.status != 204:
|
|
157
|
+
await sleep(self._config.task_poll_error_delay)
|
|
158
|
+
except (AsyncTimeoutError, ClientError) as e:
|
|
159
|
+
logger.error(f"Error polling for tasks: {e}")
|
|
160
|
+
await sleep(self._config.task_poll_error_delay)
|
|
161
|
+
|
|
162
|
+
async def _start_polling(self):
|
|
163
|
+
print("Waiting for registration")
|
|
164
|
+
"""The main loop for polling tasks."""
|
|
165
|
+
await self._registered_event.wait()
|
|
166
|
+
print("Polling started")
|
|
167
|
+
while not self._shutdown_event.is_set():
|
|
168
|
+
if self._get_current_state()["status"] == "busy":
|
|
169
|
+
await sleep(self._config.idle_poll_delay)
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if self._config.multi_orchestrator_mode == "ROUND_ROBIN":
|
|
173
|
+
orchestrator = self._config.orchestrators[self._round_robin_index]
|
|
174
|
+
await self._poll_for_tasks(orchestrator["url"])
|
|
175
|
+
self._round_robin_index = (self._round_robin_index + 1) % len(self._config.orchestrators)
|
|
176
|
+
else:
|
|
177
|
+
for orchestrator in self._config.orchestrators:
|
|
178
|
+
if self._get_current_state()["status"] == "busy":
|
|
179
|
+
break
|
|
180
|
+
await self._poll_for_tasks(orchestrator["url"])
|
|
181
|
+
|
|
182
|
+
if self._current_load == 0:
|
|
183
|
+
await sleep(self._config.idle_poll_delay)
|
|
184
|
+
|
|
185
|
+
async def _process_task(self, task_data: dict[str, Any]):
|
|
186
|
+
"""Executes the task logic."""
|
|
187
|
+
task_id, job_id, task_name = task_data["task_id"], task_data["job_id"], task_data["type"]
|
|
188
|
+
params, orchestrator_url = task_data.get("params", {}), task_data["orchestrator_url"]
|
|
189
|
+
|
|
190
|
+
result: dict[str, Any] = {}
|
|
191
|
+
handler_data = self._task_handlers.get(task_name)
|
|
192
|
+
task_type_for_limit = handler_data.get("type") if handler_data else None
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
if handler_data:
|
|
196
|
+
result = await handler_data["func"](
|
|
197
|
+
params,
|
|
198
|
+
task_id=task_id,
|
|
199
|
+
job_id=job_id,
|
|
200
|
+
priority=task_data.get("priority", 0),
|
|
201
|
+
send_progress=self.send_progress,
|
|
202
|
+
add_to_hot_cache=self.add_to_hot_cache,
|
|
203
|
+
remove_from_hot_cache=self.remove_from_hot_cache,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
result = {"status": "failure", "error_message": f"Unsupported task: {task_name}"}
|
|
207
|
+
except CancelledError:
|
|
208
|
+
result = {"status": "cancelled"}
|
|
209
|
+
except Exception as e:
|
|
210
|
+
result = {"status": "failure", "error": {"code": "TRANSIENT_ERROR", "message": str(e)}}
|
|
211
|
+
finally:
|
|
212
|
+
payload = {"job_id": job_id, "task_id": task_id, "worker_id": self._config.worker_id, "result": result}
|
|
213
|
+
await self._send_result(payload, orchestrator_url)
|
|
214
|
+
self._active_tasks.pop(task_id, None)
|
|
215
|
+
|
|
216
|
+
self._current_load -= 1
|
|
217
|
+
if task_type_for_limit:
|
|
218
|
+
self._current_load_by_type[task_type_for_limit] -= 1
|
|
219
|
+
self._schedule_heartbeat_debounce()
|
|
220
|
+
|
|
221
|
+
async def _send_result(self, payload: dict[str, Any], orchestrator_url: str):
|
|
222
|
+
"""Sends the result to a specific orchestrator."""
|
|
223
|
+
url = f"{orchestrator_url}/_worker/tasks/result"
|
|
224
|
+
delay = self._config.result_retry_initial_delay
|
|
225
|
+
for i in range(self._config.result_max_retries):
|
|
226
|
+
try:
|
|
227
|
+
if self._http_session and not self._http_session.closed:
|
|
228
|
+
async with self._http_session.post(url, json=payload, headers=self._headers) as resp:
|
|
229
|
+
if resp.status == 200:
|
|
230
|
+
return
|
|
231
|
+
except ClientError as e:
|
|
232
|
+
logger.error(f"Error sending result: {e}")
|
|
233
|
+
await sleep(delay * (2**i))
|
|
234
|
+
|
|
235
|
+
async def _manage_orchestrator_communications(self):
|
|
236
|
+
print("Registering worker")
|
|
237
|
+
"""Registers the worker and sends heartbeats."""
|
|
238
|
+
await self._register_with_all_orchestrators()
|
|
239
|
+
print("Worker registered")
|
|
240
|
+
self._registered_event.set()
|
|
241
|
+
if self._config.enable_websockets:
|
|
242
|
+
create_task(self._start_websocket_manager())
|
|
243
|
+
|
|
244
|
+
while not self._shutdown_event.is_set():
|
|
245
|
+
await self._send_heartbeats_to_all()
|
|
246
|
+
await sleep(self._config.heartbeat_interval)
|
|
247
|
+
|
|
248
|
+
async def _register_with_all_orchestrators(self):
|
|
249
|
+
"""Registers the worker with all orchestrators."""
|
|
250
|
+
state = self._get_current_state()
|
|
251
|
+
payload = {
|
|
252
|
+
"worker_id": self._config.worker_id,
|
|
253
|
+
"worker_type": self._config.worker_type,
|
|
254
|
+
"supported_tasks": state["supported_tasks"],
|
|
255
|
+
"max_concurrent_tasks": self._config.max_concurrent_tasks,
|
|
256
|
+
"installed_models": self._config.installed_models,
|
|
257
|
+
"hostname": self._config.hostname,
|
|
258
|
+
"ip_address": self._config.ip_address,
|
|
259
|
+
"resources": self._config.resources,
|
|
260
|
+
}
|
|
261
|
+
for orchestrator in self._config.orchestrators:
|
|
262
|
+
url = f"{orchestrator['url']}/_worker/workers/register"
|
|
263
|
+
try:
|
|
264
|
+
if self._http_session:
|
|
265
|
+
async with self._http_session.post(url, json=payload, headers=self._headers) as resp:
|
|
266
|
+
if resp.status >= 400:
|
|
267
|
+
logger.error(f"Error registering with {orchestrator['url']}: {resp.status}")
|
|
268
|
+
except ClientError as e:
|
|
269
|
+
logger.error(f"Error registering with orchestrator {orchestrator['url']}: {e}")
|
|
270
|
+
|
|
271
|
+
async def _send_heartbeats_to_all(self):
|
|
272
|
+
print("Sending heartbeats")
|
|
273
|
+
"""Sends heartbeat messages to all orchestrators."""
|
|
274
|
+
state = self._get_current_state()
|
|
275
|
+
payload = {
|
|
276
|
+
"load": self._current_load,
|
|
277
|
+
"status": state["status"],
|
|
278
|
+
"supported_tasks": state["supported_tasks"],
|
|
279
|
+
"hot_cache": list(self._hot_cache),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if self._skill_dependencies:
|
|
283
|
+
payload["skill_dependencies"] = self._skill_dependencies
|
|
284
|
+
hot_skills = [
|
|
285
|
+
skill for skill, models in self._skill_dependencies.items() if set(models).issubset(self._hot_cache)
|
|
286
|
+
]
|
|
287
|
+
if hot_skills:
|
|
288
|
+
payload["hot_skills"] = hot_skills
|
|
289
|
+
|
|
290
|
+
async def _send_single(orchestrator_url: str):
|
|
291
|
+
url = f"{orchestrator_url}/_worker/workers/{self._config.worker_id}"
|
|
292
|
+
try:
|
|
293
|
+
if self._http_session and not self._http_session.closed:
|
|
294
|
+
async with self._http_session.patch(url, json=payload, headers=self._headers) as resp:
|
|
295
|
+
if resp.status >= 400:
|
|
296
|
+
logger.warning(f"Heartbeat to {orchestrator_url} failed with status: {resp.status}")
|
|
297
|
+
except ClientError as e:
|
|
298
|
+
logger.error(f"Error sending heartbeat to orchestrator {orchestrator_url}: {e}")
|
|
299
|
+
|
|
300
|
+
await gather(*[_send_single(o["url"]) for o in self._config.orchestrators])
|
|
301
|
+
|
|
302
|
+
async def main(self):
|
|
303
|
+
print("Main started")
|
|
304
|
+
"""The main asynchronous function."""
|
|
305
|
+
self._validate_config() # Validate config now that all tasks are registered
|
|
306
|
+
if not self._http_session:
|
|
307
|
+
self._http_session = ClientSession()
|
|
308
|
+
print("Starting comm task")
|
|
309
|
+
comm_task = create_task(self._manage_orchestrator_communications())
|
|
310
|
+
print("Starting polling task")
|
|
311
|
+
polling_task = create_task(self._start_polling())
|
|
312
|
+
await self._shutdown_event.wait()
|
|
313
|
+
|
|
314
|
+
for task in [comm_task, polling_task]:
|
|
315
|
+
task.cancel()
|
|
316
|
+
if self._active_tasks:
|
|
317
|
+
await gather(*self._active_tasks.values(), return_exceptions=True)
|
|
318
|
+
|
|
319
|
+
if self._ws_connection and not self._ws_connection.closed:
|
|
320
|
+
await self._ws_connection.close()
|
|
321
|
+
if self._http_session and not self._http_session.closed and not self._session_is_managed_externally:
|
|
322
|
+
await self._http_session.close()
|
|
323
|
+
|
|
324
|
+
def run(self):
|
|
325
|
+
"""Runs the worker."""
|
|
326
|
+
try:
|
|
327
|
+
run(self.main())
|
|
328
|
+
except KeyboardInterrupt:
|
|
329
|
+
self._shutdown_event.set()
|
|
330
|
+
run(sleep(1.5))
|
|
331
|
+
|
|
332
|
+
async def _run_health_check_server(self):
|
|
333
|
+
app = web.Application()
|
|
334
|
+
app.router.add_get("/health", lambda r: web.Response(text="OK"))
|
|
335
|
+
runner = web.AppRunner(app)
|
|
336
|
+
await runner.setup()
|
|
337
|
+
site = web.TCPSite(runner, "0.0.0.0", self._config.worker_port)
|
|
338
|
+
await site.start()
|
|
339
|
+
await self._shutdown_event.wait()
|
|
340
|
+
await runner.cleanup()
|
|
341
|
+
|
|
342
|
+
def run_with_health_check(self):
|
|
343
|
+
async def _main_wrapper():
|
|
344
|
+
await gather(self._run_health_check_server(), self.main())
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
run(_main_wrapper())
|
|
348
|
+
except KeyboardInterrupt:
|
|
349
|
+
self._shutdown_event.set()
|
|
350
|
+
run(sleep(1.5))
|
|
351
|
+
|
|
352
|
+
# WebSocket methods omitted for brevity as they are not relevant to the changes
|
|
353
|
+
async def _start_websocket_manager(self):
|
|
354
|
+
"""Manages the WebSocket connection to the orchestrator."""
|
|
355
|
+
while not self._shutdown_event.is_set():
|
|
356
|
+
for orchestrator in self._config.orchestrators:
|
|
357
|
+
ws_url = orchestrator["url"].replace("http", "ws", 1) + "/_worker/ws"
|
|
358
|
+
try:
|
|
359
|
+
if self._http_session:
|
|
360
|
+
async with self._http_session.ws_connect(ws_url, headers=self._headers) as ws:
|
|
361
|
+
self._ws_connection = ws
|
|
362
|
+
logger.info(f"WebSocket connection established to {ws_url}")
|
|
363
|
+
await self._listen_for_commands()
|
|
364
|
+
except (ClientError, AsyncTimeoutError) as e:
|
|
365
|
+
logger.warning(f"WebSocket connection to {ws_url} failed: {e}")
|
|
366
|
+
finally:
|
|
367
|
+
self._ws_connection = None
|
|
368
|
+
logger.info(f"WebSocket connection to {ws_url} closed.")
|
|
369
|
+
await sleep(5) # Reconnection delay
|
|
370
|
+
if not self._config.orchestrators:
|
|
371
|
+
await sleep(5)
|
|
372
|
+
|
|
373
|
+
async def _listen_for_commands(self):
|
|
374
|
+
"""Listens for and processes commands from the orchestrator via WebSocket."""
|
|
375
|
+
if not self._ws_connection:
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
async for msg in self._ws_connection:
|
|
380
|
+
if msg.type == WSMsgType.TEXT:
|
|
381
|
+
try:
|
|
382
|
+
command = msg.json()
|
|
383
|
+
if command.get("type") == "cancel_task":
|
|
384
|
+
task_id = command.get("task_id")
|
|
385
|
+
if task_id in self._active_tasks:
|
|
386
|
+
self._active_tasks[task_id].cancel()
|
|
387
|
+
logger.info(f"Cancelled task {task_id} by orchestrator command.")
|
|
388
|
+
except JSONDecodeError:
|
|
389
|
+
logger.warning(f"Received invalid JSON over WebSocket: {msg.data}")
|
|
390
|
+
elif msg.type == WSMsgType.ERROR:
|
|
391
|
+
break
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error(f"Error in WebSocket listener: {e}")
|
|
394
|
+
|
|
395
|
+
async def send_progress(self, task_id: str, job_id: str, progress: float, message: str = ""):
|
|
396
|
+
"""Sends a progress update to the orchestrator via WebSocket."""
|
|
397
|
+
if self._ws_connection and not self._ws_connection.closed:
|
|
398
|
+
try:
|
|
399
|
+
payload = {
|
|
400
|
+
"type": "progress_update",
|
|
401
|
+
"task_id": task_id,
|
|
402
|
+
"job_id": job_id,
|
|
403
|
+
"progress": progress,
|
|
404
|
+
"message": message,
|
|
405
|
+
}
|
|
406
|
+
await self._ws_connection.send_json(payload)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.warning(f"Could not send progress update for task {task_id}: {e}")
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: avtomatika-worker
|
|
3
|
+
Version: 1.0a2
|
|
4
|
+
Summary: Worker SDK for the Avtomatika orchestrator.
|
|
5
|
+
Project-URL: Homepage, https://github.com/avtomatila-ai/avtomatika-worker
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/avtomatila-ai/avtomatika-worker/issues
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: aiohttp~=3.13.2
|
|
15
|
+
Requires-Dist: python-json-logger~=4.0.0
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
19
|
+
Requires-Dist: aioresponses; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Avtomatika Worker SDK
|
|
24
|
+
|
|
25
|
+
This is an SDK for creating workers compatible with the **Avtomatika** orchestrator. The SDK handles all the complexity of interacting with the orchestrator, allowing you to focus on writing your business logic.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install avtomatika-worker
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
Creating a worker is simple. You instantiate the `Worker` class and then register your task-handling functions using the `@worker.task` decorator.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from avtomatika_worker import Worker
|
|
40
|
+
|
|
41
|
+
# 1. Create a worker instance
|
|
42
|
+
worker = Worker(
|
|
43
|
+
worker_type="image-processing",
|
|
44
|
+
skill_dependencies={
|
|
45
|
+
"resize_image": ["pillow"],
|
|
46
|
+
"add_watermark": ["pillow", "numpy"],
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2. Register a task handler using the decorator
|
|
51
|
+
@worker.task("resize_image")
|
|
52
|
+
async def image_resizer(params: dict, **kwargs):
|
|
53
|
+
"""
|
|
54
|
+
An example handler that receives task parameters,
|
|
55
|
+
performs the work, and returns the result.
|
|
56
|
+
"""
|
|
57
|
+
task_id = kwargs.get("task_id")
|
|
58
|
+
job_id = kwargs.get("job_id")
|
|
59
|
+
|
|
60
|
+
print(f"Task {task_id} (Job: {job_id}): resizing image...")
|
|
61
|
+
print(f"Parameters: {params}")
|
|
62
|
+
|
|
63
|
+
# ... your business logic here ...
|
|
64
|
+
await asyncio.sleep(1) # Simulate I/O-bound work
|
|
65
|
+
|
|
66
|
+
# Return the result
|
|
67
|
+
return {
|
|
68
|
+
"status": "success",
|
|
69
|
+
"data": {
|
|
70
|
+
"resized_path": f"/path/to/resized_{params.get('filename')}"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# 3. Run the worker
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
# The SDK will automatically connect to the orchestrator,
|
|
77
|
+
# register itself, and start polling for tasks.
|
|
78
|
+
worker.run_with_health_check()
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Key Features
|
|
83
|
+
|
|
84
|
+
### 1. Task Handlers
|
|
85
|
+
|
|
86
|
+
Each handler is an asynchronous function that accepts two arguments:
|
|
87
|
+
|
|
88
|
+
- `params` (`dict`): A dictionary with the parameters that the orchestrator passed for this task.
|
|
89
|
+
- `**kwargs`: Additional metadata about the task, including:
|
|
90
|
+
- `task_id` (`str`): The unique ID of the task itself.
|
|
91
|
+
- `job_id` (`str`): The ID of the parent `Job` to which the task belongs.
|
|
92
|
+
- `priority` (`int`): The execution priority of the task.
|
|
93
|
+
|
|
94
|
+
### 2. Concurrency Limiting
|
|
95
|
+
|
|
96
|
+
The worker allows you to control how many tasks are executed in parallel. This can be configured at two levels:
|
|
97
|
+
|
|
98
|
+
- **Global Limit**: A maximum number of tasks that the worker can execute simultaneously, regardless of their type.
|
|
99
|
+
- **Per-Type Limit**: A specific limit for a group of tasks that share a common resource (e.g., a GPU, a specific API).
|
|
100
|
+
|
|
101
|
+
The worker dynamically reports its available capacity to the orchestrator. When a limit is reached, the worker informs the orchestrator that it can no longer accept tasks of that type until a slot becomes free.
|
|
102
|
+
|
|
103
|
+
**Example:**
|
|
104
|
+
|
|
105
|
+
Let's configure a worker that can run up to **10 tasks in total**, but no more than **1 video processing task** and **4 audio transcription tasks** at the same time.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import asyncio
|
|
109
|
+
from avtomatika_worker import Worker
|
|
110
|
+
|
|
111
|
+
# 1. Configure limits during initialization
|
|
112
|
+
worker = Worker(
|
|
113
|
+
worker_type="media-processor",
|
|
114
|
+
max_concurrent_tasks=10,
|
|
115
|
+
task_type_limits={
|
|
116
|
+
"video_processing": 1,
|
|
117
|
+
"audio_processing": 4,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 2. Assign a type to each task using the decorator
|
|
122
|
+
@worker.task("upscale_video", task_type="video_processing")
|
|
123
|
+
async def upscale_video(params: dict, **kwargs):
|
|
124
|
+
# This task uses the 'video_processing' slot
|
|
125
|
+
print("Upscaling video...")
|
|
126
|
+
await asyncio.sleep(5)
|
|
127
|
+
return {"status": "success"}
|
|
128
|
+
|
|
129
|
+
@worker.task("blur_video_faces", task_type="video_processing")
|
|
130
|
+
async def blur_video_faces(params: dict, **kwargs):
|
|
131
|
+
# This task also uses the 'video_processing' slot
|
|
132
|
+
print("Blurring faces in video...")
|
|
133
|
+
await asyncio.sleep(5)
|
|
134
|
+
return {"status": "success"}
|
|
135
|
+
|
|
136
|
+
@worker.task("transcribe_audio", task_type="audio_processing")
|
|
137
|
+
async def transcribe_audio(params: dict, **kwargs):
|
|
138
|
+
# This task uses one of the four 'audio_processing' slots
|
|
139
|
+
print("Transcribing audio...")
|
|
140
|
+
await asyncio.sleep(2)
|
|
141
|
+
return {"status": "success"}
|
|
142
|
+
|
|
143
|
+
@worker.task("generate_report")
|
|
144
|
+
async def generate_report(params: dict, **kwargs):
|
|
145
|
+
# This task has no specific type and is only limited by the global limit
|
|
146
|
+
print("Generating report...")
|
|
147
|
+
await asyncio.sleep(1)
|
|
148
|
+
return {"status": "success"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
worker.run_with_health_check()
|
|
153
|
+
```
|
|
154
|
+
In this example, even though the global limit is 10, the orchestrator will only ever send one task (`upscale_video` or `blur_video_faces`) to this worker at a time, because they both share the single "video_processing" slot.
|
|
155
|
+
|
|
156
|
+
### 3. Returning Results and Handling Errors
|
|
157
|
+
|
|
158
|
+
The result returned by a handler directly influences the subsequent flow of the pipeline in the orchestrator.
|
|
159
|
+
|
|
160
|
+
#### Successful Execution
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
return {
|
|
164
|
+
"status": "success",
|
|
165
|
+
"data": {"output": "some_value"}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
- The orchestrator will receive this data and use the `"success"` key in the `transitions` dictionary to determine the next step.
|
|
169
|
+
|
|
170
|
+
#### Custom Statuses
|
|
171
|
+
|
|
172
|
+
You can return custom statuses to implement complex branching logic in the orchestrator.
|
|
173
|
+
```python
|
|
174
|
+
return {
|
|
175
|
+
"status": "needs_manual_review",
|
|
176
|
+
"data": {"reason": "Low confidence score"}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
- The orchestrator will look for the `"needs_manual_review"` key in `transitions`.
|
|
180
|
+
|
|
181
|
+
#### Error Handling
|
|
182
|
+
|
|
183
|
+
To control the orchestrator's fault tolerance mechanism, you can return standardized error types.
|
|
184
|
+
|
|
185
|
+
- **Transient Error (`TRANSIENT_ERROR`)**: For issues that might be resolved on a retry (e.g., a network failure).
|
|
186
|
+
```python
|
|
187
|
+
from avtomatika_worker.typing import TRANSIENT_ERROR
|
|
188
|
+
return {
|
|
189
|
+
"status": "failure",
|
|
190
|
+
"error": {
|
|
191
|
+
"code": TRANSIENT_ERROR,
|
|
192
|
+
"message": "External API timeout"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
- **Permanent Error (`PERMANENT_ERROR`)**: For unresolvable problems (e.g., an invalid file format).
|
|
197
|
+
```python
|
|
198
|
+
from avtomatika_worker.typing import PERMANENT_ERROR
|
|
199
|
+
return {
|
|
200
|
+
"status": "failure",
|
|
201
|
+
"error": {
|
|
202
|
+
"code": PERMANENT_ERROR,
|
|
203
|
+
"message": "Corrupted input file"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 4. Failover and Load Balancing
|
|
209
|
+
|
|
210
|
+
The SDK supports connecting to multiple orchestrator instances to ensure high availability (`FAILOVER`) and load balancing (`ROUND_ROBIN`).
|
|
211
|
+
|
|
212
|
+
- **Configuration**: Set via the `ORCHESTrators_CONFIG` environment variable, which must contain a JSON string.
|
|
213
|
+
- **Mode**: Controlled by the `MULTI_ORCHESTRATOR_MODE` variable.
|
|
214
|
+
|
|
215
|
+
**Example `ORCHESTRATORS_CONFIG`:**
|
|
216
|
+
```json
|
|
217
|
+
[
|
|
218
|
+
{"url": "http://orchestrator-1.my-domain.com:8080", "weight": 100},
|
|
219
|
+
{"url": "http://orchestrator-2.my-domain.com:8080", "weight": 100}
|
|
220
|
+
]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- **`FAILOVER` (default):** The worker will connect to the first orchestrator. If it becomes unavailable, it will automatically switch to the next one in the list.
|
|
224
|
+
- **`ROUND_ROBIN`:** The worker will send requests to fetch tasks to each orchestrator in turn.
|
|
225
|
+
|
|
226
|
+
### 5. Handling Large Files (S3 Payload Offloading)
|
|
227
|
+
|
|
228
|
+
The SDK supports working with large files "out of the box" via S3-compatible storage.
|
|
229
|
+
|
|
230
|
+
- **Automatic Download**: If a value in `params` is a URI of the form `s3://...`, the SDK will automatically download the file to the local disk and replace the URI in `params` with the local path.
|
|
231
|
+
- **Automatic Upload**: If your handler returns a local file path in `data` (located within the `WORKER_PAYLOAD_DIR` directory), the SDK will automatically upload this file to S3 and replace the path with an `s3://` URI in the final result.
|
|
232
|
+
|
|
233
|
+
This functionality is transparent to your code and only requires configuring environment variables for S3 access.
|
|
234
|
+
|
|
235
|
+
### 6. WebSocket Support
|
|
236
|
+
|
|
237
|
+
If enabled, the SDK establishes a persistent WebSocket connection with the orchestrator to receive real-time commands, such as canceling an ongoing task.
|
|
238
|
+
|
|
239
|
+
## Advanced Features
|
|
240
|
+
|
|
241
|
+
### Reporting Skill & Model Dependencies
|
|
242
|
+
|
|
243
|
+
For more advanced scheduling, the worker can report detailed information about its skills and their dependencies on specific models. This allows the orchestrator to make smarter decisions, such as dispatching tasks to workers that already have the required models loaded in memory.
|
|
244
|
+
|
|
245
|
+
This is configured via the `skill_dependencies` argument in the `Worker` constructor.
|
|
246
|
+
|
|
247
|
+
- **`skill_dependencies`**: A dictionary where keys are skill names (as registered with `@worker.task`) and values are lists of model names required by that skill.
|
|
248
|
+
|
|
249
|
+
Based on this configuration and the current state of the worker's `hot_cache` (the set of models currently loaded in memory), the worker will automatically include two new fields in its heartbeat messages:
|
|
250
|
+
|
|
251
|
+
- **`skill_dependencies`**: The same dictionary provided during initialization.
|
|
252
|
+
- **`hot_skills`**: A dynamically calculated list of skills that are ready for immediate execution (i.e., all of their dependent models are in the `hot_cache`).
|
|
253
|
+
|
|
254
|
+
**Example:**
|
|
255
|
+
|
|
256
|
+
Consider a worker configured like this:
|
|
257
|
+
```python
|
|
258
|
+
worker = Worker(
|
|
259
|
+
worker_type="ai-processor",
|
|
260
|
+
skill_dependencies={
|
|
261
|
+
"image_generation": ["stable_diffusion_v1.5", "vae-ft-mse"],
|
|
262
|
+
"upscale": ["realesrgan_x4"],
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- Initially, `hot_cache` is empty. The worker's heartbeat will include `skill_dependencies` but not `hot_skills`.
|
|
268
|
+
- A task handler calls `add_to_hot_cache("stable_diffusion_v1.5")`. The next heartbeat will still not include `hot_skills` because the `image_generation` skill is only partially loaded.
|
|
269
|
+
- The handler then calls `add_to_hot_cache("vae-ft-mse")`. Now, all dependencies for `image_generation` are met. The next heartbeat will include:
|
|
270
|
+
```json
|
|
271
|
+
{
|
|
272
|
+
"hot_skills": ["image_generation"],
|
|
273
|
+
"skill_dependencies": {
|
|
274
|
+
"image_generation": ["stable_diffusion_v1.5", "vae-ft-mse"],
|
|
275
|
+
"upscale": ["realesrgan_x4"]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
This information is sent automatically. Your task handlers are only responsible for managing the `hot_cache` by calling `add_to_hot_cache()` and `remove_from_hot_cache()`, which are passed as arguments to the handler.
|
|
280
|
+
|
|
281
|
+
## Configuration
|
|
282
|
+
|
|
283
|
+
The worker is fully configured via environment variables.
|
|
284
|
+
|
|
285
|
+
| Variable | Description | Default |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `ORCHESTRATOR_URL` | The URL of a single orchestrator (used if `ORCHESTRATORS_CONFIG` is not set). | `http://localhost:8080` |
|
|
288
|
+
| `ORCHESTRATORS_CONFIG`| A JSON string with a list of orchestrators for `FAILOVER` or `ROUND_ROBIN` modes. | `[]` |
|
|
289
|
+
| `MULTI_ORCHESTRATOR_MODE` | The mode for handling multiple orchestrators. Possible values: `FAILOVER`, `ROUND_ROBIN`. | `FAILOVER` |
|
|
290
|
+
| `WORKER_ID` | **(Required)** A unique identifier for the worker. | - |
|
|
291
|
+
| `WORKER_TOKEN` | A common authentication token for all workers. | `default-token` |
|
|
292
|
+
| `WORKER_INDIVIDUAL_TOKEN` | An individual token for this worker (overrides `WORKER_TOKEN`). | - |
|
|
293
|
+
| `WORKER_ENABLE_WEBSOCKETS` | Enable (`true`) or disable (`false`) WebSocket support. | `false` |
|
|
294
|
+
| `WORKER_HEARTBEAT_DEBOUNCE_DELAY` | The delay in seconds for debouncing immediate heartbeats. | `0.1` |
|
|
295
|
+
| `WORKER_PAYLOAD_DIR` | The directory for temporarily storing files when working with S3. | `/tmp/payloads` |
|
|
296
|
+
| `S3_ENDPOINT_URL` | The URL of the S3-compatible storage. | - |
|
|
297
|
+
| `S3_ACCESS_KEY` | The access key for S3. | - |
|
|
298
|
+
| `S3_SECRET_KEY` | The secret key for S3. | - |
|
|
299
|
+
| `S3_DEFAULT_BUCKET`| The default bucket name for uploading results. | `avtomatika-payloads` |
|
|
300
|
+
|
|
301
|
+
## Development
|
|
302
|
+
|
|
303
|
+
To install the necessary dependencies for running tests, use the following command:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
pip install .[test]
|
|
307
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
avtomatika_worker/__init__.py,sha256=j0up34aVy7xyI67xg04TVbXSSSKGdO49vsBKhtH_D0M,287
|
|
2
|
+
avtomatika_worker/config.py,sha256=oEQMpmP4AkGKdgEE1BJxojdQkK7LrogmRKJ7ib-M9xs,4555
|
|
3
|
+
avtomatika_worker/types.py,sha256=2YL6MRG2LImCUKcb0G-B3757n7zWrrUc8NXnoCLKJlo,154
|
|
4
|
+
avtomatika_worker/worker.py,sha256=lyKvIPVcokQrd6qagit_BbMoZyyqivCdNyV4fwSJTY0,18421
|
|
5
|
+
avtomatika_worker-1.0a2.dist-info/licenses/LICENSE,sha256=tqCjw9Y1vbU-hLcWi__7wQstLbt2T1XWPdbQYqCxuWY,1072
|
|
6
|
+
avtomatika_worker-1.0a2.dist-info/METADATA,sha256=uJHXVdHzcJBdfQ-1rowBEYg488kbwifkvpDwvgocMqs,12288
|
|
7
|
+
avtomatika_worker-1.0a2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
avtomatika_worker-1.0a2.dist-info/top_level.txt,sha256=d3b5BUeUrHM1Cn-cbStz-hpucikEBlPOvtcmQ_j3qAs,18
|
|
9
|
+
avtomatika_worker-1.0a2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dmitrii Gagarin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
avtomatika_worker
|