Add scheduled scripts scheduler
This commit is contained in:
+260
-2
@@ -1,7 +1,9 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -12,6 +14,7 @@ DB_FILE = Path("/opt/appfactory/data/appfactory/appfactory.db")
|
|||||||
CHECK_INTERVAL_SECONDS = int(os.getenv("APPFACTORY_MONITOR_INTERVAL_SECONDS", "30"))
|
CHECK_INTERVAL_SECONDS = int(os.getenv("APPFACTORY_MONITOR_INTERVAL_SECONDS", "30"))
|
||||||
REQUEST_TIMEOUT_SECONDS = int(os.getenv("APPFACTORY_MONITOR_TIMEOUT_SECONDS", "5"))
|
REQUEST_TIMEOUT_SECONDS = int(os.getenv("APPFACTORY_MONITOR_TIMEOUT_SECONDS", "5"))
|
||||||
HEALTH_RETENTION_DAYS = int(os.getenv("APPFACTORY_HEALTH_RETENTION_DAYS", "7"))
|
HEALTH_RETENTION_DAYS = int(os.getenv("APPFACTORY_HEALTH_RETENTION_DAYS", "7"))
|
||||||
|
SCHEDULER_INTERVAL_SECONDS = int(os.getenv("APPFACTORY_SCHEDULER_INTERVAL_SECONDS", "60"))
|
||||||
|
|
||||||
CORE_SERVICES = {
|
CORE_SERVICES = {
|
||||||
"appfactory-portal": "http://appfactory-portal:9100/health",
|
"appfactory-portal": "http://appfactory-portal:9100/health",
|
||||||
@@ -20,9 +23,12 @@ CORE_SERVICES = {
|
|||||||
"appfactory-monitor": "http://appfactory-monitor:9300/health",
|
"appfactory-monitor": "http://appfactory-monitor:9300/health",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TERMINAL_JOB_STATUSES = {"success", "failed", "cancelled"}
|
||||||
|
|
||||||
app = FastAPI(title="AppFactory Monitor")
|
app = FastAPI(title="AppFactory Monitor")
|
||||||
|
|
||||||
_monitor_thread = None
|
_monitor_thread = None
|
||||||
|
_scheduler_thread = None
|
||||||
_monitor_started = False
|
_monitor_started = False
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +191,13 @@ def open_incident(service_id: str, health_id: int, status: str, error_text: str
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
service_id,
|
service_id,
|
||||||
f'{{"health_id": {health_id}, "status": "{status}"}}',
|
json.dumps(
|
||||||
|
{
|
||||||
|
"health_id": health_id,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -242,7 +254,13 @@ def resolve_incident(service_id: str, health_id: int):
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
service_id,
|
service_id,
|
||||||
f'{{"incident_id": {incident["id"]}, "health_id": {health_id}}}',
|
json.dumps(
|
||||||
|
{
|
||||||
|
"incident_id": incident["id"],
|
||||||
|
"health_id": health_id,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,6 +350,228 @@ def run_check_cycle():
|
|||||||
check_service(service_id, url)
|
check_service(service_id, url)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_hhmm(value: str | None) -> tuple[int, int]:
|
||||||
|
if not value:
|
||||||
|
return 3, 0
|
||||||
|
|
||||||
|
parts = value.strip().split(":", 1)
|
||||||
|
|
||||||
|
if len(parts) != 2:
|
||||||
|
return 3, 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
hour = int(parts[0])
|
||||||
|
minute = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return 3, 0
|
||||||
|
|
||||||
|
if hour < 0 or hour > 23:
|
||||||
|
hour = 3
|
||||||
|
|
||||||
|
if minute < 0 or minute > 59:
|
||||||
|
minute = 0
|
||||||
|
|
||||||
|
return hour, minute
|
||||||
|
|
||||||
|
|
||||||
|
def compute_next_run_at(schedule_type: str, schedule_time: str | None) -> str:
|
||||||
|
now = datetime.utcnow().replace(microsecond=0)
|
||||||
|
schedule_type = (schedule_type or "daily").strip().lower()
|
||||||
|
hour, minute = parse_time_hhmm(schedule_time)
|
||||||
|
|
||||||
|
if schedule_type == "hourly":
|
||||||
|
candidate = now.replace(minute=minute, second=0)
|
||||||
|
if candidate <= now:
|
||||||
|
candidate += timedelta(hours=1)
|
||||||
|
return candidate.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
if schedule_type == "weekly":
|
||||||
|
candidate = now.replace(hour=hour, minute=minute, second=0)
|
||||||
|
while candidate <= now:
|
||||||
|
candidate += timedelta(days=7)
|
||||||
|
return candidate.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
if schedule_type == "monthly":
|
||||||
|
candidate = now.replace(day=1, hour=hour, minute=minute, second=0)
|
||||||
|
if candidate <= now:
|
||||||
|
if candidate.month == 12:
|
||||||
|
candidate = candidate.replace(year=candidate.year + 1, month=1)
|
||||||
|
else:
|
||||||
|
candidate = candidate.replace(month=candidate.month + 1)
|
||||||
|
return candidate.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
candidate = now.replace(hour=hour, minute=minute, second=0)
|
||||||
|
if candidate <= now:
|
||||||
|
candidate += timedelta(days=1)
|
||||||
|
|
||||||
|
return candidate.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_scheduled_script_results():
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
scheduled_scripts.id AS scheduled_script_id,
|
||||||
|
scheduled_scripts.last_job_id AS last_job_id,
|
||||||
|
jobs.status AS job_status,
|
||||||
|
jobs.error_text AS job_error_text,
|
||||||
|
jobs.started_at AS job_started_at,
|
||||||
|
jobs.finished_at AS job_finished_at
|
||||||
|
FROM scheduled_scripts
|
||||||
|
JOIN jobs ON jobs.id = scheduled_scripts.last_job_id
|
||||||
|
WHERE scheduled_scripts.is_running = 1
|
||||||
|
AND jobs.status IN ('success', 'failed', 'cancelled')
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
last_run_at = row["job_finished_at"] or row["job_started_at"]
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE scheduled_scripts
|
||||||
|
SET is_running = 0,
|
||||||
|
last_status = ?,
|
||||||
|
last_error_text = ?,
|
||||||
|
last_run_at = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row["job_status"],
|
||||||
|
row["job_error_text"],
|
||||||
|
last_run_at,
|
||||||
|
row["scheduled_script_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_due_scheduled_scripts():
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
con.execute("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM scheduled_scripts
|
||||||
|
WHERE is_enabled = 1
|
||||||
|
AND is_running = 0
|
||||||
|
AND next_run_at IS NOT NULL
|
||||||
|
AND next_run_at <= CURRENT_TIMESTAMP
|
||||||
|
ORDER BY next_run_at ASC, id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
script_id = int(row["id"])
|
||||||
|
script_name = row["script_name"]
|
||||||
|
timeout_seconds = int(row["timeout_seconds"] or 300)
|
||||||
|
next_run_at = compute_next_run_at(row["schedule_type"], row["schedule_time"])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"scheduled_script_id": script_id,
|
||||||
|
"script_name": script_name,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs (
|
||||||
|
type,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
created_by_username,
|
||||||
|
payload_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'run_script',
|
||||||
|
'scheduled_script',
|
||||||
|
?,
|
||||||
|
'queued',
|
||||||
|
'scheduler',
|
||||||
|
'system',
|
||||||
|
?,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
script_name,
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
job_id = int(cur.lastrowid)
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE scheduled_scripts
|
||||||
|
SET is_running = 1,
|
||||||
|
last_job_id = ?,
|
||||||
|
last_status = 'queued',
|
||||||
|
last_error_text = NULL,
|
||||||
|
next_run_at = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
next_run_at,
|
||||||
|
script_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_events (
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
source,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'system',
|
||||||
|
'scheduled_script.enqueued',
|
||||||
|
'scheduled_script',
|
||||||
|
?,
|
||||||
|
'scheduler',
|
||||||
|
?,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(script_id),
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"script_name": script_name,
|
||||||
|
"next_run_at": next_run_at,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduler_cycle():
|
||||||
|
sync_scheduled_script_results()
|
||||||
|
enqueue_due_scheduled_scripts()
|
||||||
|
|
||||||
|
|
||||||
def monitor_loop():
|
def monitor_loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -342,9 +582,20 @@ def monitor_loop():
|
|||||||
time.sleep(CHECK_INTERVAL_SECONDS)
|
time.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_scheduler_cycle()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Scheduler loop error: {exc}", flush=True)
|
||||||
|
|
||||||
|
time.sleep(SCHEDULER_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def start_monitor():
|
def start_monitor():
|
||||||
global _monitor_thread
|
global _monitor_thread
|
||||||
|
global _scheduler_thread
|
||||||
global _monitor_started
|
global _monitor_started
|
||||||
|
|
||||||
if _monitor_started:
|
if _monitor_started:
|
||||||
@@ -358,6 +609,12 @@ def start_monitor():
|
|||||||
)
|
)
|
||||||
_monitor_thread.start()
|
_monitor_thread.start()
|
||||||
|
|
||||||
|
_scheduler_thread = threading.Thread(
|
||||||
|
target=scheduler_loop,
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
_scheduler_thread.start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -366,6 +623,7 @@ def health():
|
|||||||
"interval_seconds": CHECK_INTERVAL_SECONDS,
|
"interval_seconds": CHECK_INTERVAL_SECONDS,
|
||||||
"timeout_seconds": REQUEST_TIMEOUT_SECONDS,
|
"timeout_seconds": REQUEST_TIMEOUT_SECONDS,
|
||||||
"retention_days": HEALTH_RETENTION_DAYS,
|
"retention_days": HEALTH_RETENTION_DAYS,
|
||||||
|
"scheduler_interval_seconds": SCHEDULER_INTERVAL_SECONDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user