swactor/examples/pipeline-parallel-inference/N3_OBSERVABILITY_UPGRADE_SPEC.md

495 lines
20 KiB
Markdown
Raw Normal View History

2026-05-25 18:19:06 +00:00
# N=3 observability upgrade — behavioral spec
Sister doc to `N3_DATA_GAPS.md`. The gaps doc says *what's missing
and why we care*. This doc says *what the system must do once the
gaps are closed.*
Each section is a behavior contract: requirements the running
system has to satisfy after the work is done. Implementation
strategy — which crate, which file, which trait — is left to the
person picking up the work, except where a pattern is load-bearing
to the contract itself (the subprocess introspector is the one
explicit pattern requirement, called out below at the user's
direction).
Throughout: every "the bundle contains X" claim is testable. A
post-deployment run that doesn't satisfy these is a failed upgrade.
## Cross-cutting requirements
1. **Additive evolution.** A node running new code emits bundles
that a post-processor built against old code can still parse —
missing fields are absent, not malformed. Symmetrically, a
post-processor built against new code reads an old bundle by
showing the new fields as "absent" rather than erroring.
2. **Separation of lifecycle from state.** Anything that has a
"moment it happened" is an event on the event stream. Anything
that has a "current value" is a snapshot field. The same fact
should not be reported both ways unless one is a counter and
the other is a transition.
3. **Schema-version honesty.** Any version string the bundle
carries about a dependency must reflect the dependency actually
linked at build time. The bundle never contains a version
string that disagrees with the lockfile.
4. **Generic over the use case.** Tier-3 capture surfaces (process,
subprocess, host, etc.) are wired the same way as the existing
`ProcessIntrospector`: a trait on the aggregator with a default
production implementation and the ability to install a test
fake without going through production paths. A new caller of
`swactor` should be able to opt into the new surfaces with no
knowledge of how data flows out.
5. **Boundary stays where it is today.** Generic observability
primitives live in the distribution crate's diagnostics module.
Role-specific decisions (which PIDs to register, which probes
to install, which labels to use) live in the calling crate
(`examples/pipeline-parallel-inference/...` for this codebase).
---
## 1. Relay observability (gap 1)
After this work, the bundle answers, for every relay-mediated
peer connection that died during a run:
- Who initiated the close: the relay, the remote node, or an idle
timeout.
- What the close reason was, in a short string the relay assigned.
- How long the session had been open and how many bytes had
crossed in each direction.
- The relay's own count of active sessions, opens, closes, and
bytes transferred at end-of-run, broken down by close reason.
The bundle reader can answer "was this a relay-side eviction"
without consulting any external system, by reading the relay's
report and correlating it against the node-side
`connection_cache[peer].last_failure_reason` already in the
bundle.
The post-processor's summary surfaces this correlation per peer
in a "relay sessions" section. When the relay was not observed
(legacy run, relay observability not configured), the section
renders one line explaining that and pointing at this gap.
Acceptance: replay the 2026-05-25 incident with a new bundle.
The summary tells you who closed stage-2's session and why,
without further digging.
---
## 2. Relay-session vs. peer-connection separation (gap 2)
After this work, every snapshot a node emits carries an explicit
answer to "is my tunnel to my relay healthy right now," separate
from "do my peer connections through that tunnel work."
The field carries:
- The relay URL the node is currently using.
- A status (connected / connecting / disconnected / unknown).
- Wall-clock millis of the last status change and the moment the
current status was entered.
- The last moment the node successfully sent over the tunnel and
the last moment it received over it.
- Lifetime byte counters in each direction.
When the underlying transport library does not expose enough state
to populate the field truthfully, the snapshot must say so
explicitly: the status is `unknown`, a discriminator field
identifies the value as derived rather than reported, and the
existing `iroh_api_missing` event pattern records the gap by name.
A bundle reader must never have to guess whether `unknown` means
"the tunnel is unknown" vs. "we couldn't ask."
Acceptance: in the 2026-05-25 bundle's stage-2 snapshots, this
field reports either a real status ("disconnected" or "connected")
or `unknown` with `status_source: derived`. The investigator can
distinguish "tunnel alive but peer connection dead" from "tunnel
itself died" without speculation.
---
## 3. Per-transition relay events (gap 3)
After this work, every relay-related state flip produces an event
on the event stream, in addition to whatever counter increments.
Two kinds of flips are observable:
- **Relay session state changed**: the tunnel status field from
section 2 moved between values. Event carries the relay URL,
from-status, to-status, and a short reason string when one is
available.
- **Relay home changed**: the node switched which relay it
considers home. Event carries the from-URL and the to-URL.
Counters (e.g. `relay_home_change`) are retained for sanity-check
totals, but the per-transition event is the authoritative source.
A bundle reader can reconstruct the relay-state timeline of a
node by replaying the event stream, with no need to derive
transitions from counter deltas across snapshots.
Acceptance: in any run where a node experiences a relay flap, the
event stream contains at least one `RelaySessionStateChanged`
record. A grep for that event kind across the bundle tells you
which nodes flapped and when, with no other inputs.
---
## 4. Subprocess introspector (gap 4) — generic, through swactor
This is the largest section. The user's explicit requirement:
**the Python worker introspection must flow through swactor in a
generic way, like the existing process crate does** — meaning it
is not specific to "the Python worker" or "this example crate,"
but a reusable surface that any future user of `swactor_process`
can opt into.
### Behavior contract
After this work, every subprocess that a node owns via
`swactor_process` is reflected in the bundle on two channels,
identically to how the parent process is reflected today:
- **As snapshot state**: each periodic snapshot carries a
per-subprocess entry with the subprocess's caller-supplied
label, PID, parent PID, status (running / exited / unknown),
spawn time, exit time and code/signal when applicable, RSS,
virtual size, open FD count, CPU time, and a truncated
command line.
- **As lifecycle events**: a `SubprocessSpawned` event fires when
the subprocess starts, and a `SubprocessExited` event fires when
it ends. Both carry the caller's label, the PID, the command,
and (for exit) the exit code or terminating signal and uptime
in millis.
Subprocess capture is a tier-3 surface alongside the existing
process-stats one. It is installed via an introspector trait on
the aggregator, with the same install pattern as today's
`ProcessIntrospector`, `HostIntrospector`, etc. A test can wire a
fake introspector without going through any production code path.
The capture surface is **stage-agnostic** and **worker-agnostic**:
it knows about a PID, a label, and a parent. The fact that "the
Python worker" is one such subprocess is a decision made at the
calling site, not in the introspector.
### Wiring contract — the swactor side
The `swactor_process` driver, when it spawns a child, must
publish the child's PID through its existing notification
channel. The data flow looks like:
1. The owning actor calls into `swactor_process` to spawn.
2. `swactor_process` reports the spawn outcome back through its
existing notification mechanism, with the PID included.
3. The owning actor forwards "this PID, this label" into the
subprocess introspector it owns.
4. The owning actor forwards "this PID has exited with this
status" into the introspector on exit.
The actor's role in step 3-4 is intentionally minimal — a handful
of lines wrapping notifications it already receives. The
introspector does the actual `/proc` reading, lifecycle-event
emission, and snapshot population. A future swactor user gets
subprocess observability by installing the introspector at boot
and forwarding two notification kinds; nothing else.
### Lifecycle event coverage
The pre-existing ad-hoc `Custom { kind: "worker_starting" }` and
`Custom { kind: "worker_exited" }` strings in the example crate
are replaced by the typed `SubprocessSpawned` and
`SubprocessExited` events. The role-specific signal "the
subprocess has produced its first protocol output and is
functioning" (currently `worker_ready`) stays a `Custom` event
because functioning-as-a-pipeline-worker is not a generic
subprocess concept.
### What this gives us for the next investigation
For a stage that didn't start its worker, the bundle now tells us
unambiguously which of three things happened:
- The actor never reached its `on_start` and the subprocess was
never asked to spawn. No `SubprocessSpawned`. The bug is in
actor scheduling.
- The subprocess spawned and exited immediately. Both events
present, with exit code and the existing stderr tail available.
The bug is in the subprocess itself.
- The subprocess spawned and stayed alive but never produced
protocol output. `SubprocessSpawned` present, no
`SubprocessExited`, no `worker_ready` Custom event, and the
per-snapshot RSS/CPU on the subprocess show whether it's stuck
or thrashing. The bug is in the subprocess's startup logic
before its first protocol line.
These three were indistinguishable in the 2026-05-25 bundle.
They are immediately distinguishable after this work.
Acceptance: in any future deployment, a stage that fails to
produce inference output can be classified into one of those
three buckets by reading the bundle alone.
---
## 5. Host metadata forwarding (gap 5)
After this work, every node's boot record carries the physical
host context the node is running on:
- Public IP of the rental.
- Datacenter id and country reported by the cloud provider.
- The provider's identifier for the rental (e.g. vast.ai instance
id) — enough to re-rent or correlate against provider-side
logs.
- The hostname as the container sees it.
- The relay URL the node was configured with at boot.
- The git SHA the binary was built from.
- The version string of the underlying transport library, taken
from what is actually linked (see gap 6).
When a node runs outside the orchestrator's lease flow (e.g. a
locally-launched node for development), the cloud-provider fields
are absent rather than blank or wrong. The bundle reader can tell
"this node was not on vast.ai" from "this node was on vast.ai but
metadata wasn't forwarded" — the former leaves fields absent, the
latter is no longer a possible state.
The post-processor's summary lists each node's host context one
line per node, so "which rental was stage-2" is answerable
without grep.
Acceptance: replay the 2026-05-25 incident's recovery process.
Identifying stage-2's host requires reading one line of the
summary, not cross-referencing provider records.
---
## 6. Iroh API version sanity (gap 6)
After this work:
- The `iroh_api_missing` event reports the version of the
transport library actually linked into the binary. The version
string is sourced from the build, not a literal.
- Every tier-2 transport snapshot carries the same version string
as a field, so a bundle reader does not need to scan the event
stream to know what version the node ran.
- The list of "API gaps" — fields the bundle reader should treat
as "we couldn't ask" rather than "we asked and got zero" —
reflects what the linked version actually omits. Upgrading to a
version that exposes a previously-missing field causes the gap
to disappear from the bundle automatically; no code change is
needed to recompute the list.
Acceptance: bumping the iroh dependency to a version that exposes
`conn_type` produces a bundle whose `api_gaps` no longer mentions
`conn_type`, without any other change.
---
## 7. Bundle assembly without finalize (gap 7)
After this work:
- A bundle is retrievable for any run that has at least one boot
record in staging, regardless of whether the orchestrator sent
a finalize record. `GET /diag/bundle/<run_id>` succeeds in both
cases.
- The retrieved bundle's manifest explicitly states whether
finalize was received. Bundle readers must not have to guess.
- When finalize was received, the bundle is the canonical one and
serving it is cheap. When it wasn't, the bundle is synthesized
at request time from staging files; the latency is fine because
unfinalized bundles are by definition retrieved during incident
response.
- Staging files for runs that never finalized are retained at
least until the operator has had a reasonable window to
retrieve them (default: 30 days), bounded by a hard
disk-space cap that trims oldest-first when exceeded.
The hand-rolled recovery process used for the 2026-05-25 incident
(tar staging from the collector, scp it down, reshape, retar) is
no longer needed for any future incident, regardless of how the
orchestrator died.
Acceptance: kill an orchestrator with SIGKILL mid-run. A subsequent
`GET /diag/bundle/<run_id>` returns a usable bundle with
`finalize_received: false` in its manifest.
---
## 8. Relay-port reachability probe (gap 8)
After this work, every node periodically attempts a transport-level
reachability check against the relay's actual port, and reports
the outcome in the same snapshot probe array as the existing UDP
echo. The probe's existence does not require operator
configuration: when the node has been told a relay URL, the relay
probe is automatically registered.
The probe's outcome distinguishes:
- Reached and responded ("ok").
- Reached, no response within deadline ("timeout").
- Host reachable, port closed ("refused").
- Could not resolve target ("unresolved").
- Other error ("error").
A bundle reader can answer "could stage-2 reach the relay port at
moment T" by reading stage-2's probe array around T, without
inferring reachability from a different probe to a different port
on the same host.
Acceptance: a node placed behind a firewall that blocks the relay
port but not the existing UDP echo port produces a bundle in
which the relay probe consistently reports `refused` or `timeout`
while the UDP echo continues to report `ok`.
---
## 9. Per-peer dial rollup in summary (gap 9)
After this work, the post-processor's summary contains, per peer
in the run, a row listing:
- Total dials started against that peer.
- Total successful dials.
- Total failed dials.
- The last dial outcome (string) and its wall-clock millis.
The 3-event drift in the 2026-05-25 bundle (`DialStarted: 83`,
`DialOutcome: 80`) is attributable to specific peers in the
table; the reader can immediately tell which peers' dials never
completed.
This is a pure post-processor change — the raw events are already
in the bundle. No new fields, no new events.
Acceptance: re-run the post-processor against the existing
2026-05-25 bundle. The summary contains a per-peer dial table
that accounts for all 83 `DialStarted` events.
---
## 10. Gossip-receipt event (gap 10)
After this work, every time a node receives a payload through the
gossip / dissemination layer — name-registry update, SWIM
membership piggyback, anything similar — it emits a typed event
on its event stream. The event carries the source peer, the
payload kind (string, extensible), the payload size in bytes, and
the number of items inside.
The existing coarse `MessageReceived` counter remains for backward
compatibility, but the new event is the authoritative source for
"did node X ever hear about name Y from peer Z."
The post-processor's summary, per node, reports the total receipt
counts broken down by payload kind. "Stage-2 never received any
name-registry gossip from anyone" is a one-line answer.
Acceptance: in any run where one node fails to learn about
another node's registered name, the bundle distinguishes
unambiguously whether the gossip was never received vs. received
and ignored.
---
## 11. Kernel network counters (gap 11)
After this work, every host-scrape snapshot carries kernel-level
UDP and per-interface counters:
- UDP-side: aggregate packets in/out, drops attributable to
no-listening-port, packets discarded due to errors, packets
lost to socket buffer overflow.
- Per-interface: rx/tx bytes, rx/tx dropped, rx/tx errors.
A bundle reader can compute deltas across consecutive snapshots
to attribute packet loss to one of three layers:
- "Iroh sent and the OS dropped it" — UDP send error counters
rise on the sender.
- "OS sent it and the path silently lost it" — sender counters
clean, receiver counters clean.
- "It arrived and got dropped at the receiver's NIC" — receiver
interface drop counters rise.
All counters are best-effort: absent on non-Linux hosts, absent
when the file can't be read, never silently zero. The
post-processor's summary surfaces any node whose UDP-drop or
interface-drop deltas are non-zero across the run window, so the
reader doesn't have to inspect every snapshot.
Acceptance: a node deliberately subjected to UDP-drop-rate
injection produces a bundle whose summary highlights it with the
correct counter rising.
---
## Sim cross-pollination
The behavioral contracts above also constrain the simulator. A
node simulated by the sim should produce snapshots and events
that conform to the same shape as a real node — the bundle reader
should not be able to tell from the data shape alone whether a
given snapshot came from a real deployment or the sim.
Three areas where today's sim lags this contract and must catch up
as part of the same upgrade:
- The sim must model a relay actor whose behavior produces the
same tunnel-status field (gap 2) on simulated nodes. Without
this, sim runs of cluster scenarios are not bundle-shape
compatible with real ones.
- The sim must support installing a subprocess introspector fake
(gap 4). Scenarios that want to model "a stage's worker never
came up" wire this fake to produce a `SubprocessSpawned` with
no following `worker_ready` Custom event.
- The sim's network failure model must allow "tunnel up,
peer-connection-via-tunnel down" as a distinct failure case
from "tunnel down." Without it the sim cannot reproduce the
exact 2026-05-25 failure even after the observability lands.
These are sim-side work, not data-collection work, but they
share the data model defined here.
---
## Implementation order
Grouped by independence. Within a group, work is parallel-safe;
across groups, later groups don't depend on earlier groups
*finishing*, only on earlier groups' contracts being agreed.
**Group A — small, independent, unblock confidence elsewhere**
- 5 (host metadata) — small and pure-mechanical
- 6 (iroh version sanity) — small, but until it lands, every
iroh-side field in the bundle has a credibility asterisk
- 9 (per-peer dial rollup) — pure post-processor
- 11 (kernel counters) — additive host-scrape extension
**Group B — relay tier**
- 1 (relay observability) — the largest single info gain
- 2 (relay-session field) — depends on having something to
populate it from, ideally the work in 1
- 3 (relay events) — depends on 2's status field existing
**Group C — subprocess tier**
- 4 (subprocess introspector + events) — independent of B,
parallel-safe with it
**Group D — collector robustness**
- 7 (bundle without finalize) — independent of all the above;
land last to avoid churning the collector while other tiers
are still moving
**Group E — polish**
- 8 (relay-port probe) — small, independent
- 10 (gossip-receipt event) — small, independent
The 2026-05-25 investigation would have been closeable with
A + B + C alone. D + E reduce future investigation cost but
weren't load-bearing for the failure we hit.