15 KiB
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_receivedstayedtrue(the collector retains an in-memory finalize record that outlives deletion of the on-diskfinalize-*.json).- the synthesized
MANIFEST.jsonstill listed the deletede8151ed8node (withfinalize_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
-
Host provider fields not forwarded (gap 5 incomplete). The orchestrator has each rental's public IP, datacenter, country, and contract id at
lease_chainreturn, but only the docker container id (landing incontainer_id) and hostname reach the boot record.host_ip_public/datacenter_id/host_country/vastai_contract_id/home_relay_url_at_bootare still null. "Which rental was stage-2" is answerable only via thecontainer_id↔contract map, not the dedicated fields. -
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_receivedis 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. -
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.
-
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::serverexposes 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. -
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.
-
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.
-
DEPLOYMENT_TEST.mdGPU flag is wrong. Line 84 shows--gpu RTX_4090; vast.ai matchesgpu_nameliterally 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 frome8be135). The relay is still pinned toSWACTOR_DIAG_RUN_ID=vastai-N3-1779733878and 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).