avtomatika 1.0b5__py3-none-any.whl → 1.0b7__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/api/handlers.py +549 -0
- avtomatika/api/routes.py +118 -0
- avtomatika/app_keys.py +32 -0
- avtomatika/blueprint.py +125 -54
- avtomatika/config.py +4 -0
- avtomatika/constants.py +30 -0
- avtomatika/context.py +2 -2
- avtomatika/data_types.py +3 -2
- avtomatika/dispatcher.py +1 -1
- avtomatika/engine.py +103 -577
- avtomatika/executor.py +21 -16
- avtomatika/history/postgres.py +56 -13
- avtomatika/history/sqlite.py +54 -34
- avtomatika/logging_config.py +58 -7
- avtomatika/scheduler.py +119 -0
- avtomatika/scheduler_config_loader.py +41 -0
- avtomatika/security.py +3 -5
- avtomatika/storage/base.py +17 -3
- avtomatika/storage/memory.py +50 -8
- avtomatika/storage/redis.py +17 -0
- avtomatika/utils/__init__.py +0 -0
- avtomatika/utils/webhook_sender.py +54 -0
- avtomatika/watcher.py +1 -3
- {avtomatika-1.0b5.dist-info → avtomatika-1.0b7.dist-info}/METADATA +77 -4
- avtomatika-1.0b7.dist-info/RECORD +45 -0
- avtomatika-1.0b5.dist-info/RECORD +0 -37
- {avtomatika-1.0b5.dist-info → avtomatika-1.0b7.dist-info}/WHEEL +0 -0
- {avtomatika-1.0b5.dist-info → avtomatika-1.0b7.dist-info}/licenses/LICENSE +0 -0
- {avtomatika-1.0b5.dist-info → avtomatika-1.0b7.dist-info}/top_level.txt +0 -0
avtomatika/executor.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from asyncio import CancelledError, Task, create_task, sleep
|
|
2
|
-
from inspect import signature
|
|
3
2
|
from logging import getLogger
|
|
4
3
|
from time import monotonic
|
|
5
4
|
from types import SimpleNamespace
|
|
@@ -167,17 +166,17 @@ class JobExecutor:
|
|
|
167
166
|
handler = blueprint.find_handler(context.current_state, context)
|
|
168
167
|
|
|
169
168
|
# Build arguments for the handler dynamically.
|
|
170
|
-
|
|
171
|
-
params_to_inject = {}
|
|
169
|
+
param_names = blueprint.get_handler_params(handler)
|
|
170
|
+
params_to_inject: dict[str, Any] = {}
|
|
172
171
|
|
|
173
|
-
if "context" in
|
|
172
|
+
if "context" in param_names:
|
|
174
173
|
params_to_inject["context"] = context
|
|
175
|
-
if "actions" in
|
|
174
|
+
if "actions" in param_names:
|
|
176
175
|
params_to_inject["actions"] = action_factory
|
|
177
176
|
else:
|
|
178
177
|
# New injection logic with prioritized lookup.
|
|
179
178
|
context_as_dict = context._asdict()
|
|
180
|
-
for param_name in
|
|
179
|
+
for param_name in param_names:
|
|
181
180
|
# Look in JobContext fields first.
|
|
182
181
|
if param_name in context_as_dict:
|
|
183
182
|
params_to_inject[param_name] = context_as_dict[param_name]
|
|
@@ -232,7 +231,7 @@ class JobExecutor:
|
|
|
232
231
|
job_state: dict[str, Any],
|
|
233
232
|
next_state: str,
|
|
234
233
|
duration_ms: int,
|
|
235
|
-
):
|
|
234
|
+
) -> None:
|
|
236
235
|
job_id = job_state["id"]
|
|
237
236
|
previous_state = job_state["current_state"]
|
|
238
237
|
logger.info(f"Job {job_id} transitioning from {previous_state} to {next_state}")
|
|
@@ -260,13 +259,19 @@ class JobExecutor:
|
|
|
260
259
|
else:
|
|
261
260
|
logger.info(f"Job {job_id} reached terminal state {next_state}")
|
|
262
261
|
await self._check_and_resume_parent(job_state)
|
|
262
|
+
# Send webhook for finished/failed jobs
|
|
263
|
+
event_type = "job_finished" if next_state == "finished" else "job_failed"
|
|
264
|
+
# Since _check_and_resume_parent is for sub-jobs, we only send webhook if it's a top-level job
|
|
265
|
+
# or if the user explicitly requested it for sub-jobs (by providing webhook_url).
|
|
266
|
+
# The current logic stores webhook_url in job_state, so we just check it.
|
|
267
|
+
await self.engine.send_job_webhook(job_state, event_type)
|
|
263
268
|
|
|
264
269
|
async def _handle_dispatch(
|
|
265
270
|
self,
|
|
266
271
|
job_state: dict[str, Any],
|
|
267
272
|
task_info: dict[str, Any],
|
|
268
273
|
duration_ms: int,
|
|
269
|
-
):
|
|
274
|
+
) -> None:
|
|
270
275
|
job_id = job_state["id"]
|
|
271
276
|
current_state = job_state["current_state"]
|
|
272
277
|
|
|
@@ -302,7 +307,6 @@ class JobExecutor:
|
|
|
302
307
|
await self.storage.save_job_state(job_id, job_state)
|
|
303
308
|
await self.storage.add_job_to_watch(job_id, timeout_at)
|
|
304
309
|
|
|
305
|
-
# Now, dispatch the task
|
|
306
310
|
await self.dispatcher.dispatch(job_state, task_info)
|
|
307
311
|
|
|
308
312
|
async def _handle_run_blueprint(
|
|
@@ -310,7 +314,7 @@ class JobExecutor:
|
|
|
310
314
|
parent_job_state: dict[str, Any],
|
|
311
315
|
sub_blueprint_info: dict[str, Any],
|
|
312
316
|
duration_ms: int,
|
|
313
|
-
):
|
|
317
|
+
) -> None:
|
|
314
318
|
parent_job_id = parent_job_state["id"]
|
|
315
319
|
child_job_id = str(uuid4())
|
|
316
320
|
|
|
@@ -350,7 +354,7 @@ class JobExecutor:
|
|
|
350
354
|
job_state: dict[str, Any],
|
|
351
355
|
parallel_info: dict[str, Any],
|
|
352
356
|
duration_ms: int,
|
|
353
|
-
):
|
|
357
|
+
) -> None:
|
|
354
358
|
job_id = job_state["id"]
|
|
355
359
|
tasks_to_dispatch = parallel_info["tasks"]
|
|
356
360
|
aggregate_into = parallel_info["aggregate_into"]
|
|
@@ -398,7 +402,7 @@ class JobExecutor:
|
|
|
398
402
|
job_state: dict[str, Any],
|
|
399
403
|
error: Exception,
|
|
400
404
|
duration_ms: int,
|
|
401
|
-
):
|
|
405
|
+
) -> None:
|
|
402
406
|
"""Handles failures that occur *during the execution of a handler*.
|
|
403
407
|
|
|
404
408
|
This is different from a task failure reported by a worker. This logic
|
|
@@ -447,13 +451,14 @@ class JobExecutor:
|
|
|
447
451
|
await self.storage.quarantine_job(job_id)
|
|
448
452
|
# If this quarantined job was a sub-job, we must now resume its parent.
|
|
449
453
|
await self._check_and_resume_parent(job_state)
|
|
454
|
+
await self.engine.send_job_webhook(job_state, "job_quarantined")
|
|
450
455
|
from . import metrics
|
|
451
456
|
|
|
452
457
|
metrics.jobs_failed_total.inc(
|
|
453
458
|
{metrics.LABEL_BLUEPRINT: job_state.get("blueprint_name", "unknown")},
|
|
454
459
|
)
|
|
455
460
|
|
|
456
|
-
async def _check_and_resume_parent(self, child_job_state: dict[str, Any]):
|
|
461
|
+
async def _check_and_resume_parent(self, child_job_state: dict[str, Any]) -> None:
|
|
457
462
|
"""Checks if a completed job was a sub-job. If so, it resumes the parent
|
|
458
463
|
job, passing the success/failure outcome of the child.
|
|
459
464
|
"""
|
|
@@ -493,7 +498,7 @@ class JobExecutor:
|
|
|
493
498
|
await self.storage.enqueue_job(parent_job_id)
|
|
494
499
|
|
|
495
500
|
@staticmethod
|
|
496
|
-
def _handle_task_completion(task: Task):
|
|
501
|
+
def _handle_task_completion(task: Task) -> None:
|
|
497
502
|
"""Callback to handle completion of a job processing task."""
|
|
498
503
|
try:
|
|
499
504
|
# This will re-raise any exception caught in the task
|
|
@@ -505,7 +510,7 @@ class JobExecutor:
|
|
|
505
510
|
# Log any other exceptions that occurred in the task.
|
|
506
511
|
logger.exception("Unhandled exception in job processing task")
|
|
507
512
|
|
|
508
|
-
async def run(self):
|
|
513
|
+
async def run(self) -> None:
|
|
509
514
|
import asyncio
|
|
510
515
|
|
|
511
516
|
logger.info("JobExecutor started.")
|
|
@@ -536,5 +541,5 @@ class JobExecutor:
|
|
|
536
541
|
await sleep(1)
|
|
537
542
|
logger.info("JobExecutor stopped.")
|
|
538
543
|
|
|
539
|
-
def stop(self):
|
|
544
|
+
def stop(self) -> None:
|
|
540
545
|
self._running = False
|
avtomatika/history/postgres.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
from abc import ABC
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from datetime import datetime
|
|
2
4
|
from logging import getLogger
|
|
3
5
|
from typing import Any
|
|
4
6
|
from uuid import uuid4
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
5
8
|
|
|
6
|
-
from asyncpg import Pool, PostgresError, create_pool # type: ignore[import-untyped]
|
|
9
|
+
from asyncpg import Connection, Pool, PostgresError, create_pool # type: ignore[import-untyped]
|
|
10
|
+
from orjson import dumps, loads
|
|
7
11
|
|
|
8
12
|
from .base import HistoryStorageBase
|
|
9
13
|
|
|
@@ -41,14 +45,24 @@ CREATE_JOB_ID_INDEX_PG = "CREATE INDEX IF NOT EXISTS idx_job_id ON job_history(j
|
|
|
41
45
|
class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
42
46
|
"""Implementation of the history store based on asyncpg for PostgreSQL."""
|
|
43
47
|
|
|
44
|
-
def __init__(self, dsn: str):
|
|
48
|
+
def __init__(self, dsn: str, tz_name: str = "UTC"):
|
|
45
49
|
self._dsn = dsn
|
|
46
50
|
self._pool: Pool | None = None
|
|
51
|
+
self.tz_name = tz_name
|
|
52
|
+
self.tz = ZoneInfo(tz_name)
|
|
53
|
+
|
|
54
|
+
async def _setup_connection(self, conn: Connection):
|
|
55
|
+
"""Configures the connection session with the correct timezone."""
|
|
56
|
+
try:
|
|
57
|
+
await conn.execute(f"SET TIME ZONE '{self.tz_name}'")
|
|
58
|
+
except PostgresError as e:
|
|
59
|
+
logger.error(f"Failed to set timezone '{self.tz_name}' for PG connection: {e}")
|
|
47
60
|
|
|
48
61
|
async def initialize(self):
|
|
49
62
|
"""Initializes the connection pool to PostgreSQL and creates tables."""
|
|
50
63
|
try:
|
|
51
|
-
|
|
64
|
+
# We use init parameter to configure each new connection in the pool
|
|
65
|
+
self._pool = await create_pool(dsn=self._dsn, init=self._setup_connection)
|
|
52
66
|
if not self._pool:
|
|
53
67
|
raise RuntimeError("Failed to create a connection pool.")
|
|
54
68
|
|
|
@@ -56,7 +70,7 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
56
70
|
await conn.execute(CREATE_JOB_HISTORY_TABLE_PG)
|
|
57
71
|
await conn.execute(CREATE_WORKER_HISTORY_TABLE_PG)
|
|
58
72
|
await conn.execute(CREATE_JOB_ID_INDEX_PG)
|
|
59
|
-
logger.info("PostgreSQL history storage initialized.")
|
|
73
|
+
logger.info(f"PostgreSQL history storage initialized (TZ={self.tz_name}).")
|
|
60
74
|
except (PostgresError, OSError) as e:
|
|
61
75
|
logger.error(f"Failed to initialize PostgreSQL history storage: {e}")
|
|
62
76
|
raise
|
|
@@ -74,14 +88,20 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
74
88
|
|
|
75
89
|
query = """
|
|
76
90
|
INSERT INTO job_history (
|
|
77
|
-
event_id, job_id, state, event_type, duration_ms,
|
|
91
|
+
event_id, job_id, timestamp, state, event_type, duration_ms,
|
|
78
92
|
previous_state, next_state, worker_id, attempt_number,
|
|
79
93
|
context_snapshot
|
|
80
|
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
94
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
81
95
|
"""
|
|
96
|
+
now = datetime.now(self.tz)
|
|
97
|
+
|
|
98
|
+
context_snapshot = event_data.get("context_snapshot")
|
|
99
|
+
context_snapshot_json = dumps(context_snapshot).decode("utf-8") if context_snapshot else None
|
|
100
|
+
|
|
82
101
|
params = (
|
|
83
102
|
uuid4(),
|
|
84
103
|
event_data.get("job_id"),
|
|
104
|
+
now,
|
|
85
105
|
event_data.get("state"),
|
|
86
106
|
event_data.get("event_type"),
|
|
87
107
|
event_data.get("duration_ms"),
|
|
@@ -89,7 +109,7 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
89
109
|
event_data.get("next_state"),
|
|
90
110
|
event_data.get("worker_id"),
|
|
91
111
|
event_data.get("attempt_number"),
|
|
92
|
-
|
|
112
|
+
context_snapshot_json,
|
|
93
113
|
)
|
|
94
114
|
try:
|
|
95
115
|
async with self._pool.acquire() as conn:
|
|
@@ -104,14 +124,20 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
104
124
|
|
|
105
125
|
query = """
|
|
106
126
|
INSERT INTO worker_history (
|
|
107
|
-
event_id, worker_id, event_type, worker_info_snapshot
|
|
108
|
-
) VALUES ($1, $2, $3, $4)
|
|
127
|
+
event_id, worker_id, timestamp, event_type, worker_info_snapshot
|
|
128
|
+
) VALUES ($1, $2, $3, $4, $5)
|
|
109
129
|
"""
|
|
130
|
+
now = datetime.now(self.tz)
|
|
131
|
+
|
|
132
|
+
worker_info = event_data.get("worker_info_snapshot")
|
|
133
|
+
worker_info_json = dumps(worker_info).decode("utf-8") if worker_info else None
|
|
134
|
+
|
|
110
135
|
params = (
|
|
111
136
|
uuid4(),
|
|
112
137
|
event_data.get("worker_id"),
|
|
138
|
+
now,
|
|
113
139
|
event_data.get("event_type"),
|
|
114
|
-
|
|
140
|
+
worker_info_json,
|
|
115
141
|
)
|
|
116
142
|
try:
|
|
117
143
|
async with self._pool.acquire() as conn:
|
|
@@ -119,6 +145,23 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
119
145
|
except PostgresError as e:
|
|
120
146
|
logger.error(f"Failed to log worker event to PostgreSQL: {e}")
|
|
121
147
|
|
|
148
|
+
def _format_row(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
149
|
+
"""Helper to format a row from DB: convert timestamp to local TZ and decode JSON."""
|
|
150
|
+
item = dict(row)
|
|
151
|
+
|
|
152
|
+
if isinstance(item.get("context_snapshot"), str):
|
|
153
|
+
with suppress(Exception):
|
|
154
|
+
item["context_snapshot"] = loads(item["context_snapshot"])
|
|
155
|
+
|
|
156
|
+
if isinstance(item.get("worker_info_snapshot"), str):
|
|
157
|
+
with suppress(Exception):
|
|
158
|
+
item["worker_info_snapshot"] = loads(item["worker_info_snapshot"])
|
|
159
|
+
|
|
160
|
+
if "timestamp" in item and isinstance(item["timestamp"], datetime):
|
|
161
|
+
item["timestamp"] = item["timestamp"].astimezone(self.tz)
|
|
162
|
+
|
|
163
|
+
return item
|
|
164
|
+
|
|
122
165
|
async def get_job_history(self, job_id: str) -> list[dict[str, Any]]:
|
|
123
166
|
"""Gets the full history for the specified job from PostgreSQL."""
|
|
124
167
|
if not self._pool:
|
|
@@ -128,7 +171,7 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
128
171
|
try:
|
|
129
172
|
async with self._pool.acquire() as conn:
|
|
130
173
|
rows = await conn.fetch(query, job_id)
|
|
131
|
-
return [
|
|
174
|
+
return [self._format_row(row) for row in rows]
|
|
132
175
|
except PostgresError as e:
|
|
133
176
|
logger.error(
|
|
134
177
|
f"Failed to get job history for job_id {job_id} from PostgreSQL: {e}",
|
|
@@ -154,7 +197,7 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
154
197
|
try:
|
|
155
198
|
async with self._pool.acquire() as conn:
|
|
156
199
|
rows = await conn.fetch(query, limit, offset)
|
|
157
|
-
return [
|
|
200
|
+
return [self._format_row(row) for row in rows]
|
|
158
201
|
except PostgresError as e:
|
|
159
202
|
logger.error(f"Failed to get jobs list from PostgreSQL: {e}")
|
|
160
203
|
return []
|
|
@@ -206,7 +249,7 @@ class PostgresHistoryStorage(HistoryStorageBase, ABC):
|
|
|
206
249
|
try:
|
|
207
250
|
async with self._pool.acquire() as conn:
|
|
208
251
|
rows = await conn.fetch(query, worker_id, since_days)
|
|
209
|
-
return [
|
|
252
|
+
return [self._format_row(row) for row in rows]
|
|
210
253
|
except PostgresError as e:
|
|
211
254
|
logger.error(f"Failed to get worker history for worker_id {worker_id} from PostgreSQL: {e}")
|
|
212
255
|
return []
|
avtomatika/history/sqlite.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from datetime import datetime
|
|
1
3
|
from logging import getLogger
|
|
4
|
+
from time import time
|
|
2
5
|
from typing import Any
|
|
3
6
|
from uuid import uuid4
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
4
8
|
|
|
5
9
|
from aiosqlite import Connection, Error, Row, connect
|
|
6
10
|
from orjson import dumps, loads
|
|
@@ -13,7 +17,7 @@ CREATE_JOB_HISTORY_TABLE = """
|
|
|
13
17
|
CREATE TABLE IF NOT EXISTS job_history (
|
|
14
18
|
event_id TEXT PRIMARY KEY,
|
|
15
19
|
job_id TEXT NOT NULL,
|
|
16
|
-
timestamp
|
|
20
|
+
timestamp REAL NOT NULL,
|
|
17
21
|
state TEXT,
|
|
18
22
|
event_type TEXT NOT NULL,
|
|
19
23
|
duration_ms INTEGER,
|
|
@@ -29,7 +33,7 @@ CREATE_WORKER_HISTORY_TABLE = """
|
|
|
29
33
|
CREATE TABLE IF NOT EXISTS worker_history (
|
|
30
34
|
event_id TEXT PRIMARY KEY,
|
|
31
35
|
worker_id TEXT NOT NULL,
|
|
32
|
-
timestamp
|
|
36
|
+
timestamp REAL NOT NULL,
|
|
33
37
|
event_type TEXT NOT NULL,
|
|
34
38
|
worker_info_snapshot TEXT
|
|
35
39
|
);
|
|
@@ -39,11 +43,15 @@ CREATE_JOB_ID_INDEX = "CREATE INDEX IF NOT EXISTS idx_job_id ON job_history(job_
|
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
class SQLiteHistoryStorage(HistoryStorageBase):
|
|
42
|
-
"""Implementation of the history store based on aiosqlite.
|
|
46
|
+
"""Implementation of the history store based on aiosqlite.
|
|
47
|
+
Stores timestamps as Unix time (UTC) for correct sorting,
|
|
48
|
+
and converts them to the configured timezone upon retrieval.
|
|
49
|
+
"""
|
|
43
50
|
|
|
44
|
-
def __init__(self, db_path: str):
|
|
51
|
+
def __init__(self, db_path: str, tz_name: str = "UTC"):
|
|
45
52
|
self._db_path = db_path
|
|
46
53
|
self._conn: Connection | None = None
|
|
54
|
+
self.tz = ZoneInfo(tz_name)
|
|
47
55
|
|
|
48
56
|
async def initialize(self):
|
|
49
57
|
"""Initializes the database connection and creates tables if they don't exist."""
|
|
@@ -66,6 +74,23 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
66
74
|
await self._conn.close()
|
|
67
75
|
logger.info("SQLite history storage connection closed.")
|
|
68
76
|
|
|
77
|
+
def _format_row(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
"""Helper to format a row from DB: decode JSON and convert timestamp."""
|
|
79
|
+
item = dict(row)
|
|
80
|
+
|
|
81
|
+
if item.get("context_snapshot"):
|
|
82
|
+
with suppress(Exception):
|
|
83
|
+
item["context_snapshot"] = loads(item["context_snapshot"])
|
|
84
|
+
|
|
85
|
+
if item.get("worker_info_snapshot"):
|
|
86
|
+
with suppress(Exception):
|
|
87
|
+
item["worker_info_snapshot"] = loads(item["worker_info_snapshot"])
|
|
88
|
+
|
|
89
|
+
if "timestamp" in item and isinstance(item["timestamp"], (int, float)):
|
|
90
|
+
item["timestamp"] = datetime.fromtimestamp(item["timestamp"], self.tz)
|
|
91
|
+
|
|
92
|
+
return item
|
|
93
|
+
|
|
69
94
|
async def log_job_event(self, event_data: dict[str, Any]):
|
|
70
95
|
"""Logs a job lifecycle event to the job_history table."""
|
|
71
96
|
if not self._conn:
|
|
@@ -73,14 +98,20 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
73
98
|
|
|
74
99
|
query = """
|
|
75
100
|
INSERT INTO job_history (
|
|
76
|
-
event_id, job_id, state, event_type, duration_ms,
|
|
101
|
+
event_id, job_id, timestamp, state, event_type, duration_ms,
|
|
77
102
|
previous_state, next_state, worker_id, attempt_number,
|
|
78
103
|
context_snapshot
|
|
79
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
105
|
"""
|
|
106
|
+
now_ts = time()
|
|
107
|
+
|
|
108
|
+
context_snapshot = event_data.get("context_snapshot")
|
|
109
|
+
context_snapshot_json = dumps(context_snapshot).decode("utf-8") if context_snapshot else None
|
|
110
|
+
|
|
81
111
|
params = (
|
|
82
112
|
str(uuid4()),
|
|
83
113
|
event_data.get("job_id"),
|
|
114
|
+
now_ts,
|
|
84
115
|
event_data.get("state"),
|
|
85
116
|
event_data.get("event_type"),
|
|
86
117
|
event_data.get("duration_ms"),
|
|
@@ -88,7 +119,7 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
88
119
|
event_data.get("next_state"),
|
|
89
120
|
event_data.get("worker_id"),
|
|
90
121
|
event_data.get("attempt_number"),
|
|
91
|
-
|
|
122
|
+
context_snapshot_json,
|
|
92
123
|
)
|
|
93
124
|
|
|
94
125
|
try:
|
|
@@ -104,14 +135,20 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
104
135
|
|
|
105
136
|
query = """
|
|
106
137
|
INSERT INTO worker_history (
|
|
107
|
-
event_id, worker_id, event_type, worker_info_snapshot
|
|
108
|
-
) VALUES (?, ?, ?, ?)
|
|
138
|
+
event_id, worker_id, timestamp, event_type, worker_info_snapshot
|
|
139
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
109
140
|
"""
|
|
141
|
+
now_ts = time()
|
|
142
|
+
|
|
143
|
+
worker_info = event_data.get("worker_info_snapshot")
|
|
144
|
+
worker_info_json = dumps(worker_info).decode("utf-8") if worker_info else None
|
|
145
|
+
|
|
110
146
|
params = (
|
|
111
147
|
str(uuid4()),
|
|
112
148
|
event_data.get("worker_id"),
|
|
149
|
+
now_ts,
|
|
113
150
|
event_data.get("event_type"),
|
|
114
|
-
|
|
151
|
+
worker_info_json,
|
|
115
152
|
)
|
|
116
153
|
|
|
117
154
|
try:
|
|
@@ -130,14 +167,7 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
130
167
|
self._conn.row_factory = Row
|
|
131
168
|
async with self._conn.execute(query, (job_id,)) as cursor:
|
|
132
169
|
rows = await cursor.fetchall()
|
|
133
|
-
|
|
134
|
-
for row in rows:
|
|
135
|
-
item = dict(row)
|
|
136
|
-
# Deserialize the JSON string back into a dict
|
|
137
|
-
if item.get("context_snapshot"):
|
|
138
|
-
item["context_snapshot"] = loads(item["context_snapshot"])
|
|
139
|
-
history.append(item)
|
|
140
|
-
return history
|
|
170
|
+
return [self._format_row(row) for row in rows]
|
|
141
171
|
except Error as e:
|
|
142
172
|
logger.error(f"Failed to get job history for job_id {job_id}: {e}")
|
|
143
173
|
return []
|
|
@@ -163,13 +193,7 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
163
193
|
self._conn.row_factory = Row
|
|
164
194
|
async with self._conn.execute(query, (limit, offset)) as cursor:
|
|
165
195
|
rows = await cursor.fetchall()
|
|
166
|
-
|
|
167
|
-
for row in rows:
|
|
168
|
-
item = dict(row)
|
|
169
|
-
if item.get("context_snapshot"):
|
|
170
|
-
item["context_snapshot"] = loads(item["context_snapshot"])
|
|
171
|
-
jobs.append(item)
|
|
172
|
-
return jobs
|
|
196
|
+
return [self._format_row(row) for row in rows]
|
|
173
197
|
except Error as e:
|
|
174
198
|
logger.error(f"Failed to get jobs list: {e}")
|
|
175
199
|
return []
|
|
@@ -214,23 +238,19 @@ class SQLiteHistoryStorage(HistoryStorageBase):
|
|
|
214
238
|
if not self._conn:
|
|
215
239
|
raise RuntimeError("History storage is not initialized.")
|
|
216
240
|
|
|
241
|
+
threshold_ts = time() - (since_days * 86400)
|
|
242
|
+
|
|
217
243
|
query = """
|
|
218
244
|
SELECT * FROM job_history
|
|
219
245
|
WHERE worker_id = ?
|
|
220
|
-
AND timestamp >=
|
|
246
|
+
AND timestamp >= ?
|
|
221
247
|
ORDER BY timestamp DESC
|
|
222
248
|
"""
|
|
223
249
|
try:
|
|
224
250
|
self._conn.row_factory = Row
|
|
225
|
-
async with self._conn.execute(query, (worker_id,
|
|
251
|
+
async with self._conn.execute(query, (worker_id, threshold_ts)) as cursor:
|
|
226
252
|
rows = await cursor.fetchall()
|
|
227
|
-
|
|
228
|
-
for row in rows:
|
|
229
|
-
item = dict(row)
|
|
230
|
-
if item.get("context_snapshot"):
|
|
231
|
-
item["context_snapshot"] = loads(item["context_snapshot"])
|
|
232
|
-
history.append(item)
|
|
233
|
-
return history
|
|
253
|
+
return [self._format_row(row) for row in rows]
|
|
234
254
|
except Error as e:
|
|
235
255
|
logger.error(f"Failed to get worker history for worker_id {worker_id}: {e}")
|
|
236
256
|
return []
|
avtomatika/logging_config.py
CHANGED
|
@@ -1,10 +1,50 @@
|
|
|
1
|
+
from datetime import datetime
|
|
1
2
|
from logging import DEBUG, Formatter, StreamHandler, getLogger
|
|
2
3
|
from sys import stdout
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
3
5
|
|
|
4
6
|
from pythonjsonlogger import json
|
|
5
7
|
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
class TimezoneFormatter(Formatter):
|
|
10
|
+
"""Formatter that respects a custom timezone."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, fmt=None, datefmt=None, style="%", validate=True, *, tz_name="UTC"):
|
|
13
|
+
super().__init__(fmt, datefmt, style, validate)
|
|
14
|
+
self.tz = ZoneInfo(tz_name)
|
|
15
|
+
|
|
16
|
+
def converter(self, timestamp):
|
|
17
|
+
return datetime.fromtimestamp(timestamp, self.tz)
|
|
18
|
+
|
|
19
|
+
def formatTime(self, record, datefmt=None):
|
|
20
|
+
dt = self.converter(record.created)
|
|
21
|
+
if datefmt:
|
|
22
|
+
s = dt.strftime(datefmt)
|
|
23
|
+
else:
|
|
24
|
+
try:
|
|
25
|
+
s = dt.isoformat(timespec="milliseconds")
|
|
26
|
+
except TypeError:
|
|
27
|
+
s = dt.isoformat()
|
|
28
|
+
return s
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TimezoneJsonFormatter(json.JsonFormatter):
|
|
32
|
+
"""JSON Formatter that respects a custom timezone."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *args, tz_name="UTC", **kwargs):
|
|
35
|
+
super().__init__(*args, **kwargs)
|
|
36
|
+
self.tz = ZoneInfo(tz_name)
|
|
37
|
+
|
|
38
|
+
def formatTime(self, record, datefmt=None):
|
|
39
|
+
# Override formatTime to use timezone-aware datetime
|
|
40
|
+
dt = datetime.fromtimestamp(record.created, self.tz)
|
|
41
|
+
if datefmt:
|
|
42
|
+
return dt.strftime(datefmt)
|
|
43
|
+
# Return ISO format with offset
|
|
44
|
+
return dt.isoformat()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def setup_logging(log_level: str = "INFO", log_format: str = "json", tz_name: str = "UTC"):
|
|
8
48
|
"""Configures structured logging for the entire application."""
|
|
9
49
|
logger = getLogger("avtomatika")
|
|
10
50
|
logger.setLevel(log_level)
|
|
@@ -13,13 +53,15 @@ def setup_logging(log_level: str = "INFO", log_format: str = "json"):
|
|
|
13
53
|
formatter: Formatter
|
|
14
54
|
if log_format.lower() == "json":
|
|
15
55
|
# Formatter for JSON logs
|
|
16
|
-
formatter =
|
|
56
|
+
formatter = TimezoneJsonFormatter(
|
|
17
57
|
"%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d",
|
|
58
|
+
tz_name=tz_name,
|
|
18
59
|
)
|
|
19
60
|
else:
|
|
20
61
|
# Standard text formatter
|
|
21
|
-
formatter =
|
|
62
|
+
formatter = TimezoneFormatter(
|
|
22
63
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
64
|
+
tz_name=tz_name,
|
|
23
65
|
)
|
|
24
66
|
|
|
25
67
|
handler.setFormatter(formatter)
|
|
@@ -30,12 +72,21 @@ def setup_logging(log_level: str = "INFO", log_format: str = "json"):
|
|
|
30
72
|
|
|
31
73
|
# Configure the root logger to see logs from libraries (aiohttp, etc.)
|
|
32
74
|
root_logger = getLogger()
|
|
33
|
-
# Set the root logger level so as not to filter messages
|
|
34
|
-
# for child loggers prematurely.
|
|
35
75
|
root_logger.setLevel(DEBUG)
|
|
76
|
+
|
|
36
77
|
if not root_logger.handlers:
|
|
37
78
|
root_handler = StreamHandler(stdout)
|
|
38
|
-
|
|
39
|
-
|
|
79
|
+
root_formatter = TimezoneFormatter(
|
|
80
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
81
|
+
tz_name=tz_name,
|
|
40
82
|
)
|
|
83
|
+
root_handler.setFormatter(root_formatter)
|
|
41
84
|
root_logger.addHandler(root_handler)
|
|
85
|
+
else:
|
|
86
|
+
for h in root_logger.handlers:
|
|
87
|
+
h.setFormatter(
|
|
88
|
+
TimezoneFormatter(
|
|
89
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
90
|
+
tz_name=tz_name,
|
|
91
|
+
)
|
|
92
|
+
)
|
avtomatika/scheduler.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from asyncio import CancelledError, sleep
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from zoneinfo import ZoneInfo
|
|
6
|
+
|
|
7
|
+
from .scheduler_config_loader import ScheduledJobConfig, load_schedules_from_file
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .engine import OrchestratorEngine
|
|
11
|
+
|
|
12
|
+
logger = getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scheduler:
|
|
16
|
+
def __init__(self, engine: "OrchestratorEngine"):
|
|
17
|
+
self.engine = engine
|
|
18
|
+
self.config = engine.config
|
|
19
|
+
self.storage = engine.storage
|
|
20
|
+
self._running = False
|
|
21
|
+
self.schedules: list[ScheduledJobConfig] = []
|
|
22
|
+
self.timezone = ZoneInfo(self.config.TZ)
|
|
23
|
+
|
|
24
|
+
def load_config(self) -> None:
|
|
25
|
+
if not self.config.SCHEDULES_CONFIG_PATH:
|
|
26
|
+
logger.info("No SCHEDULES_CONFIG_PATH set. Scheduler will not run any jobs.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
self.schedules = load_schedules_from_file(self.config.SCHEDULES_CONFIG_PATH)
|
|
31
|
+
logger.info(f"Loaded {len(self.schedules)} scheduled jobs.")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error(f"Failed to load schedules config: {e}")
|
|
34
|
+
|
|
35
|
+
async def run(self) -> None:
|
|
36
|
+
self.load_config()
|
|
37
|
+
if not self.schedules:
|
|
38
|
+
logger.info("No schedules found. Scheduler loop will not start.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
logger.info("Scheduler started.")
|
|
42
|
+
self._running = True
|
|
43
|
+
|
|
44
|
+
while self._running:
|
|
45
|
+
try:
|
|
46
|
+
now_utc = datetime.now(ZoneInfo("UTC"))
|
|
47
|
+
now_tz = now_utc.astimezone(self.timezone)
|
|
48
|
+
|
|
49
|
+
for job in self.schedules:
|
|
50
|
+
await self._process_job(job, now_tz)
|
|
51
|
+
|
|
52
|
+
await sleep(1)
|
|
53
|
+
|
|
54
|
+
except CancelledError:
|
|
55
|
+
break
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Error in scheduler loop: {e}", exc_info=True)
|
|
58
|
+
await sleep(30)
|
|
59
|
+
|
|
60
|
+
logger.info("Scheduler stopped.")
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
self._running = False
|
|
64
|
+
|
|
65
|
+
async def _process_job(self, job: ScheduledJobConfig, now_tz: datetime) -> None:
|
|
66
|
+
if job.interval_seconds:
|
|
67
|
+
await self._process_interval_job(job, now_tz)
|
|
68
|
+
else:
|
|
69
|
+
await self._process_calendar_job(job, now_tz)
|
|
70
|
+
|
|
71
|
+
async def _process_interval_job(self, job: ScheduledJobConfig, now_tz: datetime) -> None:
|
|
72
|
+
last_run_key = f"scheduler:last_run:{job.name}"
|
|
73
|
+
last_run_ts = await self.storage.get_str(last_run_key)
|
|
74
|
+
|
|
75
|
+
now_ts = now_tz.timestamp()
|
|
76
|
+
|
|
77
|
+
if last_run_ts and job.interval_seconds is not None and now_ts - float(last_run_ts) < job.interval_seconds:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
lock_key = f"scheduler:lock:interval:{job.name}"
|
|
81
|
+
if await self.storage.set_nx_ttl(lock_key, "locked", ttl=5):
|
|
82
|
+
try:
|
|
83
|
+
await self._trigger_job(job)
|
|
84
|
+
await self.storage.set_str(last_run_key, str(now_ts))
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error(f"Failed to trigger interval job {job.name}: {e}")
|
|
87
|
+
|
|
88
|
+
async def _process_calendar_job(self, job: ScheduledJobConfig, now_tz: datetime) -> None:
|
|
89
|
+
target_time_str = job.daily_at or job.time
|
|
90
|
+
if not target_time_str:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
current_time_str = now_tz.strftime("%H:%M")
|
|
94
|
+
|
|
95
|
+
if current_time_str != target_time_str:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if job.weekly_days:
|
|
99
|
+
current_day_str = now_tz.strftime("%a").lower()
|
|
100
|
+
if current_day_str not in [d.lower() for d in job.weekly_days]:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if job.monthly_dates and now_tz.day not in job.monthly_dates:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
date_str = now_tz.strftime("%Y-%m-%d")
|
|
107
|
+
lock_key = f"scheduler:lock:{job.name}:{date_str}"
|
|
108
|
+
|
|
109
|
+
if await self.storage.set_nx_ttl(lock_key, "locked", ttl=86400):
|
|
110
|
+
logger.info(f"Triggering scheduled job {job.name}")
|
|
111
|
+
await self._trigger_job(job)
|
|
112
|
+
|
|
113
|
+
async def _trigger_job(self, job: ScheduledJobConfig) -> None:
|
|
114
|
+
try:
|
|
115
|
+
await self.engine.create_background_job(
|
|
116
|
+
blueprint_name=job.blueprint, initial_data=job.input_data, source=f"scheduler:{job.name}"
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Failed to create background job {job.name}: {e}")
|