fix: reserve and persist background jobs
This commit is contained in:
+97
-43
@@ -37,9 +37,11 @@ from dashboard.persistence import (
|
||||
merge_observation,
|
||||
onchain_refresh_due,
|
||||
)
|
||||
from dashboard.jobs import JobRegistry
|
||||
|
||||
_shutdown_event = threading.Event()
|
||||
_background_threads = []
|
||||
_threads_lock = threading.Lock()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -47,15 +49,19 @@ async def lifespan(_app):
|
||||
"""Own background worker startup and graceful shutdown."""
|
||||
_shutdown_event.clear()
|
||||
scraper_thread = threading.Thread(target=scraper_loop, name="scraper-scheduler")
|
||||
_background_threads.append(scraper_thread)
|
||||
with _threads_lock:
|
||||
_background_threads.append(scraper_thread)
|
||||
scraper_thread.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_shutdown_event.set()
|
||||
for thread in list(_background_threads):
|
||||
with _threads_lock:
|
||||
threads = list(_background_threads)
|
||||
for thread in threads:
|
||||
thread.join(timeout=30)
|
||||
_background_threads.clear()
|
||||
with _threads_lock:
|
||||
_background_threads.clear()
|
||||
|
||||
|
||||
app = FastAPI(title="Bitcoin Accumulation Zone Monitor", lifespan=lifespan)
|
||||
@@ -65,8 +71,10 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
CACHE_PATH = os.path.join(DATA_DIR, "cache.json")
|
||||
HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl")
|
||||
LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json")
|
||||
JOBS_PATH = os.path.join(DATA_DIR, "jobs.json")
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
_jobs = JobRegistry(JOBS_PATH)
|
||||
|
||||
# Background scraper state
|
||||
_scraper_lock = threading.Lock()
|
||||
@@ -74,6 +82,32 @@ _scraper_running = False
|
||||
_last_update = None
|
||||
_last_error = None
|
||||
|
||||
|
||||
def _job_worker(job_id, operation):
|
||||
try:
|
||||
_jobs.run(job_id, operation)
|
||||
except Exception:
|
||||
log.error("Background job %s failed:\n%s", job_id, traceback.format_exc())
|
||||
finally:
|
||||
current = threading.current_thread()
|
||||
with _threads_lock:
|
||||
if current in _background_threads:
|
||||
_background_threads.remove(current)
|
||||
|
||||
|
||||
def _spawn_job(job, operation):
|
||||
"""Start an already-reserved job in a tracked, non-daemon thread."""
|
||||
thread = threading.Thread(
|
||||
target=_job_worker,
|
||||
args=(job["id"], operation),
|
||||
name=f"{job['kind']}-{job['id'][:8]}",
|
||||
)
|
||||
with _threads_lock:
|
||||
_background_threads.append(thread)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
# ── Cache management ──────────────────────────────────────────────────────
|
||||
|
||||
def load_cache():
|
||||
@@ -268,14 +302,20 @@ def run_scrape(force_full=False):
|
||||
_scraper_running = False
|
||||
|
||||
|
||||
def _run_scheduled_refresh(force_full=False):
|
||||
job = _jobs.reserve("refresh", details={"full": force_full, "scheduled": True})
|
||||
if job is not None:
|
||||
_jobs.run(job["id"], lambda: run_scrape(force_full=force_full))
|
||||
|
||||
|
||||
def scraper_loop():
|
||||
"""Background loop: quick refresh every 15min. Full scrape only on first boot with no data."""
|
||||
"""Background loop: refresh quickly every 15 minutes, with on-chain TTL handling."""
|
||||
cache = load_cache()
|
||||
has_data = any(cache.get(k, {}).get("value") is not None
|
||||
has_data = any(cache.get(k, {}).get("value") is not None
|
||||
for k in ["puell_multiple", "mvrv_zscore", "nupl"])
|
||||
run_scrape(force_full=not has_data) # Full only if no cached on-chain data
|
||||
_run_scheduled_refresh(force_full=not has_data)
|
||||
while not _shutdown_event.wait(900):
|
||||
run_scrape() # Quick refresh only
|
||||
_run_scheduled_refresh()
|
||||
|
||||
|
||||
# ── LLM Settings (preserved from original) ───────────────────────────────
|
||||
@@ -449,16 +489,31 @@ def api_history():
|
||||
return load_history()[-90:] # Last 90 entries
|
||||
|
||||
|
||||
@app.post("/api/refresh")
|
||||
@app.post("/api/refresh", status_code=202)
|
||||
def api_refresh(full: bool = False):
|
||||
"""Trigger a scrape. Quick refresh (default) updates price + F&G only (~2s).
|
||||
Full refresh (?full=true) also re-scrapes on-chain data via Playwright (~2-3min)."""
|
||||
if _scraper_running:
|
||||
return JSONResponse({"error": "Scrape already in progress"}, status_code=409)
|
||||
t = threading.Thread(target=run_scrape, kwargs={"force_full": full}, daemon=True)
|
||||
t.start()
|
||||
"""Atomically reserve and start a quick or full metric refresh."""
|
||||
job = _jobs.reserve("refresh", details={"full": full, "scheduled": False})
|
||||
if job is None:
|
||||
active = _jobs.active("refresh")
|
||||
return JSONResponse(
|
||||
{"error": "Scrape already in progress", "job": active}, status_code=409
|
||||
)
|
||||
_spawn_job(job, lambda: run_scrape(force_full=full))
|
||||
mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
|
||||
return {"ok": True, "message": f"Scrape started — {mode}"}
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job["id"],
|
||||
"status": job["status"],
|
||||
"message": f"Scrape started — {mode}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def api_job_status(job_id: str):
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return JSONResponse({"error": "Job not found"}, status_code=404)
|
||||
return job
|
||||
|
||||
|
||||
# Settings routes (preserved)
|
||||
@@ -1500,9 +1555,6 @@ loadSettings();
|
||||
|
||||
# ── Backtest API ───────────────────────────────────────────────────────
|
||||
|
||||
_history_collector_running = False
|
||||
_history_collector_progress = {}
|
||||
|
||||
|
||||
@app.get("/api/backtest")
|
||||
def api_backtest(mode: str = "classic"):
|
||||
@@ -1528,43 +1580,45 @@ def api_backtest_history():
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/backtest/collect")
|
||||
@app.post("/api/backtest/collect", status_code=202)
|
||||
def api_backtest_collect():
|
||||
"""Trigger historical data collection."""
|
||||
global _history_collector_running, _history_collector_progress
|
||||
if _history_collector_running:
|
||||
return JSONResponse({"error": "Collection already in progress", "progress": _history_collector_progress}, status_code=409)
|
||||
"""Atomically reserve and start historical data collection."""
|
||||
initial_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
|
||||
job = _jobs.reserve("history")
|
||||
if job is None:
|
||||
active = _jobs.active("history")
|
||||
return JSONResponse(
|
||||
{"error": "Collection already in progress", "job": active}, status_code=409
|
||||
)
|
||||
_jobs.update_progress(job["id"], initial_progress)
|
||||
|
||||
def _run_collector():
|
||||
global _history_collector_running, _history_collector_progress
|
||||
_history_collector_running = True
|
||||
_history_collector_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
|
||||
try:
|
||||
from scrapers.history_collector import collect_all_history
|
||||
from scrapers.history_collector import collect_all_history
|
||||
|
||||
def progress_cb(metric, step, total):
|
||||
_history_collector_progress = {"status": "scraping", "current": metric, "step": step + 1, "total": total}
|
||||
def progress_cb(metric, step, total):
|
||||
_jobs.update_progress(job["id"], {
|
||||
"status": "scraping", "current": metric,
|
||||
"step": step + 1, "total": total,
|
||||
})
|
||||
|
||||
collect_all_history(progress_cb=progress_cb)
|
||||
_history_collector_progress = {"status": "complete"}
|
||||
except Exception as e:
|
||||
log.error("History collection error: %s", traceback.format_exc())
|
||||
_history_collector_progress = {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
_history_collector_running = False
|
||||
collect_all_history(progress_cb=progress_cb)
|
||||
_jobs.update_progress(job["id"], {"status": "complete"})
|
||||
return {"collected": True}
|
||||
|
||||
t = threading.Thread(target=_run_collector, daemon=True)
|
||||
t.start()
|
||||
return {"ok": True, "message": "Collection started"}
|
||||
_spawn_job(job, _run_collector)
|
||||
return {"ok": True, "job_id": job["id"], "status": job["status"],
|
||||
"message": "Collection started"}
|
||||
|
||||
|
||||
@app.get("/api/backtest/status")
|
||||
def api_backtest_status():
|
||||
"""Check if historical data exists and collection status."""
|
||||
"""Check historical data and expose only the active collection job's progress."""
|
||||
from scrapers.history_collector import history_status
|
||||
status = history_status()
|
||||
status["collecting"] = _history_collector_running
|
||||
status["progress"] = _history_collector_progress
|
||||
active = _jobs.active("history")
|
||||
status["collecting"] = active is not None
|
||||
status["job_id"] = active.get("id") if active else None
|
||||
status["progress"] = active.get("progress", {}) if active else {}
|
||||
return status
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user