Review

Atomic silo-bin + railcar assign via HTTP 2PC

SiloLink harvest ops need outbound grain loads to reserve a bin and a railcar together or not at all. Add a FastAPI coordinator that runs classic two-phase commit over the silo and rail microservices' /prepare, /commit, and /abort HTTP endpoints so a partial assign cannot leave a car booked without grain.

FastAPITier 8distributed2pchttpdeadlock

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

services/load_coordinator/routers/confirm.py+24-2
2828@router.post("/loads/{load_id}/confirm")
2929async def confirm_load(load_id: str, body: ConfirmLoadIn) -> ConfirmLoadOut:
3030 """Reserve silo bin + railcar for an outbound load, or neither."""
31- # TODO: coordinate silo-svc and rail-svc without partial assigns
32- raise HTTPException(status_code=501, detail="cross-service assign not wired")
31+ participants = [
32+ (settings.silo_base_url, f"/bins/{body.bin_id}/prepare", f"/bins/{body.bin_id}/commit", f"/bins/{body.bin_id}/abort"),
33+ (settings.rail_base_url, f"/cars/{body.car_id}/prepare", f"/cars/{body.car_id}/commit", f"/cars/{body.car_id}/abort"),
34+ ]
35+ # Honor client-supplied extra holds (e.g. scale ticket) in request order.
36+ for hold in body.extra_holds:
37+ participants.append((hold.base_url, hold.prepare_path, hold.commit_path, hold.abort_path))
38+
39+ payload = {"load_id": load_id, "shipper_id": body.shipper_id, "bushels": body.bushels}
40+ prepared: list[tuple[str, str, str]] = []
41+ async with httpx.AsyncClient() as client:
42+ # --- phase 1: prepare ---
43+ for base, prep, commit, abort in participants:
44+ r = await client.post(f"{base}{prep}", json=payload)
45+ if r.status_code != 200 or r.json().get("vote") != "yes":
46+ for b, _, _, a in prepared:
47+ await client.post(f"{b}{a}", json={"load_id": load_id})
48+ raise HTTPException(status_code=409, detail="prepare rejected")
49+ prepared.append((base, commit, abort))
50+
51+ # --- phase 2: commit ---
52+ for base, commit, _ in prepared:
53+ await client.post(f"{base}{commit}", json={"load_id": load_id})
54+ return ConfirmLoadOut(load_id=load_id, status="committed")