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
| 28 | 28 | @router.post("/loads/{load_id}/confirm") | |
| 29 | 29 | async def confirm_load(load_id: str, body: ConfirmLoadIn) -> ConfirmLoadOut: | |
| 30 | 30 | """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") |