Add scheduled scripts scheduler

This commit is contained in:
AppFactory Bot
2026-06-03 12:08:16 +02:00
parent e568555be1
commit 962bfddae1
+260 -2
View File
@@ -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,
} }