Skip to main content

khive_runtime/daemon/
dispatch_trait.rs

1use super::*;
2
3// ── dispatch trait ────────────────────────────────────────────────────────────
4
5/// Transport-agnostic dispatch interface for the daemon server.
6///
7/// The MCP crate implements this by dispatching through the shared request body
8/// while honoring [`DaemonRequestFrame::from_wire`] (so subhandler visibility is
9/// gated by request origin, not by transport); any future transport can do the
10/// same.
11#[cfg(unix)]
12#[async_trait]
13pub trait DaemonDispatch: Clone + Send + Sync + 'static {
14    /// Named retained resources or unknown inventory that prevents idle exit.
15    /// An implementor must explicitly account for its resources before retiring.
16    fn idle_retirement_blockers(&self) -> Vec<String> {
17        vec!["dispatcher_resource_inventory_unknown".to_owned()]
18    }
19
20    /// Describe syntax and loaded catalog membership without dispatching.
21    fn plan(&self, ops: &str) -> String;
22
23    /// Dispatch a verb-DSL request string and return the rendered result.
24    ///
25    /// `from_wire` carries the origin discriminator from
26    /// [`DaemonRequestFrame::from_wire`]: when `true`, the implementor enforces
27    /// verb visibility (rejects `Visibility::Subhandler` verbs); when `false`,
28    /// the request is from a trusted operator surface and subhandlers pass.
29    ///
30    /// `identity` is the per-request identity context threaded from the frame
31    /// (ADR-096): `Some(..)` when serving a request forwarded over the
32    /// daemon socket (built from `frame.namespace` / `frame.actor_id` /
33    /// `frame.visible_namespaces` by the connection handler), `None` for any
34    /// other dispatch path. Implementors should mint the storage/gate token from
35    /// `identity` when present and fall back to their own construction-baked
36    /// identity when absent, so pure local (non-daemon) dispatch is unchanged.
37    #[allow(clippy::too_many_arguments)]
38    async fn dispatch(
39        &self,
40        ops: String,
41        presentation: Option<String>,
42        presentation_per_op: Option<Vec<Option<String>>>,
43        format: Option<String>,
44        format_per_op: Option<Vec<Option<String>>>,
45        from_wire: bool,
46        identity: Option<RequestIdentity>,
47    ) -> Result<String, String>;
48
49    /// Read-deadline ceiling for one request. The default is the operator
50    /// ceiling; an implementor that understands the request may grant a
51    /// longer bounded allowance (a long poll's declared wait plus a margin).
52    fn request_read_timeout(&self, _ops: &str) -> std::time::Duration {
53        khive_storage::request_read_timeout_from_env()
54    }
55
56    /// Preserve structured dispatch errors without breaking string-only implementors.
57    #[allow(clippy::too_many_arguments)]
58    async fn dispatch_with_error_detail(
59        &self,
60        ops: String,
61        presentation: Option<String>,
62        presentation_per_op: Option<Vec<Option<String>>>,
63        format: Option<String>,
64        format_per_op: Option<Vec<Option<String>>>,
65        from_wire: bool,
66        identity: Option<RequestIdentity>,
67    ) -> Result<String, DaemonDispatchError> {
68        self.dispatch(
69            ops,
70            presentation,
71            presentation_per_op,
72            format,
73            format_per_op,
74            from_wire,
75            identity,
76        )
77        .await
78        .map_err(|message| DaemonDispatchError::new(message, None))
79    }
80
81    /// The startup gate for the events daemon this daemon supervises, if any
82    /// ([`crate::events_split::EventsReadiness`]). A request that dispatches
83    /// verbs waits at it before dispatch and is refused, with nothing
84    /// committed, when it does not open; probe, plan, metrics and handover
85    /// frames never wait. `None`, the default, dispatches without waiting.
86    fn events_readiness(&self) -> Option<crate::events_split::EventsReadiness> {
87        None
88    }
89
90    /// Warm every pack's in-memory state (ANN indexes, etc.).
91    async fn warm_all(&self);
92
93    /// The namespace this dispatcher was configured for.
94    fn namespace(&self) -> &str;
95
96    /// Fingerprint of this dispatcher's resolved runtime config (packs, db
97    /// target, embedders). Used to reject forwarded requests from clients whose
98    /// config differs, so a restricted client cannot dispatch through a broader
99    /// daemon.
100    fn config_id(&self) -> &str;
101
102    /// Return the pool to use for background WAL checkpointing, if available.
103    ///
104    /// Implementors backed by a file-based SQLite database should return
105    /// `Some(pool_arc)`. In-memory or test dispatchers that have no pool
106    /// return `None` and the checkpoint task is not spawned.
107    ///
108    /// The default implementation returns `None`.
109    fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
110        None
111    }
112
113    /// File-backed backend pools beyond [`Self::pool_for_checkpoint`]'s pool
114    /// (ADR-091 Amendment 3): one checkpoint task is spawned per entry here,
115    /// in addition to the one spawned for the primary pool, so a
116    /// multi-backend deployment gets PASSIVE/TRUNCATE checkpointing and
117    /// sidecar enumeration on every file-backed backend it wired, not only
118    /// the main one.
119    ///
120    /// The default implementation returns an empty `Vec` — an implementor
121    /// with only one backend (or none) needs no override.
122    fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
123        Vec::new()
124    }
125
126    /// Return the audit `EventStore` the checkpoint task should append
127    /// ADR-094 lifecycle events (`CheckpointOutcomeRecorded`) to, if any.
128    ///
129    /// Mirrors [`Self::pool_for_checkpoint`]'s default-`None` shape: an
130    /// implementor with no configured event store (or no pool at all) simply
131    /// gets a checkpoint task that never appends events — the checkpoint
132    /// task itself remains fully functional either way.
133    ///
134    /// The default implementation returns `None`.
135    fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
136        None
137    }
138}
139
140#[cfg(unix)]
141pub(super) struct CheckpointTaskSpec {
142    pub(super) pool: Arc<ConnectionPool>,
143    pub(super) lifecycle_owner: Option<CheckpointLifecycleOwner>,
144    pub(super) is_main: bool,
145}
146
147/// Build checkpoint-task fan-out and designate one lifecycle owner.
148///
149/// The main checkpoint task owns lifecycle emission when it exists. If the
150/// main backend is in-memory and therefore has no checkpoint task, the first
151/// file-backed secondary owns emission instead. All remaining tasks are
152/// explicit non-owners.
153#[cfg(unix)]
154pub(super) fn checkpoint_task_specs(
155    main_pool: Option<Arc<ConnectionPool>>,
156    secondary_pools: Vec<Arc<ConnectionPool>>,
157    event_store: Option<Arc<dyn khive_storage::EventStore>>,
158    namespace: String,
159) -> Vec<CheckpointTaskSpec> {
160    let mut tasks = Vec::with_capacity(usize::from(main_pool.is_some()) + secondary_pools.len());
161    if let Some(pool) = main_pool {
162        tasks.push(CheckpointTaskSpec {
163            pool,
164            lifecycle_owner: None,
165            is_main: true,
166        });
167    }
168    tasks.extend(secondary_pools.into_iter().map(|pool| CheckpointTaskSpec {
169        pool,
170        lifecycle_owner: None,
171        is_main: false,
172    }));
173
174    if let (Some(task), Some(event_store)) = (tasks.first_mut(), event_store) {
175        task.lifecycle_owner = Some(CheckpointLifecycleOwner::new(event_store, namespace));
176    }
177    tasks
178}
179
180// ── tracked background tasks ─────────────────────────────────────────────────
181//
182// Pack handlers (e.g. memory.recall's ADR-081 serve-ledger append) fire
183// fire-and-forget `tokio::spawn`ed work off the response path so the caller
184// never waits on a cross-pack dispatch or a SQL write. Left untracked, that
185// work is invisible to `drain()`: a SIGTERM landing between the response
186// returning and the spawned task completing can abort it mid-flight with no
187// log and no row. `track_background_task` gives such spawns a process-wide
188// presence that `drain()` waits on, exactly like the `active` counter does
189// for in-flight connections: the caller still only pays for the spawn +
190// counter increment, never the task's own work.
191/// Set once by the boot path that takes the daemon role, and never cleared: a
192/// process that is not the warm daemon has no path to becoming one except exec.
193pub(super) static WARM_INDEX_HOST: std::sync::atomic::AtomicBool =
194    std::sync::atomic::AtomicBool::new(false);
195
196/// Declare this process the warm index host. Called by the serve path as soon as
197/// the daemon role is decided, before any runtime is built, so nothing warms
198/// under the wrong answer.
199pub fn mark_warm_index_host() {
200    WARM_INDEX_HOST.store(true, std::sync::atomic::Ordering::Release);
201}
202
203/// Whether this process is the warm index host.
204///
205/// Building an ANN index from the full corpus is minutes of CPU and hundreds of
206/// megabytes of segment rewrite, and it pays for itself only across a process
207/// that outlives the request. A short-lived client that does it pays the whole
208/// cost, discards the result at exit, and publishes a checkpoint that every
209/// other reader on the root must then re-read. Consumers use this to decide
210/// whether to build or to serve degraded and let the daemon build.
211pub fn is_warm_index_host() -> bool {
212    WARM_INDEX_HOST.load(std::sync::atomic::Ordering::Acquire)
213}
214
215pub(super) static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
216    std::sync::OnceLock::new();
217
218pub(super) fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
219    BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
220}
221
222/// Decrements the shared background-task counter from `Drop`, so the count
223/// comes back down whether the tracked future returns normally, panics, or
224/// is cancelled — a plain post-`await` `fetch_sub` only covers the return
225/// path and leaks the count forever on a panic, since unwinding skips every
226/// statement after the panic point.
227// ── outstanding background-task names ────────────────────────────────────────
228//
229// Names of the tasks the background counter is currently holding, so a drain
230// timeout can say which ones held it open instead of printing a bare count.
231// Registered and released at exactly the points the counter is incremented and
232// decremented, and in the order that keeps the counter authoritative: the name
233// goes in after the increment and comes out before the decrement, so a reported
234// name always belongs to a task the counter already holds. The reverse ordering
235// would let the warning name a task that had already finished, which is the one
236// reading that would send someone looking in the wrong place.
237pub(super) static BACKGROUND_TASK_NAMES: std::sync::OnceLock<
238    std::sync::Mutex<std::collections::HashMap<&'static str, usize>>,
239> = std::sync::OnceLock::new();
240
241pub(super) fn background_task_names_registry(
242) -> &'static std::sync::Mutex<std::collections::HashMap<&'static str, usize>> {
243    BACKGROUND_TASK_NAMES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
244}
245
246/// Name recorded for tasks spawned through the unnamed entry points, which
247/// stay on the public API of a published crate.
248pub const UNNAMED_BACKGROUND_TASK: &str = "unnamed";
249
250pub(super) fn register_background_task_name(name: &'static str) {
251    let mut names = background_task_names_registry()
252        .lock()
253        .unwrap_or_else(std::sync::PoisonError::into_inner);
254    *names.entry(name).or_insert(0) += 1;
255}
256
257pub(super) fn release_background_task_name(name: &'static str) {
258    let mut names = background_task_names_registry()
259        .lock()
260        .unwrap_or_else(std::sync::PoisonError::into_inner);
261    if let Some(count) = names.get_mut(name) {
262        *count -= 1;
263        if *count == 0 {
264            names.remove(name);
265        }
266    }
267}
268
269/// Names of the in-flight tracked background tasks, sorted and deduplicated.
270/// A diagnostic beside [`background_task_count`], never a substitute for it:
271/// the count is what drain waits on.
272pub fn background_task_names() -> Vec<String> {
273    let names = background_task_names_registry()
274        .lock()
275        .unwrap_or_else(std::sync::PoisonError::into_inner);
276    let mut out: Vec<String> = names.keys().map(|name| (*name).to_string()).collect();
277    out.sort();
278    out
279}
280
281#[cfg(unix)]
282pub(super) fn idle_retirement_blockers<D: DaemonDispatch>(dispatcher: &D) -> Vec<String> {
283    let mut blockers = dispatcher.idle_retirement_blockers();
284    if !khive_storage::tx_registry::snapshot().is_empty() {
285        blockers.push("open_sql_transaction".to_owned());
286    }
287    blockers.extend(
288        active_phase_names()
289            .into_iter()
290            .map(|name| format!("active_phase:{name}")),
291    );
292    let count = background_task_count();
293    let names = background_task_names_registry()
294        .lock()
295        .unwrap_or_else(std::sync::PoisonError::into_inner);
296    if names.values().sum::<usize>() != count {
297        blockers.push("tracked_worker_inventory_unsettled".to_owned());
298    }
299    // Only these inspected loops maintain replaceable caches/checkpoint state.
300    // Their tracked lifetime still participates in the final drain.
301    for name in names.keys() {
302        if !matches!(
303            *name,
304            "wal_checkpoint" | "memory_ann_rotation_watch" | "knowledge_ann_rotation_watch"
305        ) {
306            blockers.push(format!("unsettled_worker:{name}"));
307        }
308    }
309    blockers.sort();
310    blockers.dedup();
311    blockers
312}
313
314#[cfg(unix)]
315pub(super) async fn wait_for_idle<D: DaemonDispatch>(dispatcher: &D, lifecycle: &DaemonLifecycle) {
316    if lifecycle.options.lifetime == DaemonLifetime::Persistent {
317        std::future::pending::<()>().await;
318    }
319    loop {
320        if lifecycle.try_idle(|| idle_retirement_blockers(dispatcher)) {
321            return;
322        }
323        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
324    }
325}
326
327pub(super) struct BackgroundTaskGuard {
328    pub(super) counter: Arc<std::sync::atomic::AtomicUsize>,
329    pub(super) name: &'static str,
330}
331
332impl Drop for BackgroundTaskGuard {
333    fn drop(&mut self) {
334        release_background_task_name(self.name);
335        self.counter
336            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
337    }
338}
339
340/// Spawn a task that daemon shutdown's `drain()` waits for and return its join
341/// handle. Retaining the handle lets boot coordinators form an explicit barrier;
342/// dropping it deliberately detaches the task while the background counter still
343/// keeps daemon drain aware of its lifetime. The decrement happens via
344/// `BackgroundTaskGuard`'s `Drop`, including panic and cancellation paths.
345pub fn spawn_tracked_task<F, T>(fut: F) -> tokio::task::JoinHandle<T>
346where
347    F: std::future::Future<Output = T> + Send + 'static,
348    T: Send + 'static,
349{
350    spawn_named_tracked_task(UNNAMED_BACKGROUND_TASK, fut)
351}
352
353/// [`spawn_tracked_task`] with a name that a drain timeout can print.
354///
355/// The name is a short static string describing the task, never a formatted or
356/// caller-supplied value: it is read by an operator staring at a shutdown that
357/// would not finish, so it is a label for a call site, not a record of one
358/// occurrence.
359pub fn spawn_named_tracked_task<F, T>(name: &'static str, fut: F) -> tokio::task::JoinHandle<T>
360where
361    F: std::future::Future<Output = T> + Send + 'static,
362    T: Send + 'static,
363{
364    background_tasks().fetch_add(1, std::sync::atomic::Ordering::SeqCst);
365    register_background_task_name(name);
366    let guard = BackgroundTaskGuard {
367        counter: background_tasks().clone(),
368        name,
369    };
370    tokio::spawn(async move {
371        let _guard = guard;
372        fut.await
373    })
374}
375
376/// Spawn a fire-and-forget task through [`spawn_tracked_task`].
377///
378/// Callers that need a boot or shutdown barrier should retain and await the
379/// returned handle from [`spawn_tracked_task`] instead of detaching it here.
380pub fn track_background_task<F>(fut: F)
381where
382    F: std::future::Future<Output = ()> + Send + 'static,
383{
384    track_named_background_task(UNNAMED_BACKGROUND_TASK, fut);
385}
386
387/// [`track_background_task`] with a name that a drain timeout can print. See
388/// [`spawn_named_tracked_task`] for what belongs in the name.
389pub fn track_named_background_task<F>(name: &'static str, fut: F)
390where
391    F: std::future::Future<Output = ()> + Send + 'static,
392{
393    drop(spawn_named_tracked_task(name, fut));
394}
395
396/// Current count of in-flight tasks started via [`track_background_task`].
397/// Exposed for tests; `drain()` reads the shared counter directly.
398pub fn background_task_count() -> usize {
399    background_tasks().load(std::sync::atomic::Ordering::SeqCst)
400}
401
402/// Process-wide daemon shutdown signal (ADR-119).
403///
404/// Cancelled exactly once, when the daemon's unified shutdown future resolves
405/// — before `drain()` begins waiting on tracked tasks — so long-running
406/// daemon components supervised outside this module observe shutdown through
407/// the same path the daemon itself does, rather than inventing their own.
408/// Clones share the underlying token; child tokens derived from it are
409/// cancelled transitively.
410///
411/// In non-daemon processes the token simply never fires.
412pub fn daemon_shutdown_token() -> tokio_util::sync::CancellationToken {
413    static TOKEN: std::sync::OnceLock<tokio_util::sync::CancellationToken> =
414        std::sync::OnceLock::new();
415    TOKEN
416        .get_or_init(tokio_util::sync::CancellationToken::new)
417        .clone()
418}
419
420// ── active background phase names (ADR-103) ──────────────────────────────────
421//
422// A lightweight, best-effort process-wide gauge of which named background
423// phases (e.g. `ann_warm`) are in flight right now, read by `comm.health`'s
424// resource self-report so a caller can see "what is the daemon doing" at a
425// glance without correlating timestamps across the event log itself. Counted
426// per name rather than boolean, since more than one occurrence of the same
427// named phase can legitimately overlap (e.g. two embedding models warming
428// concurrently) — the name only drops out of the reported set once every
429// concurrent occurrence has ended.
430pub(super) static ACTIVE_PHASES: std::sync::OnceLock<
431    std::sync::Mutex<std::collections::HashMap<String, usize>>,
432> = std::sync::OnceLock::new();
433
434pub(super) fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>>
435{
436    ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
437}
438
439/// RAII guard for one occurrence of a named background phase. Increments the
440/// phase's count on creation (see [`register_active_phase`]); decrements on
441/// `Drop`, so the count comes back down whether the guarded work returns
442/// normally, panics, or is cancelled — the same rationale as
443/// `BackgroundTaskGuard` above.
444pub struct PhaseGuard {
445    pub(super) name: String,
446}
447
448impl Drop for PhaseGuard {
449    fn drop(&mut self) {
450        let mut map = active_phases()
451            .lock()
452            .unwrap_or_else(std::sync::PoisonError::into_inner);
453        if let Some(count) = map.get_mut(&self.name) {
454            *count -= 1;
455            if *count == 0 {
456                map.remove(&self.name);
457            }
458        }
459    }
460}
461
462/// Register one occurrence of a named background phase as currently active.
463/// Returns a guard: drop it (or let it fall out of scope) when the phase
464/// ends. Best-effort process-wide gauge only, read by `comm.health` — never
465/// load-bearing for correctness.
466pub fn register_active_phase(name: &str) -> PhaseGuard {
467    let mut map = active_phases()
468        .lock()
469        .unwrap_or_else(std::sync::PoisonError::into_inner);
470    *map.entry(name.to_string()).or_insert(0) += 1;
471    PhaseGuard {
472        name: name.to_string(),
473    }
474}
475
476/// Currently active background-phase names, sorted for deterministic output.
477/// Empty when no tracked phase is in flight.
478pub fn active_phase_names() -> Vec<String> {
479    let map = active_phases()
480        .lock()
481        .unwrap_or_else(std::sync::PoisonError::into_inner);
482    let mut names: Vec<String> = map.keys().cloned().collect();
483    names.sort();
484    names
485}
486
487// ── server ────────────────────────────────────────────────────────────────────
488
489/// Build a point-in-time [`MetricsSnapshot`] of this process's server-side
490/// gauges. Called only from `handle_conn`'s `metrics_only` arm — a
491/// process-global, read-only assembly with no side effects of its own.
492/// See `docs/api/daemon.md#build_metrics_snapshot` for where each gauge is sourced
493/// from and why.
494#[cfg(unix)]
495pub(super) fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
496    let open_tx_count = khive_storage::tx_registry::snapshot().len();
497    // ADR-091 Amendment 3: deliberately the process-wide aggregate, not a
498    // backend-attributed view — this gauge reports "oldest pinned tx in this
499    // process" across every wired backend, not an attribution claim about
500    // which database it belongs to. Attribution consumers (the session sweep,
501    // the per-backend checkpoint tasks) use the scoped `oldest_for` views in
502    // khive-db instead.
503    let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
504        match khive_storage::tx_registry::oldest() {
505            Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
506            None => (None, None),
507        };
508
509    let checkpoint_pool = dispatcher.pool_for_checkpoint();
510    let mut secondary_index = 0;
511    let wal_checkpoint_stores = checkpoint_task_specs(
512        checkpoint_pool.clone(),
513        dispatcher.secondary_pools_for_checkpoint(),
514        None,
515        String::new(),
516    )
517    .into_iter()
518    .map(|task| {
519        let (store_id, role) = if task.is_main {
520            ("main".to_string(), "main".to_string())
521        } else {
522            let store_id = format!("secondary:{secondary_index}");
523            secondary_index += 1;
524            (store_id, "secondary".to_string())
525        };
526        CheckpointStoreMetrics {
527            store_id,
528            role,
529            database: task
530                .pool
531                .canonical_path()
532                .and_then(std::path::Path::file_name)
533                .map(|name| name.to_string_lossy().into_owned()),
534            timing: khive_db::checkpoint::checkpoint_timing(&task.pool),
535        }
536    })
537    .collect();
538    let routine_wal = checkpoint_pool
539        .as_deref()
540        .and_then(khive_db::checkpoint::routine_wal_observation);
541    let writer_stages = checkpoint_pool
542        .as_deref()
543        .and_then(khive_db::writer_task::last_writer_stage_observation);
544    let (write_queue_depth, write_queue_capacity) = checkpoint_pool
545        .as_ref()
546        .and_then(|pool| pool.writer_task_handle().ok().flatten())
547        .map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
548        .unwrap_or((None, None));
549
550    MetricsSnapshot {
551        lifecycle: None,
552        wal_pages: routine_wal.as_ref().map(|sample| sample.log_frames),
553        wal_log_frames: routine_wal.as_ref().map(|sample| sample.log_frames),
554        wal_checkpointed_frames: routine_wal
555            .as_ref()
556            .map(|sample| sample.checkpointed_frames),
557        wal_pending_frames: routine_wal.as_ref().map(|sample| sample.pending_frames),
558        wal_physical_bytes: routine_wal
559            .as_ref()
560            .and_then(|sample| sample.physical_wal_bytes),
561        wal_observed_at_unix_ms: routine_wal
562            .as_ref()
563            .map(|sample| sample.observed_at_unix_ms),
564        wal_checkpoint_stores,
565        wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
566        wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
567        wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
568        wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
569        wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
570        oldest_pinned_tx_micros,
571        oldest_pinned_tx_label,
572        open_tx_count,
573        write_queue_depth,
574        write_queue_capacity,
575        write_last_queue_wait_micros: writer_stages
576            .as_ref()
577            .map(|sample| sample.queue_wait_micros),
578        write_last_transaction_acquire_micros: writer_stages
579            .as_ref()
580            .map(|sample| sample.transaction_acquire_micros),
581        write_last_body_micros: writer_stages.as_ref().map(|sample| sample.body_micros),
582        write_last_commit_micros: writer_stages.as_ref().map(|sample| sample.commit_micros),
583        write_last_total_micros: writer_stages.as_ref().map(|sample| sample.total_micros),
584        write_last_observed_at_unix_ms: writer_stages
585            .as_ref()
586            .map(|sample| sample.observed_at_unix_ms),
587        connections: None,
588        recall_ledger: Some(recall_ledger_snapshot()),
589    }
590}