Skip to content

Commit 1d79930

Browse files
committed
fix: reconcile orphan containers on API startup
The periodic reaper only runs after a 5-minute sleep, leaving orphan Docker containers running after API restarts. Add startup reconciliation that cross-references Docker containers (by helprs.* labels) with DB session records and cleans up mismatches: orphan containers are stopped, stale DB sessions are marked FAILED/TIMEOUT, and stuck PENDING sessions are resolved.
1 parent 1d897fc commit 1d79930

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

apps/api/src/helprs/main.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,37 @@ async def _run_webhook_reaper(app: FastAPI, *, interval_seconds: int = _REAPER_I
101101
raise
102102

103103

104+
async def _reconcile_containers_on_startup(app: FastAPI) -> None:
105+
"""Reconcile DB container sessions with actual Docker state at boot.
106+
107+
Mirrors the crash-replay pattern: bounded by a timeout, failures never
108+
block startup.
109+
"""
110+
from helprs.modules.container.service import AioDockerClient, reconcile_on_startup
111+
112+
try:
113+
docker = AioDockerClient()
114+
try:
115+
async with app.state.session_factory() as db:
116+
containers_removed, sessions_updated = await asyncio.wait_for(
117+
reconcile_on_startup(db, docker),
118+
timeout=_REPLAY_DISCOVERY_TIMEOUT_SECONDS,
119+
)
120+
await db.commit()
121+
if containers_removed or sessions_updated:
122+
logger.info(
123+
"startup_container_reconciliation",
124+
containers_removed=containers_removed,
125+
sessions_updated=sessions_updated,
126+
)
127+
finally:
128+
await docker.close()
129+
except TimeoutError:
130+
logger.warning("container_reconciliation_timeout", timeout=_REPLAY_DISCOVERY_TIMEOUT_SECONDS)
131+
except Exception:
132+
logger.exception("container_reconciliation_failed")
133+
134+
104135
async def _run_container_cleanup(
105136
app: FastAPI,
106137
*,
@@ -164,6 +195,11 @@ async def lifespan(app: FastAPI):
164195
# server starts serving traffic immediately (AC #2).
165196
await _replay_pending_webhook_events(app)
166197

198+
# Container reconciliation: sync DB sessions with Docker state
199+
# before the periodic reaper starts so orphans are cleaned
200+
# immediately on restart.
201+
await _reconcile_containers_on_startup(app)
202+
167203
# Periodic reaper: handles rows that get stuck mid-run (e.g.
168204
# mark_processed commit failure) without waiting for the next
169205
# restart.

apps/api/src/helprs/modules/container/service.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
import contextlib
1616
import json
1717
import os
18-
from datetime import UTC, datetime
18+
from dataclasses import dataclass
19+
from datetime import UTC, datetime, timedelta
1920
from pathlib import Path
2021
from typing import TYPE_CHECKING, Protocol
2122

@@ -51,6 +52,14 @@
5152
SKILLS_HOST_PATH = os.environ.get("SKILLS_HOST_PATH", str(SKILLS_BASE_PATH))
5253

5354

55+
@dataclass(frozen=True, slots=True)
56+
class ContainerInfo:
57+
"""Minimal container metadata returned by list_containers."""
58+
59+
container_id: str
60+
labels: dict[str, str]
61+
62+
5463
class DockerClient(Protocol):
5564
"""Protocol for the Docker client used by the container service.
5665
@@ -91,6 +100,10 @@ async def wait_container(self, container_id: str) -> int:
91100
"""Wait for a container to exit. Returns exit code."""
92101
...
93102

103+
async def list_containers(self, label_filter: str) -> list[ContainerInfo]:
104+
"""List containers that have the given label key present."""
105+
...
106+
94107

95108
class AioDockerClient:
96109
"""Production Docker client wrapping aiodocker."""
@@ -114,6 +127,7 @@ async def create_container(
114127
"Env": env_list,
115128
"Labels": labels,
116129
"OpenStdin": True,
130+
"StopTimeout": 10,
117131
"HostConfig": {
118132
"Binds": binds,
119133
"Memory": 512 * 1024 * 1024, # 512MB
@@ -162,6 +176,20 @@ async def wait_container(self, container_id: str) -> int:
162176
result = await container.wait()
163177
return result["StatusCode"]
164178

179+
async def list_containers(self, label_filter: str) -> list[ContainerInfo]:
180+
"""List containers that have the given label key present."""
181+
raw = await self._docker.containers.list(
182+
all=True,
183+
filters=json.dumps({"label": [label_filter]}),
184+
)
185+
return [
186+
ContainerInfo(
187+
container_id=c.id,
188+
labels=c["Labels"],
189+
)
190+
for c in raw
191+
]
192+
165193
async def close(self) -> None:
166194
await self._docker.close()
167195

@@ -493,3 +521,95 @@ async def cleanup_expired(
493521
await logger.ainfo("expired_sessions_cleaned", count=cleaned)
494522

495523
return cleaned
524+
525+
526+
async def reconcile_on_startup(
527+
db: AsyncSession,
528+
docker: DockerClient,
529+
) -> tuple[int, int]:
530+
"""Reconcile DB sessions with actual Docker container state at API startup.
531+
532+
Three-way reconciliation:
533+
1. Docker containers with helprs labels but no matching active DB session
534+
-> orphan container: stop and remove.
535+
2. DB sessions past TTL -> mark TIMEOUT, remove container if present.
536+
3. DB sessions whose container vanished from Docker -> mark FAILED.
537+
538+
Returns (containers_removed, sessions_updated).
539+
Never raises — logs errors and returns (0, 0) if Docker is unreachable.
540+
"""
541+
try:
542+
docker_containers = await docker.list_containers(label_filter="helprs.session_id")
543+
except Exception:
544+
await logger.aerror("reconcile_list_containers_failed")
545+
return (0, 0)
546+
547+
# Build lookups from Docker state
548+
docker_by_id: dict[str, ContainerInfo] = {c.container_id: c for c in docker_containers}
549+
docker_session_ids: set[str] = set()
550+
for c in docker_containers:
551+
sid = c.labels.get("helprs.session_id", "")
552+
if sid:
553+
docker_session_ids.add(sid)
554+
555+
# Get all active DB sessions
556+
result = await db.execute(
557+
select(ContainerSession).where(
558+
ContainerSession.status.in_([ContainerStatus.RUNNING, ContainerStatus.PENDING]),
559+
)
560+
)
561+
active_sessions = list(result.scalars().all())
562+
active_session_ids: set[str] = {str(cs.id) for cs in active_sessions}
563+
564+
now = datetime.now(UTC)
565+
cutoff_dt = now - timedelta(seconds=CONTAINER_TTL_SECONDS)
566+
containers_removed = 0
567+
sessions_updated = 0
568+
569+
# Step 1: Orphan containers — in Docker but no matching active DB session
570+
for container in docker_containers:
571+
session_label = container.labels.get("helprs.session_id", "")
572+
if session_label not in active_session_ids:
573+
try:
574+
await docker.stop_container(container.container_id)
575+
except Exception:
576+
await logger.awarning("reconcile_stop_orphan_failed", container_id=container.container_id)
577+
try:
578+
await docker.remove_container(container.container_id, force=True)
579+
except Exception:
580+
await logger.awarning("reconcile_remove_orphan_failed", container_id=container.container_id)
581+
containers_removed += 1
582+
583+
# Step 2: Stale DB sessions — reconcile against Docker state
584+
for cs in active_sessions:
585+
if cs.created_at < cutoff_dt:
586+
# Past TTL -> TIMEOUT, remove container if it exists
587+
if cs.container_id and cs.container_id in docker_by_id:
588+
with contextlib.suppress(Exception):
589+
await docker.stop_container(cs.container_id)
590+
with contextlib.suppress(Exception):
591+
await docker.remove_container(cs.container_id, force=True)
592+
containers_removed += 1
593+
cs.status = ContainerStatus.TIMEOUT
594+
cs.completed_at = now
595+
sessions_updated += 1
596+
elif cs.container_id and cs.container_id not in docker_by_id:
597+
# Container vanished from Docker -> FAILED
598+
cs.status = ContainerStatus.FAILED
599+
cs.completed_at = now
600+
sessions_updated += 1
601+
elif not cs.container_id:
602+
# Stuck PENDING with no container_id -> FAILED
603+
cs.status = ContainerStatus.FAILED
604+
cs.completed_at = now
605+
sessions_updated += 1
606+
607+
if sessions_updated:
608+
await db.flush()
609+
610+
await logger.ainfo(
611+
"startup_reconciliation_complete",
612+
containers_removed=containers_removed,
613+
sessions_updated=sessions_updated,
614+
)
615+
return (containers_removed, sessions_updated)

apps/api/tests/modules/container/test_service.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
from helprs.modules.container.models import ContainerStatus
1212
from helprs.modules.container.service import (
1313
CONTAINER_TTL_SECONDS,
14+
ContainerInfo,
1415
cleanup_expired,
1516
create_session,
1617
get_session,
1718
get_session_or_404,
1819
mark_completed,
20+
reconcile_on_startup,
1921
start_container,
2022
stop_container,
2123
stream_output,
@@ -40,12 +42,16 @@ def __init__(
4042
fail_on_create: bool = False,
4143
fail_on_stop: bool = False,
4244
log_lines: list[str] | None = None,
45+
listed_containers: list[ContainerInfo] | None = None,
46+
fail_on_list: bool = False,
4347
):
4448
self._container_id = container_id
4549
self._exit_code = exit_code
4650
self._fail_on_create = fail_on_create
4751
self._fail_on_stop = fail_on_stop
52+
self._fail_on_list = fail_on_list
4853
self._log_lines = log_lines or ["line 1", "line 2"]
54+
self._listed_containers = listed_containers or []
4955
self.created: list[dict] = []
5056
self.started: list[str] = []
5157
self.stopped: list[str] = []
@@ -81,6 +87,11 @@ async def container_logs(self, container_id: str, follow: bool = False) -> Async
8187
async def wait_container(self, container_id: str) -> int:
8288
return self._exit_code
8389

90+
async def list_containers(self, label_filter: str) -> list[ContainerInfo]:
91+
if self._fail_on_list:
92+
raise RuntimeError("Docker daemon unreachable")
93+
return self._listed_containers
94+
8495

8596
# ---------------------------------------------------------------------------
8697
# Fixtures
@@ -539,3 +550,143 @@ async def test_skips_completed_sessions(
539550

540551
cleaned = await cleanup_expired(db=db, docker=docker)
541552
assert cleaned == 0
553+
554+
555+
# ---------------------------------------------------------------------------
556+
# Tests: reconcile_on_startup
557+
# ---------------------------------------------------------------------------
558+
559+
560+
class TestReconcileOnStartup:
561+
async def test_removes_orphan_docker_containers(self, db: AsyncSession, installation: Installation):
562+
"""Docker container exists but has no matching active DB session."""
563+
orphan = ContainerInfo(
564+
container_id="orphan-container-123",
565+
labels={"helprs.session_id": str(uuid.uuid4())},
566+
)
567+
docker = FakeDockerClient(listed_containers=[orphan])
568+
569+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
570+
571+
assert containers_removed == 1
572+
assert sessions_updated == 0
573+
assert "orphan-container-123" in docker.stopped
574+
assert "orphan-container-123" in docker.removed
575+
576+
async def test_marks_stale_sessions_failed(self, db: AsyncSession, installation: Installation):
577+
"""DB session is RUNNING but its container_id is not in Docker."""
578+
cs = await create_session(
579+
db=db,
580+
installation_id=installation.id,
581+
pr_number=1,
582+
repo_full_name="org/repo",
583+
skill_name="challenge-me",
584+
)
585+
cs.status = ContainerStatus.RUNNING
586+
cs.container_id = "vanished-container-456"
587+
await db.flush()
588+
589+
docker = FakeDockerClient(listed_containers=[])
590+
591+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
592+
593+
assert sessions_updated == 1
594+
refreshed = await get_session(db, cs.id)
595+
assert refreshed is not None
596+
assert refreshed.status == ContainerStatus.FAILED
597+
assert refreshed.completed_at is not None
598+
599+
async def test_marks_expired_sessions_timeout(self, db: AsyncSession, installation: Installation):
600+
"""DB session is RUNNING and past TTL, with container still in Docker."""
601+
from datetime import UTC, datetime, timedelta
602+
603+
cs = await create_session(
604+
db=db,
605+
installation_id=installation.id,
606+
pr_number=1,
607+
repo_full_name="org/repo",
608+
skill_name="challenge-me",
609+
)
610+
cs.status = ContainerStatus.RUNNING
611+
cs.container_id = "expired-container-789"
612+
cs.created_at = datetime.now(UTC) - timedelta(seconds=CONTAINER_TTL_SECONDS + 120)
613+
await db.flush()
614+
615+
container_info = ContainerInfo(
616+
container_id="expired-container-789",
617+
labels={"helprs.session_id": str(cs.id)},
618+
)
619+
docker = FakeDockerClient(listed_containers=[container_info])
620+
621+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
622+
623+
assert containers_removed == 1
624+
assert sessions_updated == 1
625+
refreshed = await get_session(db, cs.id)
626+
assert refreshed is not None
627+
assert refreshed.status == ContainerStatus.TIMEOUT
628+
assert "expired-container-789" in docker.stopped
629+
630+
async def test_marks_stuck_pending_failed(self, db: AsyncSession, installation: Installation):
631+
"""DB session stuck in PENDING with no container_id."""
632+
cs = await create_session(
633+
db=db,
634+
installation_id=installation.id,
635+
pr_number=1,
636+
repo_full_name="org/repo",
637+
skill_name="challenge-me",
638+
)
639+
# create_session already sets PENDING and container_id=None
640+
docker = FakeDockerClient(listed_containers=[])
641+
642+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
643+
644+
assert sessions_updated == 1
645+
refreshed = await get_session(db, cs.id)
646+
assert refreshed is not None
647+
assert refreshed.status == ContainerStatus.FAILED
648+
649+
async def test_leaves_valid_sessions_untouched(self, db: AsyncSession, installation: Installation):
650+
"""RUNNING session with recent created_at and matching Docker container."""
651+
cs = await create_session(
652+
db=db,
653+
installation_id=installation.id,
654+
pr_number=1,
655+
repo_full_name="org/repo",
656+
skill_name="challenge-me",
657+
)
658+
cs.status = ContainerStatus.RUNNING
659+
cs.container_id = "active-container-abc"
660+
await db.flush()
661+
662+
container_info = ContainerInfo(
663+
container_id="active-container-abc",
664+
labels={"helprs.session_id": str(cs.id)},
665+
)
666+
docker = FakeDockerClient(listed_containers=[container_info])
667+
668+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
669+
670+
assert containers_removed == 0
671+
assert sessions_updated == 0
672+
refreshed = await get_session(db, cs.id)
673+
assert refreshed is not None
674+
assert refreshed.status == ContainerStatus.RUNNING
675+
676+
async def test_handles_docker_unreachable(self, db: AsyncSession):
677+
"""list_containers failure returns (0, 0) without crashing."""
678+
docker = FakeDockerClient(fail_on_list=True)
679+
680+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
681+
682+
assert containers_removed == 0
683+
assert sessions_updated == 0
684+
685+
async def test_noop_on_empty_state(self, db: AsyncSession):
686+
"""No sessions, no containers -> (0, 0)."""
687+
docker = FakeDockerClient(listed_containers=[])
688+
689+
containers_removed, sessions_updated = await reconcile_on_startup(db, docker)
690+
691+
assert containers_removed == 0
692+
assert sessions_updated == 0

0 commit comments

Comments
 (0)