Samad14 commited on
Commit
a8dee3b
·
1 Parent(s): 396bcf1

fix: migrate docking/sequencing job store from file-backed dict to Supabase

Browse files

Replace per-process in-memory _jobs dict + JSON file with Supabase rows, eliminating the race condition where multiple gunicorn workers lose visibility of each other's jobs (the root cause of the 502 on status polling).

- _TABLE = docking_jobs / sequencing_jobs -- new Supabase tables
- _init/_patch/_read now use supabase-py insert/update/select
- _prune_jobs uses ISO-format cutoff and .range() pagination
- _fail_stuck_dockseq_jobs() on startup with 30min grace period
prevents rolling-restart workers from killing healthy in-flight jobs
- All done_at timestamps changed from time.time() floats to ISO 8601
- Prune is best-effort (try/except) so it never blocks job creation
- Removed _jobs dict, _lock, _PERSIST_DIR/FILE, threading, json, os imports

bioai-platform/backend/app/main.py CHANGED
@@ -1,5 +1,6 @@
1
  import logging
2
  import os
 
3
 
4
  import sentry_sdk
5
  from dotenv import load_dotenv
@@ -100,6 +101,41 @@ async def _fail_stuck_jobs():
100
  logger.warning(f"Startup resume: error: {e}")
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  @app.on_event("startup")
104
  async def startup():
105
  sentry_sdk.init(
@@ -109,11 +145,7 @@ async def startup():
109
  )
110
  init_redis()
111
  await _fail_stuck_jobs()
112
- from app.routers import sequencing, docking
113
- lost_seq = sum(1 for j in sequencing._jobs.values() if j.get("status") not in {"complete", "failed"})
114
- lost_dock = sum(1 for j in docking._jobs.values() if j.get("status") not in {"complete", "failed"})
115
- if lost_seq or lost_dock:
116
- logger.warning(f"Startup: {lost_seq} sequencing + {lost_dock} docking in-memory job(s) lost on restart")
117
 
118
 
119
  @app.get("/health")
 
1
  import logging
2
  import os
3
+ from datetime import datetime, timezone, timedelta
4
 
5
  import sentry_sdk
6
  from dotenv import load_dotenv
 
101
  logger.warning(f"Startup resume: error: {e}")
102
 
103
 
104
+ async def _fail_stuck_dockseq_jobs():
105
+ """Mark docking/sequencing jobs that were in-flight when the process restarted."""
106
+ try:
107
+ import httpx
108
+ from app.config import settings
109
+ headers = {
110
+ "apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
111
+ "Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
112
+ "Content-Type": "application/json",
113
+ "Prefer": "return=minimal",
114
+ }
115
+ base = f"{settings.SUPABASE_URL}/rest/v1"
116
+ grace_cutoff = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
117
+ for table in ("docking_jobs", "sequencing_jobs"):
118
+ select_url = f"{base}/{table}?select=id&status=not.in.(complete,failed)&created_at=lt.{grace_cutoff}"
119
+ async with httpx.AsyncClient(timeout=10) as client:
120
+ resp = await client.get(select_url, headers=headers)
121
+ if resp.status_code != 200:
122
+ logger.warning(f"Startup resume: failed to query {table} ({resp.status_code})")
123
+ continue
124
+ stuck = resp.json()
125
+ for job in stuck:
126
+ jid = job["id"]
127
+ logger.info(f"Startup resume: marking stuck {table} job {jid} as failed")
128
+ await client.patch(
129
+ f"{base}/{table}?id=eq.{jid}",
130
+ headers=headers,
131
+ json={"status": "failed", "error": "Worker lost on restart — please re-run", "done_at": datetime.now(timezone.utc).isoformat()},
132
+ )
133
+ if stuck:
134
+ logger.info(f"Startup resume: marked {len(stuck)} stuck {table} job(s) as failed")
135
+ except Exception as e:
136
+ logger.warning(f"Startup resume: error for docking/sequencing: {e}")
137
+
138
+
139
  @app.on_event("startup")
140
  async def startup():
141
  sentry_sdk.init(
 
145
  )
146
  init_redis()
147
  await _fail_stuck_jobs()
148
+ await _fail_stuck_dockseq_jobs()
 
 
 
 
149
 
150
 
151
  @app.get("/health")
bioai-platform/backend/app/routers/docking.py CHANGED
@@ -1,12 +1,8 @@
1
  from __future__ import annotations
2
 
3
- import asyncio
4
- import json
5
  import logging
6
- import os
7
- import threading
8
- import time
9
  import uuid
 
10
  from typing import Optional
11
 
12
  from fastapi import APIRouter, BackgroundTasks, HTTPException
@@ -14,51 +10,34 @@ from fastapi.responses import PlainTextResponse
14
  from pydantic import BaseModel
15
 
