Skip to content

Commit 73ab05a

Browse files
eprifticlaude
andcommitted
Fix background job execution and CI/CD Docker test
- Root cause: _run_job created a new asyncio event loop that conflicted with SQLAlchemy's async engine (Future attached to different loop) - Fix: added sync engine/session factory for background tasks (thread pool) - Fix: explicit db.commit() before background task starts so the new sync connection can see the job record - CI: Docker test now starts a postgres container instead of running standalone (app needs postgres for startup) - Console: stops polling after 10 consecutive 404s (stale job IDs) - Added psycopg2-binary for sync PostgreSQL access Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7b16dbc commit 73ab05a

6 files changed

Lines changed: 117 additions & 83 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,37 @@ jobs:
8787
8888
- name: Test Docker image
8989
run: |
90-
docker run -d --name test-app -p 8000:8000 predomicsapp-web:latest
90+
# Start a postgres container for the app
91+
docker network create test-net
92+
docker run -d --name test-db --network test-net \
93+
-e POSTGRES_USER=predomics \
94+
-e POSTGRES_PASSWORD=predomics \
95+
-e POSTGRES_DB=predomics \
96+
postgres:16-alpine
97+
# Wait for postgres
98+
for i in $(seq 1 15); do
99+
if docker exec test-db pg_isready -U predomics > /dev/null 2>&1; then
100+
echo "Postgres ready on attempt $i"
101+
break
102+
fi
103+
sleep 2
104+
done
105+
# Start app with postgres connection
106+
docker run -d --name test-app --network test-net -p 8000:8000 \
107+
-e PREDOMICS_DATABASE_URL=postgresql+asyncpg://predomics:predomics@test-db:5432/predomics \
108+
predomicsapp-web:latest
91109
for i in $(seq 1 30); do
92110
if curl -sf http://localhost:8000/health; then
93-
echo " Health check passed on attempt $i"
94-
docker stop test-app
111+
echo "Health check passed on attempt $i"
112+
docker stop test-app test-db
113+
docker network rm test-net
95114
exit 0
96115
fi
97116
echo "Waiting for app to start (attempt $i/30)..."
98117
sleep 2
99118
done
100119
echo "Health check failed after 30 attempts"
101120
docker logs test-app
102-
docker stop test-app
121+
docker stop test-app test-db
122+
docker network rm test-net
103123
exit 1

