Files
parking_solution/apps/trainer/tests/test_server.py
T
julian f4b806a538 fix(collector,trainer): migrate an existing collector DB on open; trainer handlers answer 500 JSON
The reviewer host's collector.sqlite was created by an earlier build, before the
`kind` column. CREATE TABLE IF NOT EXISTS shapes only a new database, so every query
naming the column failed: the collector's /health (container unhealthy), every
booth ingest, and the trainer's readiness — whose stdlib server printed the
traceback and dropped the socket, which the collector could only render as
"trainer not reachable: fetch failed". Nine days like that.

- CollectorDb.#migrate(): PRAGMA table_info against the list of columns added
  since the first deploy; ALTER TABLE ADD COLUMN for each missing one (all
  nullable or defaulted). Append to that list whenever a column joins the CREATE.
  Test replays the original schema: health, ingest, stats, a legacy row reads
  back with the defaults.
- Trainer Handler._guarded(): any unexpected exception → 500 JSON naming it,
  never a dropped connection; /health keeps answering. Test drives readiness
  against an old-schema DB.
- The collector's training status proxy includes the trainer's error text.

Wiki: the incident and the schema rule (vision-review-outbox), what the message
means (bodytype-classifier-training), log. Deploy: the new collector migrates on
start; nothing manual.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-16 10:33:47 +02:00

148 lines
6.8 KiB
Python

"""The job API: readiness, one job at a time, subprocess jobs with persisted logs, versions."""
from __future__ import annotations
import json
import sqlite3
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from trainer.server import Handler, Jobs, readiness, versions, wait_idle
@pytest.fixture
def api(collector_dir: Path, tmp_path: Path): # type: ignore[no-untyped-def]
out = tmp_path / "out"
Handler.jobs = Jobs(collector_dir, out, "https://example.invalid/pkg", "tok")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
base = f"http://127.0.0.1:{httpd.server_address[1]}"
def call(method: str, path: str, body: dict | None = None): # type: ignore[no-untyped-def]
req = urllib.request.Request(base + path, method=method)
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, data=data, timeout=10) as r:
raw = r.read()
return r.status, (
json.loads(raw) if r.headers.get_content_type() == "application/json" else raw.decode()
)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
yield call, out
httpd.shutdown()
httpd.server_close()
def test_readiness_and_empty_versions(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
code, r = call("GET", "/readiness")
assert code == 200 and r["ready"] is True and r["run"]["classes"] == ["sedan", "suv", "van"]
assert r["defaults"]["minAccuracy"] == 0.85 and "finetune" in r["modes"]
assert call("GET", "/versions") == (200, {"versions": []})
assert call("GET", "/health")[1]["busy"] is False
assert readiness(Path("/nonexistent"))["ready"] is False
def test_evaluate_job_runs_as_a_subprocess_and_is_recorded(api) -> None: # type: ignore[no-untyped-def]
call, out = api
code, job = call("POST", "/jobs", {"kind": "evaluate", "version": "nope"})
assert code == 202 and job["status"] == "running" and job["kind"] == "evaluate"
wait_idle(Handler.jobs)
code, j = call("GET", f"/jobs/{job['id']}")
assert code == 200 and j["status"] == "failed" and j["exitCode"] == 1
assert "evaluate --data" in j["log"] and "nope" in j["log"]
assert (out / "jobs" / f"{job['id']}.json").is_file() and (out / "jobs" / f"{job['id']}.log").is_file()
code, lst = call("GET", "/jobs")
assert code == 200 and lst["jobs"][0]["id"] == job["id"] and lst["current"] is None
def test_bad_requests(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
assert call("POST", "/jobs", {"kind": "nuke"})[0] == 400
assert call("POST", "/jobs", {"kind": "train", "mode": "magic"})[0] == 400
assert call("POST", "/jobs", {"kind": "evaluate", "version": "../etc"})[0] == 400
assert call("POST", "/jobs", {"kind": "publish", "version": "v1", "url": "ftp://x"})[0] == 400
assert call("GET", "/versions/../x/report")[0] == 400
assert call("GET", "/versions/v9/report")[0] == 404
assert call("GET", "/jobs/nope")[0] == 404
assert call("GET", "/nothing")[0] == 404
def test_train_job_then_versions_and_report(api) -> None: # type: ignore[no-untyped-def]
pytest.importorskip("torch")
call, out = api
body = {"kind": "train", "mode": "features", "minAccuracy": 0.0, "epochs": 100, "version": "vapi"}
# The test-only flags are not offered by the API; inject them via the CLI args the runner builds.
orig = Jobs._argv
def patched(self, kind, a): # type: ignore[no-untyped-def]
argv = orig(self, kind, a)
return argv + ["--no-pretrained", "--input-size", "64", "--no-cache"] if kind == "train" else argv
Jobs._argv = patched # type: ignore[method-assign]
try:
code, job = call("POST", "/jobs", body)
assert code == 202
assert call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})[0] == 409 # one at a time
wait_idle(Handler.jobs, 120)
finally:
Jobs._argv = orig # type: ignore[method-assign]
code, j = call("GET", f"/jobs/{job['id']}")
assert j["status"] == "done" and "MODEL WRITTEN" in j["log"]
code, v = call("GET", "/versions")
assert code == 200 and v["versions"][0]["version"] == "vapi" and v["versions"][0]["written"] is True
assert v["versions"][0]["classes"] == ["sedan", "suv", "van"] and v["versions"][0]["accuracy"] >= 0.9
code, report = call("GET", "/versions/vapi/report")
assert code == 200 and report.startswith("# Body-type classifier vapi")
assert versions(out)[0]["floor"] == 0.0
# evaluate on the written model now succeeds
code, job2 = call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})
wait_idle(Handler.jobs)
assert call("GET", f"/jobs/{job2['id']}")[1]["status"] == "done"
def test_a_handler_error_is_a_500_json_reply_not_a_dropped_connection(tmp_path: Path) -> None:
"""A collector DB from before the `kind` column (art-docker-station, 2026-09-16): readiness
raised sqlite3.OperationalError, the stdlib server printed the traceback and closed the
socket, and the collector could only say "trainer not reachable: fetch failed". The
handler must answer 500 JSON naming the error instead."""
old = tmp_path / "old"
old.mkdir()
con = sqlite3.connect(old / "collector.sqlite")
con.executescript(
"CREATE TABLE items (id TEXT PRIMARY KEY, booth TEXT NOT NULL, order_ref TEXT NOT NULL,"
" at TEXT NOT NULL, service TEXT NOT NULL, vision_class TEXT NOT NULL,"
" vision_confidence REAL NOT NULL, image_width INTEGER NOT NULL, image_height INTEGER NOT NULL,"
" plate_blurred INTEGER NOT NULL, image_path TEXT NOT NULL, received_at TEXT NOT NULL,"
" review_label TEXT, reviewed_at TEXT, reviewer TEXT)"
)
con.commit()
con.close()
Handler.jobs = Jobs(old, tmp_path / "out", "https://example.invalid/pkg", "tok")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
req = urllib.request.Request(f"http://127.0.0.1:{httpd.server_address[1]}/readiness")
with pytest.raises(urllib.error.HTTPError) as ei:
urllib.request.urlopen(req, timeout=10)
assert ei.value.code == 500
body = json.loads(ei.value.read())
assert "no such column: kind" in body["error"]
# /health does not touch the DB and still answers.
with urllib.request.urlopen(f"http://127.0.0.1:{httpd.server_address[1]}/health", timeout=10) as r:
assert r.status == 200
finally:
httpd.shutdown()
httpd.server_close()