107 lines
3.3 KiB
Python
Executable File
107 lines
3.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""AlphaX 容器内轻量调度器。
|
||
|
||
设计目标:
|
||
- 替代宿主机 crontab;
|
||
- 单进程串行执行,避免 SQLite 并发写锁;
|
||
- 默认 DRY_RUN=1,不影响线上,也不会真的跑任务;
|
||
- 部署验证通过后再把 ALPHAX_SCHEDULER_DRY_RUN=0 打开。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
PYTHON = sys.executable
|
||
DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
||
|
||
|
||
@dataclass
|
||
class Job:
|
||
name: str
|
||
module: str
|
||
every_seconds: int
|
||
args: tuple[str, ...] = ()
|
||
initial_delay: int = 0
|
||
next_run: float = 0.0
|
||
|
||
|
||
def now_str() -> str:
|
||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def env_for_child() -> dict[str, str]:
|
||
env = os.environ.copy()
|
||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||
return env
|
||
|
||
|
||
def run_job(job: Job) -> None:
|
||
cmd = [PYTHON, "-m", job.module, *job.args]
|
||
print(f"[{now_str()}] [scheduler] start {job.name}: {' '.join(cmd)}", flush=True)
|
||
if DRY_RUN:
|
||
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
|
||
return
|
||
started = time.time()
|
||
try:
|
||
proc = subprocess.run(
|
||
cmd,
|
||
cwd=ROOT,
|
||
env=env_for_child(),
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
timeout=max(job.every_seconds * 2, 600),
|
||
)
|
||
duration = time.time() - started
|
||
out = (proc.stdout or "").strip()
|
||
if len(out) > 8000:
|
||
out = out[-8000:]
|
||
print(f"[{now_str()}] [scheduler] done {job.name} exit={proc.returncode} duration={duration:.1f}s", flush=True)
|
||
if out:
|
||
print(out, flush=True)
|
||
except subprocess.TimeoutExpired as e:
|
||
print(f"[{now_str()}] [scheduler] timeout {job.name}: {e}", flush=True)
|
||
except Exception as e:
|
||
print(f"[{now_str()}] [scheduler] error {job.name}: {e}", flush=True)
|
||
|
||
|
||
def build_jobs() -> list[Job]:
|
||
# 与当前宿主机 crontab 对齐,但串行执行。
|
||
return [
|
||
Job("event", "app.services.event_driven_screener", 60, ("--once",), initial_delay=5),
|
||
Job("tracker", "app.services.price_tracker", 180, initial_delay=20),
|
||
Job("confirm", "app.services.altcoin_confirm", 600, initial_delay=40),
|
||
Job("screener", "app.services.altcoin_screener", 900, initial_delay=80),
|
||
Job("sentiment", "app.services.sentiment_monitor", 1800, ("--collect",), initial_delay=120),
|
||
Job("review", "app.services.review_engine", 24 * 3600, initial_delay=300),
|
||
]
|
||
|
||
|
||
def main() -> None:
|
||
jobs = build_jobs()
|
||
base = time.time()
|
||
for job in jobs:
|
||
job.next_run = base + job.initial_delay
|
||
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={DRY_RUN}", flush=True)
|
||
while True:
|
||
now = time.time()
|
||
due = [j for j in jobs if now >= j.next_run]
|
||
if not due:
|
||
time.sleep(1)
|
||
continue
|
||
# 串行执行;一个 job 跑完才跑下一个,避免 SQLite 写锁。
|
||
for job in sorted(due, key=lambda j: j.next_run):
|
||
run_job(job)
|
||
job.next_run = time.time() + job.every_seconds
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|