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/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
- handler_signature = signature(handler)
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 handler_signature.parameters:
172
+ if "context" in param_names:
174
173
  params_to_inject["context"] = context
175
- if "actions" in handler_signature.parameters:
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 handler_signature.parameters:
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
@@ -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
- self._pool = await create_pool(dsn=self._dsn)
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
- event_data.get("context_snapshot"),
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
- event_data.get("worker_info_snapshot"),
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 [dict(row) for row in rows]
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 [dict(row) for row in rows]
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 [dict(row) for row in rows]
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 []
@@ -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 DATETIME DEFAULT CURRENT_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 DATETIME DEFAULT CURRENT_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
- (dumps(event_data.get("context_snapshot")) if event_data.get("context_snapshot") else None),
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
- (dumps(event_data.get("worker_info_snapshot")) if event_data.get("worker_info_snapshot") else None),
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
- history = []
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
- jobs = []
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 >= date('now', '-' || ? || ' days')
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, since_days)) as cursor:
251
+ async with self._conn.execute(query, (worker_id, threshold_ts)) as cursor:
226
252
  rows = await cursor.fetchall()
227
- history = []
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 []
@@ -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
- def setup_logging(log_level: str = "INFO", log_format: str = "json"):
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 = json.JsonFormatter(
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 = 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
- root_handler.setFormatter(
39
- Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
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
+ )
@@ -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}")