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