16
  from app.deps import limiter
 
17
 
18
  logger = logging.getLogger(__name__)
19
  router = APIRouter(prefix="/api/docking", tags=["docking"])
20
 
21
- _PERSIST_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
22
- _PERSIST_FILE = os.path.join(_PERSIST_DIR, "jobs_docking.json")
23
  _MAX_JOBS = 200
24
  _JOB_TTL = 7200
25
 
26
 
27
- def _load_jobs() -> dict[str, dict]:
28
- if os.path.exists(_PERSIST_FILE):
29
- try:
30
- with open(_PERSIST_FILE) as f:
31
- return json.load(f)
32
- except Exception as e:
33
- logger.warning(f"Failed to load jobs from {_PERSIST_FILE}: {e}")
34
- return {}
35
-
36
-
37
- def _save_jobs() -> None:
38
- try:
39
- os.makedirs(_PERSIST_DIR, exist_ok=True)
40
- with open(_PERSIST_FILE, "w") as f:
41
- json.dump(_jobs, f, default=str)
42
- except Exception as e:
43
- logger.warning(f"Failed to save jobs: {e}")
44
-
45
-
46
  def _prune_jobs() -> None:
47
- now = time.time()
48
- stale = [
49
- jid
50
- for jid, j in list(_jobs.items())
51
- if (j.get("done_at") and (now - j["done_at"]) > _JOB_TTL)
52
- or (j.get("status") in ("complete", "failed") and len(_jobs) > _MAX_JOBS)
53
- ]
54
- for jid in stale:
55
- _jobs.pop(jid, None)
56
- if stale:
57
- _save_jobs()
58
-
59
-
60
- _jobs: dict[str, dict] = _load_jobs()
61
- _lock = threading.Lock()
 
 
62
 
63
 
64
  class DockingRequest(BaseModel):
@@ -75,37 +54,34 @@ class DockingJob(BaseModel):
75
  status: str = "queued"
76
  result: Optional[dict] = None
77
  error: Optional[str] = None
78
- created_at: float = 0.0
79
- done_at: Optional[float] = None
80
 
81
 
82
  def _init(job_id: str, req: DockingRequest) -> None:
83
- with _lock:
84
  _prune_jobs()
85
- _jobs[job_id] = {
86
- "job_id": job_id,
87
- "pdb_id": req.pdb_id,
88
- "pdb_url": req.pdb_url,
89
- "smiles": req.smiles,
90
- "status": "queued",
91
- "result": None,
92
- "error": None,
93
- "created_at": time.time(),
94
- "done_at": None,
95
- }
96
- _save_jobs()
97
 
98
 
99
  def _patch(job_id: str, **kw) -> None:
100
- with _lock:
101
- if job_id in _jobs:
102
- _jobs[job_id].update(kw)
103
- _save_jobs()
104
 
105
 
106
  def _read(job_id: str) -> dict | None:
107
- with _lock:
108
- return dict(_jobs[job_id]) if job_id in _jobs else None
109
 
110
 
111
  async def _worker(job_id: str) -> None:
@@ -128,13 +104,13 @@ async def _worker(job_id: str) -> None:
128
  result = await tool.run(params)
129
  except Exception as exc:
130
  logger.exception("Worker crashed for job %s", job_id)
131
- _patch(job_id, status="failed", error=str(exc), done_at=time.time())
132
  return
133
 
134
  if "error" in result and not result.get("poses"):
135
- _patch(job_id, status="failed", error=result["error"], done_at=time.time())
136
  else:
137
- _patch(job_id, status="complete", result=result, done_at=time.time())
138
 
139
 
140
  @router.get("/debug")
 
1
  from __future__ import annotations
2
 
 
 
3
  import logging
 
 
 
4
  import uuid
5
+ from datetime import datetime, timezone, timedelta
6
  from typing import Optional
7
 
8
  from fastapi import APIRouter, BackgroundTasks, HTTPException
 
10
  from pydantic import BaseModel
11
 
12
  from app.deps import limiter
13
+ from app.services.supabase import get_supabase
14
 
15
  logger = logging.getLogger(__name__)
16
  router = APIRouter(prefix="/api/docking", tags=["docking"])
17
 
18
+ _TABLE = "docking_jobs"
 
19
  _MAX_JOBS = 200
20
  _JOB_TTL = 7200
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def _prune_jobs() -> None:
24
+ sb = get_supabase()
25
+ cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_JOB_TTL)).isoformat()
26
+ sb.table(_TABLE).delete().lt("done_at", cutoff).execute()
27
+ count = sb.table(_TABLE).select("id", count="exact").execute().count or 0
28
+ if count > _MAX_JOBS:
29
+ to_delete = (
30
+ sb.table(_TABLE)
31
+ .select("id")
32
+ .in_("status", ("complete", "failed"))
33
+ .order("created_at", desc=True)
34
+ .range(_MAX_JOBS, _MAX_JOBS + 500)
35
+ .execute()
36
+ .data
37
+ )
38
+ ids = [r["id"] for r in to_delete]
39
+ if ids:
40
+ sb.table(_TABLE).delete().in_("id", ids).execute()
41
 
