feat(sync): 每小时后台自动同步 + 手动拉取最新接口
- services/scheduler.py:通过 job_locks 表跨 worker 抢占,
gunicorn 多进程下一个周期只跑一次;claim 超时 30 分钟自动释放,
避免 worker 中途挂掉把任务永久卡死
- POST /api/garmin/sync-latest:同步执行,窗口 clamp 到 1..7 天
- GET /api/garmin/auto-sync:返回上次/下次运行时间
- db.py:注释里的分号会被 SCHEMA.split(";") 截断,改为先剥注释再切分
19 项调度器测试,全量 324 项通过
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
157
backend/services/scheduler.py
Normal file
157
backend/services/scheduler.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Background scheduler.
|
||||
|
||||
Keeps the local database close to Garmin without the user having to press
|
||||
anything: every interval it pulls the last couple of days for each account
|
||||
that has stored OAuth tokens.
|
||||
|
||||
Two things make this fiddly in this deployment, and both are handled here:
|
||||
|
||||
* **Several workers.** gunicorn runs more than one process, and each would
|
||||
otherwise start its own timer and sync the same account concurrently. A row
|
||||
in `job_locks` is claimed before any work starts, so exactly one worker runs
|
||||
a given tick.
|
||||
* **Restarts.** The thread dies with its worker. The lock records when the job
|
||||
last completed, so a freshly started worker picks the schedule back up
|
||||
rather than either skipping an interval or immediately re-running.
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from config import DB_TYPE
|
||||
from db import execute, query_one, query_all
|
||||
from services import garmin as garmin_svc
|
||||
|
||||
JOB_NAME = "garmin_auto_sync"
|
||||
|
||||
# How often to pull, and how far back. Two days rather than one: the current
|
||||
# day is still being written to, and a day can arrive late.
|
||||
INTERVAL_SECONDS = int(os.environ.get("AUTO_SYNC_INTERVAL_SECONDS") or 3600)
|
||||
SYNC_DAYS = int(os.environ.get("AUTO_SYNC_DAYS") or 2)
|
||||
ENABLED = (os.environ.get("AUTO_SYNC") or "true").lower() not in ("0", "false", "no")
|
||||
|
||||
# A claim older than this is treated as abandoned — the worker holding it died
|
||||
# mid-run, and without expiry the job would never run again.
|
||||
CLAIM_TIMEOUT_SECONDS = 1800
|
||||
|
||||
_started = False
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.utcnow()
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _parse(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(str(value).replace(" ", "T"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def claim(name=JOB_NAME, interval=INTERVAL_SECONDS):
|
||||
"""Take the job if it is due and nobody else holds it.
|
||||
|
||||
Returns True when this process should do the work.
|
||||
"""
|
||||
holder = f"{os.getpid()}"
|
||||
now = _now()
|
||||
row = query_one("SELECT * FROM job_locks WHERE name = ?", [name])
|
||||
|
||||
if row:
|
||||
last_run = _parse(row.get("last_run_at"))
|
||||
if last_run and (now - last_run).total_seconds() < interval:
|
||||
return False
|
||||
claimed = _parse(row.get("claimed_at"))
|
||||
if claimed and (now - claimed).total_seconds() < CLAIM_TIMEOUT_SECONDS:
|
||||
return False
|
||||
|
||||
if DB_TYPE == "mariadb":
|
||||
sql = ("INSERT INTO job_locks (name, holder, claimed_at) VALUES (?, ?, ?) "
|
||||
"ON DUPLICATE KEY UPDATE holder=VALUES(holder), claimed_at=VALUES(claimed_at)")
|
||||
else:
|
||||
sql = ("INSERT INTO job_locks (name, holder, claimed_at) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET holder=excluded.holder, "
|
||||
"claimed_at=excluded.claimed_at")
|
||||
execute(sql, [name, holder, _iso(now)])
|
||||
|
||||
# Re-read: if another worker claimed between our check and our write, its
|
||||
# holder is the one now recorded and we must stand down.
|
||||
check = query_one("SELECT holder FROM job_locks WHERE name = ?", [name])
|
||||
return bool(check and check.get("holder") == holder)
|
||||
|
||||
|
||||
def release(name=JOB_NAME, ran=True):
|
||||
if ran:
|
||||
execute(
|
||||
"UPDATE job_locks SET claimed_at = NULL, last_run_at = ? WHERE name = ?",
|
||||
[_iso(_now()), name],
|
||||
)
|
||||
else:
|
||||
execute("UPDATE job_locks SET claimed_at = NULL WHERE name = ?", [name])
|
||||
|
||||
|
||||
def sync_all_accounts(days=None):
|
||||
"""Sync every account that has a stored token. Returns a per-user result."""
|
||||
days = days or SYNC_DAYS
|
||||
rows = query_all("SELECT user_id FROM garmin_tokens")
|
||||
results = []
|
||||
for row in rows:
|
||||
uid = row["user_id"]
|
||||
try:
|
||||
out = garmin_svc.sync_data(uid, {}, days=days)
|
||||
results.append({"user": uid, "status": out.get("status"),
|
||||
"records": out.get("recordsSynced")})
|
||||
except Exception as e: # noqa: BLE001 - one account must not stop the rest
|
||||
results.append({"user": uid, "status": "error", "error": str(e)[:200]})
|
||||
return results
|
||||
|
||||
|
||||
def _loop():
|
||||
while True:
|
||||
try:
|
||||
if claim():
|
||||
try:
|
||||
sync_all_accounts()
|
||||
finally:
|
||||
release()
|
||||
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure
|
||||
print(f"[scheduler] tick failed: {e}")
|
||||
# Checked more often than the interval so a worker that starts late
|
||||
# still picks the job up promptly rather than waiting a full hour.
|
||||
time.sleep(min(300, INTERVAL_SECONDS))
|
||||
|
||||
|
||||
def start():
|
||||
"""Start the scheduler thread once per process."""
|
||||
global _started
|
||||
if not ENABLED:
|
||||
print("[scheduler] disabled by AUTO_SYNC")
|
||||
return
|
||||
with _lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
threading.Thread(target=_loop, daemon=True, name="auto-sync").start()
|
||||
print(f"[scheduler] auto-sync every {INTERVAL_SECONDS}s, {SYNC_DAYS} day(s) back")
|
||||
|
||||
|
||||
def status():
|
||||
row = query_one("SELECT * FROM job_locks WHERE name = ?", [JOB_NAME])
|
||||
last = _parse(row.get("last_run_at")) if row else None
|
||||
return {
|
||||
"enabled": ENABLED,
|
||||
"intervalSeconds": INTERVAL_SECONDS,
|
||||
"days": SYNC_DAYS,
|
||||
"lastRunAt": _iso(last) if last else None,
|
||||
"nextRunAt": _iso(last + datetime.timedelta(seconds=INTERVAL_SECONDS)) if last else None,
|
||||
"running": bool(row and row.get("claimed_at")),
|
||||
}
|
||||
Reference in New Issue
Block a user