monarch_hyperactor/logging.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#![allow(unsafe_op_in_unsafe_fn)]
10
11use std::ops::Deref;
12use std::sync::Arc;
13use std::sync::atomic::AtomicUsize;
14use std::sync::atomic::Ordering;
15
16use anyhow::Result;
17use async_trait::async_trait;
18use hyperactor::Actor;
19use hyperactor::ActorEnvironment;
20use hyperactor::ActorHandle;
21use hyperactor::Context;
22use hyperactor::Endpoint as _;
23use hyperactor::HandleClient;
24use hyperactor::Handler;
25use hyperactor::Instance;
26use hyperactor::RefClient;
27use hyperactor::RemoteSpawn;
28use hyperactor::context;
29use hyperactor_mesh::ActorMesh;
30use hyperactor_mesh::actor_mesh::ActorMeshRef;
31use hyperactor_mesh::bootstrap::MESH_ENABLE_LOG_FORWARDING;
32use hyperactor_mesh::logging::LogClientActor;
33use hyperactor_mesh::logging::LogClientMessage;
34use hyperactor_mesh::logging::LogForwardActor;
35use hyperactor_mesh::logging::LogForwardMessage;
36use monarch_types::SerializablePyErr;
37use ndslice::View;
38use pyo3::Bound;
39use pyo3::prelude::*;
40use pyo3::types::PyModule;
41use pyo3::types::PyString;
42use serde::Deserialize;
43use serde::Serialize;
44use typeuri::Named;
45
46use crate::context::PyInstance;
47use crate::handle::PyHandle;
48use crate::proc::PyActorAddr;
49use crate::proc_mesh::PyProcMesh;
50use crate::pytokio::PyPythonTask;
51use crate::runtime::GilSite;
52use crate::runtime::monarch_with_gil;
53
54#[derive(
55 Debug,
56 Clone,
57 Serialize,
58 Deserialize,
59 Named,
60 Handler,
61 HandleClient,
62 RefClient
63)]
64pub enum LoggerRuntimeMessage {
65 SetLogging { level: u8 },
66}
67
68/// Simple Rust actor that invokes python logger APIs. It needs a python runtime.
69#[derive(Debug)]
70#[hyperactor::export(handlers = [LoggerRuntimeMessage])]
71#[hyperactor::spawnable]
72pub struct LoggerRuntimeActor {
73 logger: Arc<Py<PyAny>>,
74}
75
76impl LoggerRuntimeActor {
77 fn get_logger(py: Python) -> PyResult<Py<PyAny>> {
78 // Import the Python AutoReloader class
79 let logging_module = py.import("logging")?;
80 let logger = logging_module.call_method0("getLogger")?;
81
82 Ok(logger.into())
83 }
84
85 fn set_logger_level(py: Python, logger: &Py<PyAny>, level: u8) -> PyResult<()> {
86 let logger = logger.bind(py);
87 logger.call_method1("setLevel", (level,))?;
88 Ok(())
89 }
90}
91#[async_trait]
92impl Actor for LoggerRuntimeActor {
93 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
94 this.set_system();
95 Ok(())
96 }
97}
98
99#[async_trait]
100impl RemoteSpawn for LoggerRuntimeActor {
101 type Params = ();
102
103 async fn new(_: (), _environment: &ActorEnvironment) -> Result<Self, anyhow::Error> {
104 let logger = monarch_with_gil(GilSite::Logging, |py| {
105 Self::get_logger(py).map_err(SerializablePyErr::from_fn(py))
106 })
107 .await?;
108 Ok(Self {
109 logger: Arc::new(logger),
110 })
111 }
112}
113
114#[async_trait]
115#[hyperactor::handle(LoggerRuntimeMessage)]
116impl LoggerRuntimeMessageHandler for LoggerRuntimeActor {
117 async fn set_logging(&mut self, _cx: &Context<Self>, level: u8) -> Result<(), anyhow::Error> {
118 let logger: Arc<_> = self.logger.clone();
119 monarch_with_gil(GilSite::Logging, |py| {
120 Self::set_logger_level(py, logger.as_ref(), level)
121 .map_err(SerializablePyErr::from_fn(py))
122 })
123 .await?;
124 Ok(())
125 }
126}
127
128/// `LoggingMeshClient` is the Python-facing handle for distributed
129/// logging over a `ProcMesh`.
130///
131/// Calling `spawn(...)` builds three pieces of logging infra:
132///
133/// - `client_actor`: a single `LogClientActor` running in the
134/// *local* process. It aggregates forwarded stdout/stderr,
135/// batches it, and coordinates sync flush barriers.
136///
137/// - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
138/// with one actor per remote proc. Each `LogForwardActor` sits in
139/// that proc and forwards its stdout/stderr back to the client.
140/// This mesh only exists if `MESH_ENABLE_LOG_FORWARDING` was `true`
141/// at startup; otherwise it's `None` and we never spawn any
142/// forwarders.
143///
144/// - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
145/// actor per remote proc. Each `LoggerRuntimeActor` controls that
146/// proc's Python logging runtime (log level, handlers, etc.).
147/// This mesh is always created, even if forwarding is disabled.
148///
149/// The Python object you get back holds references to all of this so
150/// that you can:
151/// - toggle streaming vs "stay quiet" (`set_mode(...)`),
152/// - adjust the per-proc Python log level (`set_mode(...)`),
153/// - force a sync flush of forwarded output and wait for completion
154/// (`flush(...)`).
155///
156/// Drop semantics:
157/// Dropping the Python handle runs `Drop` on this Rust struct,
158/// which drains/stops the local `LogClientActor` but does *not*
159/// synchronously tear down the per-proc meshes. The remote
160/// `LogForwardActor` / `LoggerRuntimeActor` instances keep running
161/// until the remote procs themselves are shut down (e.g. via
162/// `host_mesh.shutdown(...)` in tests).
163#[pyclass(
164 frozen,
165 name = "LoggingMeshClient",
166 module = "monarch._rust_bindings.monarch_hyperactor.logging"
167)]
168pub struct LoggingMeshClient {
169 // Per-proc LogForwardActor mesh (optional). When enabled, each
170 // remote proc forwards its stdout/stderr back to the client. This
171 // actor does not interact with the embedded Python runtime.
172 forwarder_mesh: Option<ActorMesh<LogForwardActor>>,
173
174 // Per-proc LoggerRuntimeActor mesh. One LoggerRuntimeActor runs
175 // on every proc in the mesh and is responsible for driving that
176 // proc's Python logging configuration (log level, handlers,
177 // etc.).
178 //
179 // `set_mode(..)` always broadcasts the requested log level to
180 // this mesh, regardless of whether stdout/stderr forwarding is
181 // enabled.
182 //
183 // Even on a proc that isn't meaningfully running Python code, we
184 // still spawn LoggerRuntimeActor and it will still apply the new
185 // level to that proc's Python logger. In that case, updating the
186 // level may have no visible effect simply because nothing on that
187 // proc ever emits logs through Python's `logging` module.
188 logger_mesh: ActorMesh<LoggerRuntimeActor>,
189
190 // Client-side LogClientActor. Lives in the client process;
191 // receives forwarded output, aggregates and buffers it, and
192 // coordinates sync flush barriers.
193 client_actor: ActorHandle<LogClientActor>,
194}
195
196impl LoggingMeshClient {
197 /// Drive a synchronous "drain all logs now" barrier across the
198 /// mesh.
199 ///
200 /// Protocol:
201 /// 1. Tell the local `LogClientActor` we're starting a sync
202 /// flush. We give it:
203 /// - how many procs we expect to hear from
204 /// (`expected_procs`),
205 /// - a `reply` port it will use to signal completion,
206 /// - a `version` port it will use to hand us a flush version
207 /// token. After this send, the client_actor is now in "sync
208 /// flush vN" mode.
209 ///
210 /// 2. Wait for that version token from the client. This tells
211 /// us which flush epoch we're coordinating
212 /// (`version_rx.recv()`).
213 ///
214 /// 3. Broadcast `ForceSyncFlush { version }` to every
215 /// `LogForwardActor` in the `forwarder_mesh`. Each forwarder
216 /// tells its proc-local logger/forwarding loop: "flush
217 /// everything you have for this version now, then report
218 /// back."
219 ///
220 /// 4. Wait on `reply_rx`. The `LogClientActor` only replies
221 /// once it has:
222 /// - received the per-proc sync points for this version from
223 /// all forwarders,
224 /// - emitted/forwarded their buffered output,
225 /// - and finished flushing its own buffers.
226 ///
227 /// When this returns `Ok(())`, all stdout/stderr that existed at
228 /// the moment we kicked off the flush has been forwarded to the
229 /// client and drained. This is used by
230 /// `LoggingMeshClient.flush()`.
231 async fn flush_internal(
232 cx: &impl context::Actor,
233 client_actor: ActorHandle<LogClientActor>,
234 forwarder_mesh: ActorMeshRef<LogForwardActor>,
235 ) -> Result<(), anyhow::Error> {
236 let (reply_tx, reply_rx) = cx.instance().open_once_port::<()>();
237 let (version_tx, version_rx) = cx.instance().open_once_port::<u64>();
238
239 // First initialize a sync flush.
240 client_actor.post(
241 cx,
242 LogClientMessage::StartSyncFlush {
243 expected_procs: forwarder_mesh.region().num_ranks(),
244 reply: reply_tx.bind(),
245 version: version_tx.bind(),
246 },
247 );
248
249 let version = version_rx.recv().await?;
250
251 // Then ask all the flushers to ask the log forwarders to sync
252 // flush
253 forwarder_mesh.cast(cx, LogForwardMessage::ForceSyncFlush { version })?;
254
255 // Finally the forwarder will send sync point back to the
256 // client, flush, and return.
257 reply_rx.recv().await?;
258
259 Ok(())
260 }
261}
262
263#[pymethods]
264impl LoggingMeshClient {
265 /// Initialize logging for a `ProcMesh` and return a
266 /// `LoggingMeshClient`.
267 ///
268 /// This wires up three pieces of logging infrastructure:
269 ///
270 /// 1. A single `LogClientActor` in the *client* process. This
271 /// actor receives forwarded stdout/stderr, buffers and
272 /// aggregates it, and coordinates sync flush barriers.
273 ///
274 /// 2. (Optional) A `LogForwardActor` on every remote proc in the
275 /// mesh. These forwarders read that proc's stdout/stderr and
276 /// stream it back to the client. We only spawn this mesh if
277 /// `MESH_ENABLE_LOG_FORWARDING` was `true` in the config. If
278 /// forwarding is disabled at startup, we do not spawn these
279 /// actors and `forwarder_mesh` will be `None`.
280 ///
281 /// 3. A `LoggerRuntimeActor` on every remote proc in the mesh.
282 /// This actor controls the Python logging runtime (log level,
283 /// handlers, etc.) in that process. This is always spawned,
284 /// even if log forwarding is disabled.
285 ///
286 /// The returned `LoggingMeshClient` holds handles to those
287 /// actors. Later, `set_mode(...)` can adjust per-proc log level
288 /// and (if forwarding was enabled) toggle whether remote output
289 /// is actually streamed back to the client. If forwarding was
290 /// disabled by config, requests to enable streaming will fail.
291 #[staticmethod]
292 fn spawn(instance: &PyInstance, proc_mesh: &PyProcMesh) -> PyResult<PyPythonTask> {
293 let proc_mesh = proc_mesh.mesh_ref()?;
294 let instance = instance.clone();
295
296 PyPythonTask::new(async move {
297 // 1. Spawn the client-side coordinator actor (lives in
298 // the caller's process).
299 static LOG_CLIENT_COUNTER: AtomicUsize = AtomicUsize::new(0);
300 let id = LOG_CLIENT_COUNTER.fetch_add(1, Ordering::Relaxed);
301 let name = if id == 0 {
302 "log_client".to_string()
303 } else {
304 format!("log_client_{}", id)
305 };
306 let client_actor: ActorHandle<LogClientActor> = instance
307 .proc()
308 .spawn_with_label(&name, LogClientActor::default());
309 let client_actor_ref = client_actor.bind();
310
311 // Read config to decide if we stand up per-proc
312 // stdout/stderr forwarding.
313 let forwarding_enabled = hyperactor_config::global::get(MESH_ENABLE_LOG_FORWARDING);
314
315 // 2. Optionally spawn per-proc `LogForwardActor` mesh
316 // (stdout/stderr forwarders).
317 let forwarder_mesh = if forwarding_enabled {
318 // Spawn a `LogFwdActor` on every proc.
319 let mesh = proc_mesh
320 .spawn(instance.deref(), "log_forwarder", &client_actor_ref)
321 .await
322 .map_err(anyhow::Error::from)?;
323
324 Some(mesh)
325 } else {
326 None
327 };
328
329 // 3. Always spawn a `LoggerRuntimeActor` on every proc.
330 let logger_mesh = proc_mesh
331 .spawn(instance.deref(), "logger", &())
332 .await
333 .map_err(anyhow::Error::from)?;
334
335 Ok(Self {
336 forwarder_mesh,
337 logger_mesh,
338 client_actor,
339 })
340 })
341 }
342
343 /// Update logging behavior for this mesh.
344 ///
345 /// `stream_to_client` controls whether remote procs actively
346 /// stream their stdout/stderr back to the client process.
347 ///
348 /// - If log forwarding was enabled at startup, `forwarder_mesh`
349 /// is `Some` and we propagate this flag to every per-proc
350 /// `LogForwardActor`.
351 /// - If log forwarding was disabled at startup, `forwarder_mesh`
352 /// is `None`.
353 /// In that case:
354 /// * requesting `stream_to_client = false` is a no-op
355 /// (accepted),
356 /// * requesting `stream_to_client = true` is rejected,
357 /// because we did not spawn forwarders and we don't
358 /// dynamically create them later.
359 ///
360 /// `aggregate_window_sec` configures how the client-side
361 /// `LogClientActor` batches forwarded output. It is only
362 /// meaningful when streaming is enabled. Calling this with
363 /// `Some(..)` while `stream_to_client == false` is invalid and
364 /// returns an error.
365 ///
366 /// `level` is the desired Python logging level. We always
367 /// broadcast this to the per-proc `LoggerRuntimeActor` mesh so
368 /// each remote process can update its own Python logger
369 /// configuration, regardless of whether stdout/stderr forwarding
370 /// is active.
371 fn set_mode(
372 &self,
373 instance: &PyInstance,
374 stream_to_client: bool,
375 aggregate_window_sec: Option<u64>,
376 level: u8,
377 ) -> PyResult<()> {
378 // We can't ask for an aggregation window if we're not
379 // streaming.
380 if aggregate_window_sec.is_some() && !stream_to_client {
381 return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
382 "cannot set aggregate window without streaming to client".to_string(),
383 ));
384 }
385
386 // Handle the forwarder side (stdout/stderr streaming back to
387 // client).
388 match (&self.forwarder_mesh, stream_to_client) {
389 // Forwarders exist (config enabled at startup). We can
390 // toggle live.
391 (Some(fwd_mesh), _) => {
392 fwd_mesh
393 .cast(
394 instance.deref(),
395 LogForwardMessage::SetMode { stream_to_client },
396 )
397 .map_err(|e| {
398 PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string())
399 })?;
400 }
401
402 // Forwarders were never spawned (global forwarding
403 // disabled) and the caller is asking NOT to stream.
404 // That's effectively a no-op so we silently accept.
405 (None, false) => {
406 // Nothing to do.
407 }
408
409 // Forwarders were never spawned, but caller is asking to
410 // stream. We can't satisfy this request without
411 // re-spawning infra, which we deliberately don't do at
412 // runtime.
413 (None, true) => {
414 // return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
415 // "log forwarding disabled by config at startup; cannot enable streaming_to_client",
416 // ));
417 }
418 }
419
420 // Always update the per-proc Python logging level.
421 self.logger_mesh
422 .cast_with_return_undeliverable(
423 instance.deref(),
424 &Default::default(),
425 LoggerRuntimeMessage::SetLogging { level },
426 false,
427 )
428 .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
429
430 // Always update the client actor's aggregation window.
431 self.client_actor.post(
432 instance.deref(),
433 LogClientMessage::SetAggregate {
434 aggregate_window_sec,
435 },
436 );
437
438 Ok(())
439 }
440
441 /// Start a sync flush of remote stdout/stderr back to the client
442 /// and return a `Handle` that completes when the flush has
443 /// finished.
444 ///
445 /// The flush starts before this returns. Dropping the `Handle`
446 /// does not cancel it.
447 ///
448 /// If log forwarding was disabled at startup (so we never spawned
449 /// any `LogForwardActor`s), this becomes a no-op success: there's
450 /// nothing to flush from remote procs in that mode, and we don't
451 /// try to manufacture it dynamically.
452 fn flush(&self, instance: &PyInstance) -> PyResult<PyHandle> {
453 let forwarder_mesh_opt = self
454 .forwarder_mesh
455 .as_ref()
456 .map(|mesh| mesh.deref().clone());
457 let client_actor = self.client_actor.clone();
458 let instance = instance.clone();
459
460 Ok(PyHandle::spawn(async move {
461 // If there's no forwarder mesh (forwarding disabled by
462 // config), we just succeed immediately.
463 let Some(forwarder_mesh) = forwarder_mesh_opt else {
464 return Ok(());
465 };
466
467 Self::flush_internal(instance.deref(), client_actor, forwarder_mesh)
468 .await
469 .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
470 }))
471 }
472}
473
474// NOTE ON LIFECYCLE / CLEANUP
475//
476// `LoggingMeshClient` is a thin owner for three pieces of logging
477// infra:
478//
479// - `client_actor`: a single `LogClientActor` in the *local*
480// process.
481// - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
482// with one actor per remote proc in the `ProcMesh`, responsible for
483// forwarding that proc's stdout/stderr back to the client.
484// - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
485// actor per remote proc, responsible for driving that proc's Python
486// logging configuration.
487//
488// The Python-facing handle we hand back to callers is a
489// `Py<LoggingMeshClient>`. When that handle is dropped (or goes out
490// of scope in a test), PyO3 will run `Drop` for `LoggingMeshClient`.
491//
492// Important:
493//
494// - In `Drop` we *only* call `drain_and_stop()` on the local
495// `LogClientActor`. This asks the client-side aggregator to
496// flush/stop so we don't leave a local task running.
497// - We do NOT synchronously tear down the per-proc meshes here.
498// Dropping `forwarder_mesh` / `logger_mesh` just releases our
499// handles; the actual `LogForwardActor` / `LoggerRuntimeActor`
500// instances keep running on the remote procs until those procs are
501// shut down.
502//
503// This is fine in tests because we always shut the world down
504// afterward via `host_mesh.shutdown(&instance)`, which tears down the
505// spawned procs and all actors running in them. In other words:
506//
507// drop(Py<LoggingMeshClient>)
508// → stops the local `LogClientActor`, drops mesh handles
509// host_mesh.shutdown(...)
510// → kills the remote procs, which takes out the per-proc actors
511//
512// If you reuse this type outside tests, keep in mind that simply
513// dropping `LoggingMeshClient` does *not* on its own tear down the
514// remote logging actors; it only stops the local client actor.
515impl Drop for LoggingMeshClient {
516 fn drop(&mut self) {
517 // Use catch_unwind to guard against panics during interpreter shutdown.
518 // During Python teardown, the tokio runtime or channels may already be
519 // deallocated, and attempting to drain could cause a segfault.
520 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
521 match self.client_actor.drain_and_stop("logging client shutdown") {
522 Ok(_) => {}
523 Err(e) => {
524 // it is ok as during shutdown, the channel might already be closed
525 tracing::debug!("error draining logging client actor during shutdown: {}", e);
526 }
527 }
528 }));
529 }
530}
531
532/// Turns a python exception into a string with a traceback. If the traceback doesn't
533/// exist or can't be formatted, returns just the exception message.
534pub(crate) fn format_traceback(py: Python<'_>, err: &PyErr) -> String {
535 let traceback = err.traceback(py);
536 if traceback.is_some() {
537 let inner = || -> PyResult<String> {
538 let formatted = py
539 .import("traceback")?
540 .call_method1("format_exception", (err.clone_ref(py),))?;
541 Ok(PyString::new(py, "")
542 .call_method1("join", (formatted,))?
543 .to_string())
544 };
545 match inner() {
546 Ok(s) => s,
547 Err(e) => format!("{}: no traceback {}", err, e),
548 }
549 } else {
550 err.to_string()
551 }
552}
553
554#[pyfunction]
555fn log_endpoint_exception(
556 py: Python<'_>,
557 e: Py<PyAny>,
558 endpoint: Py<PyAny>,
559 actor_id: PyActorAddr,
560) {
561 let pyerr = PyErr::from_value(e.into_bound(py));
562 let exception_str = format_traceback(py, &pyerr);
563 let endpoint = endpoint.into_bound(py).to_string();
564 tracing::info!(
565 actor_id = actor_id.inner.to_string(),
566 %endpoint,
567 "exception occurred in endpoint: {}",
568 exception_str,
569 );
570}
571
572/// Register the Python-facing types for this module.
573///
574/// `pyo3` calls this when building `monarch._rust_bindings...`. We
575/// expose `LoggingMeshClient` so that Python can construct it and
576/// call its methods (`spawn`, `set_mode`, `flush`, ...).
577pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
578 module.add_class::<LoggingMeshClient>()?;
579 let log_endpoint_exception = wrap_pyfunction!(log_endpoint_exception, module.py())?;
580 log_endpoint_exception.setattr(
581 "__module__",
582 "monarch._rust_bindings.monarch_hyperactor.logging",
583 )?;
584 module.add_function(log_endpoint_exception)?;
585 Ok(())
586}
587
588#[cfg(test)]
589mod tests {
590 use std::time::Duration;
591
592 use anyhow::Result;
593 use hyperactor::Instance;
594 use hyperactor::channel::ChannelTransport;
595 use hyperactor::proc::Proc;
596 use hyperactor_mesh::ProcMesh;
597 use hyperactor_mesh::host_mesh::HostMesh;
598 use hyperactor_mesh::host_mesh::HostMeshShutdownGuard;
599 use hyperactor_mesh::logging::LogMessage;
600 use ndslice::Extent;
601 use tokio::time::timeout;
602
603 use super::*;
604 use crate::actor::PythonActor;
605 use crate::runtime::ensure_python;
606
607 const TEST_DEADLINE: Duration = Duration::from_secs(30);
608
609 /// The guard covers setup failures and panics. This helper gives ordinary
610 /// `Result` exits a bounded explicit shutdown.
611 async fn shutdown_after_test(
612 mut host_mesh: HostMeshShutdownGuard,
613 instance: &Instance<PythonActor>,
614 test_result: Result<()>,
615 ) -> Result<()> {
616 let shutdown_result = match timeout(TEST_DEADLINE, host_mesh.shutdown(instance)).await {
617 Ok(result) => result,
618 Err(_) => Err(anyhow::anyhow!("host shutdown exceeded {TEST_DEADLINE:?}")),
619 };
620
621 match (test_result, shutdown_result) {
622 (Ok(()), Ok(())) => Ok(()),
623 (Err(test_error), Ok(())) => Err(test_error),
624 (Ok(()), Err(shutdown_error)) => Err(shutdown_error),
625 (Err(test_error), Err(shutdown_error)) => Err(anyhow::anyhow!(
626 "test failed: {test_error:#}; host shutdown also failed: {shutdown_error:#}"
627 )),
628 }
629 }
630
631 /// Drive a logging-client task without the test-only conversion helper's
632 /// internal `expect`s, so an error remains available to the cleanup path.
633 async fn drive_logging_client(mut task: PyPythonTask) -> Result<Py<LoggingMeshClient>> {
634 let future = task.take_task()?;
635 let value = timeout(TEST_DEADLINE, future)
636 .await
637 .map_err(|_| anyhow::anyhow!("logging client spawn exceeded {TEST_DEADLINE:?}"))??;
638 Ok(monarch_with_gil(GilSite::Test, |py| {
639 value
640 .bind(py)
641 .extract::<Py<LoggingMeshClient>>()
642 .map_err(|error| pyo3::exceptions::PyTypeError::new_err(error.to_string()))
643 })
644 .await?)
645 }
646
647 fn log_client_ordinal(label: &str) -> Result<usize> {
648 if label == "log_client" {
649 return Ok(0);
650 }
651 let suffix = label
652 .strip_prefix("log_client_")
653 .ok_or_else(|| anyhow::anyhow!("unexpected logging client label {label:?}"))?;
654 Ok(suffix.parse()?)
655 }
656
657 /// Read the current sync-flush version without leaving a flush in progress.
658 ///
659 /// `StartSyncFlush` increments the version, so this probe changes the value it
660 /// observes. Posting the matching acknowledgement and awaiting the reply closes
661 /// that operation before another probe or production flush begins.
662 async fn completed_sync_flush_probe(
663 instance: &Instance<PythonActor>,
664 client_actor: &ActorHandle<LogClientActor>,
665 ) -> Result<u64> {
666 let (reply_tx, reply_rx) = instance.open_once_port::<()>();
667 let (version_tx, version_rx) = instance.open_once_port::<u64>();
668
669 client_actor.try_post(
670 instance,
671 LogClientMessage::StartSyncFlush {
672 expected_procs: 1,
673 reply: reply_tx.bind(),
674 version: version_tx.bind(),
675 },
676 )?;
677 let version = timeout(TEST_DEADLINE, version_rx.recv())
678 .await
679 .map_err(|_| {
680 anyhow::anyhow!("sync-flush version probe exceeded {TEST_DEADLINE:?}")
681 })??;
682
683 client_actor.try_post(
684 instance,
685 LogMessage::Flush {
686 sync_version: Some(version),
687 },
688 )?;
689 timeout(TEST_DEADLINE, reply_rx.recv())
690 .await
691 .map_err(|_| anyhow::anyhow!("sync-flush reply probe exceeded {TEST_DEADLINE:?}"))??;
692
693 Ok(version)
694 }
695
696 /// How long a probe waits for its completion before treating the attempt
697 /// as inconclusive.
698 const PROBE_REPLY_BOUND: Duration = Duration::from_secs(1);
699
700 /// Start one sync-flush probe and return its version and whether it
701 /// completed.
702 ///
703 /// The version receive runs until `deadline` and must succeed: the client
704 /// has incremented its version for this probe, so the caller must count it.
705 /// A later `StartSyncFlush` can replace this probe's reply port, so the
706 /// completion wait is bounded by `PROBE_REPLY_BOUND` and its timeout is
707 /// reported as `false`, not as an error.
708 async fn sync_flush_probe(
709 instance: &Instance<PythonActor>,
710 client_actor: &ActorHandle<LogClientActor>,
711 deadline: tokio::time::Instant,
712 ) -> Result<(u64, bool)> {
713 let (reply_tx, reply_rx) = instance.open_once_port::<()>();
714 let (version_tx, version_rx) = instance.open_once_port::<u64>();
715
716 client_actor.try_post(
717 instance,
718 LogClientMessage::StartSyncFlush {
719 expected_procs: 1,
720 reply: reply_tx.bind(),
721 version: version_tx.bind(),
722 },
723 )?;
724 let version = tokio::time::timeout_at(deadline, version_rx.recv())
725 .await
726 .map_err(|_| anyhow::anyhow!("sync-flush probe version exceeded the deadline"))??;
727
728 client_actor.try_post(
729 instance,
730 LogMessage::Flush {
731 sync_version: Some(version),
732 },
733 )?;
734 let completed = matches!(
735 timeout(PROBE_REPLY_BOUND, reply_rx.recv()).await,
736 Ok(Ok(()))
737 );
738
739 Ok((version, completed))
740 }
741
742 /// Bring up the actor topology exercised by the logging-client tests.
743 async fn logging_test_world()
744 -> Result<(Proc, Instance<PythonActor>, HostMeshShutdownGuard, ProcMesh)> {
745 ensure_python();
746
747 let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string())?;
748
749 let ai = proc.actor_instance("client")?;
750 let instance = ai.instance;
751
752 let host_mesh = HostMesh::local_in_process().await?.shutdown_guard();
753
754 let proc_mesh = host_mesh
755 .spawn(&instance, "p0", Extent::unity(), None, None)
756 .await?;
757
758 Ok((proc, instance, host_mesh, proc_mesh))
759 }
760
761 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
762 #[tokio::test]
763 async fn spawn_respects_forwarding_flag() {
764 let (_, instance, mut host_mesh, proc_mesh) =
765 logging_test_world().await.expect("world failed");
766
767 let py_instance = PyInstance::from(&instance);
768 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
769 let lock = hyperactor_config::global::lock();
770
771 // Case 1: forwarding disabled => `forwarder_mesh` should be `None`.
772 {
773 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
774
775 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
776 .expect("spawn PyPythonTask (forwarding disabled)");
777
778 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
779 .await
780 .expect("spawn failed (forwarding disabled)");
781
782 monarch_with_gil(GilSite::Test, |py| {
783 let client_ref = client_py.borrow(py);
784 assert!(
785 client_ref.forwarder_mesh.is_none(),
786 "forwarder_mesh should be None when forwarding disabled"
787 );
788 })
789 .await;
790
791 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
792 }
793
794 // Case 2: forwarding enabled => `forwarder_mesh` should be `Some`.
795 {
796 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
797
798 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
799 .expect("spawn PyPythonTask (forwarding enabled)");
800
801 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
802 .await
803 .expect("spawn failed (forwarding enabled)");
804
805 monarch_with_gil(GilSite::Test, |py| {
806 let client_ref = client_py.borrow(py);
807 assert!(
808 client_ref.forwarder_mesh.is_some(),
809 "forwarder_mesh should be Some(..) when forwarding is enabled"
810 );
811 })
812 .await;
813
814 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
815 }
816
817 host_mesh.shutdown(&instance).await.expect("host shutdown");
818 }
819
820 /// Constructing the raw spawn task validates and captures its inputs but does
821 /// not spawn the local logging actor. The monotonic label counter is durable
822 /// even if a wrongly created actor stops before the assertion can inspect it.
823 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
824 #[tokio::test]
825 async fn discarded_spawn_task_creates_no_log_client_actor() -> Result<()> {
826 let (_proc, instance, host_mesh, proc_mesh) = timeout(TEST_DEADLINE, logging_test_world())
827 .await
828 .map_err(|_| anyhow::anyhow!("test world setup exceeded {TEST_DEADLINE:?}"))??;
829
830 let test_result = async {
831 let py_instance = PyInstance::from(&instance);
832 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
833 let lock = hyperactor_config::global::lock();
834 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
835
836 let baseline_py = drive_logging_client(LoggingMeshClient::spawn(
837 &py_instance,
838 &py_proc_mesh,
839 )?)
840 .await?;
841 let baseline_label = monarch_with_gil(GilSite::Test, |py| {
842 baseline_py
843 .borrow(py)
844 .client_actor
845 .actor_addr()
846 .id()
847 .label()
848 .map(|label| label.as_str().to_owned())
849 })
850 .await
851 .ok_or_else(|| anyhow::anyhow!("baseline logging client has no label"))?;
852 let baseline_ordinal = log_client_ordinal(&baseline_label)?;
853 drop(baseline_py);
854
855 let discarded = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)?;
856 drop(discarded);
857
858 let control_py = drive_logging_client(LoggingMeshClient::spawn(
859 &py_instance,
860 &py_proc_mesh,
861 )?)
862 .await?;
863 let control_label = monarch_with_gil(GilSite::Test, |py| {
864 control_py
865 .borrow(py)
866 .client_actor
867 .actor_addr()
868 .id()
869 .label()
870 .map(|label| label.as_str().to_owned())
871 })
872 .await
873 .ok_or_else(|| anyhow::anyhow!("control logging client has no label"))?;
874 let control_ordinal = log_client_ordinal(&control_label)?;
875 anyhow::ensure!(
876 control_ordinal == baseline_ordinal + 1,
877 "dropping an undriven spawn task must not consume a LogClientActor id: baseline {baseline_label}, control {control_label}"
878 );
879
880 drop(control_py);
881 Ok(())
882 }
883 .await;
884
885 shutdown_after_test(host_mesh, &instance, test_result).await
886 }
887
888 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
889 #[tokio::test]
890 async fn set_mode_behaviors() {
891 let (_proc, instance, mut host_mesh, proc_mesh) =
892 logging_test_world().await.expect("world failed");
893
894 let py_instance = PyInstance::from(&instance);
895 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
896 let lock = hyperactor_config::global::lock();
897
898 // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
899 {
900 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
901
902 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
903 .expect("spawn PyPythonTask (forwarding disabled)");
904
905 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
906 .await
907 .expect("spawn failed (forwarding disabled)");
908
909 monarch_with_gil(GilSite::Test, |py| {
910 let client_ref = client_py.borrow(py);
911
912 // (a) stream_to_client = false, no aggregate window
913 // -> OK
914 let res = client_ref.set_mode(&py_instance, false, None, 10);
915 assert!(res.is_ok(), "expected Ok(..), got {res:?}");
916
917 // (b) stream_to_client = false,
918 // aggregate_window_sec.is_some() -> Err = Some(..) ->
919 // Err
920 let res = client_ref.set_mode(&py_instance, false, Some(1), 10);
921 assert!(
922 res.is_err(),
923 "expected Err(..) for window without streaming"
924 );
925 if let Err(e) = res {
926 let msg = e.to_string();
927 assert!(
928 msg.contains("cannot set aggregate window without streaming to client"),
929 "unexpected err for aggregate_window without streaming: {msg}"
930 );
931 }
932
933 /*
934 // Update (SF: 2025, 11, 13): We now ignore stream to client requests if
935 // log forwarding is enabled.
936 // (c) stream_to_client = true when forwarding was
937 // never spawned -> Err
938 let res = client_ref.set_mode(&py_instance, true, None, 10);
939 assert!(
940 res.is_err(),
941 "expected Err(..) when enabling streaming but no forwarders"
942 );
943 if let Err(e) = res {
944 let msg = e.to_string();
945 assert!(
946 msg.contains("log forwarding disabled by config at startup"),
947 "unexpected err when enabling streaming with no forwarders: {msg}"
948 );
949 }
950 */
951 })
952 .await;
953
954 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
955 }
956
957 // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
958 {
959 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
960
961 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
962 .expect("spawn PyPythonTask (forwarding enabled)");
963
964 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
965 .await
966 .expect("spawn failed (forwarding enabled)");
967
968 monarch_with_gil(GilSite::Test, |py| {
969 let client_ref = client_py.borrow(py);
970
971 // (d) stream_to_client = true, aggregate_window_sec =
972 // Some(..) -> OK now that we *do* have forwarders,
973 // enabling streaming should succeed.
974 let res = client_ref.set_mode(&py_instance, true, Some(2), 20);
975 assert!(
976 res.is_ok(),
977 "expected Ok(..) enabling streaming w/ window: {res:?}"
978 );
979
980 // (e) aggregate_window_sec = Some(..) but
981 // stream_to_client = false -> still Err (this
982 // rule doesn't care about forwarding being
983 // enabled or not).
984 let res = client_ref.set_mode(&py_instance, false, Some(2), 20);
985 assert!(
986 res.is_err(),
987 "expected Err(..) for window without streaming even w/ forwarders"
988 );
989 if let Err(e) = res {
990 let msg = e.to_string();
991 assert!(
992 msg.contains("cannot set aggregate window without streaming to client"),
993 "unexpected err when setting window but disabling streaming: {msg}"
994 );
995 }
996 })
997 .await;
998
999 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
1000 }
1001
1002 host_mesh.shutdown(&instance).await.expect("host shutdown");
1003 }
1004
1005 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
1006 #[tokio::test]
1007 async fn flush_behaviors() {
1008 let (_proc, instance, mut host_mesh, proc_mesh) =
1009 logging_test_world().await.expect("world failed");
1010
1011 let py_instance = PyInstance::from(&instance);
1012 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
1013 let lock = hyperactor_config::global::lock();
1014
1015 // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
1016 {
1017 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
1018
1019 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
1020 .expect("spawn PyPythonTask (forwarding disabled)");
1021
1022 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
1023 .await
1024 .expect("spawn failed (forwarding disabled)");
1025
1026 // flush() returns a Handle for an already-running flush.
1027 let flush_handle = monarch_with_gil(GilSite::Test, |py| {
1028 let client_ref = client_py.borrow(py);
1029 client_ref
1030 .flush(&py_instance)
1031 .expect("flush() Handle (forwarding disabled)")
1032 })
1033 .await;
1034
1035 // Observe the Handle's completion outside the GIL.
1036 timeout(TEST_DEADLINE, flush_handle.wait_completion())
1037 .await
1038 .expect("flush exceeded the deadline (forwarding disabled)")
1039 .expect("flush failed (forwarding disabled)");
1040
1041 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
1042 }
1043
1044 // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
1045 {
1046 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
1047
1048 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
1049 .expect("spawn PyPythonTask (forwarding enabled)");
1050
1051 let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
1052 .await
1053 .expect("spawn failed (forwarding enabled)");
1054
1055 // Call flush() to exercise the barrier path.
1056 let flush_handle = monarch_with_gil(GilSite::Test, |py| {
1057 client_py
1058 .borrow(py)
1059 .flush(&py_instance)
1060 .expect("flush() Handle (forwarding enabled)")
1061 })
1062 .await;
1063
1064 // Observe the Handle's completion outside the GIL.
1065 timeout(TEST_DEADLINE, flush_handle.wait_completion())
1066 .await
1067 .expect("flush exceeded the deadline (forwarding enabled)")
1068 .expect("flush failed (forwarding enabled)");
1069
1070 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
1071 }
1072
1073 host_mesh.shutdown(&instance).await.expect("host shutdown");
1074 }
1075
1076 /// A flush whose only Handle is dropped still enters the native barrier.
1077 ///
1078 /// Every probe's `StartSyncFlush` increments the client's version. After a
1079 /// completed baseline probe at `baseline`, a completed probe at `baseline +
1080 /// probes` means the dropped flush has not started yet, and `baseline +
1081 /// probes + 1` means it has started exactly once. Its start may land before,
1082 /// between or after probes, so the loop accepts any order. A probe whose
1083 /// completion times out was replaced by a later start and is only counted.
1084 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
1085 #[tokio::test]
1086 async fn dropped_flush_handle_enters_the_sync_flush_barrier() -> Result<()> {
1087 let (_, instance, host_mesh, proc_mesh) = timeout(TEST_DEADLINE, logging_test_world())
1088 .await
1089 .map_err(|_| anyhow::anyhow!("test world setup exceeded {TEST_DEADLINE:?}"))??;
1090
1091 let test_result = async {
1092 let py_instance = PyInstance::from(&instance);
1093 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
1094 // Held for the whole test, so no other test's logging operation can
1095 // reach this client.
1096 let lock = hyperactor_config::global::lock();
1097 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
1098
1099 let client_py = drive_logging_client(LoggingMeshClient::spawn(
1100 &py_instance,
1101 &py_proc_mesh,
1102 )?)
1103 .await?;
1104 let (forwarding_enabled, client_actor) =
1105 monarch_with_gil(GilSite::Test, |py| {
1106 let client = client_py.borrow(py);
1107 (client.forwarder_mesh.is_some(), client.client_actor.clone())
1108 })
1109 .await;
1110 anyhow::ensure!(
1111 forwarding_enabled,
1112 "the flush witness requires a real forwarding mesh"
1113 );
1114
1115 let baseline = completed_sync_flush_probe(&instance, &client_actor).await?;
1116
1117 let dropped = monarch_with_gil(GilSite::Test, |py| {
1118 client_py.borrow(py).flush(&py_instance)
1119 })
1120 .await?;
1121 drop(dropped);
1122
1123 let deadline = tokio::time::Instant::now() + TEST_DEADLINE;
1124 let mut probes = 0u64;
1125 loop {
1126 anyhow::ensure!(
1127 tokio::time::Instant::now() < deadline,
1128 "the dropped flush never reached the barrier: baseline probe {baseline}, {probes} probes"
1129 );
1130 let (version, completed) =
1131 sync_flush_probe(&instance, &client_actor, deadline).await?;
1132 probes += 1;
1133 if !completed {
1134 continue;
1135 }
1136 if version == baseline + probes {
1137 continue;
1138 }
1139 anyhow::ensure!(
1140 version == baseline + probes + 1,
1141 "expected exactly one real flush: baseline probe {baseline}, {probes} probes, completed probe {version}"
1142 );
1143 break;
1144 }
1145
1146 drop(client_py);
1147 Ok(())
1148 }
1149 .await;
1150
1151 shutdown_after_test(host_mesh, &instance, test_result).await
1152 }
1153}