42
 
43
  class DockingRequest(BaseModel):
 
54
  status: str = "queued"
55
  result: Optional[dict] = None
56
  error: Optional[str] = None
57
+ created_at: str = ""
58
+ done_at: Optional[str] = None
59
 
60
 
61
  def _init(job_id: str, req: DockingRequest) -> None:
62
+ try:
63
  _prune_jobs()
64
+ except Exception:
65
+ pass
66
+ get_supabase().table(_TABLE).insert({
67
+ "id": job_id,
68
+ "pdb_id": req.pdb_id,
69
+ "pdb_url": req.pdb_url,
70
+ "smiles": req.smiles,
71
+ "status": "queued",
72
+ "result": None,
73
+ "error": None,
74
+ "done_at": None,
75
+ }).execute()
76
 
77
 
78
  def _patch(job_id: str, **kw) -> None:
79
+ get_supabase().table(_TABLE).update(kw).eq("id", job_id).execute()
 
 
 
80
 
81
 
82
  def _read(job_id: str) -> dict | None:
83
+ rows = get_supabase().table(_TABLE).select("*").eq("id", job_id).execute().data
84
+ return dict(rows[0]) if rows else None
85
 
86
 
87
  async def _worker(job_id: str) -> None:
 
104
  result = await tool.run(params)
105
  except Exception as exc:
106
  logger.exception("Worker crashed for job %s", job_id)
107
+ _patch(job_id, status="failed", error=str(exc), done_at=datetime.now(timezone.utc).isoformat())
108
  return
109
 
110
  if "error" in result and not result.get("poses"):
111
+ _patch(job_id, status="failed", error=result["error"], done_at=datetime.now(timezone.utc).isoformat())
112
  else:
113
+ _patch(job_id, status="complete", result=result, done_at=datetime.now(timezone.utc).isoformat())
114
 
115
 
116
  @router.get("/debug")
bioai-platform/backend/app/routers/sequencing.py CHANGED
@@ -1,63 +1,43 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
4
- import json
5
  import logging
6
- import os
7
- import threading
8
- import time
9
  import uuid
 
10
  from typing import Optional
11
 
12
  from fastapi import APIRouter, BackgroundTasks, HTTPException
13
  from pydantic import BaseModel
14
 
15
  from app.deps import limiter
 
16
 
17
  logger = logging.getLogger(__name__)
18
  router = APIRouter(prefix="/api/sequencing", tags=["sequencing"])
19
 
20
- _PERSIST_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
21
- _PERSIST_FILE = os.path.join(_PERSIST_DIR, "jobs_sequencing.json")
22
  _MAX_JOBS = 200
23
  _JOB_TTL = 7200
24
 
25
 
26
- def _load_jobs() -> dict[str, dict]:
27
- if os.path.exists(_PERSIST_FILE):
28
- try:
29
- with open(_PERSIST_FILE) as f:
30
- return json.load(f)
31
- except Exception as e:
32
- logger.warning(f"Failed to load jobs from {_PERSIST_FILE}: {e}")
33
- return {}
34
-
35
-
36
- def _save_jobs() -> None:
37
- try:
38
- os.makedirs(_PERSIST_DIR, exist_ok=True)
39
- with open(_PERSIST_FILE, "w") as f:
40
- json.dump(_jobs, f, default=str)
41
- except Exception as e:
42
- logger.warning(f"Failed to save jobs: {e}")
43
-
44
-
45
  def _prune_jobs() -> None:
46
- now = time.time()
47
- stale = [
48
- jid
49
- for jid, j in list(_jobs.items())
50
- if (j.get("done_at") and (now - j["done_at"]) > _JOB_TTL)
51
- or (j.get("status") in ("complete", "failed") and len(_jobs) > _MAX_JOBS)
52
- ]
53
- for jid in stale:
54
- _jobs.pop(jid, None)
55
- if stale:
56
- _save_jobs()
57
-
58
-
59
- _jobs: dict[str, dict] = _load_jobs()
60
- _lock = threading.Lock()
 
 
61
 
62
 
63
  class SequencingRequest(BaseModel):
@@ -72,36 +52,33 @@ class SequencingJob(BaseModel):
72
  status: str = "queued"
73
  result: Optional[dict] = None
74
  error: Optional[str] = None
75
- created_at: float = 0.0
76
- done_at: Optional[float] = None
77
 
78
 
79
  def _init(job_id: str, req: SequencingRequest) -> None:
80
- with _lock:
81
  _prune_jobs()
