swactor/examples/pipeline-parallel-inference/N3_POSTMORTEM_2026-05-25_1779733878.md

325 lines
15 KiB
Markdown
Raw Normal View History

# N=3 vast.ai deployment post-mortem — 2026-05-25 (run `1779733878`)
Second N=3 deployment of 2026-05-25, and the **first run on the
observability upgrade** (commit `e8be135`, the work specified in
`N3_OBSERVABILITY_UPGRADE_SPEC.md` and motivated by
`N3_DATA_GAPS.md`). Companion to the earlier post-mortem
`N3_POSTMORTEM_2026-05-25.md` (run `1779720002`), whose failure this
deployment was meant to (a) avoid and (b) make diagnosable.
One invocation of `pp-smoke-run --vastai --num-stages 3` (stub worker)
on 2026-05-25 (`vastai-N3-1779733878`). The cluster came up cleanly,
all three stage workers reached `ready`, the request was sent — and
then SWIM membership flapped continuously and no `InferenceResponse`
ever returned. The operator declared a deadstop at ~7 min and killed
the orchestrator. Unlike last time, the bundle was recovered through
the collector's own endpoint (gap 7), and the new diagnostics
**attributed the failure to a specific edge**: the response path from
the last stage back to the (NAT'd, locally-run) orchestrator.
## Outcome in one line
Not a worker bug and not the relay-session loss of run `1779720002`.
The stage chain was healthy; the weak link was reaching the
orchestrator over the relay. Per-peer dial data (gap 9) shows dials
**to the orchestrator failing 7/11 with `Timeout`** while every
inter-stage dial succeeded (19/19). This was indistinguishable in the
previous bundle and is a one-table answer now.
## Cleanup note
The run was terminated with `SIGTERM` (operator kill on deadstop),
which — like the previous `SIGKILL` — skips the orchestrator's
destroy-on-exit handler. Three rentals (`37803546`, `37803550`,
`37803555`) were destroyed via `DELETE /api/v0/instances/<id>/`
(HTTP 200 each). Post-cleanup instance count = 0, verified. No leak
from the earlier failed lease attempt either (see Sequence).
## Sequence
Orchestrator runs locally (behind home NAT, no direct port); three
stages on vast.ai RTX 4090 hosts; relay + collector on docean
(`146.190.110.128`).
```
t=— first launch dies instantly: lease_chain failed,
"no offers available (after geo/exclusion filter)".
Root cause: --gpu RTX_4090 (underscore) matches 0 vast.ai
offers; the API uses "RTX 4090" (space). 0 instances leased.
t=0 relaunch with --gpu "RTX 4090": orchestrator node 146aef53,
custom-relay banner emitted.
t=~135s contracts 37803546/37803550/37803555 reach running,
leased as stage-0 (aa0ef1c5), stage-1 (32abf9d6),
stage-2 (a1ebaa08).
"waiting for SWIM convergence (3 alive)" → converges.
t=~150s registered pp-orchestrator; pp-entry resolved; one
InferenceRequest sent. Enter await_response (600s budget).
t=~165s+ SWIM begins flapping. Members oscillate, e.g.:
+63s : 32abf9d6=suspect
+79s : 32abf9d6=dead, a1ebaa08=dead
+94s : 32abf9d6=dead (others alive)
+194s: 32abf9d6=dead, a1ebaa08=suspect
+216s: aa0ef1c5=suspect
iroh keeps exchanging messages throughout; connect-timeout
count to peers is 0 (contrast 1779720002).
t=~419s await_response still open, members momentarily all-alive,
still no InferenceResponse.
t≈7min operator declares deadstop (a peer Dead across two
consecutive 45s heartbeats with zero forward progress),
kills orchestrator (SIGTERM). No finalize record written.
```
## Bundle recovery (gap 7 — worked, with a caveat)
`GET /diag/bundle/vastai-N3-1779733878` returned **HTTP 200** with a
usable tarball — no hand tar/scp/reshape, unlike last time. The
gap-7 synthesis-from-staging path is the intended fix and it
functioned.
Caveat surfaced by this run: the run id was **reused across the
failed first lease attempt**. That attempt's orchestrator
(`e8151ed8`) called `finalize("lease_chain_error")`, which made the
collector build and cache a tiny (5.3 KB) canonical bundle from the
staging that existed *at that moment* — orchestrator + relay only, no
stages. Because `finalize_received` was then true, the first `GET`
served that **stale canonical bundle** rather than synthesizing from
current staging. Removing the cached bundle and the junk `e8151ed8`
node forced re-synthesis → full **9.3 MB** bundle with all five real
nodes. Two residual quirks observed even after removal:
- `finalize_received` stayed `true` (the collector retains an
in-memory finalize record that outlives deletion of the on-disk
`finalize-*.json`).
- the synthesized `MANIFEST.json` still listed the deleted `e8151ed8`
node (with `finalize_recorded: true`) although no such directory
was in the tarball.
Neither blocked analysis, but both are worth hardening: gap-7 assumed
finalize == end-of-run, and run-id reuse breaks that assumption.
## Bundle findings
### Volume
| node | role | snapshots | events |
|----------------------------|--------------|-----------|--------|
| `146aef53` orchestrator | orchestrator | 409 | 4385 |
| `1c8357a1` relay (docean) | relay | 186 | 187 |
| `aa0ef1c5` stage-0 | stage | 771 | 8638 |
| `32abf9d6` stage-1 | stage | 305 | 3234 |
| `a1ebaa08` stage-2 | stage | 535 | 5963 |
`run_start_ms=1779734098442`, `run_end_ms=1779735024439`
(`duration_ms=925997`; the tail includes the relay's continued
periodic reporting after the orchestrator died — the relay on docean
is still pinned to this run id, see Infra state).
### Subprocess lifecycle (gap 4) — decisive
`SubprocessSpawned: 3`. `Custom(worker_starting): 3`,
`Custom(worker_ready): 3`, `Custom(worker_heartbeat): 37`. No
`SubprocessExited`.
**All three stage workers spawned and became ready and stayed up.**
This is the single fact the `1779720002` bundle could not establish
(there, stage-2 emitted neither `worker_starting` nor `worker_ready`,
and we could not tell "never spawned" from "spawned and died"). The
worker is conclusively ruled out as the cause this time.
### Per-peer dials (gap 9) — the attribution
Totals: `started=50, succeeded=42, failed=7, in-flight=1`.
| peer | started | ok | failed | in-flight | last_outcome | at_ms |
|-----------------------|---------|----|--------|-----------|--------------|----------------|
| orchestrator-146aef53 | 11 | 3 | **7** | 1 | **Timeout** | 1779734955567 |
| stage-0 (aa0ef1c5) | 1 | 1 | 0 | 0 | Success | 1779734410397 |
| stage-1 (32abf9d6) | 19 | 19 | 0 | 0 | Success | 1779734518356 |
| stage-2 (a1ebaa08) | 19 | 19 | 0 | 0 | Success | 1779734522415 |
Every dial *between stages* succeeded. Only dials *to the
orchestrator* failed, and they failed by timeout. The last stage's
`InferenceResponse` is addressed to the orchestrator's inbox; if it
cannot dial the orchestrator, the response never lands. This table is
the proximate cause of the empty result.
### Relay-session field (gap 2) and conn type
stage-2's latest snapshot `body.iroh.relay_session`:
```
relay_url: http://146.190.110.128:7843/
status: connected
status_changed_at_ms:1779734401024
status_entered_at_ms:1779734401024
status_source: derived
```
The tunnel to the relay was **connected**, with `status_source:
derived` honestly flagging that iroh does not expose this natively
(per spec §2). So the failure is *not* "tunnel died" — it is "tunnel
alive, peer-connection-through-tunnel to the orchestrator dead." That
distinction was the explicit acceptance criterion for gap 2, and it
holds here.
`First peer to go Dead`: stage-2 marked stage-1 (`32abf9d6`) Dead at
`t=1779734428439`, reason `suspicion-timeout`. Both sides
`conn_type=Relay` (no direct hole-punch anywhere in the run). Observer
`probes_ok=yes`, peer `probes_ok=no`.
### SWIM churn and relay events (gap 3)
`SwimTransition: 1701` over a ~7-minute run — heavy flapping,
consistent with the relay-mediated reachability of a NAT'd
orchestrator and the §10.3 self-incarnation flap (see
`SWIM_TUNING_REPORT`). `RelaySessionStateChanged: 8`, `RelayChanged:
4`, `IrohConnTypeChanged: 8` — relay/transport flips are now on the
event stream, not just counter deltas.
### Kernel network drops (gap 11)
`udp.no_ports` delta across the run:
| node | udp.no_ports |
|-------------------|--------------|
| orchestrator | +1 |
| stage-0 | +139 |
| stage-1 | +134 |
| stage-2 | **+1424** |
stage-2 took ~10× the no-listening-port UDP drops of its siblings —
an interface-level corroboration of localized relay/hole-punch path
instability, surfaced automatically in the summary.
### Gossip receipts (gap 10)
| node | swim_piggyback | bytes | items |
|--------------|----------------|--------|-------|
| orchestrator | 806 | 263825 | 1607 |
| stage-0 | 1589 | 522855 | 3178 |
| stage-1 | 563 | 194236 | 1183 |
| stage-2 | 1082 | 340115 | 2069 |
Every node received gossip. Gossip starvation is ruled out — the
flap is not "a node never heard membership," it is "membership churned
because the underlying relay path to a peer was unreliable."
### UDP echo probes (collector tier-2)
| node | result |
|--------------|---------------------------------|
| orchestrator | ok, rtt=293 ms, 34/35 |
| stage-0 | ok, rtt=181 ms, 55/55 |
| stage-1 | ok, rtt=405 ms, 27/28 |
| stage-2 | ok, rtt=184 ms, 38/38 |
All nodes had clean tier-2 reachability to docean:9081 — i.e. the
hosts themselves were on the network. The failure was at the iroh
peer-connection layer, not raw host reachability.
### iroh version honesty (gap 6)
`iroh_version: "0.98.2"` on every node and in every
`iroh_api_missing` event (4 total) — matches `Cargo.lock`. The
hard-coded `"0.96"` literal from `1779720002` is gone. The API-gap
list now also carries the `RelayTunnel.*` derived-field markers.
## Observability upgrade scorecard
What this run confirms the upgrade delivers, versus what it doesn't:
| gap | status | evidence |
|-----|--------|----------|
| 2 relay-session field | ✅ | `relay_session.status=connected, status_source=derived` |
| 3 relay events | ✅ | `RelaySessionStateChanged: 8`, `RelayChanged: 4` |
| 4 subprocess introspector | ✅ | `SubprocessSpawned: 3`; worker ruled out |
| 6 iroh version honesty | ✅ | `0.98.2` everywhere, lockfile match |
| 7 bundle without finalize | ✅¹ | `GET` returned a usable 9.3 MB bundle; ¹run-id reuse exposed stale-canonical serve + sticky finalize flag |
| 9 per-peer dials | ✅ | the orchestrator-reachability table (the headline finding) |
| 10 gossip receipts | ✅ | per-node `swim_piggyback` breakdown |
| 11 kernel counters | ✅ | stage-2 `udp.no_ports +1424` surfaced |
| 1 relay observability | ◐ | relay reports identity + 186 snapshots, but per-session lifecycle is the documented skeleton: `active=0 opens=0 closes=0` (iroh-relay exposes no session hooks) |
| 5 host metadata | ◐ | `container_id`/`hostname`/`git_sha`/`iroh_version`/`binary_version` present; `host_ip_public`/`datacenter_id`/`host_country`/`vastai_contract_id` still **null** — provider fields not forwarded. (The contract id does arrive, but in `container_id`, not `vastai_contract_id`.) |
| 8 relay-port probe | ✗ | not present: stage probe arrays carry only `collector_udp_echo` (:9081); no relay-port (:7843) probe |
A+B+C (the tiers the previous investigation needed) all landed and
were load-bearing here. The polish/robustness tiers (5 ip/dc/country,
7 finalize edge cases, 8 relay probe, 1 relay session lifecycle) have
remaining work.
## Data-collection / deployment gaps surfaced by this run
1. **Host provider fields not forwarded (gap 5 incomplete).** The
orchestrator has each rental's public IP, datacenter, country, and
contract id at `lease_chain` return, but only the docker container
id (landing in `container_id`) and hostname reach the boot record.
`host_ip_public`/`datacenter_id`/`host_country`/`vastai_contract_id`
/`home_relay_url_at_boot` are still null. "Which rental was
stage-2" is answerable only via the `container_id`↔contract map,
not the dedicated fields.
2. **gap-7 stale-canonical serve on run-id reuse.** A finalize from an
earlier phase of the same run id pins a stale cached bundle, and
`finalize_received` is sticky in collector memory across staging
deletion. Serve logic should prefer the richer of {canonical,
synthesized-from-current-staging} or rebuild canonical when staging
has grown past the cached bundle.
3. **No relay-port reachability probe (gap 8 absent).** Whether
stage-2 could reach docean:7843 (the relay) at moment T is still
inferred from a different port (:9081 echo). The exact gap the
previous post-mortem flagged remains open.
4. **Relay per-session lifecycle still skeleton (gap 1).** The relay
reports into the bundle but cannot yet say "who closed session X
and why" because `iroh_relay::server` exposes no session hooks.
Until it does, "was this a relay-side eviction" is unanswerable
from the relay side; we relied on node-side dial outcomes instead.
5. **No response-leg instrumentation.** The conclusion "last stage
could not deliver the response" was inferred from dial timeouts +
absence of an inbound response, not from a typed event on the last
stage ("attempted to send InferenceResponse to orchestrator,
outcome=…"). A response-send event would make this a direct read
rather than an inference.
6. **Environmental: orchestrator topology is the root cause.** A
locally-run, NAT'd orchestrator with no direct port is reachable
only via the relay, and the relay path to it proved unreliable
under load (7/11 inbound dials timed out, 1701 SWIM transitions).
Running the orchestrator on a reachable host (e.g. docean) or
giving it a direct/forwarded port is the likely fix to test next.
7. **`DEPLOYMENT_TEST.md` GPU flag is wrong.** Line 84 shows
`--gpu RTX_4090`; vast.ai matches `gpu_name` literally and the
underscore form returns 0 offers. The orchestrator's own default
is the correct `"RTX 4090"`. Fix the runbook example.
## Artifacts
In `.vastai-logs/` (gitignored) after recovery:
```
vastai-N3-1779733878.bundle.tar.gz full synthesized bundle (9.3 MB, 5 nodes)
vastai-N3-1779733878.staging-full.tar.gz raw collector staging backup (~10 MB)
vastai-N3-1779733878.tar.gz the stale 5.3 KB canonical bundle (kept for reference)
vastai-N3-1779733878.log orchestrator stdout (both launch attempts)
vastai-N3-1779733878.out/summary.md postproc summary
vastai-N3-1779733878.out/reachability.tsv
vastai-N3-1779733878.out/timeline-*.tsv per-link timelines
```
Staging copy retained on docean at
`/var/lib/swactor-diag/vastai-N3-1779733878/` (minus the removed
`e8151ed8` junk node).
## Infrastructure state at end of session
- docean (`146.190.110.128`): collector and relay running (rebuilt
static-musl binaries from `e8be135`). The relay is **still pinned to
`SWACTOR_DIAG_RUN_ID=vastai-N3-1779733878`** and continues appending
periodic snapshots to that run's staging; the next run's redeploy
re-pins it. Re-pulling the bundle later will include those extra
relay snapshots.
- vast.ai instances under `$VAST_API_KEY`: **0** (verified).