‎backend/app/core/database.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Async SQLAlchemy database setup."""
22

3+
from sqlalchemy import create_engine as create_sync_engine
34
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
4-
from sqlalchemy.orm import DeclarativeBase
5+
from sqlalchemy.orm import DeclarativeBase, sessionmaker
56

67
from .config import settings
78

@@ -10,9 +11,15 @@ class Base(DeclarativeBase):
1011
pass
1112

1213

14+
# Async engine — used by FastAPI request handlers
1315
engine = create_async_engine(settings.database_url, echo=settings.debug)
1416
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
1517

18+
# Sync engine — used by background tasks (which run in thread pools)
19+
_sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "")
20+
sync_engine = create_sync_engine(_sync_url, echo=settings.debug)
21+
sync_session_factory = sessionmaker(sync_engine, expire_on_commit=False)
22+
1623

1724
async def get_db():
1825
"""FastAPI dependency that yields a database session."""

‎backend/app/routers/analysis.py‎

Lines changed: 68 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy import select
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111

12-
from ..core.database import get_db, async_session_factory
12+
from ..core.database import get_db, sync_session_factory
1313
from ..core.deps import get_current_user
1414
from ..models.db_models import User, Project, Job
1515
from ..models.schemas import (
@@ -60,10 +60,11 @@ async def run_analysis(
6060
p = storage.get_dataset_path(project_id, ytest_dataset_id)
6161
ytest_path = str(p) if p else ""
6262

63-
# Create job record in database
63+
# Create job record in database — commit immediately so the background
64+
# task (which uses a separate sync DB connection) can see the job.
6465
job = Job(project_id=project_id, status="pending", config=config.model_dump())
6566
db.add(job)
66-
await db.flush()
67+
await db.commit()
6768

6869
job_dir = Path(storage.settings.project_dir) / project_id / "jobs" / job.id
6970
job_dir.mkdir(parents=True, exist_ok=True)
@@ -86,8 +87,11 @@ async def run_analysis(
8687

8788

8889
def _run_job(job_id: str, project_id: str, param_path: str) -> None:
89-
"""Execute gpredomics in a subprocess so we can capture Rust log output."""
90-
import asyncio
90+
"""Execute gpredomics in a subprocess so we can capture Rust log output.
91+
92+
Runs in a Starlette thread pool — uses synchronous DB sessions to avoid
93+
event-loop conflicts with the async engine.
94+
"""
9195
import os
9296
import subprocess
9397
import sys
@@ -96,86 +100,75 @@ def _run_job(job_id: str, project_id: str, param_path: str) -> None:
96100
log_path = job_dir / "console.log"
97101
results_path = job_dir / "results.json"
98102

99-
async def _execute():
100-
# Mark job as running
101-
async with async_session_factory() as db:
102-
result = await db.execute(select(Job).where(Job.id == job_id))
103-
job = result.scalar_one()
104-
job.status = "running"
105-
await db.commit()
106-
107-
try:
108-
if not engine.HAS_ENGINE:
109-
# Mock mode — no subprocess needed
110-
mock_results = engine._mock_results()
111-
storage.save_job_result(project_id, job_id, mock_results)
112-
with open(log_path, "w") as lf:
113-
lf.write("[mock] Engine not available, using mock results\n")
114-
115-
async with async_session_factory() as db:
116-
result = await db.execute(select(Job).where(Job.id == job_id))
117-
job = result.scalar_one()
118-
job.status = "completed"
119-
job.results_path = str(results_path)
120-
job.completed_at = datetime.now(timezone.utc)
121-
await db.commit()
122-
return
123-
124-
# Run worker subprocess — stream output line-by-line for live console
125-
worker_module = "app.services.worker"
126-
env = os.environ.copy()
127-
env["PYTHONUNBUFFERED"] = "1"
128-
129-
proc = subprocess.Popen(
130-
[sys.executable, "-u", "-m", worker_module, param_path, str(results_path)],
131-
stdout=subprocess.PIPE,
132-
stderr=subprocess.STDOUT,
133-
cwd=str(Path(__file__).resolve().parents[2]), # backend/
134-
env=env,
135-
)
136-
137-
# Read output line-by-line and flush to log file immediately
138-
with open(log_path, "w") as lf:
139-
for line in proc.stdout:
140-
lf.write(line.decode("utf-8", errors="replace"))
141-
lf.flush()
142-
143-
proc.wait()
103+
# Mark job as running
104+
with sync_session_factory() as db:
105+
job = db.execute(select(Job).where(Job.id == job_id)).scalar_one()
106+
job.status = "running"
107+
db.commit()
144108

145-
if proc.returncode != 0:
146-
error_msg = log_path.read_text()[-500:] if log_path.exists() else "Unknown error"
147-
raise RuntimeError(f"Worker exited with code {proc.returncode}: {error_msg}")
109+
try:
110+
if not engine.HAS_ENGINE:
111+
# Mock mode — no subprocess needed
112+
mock_results = engine._mock_results()
113+
storage.save_job_result(project_id, job_id, mock_results)
114+
with open(log_path, "w") as lf:
115+
lf.write("[mock] Engine not available, using mock results\n")
148116

149-
async with async_session_factory() as db:
150-
result = await db.execute(select(Job).where(Job.id == job_id))
151-
job = result.scalar_one()
117+
with sync_session_factory() as db:
118+
job = db.execute(select(Job).where(Job.id == job_id)).scalar_one()
152119
job.status = "completed"
153120
job.results_path = str(results_path)
154121
job.completed_at = datetime.now(timezone.utc)
155-
await db.commit()
122+
db.commit()
123+
return
124+
125+
# Run worker subprocess — stream output line-by-line for live console
126+
worker_module = "app.services.worker"
127+
env = os.environ.copy()
128+
env["PYTHONUNBUFFERED"] = "1"
129+
130+
proc = subprocess.Popen(
131+
[sys.executable, "-u", "-m", worker_module, param_path, str(results_path)],
132+
stdout=subprocess.PIPE,
133+
stderr=subprocess.STDOUT,
134+
cwd=str(Path(__file__).resolve().parents[2]), # backend/
135+
env=env,
136+
)
156137

157-
logger.info("Job %s completed", job_id)
138+
# Read output line-by-line and flush to log file immediately
139+
with open(log_path, "w") as lf:
140+
for line in proc.stdout:
141+
lf.write(line.decode("utf-8", errors="replace"))
142+
lf.flush()
158143

159-
except Exception as e:
160-
# Append error to log
161-
with open(log_path, "a") as lf:
162-
lf.write(f"\n[error] {e}\n")
144+
proc.wait()
163145

164-
async with async_session_factory() as db:
165-
result = await db.execute(select(Job).where(Job.id == job_id))
166-
job = result.scalar_one()
167-
job.status = "failed"
168-
job.error_message = str(e)
169-
job.completed_at = datetime.now(timezone.utc)
170-
await db.commit()
146+
if proc.returncode != 0:
147+
error_msg = log_path.read_text()[-500:] if log_path.exists() else "Unknown error"
148+
raise RuntimeError(f"Worker exited with code {proc.returncode}: {error_msg}")
171149

172-
logger.error("Job %s failed: %s", job_id, e)
150+
with sync_session_factory() as db:
151+
job = db.execute(select(Job).where(Job.id == job_id)).scalar_one()
152+
job.status = "completed"
153+
job.results_path = str(results_path)
154+
job.completed_at = datetime.now(timezone.utc)
155+
db.commit()
173156

174-
loop = asyncio.new_event_loop()
175-
try:
176-
loop.run_until_complete(_execute())
177-
finally:
178-
loop.close()
157+
logger.info("Job %s completed", job_id)
158+
159+
except Exception as e:
160+
# Append error to log
161+
with open(log_path, "a") as lf:
162+
lf.write(f"\n[error] {e}\n")
163+
164+
with sync_session_factory() as db:
165+
job = db.execute(select(Job).where(Job.id == job_id)).scalar_one()
166+
job.status = "failed"
167+
job.error_message = str(e)
168+
job.completed_at = datetime.now(timezone.utc)
169+
db.commit()
170+
171+
logger.error("Job %s failed: %s", job_id, e)
179172

180173

181174
@router.get("/{project_id}/jobs/{job_id}/logs")

‎backend/pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies = [
1414
"pandas>=1.3",
1515
"sqlalchemy[asyncio]>=2.0",
1616
"asyncpg>=0.29",
17+
"psycopg2-binary>=2.9",
1718
"python-jose[cryptography]>=3.3",
1819
"bcrypt>=4.0",
1920
"pyyaml>=6.0",

‎backend/tests/test_api.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
os.environ["PREDOMICS_PROJECT_DIR"] = os.path.join(_tmp, "projects")
1717
os.environ["PREDOMICS_UPLOAD_DIR"] = os.path.join(_tmp, "uploads")
1818
os.environ["PREDOMICS_SAMPLE_DIR"] = os.path.join(_tmp, "samples")
19-
os.environ["PREDOMICS_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
19+
_db_path = os.path.join(_tmp, "test.db")
20+
os.environ["PREDOMICS_DATABASE_URL"] = f"sqlite+aiosqlite:///{_db_path}"
2021
os.environ["PREDOMICS_SECRET_KEY"] = "test-secret-key"
2122

2223
from app.main import app # noqa: E402
23-
from app.core.database import engine, Base, get_db, async_session_factory # noqa: E402
24+
from app.core.database import engine, Base, get_db, async_session_factory, sync_engine # noqa: E402
2425
from app.services import engine as ml_engine # noqa: E402
2526
from app.services import storage # noqa: E402
2627

@@ -30,10 +31,13 @@ async def db_session():
3031
"""Create tables and yield a test database session."""
3132
async with engine.begin() as conn:
3233
await conn.run_sync(Base.metadata.create_all)
34+
# Also create tables for the sync engine (used by background tasks)
35+
Base.metadata.create_all(sync_engine)
3336
async with async_session_factory() as session:
3437
yield session
3538
async with engine.begin() as conn:
3639
await conn.run_sync(Base.metadata.drop_all)
40+
Base.metadata.drop_all(sync_engine)
3741

3842

3943
@pytest_asyncio.fixture

‎frontend/src/components/ConsolePanel.vue‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@ const jobStatus = ref('pending')
2727
const consoleEl = ref(null)
2828
let pollTimer = null
2929
30+
let errorCount = 0
31+
3032
async function pollLogs() {
3133
try {
3234
const { data } = await axios.get(`/api/analysis/${props.projectId}/jobs/${props.jobId}/logs`)
35+
errorCount = 0
3336
logContent.value = data.log
3437
jobStatus.value = data.status
3538
@@ -46,7 +49,13 @@ async function pollLogs() {
4649
emit('failed', props.jobId)
4750
}
4851
} catch (e) {
49-
console.error('Failed to poll logs:', e)
52+
errorCount++
53+
if (errorCount >= 10) {
54+
logContent.value = '[error] Job not found. Close the console and re-launch the analysis.'
55+
jobStatus.value = 'failed'
56+
stopPolling()
57+
emit('failed', props.jobId)
58+
}
5059
}
5160
}
5261

0 commit comments

Comments
 (0)