82
- _jobs[job_id] = {
83
- "job_id": job_id,
84
- "fastq_url": req.fastq_url,
85
- "reference": req.reference,
86
- "status": "queued",
87
- "result": None,
88
- "error": None,
89
- "created_at": time.time(),
90
- "done_at": None,
91
- }
92
- _save_jobs()
93
 
94
 
95
  def _patch(job_id: str, **kw) -> None:
96
- with _lock:
97
- if job_id in _jobs:
98
- _jobs[job_id].update(kw)
99
- _save_jobs()
100
 
101
 
102
  def _read(job_id: str) -> dict | None:
103
- with _lock:
104
- return dict(_jobs[job_id]) if job_id in _jobs else None
105
 
106
 
107
  async def _worker(job_id: str) -> None:
@@ -122,13 +99,13 @@ async def _worker(job_id: str) -> None:
122
  timeout=PIPELINE_TIMEOUT,
123
  )
124
  except asyncio.TimeoutError:
125
- _patch(job_id, status="failed", error="Pipeline timed out", done_at=time.time())
126
  return
127
 
128
  if "error" in result and not result.get("steps_completed"):
129
- _patch(job_id, status="failed", error=result["error"], done_at=time.time())
130
  else:
131
- _patch(job_id, status="complete", result=result, done_at=time.time())
132
 
133
 
134
  VALID_DEMO = {"synthetic", "demo", "test"}
 
1
  from __future__ import annotations
2
 
3
  import asyncio
 
4
  import logging
 
 
 
5
  import uuid
6
+ from datetime import datetime, timezone, timedelta
7
  from typing import Optional
8
 
9
  from fastapi import APIRouter, BackgroundTasks, HTTPException
10
  from pydantic import BaseModel
11
 
12
  from app.deps import limiter
13
+ from app.services.supabase import get_supabase
14
 
15
  logger = logging.getLogger(__name__)
16
  router = APIRouter(prefix="/api/sequencing", tags=["sequencing"])
17
 
18
+ _TABLE = "sequencing_jobs"
 
19
  _MAX_JOBS = 200
20
  _JOB_TTL = 7200
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def _prune_jobs() -> None:
24
+ sb = get_supabase()
25
+ cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_JOB_TTL)).isoformat()
26
+ sb.table(_TABLE).delete().lt("done_at", cutoff).execute()
27
+ count = sb.table(_TABLE).select("id", count="exact").execute().count or 0
28
+ if count > _MAX_JOBS:
29
+ to_delete = (
30
+ sb.table(_TABLE)
31
+ .select("id")
32
+ .in_("status", ("complete", "failed"))
33
+ .order("created_at", desc=True)
34
+ .range(_MAX_JOBS, _MAX_JOBS + 500)
35
+ .execute()
36
+ .data
37
+ )
38
+ ids = [r["id"] for r in to_delete]
39
+ if ids:
40
+ sb.table(_TABLE).delete().in_("id", ids).execute()
41
 
42
 
43
  class SequencingRequest(BaseModel):
 
52
  status: str = "queued"
53
  result: Optional[dict] = None
54
  error: Optional[str] = None
55
+ created_at: str = ""
56
+ done_at: Optional[str] = None
57
 
58
 
59
  def _init(job_id: str, req: SequencingRequest) -> None:
60
+ try:
61
  _prune_jobs()
62
+ except Exception:
63
+ pass
64
+ get_supabase().table(_TABLE).insert({
65
+ "id": job_id,
66
+ "fastq_url": req.fastq_url,
67
+ "reference": req.reference,
68
+ "status": "queued",
69
+ "result": None,
70
+ "error": None,
71
+ "done_at": None,
72
+ }).execute()
73
 
74
 
75
  def _patch(job_id: str, **kw) -> None:
76
+ get_supabase().table(_TABLE).update(kw).eq("id", job_id).execute()
 
 
 
77
 
78
 
79
  def _read(job_id: str) -> dict | None:
80
+ rows = get_supabase().table(_TABLE).select("*").eq("id", job_id).execute().data
81
+ return dict(rows[0]) if rows else None
82
 
83
 
84
  async def _worker(job_id: str) -> None:
 
99
  timeout=PIPELINE_TIMEOUT,
100
  )
101
  except asyncio.TimeoutError:
102
+ _patch(job_id, status="failed", error="Pipeline timed out", done_at=datetime.now(timezone.utc).isoformat())
103
  return
104
 
105
  if "error" in result and not result.get("steps_completed"):
106
+ _patch(job_id, status="failed", error=result["error"], done_at=datetime.now(timezone.utc).isoformat())
107
  else:
108
+ _patch(job_id, status="complete", result=result, done_at=datetime.now(timezone.utc).isoformat())
109
 
110
 
111
  VALID_DEMO = {"synthetic", "demo", "test"}