hyperactor_mesh/introspect.rs
1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Mesh-topology introspection types and attrs.
10//!
11//! This module owns the typed internal model used by mesh-admin and the
12//! TUI: mesh-topology attr keys, typed attrs views, `NodeRef`, and the
13//! domain `NodePayload` / `NodeProperties` / `FailureInfo` values derived
14//! from `hyperactor::introspect::IntrospectResult`.
15//!
16//! These keys are published by `HostMeshAgent`, `ProcAgent`, and
17//! `MeshAdminAgent` to describe mesh topology (hosts, procs, root).
18//! Actor-runtime keys (status, actor_type, messages_processed, etc.) are
19//! declared in `hyperactor::introspect`.
20//!
21//! The HTTP wire representations live in [`dto`]. That submodule owns the
22//! curl-friendly JSON contract, schema/OpenAPI generation, and boundary
23//! invariants for string-encoded references and timestamps. This module
24//! keeps the internal typed invariants.
25//!
26//! These invariants govern the introspection model and derived
27//! payloads exposed by mesh-admin; lower-level runtime accounting
28//! invariants remain owned by the runtime modules that produce those
29//! values.
30//!
31//! See `hyperactor::introspect` for naming convention, invariant
32//! labels, and the `IntrospectAttr` meta-attribute pattern.
33//!
34//! ## Mesh key invariants (MK-*)
35//!
36//! - **MK-1 (metadata completeness):** Every mesh-topology
37//! introspection key must carry `@meta(INTROSPECT = ...)` with
38//! non-empty `name` and `desc`.
39//! - **MK-2 (short-name uniqueness):** Covered by
40//! `test_introspect_short_names_are_globally_unique` in
41//! `hyperactor::introspect` (cross-crate).
42//!
43//! ## Proc debug stats invariants (PD-*)
44//!
45//! These invariants govern the proc-debug introspection surface
46//! exposed by mesh-admin: proc attrs, typed proc views, and the
47//! proc-debug portion of `NodeProperties::Proc`.
48//!
49//! They do not define proc runtime mechanics. The underlying
50//! per-actor queue-depth accounting invariants live in
51//! `hyperactor::proc`; this module owns the proc-level debug values
52//! derived from that runtime state.
53//!
54//! - **PD-1:** `actor_work_queue_depth_max <=
55//! actor_work_queue_depth_total`.
56//! - **PD-2:** `process_rss_bytes` and `process_vm_size_bytes` are
57//! `None` on non-Linux or read failure. Never fabricated.
58//! - **PD-3:** All debug fields default to zero/None for backward
59//! compatibility. Old procs that haven't published yet produce a
60//! valid `ProcDebugStats::default()`.
61//! - **PD-4:** Queue depth aggregation covers live actors only.
62//! Stopped/retained actor snapshots are excluded.
63//! - **PD-5:** See `hyperactor::proc` module doc for the per-actor
64//! queue depth accounting invariants (PD-5a through PD-5e).
65//!
66//! ## HTTP boundary invariants (HB-*)
67//!
68//! These govern the HTTP DTO layer in [`dto`].
69//!
70//! - **HB-1 (typed-internal, string-external):** `NodeRef`, `ActorAddr`,
71//! `ProcAddr`, and `SystemTime` are typed Rust values internally. At the
72//! HTTP JSON boundary, [`dto::NodePayloadDto`],
73//! [`dto::NodePropertiesDto`], and [`dto::FailureInfoDto`] encode them
74//! as canonical strings.
75//! - **HB-2 (round-trip):** The HTTP string forms round-trip through the
76//! internal typed parsers (`NodeRef::from_str`, `ActorAddr::from_str`,
77//! `humantime::parse_rfc3339`). Timestamps are formatted at
78//! millisecond precision; sub-millisecond values are truncated at
79//! the boundary.
80//! - **HB-3 (schema-honesty):** Schema/OpenAPI are generated from the DTO
81//! types, so the published schema reflects the actual wire format rather
82//! than the internal domain representation.
83//!
84//! ## Attrs invariants (IA-*)
85//!
86//! These govern how `IntrospectResult.attrs` is built in
87//! `hyperactor::introspect` and how `properties` is derived via
88//! `derive_properties`.
89//!
90//! - **IA-1 (attrs-json):** `IntrospectResult.attrs` is always a
91//! valid JSON object string.
92//! - **IA-2 (runtime-precedence):** Runtime-owned introspection keys
93//! override any same-named keys in published attrs.
94//! - **IA-3 (status-shape):** `status_reason` is present in attrs
95//! iff the status string carries a reason.
96//! - **IA-4 (failure-shape):** `failure_*` attrs are present iff
97//! effective status is `failed`.
98//! - **IA-5 (payload-totality):** Every `IntrospectResult` sets
99//! `attrs` -- never omitted, never null.
100//! - **IA-6 (open-row-forward-compat):** View decoders ignore
101//! unknown attrs keys; only required known keys and local
102//! invariants affect decoding outcome. Concretized by AV-3.
103//!
104//! ## Attrs view invariants (AV-*)
105//!
106//! These govern the typed view layer (`*AttrsView` structs).
107//!
108//! - **AV-1 (view-roundtrip):** For each view V,
109//! `V::from_attrs(&v.to_attrs()) == Ok(v)` (modulo documented
110//! normalization/defaulting).
111//! - **AV-2 (required-key-strictness):** `from_attrs` fails iff
112//! required keys for that view are missing.
113//! - **AV-3 (unknown-key-tolerance):** Unknown attrs keys must
114//! not affect successful decode outcome. Concretization of
115//! IA-6.
116//!
117//! ## Derive invariants (DP-*)
118//!
119//! - **DP-1 (derive-precedence):** `derive_properties` dispatches
120//! on `status` first (DP-5), then `node_type`, then `error_code`,
121//! then unknown. This order is the canonical detection chain.
122//! - **DP-2 (derive-totality-on-parse-failure):**
123//! `derive_properties` is total; malformed or incoherent attrs
124//! never panic and map to `NodeProperties::Error` with detail.
125//! - **DP-3 (derive-precedence-stability):**
126//! `derive_properties` detection order is stable and explicit:
127//! `status` > `node_type` > `error_code` > unknown.
128//! - **DP-4 (error-on-decode-failure):** Any view decode or
129//! invariant failure maps to a deterministic
130//! `NodeProperties::Error` with a `malformed_*` code family,
131//! without panic.
132//! - **DP-5 (actor-view classification safety):** a payload
133//! carrying the core `STATUS` key always decodes as `Actor`.
134//! `STATUS` is set only by the blanket Actor builder
135//! (`build_actor_attrs`) and is core-owned, so the actor-attrs
136//! snapshot seam can neither remove nor override it (AS-2), and no
137//! non-actor payload (root/host/proc/error) carries it. Therefore
138//! an actor cannot inject `node_type`/`error_code` via the seam to
139//! spoof a different node kind.
140//!
141//! ## Execution presentation (EX-*)
142//!
143//! These govern the `execution` field on `NodeProperties::Actor` — an
144//! actor's in-flight handler execution, reported through the generic
145//! actor-attrs snapshot seam (`AS-*` in `hyperactor::introspect`) and
146//! decoded from the `EXECUTION` attr in `derive_properties`.
147//!
148//! - **EX-1 (unsupported-vs-idle):** `execution: None` means the actor
149//! does not report execution (no snapshot installed) -- *unsupported*,
150//! not idle. A supported-but-idle actor is `Some` with
151//! `active_count == 0`.
152//! - **EX-2 (partial-detail, never absence):** `complete == false`
153//! means the per-handler detail was momentarily unavailable on that
154//! read (e.g. a non-blocking tracker miss); `active_count` stays
155//! authoritative and the field stays `Some` -- contention never
156//! collapses `execution` to `None`.
157//! - **EX-3 (observational, not transactional):** `active_count` and
158//! `active_handlers` are independent point-in-time reads;
159//! `active_count` need not equal `sum(active_handlers[*].active_count)`
160//! on a given poll (cf. IO-3). Consumers must not derive one from the
161//! other.
162//! - **EX-4 (deterministic truncation):** `active_handlers` is ordered
163//! oldest-first with a stable tie-break on `name`; `truncated == true`
164//! means it is a prefix of the N oldest while `active_count` remains
165//! the full total.
166//! - **EX-5 (post-mortem semantics):** a terminated actor's stored
167//! snapshot persists its last `live_actor_payload`, so `execution`
168//! reflects state *as of termination*. The producer drains in-flight
169//! entries on stop (try/finally), so a stopped actor reports
170//! `active_count == 0`.
171//!
172//! ## Inbound ordering presentation (IO-*)
173//!
174//! Mesh-admin presentation extension of the cross-crate `IO-*`
175//! family. The lower-level invariants `IO-1` (tri-state absence),
176//! `IO-2` (publish-time `try_lock`), and `IO-3` (no arithmetic
177//! relation between `queue_depth` and reorder-buffer depth) live in
178//! `hyperactor::introspect`. The presentation layer below adds:
179//!
180//! - **IO-4 (snapshot_complete derivation):**
181//! `InboundOrdering.snapshot_complete ==
182//! (skipped_session_count == 0)`. Mirrors
183//! `OrderingSnapshot::is_complete()` at the presentation layer.
184//! - **IO-5 (known_session_count totality):**
185//! `InboundOrdering.known_session_count ==
186//! sessions.len() + skipped_session_count`. The only rollup
187//! that is a true total across returned and skipped sessions.
188//! - **IO-6 (returned_* scope):**
189//! `returned_buffered_session_count`,
190//! `returned_buffered_message_count`, and
191//! `returned_max_buffered_count` are computed over `sessions`
192//! only and are LOWER BOUNDS when `snapshot_complete == false`.
193//! - **IO-7 (live-actor exposure):** For any actor built through
194//! `Instance::new`, `/v1/{actor}` exposes
195//! `inbound_ordering: Some(...)` -- never `None`. `None`
196//! indicates either structural absence (test fixtures,
197//! hand-built `InstanceCellState`) or a regression in the
198//! publish path.
199//!
200//! ## py-spy integration (PS-*)
201//!
202//! - **PS-1 (target locality):** `PySpyDump` always targets
203//! `std::process::id()` of the handling ProcAgent process. No
204//! caller-supplied PID exists in the API.
205//! - **PS-2 (deterministic failure shape):** Execution failures are
206//! classified into `BinaryNotFound { searched }` vs
207//! `Failed { pid, binary, exit_code, stderr }`, never collapsed.
208//! - **PS-3 (binary resolution order):** Resolution order is exactly:
209//! `PYSPY_BIN` config attr (if non-empty) then `"py-spy"` on PATH.
210//! The attr is read via `hyperactor_config::global::get_cloned`;
211//! env var `PYSPY_BIN` feeds in through the config layer.
212//! If the first attempt is not found, the fallback attempt is
213//! required.
214//! - **PS-4 (structured JSON output):** py-spy runs with `--json`;
215//! output is parsed into `Vec<PySpyStackTrace>`. Parse failure
216//! maps to `PySpyResult::Failed`.
217//! - **PS-5 (subprocess timeout):** `try_exec` bounds the py-spy
218//! subprocess inside the worker to `MESH_ADMIN_PYSPY_TIMEOUT`
219//! (default 10s). The budget is sized for `--native --native-all`
220//! which unwinds native stacks via libunwind — significantly
221//! slower than Python-only capture on loaded hosts. On expiry the
222//! child is killed and reaped, and the worker returns
223//! `Failed { stderr: "…timed out…" }`.
224//! - **PS-6 (bridge timeout):** The HTTP bridge uses a separate
225//! `MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT` (default 13s), which must
226//! exceed `MESH_ADMIN_PYSPY_TIMEOUT` so the subprocess kill/reap
227//! and reply can arrive before the bridge declares
228//! `gateway_timeout`. Independent of
229//! `MESH_ADMIN_SINGLE_HOST_TIMEOUT`.
230//! - **PS-7 (non-blocking delegation):** ProcAgent never awaits
231//! py-spy execution inline. On `PySpyDump` it spawns a child
232//! `PySpyWorker`, forwards the request, and returns immediately.
233//! - **PS-8 (worker lifecycle):** Each `PySpyWorker` handles
234//! exactly one forwarded `RunPySpyDump`, replies directly to the
235//! forwarded `OncePortRef`, then self-terminates via
236//! `cx.stop()`. Clean exit, no supervision event.
237//! - **PS-9 (concurrent dumps):** py-spy is spawn-per-request, so
238//! overlapping dumps on the same proc are allowed. Each worker
239//! runs independently.
240//! - **PS-10 (nonblocking retry):** In nonblocking mode, `try_exec`
241//! retries up to 3 times with 100ms backoff on failure, because
242//! py-spy can segfault reading mutating process memory. All
243//! attempts share a single deadline bounded by
244//! `MESH_ADMIN_PYSPY_TIMEOUT` (PS-5).
245//! - **PS-11a (native-all-immediate-downgrade):** If py-spy rejects
246//! `--native-all` with the recognized unsupported-flag signature
247//! (exit code 2, stderr mentions `--native-all`), `try_exec`
248//! retries immediately with `native_all = false` in the same outer
249//! attempt.
250//! - **PS-11b (native-all-no-retry-consumption):** That downgrade
251//! retry does not consume an outer nonblocking retry slot (PS-10)
252//! and does not incur the 100ms inter-attempt backoff.
253//! - **PS-11c (native-all-downgrade-warning):** A successful
254//! downgraded result includes the warning `"--native-all
255//! unsupported by this py-spy; fell back to --native"`.
256//! - **PS-11d (native-all-failure-passthrough):** If the downgraded
257//! retry also fails, the failure flows through the normal
258//! nonblocking retry logic (PS-10) unchanged.
259//! - **PS-11e (native-all-sticky-downgrade):** Once the
260//! unsupported-flag signature is detected,
261//! `effective_opts.native_all` remains `false` for all subsequent
262//! outer retries. The flag is not re-tested on later attempts.
263//! - **PS-12 (universal py-spy):** Worker procs and the service
264//! proc can handle `PySpyDump`. Worker procs handle it via
265//! ProcAgent; the service proc handles it via HostAgent (same
266//! spawn-worker pattern). `pyspy_bridge` routes by proc name:
267//! if `proc_id.base_name() == SERVICE_PROC_NAME`, the target
268//! is `host_agent`; otherwise `proc_agent[0]`. Procs lacking
269//! either agent (e.g. mesh-admin) fast-fail via PS-13.
270//! - **PS-13 (defensive probe):** Before sending `PySpyDump`,
271//! `pyspy_bridge` probes the selected actor with an introspect
272//! query bounded by `MESH_ADMIN_QUERY_CHILD_TIMEOUT` (default
273//! 100ms). Three outcomes: (a) probe reply arrives — proceed
274//! with `PySpyDump`; (b) probe times out or recv closes —
275//! return `not_found` (actor absent/unreachable); (c) probe
276//! send itself fails — return `internal_error` (bridge-side
277//! infrastructure failure). Cases (b) and (c) fast-fail
278//! instead of waiting the full 13s
279//! `MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT`.
280//! - **PS-14 (reachability-based capability):** A proc supports
281//! py-spy iff its stable handler actor is reachable: the
282//! service proc requires a reachable `host_agent`; non-service
283//! procs require a reachable `proc_agent[0]`. `PySpyWorker` is
284//! transient per-request machinery (spawned on `PySpyDump`,
285//! stopped after replying) and is not part of the reachability
286//! contract.
287//!
288//! v1 contract notes:
289//! - The current py-spy bridge expects a ProcAddr-form reference and
290//! rejects other forms as `bad_request`. This may be broadened in
291//! future versions.
292//! - If `worker.send()` fails after the reply port has moved into
293//! `RunPySpyDump`, the caller receives no explicit
294//! `PySpyResult::Failed` — they observe a timeout.
295//! `MailboxSenderError` does not carry the unsent message, so the
296//! port is irrecoverable on this path.
297//! - **Contract change (D96756537 follow-up):** `PySpyResult::Ok`
298//! replaced `stack: String` (raw py-spy text) with
299//! `stack_traces: Vec<PySpyStackTrace>` (structured JSON) and
300//! added `warnings: Vec<String>`. Clients reading the old `stack`
301//! field will see it absent; they must migrate to `stack_traces`.
302//!
303//! ## py-spy profiling (PP-*)
304//!
305//! Profile capture (`py-spy record`) is a separate contract from
306//! dump (`py-spy dump`). Types, messages, workers, and routes are
307//! independent — no shared state, no shared timeout budget.
308//!
309//! - **PP-1 (input validation):** `duration_s` (u32) must be
310//! non-zero and at most `MESH_ADMIN_PYSPY_MAX_PROFILE_DURATION`.
311//! `rate_hz` must be 1..1000. Violations → 400 before any
312//! actor messaging.
313//! - **PP-2 (dynamic timeout cascade):** Subprocess timeout =
314//! `duration_s + 15s`. Bridge timeout = subprocess + 5s.
315//! Computed per-request from validated opts, not static config.
316//! - **PP-3 (temp file lifecycle):** `py-spy record` writes to a
317//! temp file; the worker reads it after successful exit and
318//! deletes via tempfile drop. On failure or timeout, stderr is
319//! captured. On timeout, the child is explicitly killed and
320//! reaped via `start_kill()` + `wait().await`. If the file is
321//! missing, empty, or unreadable after successful exit, the
322//! result is `OutputMissing`, `OutputEmpty`, or
323//! `OutputReadFailure`, not `Ok`.
324//! - **PP-4 (target locality):** Inherits PS-1 — always targets
325//! `std::process::id()`, never a caller-supplied PID.
326//! - **PP-5 (separate worker):** `PySpyProfileWorker` is a
327//! distinct actor from `PySpyWorker`. Profile durations block
328//! for seconds to minutes; isolation prevents starving dumps.
329//! - **PP-6 (wire projection):** `ProfileExecOutcome` maps to
330//! `PySpyProfileResult` 1:1 via `From`. Every internal variant
331//! has an identically-named wire variant. The only shape change
332//! is `TimedOut.timeout: Duration` → `TimedOut.timeout_s: u64`.
333//!
334//! ## Mesh-admin config (MA-*)
335//!
336//! - **MA-C1 (timeout config centralization):** Mesh-admin timeout
337//! budgets are read from config attrs at call-time, with defaults
338//! in `config.rs`. No hardcoded timeout constants in
339//! `mesh_admin.rs`.
340
341pub mod dto;
342
343use hyperactor_config::AttrValue;
344use hyperactor_config::Attrs;
345use hyperactor_config::INTROSPECT;
346use hyperactor_config::IntrospectAttr;
347use hyperactor_config::declare_attrs;
348
349// See MK-1, MK-2, IA-1..IA-5 in module doc.
350declare_attrs! {
351 /// Topology role of this node: "root", "host", "proc", "error".
352 @meta(INTROSPECT = IntrospectAttr {
353 name: "node_type".into(),
354 desc: "Topology role: root, host, proc, error".into(),
355 })
356 pub attr NODE_TYPE: String;
357
358 /// Host network address (e.g. "10.0.0.1:8080").
359 @meta(INTROSPECT = IntrospectAttr {
360 name: "addr".into(),
361 desc: "Host network address".into(),
362 })
363 pub attr ADDR: String;
364
365 /// Number of procs on a host.
366 @meta(INTROSPECT = IntrospectAttr {
367 name: "num_procs".into(),
368 desc: "Number of procs on a host".into(),
369 })
370 pub attr NUM_PROCS: usize = 0;
371
372 /// Human-readable proc name.
373 @meta(INTROSPECT = IntrospectAttr {
374 name: "proc_name".into(),
375 desc: "Human-readable proc name".into(),
376 })
377 pub attr PROC_NAME: String;
378
379 /// Number of actors in a proc.
380 @meta(INTROSPECT = IntrospectAttr {
381 name: "num_actors".into(),
382 desc: "Number of actors in a proc".into(),
383 })
384 pub attr NUM_ACTORS: usize = 0;
385
386 /// References of system/infrastructure children.
387 @meta(INTROSPECT = IntrospectAttr {
388 name: "system_children".into(),
389 desc: "References of system/infrastructure children".into(),
390 })
391 pub attr SYSTEM_CHILDREN: Vec<NodeRef>;
392
393 /// References of stopped children (proc only).
394 @meta(INTROSPECT = IntrospectAttr {
395 name: "stopped_children".into(),
396 desc: "References of stopped children".into(),
397 })
398 pub attr STOPPED_CHILDREN: Vec<NodeRef>;
399
400 /// Cap on stopped children retention.
401 @meta(INTROSPECT = IntrospectAttr {
402 name: "stopped_retention_cap".into(),
403 desc: "Maximum number of stopped children retained".into(),
404 })
405 pub attr STOPPED_RETENTION_CAP: usize = 0;
406
407 /// Whether this proc is refusing new spawns due to actor
408 /// failures.
409 @meta(INTROSPECT = IntrospectAttr {
410 name: "is_poisoned".into(),
411 desc: "Whether this proc is poisoned (refusing new spawns)".into(),
412 })
413 pub attr IS_POISONED: bool = false;
414
415 /// Count of failed actors in a proc.
416 @meta(INTROSPECT = IntrospectAttr {
417 name: "failed_actor_count".into(),
418 desc: "Number of failed actors in this proc".into(),
419 })
420 pub attr FAILED_ACTOR_COUNT: usize = 0;
421
422 /// Timestamp when the mesh was started.
423 @meta(INTROSPECT = IntrospectAttr {
424 name: "started_at".into(),
425 desc: "Timestamp when the mesh was started".into(),
426 })
427 pub attr STARTED_AT: std::time::SystemTime;
428
429 /// Username who started the mesh.
430 @meta(INTROSPECT = IntrospectAttr {
431 name: "started_by".into(),
432 desc: "Username who started the mesh".into(),
433 })
434 pub attr STARTED_BY: String;
435
436 /// Number of hosts in the mesh (root only).
437 @meta(INTROSPECT = IntrospectAttr {
438 name: "num_hosts".into(),
439 desc: "Number of hosts in the mesh".into(),
440 })
441 pub attr NUM_HOSTS: usize = 0;
442
443 // ── Proc debug stats (PD-*) ──────────────────────────────
444
445 /// RSS of the hosting OS process (bytes). `None` means the
446 /// measurement was unavailable (for example non-Linux or procfs
447 /// read/parse failure); values are never fabricated (PD-2).
448 @meta(INTROSPECT = IntrospectAttr {
449 name: "process_rss_bytes".into(),
450 desc: "RSS of the hosting OS process (bytes)".into(),
451 })
452 pub attr PROCESS_RSS_BYTES: Option<u64>;
453
454 /// Virtual memory size of the hosting OS process (bytes). `None`
455 /// means the measurement was unavailable (for example non-Linux
456 /// or procfs read/parse failure); values are never fabricated
457 /// (PD-2).
458 @meta(INTROSPECT = IntrospectAttr {
459 name: "process_vm_size_bytes".into(),
460 desc: "Virtual memory size of the hosting OS process (bytes)".into(),
461 })
462 pub attr PROCESS_VM_SIZE_BYTES: Option<u64>;
463
464 /// Sum of per-actor message queue depths across live actors.
465 @meta(INTROSPECT = IntrospectAttr {
466 name: "actor_work_queue_depth_total".into(),
467 desc: "Sum of per-actor message queue depths (live actors only)".into(),
468 })
469 pub attr ACTOR_WORK_QUEUE_DEPTH_TOTAL: u64 = 0;
470
471 /// Maximum current per-actor message queue depth across live
472 /// actors at publish time. This is not a historical high-water
473 /// mark.
474 @meta(INTROSPECT = IntrospectAttr {
475 name: "actor_work_queue_depth_max".into(),
476 desc: "Maximum per-actor message queue depth (live actors only)".into(),
477 })
478 pub attr ACTOR_WORK_QUEUE_DEPTH_MAX: u64 = 0;
479
480 /// Maximum proc-wide queue depth observed since startup (PD-6).
481 /// Eventually consistent — concurrent readers may transiently
482 /// observe total > high_water_mark. Retained evidence — driven
483 /// from the runtime accounting path, not publish-time sampling.
484 @meta(INTROSPECT = IntrospectAttr {
485 name: "actor_work_queue_depth_high_water_mark".into(),
486 desc: "Maximum proc-wide queue depth since startup (eventually consistent)".into(),
487 })
488 pub attr ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK: u64 = 0;
489
490 /// How long ago proc-wide queue depth was last observed non-zero
491 /// (PD-7). `None` means no counted actor work has traversed the
492 /// queue accounting path since startup. Uses wall clock, so the
493 /// age is best-effort telemetry and may not be strictly monotonic.
494 /// Retained evidence — driven from the runtime accounting path.
495 @meta(INTROSPECT = IntrospectAttr {
496 name: "last_nonzero_queue_depth_age_ms".into(),
497 desc: "Milliseconds since proc-wide queue depth was last observed non-zero (wall clock)".into(),
498 })
499 pub attr LAST_NONZERO_QUEUE_DEPTH_AGE_MS: Option<u64>;
500
501}
502
503use hyperactor::introspect::AttrsViewError;
504
505/// Typed view over attrs for a root node.
506#[derive(Debug, Clone, PartialEq)]
507pub struct RootAttrsView {
508 pub num_hosts: usize,
509 pub started_at: SystemTime,
510 pub started_by: String,
511 pub system_children: Vec<NodeRef>,
512}
513
514impl RootAttrsView {
515 /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
516 /// `STARTED_AT` and `STARTED_BY`; `NUM_HOSTS` defaults to 0,
517 /// `SYSTEM_CHILDREN` defaults to empty.
518 pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
519 let num_hosts = *attrs.get(NUM_HOSTS).unwrap_or(&0);
520 let started_at = *attrs
521 .get(STARTED_AT)
522 .ok_or_else(|| AttrsViewError::missing("started_at"))?;
523 let started_by = attrs
524 .get(STARTED_BY)
525 .ok_or_else(|| AttrsViewError::missing("started_by"))?
526 .clone();
527 let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
528 Ok(Self {
529 num_hosts,
530 started_at,
531 started_by,
532 system_children,
533 })
534 }
535
536 /// Encode into an `Attrs` bag (AV-1 round-trip producer).
537 pub fn to_attrs(&self) -> Attrs {
538 let mut attrs = Attrs::new();
539 attrs.set(NODE_TYPE, "root".to_string());
540 attrs.set(NUM_HOSTS, self.num_hosts);
541 attrs.set(STARTED_AT, self.started_at);
542 attrs.set(STARTED_BY, self.started_by.clone());
543 attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
544 attrs
545 }
546}
547
548/// Memory stats of the hosting OS process. Shared by host and
549/// proc introspection surfaces — both agents are authoritative
550/// for the OS process they run in.
551///
552/// In the common one-proc-per-process deployment these read like
553/// "proc memory". In multi-proc-per-process setups, co-hosted procs
554/// report the same hosting-process values.
555#[derive(
556 Debug,
557 Clone,
558 Copy,
559 PartialEq,
560 Eq,
561 Default,
562 Serialize,
563 Deserialize,
564 Named
565)]
566pub struct ProcessMemoryStats {
567 /// RSS of the hosting OS process (bytes). `None` on non-Linux
568 /// or read failure (PD-2).
569 pub process_rss_bytes: Option<u64>,
570 /// Virtual memory size of the hosting OS process (bytes).
571 /// `None` on non-Linux or read failure (PD-2).
572 pub process_vm_size_bytes: Option<u64>,
573}
574
575impl ProcessMemoryStats {
576 /// Read the hosting OS process memory stats from procfs.
577 /// Returns `ProcessMemoryStats` with `None` fields on non-Linux
578 /// or any read/parse failure (PD-2: never fabricated).
579 pub fn read_from_procfs() -> Self {
580 let (rss, vm) = read_procfs_memory();
581 Self {
582 process_rss_bytes: rss,
583 process_vm_size_bytes: vm,
584 }
585 }
586
587 pub fn from_attrs(attrs: &Attrs) -> Self {
588 Self {
589 process_rss_bytes: attrs.get(PROCESS_RSS_BYTES).copied().flatten(),
590 process_vm_size_bytes: attrs.get(PROCESS_VM_SIZE_BYTES).copied().flatten(),
591 }
592 }
593
594 pub fn to_attrs(&self, attrs: &mut Attrs) {
595 attrs.set(PROCESS_RSS_BYTES, self.process_rss_bytes);
596 attrs.set(PROCESS_VM_SIZE_BYTES, self.process_vm_size_bytes);
597 }
598}
599
600/// Read RSS and VM size from `/proc/self/statm`.
601///
602/// `statm` field 0 is total program size (virtual memory) in pages;
603/// field 1 is resident set size in pages. This is sufficient for the
604/// Stage 1 operator signal and avoids parsing a larger procfs file.
605/// Returns `(Some(rss_bytes), Some(vm_bytes))` on success and `(None,
606/// None)` on any failure.
607#[cfg(target_os = "linux")]
608fn read_procfs_memory() -> (Option<u64>, Option<u64>) {
609 // SAFETY: sysconf(_SC_PAGESIZE) is a read-only query with no
610 // preconditions. It returns the system page size or -1 on error.
611 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
612 if page_size <= 0 {
613 return (None, None);
614 }
615 let page_size = page_size as u64;
616 // Sync I/O is intentional even though callers may invoke this from
617 // async contexts. `/proc/self/statm` is in the O(1) procfs tier —
618 // the kernel formats values from `mm_struct` atomic counters
619 // maintained on the page-fault and exit paths, with no page-table
620 // walk; typical wall time is a few microseconds. Dispatching via
621 // `tokio::fs::read_to_string` would cost more than the read
622 // itself, and this call cannot block on real disk I/O.
623 match std::fs::read_to_string("/proc/self/statm") {
624 Ok(contents) => {
625 let mut fields = contents.split_whitespace();
626 let vm_pages: Option<u64> = fields.next().and_then(|s| s.parse().ok());
627 let rss_pages: Option<u64> = fields.next().and_then(|s| s.parse().ok());
628 (
629 rss_pages.map(|p| p * page_size),
630 vm_pages.map(|p| p * page_size),
631 )
632 }
633 Err(_) => (None, None),
634 }
635}
636
637#[cfg(not(target_os = "linux"))]
638fn read_procfs_memory() -> (Option<u64>, Option<u64>) {
639 (None, None)
640}
641
642/// Proc-level debug/operational stats. Groups hosting-process memory
643/// (process-scoped) and actor queue pressure (proc-scoped) into one
644/// operational summary.
645///
646/// This asymmetry is intentional: memory belongs to the hosting OS
647/// process, while queue pressure is aggregated over live actors in
648/// this Monarch proc only.
649///
650/// Queue depth is an **instantaneous snapshot** at publish time, not
651/// a historical watermark or backlog accumulator. It reflects
652/// currently queued work that has not yet been received by the
653/// actor's run loop. Transient bursts that drain between publishes
654/// will not be observed.
655#[derive(
656 Debug,
657 Clone,
658 Copy,
659 PartialEq,
660 Eq,
661 Default,
662 Serialize,
663 Deserialize,
664 Named
665)]
666pub struct ProcDebugStats {
667 /// Hosting-process memory (shared type with host surface).
668 pub memory: ProcessMemoryStats,
669 /// Sum of per-actor message queue depths across live actors in
670 /// this proc (PD-4: live actors only).
671 pub actor_work_queue_depth_total: u64,
672 /// Maximum current per-actor message queue depth across live
673 /// actors in this proc at publish time. Not a historical
674 /// high-water mark.
675 pub actor_work_queue_depth_max: u64,
676 /// Maximum proc-wide queue depth observed since startup (PD-6).
677 /// Eventually consistent — see PD-6 docs. Retained — driven
678 /// from the runtime accounting path.
679 pub actor_work_queue_depth_high_water_mark: u64,
680 /// How long ago proc-wide queue depth was last observed non-zero
681 /// (PD-7). `None` means never. Wall clock — see PD-9 docs.
682 /// Retained — driven from the runtime accounting path.
683 pub last_nonzero_queue_depth_age_ms: Option<u64>,
684}
685
686impl ProcDebugStats {
687 pub fn from_attrs(attrs: &Attrs) -> Self {
688 let total = attrs
689 .get(ACTOR_WORK_QUEUE_DEPTH_TOTAL)
690 .copied()
691 .unwrap_or(0);
692 let max = attrs.get(ACTOR_WORK_QUEUE_DEPTH_MAX).copied().unwrap_or(0);
693 // PD-1: max <= total.
694 if max > total {
695 tracing::warn!(
696 "PD-1 violation: actor_work_queue_depth_max ({}) > total ({})",
697 max,
698 total,
699 );
700 }
701 let high_water = attrs
702 .get(ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK)
703 .copied()
704 .unwrap_or(0);
705 // PD-6: high_water_mark >= total eventually, but concurrent
706 // readers may transiently see total > high_water_mark (a
707 // sampling artifact, not an accounting error).
708 let last_nonzero = attrs
709 .get(LAST_NONZERO_QUEUE_DEPTH_AGE_MS)
710 .copied()
711 .flatten();
712 Self {
713 memory: ProcessMemoryStats::from_attrs(attrs),
714 actor_work_queue_depth_total: total,
715 actor_work_queue_depth_max: max,
716 actor_work_queue_depth_high_water_mark: high_water,
717 last_nonzero_queue_depth_age_ms: last_nonzero,
718 }
719 }
720
721 pub fn to_attrs(&self, attrs: &mut Attrs) {
722 self.memory.to_attrs(attrs);
723 attrs.set(
724 ACTOR_WORK_QUEUE_DEPTH_TOTAL,
725 self.actor_work_queue_depth_total,
726 );
727 attrs.set(ACTOR_WORK_QUEUE_DEPTH_MAX, self.actor_work_queue_depth_max);
728 attrs.set(
729 ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
730 self.actor_work_queue_depth_high_water_mark,
731 );
732 attrs.set(
733 LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
734 self.last_nonzero_queue_depth_age_ms,
735 );
736 }
737}
738
739/// Typed view over attrs for a host node.
740#[derive(Debug, Clone, PartialEq)]
741pub struct HostAttrsView {
742 pub addr: String,
743 pub num_procs: usize,
744 pub system_children: Vec<NodeRef>,
745 /// Hosting-process memory stats.
746 pub memory: ProcessMemoryStats,
747}
748
749impl HostAttrsView {
750 /// Decode from an `Attrs` bag (AV-2, AV-3). Requires `ADDR`;
751 /// `NUM_PROCS` defaults to 0, `SYSTEM_CHILDREN` defaults to
752 /// empty.
753 pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
754 let addr = attrs
755 .get(ADDR)
756 .ok_or_else(|| AttrsViewError::missing("addr"))?
757 .clone();
758 let num_procs = *attrs.get(NUM_PROCS).unwrap_or(&0);
759 let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
760 let memory = ProcessMemoryStats::from_attrs(attrs);
761 Ok(Self {
762 addr,
763 num_procs,
764 system_children,
765 memory,
766 })
767 }
768
769 /// Encode into an `Attrs` bag (AV-1 round-trip producer).
770 pub fn to_attrs(&self) -> Attrs {
771 let mut attrs = Attrs::new();
772 attrs.set(NODE_TYPE, "host".to_string());
773 attrs.set(ADDR, self.addr.clone());
774 attrs.set(NUM_PROCS, self.num_procs);
775 attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
776 self.memory.to_attrs(&mut attrs);
777 attrs
778 }
779}
780
781/// Typed view over attrs for a proc node.
782#[derive(Debug, Clone, PartialEq)]
783pub struct ProcAttrsView {
784 pub proc_name: String,
785 pub num_actors: usize,
786 pub system_children: Vec<NodeRef>,
787 pub stopped_children: Vec<NodeRef>,
788 pub stopped_retention_cap: usize,
789 pub is_poisoned: bool,
790 pub failed_actor_count: usize,
791 /// Runtime debug/operational stats (PD-*).
792 pub debug: ProcDebugStats,
793}
794
795impl ProcAttrsView {
796 /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
797 /// `PROC_NAME`; remaining fields have defaults. Checks FI-5
798 /// coherence.
799 pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
800 let proc_name = attrs
801 .get(PROC_NAME)
802 .ok_or_else(|| AttrsViewError::missing("proc_name"))?
803 .clone();
804 let num_actors = *attrs.get(NUM_ACTORS).unwrap_or(&0);
805 let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
806 let stopped_children = attrs.get(STOPPED_CHILDREN).cloned().unwrap_or_default();
807 let stopped_retention_cap = *attrs.get(STOPPED_RETENTION_CAP).unwrap_or(&0);
808 let is_poisoned = *attrs.get(IS_POISONED).unwrap_or(&false);
809 let failed_actor_count = *attrs.get(FAILED_ACTOR_COUNT).unwrap_or(&0);
810
811 // FI-5: is_poisoned iff failed_actor_count > 0.
812 if is_poisoned != (failed_actor_count > 0) {
813 return Err(AttrsViewError::invariant(
814 "FI-5",
815 format!("is_poisoned={is_poisoned} but failed_actor_count={failed_actor_count}"),
816 ));
817 }
818
819 let debug = ProcDebugStats::from_attrs(attrs);
820
821 Ok(Self {
822 proc_name,
823 num_actors,
824 system_children,
825 stopped_children,
826 stopped_retention_cap,
827 is_poisoned,
828 failed_actor_count,
829 debug,
830 })
831 }
832
833 /// Encode into an `Attrs` bag (AV-1 round-trip producer).
834 pub fn to_attrs(&self) -> Attrs {
835 let mut attrs = Attrs::new();
836 attrs.set(NODE_TYPE, "proc".to_string());
837 attrs.set(PROC_NAME, self.proc_name.clone());
838 attrs.set(NUM_ACTORS, self.num_actors);
839 attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
840 attrs.set(STOPPED_CHILDREN, self.stopped_children.clone());
841 attrs.set(STOPPED_RETENTION_CAP, self.stopped_retention_cap);
842 attrs.set(IS_POISONED, self.is_poisoned);
843 attrs.set(FAILED_ACTOR_COUNT, self.failed_actor_count);
844 self.debug.to_attrs(&mut attrs);
845 attrs
846 }
847}
848
849/// Typed view over attrs for an error node.
850#[derive(Debug, Clone, PartialEq)]
851pub struct ErrorAttrsView {
852 pub code: String,
853 pub message: String,
854}
855
856impl ErrorAttrsView {
857 /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
858 /// `ERROR_CODE`; `ERROR_MESSAGE` defaults to empty.
859 pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
860 use hyperactor::introspect::ERROR_CODE;
861 use hyperactor::introspect::ERROR_MESSAGE;
862
863 let code = attrs
864 .get(ERROR_CODE)
865 .ok_or_else(|| AttrsViewError::missing("error_code"))?
866 .clone();
867 let message = attrs.get(ERROR_MESSAGE).cloned().unwrap_or_default();
868 Ok(Self { code, message })
869 }
870
871 /// Encode into an `Attrs` bag (AV-1 round-trip producer).
872 pub fn to_attrs(&self) -> Attrs {
873 use hyperactor::introspect::ERROR_CODE;
874 use hyperactor::introspect::ERROR_MESSAGE;
875
876 let mut attrs = Attrs::new();
877 attrs.set(ERROR_CODE, self.code.clone());
878 attrs.set(ERROR_MESSAGE, self.message.clone());
879 attrs
880 }
881}
882
883// --- API / presentation types ---
884
885use std::fmt;
886use std::str::FromStr;
887use std::time::SystemTime;
888
889use serde::Deserialize;
890use serde::Serialize;
891use typeuri::Named;
892
893/// Typed reference to a node in the mesh-admin navigation tree.
894///
895/// Extends `IntrospectRef` with mesh-only concepts (`Root`, `Host`).
896/// hyperactor does not know about these variants.
897#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Named)]
898pub enum NodeRef {
899 /// Synthetic mesh root node.
900 /// Serializes as lowercase `"root"` to match the HTTP path convention.
901 #[serde(rename = "root")]
902 Root,
903 /// A host in the mesh, identified by its `HostAgent` actor ID.
904 Host(hyperactor::ActorAddr),
905 /// A proc running on a host.
906 Proc(hyperactor::ProcAddr),
907 /// An actor instance within a proc.
908 Actor(hyperactor::ActorAddr),
909}
910
911hyperactor_config::impl_attrvalue!(NodeRef);
912
913impl fmt::Display for NodeRef {
914 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
915 match self {
916 Self::Root => write!(f, "root"),
917 Self::Host(id) => write!(f, "host:{}", id),
918 Self::Proc(id) => fmt::Display::fmt(id, f),
919 Self::Actor(id) => fmt::Display::fmt(id, f),
920 }
921 }
922}
923
924/// Error parsing a `NodeRef` from a string.
925#[derive(Debug, thiserror::Error)]
926pub enum NodeRefParseError {
927 #[error("empty reference string")]
928 Empty,
929 #[error("invalid host reference: {0}")]
930 InvalidHost(hyperactor::AddrParseError),
931 #[error("port references are not valid node references")]
932 PortNotAllowed,
933 #[error(transparent)]
934 Reference(#[from] hyperactor::AddrParseError),
935}
936
937impl FromStr for NodeRef {
938 type Err = NodeRefParseError;
939
940 fn from_str(s: &str) -> Result<Self, Self::Err> {
941 if s.is_empty() {
942 return Err(NodeRefParseError::Empty);
943 }
944 if s == "root" {
945 return Ok(Self::Root);
946 }
947 if let Some(rest) = s.strip_prefix("host:") {
948 let actor_id: hyperactor::ActorAddr =
949 rest.parse().map_err(NodeRefParseError::InvalidHost)?;
950 return Ok(Self::Host(actor_id));
951 }
952 let r: hyperactor::Addr = s.parse()?;
953 match r {
954 hyperactor::Addr::Proc(id) => Ok(Self::Proc(id)),
955 hyperactor::Addr::Actor(id) => Ok(Self::Actor(id)),
956 hyperactor::Addr::Port(_) => Err(NodeRefParseError::PortNotAllowed),
957 }
958 }
959}
960
961impl From<hyperactor::introspect::IntrospectRef> for NodeRef {
962 fn from(r: hyperactor::introspect::IntrospectRef) -> Self {
963 match r {
964 hyperactor::introspect::IntrospectRef::Proc(id) => Self::Proc(id),
965 hyperactor::introspect::IntrospectRef::Actor(id) => Self::Actor(id),
966 }
967 }
968}
969
970/// Uniform response for any node in the mesh topology.
971///
972/// Every addressable entity (root, host, proc, actor) is represented
973/// as a `NodePayload`. The client navigates the mesh by fetching a
974/// node and following its `children` references.
975///
976/// See IA-1..IA-5 in module doc.
977// Serialize/Deserialize required by wirevalue::register_type! and
978// ResolveReferenceResponse actor messaging. HTTP serialization uses
979// dto::NodePayloadDto, not these derives.
980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
981pub struct NodePayload {
982 /// Canonical node reference identifying this node.
983 pub identity: NodeRef,
984 /// Node-specific metadata (type, status, metrics, etc.).
985 pub properties: NodeProperties,
986 /// Child node references for downward navigation.
987 pub children: Vec<NodeRef>,
988 /// Parent node reference for upward navigation.
989 pub parent: Option<NodeRef>,
990 /// When this payload was captured.
991 pub as_of: SystemTime,
992}
993wirevalue::register_type!(NodePayload);
994
995/// Node-specific metadata. Externally-tagged enum — the variant
996/// name is the discriminator (Root, Host, Proc, Actor, Error).
997// Serialize/Deserialize required by wirevalue::register_type! and
998// ResolveReferenceResponse actor messaging. HTTP serialization uses
999// dto::NodePropertiesDto, not these derives.
1000//
1001// `inbound_ordering` is boxed because the per-session detail can grow
1002// large, and every NodeProperties value is padded to the size of the
1003// biggest variant; boxing keeps the cost on the Actor heap when actually
1004// present rather than padding every Root/Host/Proc/Error value. The
1005// wire format is unchanged (serde transparent over Box<T>).
1006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1007pub enum NodeProperties {
1008 /// Synthetic mesh root node (not a real actor/proc).
1009 Root {
1010 num_hosts: usize,
1011 started_at: SystemTime,
1012 started_by: String,
1013 system_children: Vec<NodeRef>,
1014 },
1015 /// A host in the mesh, represented by its `HostAgent`.
1016 Host {
1017 addr: String,
1018 num_procs: usize,
1019 system_children: Vec<NodeRef>,
1020 memory: ProcessMemoryStats,
1021 },
1022 /// Properties describing a proc running on a host.
1023 Proc {
1024 proc_name: String,
1025 num_actors: usize,
1026 system_children: Vec<NodeRef>,
1027 stopped_children: Vec<NodeRef>,
1028 stopped_retention_cap: usize,
1029 is_poisoned: bool,
1030 failed_actor_count: usize,
1031 debug: ProcDebugStats,
1032 },
1033 /// Runtime metadata for a single actor instance.
1034 Actor {
1035 actor_status: String,
1036 actor_type: String,
1037 instance_id: String,
1038 messages_processed: u64,
1039 created_at: Option<SystemTime>,
1040 last_message_handler: Option<String>,
1041 total_processing_time_us: u64,
1042 queue_depth: u64,
1043 flight_recorder: Option<String>,
1044 is_system: bool,
1045 inbound_ordering: Option<Box<InboundOrdering>>,
1046 failure_info: Option<FailureInfo>,
1047 /// In-flight handler execution (EX-*). `None` means the actor
1048 /// does not report execution (unsupported), not idle.
1049 execution: Option<Box<Execution>>,
1050 },
1051 /// Error sentinel returned when a child reference cannot be resolved.
1052 Error { code: String, message: String },
1053}
1054wirevalue::register_type!(NodeProperties);
1055
1056/// Structured failure information for failed actors.
1057// Serialize/Deserialize required by wirevalue::register_type! and
1058// ResolveReferenceResponse actor messaging. HTTP serialization uses
1059// dto::FailureInfoDto, not these derives.
1060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1061pub struct FailureInfo {
1062 /// Error message describing the failure.
1063 pub error_message: String,
1064 /// Actor that caused the failure (root cause).
1065 pub root_cause_actor: hyperactor::ActorAddr,
1066 /// Display name of the root-cause actor, if available.
1067 pub root_cause_name: Option<String>,
1068 /// When the failure occurred.
1069 pub occurred_at: SystemTime,
1070 /// Whether this failure was propagated from a child.
1071 pub is_propagated: bool,
1072}
1073wirevalue::register_type!(FailureInfo);
1074
1075/// Mesh-admin presentation of inbound ordering state. Computed from
1076/// the upstream `hyperactor::ordering::OrderingSnapshot`; rollup fields
1077/// are derived at conversion time so consumers don't have to iterate
1078/// sessions for the common "is anything stalled?" question.
1079///
1080/// Partial-snapshot semantics (IO-2): when `snapshot_complete == false`,
1081/// `sessions` excludes any session held by a concurrent send. Rollups
1082/// marked "returned" below are computed over `sessions` only and are
1083/// LOWER BOUNDS in that case -- agents must refetch before concluding
1084/// "no stalls". `known_session_count` is the exception: it counts both
1085/// returned and skipped sessions.
1086//
1087// Serialize/Deserialize required by wirevalue::register_type! and
1088// ResolveReferenceResponse actor messaging. HTTP serialization uses
1089// dto::InboundOrderingDto, not these derives.
1090#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1091pub struct InboundOrdering {
1092 /// Whether reorder buffering is enabled for this sender. When
1093 /// `false`, messages flow via `direct_send` and `sessions` is
1094 /// empty even under load.
1095 pub enabled: bool,
1096 /// IO-4: `true` iff `skipped_session_count == 0`. Mirrors
1097 /// `OrderingSnapshot::is_complete()`.
1098 pub snapshot_complete: bool,
1099 /// Sessions whose mutex was held by a concurrent send when we
1100 /// tried to snapshot. NOT in `sessions`.
1101 pub skipped_session_count: usize,
1102 /// IO-5: total live sessions known to `OrderedSender` at snapshot
1103 /// time: `sessions.len() + skipped_session_count`. Includes idle /
1104 /// drained sessions (state retained for duplicate-detection).
1105 pub known_session_count: usize,
1106 /// IO-6: sessions with `buffered_count > 0` AMONG RETURNED
1107 /// sessions. Lower bound if `!snapshot_complete`.
1108 pub returned_buffered_session_count: usize,
1109 /// IO-6: sum of `buffered_count` OVER RETURNED sessions.
1110 /// Reorder-buffer scope only (see IO-3 in `hyperactor::introspect`).
1111 /// Lower bound if `!snapshot_complete`.
1112 pub returned_buffered_message_count: usize,
1113 /// IO-6: max of `buffered_count` OVER RETURNED sessions. Lower
1114 /// bound if `!snapshot_complete`.
1115 pub returned_max_buffered_count: usize,
1116 /// Per-session entries, sorted by `session_id` (preserved from
1117 /// upstream sort). API returns all returned sessions; TUI may
1118 /// truncate.
1119 pub sessions: Vec<hyperactor::ordering::OrderingSessionSnapshot>,
1120}
1121wirevalue::register_type!(InboundOrdering);
1122
1123impl From<hyperactor::ordering::OrderingSnapshot> for InboundOrdering {
1124 fn from(s: hyperactor::ordering::OrderingSnapshot) -> Self {
1125 let snapshot_complete = s.skipped_session_count == 0;
1126 let returned_buffered_session_count =
1127 s.sessions.iter().filter(|x| x.buffered_count > 0).count();
1128 let returned_buffered_message_count: usize =
1129 s.sessions.iter().map(|x| x.buffered_count).sum();
1130 let returned_max_buffered_count = s
1131 .sessions
1132 .iter()
1133 .map(|x| x.buffered_count)
1134 .max()
1135 .unwrap_or(0);
1136 let known_session_count = s.sessions.len() + s.skipped_session_count;
1137 Self {
1138 enabled: s.enabled,
1139 snapshot_complete,
1140 skipped_session_count: s.skipped_session_count,
1141 known_session_count,
1142 returned_buffered_session_count,
1143 returned_buffered_message_count,
1144 returned_max_buffered_count,
1145 sessions: s.sessions,
1146 }
1147 }
1148}
1149
1150/// One handler with in-flight invocations, aggregated by name (EX-4).
1151// Serialize/Deserialize required for the `EXECUTION` attr and for
1152// `wirevalue` messaging via the enclosing `Execution`. HTTP
1153// serialization uses dto::ActiveHandlerDto, not these derives.
1154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1155pub struct ActiveHandler {
1156 /// Handler name (e.g. a Python endpoint method name).
1157 pub name: String,
1158 /// In-flight invocations of this handler.
1159 pub active_count: u64,
1160 /// Start time of the oldest in-flight invocation of this handler.
1161 pub oldest_since: SystemTime,
1162}
1163
1164/// An actor's in-flight handler execution, reported through the generic
1165/// actor-attrs snapshot seam (`AS-*`). Carried both as the `EXECUTION`
1166/// attr value and as the `NodeProperties::Actor.execution` field; core
1167/// hyperactor does not interpret it. See EX-* in module doc.
1168// Serialize/Deserialize required by wirevalue::register_type! and the
1169// `EXECUTION` attr. HTTP serialization uses dto::ExecutionDto.
1170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named, AttrValue)]
1171pub struct Execution {
1172 /// EX-2/EX-3: handler invocations currently in flight. Lock-free
1173 /// count, always present; need not equal
1174 /// `sum(active_handlers[*].active_count)`.
1175 pub active_count: u64,
1176 /// EX-4: per-handler detail, oldest-first; a prefix of the N oldest
1177 /// when `truncated`.
1178 pub active_handlers: Vec<ActiveHandler>,
1179 /// EX-2: `true` iff the per-handler detail was captured on this read.
1180 pub complete: bool,
1181 /// EX-4: `true` iff `active_handlers` is a prefix of the N oldest
1182 /// (`active_count` stays the full total).
1183 pub truncated: bool,
1184}
1185wirevalue::register_type!(Execution);
1186
1187impl fmt::Display for Execution {
1188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189 write!(f, "{}", serde_json::to_string(self).unwrap())
1190 }
1191}
1192
1193impl FromStr for Execution {
1194 type Err = serde_json::Error;
1195 fn from_str(s: &str) -> Result<Self, Self::Err> {
1196 serde_json::from_str(s)
1197 }
1198}
1199
1200// The mesh-owned `EXECUTION` attr. Populated by a runtime via the
1201// generic seam (e.g. `monarch_hyperactor` for Python actors) and decoded
1202// in `derive_properties`; core hyperactor never interprets it.
1203declare_attrs! {
1204 /// In-flight handler execution for an actor.
1205 @meta(INTROSPECT = IntrospectAttr {
1206 name: "execution".into(),
1207 desc: "In-flight handler execution for an actor".into(),
1208 })
1209 pub attr EXECUTION: Execution;
1210}
1211
1212/// Mesh-layer conversion from a typed attrs view to `NodeProperties`.
1213///
1214/// Defined here so that `hyperactor` views (e.g. `ActorAttrsView`) can
1215/// produce `NodeProperties` without depending on the mesh crate.
1216trait IntoNodeProperties {
1217 fn into_node_properties(self) -> NodeProperties;
1218}
1219
1220impl IntoNodeProperties for RootAttrsView {
1221 fn into_node_properties(self) -> NodeProperties {
1222 NodeProperties::Root {
1223 num_hosts: self.num_hosts,
1224 started_at: self.started_at,
1225 started_by: self.started_by,
1226 system_children: self.system_children,
1227 }
1228 }
1229}
1230
1231impl IntoNodeProperties for HostAttrsView {
1232 fn into_node_properties(self) -> NodeProperties {
1233 NodeProperties::Host {
1234 addr: self.addr,
1235 num_procs: self.num_procs,
1236 system_children: self.system_children,
1237 memory: self.memory,
1238 }
1239 }
1240}
1241
1242impl IntoNodeProperties for ProcAttrsView {
1243 fn into_node_properties(self) -> NodeProperties {
1244 NodeProperties::Proc {
1245 proc_name: self.proc_name,
1246 num_actors: self.num_actors,
1247 system_children: self.system_children,
1248 stopped_children: self.stopped_children,
1249 stopped_retention_cap: self.stopped_retention_cap,
1250 is_poisoned: self.is_poisoned,
1251 failed_actor_count: self.failed_actor_count,
1252 debug: self.debug,
1253 }
1254 }
1255}
1256
1257impl IntoNodeProperties for ErrorAttrsView {
1258 fn into_node_properties(self) -> NodeProperties {
1259 NodeProperties::Error {
1260 code: self.code,
1261 message: self.message,
1262 }
1263 }
1264}
1265
1266impl IntoNodeProperties for hyperactor::introspect::ActorAttrsView {
1267 fn into_node_properties(self) -> NodeProperties {
1268 let actor_status = match &self.status_reason {
1269 Some(reason) => format!("{}: {}", self.status, reason),
1270 None => self.status.clone(),
1271 };
1272
1273 let failure_info = self.failure.map(|fi| FailureInfo {
1274 error_message: fi.error_message,
1275 root_cause_actor: fi.root_cause_actor,
1276 root_cause_name: fi.root_cause_name,
1277 occurred_at: fi.occurred_at,
1278 is_propagated: fi.is_propagated,
1279 });
1280
1281 NodeProperties::Actor {
1282 actor_status,
1283 actor_type: self.actor_type,
1284 instance_id: self.instance_id,
1285 messages_processed: self.messages_processed,
1286 created_at: self.created_at,
1287 last_message_handler: self.last_handler,
1288 total_processing_time_us: self.total_processing_time_us,
1289 queue_depth: self.queue_depth,
1290 flight_recorder: self.flight_recorder,
1291 is_system: self.is_system,
1292 inbound_ordering: self
1293 .inbound_ordering
1294 .map(|io| Box::new(InboundOrdering::from(io))),
1295 failure_info,
1296 // `ActorAttrsView` (core) is execution-agnostic; the mesh
1297 // decodes `execution` from the full attrs in
1298 // `derive_properties` (the seam keystone). Default to None.
1299 execution: None,
1300 }
1301 }
1302}
1303
1304/// Derive `NodeProperties` from a JSON-serialized attrs string.
1305///
1306/// Detection precedence (DP-1, DP-3, DP-5):
1307/// 1. `STATUS` key present → Actor (DP-5: a STATUS-bearing payload always
1308/// decodes as Actor, so the actor-attrs seam cannot spoof node kind)
1309/// 2. `node_type` = "root" / "host" / "proc" → corresponding variant
1310/// 3. `error_code` present → Error
1311/// 4. none of the above → Error("unknown_node_type")
1312///
1313/// DP-2 / DP-4: this function is total — malformed attrs never
1314/// panic; view decode failures map to `NodeProperties::Error`
1315/// with a `malformed_*` code.
1316/// AV-3 / IA-6: view decoders ignore unknown keys.
1317pub fn derive_properties(attrs_json: &str) -> NodeProperties {
1318 use hyperactor::introspect::ERROR_CODE;
1319 use hyperactor::introspect::STATUS;
1320
1321 let attrs: Attrs = match serde_json::from_str(attrs_json) {
1322 Ok(a) => a,
1323 Err(_) => {
1324 return NodeProperties::Error {
1325 code: "parse_error".into(),
1326 message: "failed to parse attrs JSON".into(),
1327 };
1328 }
1329 };
1330
1331 // DP-5 (actor-view classification safety): the core `STATUS` key is set
1332 // only by the blanket Actor builder (`build_actor_attrs`) and is
1333 // core-owned, so the actor-attrs snapshot seam can neither remove nor
1334 // override it (AS-2), and no non-actor payload carries it. Classifying
1335 // STATUS-present as `Actor` *before* `node_type`/`error_code` means a
1336 // snapshot-injected `node_type`/`error_code` cannot make a blanket actor
1337 // decode as Root/Proc/Error.
1338 if attrs.get(STATUS).is_some() {
1339 return match hyperactor::introspect::ActorAttrsView::from_attrs(&attrs) {
1340 Ok(v) => {
1341 // Keystone: `ActorAttrsView` (core) ignores the
1342 // mesh-owned `EXECUTION` key, so decode it here from the
1343 // full attrs and layer it onto the Actor node (EX-1:
1344 // absent → None).
1345 let mut props = v.into_node_properties();
1346 if let NodeProperties::Actor { execution, .. } = &mut props {
1347 *execution = attrs.get(EXECUTION).cloned().map(Box::new);
1348 }
1349 props
1350 }
1351 Err(e) => NodeProperties::Error {
1352 code: "malformed_actor".into(),
1353 message: e.to_string(),
1354 },
1355 };
1356 }
1357
1358 let node_type = attrs.get(NODE_TYPE).cloned().unwrap_or_default();
1359
1360 match node_type.as_str() {
1361 "root" => match RootAttrsView::from_attrs(&attrs) {
1362 Ok(v) => v.into_node_properties(),
1363 Err(e) => NodeProperties::Error {
1364 code: "malformed_root".into(),
1365 message: e.to_string(),
1366 },
1367 },
1368 "host" => match HostAttrsView::from_attrs(&attrs) {
1369 Ok(v) => v.into_node_properties(),
1370 Err(e) => NodeProperties::Error {
1371 code: "malformed_host".into(),
1372 message: e.to_string(),
1373 },
1374 },
1375 "proc" => match ProcAttrsView::from_attrs(&attrs) {
1376 Ok(v) => v.into_node_properties(),
1377 Err(e) => NodeProperties::Error {
1378 code: "malformed_proc".into(),
1379 message: e.to_string(),
1380 },
1381 },
1382 _ => {
1383 // STATUS-bearing payloads decoded as Actor above (DP-5), so
1384 // here STATUS is absent: error_code → Error, else unknown.
1385 if attrs.get(ERROR_CODE).is_some() {
1386 return match ErrorAttrsView::from_attrs(&attrs) {
1387 Ok(v) => v.into_node_properties(),
1388 Err(e) => NodeProperties::Error {
1389 code: "malformed_error".into(),
1390 message: e.to_string(),
1391 },
1392 };
1393 }
1394
1395 NodeProperties::Error {
1396 code: "unknown_node_type".into(),
1397 message: format!("unrecognized node_type: {:?}", node_type),
1398 }
1399 }
1400 }
1401}
1402
1403/// Convert an `IntrospectResult` to a presentation `NodePayload`.
1404/// Lifts `IntrospectRef` → `NodeRef` and passes through typed timestamps.
1405pub fn to_node_payload(result: hyperactor::introspect::IntrospectResult) -> NodePayload {
1406 NodePayload {
1407 identity: result.identity.into(),
1408 properties: derive_properties(&result.attrs),
1409 children: result.children.into_iter().map(NodeRef::from).collect(),
1410 parent: result.parent.map(NodeRef::from),
1411 as_of: result.as_of,
1412 }
1413}
1414
1415/// Convert an `IntrospectResult` to a `NodePayload`, overriding
1416/// identity and parent for correct tree navigation.
1417pub fn to_node_payload_with(
1418 result: hyperactor::introspect::IntrospectResult,
1419 identity: NodeRef,
1420 parent: Option<NodeRef>,
1421) -> NodePayload {
1422 NodePayload {
1423 identity,
1424 properties: derive_properties(&result.attrs),
1425 children: result.children.into_iter().map(NodeRef::from).collect(),
1426 parent,
1427 as_of: result.as_of,
1428 }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use crate::mesh_id::ResourceId;
1435
1436 /// Enforces MK-1 (metadata completeness) for all mesh-topology
1437 /// introspection keys.
1438 #[test]
1439 fn test_mesh_introspect_keys_are_tagged() {
1440 let cases = vec![
1441 ("node_type", NODE_TYPE.attrs()),
1442 ("addr", ADDR.attrs()),
1443 ("num_procs", NUM_PROCS.attrs()),
1444 ("proc_name", PROC_NAME.attrs()),
1445 ("num_actors", NUM_ACTORS.attrs()),
1446 ("system_children", SYSTEM_CHILDREN.attrs()),
1447 ("stopped_children", STOPPED_CHILDREN.attrs()),
1448 ("stopped_retention_cap", STOPPED_RETENTION_CAP.attrs()),
1449 ("is_poisoned", IS_POISONED.attrs()),
1450 ("failed_actor_count", FAILED_ACTOR_COUNT.attrs()),
1451 ("started_at", STARTED_AT.attrs()),
1452 ("started_by", STARTED_BY.attrs()),
1453 ("num_hosts", NUM_HOSTS.attrs()),
1454 // PD-* proc debug stats keys.
1455 ("process_rss_bytes", PROCESS_RSS_BYTES.attrs()),
1456 ("process_vm_size_bytes", PROCESS_VM_SIZE_BYTES.attrs()),
1457 (
1458 "actor_work_queue_depth_total",
1459 ACTOR_WORK_QUEUE_DEPTH_TOTAL.attrs(),
1460 ),
1461 (
1462 "actor_work_queue_depth_max",
1463 ACTOR_WORK_QUEUE_DEPTH_MAX.attrs(),
1464 ),
1465 (
1466 "actor_work_queue_depth_high_water_mark",
1467 ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK.attrs(),
1468 ),
1469 (
1470 "last_nonzero_queue_depth_age_ms",
1471 LAST_NONZERO_QUEUE_DEPTH_AGE_MS.attrs(),
1472 ),
1473 ("execution", EXECUTION.attrs()),
1474 ];
1475
1476 for (expected_name, meta) in &cases {
1477 // MK-1: every key must have INTROSPECT with non-empty
1478 // name and desc.
1479 let introspect = meta
1480 .get(INTROSPECT)
1481 .unwrap_or_else(|| panic!("{expected_name}: missing INTROSPECT meta-attr"));
1482 assert_eq!(
1483 introspect.name, *expected_name,
1484 "short name mismatch for {expected_name}"
1485 );
1486 assert!(
1487 !introspect.desc.is_empty(),
1488 "{expected_name}: desc should not be empty"
1489 );
1490 }
1491
1492 // Exhaustiveness: verify cases covers all INTROSPECT-tagged
1493 // keys declared in this module.
1494 use hyperactor_config::attrs::AttrKeyInfo;
1495 let registry_count = inventory::iter::<AttrKeyInfo>()
1496 .filter(|info| {
1497 info.name.starts_with("hyperactor_mesh::introspect::")
1498 && info.meta.get(INTROSPECT).is_some()
1499 })
1500 .count();
1501 assert_eq!(
1502 cases.len(),
1503 registry_count,
1504 "test must cover all INTROSPECT-tagged keys in this module"
1505 );
1506 }
1507
1508 fn test_actor_ref(proc_name: &str, actor_name: &str) -> NodeRef {
1509 use hyperactor::channel::ChannelAddr;
1510
1511 NodeRef::Actor(
1512 ResourceId::proc_addr_from_name(ChannelAddr::Local(0), proc_name)
1513 .actor_addr(actor_name),
1514 )
1515 }
1516
1517 fn root_view() -> RootAttrsView {
1518 RootAttrsView {
1519 num_hosts: 3,
1520 started_at: std::time::UNIX_EPOCH,
1521 started_by: "testuser".into(),
1522 system_children: vec![test_actor_ref("proc", "child1")],
1523 }
1524 }
1525
1526 fn host_view() -> HostAttrsView {
1527 HostAttrsView {
1528 addr: "10.0.0.1:8080".into(),
1529 num_procs: 2,
1530 system_children: vec![test_actor_ref("proc", "sys")],
1531 memory: Default::default(),
1532 }
1533 }
1534
1535 fn proc_view() -> ProcAttrsView {
1536 ProcAttrsView {
1537 proc_name: "worker".into(),
1538 num_actors: 5,
1539 system_children: vec![],
1540 stopped_children: vec![test_actor_ref("proc", "old")],
1541 stopped_retention_cap: 10,
1542 is_poisoned: false,
1543 failed_actor_count: 0,
1544 debug: Default::default(),
1545 }
1546 }
1547
1548 fn error_view() -> ErrorAttrsView {
1549 ErrorAttrsView {
1550 code: "not_found".into(),
1551 message: "child not found".into(),
1552 }
1553 }
1554
1555 /// AV-1: from_attrs(to_attrs(v)) == v.
1556 #[test]
1557 fn test_root_view_round_trip() {
1558 let view = root_view();
1559 let rt = RootAttrsView::from_attrs(&view.to_attrs()).unwrap();
1560 assert_eq!(rt, view);
1561 }
1562
1563 /// AV-1.
1564 #[test]
1565 fn test_host_view_round_trip() {
1566 let view = host_view();
1567 let rt = HostAttrsView::from_attrs(&view.to_attrs()).unwrap();
1568 assert_eq!(rt, view);
1569 }
1570
1571 /// AV-1.
1572 #[test]
1573 fn test_proc_view_round_trip() {
1574 let view = proc_view();
1575 let rt = ProcAttrsView::from_attrs(&view.to_attrs()).unwrap();
1576 assert_eq!(rt, view);
1577 }
1578
1579 /// AV-1: host view with non-default memory round-trips.
1580 #[test]
1581 fn test_host_view_round_trip_with_memory() {
1582 let view = HostAttrsView {
1583 addr: "10.0.0.1:8080".into(),
1584 num_procs: 2,
1585 system_children: vec![],
1586 memory: ProcessMemoryStats {
1587 process_rss_bytes: Some(512 * 1024 * 1024),
1588 process_vm_size_bytes: Some(2 * 1024 * 1024 * 1024),
1589 },
1590 };
1591 let rt = HostAttrsView::from_attrs(&view.to_attrs()).unwrap();
1592 assert_eq!(rt, view);
1593 }
1594
1595 /// AV-1: proc view with non-default debug stats round-trips.
1596 #[test]
1597 fn test_proc_view_round_trip_with_debug() {
1598 let view = ProcAttrsView {
1599 proc_name: "worker".into(),
1600 num_actors: 5,
1601 system_children: vec![],
1602 stopped_children: vec![],
1603 stopped_retention_cap: 10,
1604 is_poisoned: false,
1605 failed_actor_count: 0,
1606 debug: ProcDebugStats {
1607 memory: ProcessMemoryStats {
1608 process_rss_bytes: Some(256 * 1024 * 1024),
1609 process_vm_size_bytes: Some(1024 * 1024 * 1024),
1610 },
1611 actor_work_queue_depth_total: 42,
1612 actor_work_queue_depth_max: 7,
1613 actor_work_queue_depth_high_water_mark: 100,
1614 last_nonzero_queue_depth_age_ms: Some(5000),
1615 },
1616 };
1617 let rt = ProcAttrsView::from_attrs(&view.to_attrs()).unwrap();
1618 assert_eq!(rt, view);
1619 }
1620
1621 /// PD-1: max <= total enforced (warning logged, no error).
1622 #[test]
1623 fn test_proc_debug_stats_pd1_warning_on_violation() {
1624 let mut attrs = Attrs::new();
1625 attrs.set(PROC_NAME, "test".to_string());
1626 attrs.set(ACTOR_WORK_QUEUE_DEPTH_TOTAL, 5u64);
1627 attrs.set(ACTOR_WORK_QUEUE_DEPTH_MAX, 10u64); // violation
1628 // Should not error, but should log warning.
1629 let view = ProcAttrsView::from_attrs(&attrs).unwrap();
1630 assert_eq!(view.debug.actor_work_queue_depth_total, 5);
1631 assert_eq!(view.debug.actor_work_queue_depth_max, 10);
1632 }
1633
1634 /// PD-3: missing debug attrs default to zero/None.
1635 #[test]
1636 fn test_proc_debug_stats_defaults_on_missing_attrs() {
1637 let mut attrs = Attrs::new();
1638 attrs.set(PROC_NAME, "old_proc".to_string());
1639 let view = ProcAttrsView::from_attrs(&attrs).unwrap();
1640 assert_eq!(view.debug, ProcDebugStats::default());
1641 }
1642
1643 /// AV-1.
1644 #[test]
1645 fn test_error_view_round_trip() {
1646 let view = error_view();
1647 let rt = ErrorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1648 assert_eq!(rt, view);
1649 }
1650
1651 /// AV-2: missing required key rejected.
1652 #[test]
1653 fn test_root_view_missing_started_at() {
1654 let mut attrs = Attrs::new();
1655 attrs.set(NODE_TYPE, "root".into());
1656 attrs.set(STARTED_BY, "user".into());
1657 let err = RootAttrsView::from_attrs(&attrs).unwrap_err();
1658 assert_eq!(err, AttrsViewError::MissingKey { key: "started_at" });
1659 }
1660
1661 /// AV-2.
1662 #[test]
1663 fn test_root_view_missing_started_by() {
1664 let mut attrs = Attrs::new();
1665 attrs.set(NODE_TYPE, "root".into());
1666 attrs.set(STARTED_AT, std::time::UNIX_EPOCH);
1667 let err = RootAttrsView::from_attrs(&attrs).unwrap_err();
1668 assert_eq!(err, AttrsViewError::MissingKey { key: "started_by" });
1669 }
1670
1671 /// AV-2.
1672 #[test]
1673 fn test_host_view_missing_addr() {
1674 let attrs = Attrs::new();
1675 let err = HostAttrsView::from_attrs(&attrs).unwrap_err();
1676 assert_eq!(err, AttrsViewError::MissingKey { key: "addr" });
1677 }
1678
1679 /// AV-2.
1680 #[test]
1681 fn test_proc_view_missing_proc_name() {
1682 let attrs = Attrs::new();
1683 let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1684 assert_eq!(err, AttrsViewError::MissingKey { key: "proc_name" });
1685 }
1686
1687 /// FI-5: poisoned without failures rejected.
1688 #[test]
1689 fn test_proc_view_fi5_poisoned_but_no_failures() {
1690 let mut attrs = Attrs::new();
1691 attrs.set(PROC_NAME, "bad".into());
1692 attrs.set(IS_POISONED, true);
1693 attrs.set(FAILED_ACTOR_COUNT, 0usize);
1694 let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1695 assert!(matches!(
1696 err,
1697 AttrsViewError::InvariantViolation { label: "FI-5", .. }
1698 ));
1699 }
1700
1701 /// FI-5: failures without poisoned rejected.
1702 #[test]
1703 fn test_proc_view_fi5_failures_but_not_poisoned() {
1704 let mut attrs = Attrs::new();
1705 attrs.set(PROC_NAME, "bad".into());
1706 attrs.set(IS_POISONED, false);
1707 attrs.set(FAILED_ACTOR_COUNT, 2usize);
1708 let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1709 assert!(matches!(
1710 err,
1711 AttrsViewError::InvariantViolation { label: "FI-5", .. }
1712 ));
1713 }
1714
1715 /// DP-2 / DP-4: unparseable JSON → Error.
1716 #[test]
1717 fn test_derive_properties_unparseable_json() {
1718 let props = derive_properties("not json");
1719 assert!(matches!(props, NodeProperties::Error { code, .. } if code == "parse_error"));
1720 }
1721
1722 /// DP-3: unknown node_type → Error.
1723 #[test]
1724 fn test_derive_properties_unknown_node_type() {
1725 let attrs = Attrs::new();
1726 let json = serde_json::to_string(&attrs).unwrap();
1727 let props = derive_properties(&json);
1728 assert!(matches!(props, NodeProperties::Error { code, .. } if code == "unknown_node_type"));
1729 }
1730
1731 /// DP-4: view decode failure → malformed_* Error.
1732 #[test]
1733 fn test_derive_properties_malformed_root() {
1734 let mut attrs = Attrs::new();
1735 attrs.set(NODE_TYPE, "root".into());
1736 let json = serde_json::to_string(&attrs).unwrap();
1737 let props = derive_properties(&json);
1738 assert!(matches!(props, NodeProperties::Error { code, .. } if code == "malformed_root"));
1739 }
1740
1741 /// DP-4: invariant violation → malformed_* Error.
1742 #[test]
1743 fn test_derive_properties_malformed_proc_fi5() {
1744 let mut attrs = Attrs::new();
1745 attrs.set(NODE_TYPE, "proc".into());
1746 attrs.set(PROC_NAME, "bad".into());
1747 attrs.set(IS_POISONED, true);
1748 attrs.set(FAILED_ACTOR_COUNT, 0usize);
1749 let json = serde_json::to_string(&attrs).unwrap();
1750 let props = derive_properties(&json);
1751 assert!(matches!(props, NodeProperties::Error { code, .. } if code == "malformed_proc"));
1752 }
1753
1754 /// DP-3: node_type "root" → Root variant.
1755 #[test]
1756 fn test_derive_properties_valid_root() {
1757 let view = root_view();
1758 let json = serde_json::to_string(&view.to_attrs()).unwrap();
1759 let props = derive_properties(&json);
1760 assert!(matches!(props, NodeProperties::Root { num_hosts: 3, .. }));
1761 }
1762
1763 /// DP-3: node_type "host" → Host variant.
1764 #[test]
1765 fn test_derive_properties_valid_host() {
1766 let view = host_view();
1767 let json = serde_json::to_string(&view.to_attrs()).unwrap();
1768 let props = derive_properties(&json);
1769 assert!(matches!(props, NodeProperties::Host { num_procs: 2, .. }));
1770 }
1771
1772 /// DP-3: node_type "proc" → Proc variant.
1773 #[test]
1774 fn test_derive_properties_valid_proc() {
1775 let view = proc_view();
1776 let json = serde_json::to_string(&view.to_attrs()).unwrap();
1777 let props = derive_properties(&json);
1778 assert!(matches!(props, NodeProperties::Proc { num_actors: 5, .. }));
1779 }
1780
1781 /// DP-3: error_code present → Error variant.
1782 #[test]
1783 fn test_derive_properties_valid_error() {
1784 let view = error_view();
1785 let json = serde_json::to_string(&view.to_attrs()).unwrap();
1786 let props = derive_properties(&json);
1787 assert!(matches!(props, NodeProperties::Error { .. }));
1788 if let NodeProperties::Error { code, message } = props {
1789 assert_eq!(code, "not_found");
1790 assert_eq!(message, "child not found");
1791 }
1792 }
1793
1794 /// DP-3: STATUS present → Actor variant.
1795 #[test]
1796 fn test_derive_properties_valid_actor() {
1797 use hyperactor::introspect::ACTOR_TYPE;
1798 use hyperactor::introspect::INSTANCE_ID;
1799 use hyperactor::introspect::MESSAGES_PROCESSED;
1800 use hyperactor::introspect::STATUS;
1801
1802 let mut attrs = Attrs::new();
1803 attrs.set(STATUS, "running".into());
1804 attrs.set(ACTOR_TYPE, "TestActor".into());
1805 attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1806 attrs.set(MESSAGES_PROCESSED, 7u64);
1807 let json = serde_json::to_string(&attrs).unwrap();
1808 let props = derive_properties(&json);
1809 assert!(matches!(
1810 props,
1811 NodeProperties::Actor {
1812 messages_processed: 7,
1813 ..
1814 }
1815 ));
1816 }
1817
1818 /// DP-5: a snapshot-injected `node_type` cannot make a STATUS-bearing
1819 /// (blanket) actor decode as a non-Actor node.
1820 #[test]
1821 fn test_derive_properties_status_first_ignores_injected_node_type() {
1822 use hyperactor::introspect::ACTOR_TYPE;
1823 use hyperactor::introspect::INSTANCE_ID;
1824 use hyperactor::introspect::STATUS;
1825
1826 let mut attrs = Attrs::new();
1827 attrs.set(STATUS, "running".into());
1828 attrs.set(ACTOR_TYPE, "TestActor".into());
1829 attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1830 // Hostile injection via the actor-attrs seam.
1831 attrs.set(NODE_TYPE, "root".into());
1832 let json = serde_json::to_string(&attrs).unwrap();
1833 assert!(matches!(
1834 derive_properties(&json),
1835 NodeProperties::Actor { .. }
1836 ));
1837 }
1838
1839 /// DP-5: a snapshot-injected `error_code` cannot make a STATUS-bearing
1840 /// actor decode as an Error node.
1841 #[test]
1842 fn test_derive_properties_status_first_ignores_injected_error_code() {
1843 use hyperactor::introspect::ACTOR_TYPE;
1844 use hyperactor::introspect::ERROR_CODE;
1845 use hyperactor::introspect::INSTANCE_ID;
1846 use hyperactor::introspect::STATUS;
1847
1848 let mut attrs = Attrs::new();
1849 attrs.set(STATUS, "running".into());
1850 attrs.set(ACTOR_TYPE, "TestActor".into());
1851 attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1852 // Hostile injection via the actor-attrs seam.
1853 attrs.set(ERROR_CODE, "not_found".into());
1854 let json = serde_json::to_string(&attrs).unwrap();
1855 assert!(matches!(
1856 derive_properties(&json),
1857 NodeProperties::Actor { .. }
1858 ));
1859 }
1860
1861 /// Injects an unknown key into serialized attrs JSON and
1862 /// verifies that derive_properties still decodes successfully.
1863 /// Exercises IA-6 (open-row-forward-compat) for each view.
1864 fn inject_unknown_key(attrs: &Attrs) -> String {
1865 let mut map: serde_json::Map<String, serde_json::Value> =
1866 serde_json::from_str(&serde_json::to_string(attrs).unwrap()).unwrap();
1867 map.insert(
1868 "unknown_future_key".into(),
1869 serde_json::Value::String("surprise".into()),
1870 );
1871 serde_json::to_string(&map).unwrap()
1872 }
1873
1874 #[test]
1875 fn test_ia6_root_ignores_unknown_keys() {
1876 let json = inject_unknown_key(&root_view().to_attrs());
1877 let props = derive_properties(&json);
1878 assert!(matches!(props, NodeProperties::Root { num_hosts: 3, .. }));
1879 }
1880
1881 #[test]
1882 fn test_ia6_host_ignores_unknown_keys() {
1883 let json = inject_unknown_key(&host_view().to_attrs());
1884 let props = derive_properties(&json);
1885 assert!(matches!(props, NodeProperties::Host { num_procs: 2, .. }));
1886 }
1887
1888 #[test]
1889 fn test_ia6_proc_ignores_unknown_keys() {
1890 let json = inject_unknown_key(&proc_view().to_attrs());
1891 let props = derive_properties(&json);
1892 assert!(matches!(props, NodeProperties::Proc { num_actors: 5, .. }));
1893 }
1894
1895 #[test]
1896 fn test_ia6_error_ignores_unknown_keys() {
1897 let json = inject_unknown_key(&error_view().to_attrs());
1898 let props = derive_properties(&json);
1899 assert!(matches!(props, NodeProperties::Error { .. }));
1900 }
1901
1902 #[test]
1903 fn test_ia6_actor_ignores_unknown_keys() {
1904 use hyperactor::introspect::ACTOR_TYPE;
1905 use hyperactor::introspect::INSTANCE_ID;
1906 use hyperactor::introspect::STATUS;
1907
1908 let mut attrs = Attrs::new();
1909 attrs.set(STATUS, "running".into());
1910 attrs.set(ACTOR_TYPE, "TestActor".into());
1911 attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1912 let json = inject_unknown_key(&attrs);
1913 let props = derive_properties(&json);
1914 assert!(matches!(props, NodeProperties::Actor { .. }));
1915 }
1916
1917 /// SC-1 / SC-2: schema is derived from types and matches the
1918 /// checked-in snapshot.
1919 ///
1920 /// To update after intentional type changes:
1921 /// ```sh
1922 /// buck run fbcode//monarch/hyperactor_mesh:generate_api_artifacts \
1923 /// @fbcode//mode/dev-nosan -- \
1924 /// fbcode/monarch/hyperactor_mesh/src/testdata
1925 /// ```
1926 /// Strip the `$comment` field (containing the @\u{200B}generated marker)
1927 /// from a JSON value so snapshot comparisons ignore it.
1928 fn strip_comment(mut value: serde_json::Value) -> serde_json::Value {
1929 if let Some(obj) = value.as_object_mut() {
1930 obj.remove("$comment");
1931 }
1932 value
1933 }
1934
1935 #[test]
1936 fn test_node_payload_schema_snapshot() {
1937 let schema = schemars::schema_for!(dto::NodePayloadDto);
1938 let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
1939 let expected: serde_json::Value = strip_comment(
1940 serde_json::from_str(include_str!("testdata/node_payload_schema.json"))
1941 .expect("snapshot must be valid JSON"),
1942 );
1943 assert_eq!(
1944 actual, expected,
1945 "schema changed — review and update snapshot if intentional"
1946 );
1947 }
1948
1949 /// SC-3: real payloads validate against the generated schema.
1950 #[test]
1951 fn test_payloads_validate_against_schema() {
1952 use hyperactor::channel::ChannelAddr;
1953
1954 let schema = schemars::schema_for!(dto::NodePayloadDto);
1955 let schema_value = serde_json::to_value(&schema).unwrap();
1956 let compiled = jsonschema::JSONSchema::compile(&schema_value).expect("schema must compile");
1957
1958 let epoch = std::time::UNIX_EPOCH;
1959 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker");
1960 let actor_id = proc_id.actor_addr("actor");
1961
1962 let samples = [
1963 NodePayload {
1964 identity: NodeRef::Root,
1965 properties: NodeProperties::Root {
1966 num_hosts: 2,
1967 started_at: epoch,
1968 started_by: "testuser".into(),
1969 system_children: vec![],
1970 },
1971 children: vec![NodeRef::Host(actor_id.clone())],
1972 parent: None,
1973 as_of: epoch,
1974 },
1975 NodePayload {
1976 identity: NodeRef::Host(actor_id.clone()),
1977 properties: NodeProperties::Host {
1978 addr: "10.0.0.1:8080".into(),
1979 num_procs: 2,
1980 system_children: vec![test_actor_ref("proc", "sys")],
1981 memory: Default::default(),
1982 },
1983 children: vec![NodeRef::Proc(proc_id.clone())],
1984 parent: Some(NodeRef::Root),
1985 as_of: epoch,
1986 },
1987 NodePayload {
1988 identity: NodeRef::Proc(proc_id.clone()),
1989 properties: NodeProperties::Proc {
1990 proc_name: "worker".into(),
1991 num_actors: 5,
1992 system_children: vec![],
1993 stopped_children: vec![],
1994 stopped_retention_cap: 10,
1995 is_poisoned: false,
1996 failed_actor_count: 0,
1997 debug: Default::default(),
1998 },
1999 children: vec![NodeRef::Actor(actor_id.clone())],
2000 parent: Some(NodeRef::Host(actor_id.clone())),
2001 as_of: epoch,
2002 },
2003 NodePayload {
2004 identity: NodeRef::Actor(actor_id.clone()),
2005 properties: NodeProperties::Actor {
2006 actor_status: "running".into(),
2007 actor_type: "MyActor".into(),
2008 instance_id: "01900000-0000-7000-8000-000000000001".into(),
2009 messages_processed: 42,
2010 created_at: Some(epoch),
2011 last_message_handler: Some("handle_ping".into()),
2012 total_processing_time_us: 1000,
2013 queue_depth: 0,
2014 flight_recorder: None,
2015 is_system: false,
2016 inbound_ordering: None,
2017 failure_info: None,
2018 execution: None,
2019 },
2020 children: vec![],
2021 parent: Some(NodeRef::Proc(proc_id.clone())),
2022 as_of: epoch,
2023 },
2024 NodePayload {
2025 identity: NodeRef::Actor(actor_id.clone()),
2026 properties: NodeProperties::Error {
2027 code: "not_found".into(),
2028 message: "child not found".into(),
2029 },
2030 children: vec![],
2031 parent: None,
2032 as_of: epoch,
2033 },
2034 ];
2035
2036 for (i, payload) in samples.iter().enumerate() {
2037 let dto = dto::NodePayloadDto::from(payload.clone());
2038 let value = serde_json::to_value(&dto).unwrap();
2039 assert!(
2040 compiled.is_valid(&value),
2041 "sample {i} failed schema validation"
2042 );
2043 }
2044 }
2045
2046 /// SC-4: `$id` is injected only at the serve boundary.
2047 /// Stripping `$id` from the served schema must yield the raw
2048 /// schemars output.
2049 #[test]
2050 fn test_served_schema_is_raw_plus_id() {
2051 let raw: serde_json::Value =
2052 serde_json::to_value(schemars::schema_for!(dto::NodePayloadDto)).unwrap();
2053
2054 // Simulate what the endpoint does.
2055 let mut served = raw.clone();
2056 served.as_object_mut().unwrap().insert(
2057 "$id".into(),
2058 serde_json::Value::String("https://monarch.meta.com/schemas/v1/node_payload".into()),
2059 );
2060
2061 // Strip $id — remainder must equal raw.
2062 let mut stripped = served;
2063 stripped.as_object_mut().unwrap().remove("$id");
2064 assert_eq!(raw, stripped, "served schema differs from raw beyond $id");
2065 }
2066
2067 /// SC-2: error envelope schema matches checked-in snapshot.
2068 #[test]
2069 fn test_error_schema_snapshot() {
2070 use crate::mesh_admin::ApiErrorEnvelope;
2071
2072 let schema = schemars::schema_for!(ApiErrorEnvelope);
2073 let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
2074 let expected: serde_json::Value = strip_comment(
2075 serde_json::from_str(include_str!("testdata/error_schema.json"))
2076 .expect("error snapshot must be valid JSON"),
2077 );
2078 assert_eq!(
2079 actual, expected,
2080 "error schema changed — review and update snapshot if intentional"
2081 );
2082 }
2083
2084 /// SC-2: AdminInfo schema matches checked-in snapshot.
2085 #[test]
2086 fn test_admin_info_schema_snapshot() {
2087 use crate::mesh_admin::AdminInfo;
2088
2089 let schema = schemars::schema_for!(AdminInfo);
2090 let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
2091 let expected: serde_json::Value = strip_comment(
2092 serde_json::from_str(include_str!("testdata/admin_info_schema.json"))
2093 .expect("admin info snapshot must be valid JSON"),
2094 );
2095 assert_eq!(
2096 actual, expected,
2097 "AdminInfo schema changed — review and update snapshot if intentional"
2098 );
2099 }
2100
2101 /// SC-2: OpenAPI spec matches checked-in snapshot.
2102 #[test]
2103 fn test_openapi_spec_snapshot() {
2104 let actual = crate::mesh_admin::build_openapi_spec();
2105 let expected: serde_json::Value = strip_comment(
2106 serde_json::from_str(include_str!("testdata/openapi.json"))
2107 .expect("OpenAPI snapshot must be valid JSON"),
2108 );
2109 assert_eq!(
2110 actual, expected,
2111 "OpenAPI spec changed — review and update snapshot if intentional"
2112 );
2113 }
2114}