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}