f4b806a538
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
148 lines
6.8 KiB
Python
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()
|