Skip to main content

autumn_web/
actuator.rs

1//! Actuator endpoints for operational observability.
2//!
3//! Provides health, info, env, metrics, configprops, loggers, and tasks
4//! endpoints under the configured actuator prefix.
5//!
6//! Sensitive endpoints are gated by profile-aware defaults:
7//! - **dev**: all endpoints enabled
8//! - **prod**: only health, info, and metrics
9
10// autumn-determinism-gate: production code in this module must read time and
11// mint identifiers through the framework's injected seams (ClockSource /
12// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
13// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
14// (issue #1797). Justify exceptions with
15// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
16#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
17
18use std::collections::HashMap;
19use std::sync::{Arc, RwLock};
20
21use axum::Json;
22use axum::extract::{Path, State};
23use axum::http::StatusCode;
24use axum::response::IntoResponse;
25use serde::{Deserialize, Serialize};
26
27/// Scaffold-level accessibility posture reported by `/actuator/a11y`.
28///
29/// Each field indicates whether a foundational WCAG 2.1 AA scaffold concern is
30/// addressed in the application.  Apps generated with `autumn new` satisfy all
31/// three by default; existing apps can opt in incrementally.
32#[derive(Debug, Clone, Serialize, Default)]
33pub struct A11yPosture {
34    /// `<html lang="…">` is set in the page template.
35    pub lang_set: bool,
36    /// A skip-to-content link is present as the first focusable element.
37    pub skip_link_present: bool,
38    /// Semantic landmark regions (`<header>`, `<main>`, `<nav>`, `<footer>`)
39    /// are used in the page layout.
40    pub landmark_regions_present: bool,
41}
42
43impl A11yPosture {
44    /// Returns `true` when all scaffold-level a11y concerns are addressed.
45    #[must_use]
46    pub const fn is_compliant(&self) -> bool {
47        self.lang_set && self.skip_link_present && self.landmark_regions_present
48    }
49}
50
51// ── Plugin-contributed metrics ──────────────────────────────────
52
53/// Kind of a Prometheus metric family.
54///
55/// Used in [`MetricFamily`] to emit the correct `# TYPE` line in the
56/// Prometheus text format.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum MetricKind {
59    /// A monotonically increasing value (e.g., request count).
60    Counter,
61    /// An arbitrary up-or-down value (e.g., queue depth, active connections).
62    Gauge,
63}
64
65impl MetricKind {
66    const fn as_str(&self) -> &'static str {
67        match self {
68            Self::Counter => "counter",
69            Self::Gauge => "gauge",
70        }
71    }
72}
73
74/// A single metric sample with optional label set and a value.
75///
76/// Labels are `(name, value)` pairs rendered as `{name="value"}` in
77/// Prometheus text format.
78#[derive(Debug, Clone)]
79pub struct MetricSample {
80    /// Label key-value pairs. Empty means no labels.
81    pub labels: Vec<(String, String)>,
82    /// The metric value.
83    pub value: f64,
84}
85
86/// A complete metric family: name, kind, help text, and current samples.
87///
88/// Each `MetricFamily` is rendered as one `# HELP` / `# TYPE` block followed
89/// by one line per sample in the Prometheus text format.
90#[derive(Debug, Clone)]
91pub struct MetricFamily {
92    /// Unique metric name (e.g., `"harvest_workflow_completions_total"`).
93    ///
94    /// Use a stable namespace prefix so names don't collide with the built-in
95    /// `autumn_*` families or other registered sources.
96    pub name: String,
97    /// One-line description emitted as `# HELP` in the Prometheus output.
98    pub help: String,
99    /// Metric type: counter or gauge.
100    ///
101    /// For histograms, record through [`crate::metrics::histogram`] (or
102    /// [`crate::metrics::timer`]) instead — a [`MetricsSource`] cannot express
103    /// one.
104    pub kind: MetricKind,
105    /// Current samples.  Each sample produces one line in the scrape output.
106    pub samples: Vec<MetricSample>,
107}
108
109/// Contract for a subsystem that contributes metrics to the unified actuator endpoints.
110///
111/// Implement this trait and register the implementation via
112/// [`crate::app::AppBuilder::metrics_source`] to publish metric families that appear in
113/// `/actuator/prometheus` alongside the built-in `autumn_http_*` families, and
114/// in `/actuator/metrics` under the `sources` key.
115///
116/// # Naming rules
117///
118/// Prefix every metric name with a stable namespace (e.g. `harvest_` for
119/// autumn-harvest, `myapp_` for an application-level source).  The registry
120/// enforces that two sources cannot share the same **registration name**; metric
121/// family name uniqueness is the source's responsibility.
122///
123/// # Sync-snapshot contract
124///
125/// `collect` is called synchronously on the HTTP request goroutine.
126/// Implementations **must not block on I/O** — read from atomics,
127/// `RwLock`-protected snapshots, or channels that already have buffered data.
128/// If async work is needed, collect it into a pre-computed cache and update
129/// that cache from a background task.
130pub trait MetricsSource: Send + Sync + 'static {
131    /// Return zero or more metric families, all read from in-memory state.
132    fn collect(&self) -> Vec<MetricFamily>;
133}
134
135/// Registry of named [`MetricsSource`] implementations.
136///
137/// Maintained by [`crate::app::AppBuilder`] and stored on
138/// [`crate::AppState`]. Provides duplicate-registration detection at startup
139/// and per-source panic isolation at scrape time.
140#[derive(Clone, Default)]
141pub struct MetricsSourceRegistry {
142    inner: Arc<RwLock<MetricsSourceRegistryInner>>,
143}
144
145#[derive(Default)]
146struct MetricsSourceRegistryInner {
147    /// Registered sources in insertion order.
148    sources: Vec<(String, Arc<dyn MetricsSource>)>,
149    /// Per-source scrape-error counter incremented when a source panics.
150    error_counts: HashMap<String, u64>,
151}
152
153impl MetricsSourceRegistry {
154    /// Create a new, empty registry.
155    #[must_use]
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Register a named source.
161    ///
162    /// Returns `Err` containing a message if a source with `name` has already
163    /// been registered (startup-time collision detection).
164    ///
165    /// # Errors
166    ///
167    /// Returns an error string when `name` is already registered.
168    pub fn register(
169        &self,
170        name: impl Into<String>,
171        source: Arc<dyn MetricsSource>,
172    ) -> Result<(), String> {
173        let name = name.into();
174        {
175            let mut inner = self
176                .inner
177                .write()
178                .unwrap_or_else(std::sync::PoisonError::into_inner);
179            if inner.sources.iter().any(|(n, _)| n == &name) {
180                return Err(format!(
181                    "MetricsSource '{name}' is already registered; skipping duplicate"
182                ));
183            }
184            inner.sources.push((name, source));
185        }
186        Ok(())
187    }
188
189    /// Collect from all registered sources, isolating panics.
190    ///
191    /// Returns one entry per registered source; panicking sources contribute an
192    /// empty `Vec<MetricFamily>` and increment their error counter.
193    pub fn collect_all(&self) -> Vec<(String, Vec<MetricFamily>)> {
194        let sources: Vec<(String, Arc<dyn MetricsSource>)> = self
195            .inner
196            .read()
197            .unwrap_or_else(std::sync::PoisonError::into_inner)
198            .sources
199            .clone();
200
201        let mut results = Vec::with_capacity(sources.len());
202        let mut panicked = Vec::new();
203
204        for (name, source) in &sources {
205            let result =
206                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| source.collect()));
207            if let Ok(families) = result {
208                results.push((name.clone(), families));
209            } else {
210                tracing::error!(source_name = %name, "MetricsSource panicked during collection");
211                panicked.push(name.clone());
212                results.push((name.clone(), vec![]));
213            }
214        }
215
216        if !panicked.is_empty() {
217            let mut inner = self
218                .inner
219                .write()
220                .unwrap_or_else(std::sync::PoisonError::into_inner);
221            for name in panicked {
222                *inner.error_counts.entry(name).or_insert(0) += 1;
223            }
224        }
225
226        results
227    }
228
229    /// Current per-source scrape-error counts (incremented on panic isolation).
230    #[must_use]
231    pub fn error_counts(&self) -> HashMap<String, u64> {
232        self.inner
233            .read()
234            .unwrap_or_else(std::sync::PoisonError::into_inner)
235            .error_counts
236            .clone()
237    }
238
239    /// Names of all registered sources, in insertion order.
240    #[must_use]
241    pub fn source_names(&self) -> Vec<String> {
242        self.inner
243            .read()
244            .unwrap_or_else(std::sync::PoisonError::into_inner)
245            .sources
246            .iter()
247            .map(|(n, _)| n.clone())
248            .collect()
249    }
250
251    /// Returns `true` when no sources have been registered.
252    #[must_use]
253    pub fn is_empty(&self) -> bool {
254        self.inner
255            .read()
256            .unwrap_or_else(std::sync::PoisonError::into_inner)
257            .sources
258            .is_empty()
259    }
260
261    /// Whether `name` is already registered.
262    ///
263    /// For subsystems that register into more than one registry and must not
264    /// leave half of themselves behind: check every name first, then register.
265    /// Registration has no undo, so a caller that registers one name and then
266    /// collides on the second would strand the first.
267    #[must_use]
268    pub fn contains(&self, name: &str) -> bool {
269        self.inner
270            .read()
271            .unwrap_or_else(std::sync::PoisonError::into_inner)
272            .sources
273            .iter()
274            .any(|(registered, _)| registered == name)
275    }
276}
277
278/// Trait to abstract the state requirements for actuator handlers.
279///
280/// Implement this trait on your application's state type to provide
281/// the necessary dependencies for actuator endpoints (e.g. `/actuator/metrics`).
282/// This avoids tight coupling between the actuator middleware and the specific `AppState`.
283pub trait ProvideActuatorState {
284    /// Returns a reference to the [`crate::middleware::MetricsCollector`]
285    /// tracking current HTTP traffic metrics.
286    fn metrics(&self) -> &crate::middleware::MetricsCollector;
287
288    /// Returns a reference to the dynamic [`LogLevels`] configuration
289    /// allowing runtime adjustment of `tracing` filters.
290    fn log_levels(&self) -> &LogLevels;
291
292    /// Returns a reference to the [`TaskRegistry`] holding status and metadata
293    /// for async scheduled background tasks.
294    fn task_registry(&self) -> &TaskRegistry;
295
296    /// Returns a reference to the [`JobRegistry`] holding queue and failure
297    /// information for ad-hoc background jobs.
298    fn job_registry(&self) -> &JobRegistry;
299
300    /// Returns a reference to the [`ConfigProperties`] snapshot, providing
301    /// active configuration state for the environment endpoint.
302    fn config_props(&self) -> &ConfigProperties;
303
304    /// Returns the currently active execution profile (e.g. "dev", "prod")
305    /// which modifies what sensitive endpoints are exposed.
306    fn profile(&self) -> &str;
307
308    /// Returns a human-readable string displaying how long the application
309    /// has been running (e.g., "2d 4h 13m").
310    fn uptime_display(&self) -> String;
311
312    /// Returns the resolved role that `/actuator/info`, `/actuator/jobs` and
313    /// the metrics show. The default returns `None`. Then the endpoints omit
314    /// the `role` key.
315    fn process_role(&self) -> Option<crate::config::Role> {
316        None
317    }
318
319    /// Returns a reference to the system [`crate::channels::Channels`] which
320    /// broadcasts operational events to WebSocket streams.
321    #[cfg(feature = "ws")]
322    fn channels(&self) -> &crate::channels::Channels;
323
324    /// Returns the main cancellation token that triggers a graceful framework shutdown.
325    #[cfg(feature = "ws")]
326    fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
327
328    /// Returns an optional reference to the database connection pool,
329    /// used to expose database connection metrics in the `/actuator/metrics` endpoint.
330    #[cfg(feature = "db")]
331    fn pool(
332        &self,
333    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>;
334
335    /// Returns the probe state that holds the cached primary database ping.
336    ///
337    /// `/actuator/health` uses this state to share one ping cache with
338    /// `/ready`. The default returns `None`. Then each request opens a new
339    /// connection and pings the primary with a 2 s limit. Override this
340    /// method to return the same state as `ProvideProbeState::probes`.
341    #[cfg(feature = "db")]
342    fn probe_state(&self) -> Option<&crate::probe::ProbeState> {
343        None
344    }
345
346    /// Returns the configured shard set, used to expose per-shard pool
347    /// metrics in the `/actuator/metrics` endpoint. Defaults to `None`.
348    #[cfg(feature = "db")]
349    fn shards(&self) -> Option<&crate::sharding::ShardSet> {
350        None
351    }
352
353    /// Returns the read-replica pool, used to export its gauges on
354    /// `/actuator/prometheus` with `pool="replica"`. Defaults to `None`.
355    #[cfg(feature = "db")]
356    fn replica_pool(
357        &self,
358    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
359    {
360        None
361    }
362
363    /// Returns the scaffold-level accessibility posture reported by `/actuator/a11y`.
364    ///
365    /// Override this in your `AppState` implementation to declare which
366    /// WCAG 2.1 AA scaffold concerns your application addresses.  The default
367    /// returns all-false (no concerns addressed) — a conservative safe default.
368    fn a11y_posture(&self) -> A11yPosture {
369        A11yPosture::default()
370    }
371
372    /// Returns the registry of plugin-contributed [`MetricsSource`] implementations.
373    ///
374    /// The default returns `None`, meaning no plugin sources are consulted.
375    /// [`crate::AppState`] overrides this to return its registry, which is
376    /// populated by [`crate::app::AppBuilder::metrics_source`].
377    fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
378        None
379    }
380
381    /// Returns the registry of [`HealthIndicator`] implementations.
382    ///
383    /// The default returns `None`, meaning no custom indicators are consulted.
384    /// [`crate::AppState`] overrides this to return its registry, which is
385    /// populated by [`crate::app::AppBuilder::health_indicator`].
386    fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
387        None
388    }
389
390    /// Returns whether detailed health information should be included in responses.
391    ///
392    /// When `false`, per-component `details` maps are omitted from
393    /// `/actuator/health` output. Defaults to `true`.
394    fn health_detailed(&self) -> bool {
395        true
396    }
397
398    /// Returns the deploy-version label for this replica (e.g. `"stable"` or
399    /// `"canary"`), used to tag Prometheus metrics so a canary controller can
400    /// compare canary vs. stable cohorts.
401    ///
402    /// Defaults to [`crate::canary::STABLE`]. [`crate::AppState`] overrides this
403    /// to return the value resolved from `AUTUMN_DEPLOY_VERSION` /
404    /// `AUTUMN_CANARY` (see [`crate::canary`]).
405    fn deploy_version(&self) -> String {
406        crate::canary::STABLE.to_owned()
407    }
408
409    #[cfg(feature = "http-client")]
410    /// Returns the optional webhook outbound manager if enabled/registered.
411    fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
412        None
413    }
414
415    /// Returns the in-memory log capture buffer, if capture is enabled.
416    ///
417    /// The default returns `None` (capture disabled). [`crate::AppState`]
418    /// overrides this to return the buffer installed at startup when
419    /// `log.capture.enabled = true`.
420    fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
421        None
422    }
423
424    #[cfg(feature = "graph")]
425    /// Returns the serialized architecture graph served at `/actuator/graph`.
426    ///
427    /// The default returns `None`, and the endpoint answers `503`.
428    /// [`crate::AppState`] overrides this to return the graph that the
429    /// framework installed when it built this application's router.
430    fn architecture_graph_json(&self) -> Option<bytes::Bytes> {
431        None
432    }
433
434    /// Returns the shadow-mirroring handle, when this replica assembled a
435    /// mirror (`[shadow] enabled = true` with a reachable target configured).
436    ///
437    /// The default returns `None` — mirroring is off — and
438    /// `{prefix}/shadow` then reports a disabled mirror rather than 404ing, so
439    /// an operator turning the feature on can tell "not enabled here" apart
440    /// from "endpoint not mounted".
441    fn shadow(&self) -> Option<crate::shadow::ShadowHandle> {
442        None
443    }
444
445    #[cfg(feature = "gdpr")]
446    /// Returns the data-capsule service of `{prefix}/capsules/*` (issue #1811).
447    ///
448    /// The default gives an error, so the endpoints answer `501`.
449    /// [`crate::AppState`] uses
450    /// [`CapsuleService::from_state`](crate::gdpr::portability::CapsuleService::from_state).
451    ///
452    /// # Errors
453    ///
454    /// [`CapsuleError`](crate::gdpr::portability::DataCapsuleError) when the
455    /// service is not configured.
456    fn data_capsules(
457        &self,
458    ) -> Result<crate::gdpr::portability::CapsuleService, crate::gdpr::portability::DataCapsuleError>
459    {
460        Err(crate::gdpr::portability::DataCapsuleError::NotConfigured(
461            "this state has no data-capsule service".to_owned(),
462        ))
463    }
464
465    /// Returns the cost signal (issue #1720). The default returns `None`.
466    fn cost_signal(&self) -> Option<crate::cost::CostSignal> {
467        None
468    }
469
470    /// Returns the cost accountant (issue #1720). The default returns `None`,
471    /// and `{prefix}/cost` then reports `"enabled": false`.
472    fn cost_accountant(&self) -> Option<crate::cost::CostAccountant> {
473        None
474    }
475}
476
477// ── Shared types for AppState ──────────────────────────────────
478
479/// Runtime log level management for the loggers actuator endpoint.
480///
481/// Stores the current effective log level and per-logger overrides.
482/// Changes are ephemeral -- they reset on restart.
483#[derive(Clone)]
484pub struct LogLevels {
485    inner: Arc<RwLock<LogLevelsInner>>,
486}
487
488struct LogLevelsInner {
489    /// The current global log level.
490    current_level: String,
491    /// Per-logger level overrides applied at runtime.
492    logger_overrides: HashMap<String, String>,
493    /// Handle for pushing level changes to the live `tracing` subscriber.
494    ///
495    /// `Some` once `AppBuilder` wires in the reload handle from the telemetry
496    /// guard; `None` in bare `LogLevels` (e.g. unit tests) where no
497    /// reload-capable subscriber is installed. When `None`, a level change is
498    /// recorded but cannot affect emission — the endpoint reports this honestly
499    /// rather than returning a false-positive `ok` (issue #1044).
500    reload_handle: Option<crate::telemetry::FilterReloadHandle>,
501}
502
503impl LogLevelsInner {
504    /// Build a combined `EnvFilter` directive from the global level plus every
505    /// per-target override, e.g. `"info,my_app::module=trace"`. Targets are
506    /// sorted for deterministic output.
507    fn build_directive(&self) -> String {
508        let mut parts = Vec::new();
509        if !self.current_level.is_empty() {
510            parts.push(self.current_level.clone());
511        }
512        let mut targets: Vec<String> = self
513            .logger_overrides
514            .iter()
515            .filter(|(name, _)| name.as_str() != "root" && !name.is_empty())
516            .map(|(name, level)| format!("{name}={level}"))
517            .collect();
518        targets.sort();
519        parts.extend(targets);
520        parts.join(",")
521    }
522}
523
524/// Split a startup `log.level` value — which may be a full `EnvFilter`
525/// directive such as `"info,tower_http=warn,my_app=debug"` — into a bare
526/// global level plus per-target overrides.
527///
528/// Seeding the overrides map at construction ensures that a later
529/// `PUT /actuator/loggers/root` (which replaces only the global level) does
530/// not silently drop the module-specific directives configured at startup.
531///
532/// Classification follows `EnvFilter` semantics:
533/// - A segment containing `=` is a per-target override (keyed on the part
534///   before the final `=`, so span-field directives round-trip). `root=<level>`
535///   and `=<level>` fold into the global level.
536/// - A bare segment that IS a tracing level (`trace|debug|info|warn|error|off`,
537///   case-insensitive) updates the global level, last one winning to match
538///   `EnvFilter` precedence.
539/// - A bare segment that is NOT a level is a *target directive at trace* (e.g.
540///   `my_app` means "enable target `my_app` at trace"), so it is stored as a
541///   per-target override with the implicit level `trace`.
542fn parse_initial_directive(directive: &str) -> (String, HashMap<String, String>) {
543    let mut global = String::new();
544    let mut overrides = HashMap::new();
545    for segment in directive.split(',') {
546        let segment = segment.trim();
547        if segment.is_empty() {
548            continue;
549        }
550        if let Some((target, level)) = segment.rsplit_once('=') {
551            let target = target.trim();
552            let level = level.trim();
553            // `root=<level>` / `=<level>` are just the global level in disguise.
554            if target.is_empty() || target == "root" {
555                global = level.to_string();
556            } else {
557                overrides.insert(target.to_string(), level.to_string());
558            }
559        } else if is_tracing_level(segment) {
560            // A bare level sets the global level (last-level-wins).
561            global = segment.to_string();
562        } else {
563            // A bare non-level segment is a target enabled at trace.
564            overrides.insert(segment.to_string(), "trace".to_string());
565        }
566    }
567    (global, overrides)
568}
569
570/// Returns `true` when `segment` is a bare `tracing` level keyword
571/// (case-insensitive), i.e. a global-level directive rather than a target.
572fn is_tracing_level(segment: &str) -> bool {
573    matches!(
574        segment.to_ascii_lowercase().as_str(),
575        "trace" | "debug" | "info" | "warn" | "error" | "off"
576    )
577}
578
579impl LogLevels {
580    /// Create a new `LogLevels` with the given initial level.
581    #[must_use]
582    pub fn new(initial_level: &str) -> Self {
583        let (current_level, logger_overrides) = parse_initial_directive(initial_level);
584        Self {
585            inner: Arc::new(RwLock::new(LogLevelsInner {
586                current_level,
587                logger_overrides,
588                reload_handle: None,
589            })),
590        }
591    }
592
593    /// Attach the live-subscriber reload handle produced by telemetry init.
594    ///
595    /// Called once by `AppBuilder` after the tracing subscriber is installed so
596    /// subsequent [`Self::set_logger_level`] calls take effect on the running
597    /// process (issue #1044).
598    pub fn attach_reload_handle(&self, handle: crate::telemetry::FilterReloadHandle) {
599        if let Ok(mut guard) = self.inner.write() {
600            guard.reload_handle = Some(handle);
601        }
602    }
603
604    /// Returns `true` when a reload-capable subscriber is installed, i.e. level
605    /// changes made via [`Self::set_logger_level`] actually reach the live
606    /// `tracing` subscriber.
607    #[must_use]
608    pub fn reload_available(&self) -> bool {
609        self.inner
610            .read()
611            .is_ok_and(|guard| guard.reload_handle.is_some())
612    }
613
614    /// Get the current global log level.
615    #[must_use]
616    pub fn current_level(&self) -> String {
617        self.inner
618            .read()
619            .map_or_else(|_| "info".to_string(), |guard| guard.current_level.clone())
620    }
621
622    /// Get all per-logger overrides.
623    #[must_use]
624    pub fn logger_overrides(&self) -> HashMap<String, String> {
625        self.inner
626            .read()
627            .map(|guard| guard.logger_overrides.clone())
628            .unwrap_or_default()
629    }
630
631    /// The combined `EnvFilter` directive currently pushed to the live
632    /// subscriber (global level plus per-target overrides). Test-only.
633    #[cfg(test)]
634    fn rebuilt_directive_for_test(&self) -> String {
635        self.inner
636            .read()
637            .map(|guard| guard.build_directive())
638            .unwrap_or_default()
639    }
640
641    /// Set the level for a specific logger.
642    ///
643    /// When a reload-capable subscriber is installed (see
644    /// [`Self::attach_reload_handle`]), the rebuilt filter directive is pushed
645    /// to the live `tracing` subscriber so the change takes effect immediately
646    /// on the next event (issue #1044). Overrides remain ephemeral — they live
647    /// only in this in-memory state and reset on process restart.
648    ///
649    /// The returned [`LogLevelChange`] reflects the **actual** outcome, not
650    /// merely handle presence:
651    /// - [`LogLevelChange::Applied`] — pushed to a live subscriber.
652    /// - [`LogLevelChange::Recorded`] — stored, but no reload-capable subscriber
653    ///   is installed (bare state / tests).
654    /// - [`LogLevelChange::Rejected`] — the map is at capacity, or the directive
655    ///   failed to apply and the override was **rolled back** so the map never
656    ///   claims a live override that isn't (issue #1044).
657    ///
658    /// The directive is applied while the write lock is held so that concurrent
659    /// callers apply directives in the same order they mutate the map — the live
660    /// subscriber can never disagree with `GET /loggers` (issue #1044 AC4).
661    /// `reload`/`apply` never re-enters `LogLevels`, so this is deadlock-free.
662    pub fn set_logger_level(&self, name: &str, level: &str) -> LogLevelChange {
663        let Ok(mut guard) = self.inner.write() else {
664            return LogLevelChange::Rejected {
665                reason: "log level state lock poisoned".to_string(),
666            };
667        };
668        // Prevent unbounded memory growth from arbitrary logger names
669        if guard.logger_overrides.len() >= 1000 && !guard.logger_overrides.contains_key(name) {
670            return LogLevelChange::Rejected {
671                reason: "too many logger overrides".to_string(),
672            };
673        }
674
675        let is_root = name == "root" || name.is_empty();
676        let previous_override = guard.logger_overrides.get(name).cloned();
677        let previous_current = guard.current_level.clone();
678
679        guard
680            .logger_overrides
681            .insert(name.to_string(), level.to_string());
682        // If setting the root level, update current_level too.
683        let returned = if is_root {
684            guard.current_level = level.to_string();
685            Some(previous_current.clone())
686        } else {
687            previous_override.clone()
688        };
689
690        let directive = guard.build_directive();
691        // Clone the handle out so the rollback path below can mutate `guard`
692        // without holding a borrow of `guard.reload_handle`.
693        let Some(handle) = guard.reload_handle.clone() else {
694            return LogLevelChange::Recorded { previous: returned };
695        };
696
697        match handle.apply_directive(&directive) {
698            Ok(()) => LogLevelChange::Applied { previous: returned },
699            Err(error) => {
700                // Roll back so `GET /loggers` never advertises an override that
701                // failed to reach the live subscriber.
702                match previous_override {
703                    Some(prev) => {
704                        guard.logger_overrides.insert(name.to_string(), prev);
705                    }
706                    None => {
707                        guard.logger_overrides.remove(name);
708                    }
709                }
710                if is_root {
711                    guard.current_level = previous_current;
712                }
713                LogLevelChange::Rejected { reason: error }
714            }
715        }
716    }
717}
718
719/// Outcome of [`LogLevels::set_logger_level`].
720#[derive(Debug, Clone, PartialEq, Eq)]
721#[must_use]
722pub enum LogLevelChange {
723    /// The change was pushed to a live, reload-capable subscriber. Carries the
724    /// previous level for that target, if any.
725    Applied {
726        /// Previous level for the target, if it had one.
727        previous: Option<String>,
728    },
729    /// The change was stored in the in-memory map, but no reload-capable
730    /// subscriber is installed, so it does not affect emission.
731    Recorded {
732        /// Previous level for the target, if it had one.
733        previous: Option<String>,
734    },
735    /// The change was not stored: the map was at capacity, or applying the
736    /// directive to the live subscriber failed and the override was rolled back.
737    Rejected {
738        /// Human-readable reason.
739        reason: String,
740    },
741}
742
743impl LogLevelChange {
744    /// The previous level for the target, if the change was stored.
745    #[must_use]
746    pub fn previous(&self) -> Option<&str> {
747        match self {
748            Self::Applied { previous } | Self::Recorded { previous } => previous.as_deref(),
749            Self::Rejected { .. } => None,
750        }
751    }
752}
753
754impl std::fmt::Debug for LogLevels {
755    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756        f.debug_struct("LogLevels")
757            .field("current_level", &self.current_level())
758            .finish()
759    }
760}
761
762/// Scheduled task status information.
763#[derive(Debug, Clone, Serialize)]
764pub struct TaskStatus {
765    /// The schedule description (e.g., "every 5m" or "cron 0 0 * * *").
766    pub schedule: String,
767    /// Whether this task is coordinated across the fleet or per replica.
768    pub coordination: crate::task::TaskCoordination,
769    /// Scheduler backend currently coordinating this task.
770    pub scheduler_backend: String,
771    /// Replica id for this process.
772    pub replica_id: String,
773    /// Replica id that last acquired leadership for this task.
774    #[serde(skip_serializing_if = "Option::is_none")]
775    pub current_leader: Option<String>,
776    /// Last global tick key observed for this task.
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub last_tick: Option<String>,
779    /// Last time this task fired (ISO 8601), if ever.
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub last_fired_at: Option<String>,
782    /// Next scheduled run time (ISO 8601), if known.
783    #[serde(skip_serializing_if = "Option::is_none")]
784    pub next_run_at: Option<String>,
785    /// Current task state.
786    pub status: String,
787    /// Last time the task ran (ISO 8601), if ever.
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub last_run: Option<String>,
790    /// Duration of last run in milliseconds.
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub last_duration_ms: Option<u64>,
793    /// Result of last run.
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub last_result: Option<String>,
796    /// Last error message, if the task failed.
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub last_error: Option<String>,
799    /// Total number of times the task has run.
800    pub total_runs: u64,
801    /// Total number of failures.
802    pub total_failures: u64,
803}
804
805/// Registry of scheduled tasks and their runtime status.
806#[derive(Clone)]
807pub struct TaskRegistry {
808    inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
809}
810
811/// On-demand background job status information.
812#[derive(Debug, Clone, Serialize)]
813pub struct JobStatus {
814    /// Approximate queued jobs waiting to run.
815    pub queued: u64,
816    /// Number of currently running jobs.
817    pub in_flight: u64,
818    /// Approximate jobs currently waiting on a free concurrency slot.
819    pub blocked_on_concurrency: u64,
820    /// Total successful executions.
821    pub total_successes: u64,
822    /// Total failed executions.
823    pub total_failures: u64,
824    /// Total dead-lettered executions.
825    pub dead_letters: u64,
826    /// Total enqueues coalesced because a matching unique job was already held.
827    pub total_deduplicated: u64,
828    /// Last observed error for this job, if any.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub last_error: Option<String>,
831}
832
833impl JobStatus {
834    const fn empty() -> Self {
835        Self {
836            queued: 0,
837            in_flight: 0,
838            blocked_on_concurrency: 0,
839            total_successes: 0,
840            total_failures: 0,
841            dead_letters: 0,
842            total_deduplicated: 0,
843            last_error: None,
844        }
845    }
846}
847
848/// Per-queue observability gauges for the actuator jobs endpoint (issue #1623,
849/// AC7): queue depth and the age of the oldest still-waiting job.
850///
851/// On the **local** backend (single process, single registry) these reflect
852/// enqueue/start events observed in this process via the `record_*` marks.
853/// On the **durable** backends (Postgres/Redis) they are backend-derived and
854/// authoritative (issue #1752): a periodic survey of the durable store
855/// wholesale-replaces this snapshot each tick, so an enqueue-only web replica
856/// reports the true shared backlog rather than its own local enqueue marks.
857#[derive(Debug, Clone, Serialize)]
858pub struct QueueStatus {
859    /// Jobs waiting to run on this queue.
860    pub depth: u64,
861    /// Age in milliseconds of the oldest still-waiting job on this queue
862    /// (`0` when the queue is empty).
863    pub oldest_waiting_age_ms: u64,
864}
865
866/// Per-queue depth/age bookkeeping backing [`QueueStatus`].
867#[derive(Default)]
868struct QueueGaugeState {
869    /// Job name → the queue it drains from.
870    name_to_queue: HashMap<String, String>,
871    /// Queue → ready-at timestamps (epoch ms) of jobs still waiting to start.
872    /// A mark whose ready-at is in the future belongs to a scheduled (delayed)
873    /// job that is not yet claimable, so it is excluded from ready depth/age
874    /// until its ready-at time passes.
875    ///
876    /// Only authoritative on the local backend. On the durable backends this
877    /// still records marks (harmless) but [`Self::surveyed`] overrides them.
878    waiting: HashMap<String, std::collections::VecDeque<u64>>,
879    /// Backend-derived per-queue gauges from the durable survey (issue #1752).
880    /// When `Some`, this authoritative snapshot — refreshed each survey tick
881    /// from the durable store — backs [`JobRegistry::queue_snapshot`] instead
882    /// of the per-process `waiting` marks. `None` on the local backend, which
883    /// keeps the in-memory mark path. Each value is `(ready depth, oldest
884    /// ready-at epoch ms)`; the reported age is derived from the timestamp at
885    /// snapshot time so it stays fresh between surveys.
886    ///
887    /// **The timestamps here must be on the registry's clock.** The age is
888    /// freshened as `registry_now - ready_at`, so a surveyor whose marks live on
889    /// a different clock has to rebase them before calling
890    /// [`JobRegistry::set_queue_depth_gauges`] — the Postgres survey asks the
891    /// database for an *age* and subtracts it from its own reading of the
892    /// injected clock for exactly this reason, while the redis survey already
893    /// stamps from that clock and passes its marks through unchanged.
894    surveyed: Option<HashMap<String, (u64, Option<u64>)>>,
895    /// Postgres-backed jobs' own waiting mark, by job id, so an admin-cancel
896    /// (`JobRegistry::record_cancel_at_backend_offset`) can remove precisely
897    /// the mark it pushed instead of guessing which of several co-queued
898    /// marks belongs to it — a guess that can land on the wrong job when two
899    /// marks happen to sit at (or near) the same instant. `Self::record_pg_start`
900    /// removes a job's entry the moment its mark is popped from the waiting
901    /// queue (a non-terminal retry re-adds it with the retry's own mark), so
902    /// growth tracks live queue residency, not lifetime enqueue count.
903    /// Bounded by [`PG_MARKS_BY_JOB_ID_CAP`] regardless, as a backstop for
904    /// jobs enqueued but never claimed (a crashed worker, a queue with no
905    /// consumer) — without it those would still leak one entry each forever.
906    ///
907    /// Removed with [`indexmap::IndexMap::swap_remove_index`], not
908    /// `shift_remove_index`: this map's *physical* order is never read (each
909    /// [`PgMark`] carries its own [`PgMark::seq`] for that), so there is no
910    /// reason to pay `shift_remove_index`'s O(n) shift-every-later-entry cost
911    /// on every capacity eviction — under sustained enqueue-only load past
912    /// the cap, that is *every* subsequent enqueue.
913    pg_marks_by_job_id: indexmap::IndexMap<String, PgMark>,
914    /// Monotonic counter handing out each new [`PgMark`]'s [`PgMark::seq`].
915    pg_marks_next_seq: u64,
916    /// `seq` → id, mirroring [`Self::pg_marks_by_job_id`]'s keys in
917    /// insertion order. Eviction walks this ascending (oldest first) and
918    /// stops at the first entry that is actually due, rather than scanning
919    /// every entry in [`Self::pg_marks_by_job_id`] to find the *provably
920    /// oldest* due one: any due entry is an equally valid eviction target
921    /// (nothing here relies on evicting the single oldest of them), and an
922    /// older entry has had more time to become due, so this walk is O(1) in
923    /// the common case — a long-lived backlog past the cap keeps its oldest
924    /// entries genuinely overdue, not its newest ones. Only a workload where
925    /// no entry is ever due (every mark carries a far-future instant) still
926    /// walks every entry, matching the fallback's own already-O(n) oldest
927    /// lookup in that case.
928    pg_marks_seq_order: std::collections::BTreeMap<u64, String>,
929}
930
931impl QueueGaugeState {
932    /// The queue `name` drains from (`default` when unregistered), borrowed
933    /// from the registration map rather than copied out per lookup.
934    fn queue_of(&self, name: &str) -> &str {
935        self.name_to_queue
936            .get(name)
937            .map_or("default", String::as_str)
938    }
939
940    /// The waiting marks of `name`'s queue, when that queue has an entry.
941    fn waiting_for_mut(&mut self, name: &str) -> Option<&mut std::collections::VecDeque<u64>> {
942        // Disjoint fields: `name_to_queue` is read while `waiting` is borrowed
943        // mutably, so the queue name is never copied out.
944        let queue = self
945            .name_to_queue
946            .get(name)
947            .map_or("default", String::as_str);
948        self.waiting.get_mut(queue)
949    }
950}
951
952/// Cap on [`QueueGaugeState::pg_marks_by_job_id`]. Comfortably above any
953/// realistic count of Postgres jobs sitting `enqueued` at once — enough that
954/// a job an operator wants to cancel is essentially always still tracked —
955/// while keeping the map's memory bounded no matter how large an unclaimed
956/// backlog grows.
957const PG_MARKS_BY_JOB_ID_CAP: usize = 10_000;
958
959/// Which clock a [`PgMark`]'s `ms` value is measured on.
960///
961/// `note_pg_job_mark`'s eviction used to compare every entry against both
962/// the registry's own clock and real time, requiring both to agree an entry
963/// was due before evicting it — safe against wrongly evicting a
964/// still-delayed entry, but it can also make eviction blind: a `FixedClock`/
965/// `TickingClock` registry clock that has drifted *behind* real time (not
966/// just ahead, the case that motivated the both-clocks check) makes every
967/// real-timeline mark compare as "not yet due" against the registry clock
968/// forever, however far in the real past it actually is, so eviction can
969/// never recognize any of them as safe to evict and falls back to
970/// oldest-inserted — which can just as easily be the one genuinely-delayed
971/// entry sitting first among thousands of already-claimed ones. Tagging each
972/// mark with the one clock that actually measures it (known unambiguously at
973/// insertion time: [`JobRegistry::record_pg_enqueue`] passes a real instant
974/// for an absolute/relative-delay enqueue, `None` — resolved from the
975/// registry's own [`JobRegistry::now_ms`] — for an immediate one) lets
976/// eviction judge each entry correctly instead of guessing from its
977/// magnitude against a clock that may not be its own.
978#[derive(Clone, Copy, Debug, PartialEq, Eq)]
979pub(crate) enum PgMarkTimeline {
980    /// A real epoch-ms instant: an `enqueue_at`/`enqueue_in` mark, forwarded
981    /// unchanged or reconstructed via [`JobClient::due_origin`]'s real-time
982    /// reading — the same timeline Postgres's own `clock_timestamp()`
983    /// measures `run_at` against.
984    Real,
985    /// The registry's own (possibly injected/virtual) clock reading at
986    /// enqueue time — an immediate enqueue's mark.
987    Registry,
988}
989
990/// A [`QueueGaugeState::pg_marks_by_job_id`] entry: the raw waiting-queue
991/// mark plus which clock ([`PgMarkTimeline`]) it is measured on, so eviction
992/// can judge "due" against the one clock that actually applies to it.
993#[derive(Clone, Copy, Debug)]
994struct PgMark {
995    ms: u64,
996    timeline: PgMarkTimeline,
997    /// This entry's position in insertion order, from
998    /// [`QueueGaugeState::pg_marks_next_seq`] — capacity eviction's
999    /// "oldest" fallback reads this instead of the map's physical position,
1000    /// so removal can use O(1) `swap_remove_index` instead of an
1001    /// order-preserving (and so O(n)) shift.
1002    seq: u64,
1003}
1004
1005/// Rebase a surveyed age onto the registry's timeline.
1006///
1007/// A surveyor whose marks live on another clock — the Postgres survey, whose
1008/// `run_at` values and readiness filter are both the database's — asks its own
1009/// source how long the oldest ready job has waited, then converts that age into
1010/// a ready-at instant on the registry's clock with this. Both subtractions then
1011/// stay within one timeline: the database's for the age, the registry's for the
1012/// freshening at read time.
1013#[must_use]
1014pub const fn ready_at_from_age(registry_now_ms: u64, age_ms: u64) -> u64 {
1015    registry_now_ms.saturating_sub(age_ms)
1016}
1017
1018/// Real epoch milliseconds, for tests that build fixture timestamps against a
1019/// default (real-clock) registry.
1020///
1021/// **Test-only on purpose.** Production gauge code must read
1022/// [`JobRegistry::now_ms`] instead: the waiting marks are stamped from the job
1023/// runtime's injected clock, and comparing them against the real clock is the
1024/// mixed-timeline bug this is confined to prevent.
1025#[cfg(test)]
1026fn now_epoch_ms() -> u64 {
1027    std::time::SystemTime::now()
1028        .duration_since(std::time::UNIX_EPOCH)
1029        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
1030}
1031
1032/// Registry of ad-hoc jobs and their runtime status.
1033#[derive(Clone)]
1034pub struct JobRegistry {
1035    inner: Arc<RwLock<HashMap<String, JobStatus>>>,
1036    queues: Arc<RwLock<QueueGaugeState>>,
1037    /// Clock the queue gauges stamp and compare their epoch-ms marks on.
1038    ///
1039    /// The waiting marks hold a *ready-at* instant supplied by the job runtime
1040    /// (`record_enqueue_scheduled`), which reads the runtime's injected clock.
1041    /// A gauge that compared those marks against `SystemTime::now()` would be
1042    /// mixing timelines: under a `#[sim_test]` a job delayed from the 2020 sim
1043    /// epoch reads as ready immediately on a 2026 host, and `/actuator/jobs`
1044    /// reports a nonzero ready depth with roughly six years of waiting age
1045    /// before `Sim::advance` ever reaches the deadline.
1046    ///
1047    /// Defaults to the real clock; [`AppState::with_clock`](crate::AppState)
1048    /// installs the app's.
1049    ///
1050    /// **Shared, exactly like `queues`.** A clone of this registry keeps the
1051    /// same marks, so it has to keep the same clock — a per-handle clock lets
1052    /// one handle stamp a ready-at on a virtual timeline that another reads back
1053    /// against `SystemClock`, and the two report different depths and ages for
1054    /// one set of marks. Installing a clock therefore travels to every clone.
1055    clock: Arc<RwLock<Arc<dyn crate::time::ClockSource>>>,
1056    /// Redis dead letters this process removed to obey
1057    /// `jobs.redis.dead_letter_limit` (issue #3055). Shared by all clones.
1058    dead_letter_trimmed: Arc<std::sync::atomic::AtomicU64>,
1059}
1060
1061impl JobRegistry {
1062    /// Create a new empty job registry reading the real system clock.
1063    ///
1064    /// Use [`Self::with_clock`] to put its gauges on an injected timeline.
1065    #[must_use]
1066    pub fn new() -> Self {
1067        Self {
1068            inner: Arc::new(RwLock::new(HashMap::new())),
1069            queues: Arc::new(RwLock::new(QueueGaugeState::default())),
1070            clock: Arc::new(RwLock::new(
1071                Arc::new(crate::time::SystemClock) as Arc<dyn crate::time::ClockSource>
1072            )),
1073            dead_letter_trimmed: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1074        }
1075    }
1076
1077    /// Return this registry with its queue gauges reading `clock`.
1078    ///
1079    /// Builder form, for wiring a registry at construction. It reaches **every
1080    /// clone** of this registry, since they all share one set of marks and must
1081    /// judge them on one clock — the clock lives in a shared cell beside the
1082    /// marks, not on the handle.
1083    #[must_use]
1084    pub fn with_clock(self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
1085        self.install_clock(clock);
1086        self
1087    }
1088
1089    /// Put this registry's queue gauges on `clock`.
1090    ///
1091    /// Writes through the shared clock cell, so a registry already cloned into
1092    /// a running job client or another `AppState` moves onto the new timeline
1093    /// too. That is the point: the clones share `queues`, and marks stamped by
1094    /// one must be judged the same way by all of them.
1095    pub(crate) fn install_clock(&self, clock: Arc<dyn crate::time::ClockSource>) {
1096        let mut slot = self
1097            .clock
1098            .write()
1099            .unwrap_or_else(std::sync::PoisonError::into_inner);
1100        *slot = clock;
1101    }
1102
1103    /// The gauges' current instant in epoch milliseconds, on the injected
1104    /// clock — the timeline every mark this registry holds is stamped on.
1105    ///
1106    /// Both mark sources have to agree with this: the local `waiting` marks come
1107    /// from the job runtime's injected clock, and a surveyed mark is rebased
1108    /// onto that clock by its surveyor (see [`QueueGaugeState::surveyed`]).
1109    /// Mixing in a mark stamped elsewhere — a database `run_at`, a real
1110    /// `SystemTime` reading — is what makes an age come back as years or zero.
1111    fn now_ms(&self) -> u64 {
1112        // Recover rather than drop the reading on a poisoned lock: the cell holds
1113        // an immutable `Arc` handle, so a panic elsewhere leaves it perfectly
1114        // readable, and falling back to real time here would reintroduce exactly
1115        // the mixed timeline this field exists to prevent.
1116        let clock = self
1117            .clock
1118            .read()
1119            .unwrap_or_else(std::sync::PoisonError::into_inner);
1120        u64::try_from(clock.now().timestamp_millis()).unwrap_or(0)
1121    }
1122
1123    /// Whether any job name has been registered against this registry.
1124    ///
1125    /// Job names are registered when a job runtime starts, so this doubles as
1126    /// "has the state owning this registry moved past construction?" — see
1127    /// [`AppState::with_clock`](crate::AppState::with_clock), which is only
1128    /// meaningful before that point.
1129    #[must_use]
1130    pub fn is_initialized(&self) -> bool {
1131        !self
1132            .inner
1133            .read()
1134            .unwrap_or_else(std::sync::PoisonError::into_inner)
1135            .is_empty()
1136    }
1137
1138    /// Register a job name with initial counters.
1139    pub fn register(&self, name: &str) {
1140        if let Ok(mut guard) = self.inner.write() {
1141            guard.entry(name.to_string()).or_insert(JobStatus::empty());
1142        }
1143    }
1144
1145    /// Register a job name and the queue it drains from, so per-queue depth and
1146    /// oldest-waiting-age gauges (AC7) can be attributed to the right queue.
1147    pub fn register_on_queue(&self, name: &str, queue: &str) {
1148        self.register(name);
1149        if let Ok(mut guard) = self.queues.write() {
1150            guard
1151                .name_to_queue
1152                .insert(name.to_string(), queue.to_string());
1153            guard.waiting.entry(queue.to_string()).or_default();
1154        }
1155    }
1156
1157    /// Snapshot per-queue depth and oldest-waiting-job age.
1158    ///
1159    /// On the durable backends a periodic survey has populated an authoritative
1160    /// snapshot (via [`Self::set_queue_depth_gauges`]); it takes precedence over
1161    /// the per-process `waiting` marks so an enqueue-only replica reports the
1162    /// true shared backlog. On the local backend the survey is absent and the
1163    /// in-memory marks drive the gauges.
1164    #[must_use]
1165    pub fn queue_snapshot(&self) -> HashMap<String, QueueStatus> {
1166        let now = self.now_ms();
1167        self.queues
1168            .read()
1169            .map(|g| {
1170                if let Some(surveyed) = &g.surveyed {
1171                    // Durable backend: the survey is authoritative. Report every
1172                    // known queue (registered locally or seen in the survey);
1173                    // queues absent from the latest survey reset to depth 0 so
1174                    // stale backlog never leaks between ticks.
1175                    return g
1176                        .waiting
1177                        .keys()
1178                        .chain(surveyed.keys())
1179                        .cloned()
1180                        .collect::<std::collections::HashSet<String>>()
1181                        .into_iter()
1182                        .map(|queue| {
1183                            let (depth, oldest_ready_at) =
1184                                surveyed.get(&queue).copied().unwrap_or((0, None));
1185                            let oldest_waiting_age_ms =
1186                                oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1187                            (
1188                                queue,
1189                                QueueStatus {
1190                                    depth,
1191                                    oldest_waiting_age_ms,
1192                                },
1193                            )
1194                        })
1195                        .collect();
1196                }
1197                g.waiting
1198                    .iter()
1199                    .map(|(queue, waiting)| {
1200                        // Count only marks whose ready-at time has arrived; a
1201                        // future ready-at is a scheduled job that is not yet
1202                        // claimable and must not read as ready backlog.
1203                        let mut depth = 0u64;
1204                        let mut oldest_ready_at: Option<u64> = None;
1205                        for ready_at in waiting {
1206                            if *ready_at <= now {
1207                                depth += 1;
1208                                oldest_ready_at =
1209                                    Some(oldest_ready_at.map_or(*ready_at, |o| o.min(*ready_at)));
1210                            }
1211                        }
1212                        let oldest_waiting_age_ms =
1213                            oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1214                        (
1215                            queue.clone(),
1216                            QueueStatus {
1217                                depth,
1218                                oldest_waiting_age_ms,
1219                            },
1220                        )
1221                    })
1222                    .collect()
1223            })
1224            .unwrap_or_default()
1225    }
1226
1227    /// Record that a new job instance was enqueued and is immediately runnable.
1228    pub fn record_enqueue(&self, name: &str) {
1229        self.record_enqueue_at(name, self.now_ms());
1230    }
1231
1232    /// Record a delayed enqueue whose job only becomes claimable at
1233    /// `ready_at_ms` (epoch ms). Until that instant the job is tracked as
1234    /// scheduled rather than ready queue depth, so future-dated jobs enqueued
1235    /// via `enqueue_in`/`enqueue_at` do not inflate `queues.<name>.depth` or
1236    /// `oldest_waiting_age_ms` (which would fire false backlog alerts).
1237    pub fn record_enqueue_scheduled(&self, name: &str, ready_at_ms: u64) {
1238        self.record_enqueue_at(name, ready_at_ms);
1239    }
1240
1241    /// [`Self::record_enqueue`]/[`Self::record_enqueue_scheduled`] for a
1242    /// Postgres-backed job, additionally remembering the exact mark pushed
1243    /// under `id` (`note_pg_job_mark`) for a later
1244    /// [`Self::record_cancel_at_backend_offset`]'s exact lookup.
1245    ///
1246    /// `ready_at_ms` is `Some` for a scheduled enqueue (its mark, stamped
1247    /// from whichever clock `timeline` names) or `None` for an immediate
1248    /// one (always this registry's own clock, read once here rather than
1249    /// inside [`Self::record_enqueue`] so the exact same value can be
1250    /// remembered — `timeline` is ignored in this case).
1251    ///
1252    /// `timeline` must name the clock `ready_at_ms` was actually measured
1253    /// on — the caller's job, not something this method can infer from
1254    /// `ready_at_ms` being present. A Postgres retry mirroring the nack
1255    /// UPDATE's `run_at = NOW() + backoff` locally, for instance, still
1256    /// reads `state.clock()` (the registry's own, [`PgMarkTimeline::Registry`])
1257    /// even though it always supplies `Some`, unlike an `enqueue_at`/
1258    /// `enqueue_in` mark, which is real time ([`PgMarkTimeline::Real`]) —
1259    /// `Some`-ness alone does not say which.
1260    pub(crate) fn record_pg_enqueue(
1261        &self,
1262        name: &str,
1263        id: &str,
1264        ready_at_ms: Option<u64>,
1265        timeline: PgMarkTimeline,
1266    ) {
1267        let (ready_at_ms, timeline) = ready_at_ms.map_or_else(
1268            || (self.now_ms(), PgMarkTimeline::Registry),
1269            |ms| (ms, timeline),
1270        );
1271        self.record_enqueue_at(name, ready_at_ms);
1272        self.note_pg_job_mark(id, ready_at_ms, timeline);
1273    }
1274
1275    /// Shared enqueue bookkeeping: bump the per-name `queued` counter and push a
1276    /// per-queue waiting mark stamped with the job's ready-at time.
1277    fn record_enqueue_at(&self, name: &str, ready_at_ms: u64) {
1278        // Probe both maps by borrow: the name is registered at runtime start,
1279        // so owned keys are only built for a never-seen name or queue.
1280        if let Ok(mut guard) = self.inner.write() {
1281            #[allow(clippy::option_if_let_else)] // the else branch re-borrows the map mutably
1282            if let Some(status) = guard.get_mut(name) {
1283                status.queued = status.queued.saturating_add(1);
1284            } else {
1285                guard.insert(
1286                    name.to_owned(),
1287                    JobStatus {
1288                        queued: 1,
1289                        ..JobStatus::empty()
1290                    },
1291                );
1292            }
1293        }
1294        if let Ok(mut guard) = self.queues.write() {
1295            #[allow(clippy::option_if_let_else)] // the else branch re-borrows the map mutably
1296            if let Some(waiting) = guard.waiting_for_mut(name) {
1297                waiting.push_back(ready_at_ms);
1298            } else {
1299                let queue = guard.queue_of(name).to_owned();
1300                guard
1301                    .waiting
1302                    .entry(queue)
1303                    .or_default()
1304                    .push_back(ready_at_ms);
1305            }
1306        }
1307    }
1308
1309    /// Record that an enqueue was coalesced into an existing unique job.
1310    ///
1311    /// Reverses the `record_enqueue` bookkeeping for the coalesced instance
1312    /// and bumps the deduplication counter.
1313    ///
1314    /// `had_enqueue_mark` says whether this coalesced job previously pushed a
1315    /// per-queue waiting mark (via `record_enqueue`/`record_enqueue_scheduled`).
1316    /// Only then is a mark popped: retry-dedup paths coalesce a job that already
1317    /// left the ready set at start time and never re-recorded an enqueue, so
1318    /// popping there would steal a *different* waiting job's mark and under-report
1319    /// that queue's depth. Every pop must correspond to a prior push.
1320    ///
1321    /// `was_scheduled` says which category the coalesced enqueue recorded: a
1322    /// delayed enqueue (`record_enqueue_scheduled`, future ready-at) pushed a
1323    /// *scheduled* mark, an immediate one (`record_enqueue`) a *ready* mark. The
1324    /// removal must target that same category — otherwise a delayed duplicate's
1325    /// dedup could pop a co-queued *ready* job's mark, reporting queue depth 0
1326    /// while ready work is still waiting (and vice versa). Mirrors the
1327    /// category-aware cancel path (`record_cancel`/`record_cancel_scheduled`).
1328    /// Ignored when `had_enqueue_mark` is false (no mark is popped).
1329    pub fn record_deduplicated(&self, name: &str, had_enqueue_mark: bool, was_scheduled: bool) {
1330        if let Ok(mut guard) = self.inner.write()
1331            && let Some(status) = guard.get_mut(name)
1332        {
1333            status.queued = status.queued.saturating_sub(1);
1334            status.total_deduplicated = status.total_deduplicated.saturating_add(1);
1335        }
1336        if !had_enqueue_mark {
1337            return;
1338        }
1339        // The coalesced enqueue never runs; drop its waiting mark from the SAME
1340        // ready/scheduled category it recorded (prefer_ready = !was_scheduled),
1341        // so it cannot steal a co-queued mark from the other category.
1342        self.pop_waiting(name, !was_scheduled);
1343    }
1344
1345    /// Record that a job is parked waiting on a free concurrency slot.
1346    pub fn record_concurrency_blocked(&self, name: &str) {
1347        if let Ok(mut guard) = self.inner.write()
1348            && let Some(status) = guard.get_mut(name)
1349        {
1350            status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_add(1);
1351        }
1352    }
1353
1354    /// Record that a parked job was released back to the queue.
1355    pub fn record_concurrency_unblocked(&self, name: &str) {
1356        if let Ok(mut guard) = self.inner.write()
1357            && let Some(status) = guard.get_mut(name)
1358        {
1359            status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_sub(1);
1360        }
1361    }
1362
1363    /// Replace the blocked-on-concurrency gauges from a backend-wide survey.
1364    ///
1365    /// Names absent from `counts` are reset to zero. Used by the durable
1366    /// backends whose blocked set is observed periodically rather than
1367    /// tracked per event.
1368    pub fn set_concurrency_blocked_counts(&self, counts: &HashMap<String, u64>) {
1369        if let Ok(mut guard) = self.inner.write() {
1370            for (name, status) in guard.iter_mut() {
1371                status.blocked_on_concurrency = counts.get(name).copied().unwrap_or(0);
1372            }
1373        }
1374    }
1375
1376    /// Replace the per-queue depth/oldest-age gauges from a backend-wide survey
1377    /// (issue #1752).
1378    ///
1379    /// On the durable backends (Postgres/Redis) a queue is drained by other
1380    /// processes, so the authoritative ready depth and oldest-waiting age come
1381    /// from a periodic survey of the durable store rather than this process's
1382    /// local enqueue marks. This wholesale-replaces the snapshot each tick:
1383    /// queues absent from `per_queue` reset to depth 0 (no leaks). Each value
1384    /// is `(ready depth, oldest ready-at epoch ms)`; the reported age is
1385    /// derived from the timestamp at [`Self::queue_snapshot`] time so it stays
1386    /// fresh between surveys. Once called, the survey overrides the in-memory
1387    /// `record_*` marks for the `queues` gauge family.
1388    pub fn set_queue_depth_gauges(&self, per_queue: &HashMap<String, (u64, Option<u64>)>) {
1389        if let Ok(mut guard) = self.queues.write() {
1390            guard.surveyed = Some(per_queue.clone());
1391        }
1392    }
1393
1394    /// Replace the per-job-type `queued` gauge from a backend-wide survey
1395    /// (issue #1752).
1396    ///
1397    /// Names absent from `counts` reset to zero, mirroring
1398    /// [`Self::set_concurrency_blocked_counts`]. Used by the durable backends
1399    /// whose ready depth is observed periodically rather than tracked per
1400    /// enqueue/start event, so the reported `jobs.<name>.queued` reflects the
1401    /// shared durable backlog instead of this process's local enqueue marks.
1402    pub fn set_queued_counts(&self, counts: &HashMap<String, u64>) {
1403        if let Ok(mut guard) = self.inner.write() {
1404            for (name, status) in guard.iter_mut() {
1405                status.queued = counts.get(name).copied().unwrap_or(0);
1406            }
1407        }
1408    }
1409
1410    /// Record that a queued job started execution.
1411    pub fn record_start(&self, name: &str) {
1412        if let Ok(mut guard) = self.inner.write()
1413            && let Some(status) = guard.get_mut(name)
1414        {
1415            status.queued = status.queued.saturating_sub(1);
1416            status.in_flight = status.in_flight.saturating_add(1);
1417        }
1418        self.pop_waiting(name, true);
1419    }
1420
1421    /// [`Self::record_start`] for a Postgres-backed job, additionally
1422    /// removing `id`'s own `pg_marks_by_job_id` entry — precisely, not via
1423    /// the generic [`Self::pop_waiting`] plain `record_start` delegates to.
1424    ///
1425    /// `pop_waiting` does not know about job identity: it removes whichever
1426    /// ready mark comes first in this queue's internal order, which is not
1427    /// necessarily `id`'s own mark — Postgres can start a later-enqueued job
1428    /// ahead of an older one this process still has concurrency-blocked.
1429    /// Popping generically and then unconditionally forgetting `id`'s entry
1430    /// would tear down `id`'s mapping even when its actual mark never left
1431    /// the queue, while some other still-waiting job's mark vanished in its
1432    /// place. Look up and remove `id`'s own mark first instead, falling back
1433    /// to the generic pop (matching plain `record_start`) only when no exact
1434    /// entry exists or it is already stale — the same stale-degrades-to-
1435    /// fallback pattern [`Self::record_cancel_at_backend_offset`] uses — so a
1436    /// cancel racing this start never finds a torn-down mapping for a mark
1437    /// that in fact never left the queue. A non-terminal retry re-adds the
1438    /// entry with the retry's own mark via [`Self::record_pg_enqueue`]; a
1439    /// job that succeeds or terminally fails never needs it again.
1440    #[cfg_attr(not(feature = "postgres"), allow(dead_code))]
1441    pub(crate) fn record_pg_start(&self, name: &str, id: &str) {
1442        if let Ok(mut guard) = self.inner.write()
1443            && let Some(status) = guard.get_mut(name)
1444        {
1445            status.queued = status.queued.saturating_sub(1);
1446            status.in_flight = status.in_flight.saturating_add(1);
1447        }
1448        let exact_ms = self
1449            .queues
1450            .write()
1451            .ok()
1452            .and_then(|mut guard| {
1453                let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1454                guard.pg_marks_seq_order.remove(&removed.seq);
1455                Some(removed)
1456            })
1457            .map(|mark| mark.ms);
1458        let removed_exact = exact_ms.is_some_and(|exact_ms| self.pop_waiting_exact(name, exact_ms));
1459        if !removed_exact {
1460            self.pop_waiting(name, true);
1461        }
1462    }
1463
1464    /// Forget `id`'s `pg_marks_by_job_id` entry without touching any waiting
1465    /// mark — for an enqueue attempt that [`Self::record_pg_enqueue`]
1466    /// registered provisionally but that never became a real queued row: a
1467    /// dedup coalesce into an existing unique job, a backend error, or an
1468    /// interceptor that skipped the actual enqueue. That `id` will never be
1469    /// looked up by an admin-cancel — no row was ever inserted under it — so
1470    /// leaving the entry in place would only crowd out a genuinely
1471    /// still-queued job's mapping under [`PG_MARKS_BY_JOB_ID_CAP`] for no
1472    /// benefit. A no-op if `id` was never entered (a non-Postgres backend,
1473    /// or an entry already removed).
1474    pub(crate) fn forget_pg_job_mark(&self, id: &str) {
1475        if let Ok(mut guard) = self.queues.write()
1476            && let Some(removed) = guard.pg_marks_by_job_id.swap_remove(id)
1477        {
1478            guard.pg_marks_seq_order.remove(&removed.seq);
1479        }
1480    }
1481
1482    /// Record that a queued job was canceled before execution.
1483    pub fn record_cancel(&self, name: &str) {
1484        if let Ok(mut guard) = self.inner.write()
1485            && let Some(status) = guard.get_mut(name)
1486        {
1487            status.queued = status.queued.saturating_sub(1);
1488        }
1489        self.pop_waiting(name, true);
1490    }
1491
1492    /// Record that a scheduled (delayed) job was canceled before its ready time.
1493    ///
1494    /// Unlike [`Self::record_cancel`], this removes a *scheduled* waiting mark
1495    /// (ready-at still in the future) so canceling a not-yet-runnable job does
1496    /// not consume a ready job's mark and under-report queue depth.
1497    pub fn record_cancel_scheduled(&self, name: &str) {
1498        if let Ok(mut guard) = self.inner.write()
1499            && let Some(status) = guard.get_mut(name)
1500        {
1501            status.queued = status.queued.saturating_sub(1);
1502        }
1503        self.pop_waiting(name, false);
1504    }
1505
1506    /// Remember the exact per-queue waiting mark a Postgres-backed enqueue
1507    /// pushed, keyed by job id, so a later [`Self::record_cancel_at_backend_offset`]
1508    /// can remove precisely that mark instead of guessing which of several
1509    /// co-queued marks belongs to it.
1510    ///
1511    /// Bounded by [`PG_MARKS_BY_JOB_ID_CAP`]: a job that falls out (or was
1512    /// never entered — see that method's fallback) is not lost, it just
1513    /// falls back to candidate-based matching there.
1514    ///
1515    /// This table is per-process, in-memory state — in a split web/worker
1516    /// deployment, a web replica that only enqueues (`run_workers == false`,
1517    /// see `start_postgres_runtime`) never calls [`Self::record_pg_start`],
1518    /// so every entry it inserts would otherwise sit here until capacity
1519    /// eviction, regardless of whether some other replica has long since
1520    /// claimed and finished the job. Eviction therefore prefers an entry
1521    /// that is already due: Postgres never hands a row to a claimer before
1522    /// its own `run_at`, so an entry still in the future is *guaranteed* to
1523    /// be a live, uncontested "enqueued but not yet claimable" mark, while
1524    /// an already-due one may well have been claimed by this process or
1525    /// another already.
1526    ///
1527    /// "Already due" has to be judged carefully: values in this table sit on
1528    /// two different timelines depending on how they were enqueued — an
1529    /// absolute (`enqueue_at`) or relative-delay (`enqueue_in`) mark is real
1530    /// time (`JobClient::due_origin`'s Postgres convention), while an
1531    /// immediate one is this registry's own (possibly injected/virtual)
1532    /// clock (see [`Self::record_pg_enqueue`]). Each entry carries a
1533    /// [`PgMarkTimeline`] tag saying which, fixed unambiguously at
1534    /// insertion time, so eviction judges every mark against the one clock
1535    /// that actually measures it instead of guessing from its magnitude —
1536    /// comparing a mark against the *other*, irrelevant clock (or requiring
1537    /// both to agree, an earlier version of this check) can misjudge it
1538    /// whenever the registry clock diverges from wall time in either
1539    /// direction: a `FixedClock`/`TickingClock` far ahead of real time can
1540    /// make a genuinely-still-delayed real-time mark look "due" against the
1541    /// registry clock alone, while one far *behind* real time can make an
1542    /// already-claimed real-time mark look "not yet due" against the
1543    /// registry clock forever, blinding eviction to every real-timeline
1544    /// entry and leaving it to fall back on oldest-inserted regardless of
1545    /// which entry that happens to be. Falling back to oldest-inserted
1546    /// (matching this method's pre-existing behavior) still happens when no
1547    /// entry's own clock calls it due.
1548    pub(crate) fn note_pg_job_mark(&self, id: &str, ready_at_ms: u64, timeline: PgMarkTimeline) {
1549        if let Ok(mut guard) = self.queues.write() {
1550            if guard.pg_marks_by_job_id.len() >= PG_MARKS_BY_JOB_ID_CAP
1551                && !guard.pg_marks_by_job_id.contains_key(id)
1552            {
1553                let registry_now = self.now_ms();
1554                // Real-timeline marks follow Postgres's own clock, so read the real clock.
1555                #[allow(
1556                    clippy::disallowed_methods,
1557                    reason = "Real marks are measured by Postgres clock_timestamp(), a real clock"
1558                )]
1559                let real_now =
1560                    u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(u64::MAX);
1561                // Walk oldest-first (ascending `seq`) and stop at the first
1562                // entry that is actually due; only when none is found (the
1563                // loop runs to completion) fall back to the oldest entry
1564                // overall (`pg_marks_seq_order`'s own first key). See
1565                // `pg_marks_seq_order`'s doc comment for why this is not a
1566                // full scan in the common case.
1567                let evict_id = guard
1568                    .pg_marks_seq_order
1569                    .iter()
1570                    .find_map(|(_, candidate_id)| {
1571                        let mark = guard.pg_marks_by_job_id.get(candidate_id)?;
1572                        let due = match mark.timeline {
1573                            PgMarkTimeline::Real => mark.ms <= real_now,
1574                            PgMarkTimeline::Registry => mark.ms <= registry_now,
1575                        };
1576                        due.then(|| candidate_id.clone())
1577                    })
1578                    .or_else(|| guard.pg_marks_seq_order.values().next().cloned());
1579                if let Some(evict_id) = evict_id
1580                    && let Some(evicted) = guard.pg_marks_by_job_id.swap_remove(&evict_id)
1581                {
1582                    guard.pg_marks_seq_order.remove(&evicted.seq);
1583                }
1584            }
1585            let seq = guard.pg_marks_next_seq;
1586            guard.pg_marks_next_seq = guard.pg_marks_next_seq.wrapping_add(1);
1587            guard.pg_marks_seq_order.insert(seq, id.to_string());
1588            guard.pg_marks_by_job_id.insert(
1589                id.to_string(),
1590                PgMark {
1591                    ms: ready_at_ms,
1592                    timeline,
1593                    seq,
1594                },
1595            );
1596        }
1597    }
1598
1599    /// Record that an enqueued Postgres-backed job was canceled, given the
1600    /// database's own measurement of its due time, and remove precisely the
1601    /// waiting mark that job pushed.
1602    ///
1603    /// Looks up `id` in the exact-mark table `note_pg_job_mark` populated
1604    /// at enqueue time first — this is unambiguous by
1605    /// construction, immune to two co-queued marks coincidentally landing at
1606    /// (or near) the same instant. Falls back to nearest-match over three
1607    /// candidate timelines only when no exact entry exists (evicted for
1608    /// capacity, or never recorded — the transactional `enqueue_on_conn`
1609    /// path skips registry bookkeeping entirely, by design, until its
1610    /// surrounding transaction commits):
1611    ///
1612    /// * an **absolute** enqueue (`enqueue_at`) stamps the registry mark with
1613    ///   the caller's own instant verbatim, byte-identical to `absolute_ms`
1614    ///   (Postgres's stored `run_at`, forwarded unchanged) — no clock read
1615    ///   in between, so this candidate matches it exactly;
1616    /// * a **relative-delay** enqueue (`enqueue_in`) stamps the mark from
1617    ///   `JobClient::due_origin`'s real-time reading at enqueue time, so
1618    ///   `real_reference_ms + offset_ms` (translating the database's
1619    ///   `run_at - clock_timestamp()` onto that same real timeline)
1620    ///   reconstructs it;
1621    /// * an **immediate** enqueue stamps the mark from this registry's own
1622    ///   injected clock (`record_enqueue`), so `self.now_ms() + offset_ms`
1623    ///   reconstructs it instead.
1624    ///
1625    /// The fallback mirrors [`ready_at_from_age`]'s translate-onto-a-known-timeline
1626    /// approach, tried against all three candidates: the nearest existing
1627    /// mark to *any* candidate is removed, instead of deciding a
1628    /// ready/scheduled category from the offset and asking this registry's
1629    /// internal category search to find any mark sharing it, which is only
1630    /// correct when every mark in the queue shares one timeline with the
1631    /// offset. It can still, rarely, pick an unrelated co-queued job's mark
1632    /// (the reason the exact lookup exists at all) — that mismatch corrects
1633    /// itself at the next durable-backend survey tick.
1634    ///
1635    /// An exact entry can still, in principle, be stale — a caller that
1636    /// pushes a waiting mark without going through `record_pg_start`/
1637    /// `record_pg_enqueue`'s matched clear-then-reset pair (`Self::record_start`
1638    /// itself, used directly by the local/redis backends, never touches
1639    /// `pg_marks_by_job_id` at all) could leave an entry pointing at a value
1640    /// no longer in the queue. The exact removal reports whether it actually
1641    /// removed something, and this only trusts the exact lookup when it did
1642    /// — a stale entry falls through to the same candidate fallback as no
1643    /// entry at all, rather than silently leaving the job's real mark in
1644    /// place.
1645    pub fn record_cancel_at_backend_offset(
1646        &self,
1647        name: &str,
1648        id: &str,
1649        absolute_ms: Option<u64>,
1650        real_reference_ms: u64,
1651        offset_ms: i64,
1652    ) {
1653        if let Ok(mut guard) = self.inner.write()
1654            && let Some(status) = guard.get_mut(name)
1655        {
1656            status.queued = status.queued.saturating_sub(1);
1657        }
1658        let exact_ms = self
1659            .queues
1660            .write()
1661            .ok()
1662            .and_then(|mut guard| {
1663                let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1664                guard.pg_marks_seq_order.remove(&removed.seq);
1665                Some(removed)
1666            })
1667            .map(|mark| mark.ms);
1668        if let Some(exact_ms) = exact_ms
1669            && self.pop_waiting_exact(name, exact_ms)
1670        {
1671            return;
1672        }
1673        let mut candidates: Vec<u64> = Vec::with_capacity(3);
1674        candidates.extend(absolute_ms);
1675        candidates.push(real_reference_ms.saturating_add_signed(offset_ms));
1676        candidates.push(self.now_ms().saturating_add_signed(offset_ms));
1677        self.pop_waiting_nearest(name, &candidates);
1678    }
1679
1680    /// Drop one waiting mark for this job's queue (its wait is over).
1681    ///
1682    /// `prefer_ready` picks which mark to remove when the queue holds a mix of
1683    /// ready and still-scheduled marks: a starting/canceled ready job removes an
1684    /// already-claimable mark, while a canceled scheduled job removes a future
1685    /// one. If no mark in the preferred category exists we fall back to the
1686    /// oldest mark so every enqueue still has a matching removal (no leak).
1687    fn pop_waiting(&self, name: &str, prefer_ready: bool) {
1688        let now = self.now_ms();
1689        // The queue is looked up by borrow under the write lock taken here
1690        // anyway, instead of copied out under a separate read lock first.
1691        if let Ok(mut guard) = self.queues.write()
1692            && let Some(waiting) = guard.waiting_for_mut(name)
1693        {
1694            let idx = waiting
1695                .iter()
1696                .position(|ready_at| (*ready_at <= now) == prefer_ready)
1697                .or(if waiting.is_empty() { None } else { Some(0) });
1698            if let Some(idx) = idx {
1699                waiting.remove(idx);
1700            }
1701        }
1702    }
1703
1704    /// Drop the waiting mark closest to any of `candidates`, each expressed
1705    /// on whatever timeline the caller resolved it against. A queue with no
1706    /// marks removes nothing — there is nothing to leak.
1707    fn pop_waiting_nearest(&self, name: &str, candidates: &[u64]) {
1708        if let Ok(mut guard) = self.queues.write()
1709            && let Some(waiting) = guard.waiting_for_mut(name)
1710        {
1711            let idx = waiting
1712                .iter()
1713                .enumerate()
1714                .min_by_key(|(_, ready_at)| {
1715                    candidates
1716                        .iter()
1717                        .map(|target| ready_at.abs_diff(*target))
1718                        .min()
1719                })
1720                .map(|(idx, _)| idx);
1721            if let Some(idx) = idx {
1722                waiting.remove(idx);
1723            }
1724        }
1725    }
1726
1727    /// Drop the waiting mark equal to `exact_ms`, returning whether one was
1728    /// found. A no-op (returning `false`) when `exact_ms` is not present —
1729    /// e.g. it is a stale entry pointing at a mark something else already
1730    /// removed — so the caller can fall back to a heuristic guess rather
1731    /// than silently leaving the job's real, current mark in place.
1732    fn pop_waiting_exact(&self, name: &str, exact_ms: u64) -> bool {
1733        if let Ok(mut guard) = self.queues.write()
1734            && let Some(waiting) = guard.waiting_for_mut(name)
1735            && let Some(idx) = waiting.iter().position(|mark| *mark == exact_ms)
1736        {
1737            waiting.remove(idx);
1738            return true;
1739        }
1740        false
1741    }
1742
1743    /// The raw waiting marks (epoch ms) for `name`'s queue, in push order.
1744    ///
1745    /// Test-only: asserting on this instead of [`Self::queue_snapshot`]'s
1746    /// depth avoids that method's own `now_ms()`-relative bucketing, which a
1747    /// test deliberately pinning the registry's clock far from a mark's
1748    /// timeline would otherwise contaminate.
1749    #[cfg(test)]
1750    pub(crate) fn waiting_marks_for_test(&self, name: &str) -> Vec<u64> {
1751        self.queues
1752            .read()
1753            .ok()
1754            .and_then(|g| {
1755                g.waiting
1756                    .get(g.queue_of(name))
1757                    .map(|w| w.iter().copied().collect())
1758            })
1759            .unwrap_or_default()
1760    }
1761
1762    /// The number of entries currently held in `pg_marks_by_job_id`.
1763    /// Test-only: proves the bound in [`PG_MARKS_BY_JOB_ID_CAP`] actually
1764    /// holds without exposing the table itself.
1765    #[cfg(test)]
1766    pub(crate) fn pg_marks_len_for_test(&self) -> usize {
1767        self.queues.read().map_or(0, |g| g.pg_marks_by_job_id.len())
1768    }
1769
1770    /// Whether `pg_marks_seq_order` currently mirrors `pg_marks_by_job_id`'s
1771    /// keys exactly (same length, same ids) — the invariant every insert and
1772    /// removal on that table must maintain. Test-only.
1773    #[cfg(test)]
1774    pub(crate) fn pg_marks_seq_order_is_consistent_for_test(&self) -> bool {
1775        self.queues.read().is_ok_and(|g| {
1776            g.pg_marks_seq_order.len() == g.pg_marks_by_job_id.len()
1777                && g.pg_marks_seq_order
1778                    .values()
1779                    .all(|id| g.pg_marks_by_job_id.contains_key(id))
1780        })
1781    }
1782
1783    /// Whether `pg_marks_by_job_id` currently holds an entry for `id`.
1784    /// Test-only.
1785    #[cfg(test)]
1786    pub(crate) fn pg_mark_contains_id_for_test(&self, id: &str) -> bool {
1787        self.queues
1788            .read()
1789            .is_ok_and(|g| g.pg_marks_by_job_id.contains_key(id))
1790    }
1791
1792    /// [`PG_MARKS_BY_JOB_ID_CAP`], for a test in another module (e.g.
1793    /// `job.rs`) that needs to force capacity eviction without duplicating
1794    /// the constant. Test-only.
1795    #[cfg(test)]
1796    #[allow(dead_code, reason = "used only by feature-gated job tests")]
1797    pub(crate) const fn pg_marks_cap_for_test() -> usize {
1798        PG_MARKS_BY_JOB_ID_CAP
1799    }
1800
1801    /// Record a successful execution.
1802    pub fn record_success(&self, name: &str) {
1803        if let Ok(mut guard) = self.inner.write()
1804            && let Some(status) = guard.get_mut(name)
1805        {
1806            status.in_flight = status.in_flight.saturating_sub(1);
1807            status.total_successes = status.total_successes.saturating_add(1);
1808            status.last_error = None;
1809        }
1810    }
1811
1812    /// Record a retriable failure.
1813    pub fn record_retry(&self, name: &str, error: &str, _attempt: u32) {
1814        if let Ok(mut guard) = self.inner.write()
1815            && let Some(status) = guard.get_mut(name)
1816        {
1817            status.in_flight = status.in_flight.saturating_sub(1);
1818            status.last_error = Some(error.to_string());
1819        }
1820    }
1821
1822    /// Record a terminal failure.
1823    pub fn record_failure(&self, name: &str, error: String, dead_lettered: bool) {
1824        self.record_failure_inner(name, error, dead_lettered, true);
1825    }
1826
1827    /// Count a start again that a newer attempt already balanced. The caller
1828    /// then makes the `record_*` call that ends the run, which decrements once.
1829    #[cfg(any(feature = "db", feature = "redis"))]
1830    pub(crate) fn restore_in_flight(&self, name: &str) {
1831        if let Ok(mut guard) = self.inner.write()
1832            && let Some(status) = guard.get_mut(name)
1833        {
1834            status.in_flight = status.in_flight.saturating_add(1);
1835        }
1836    }
1837
1838    /// Record a terminal failure of a run this process did not start.
1839    /// Count the failure. Leave `in_flight` alone.
1840    #[cfg(any(feature = "db", feature = "redis"))]
1841    pub(crate) fn record_failure_not_started(
1842        &self,
1843        name: &str,
1844        error: String,
1845        dead_lettered: bool,
1846    ) {
1847        self.record_failure_inner(name, error, dead_lettered, false);
1848    }
1849
1850    fn record_failure_inner(&self, name: &str, error: String, dead_lettered: bool, started: bool) {
1851        if let Ok(mut guard) = self.inner.write()
1852            && let Some(status) = guard.get_mut(name)
1853        {
1854            if started {
1855                status.in_flight = status.in_flight.saturating_sub(1);
1856            }
1857            status.total_failures = status.total_failures.saturating_add(1);
1858            status.last_error = Some(error);
1859            if dead_lettered {
1860                status.dead_letters = status.dead_letters.saturating_add(1);
1861            }
1862        }
1863    }
1864
1865    /// Snapshot all registered jobs.
1866    #[must_use]
1867    pub fn snapshot(&self) -> HashMap<String, JobStatus> {
1868        self.inner.read().map(|g| g.clone()).unwrap_or_default()
1869    }
1870
1871    /// Add `count` dead letters that a trim removed.
1872    pub fn record_dead_letter_trimmed(&self, count: u64) {
1873        self.dead_letter_trimmed
1874            .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
1875    }
1876
1877    /// Total dead letters that trims removed in this process.
1878    #[must_use]
1879    pub fn dead_letter_trimmed_total(&self) -> u64 {
1880        self.dead_letter_trimmed
1881            .load(std::sync::atomic::Ordering::Relaxed)
1882    }
1883}
1884
1885impl Default for JobRegistry {
1886    fn default() -> Self {
1887        Self::new()
1888    }
1889}
1890
1891impl TaskRegistry {
1892    /// Create a new empty task registry.
1893    #[must_use]
1894    pub fn new() -> Self {
1895        Self {
1896            inner: Arc::new(RwLock::new(HashMap::new())),
1897        }
1898    }
1899
1900    /// Register a task with its schedule description.
1901    pub fn register(&self, name: &str, schedule: &str) {
1902        self.register_scheduled(
1903            name,
1904            schedule,
1905            crate::task::TaskCoordination::Fleet,
1906            "in_process",
1907            "unknown",
1908        );
1909    }
1910
1911    /// Register a scheduled task with scheduler coordination metadata.
1912    pub fn register_scheduled(
1913        &self,
1914        name: &str,
1915        schedule: &str,
1916        coordination: crate::task::TaskCoordination,
1917        scheduler_backend: &str,
1918        replica_id: &str,
1919    ) {
1920        let Ok(mut guard) = self.inner.write() else {
1921            return;
1922        };
1923        guard.insert(
1924            name.to_string(),
1925            TaskStatus {
1926                schedule: schedule.to_string(),
1927                coordination,
1928                scheduler_backend: scheduler_backend.to_string(),
1929                replica_id: replica_id.to_string(),
1930                current_leader: None,
1931                last_tick: None,
1932                last_fired_at: None,
1933                next_run_at: None,
1934                status: "idle".to_string(),
1935                last_run: None,
1936                last_duration_ms: None,
1937                last_result: None,
1938                last_error: None,
1939                total_runs: 0,
1940                total_failures: 0,
1941            },
1942        );
1943    }
1944
1945    /// Record the replica that acquired leadership for a global task tick.
1946    pub fn record_leader(&self, name: &str, leader_id: &str, tick_key: &str) {
1947        let Ok(mut guard) = self.inner.write() else {
1948            return;
1949        };
1950        let Some(task) = guard.get_mut(name) else {
1951            return;
1952        };
1953        task.current_leader = Some(leader_id.to_string());
1954        task.last_tick = Some(tick_key.to_string());
1955    }
1956
1957    /// Record that a task started running.
1958    pub fn record_start(&self, name: &str) {
1959        let Ok(mut guard) = self.inner.write() else {
1960            return;
1961        };
1962        let Some(task) = guard.get_mut(name) else {
1963            return;
1964        };
1965        task.status = "running".to_string();
1966        task.next_run_at = None;
1967    }
1968
1969    /// Record the next scheduled run time for an idle task.
1970    pub fn record_next_run_at(&self, name: &str, next_run_at: &str) {
1971        let Ok(mut guard) = self.inner.write() else {
1972            return;
1973        };
1974        let Some(task) = guard.get_mut(name) else {
1975            return;
1976        };
1977        task.next_run_at = Some(next_run_at.to_string());
1978    }
1979
1980    /// Record that a task completed successfully.
1981    pub fn record_success(&self, name: &str, duration_ms: u64) {
1982        let Ok(mut guard) = self.inner.write() else {
1983            return;
1984        };
1985        let Some(task) = guard.get_mut(name) else {
1986            return;
1987        };
1988        task.status = "idle".to_string();
1989        let now = crate::time::ambient_now().to_rfc3339();
1990        task.last_run = Some(now.clone());
1991        task.last_fired_at = Some(now);
1992        task.last_duration_ms = Some(duration_ms);
1993        task.last_result = Some("ok".to_string());
1994        task.last_error = None;
1995        task.total_runs += 1;
1996    }
1997
1998    /// Record that a task failed.
1999    pub fn record_failure(&self, name: &str, duration_ms: u64, error: &str) {
2000        let Ok(mut guard) = self.inner.write() else {
2001            return;
2002        };
2003        let Some(task) = guard.get_mut(name) else {
2004            return;
2005        };
2006        task.status = "idle".to_string();
2007        let now = crate::time::ambient_now().to_rfc3339();
2008        task.last_run = Some(now.clone());
2009        task.last_fired_at = Some(now);
2010        task.last_duration_ms = Some(duration_ms);
2011        task.last_result = Some("failed".to_string());
2012        task.last_error = Some(error.to_string());
2013        task.total_runs += 1;
2014        task.total_failures += 1;
2015    }
2016
2017    /// Get a snapshot of all task statuses.
2018    #[must_use]
2019    pub fn snapshot(&self) -> HashMap<String, TaskStatus> {
2020        self.inner
2021            .read()
2022            .map(|guard| guard.clone())
2023            .unwrap_or_default()
2024    }
2025}
2026
2027impl Default for TaskRegistry {
2028    fn default() -> Self {
2029        Self::new()
2030    }
2031}
2032
2033impl std::fmt::Debug for TaskRegistry {
2034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035        f.debug_struct("TaskRegistry")
2036            .field("count", &self.snapshot().len())
2037            .finish()
2038    }
2039}
2040
2041/// Resolved config property with source provenance.
2042#[derive(Debug, Clone, Serialize, Deserialize)]
2043pub struct ConfigProperty {
2044    /// The resolved value (redacted if sensitive).
2045    pub value: serde_json::Value,
2046    /// Where the value came from.
2047    pub source: String,
2048}
2049
2050/// Collection of resolved config properties with source tracking.
2051#[derive(Debug, Clone, Default)]
2052pub struct ConfigProperties {
2053    inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
2054}
2055
2056impl ConfigProperties {
2057    /// Build config properties with source tracking from the loaded config.
2058    #[must_use]
2059    #[allow(clippy::too_many_lines)]
2060    pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
2061        let profile = config.profile.as_deref().unwrap_or("default");
2062        let defaults = crate::config::AutumnConfig::default();
2063
2064        // Avoids dynamic reallocation since we know roughly how many config properties are tracked.
2065        let mut props = HashMap::with_capacity(32);
2066        let profile_str = profile.to_string();
2067
2068        Self::track_server_props(&mut props, config, &defaults, &profile_str);
2069        Self::track_db_props(&mut props, config, &defaults, &profile_str);
2070        Self::track_log_props(&mut props, config, &defaults, &profile_str);
2071        Self::track_telemetry_props(&mut props, config, &defaults, &profile_str);
2072        Self::track_health_props(&mut props, config, &defaults, &profile_str);
2073        Self::track_actuator_props(&mut props, config, &defaults, &profile_str);
2074        Self::track_metrics_props(&mut props, config, &defaults, &profile_str);
2075        Self::track_session_props(&mut props, config, &defaults, &profile_str);
2076        Self::track_channels_props(&mut props, config, &defaults, &profile_str);
2077
2078        Self {
2079            inner: Arc::new(RwLock::new(props)),
2080        }
2081    }
2082
2083    fn track_server_props(
2084        props: &mut HashMap<String, ConfigProperty>,
2085        config: &crate::config::AutumnConfig,
2086        defaults: &crate::config::AutumnConfig,
2087        profile_str: &str,
2088    ) {
2089        Self::track_property(
2090            props,
2091            "server.host",
2092            &config.server.host,
2093            &defaults.server.host,
2094            profile_str,
2095        );
2096        Self::track_property(
2097            props,
2098            "server.port",
2099            &config.server.port.to_string(),
2100            &defaults.server.port.to_string(),
2101            profile_str,
2102        );
2103        Self::track_property(
2104            props,
2105            "server.shutdown_timeout_secs",
2106            &config.server.shutdown_timeout_secs.to_string(),
2107            &defaults.server.shutdown_timeout_secs.to_string(),
2108            profile_str,
2109        );
2110    }
2111
2112    fn track_db_props(
2113        props: &mut HashMap<String, ConfigProperty>,
2114        config: &crate::config::AutumnConfig,
2115        defaults: &crate::config::AutumnConfig,
2116        profile_str: &str,
2117    ) {
2118        let db_url = config.database.url.as_deref().unwrap_or("").to_string();
2119        let primary_url = config
2120            .database
2121            .primary_url
2122            .as_deref()
2123            .unwrap_or("")
2124            .to_string();
2125        let replica_url = config
2126            .database
2127            .replica_url
2128            .as_deref()
2129            .unwrap_or("")
2130            .to_string();
2131        Self::track_property(props, "database.url", &db_url, "", profile_str);
2132        Self::track_property(props, "database.primary_url", &primary_url, "", profile_str);
2133        Self::track_property(props, "database.replica_url", &replica_url, "", profile_str);
2134        Self::track_property(
2135            props,
2136            "database.pool_size",
2137            &config.database.pool_size.to_string(),
2138            &defaults.database.pool_size.to_string(),
2139            profile_str,
2140        );
2141        Self::track_property(
2142            props,
2143            "database.primary_pool_size",
2144            &config.database.effective_primary_pool_size().to_string(),
2145            &defaults.database.effective_primary_pool_size().to_string(),
2146            profile_str,
2147        );
2148        Self::track_property(
2149            props,
2150            "database.replica_pool_size",
2151            &config.database.effective_replica_pool_size().to_string(),
2152            &defaults.database.effective_replica_pool_size().to_string(),
2153            profile_str,
2154        );
2155        Self::track_property(
2156            props,
2157            "database.replica_fallback",
2158            &format!("{:?}", config.database.replica_fallback),
2159            &format!("{:?}", defaults.database.replica_fallback),
2160            profile_str,
2161        );
2162        Self::track_property(
2163            props,
2164            "database.replica_max_lag_ms",
2165            &format!("{:?}", config.database.replica_max_lag_ms),
2166            &format!("{:?}", defaults.database.replica_max_lag_ms),
2167            profile_str,
2168        );
2169    }
2170
2171    fn track_log_props(
2172        props: &mut HashMap<String, ConfigProperty>,
2173        config: &crate::config::AutumnConfig,
2174        defaults: &crate::config::AutumnConfig,
2175        profile_str: &str,
2176    ) {
2177        Self::track_property(
2178            props,
2179            "log.level",
2180            &config.log.level,
2181            &defaults.log.level,
2182            profile_str,
2183        );
2184        Self::track_property(
2185            props,
2186            "log.format",
2187            &format!("{:?}", config.log.format),
2188            &format!("{:?}", defaults.log.format),
2189            profile_str,
2190        );
2191        Self::track_property(
2192            props,
2193            "log.capture.enabled",
2194            &config.log.capture.enabled.to_string(),
2195            &defaults.log.capture.enabled.to_string(),
2196            profile_str,
2197        );
2198        Self::track_property(
2199            props,
2200            "log.capture.capacity",
2201            &config.log.capture.capacity.to_string(),
2202            &defaults.log.capture.capacity.to_string(),
2203            profile_str,
2204        );
2205    }
2206
2207    fn track_telemetry_props(
2208        props: &mut HashMap<String, ConfigProperty>,
2209        config: &crate::config::AutumnConfig,
2210        defaults: &crate::config::AutumnConfig,
2211        profile_str: &str,
2212    ) {
2213        Self::track_property(
2214            props,
2215            "telemetry.enabled",
2216            &config.telemetry.enabled.to_string(),
2217            &defaults.telemetry.enabled.to_string(),
2218            profile_str,
2219        );
2220        Self::track_property(
2221            props,
2222            "telemetry.service_name",
2223            &config.telemetry.service_name,
2224            &defaults.telemetry.service_name,
2225            profile_str,
2226        );
2227        Self::track_property(
2228            props,
2229            "telemetry.service_namespace",
2230            config.telemetry.service_namespace.as_deref().unwrap_or(""),
2231            defaults
2232                .telemetry
2233                .service_namespace
2234                .as_deref()
2235                .unwrap_or(""),
2236            profile_str,
2237        );
2238        Self::track_property(
2239            props,
2240            "telemetry.service_version",
2241            &config.telemetry.service_version,
2242            &defaults.telemetry.service_version,
2243            profile_str,
2244        );
2245        Self::track_property(
2246            props,
2247            "telemetry.environment",
2248            &config.telemetry.environment,
2249            &defaults.telemetry.environment,
2250            profile_str,
2251        );
2252        Self::track_property(
2253            props,
2254            "telemetry.otlp_endpoint",
2255            config.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2256            defaults.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2257            profile_str,
2258        );
2259        Self::track_property(
2260            props,
2261            "telemetry.protocol",
2262            &format!("{:?}", config.telemetry.protocol),
2263            &format!("{:?}", defaults.telemetry.protocol),
2264            profile_str,
2265        );
2266        Self::track_property(
2267            props,
2268            "telemetry.strict",
2269            &config.telemetry.strict.to_string(),
2270            &defaults.telemetry.strict.to_string(),
2271            profile_str,
2272        );
2273    }
2274
2275    fn track_health_props(
2276        props: &mut HashMap<String, ConfigProperty>,
2277        config: &crate::config::AutumnConfig,
2278        defaults: &crate::config::AutumnConfig,
2279        profile_str: &str,
2280    ) {
2281        Self::track_property(
2282            props,
2283            "health.path",
2284            &config.health.path,
2285            &defaults.health.path,
2286            profile_str,
2287        );
2288        Self::track_property(
2289            props,
2290            "health.live_path",
2291            &config.health.live_path,
2292            &defaults.health.live_path,
2293            profile_str,
2294        );
2295        Self::track_property(
2296            props,
2297            "health.ready_path",
2298            &config.health.ready_path,
2299            &defaults.health.ready_path,
2300            profile_str,
2301        );
2302        Self::track_property(
2303            props,
2304            "health.startup_path",
2305            &config.health.startup_path,
2306            &defaults.health.startup_path,
2307            profile_str,
2308        );
2309        Self::track_property(
2310            props,
2311            "health.detailed",
2312            &config.health.detailed.to_string(),
2313            &defaults.health.detailed.to_string(),
2314            profile_str,
2315        );
2316    }
2317
2318    fn track_actuator_props(
2319        props: &mut HashMap<String, ConfigProperty>,
2320        config: &crate::config::AutumnConfig,
2321        defaults: &crate::config::AutumnConfig,
2322        profile_str: &str,
2323    ) {
2324        Self::track_property(
2325            props,
2326            "actuator.prefix",
2327            &config.actuator.prefix,
2328            &defaults.actuator.prefix,
2329            profile_str,
2330        );
2331        Self::track_property(
2332            props,
2333            "actuator.sensitive",
2334            &config.actuator.sensitive.to_string(),
2335            &defaults.actuator.sensitive.to_string(),
2336            profile_str,
2337        );
2338        Self::track_property(
2339            props,
2340            "actuator.prometheus",
2341            &config.actuator.prometheus.to_string(),
2342            &defaults.actuator.prometheus.to_string(),
2343            profile_str,
2344        );
2345    }
2346
2347    fn track_metrics_props(
2348        props: &mut HashMap<String, ConfigProperty>,
2349        config: &crate::config::AutumnConfig,
2350        defaults: &crate::config::AutumnConfig,
2351        profile_str: &str,
2352    ) {
2353        Self::track_property(
2354            props,
2355            "metrics.max_series_per_metric",
2356            &config.metrics.max_series_per_metric.to_string(),
2357            &defaults.metrics.max_series_per_metric.to_string(),
2358            profile_str,
2359        );
2360        Self::track_property(
2361            props,
2362            "metrics.max_instruments",
2363            &config.metrics.max_instruments.to_string(),
2364            &defaults.metrics.max_instruments.to_string(),
2365            profile_str,
2366        );
2367        Self::track_property(
2368            props,
2369            "metrics.max_labels_per_series",
2370            &config.metrics.max_labels_per_series.to_string(),
2371            &defaults.metrics.max_labels_per_series.to_string(),
2372            profile_str,
2373        );
2374    }
2375
2376    fn track_session_props(
2377        props: &mut HashMap<String, ConfigProperty>,
2378        config: &crate::config::AutumnConfig,
2379        defaults: &crate::config::AutumnConfig,
2380        profile_str: &str,
2381    ) {
2382        Self::track_property(
2383            props,
2384            "session.backend",
2385            &format!("{:?}", config.session.backend),
2386            &format!("{:?}", defaults.session.backend),
2387            profile_str,
2388        );
2389        Self::track_property(
2390            props,
2391            "session.cookie_name",
2392            &config.session.cookie_name,
2393            &defaults.session.cookie_name,
2394            profile_str,
2395        );
2396        Self::track_property(
2397            props,
2398            "session.max_age_secs",
2399            &config.session.max_age_secs.to_string(),
2400            &defaults.session.max_age_secs.to_string(),
2401            profile_str,
2402        );
2403        Self::track_property(
2404            props,
2405            "session.secure",
2406            &config.session.secure.to_string(),
2407            &defaults.session.secure.to_string(),
2408            profile_str,
2409        );
2410        Self::track_property(
2411            props,
2412            "session.same_site",
2413            &config.session.same_site,
2414            &defaults.session.same_site,
2415            profile_str,
2416        );
2417        Self::track_property(
2418            props,
2419            "session.http_only",
2420            &config.session.http_only.to_string(),
2421            &defaults.session.http_only.to_string(),
2422            profile_str,
2423        );
2424        Self::track_property(
2425            props,
2426            "session.path",
2427            &config.session.path,
2428            &defaults.session.path,
2429            profile_str,
2430        );
2431        Self::track_property(
2432            props,
2433            "session.allow_memory_in_production",
2434            &config.session.allow_memory_in_production.to_string(),
2435            &defaults.session.allow_memory_in_production.to_string(),
2436            profile_str,
2437        );
2438        Self::track_property(
2439            props,
2440            "session.redis.url",
2441            config.session.redis.url.as_deref().unwrap_or(""),
2442            defaults.session.redis.url.as_deref().unwrap_or(""),
2443            profile_str,
2444        );
2445        Self::track_property(
2446            props,
2447            "session.redis.key_prefix",
2448            &config.session.redis.key_prefix,
2449            &defaults.session.redis.key_prefix,
2450            profile_str,
2451        );
2452    }
2453
2454    fn track_channels_props(
2455        props: &mut HashMap<String, ConfigProperty>,
2456        config: &crate::config::AutumnConfig,
2457        defaults: &crate::config::AutumnConfig,
2458        profile_str: &str,
2459    ) {
2460        Self::track_property(
2461            props,
2462            "channels.backend",
2463            &format!("{:?}", config.channels.backend),
2464            &format!("{:?}", defaults.channels.backend),
2465            profile_str,
2466        );
2467        Self::track_property(
2468            props,
2469            "channels.capacity",
2470            &config.channels.capacity.to_string(),
2471            &defaults.channels.capacity.to_string(),
2472            profile_str,
2473        );
2474        Self::track_property(
2475            props,
2476            "channels.redis.url",
2477            config.channels.redis.url.as_deref().unwrap_or(""),
2478            defaults.channels.redis.url.as_deref().unwrap_or(""),
2479            profile_str,
2480        );
2481        Self::track_property(
2482            props,
2483            "channels.redis.key_prefix",
2484            &config.channels.redis.key_prefix,
2485            &defaults.channels.redis.key_prefix,
2486            profile_str,
2487        );
2488    }
2489
2490    fn track_property(
2491        props: &mut HashMap<String, ConfigProperty>,
2492        key: &str,
2493        value: &str,
2494        default_value: &str,
2495        profile: &str,
2496    ) {
2497        // Check if there's an env var override
2498        let env_key = format!("AUTUMN_{}", key.replace('.', "__").to_uppercase());
2499        let source = if std::env::var(&env_key).is_ok() {
2500            env_key
2501        } else if value != default_value && (profile == "dev" || profile == "prod") {
2502            format!("profile_default:{profile}")
2503        } else if value != default_value {
2504            "autumn.toml".to_string()
2505        } else {
2506            "default".to_string()
2507        };
2508
2509        let display_value = if should_redact(key) {
2510            serde_json::Value::String("****".into())
2511        } else {
2512            serde_json::Value::String(value.to_string())
2513        };
2514
2515        props.insert(
2516            key.to_string(),
2517            ConfigProperty {
2518                value: display_value,
2519                source,
2520            },
2521        );
2522    }
2523
2524    /// Get a snapshot of all properties.
2525    #[must_use]
2526    pub fn snapshot(&self) -> HashMap<String, ConfigProperty> {
2527        self.inner
2528            .read()
2529            .map(|guard| guard.clone())
2530            .unwrap_or_default()
2531    }
2532}
2533
2534// ── Health Indicator ─────────────────────────────────────────────
2535
2536/// Health status reported by a [`HealthIndicator`].
2537///
2538/// Follows Spring Boot precedence:
2539/// `Down` > `OutOfService` > `Unknown` > `Up`
2540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2541pub enum HealthStatus {
2542    /// The component is functioning normally.
2543    #[serde(rename = "UP")]
2544    Up,
2545    /// The component is unavailable.
2546    #[serde(rename = "DOWN")]
2547    Down,
2548    /// The component is out of service (maintenance, etc.).
2549    #[serde(rename = "OUT_OF_SERVICE")]
2550    OutOfService,
2551    /// The component status cannot be determined.
2552    #[serde(rename = "UNKNOWN")]
2553    Unknown,
2554}
2555
2556impl HealthStatus {
2557    /// Human-readable string for this status.
2558    #[must_use]
2559    pub const fn as_str(self) -> &'static str {
2560        match self {
2561            Self::Up => "UP",
2562            Self::Down => "DOWN",
2563            Self::OutOfService => "OUT_OF_SERVICE",
2564            Self::Unknown => "UNKNOWN",
2565        }
2566    }
2567
2568    /// Returns `true` when this status does not indicate a failure
2569    /// (`Up` and `Unknown` are healthy; `Down` and `OutOfService` are not).
2570    #[must_use]
2571    pub const fn is_healthy(self) -> bool {
2572        matches!(self, Self::Up | Self::Unknown)
2573    }
2574}
2575
2576/// Which group a [`HealthIndicator`] belongs to.
2577///
2578/// `Readiness` indicators gate both `/ready` and `/actuator/health`.
2579/// `HealthOnly` indicators appear only in `/actuator/health`.
2580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2581pub enum IndicatorGroup {
2582    /// Participates in `/ready` and `/actuator/health`.
2583    Readiness,
2584    /// Participates only in `/actuator/health`.
2585    HealthOnly,
2586}
2587
2588/// Output from a single [`HealthIndicator::check`] call.
2589#[derive(Debug, Clone)]
2590pub struct HealthCheckOutput {
2591    /// The health status of this component.
2592    pub status: HealthStatus,
2593    /// Optional human-readable key-value detail map.
2594    pub details: HashMap<String, serde_json::Value>,
2595}
2596
2597impl HealthCheckOutput {
2598    /// Create an `Up` output with no details.
2599    #[must_use]
2600    pub fn up() -> Self {
2601        Self {
2602            status: HealthStatus::Up,
2603            details: HashMap::new(),
2604        }
2605    }
2606
2607    /// Create a `Down` output with no details.
2608    #[must_use]
2609    pub fn down() -> Self {
2610        Self {
2611            status: HealthStatus::Down,
2612            details: HashMap::new(),
2613        }
2614    }
2615
2616    /// Attach a detail map to this output.
2617    #[must_use]
2618    pub fn with_details(mut self, details: HashMap<String, serde_json::Value>) -> Self {
2619        self.details = details;
2620        self
2621    }
2622}
2623
2624/// Contract for a custom health check.
2625///
2626/// Implement this trait and register it via [`crate::app::AppBuilder::health_indicator`]
2627/// to surface the health of an external dependency in `/actuator/health` and optionally
2628/// in `/ready`.
2629///
2630/// # Example
2631///
2632/// ```rust
2633/// use autumn_web::actuator::{HealthCheckOutput, HealthIndicator, HealthStatus};
2634///
2635/// pub struct StripeIndicator;
2636///
2637/// impl HealthIndicator for StripeIndicator {
2638///     fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
2639///         Box::pin(async move {
2640///             // TODO: ping Stripe API
2641///             HealthCheckOutput::up()
2642///         })
2643///     }
2644/// }
2645/// ```
2646pub trait HealthIndicator: Send + Sync + 'static {
2647    /// Run the check and return the current health output.
2648    ///
2649    /// The future is polled inside a per-indicator timeout; if it does not
2650    /// resolve within [`Self::timeout_ms`] milliseconds it is cancelled and
2651    /// the indicator is reported as `Unknown` with `timed_out: true`.
2652    fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput>;
2653
2654    /// Per-indicator timeout in milliseconds. Default: 2 000 ms.
2655    fn timeout_ms(&self) -> u64 {
2656        2000
2657    }
2658
2659    /// Which probe group this indicator belongs to. Default: [`IndicatorGroup::Readiness`].
2660    fn group(&self) -> IndicatorGroup {
2661        IndicatorGroup::Readiness
2662    }
2663}
2664
2665/// A single result returned from [`HealthIndicatorRegistry::run_all`] or
2666/// [`HealthIndicatorRegistry::run_readiness`].
2667#[derive(Debug, Clone)]
2668pub struct HealthRunResult {
2669    /// The registration name of this indicator.
2670    pub name: String,
2671    /// Which probe group this indicator belongs to.
2672    pub group: IndicatorGroup,
2673    /// The output of the check (possibly timed-out).
2674    pub output: HealthCheckOutput,
2675}
2676
2677/// One registered indicator and its result cache.
2678#[derive(Clone)]
2679struct IndicatorEntry {
2680    name: String,
2681    group: IndicatorGroup,
2682    indicator: Arc<dyn HealthIndicator>,
2683    cache: Arc<crate::health_cache::SingleFlightCache<HealthCheckOutput>>,
2684}
2685
2686impl IndicatorEntry {
2687    /// Run the check with its timeout, or return the cached result.
2688    async fn run(self) -> HealthRunResult {
2689        let indicator = Arc::clone(&self.indicator);
2690        let output = self
2691            .cache
2692            .get_or_refresh(
2693                move || async move { run_with_timeout(indicator.as_ref()).await },
2694                || {
2695                    let mut details = HashMap::new();
2696                    details.insert("panicked".to_owned(), serde_json::Value::Bool(true));
2697                    HealthCheckOutput::down().with_details(details)
2698                },
2699            )
2700            .await;
2701        HealthRunResult {
2702            name: self.name,
2703            group: self.group,
2704            output,
2705        }
2706    }
2707}
2708
2709#[derive(Default)]
2710struct IndicatorList {
2711    entries: Vec<IndicatorEntry>,
2712    cache_ttl: std::time::Duration,
2713}
2714
2715/// Registry of named [`HealthIndicator`] implementations.
2716///
2717/// Populated by [`crate::app::AppBuilder::health_indicator`] and stored on
2718/// [`crate::AppState`]. Provides duplicate-registration detection at startup
2719/// and per-indicator timeout enforcement at request time. Results can be
2720/// cached for a short TTL (see [`Self::set_cache_ttl`]).
2721#[derive(Clone, Default)]
2722pub struct HealthIndicatorRegistry {
2723    inner: Arc<RwLock<IndicatorList>>,
2724}
2725
2726impl HealthIndicatorRegistry {
2727    /// Create a new, empty registry.
2728    #[must_use]
2729    pub fn new() -> Self {
2730        Self::default()
2731    }
2732
2733    /// Register a named indicator with its group.
2734    ///
2735    /// Returns `Err` if a indicator with `name` was already registered.
2736    ///
2737    /// # Errors
2738    ///
2739    /// Returns an error string when `name` is already registered.
2740    pub fn register(
2741        &self,
2742        name: impl Into<String>,
2743        group: IndicatorGroup,
2744        indicator: Arc<dyn HealthIndicator>,
2745    ) -> Result<(), String> {
2746        let name = name.into();
2747        let mut inner = self
2748            .inner
2749            .write()
2750            .unwrap_or_else(std::sync::PoisonError::into_inner);
2751        if inner.entries.iter().any(|entry| entry.name == name) {
2752            return Err(format!(
2753                "HealthIndicator '{name}' is already registered; skipping duplicate"
2754            ));
2755        }
2756        let cache = Arc::new(crate::health_cache::SingleFlightCache::new(inner.cache_ttl));
2757        inner.entries.push(IndicatorEntry {
2758            name,
2759            group,
2760            indicator,
2761            cache,
2762        });
2763        drop(inner);
2764        Ok(())
2765    }
2766
2767    /// Set how long one indicator result stays valid. Zero (the default for
2768    /// [`Self::new`]) turns the cache off. Applies to indicators registered
2769    /// before and after this call.
2770    ///
2771    /// When a result is stale, one caller runs the check and the other
2772    /// callers wait for its result (single-flight). A timed-out result is
2773    /// cached too, so a hung dependency is not called again by each prober.
2774    pub fn set_cache_ttl(&self, ttl: std::time::Duration) {
2775        let mut inner = self
2776            .inner
2777            .write()
2778            .unwrap_or_else(std::sync::PoisonError::into_inner);
2779        inner.cache_ttl = ttl;
2780        for entry in &inner.entries {
2781            entry.cache.set_ttl(ttl);
2782        }
2783    }
2784
2785    /// Returns `true` when no indicators have been registered.
2786    #[must_use]
2787    pub fn is_empty(&self) -> bool {
2788        self.inner
2789            .read()
2790            .unwrap_or_else(std::sync::PoisonError::into_inner)
2791            .entries
2792            .is_empty()
2793    }
2794
2795    /// Whether `name` is already registered.
2796    ///
2797    /// For subsystems that register into more than one registry and must not
2798    /// leave half of themselves behind: check every name first, then register.
2799    /// Registration has no undo, so a caller that registers its indicator and
2800    /// then collides on its metrics source would strand an indicator nothing
2801    /// owns — permanently `UP`, and blocking the retry that would fix it.
2802    #[must_use]
2803    pub fn contains(&self, name: &str) -> bool {
2804        self.inner
2805            .read()
2806            .unwrap_or_else(std::sync::PoisonError::into_inner)
2807            .entries
2808            .iter()
2809            .any(|entry| entry.name == name)
2810    }
2811
2812    /// Clone the entries so the read lock is released before async work.
2813    fn entries(&self) -> Vec<IndicatorEntry> {
2814        self.inner
2815            .read()
2816            .unwrap_or_else(std::sync::PoisonError::into_inner)
2817            .entries
2818            .clone()
2819    }
2820
2821    /// Run all registered indicators (both groups) with per-indicator timeouts.
2822    ///
2823    /// All indicators execute **concurrently**; total wall time is bounded by
2824    /// the slowest single indicator rather than N × timeout.
2825    pub async fn run_all(&self) -> Vec<HealthRunResult> {
2826        let mut results =
2827            futures::future::join_all(self.entries().into_iter().map(IndicatorEntry::run)).await;
2828
2829        for breaker in crate::circuit_breaker::global_registry().all_breakers() {
2830            let state = breaker.state();
2831            let status = match state {
2832                crate::circuit_breaker::CircuitState::Open
2833                | crate::circuit_breaker::CircuitState::HalfOpen => HealthStatus::Down,
2834                crate::circuit_breaker::CircuitState::Closed => HealthStatus::Up,
2835            };
2836
2837            let mut details = HashMap::new();
2838            details.insert(
2839                "state".to_string(),
2840                serde_json::Value::String(state.as_str().to_string()),
2841            );
2842            for (key, ratio) in [
2843                ("failure_ratio", breaker.failure_ratio()),
2844                ("slow_call_ratio", breaker.slow_call_ratio()),
2845            ] {
2846                if let Some(ratio_num) = serde_json::Number::from_f64(ratio) {
2847                    details.insert(key.to_string(), serde_json::Value::Number(ratio_num));
2848                }
2849            }
2850
2851            results.push(HealthRunResult {
2852                name: format!("circuit_breaker.{}", breaker.name()),
2853                group: IndicatorGroup::HealthOnly,
2854                output: HealthCheckOutput { status, details },
2855            });
2856        }
2857
2858        results
2859    }
2860
2861    /// Run only `Readiness`-group indicators with per-indicator timeouts.
2862    ///
2863    /// All indicators execute **concurrently**; total wall time is bounded by
2864    /// the slowest single indicator rather than N × timeout.
2865    pub async fn run_readiness(&self) -> Vec<HealthRunResult> {
2866        futures::future::join_all(
2867            self.entries()
2868                .into_iter()
2869                .filter(|entry| entry.group == IndicatorGroup::Readiness)
2870                .map(IndicatorEntry::run),
2871        )
2872        .await
2873    }
2874
2875    /// Compute the aggregate status following Spring Boot precedence.
2876    ///
2877    /// Precedence: `Down` > `OutOfService` > `Unknown` > `Up`.
2878    /// An empty slice returns `Up`.
2879    #[must_use]
2880    pub fn aggregate_status(statuses: &[HealthStatus]) -> HealthStatus {
2881        let mut overall = HealthStatus::Up;
2882        for &s in statuses {
2883            overall = match (overall, s) {
2884                (_, HealthStatus::Down) | (HealthStatus::Down, _) => HealthStatus::Down,
2885                (_, HealthStatus::OutOfService) | (HealthStatus::OutOfService, _) => {
2886                    HealthStatus::OutOfService
2887                }
2888                (_, HealthStatus::Unknown) | (HealthStatus::Unknown, _) => HealthStatus::Unknown,
2889                _ => HealthStatus::Up,
2890            };
2891        }
2892        overall
2893    }
2894}
2895
2896/// Run a single indicator with its declared timeout. Returns `Unknown` with
2897/// `timed_out: true` when the future does not resolve in time.
2898async fn run_with_timeout(indicator: &dyn HealthIndicator) -> HealthCheckOutput {
2899    let duration = tokio::time::Duration::from_millis(indicator.timeout_ms());
2900    match tokio::time::timeout(duration, indicator.check()).await {
2901        Ok(output) => output,
2902        Err(_elapsed) => {
2903            let mut details = HashMap::new();
2904            details.insert("timed_out".to_string(), serde_json::Value::Bool(true));
2905            HealthCheckOutput {
2906                status: HealthStatus::Unknown,
2907                details,
2908            }
2909        }
2910    }
2911}
2912
2913// ── Health ──────────────────────────────────────────────────────
2914
2915/// Enhanced health response for the actuator health endpoint.
2916#[derive(Serialize)]
2917struct ActuatorHealth {
2918    /// Overall aggregate status following Spring Boot precedence.
2919    status: &'static str,
2920    version: &'static str,
2921    profile: String,
2922    uptime: String,
2923    #[cfg(feature = "db")]
2924    autumn_after_commit_failures_total: u64,
2925    /// Total transaction retries triggered by a `40001`/`40P01` (issue #1202).
2926    #[cfg(feature = "db")]
2927    autumn_tx_retries_total: u64,
2928    /// Total transactions that exhausted their retry budget (issue #1202).
2929    #[cfg(feature = "db")]
2930    autumn_tx_retry_exhausted_total: u64,
2931    /// Per-component health, keyed by indicator name.
2932    #[serde(skip_serializing_if = "HashMap::is_empty")]
2933    components: HashMap<String, ComponentHealth>,
2934    /// Backwards-compatible checks block (populated by built-in db indicator).
2935    #[serde(skip_serializing_if = "Option::is_none")]
2936    checks: Option<HealthChecks>,
2937}
2938
2939#[derive(Serialize, Clone)]
2940struct ComponentHealth {
2941    status: &'static str,
2942    #[serde(skip_serializing_if = "Option::is_none")]
2943    details: Option<serde_json::Value>,
2944}
2945
2946#[derive(Serialize)]
2947struct HealthChecks {
2948    #[serde(skip_serializing_if = "Option::is_none")]
2949    database: Option<DatabaseCheck>,
2950}
2951
2952#[derive(Serialize)]
2953struct DatabaseCheck {
2954    status: &'static str,
2955    pool_size: u64,
2956    active_connections: u64,
2957    idle_connections: u64,
2958    #[serde(skip_serializing_if = "Option::is_none")]
2959    error: Option<String>,
2960}
2961
2962fn build_health_components(
2963    db_status: Option<HealthStatus>,
2964    db_check: Option<&DatabaseCheck>,
2965    indicator_results: &[HealthRunResult],
2966    detailed: bool,
2967) -> HashMap<String, ComponentHealth> {
2968    let mut components: HashMap<String, ComponentHealth> = HashMap::new();
2969    // Custom indicators first so the built-in "db" key inserted below can never
2970    // be overwritten by a user-registered indicator with the same name.
2971    for result in indicator_results {
2972        if !detailed
2973            && result.name.starts_with("circuit_breaker.")
2974            && result.output.status.is_healthy()
2975        {
2976            continue;
2977        }
2978        let details = (detailed && !result.output.details.is_empty())
2979            .then(|| serde_json::to_value(&result.output.details).unwrap_or_default());
2980        components.insert(
2981            result.name.clone(),
2982            ComponentHealth {
2983                status: result.output.status.as_str(),
2984                details,
2985            },
2986        );
2987    }
2988    if let Some(s) = db_status {
2989        let details = detailed
2990            .then(|| {
2991                db_check.map(|d| {
2992                    let mut details = serde_json::json!({
2993                        "status": d.status,
2994                        "pool_size": d.pool_size,
2995                        "active_connections": d.active_connections,
2996                        "idle_connections": d.idle_connections,
2997                    });
2998                    if let Some(error) = &d.error {
2999                        details["error"] = serde_json::json!(error);
3000                    }
3001                    details
3002                })
3003            })
3004            .flatten();
3005        components.insert(
3006            "db".to_string(),
3007            ComponentHealth {
3008                status: s.as_str(),
3009                details,
3010            },
3011        );
3012    }
3013    components
3014}
3015
3016/// `GET <actuator-prefix>/health`
3017pub async fn health<S: ProvideActuatorState + Send + Sync + 'static>(
3018    State(state): State<S>,
3019) -> impl IntoResponse {
3020    let detailed = state.health_detailed();
3021
3022    // ── built-in db component ────────────────────────────────────
3023    let db_component = async {
3024        #[cfg(feature = "db")]
3025        {
3026            if let Some(pool) = state.pool() {
3027                // Readiness is a `SELECT 1` on the primary, not pool
3028                // saturation (#3059). Pool numbers stay as details.
3029                let primary = match state.probe_state() {
3030                    Some(probes) => probes.check_primary_db(pool).await,
3031                    None => crate::probe::ProbeState::check_primary_db_uncached(pool).await,
3032                };
3033                let status = pool.status();
3034                let size = status.max_size as u64;
3035                let idle = status.available as u64;
3036                let db_status = if primary.up {
3037                    HealthStatus::Up
3038                } else {
3039                    HealthStatus::Down
3040                };
3041                let db_check = Some(DatabaseCheck {
3042                    status: if primary.up { "ok" } else { "down" },
3043                    pool_size: size,
3044                    active_connections: size.saturating_sub(idle),
3045                    idle_connections: idle,
3046                    // The error can name the host or the user. `checks` is
3047                    // always in the body, so show it only when detailed.
3048                    error: primary.error.filter(|_| detailed),
3049                });
3050                (Some(db_status), db_check)
3051            } else {
3052                (None, None)
3053            }
3054        }
3055        #[cfg(not(feature = "db"))]
3056        {
3057            (None::<HealthStatus>, None::<DatabaseCheck>)
3058        }
3059    };
3060
3061    // ── registered custom indicators ───────────────────────────
3062    let indicators = async {
3063        if let Some(registry) = state.health_indicator_registry() {
3064            registry.run_all().await
3065        } else {
3066            Vec::new()
3067        }
3068    };
3069
3070    // Run both at the same time: the wall time is the slower one, not the sum.
3071    let ((db_component_status, db_check), indicator_results) =
3072        tokio::join!(db_component, indicators);
3073
3074    // ── aggregate status ────────────────────────────────────────
3075    let mut all_statuses: Vec<HealthStatus> =
3076        indicator_results.iter().map(|r| r.output.status).collect();
3077    if let Some(s) = db_component_status {
3078        all_statuses.push(s);
3079    }
3080    let overall = HealthIndicatorRegistry::aggregate_status(&all_statuses);
3081
3082    // ── build components map ────────────────────────────────────
3083    let components = build_health_components(
3084        db_component_status,
3085        db_check.as_ref(),
3086        &indicator_results,
3087        detailed,
3088    );
3089
3090    let checks = db_check.map(|db| HealthChecks { database: Some(db) });
3091
3092    let body = ActuatorHealth {
3093        status: overall.as_str(),
3094        version: env!("CARGO_PKG_VERSION"),
3095        profile: state.profile().to_owned(),
3096        uptime: state.uptime_display(),
3097        #[cfg(feature = "db")]
3098        autumn_after_commit_failures_total: crate::db::AFTER_COMMIT_FAILURES_TOTAL
3099            .load(std::sync::atomic::Ordering::Relaxed),
3100        #[cfg(feature = "db")]
3101        autumn_tx_retries_total: crate::db::TX_RETRIES_TOTAL
3102            .load(std::sync::atomic::Ordering::Relaxed),
3103        #[cfg(feature = "db")]
3104        autumn_tx_retry_exhausted_total: crate::db::TX_RETRY_EXHAUSTED_TOTAL
3105            .load(std::sync::atomic::Ordering::Relaxed),
3106        components,
3107        checks,
3108    };
3109
3110    let code = if overall.is_healthy() {
3111        StatusCode::OK
3112    } else {
3113        StatusCode::SERVICE_UNAVAILABLE
3114    };
3115    (code, Json(body))
3116}
3117
3118// ── Info ────────────────────────────────────────────────────────
3119
3120/// Application info response.
3121#[derive(Serialize)]
3122pub(crate) struct ActuatorInfo {
3123    app: AppInfo,
3124    autumn: FrameworkInfo,
3125    runtime: RuntimeInfo,
3126    /// Build + git provenance baked into the running binary (issue #1242).
3127    build: crate::build_info::BuildProvenance,
3128    /// The process role and its resolved capabilities (#3542, #3543).
3129    #[serde(skip_serializing_if = "Option::is_none")]
3130    role: Option<RoleInfo>,
3131}
3132
3133#[derive(Serialize)]
3134struct RoleInfo {
3135    name: String,
3136    base: &'static str,
3137    preset: bool,
3138    capabilities: crate::config::RoleCapabilities,
3139    /// The route groups the role mounts (#3544). Omitted: every group.
3140    #[serde(skip_serializing_if = "Option::is_none")]
3141    mount: Option<Vec<String>>,
3142}
3143
3144impl From<&crate::config::Role> for RoleInfo {
3145    fn from(role: &crate::config::Role) -> Self {
3146        Self {
3147            name: role.name().to_owned(),
3148            base: role.base().as_str(),
3149            preset: role.is_preset(),
3150            capabilities: role.capabilities(),
3151            mount: role.mount().map(<[String]>::to_vec),
3152        }
3153    }
3154}
3155
3156#[derive(Serialize)]
3157struct AppInfo {
3158    name: String,
3159    version: String,
3160}
3161
3162#[derive(Serialize)]
3163struct FrameworkInfo {
3164    version: &'static str,
3165    profile: String,
3166}
3167
3168#[derive(Serialize)]
3169struct RuntimeInfo {
3170    uptime: String,
3171}
3172
3173/// `GET <actuator-prefix>/info`
3174pub(crate) async fn info<S: ProvideActuatorState + Send + Sync + 'static>(
3175    State(state): State<S>,
3176) -> Json<ActuatorInfo> {
3177    Json(ActuatorInfo {
3178        app: AppInfo {
3179            // Read the consuming app's compile-time name/version (baked in by
3180            // `#[autumn_web::main]`), not a runtime `std::env::var` lookup that
3181            // always failed in a released binary (issue #1242).
3182            name: crate::build_info::app_name(),
3183            version: crate::build_info::app_version(),
3184        },
3185        autumn: FrameworkInfo {
3186            version: env!("CARGO_PKG_VERSION"),
3187            profile: state.profile().to_owned(),
3188        },
3189        runtime: RuntimeInfo {
3190            uptime: state.uptime_display(),
3191        },
3192        build: crate::build_info::build_provenance(),
3193        role: state.process_role().as_ref().map(RoleInfo::from),
3194    })
3195}
3196
3197// ── Env (sensitive) ─────────────────────────────────────────────
3198
3199/// Config environment response with redacted secrets.
3200#[derive(Serialize)]
3201pub(crate) struct ActuatorEnv {
3202    active_profile: String,
3203    properties: std::collections::HashMap<String, serde_json::Value>,
3204}
3205
3206/// Keys that trigger value redaction.
3207const REDACT_PATTERNS: &[&str] = &[
3208    "password",
3209    "secret",
3210    "key",
3211    "token",
3212    "credential",
3213    "auth",
3214    "url",
3215];
3216
3217fn should_redact(key: &str) -> bool {
3218    let lower = key.to_lowercase();
3219    REDACT_PATTERNS.iter().any(|p| lower.contains(p))
3220}
3221
3222/// `GET /actuator/env` — only available when actuator sensitive mode is enabled.
3223pub(crate) async fn env_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3224    State(state): State<S>,
3225) -> Json<ActuatorEnv> {
3226    let properties = state
3227        .config_props()
3228        .snapshot()
3229        .into_iter()
3230        .map(|(key, prop)| (key, prop.value))
3231        .collect();
3232
3233    Json(ActuatorEnv {
3234        active_profile: state.profile().to_owned(),
3235        properties,
3236    })
3237}
3238
3239// ── Metrics ────────────────────────────────────────────────────
3240
3241/// `GET <actuator-prefix>/metrics` -- request metrics, latency, status codes, DB pool stats.
3242/// The `cache` section of `/actuator/metrics`: the read-through snapshot, plus
3243/// the stale-if-error and invalidation-failure counters (#3056).
3244fn cache_metrics_json() -> serde_json::Value {
3245    let mut cache =
3246        serde_json::to_value(crate::cache::read_through_metrics().snapshot()).unwrap_or_default();
3247    if let serde_json::Value::Object(ref mut map) = cache {
3248        map.insert(
3249            "stale_if_error_serves".to_string(),
3250            crate::cache::read_through_metrics()
3251                .stale_if_error_serves()
3252                .into(),
3253        );
3254        map.insert(
3255            "invalidation_failures".to_string(),
3256            crate::cache::invalidation_failures_total().into(),
3257        );
3258    }
3259    cache
3260}
3261
3262pub(crate) async fn metrics_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3263    State(state): State<S>,
3264) -> Json<serde_json::Value> {
3265    let snapshot = state.metrics().snapshot();
3266    let mut result = serde_json::to_value(&snapshot).unwrap_or_default();
3267
3268    // Include read-through cache stampede-protection counters (always
3269    // present; the read-through API works standalone without app state).
3270    if let serde_json::Value::Object(ref mut map) = result {
3271        map.insert("cache".to_string(), cache_metrics_json());
3272    }
3273
3274    // Include DB pool stats if available
3275    #[cfg(feature = "db")]
3276    if let Some(pool) = state.pool() {
3277        let status = pool.status();
3278        let db_stats = serde_json::json!({
3279            "pool_size": status.max_size,
3280            "active_connections": (status.size as u64).saturating_sub(status.available as u64),
3281            "idle_connections": status.available,
3282        });
3283        if let serde_json::Value::Object(ref mut map) = result {
3284            map.insert("database".to_string(), db_stats);
3285        }
3286    }
3287
3288    // Include per-shard pool stats keyed by shard name
3289    #[cfg(feature = "db")]
3290    if let Some(shards) = state.shards() {
3291        let mut shard_stats = serde_json::Map::new();
3292        for shard in shards.iter() {
3293            let status = shard.primary_pool().status();
3294            let mut entry = serde_json::json!({
3295                "pool_size": status.max_size,
3296                "active_connections":
3297                    (status.size as u64).saturating_sub(status.available as u64),
3298                "idle_connections": status.available,
3299                "slots": shard.slots().len(),
3300            });
3301            if let Some(replica) = shard.replica_pool() {
3302                let replica_status = replica.status();
3303                if let serde_json::Value::Object(ref mut entry_map) = entry {
3304                    entry_map.insert(
3305                        "replica".to_string(),
3306                        serde_json::json!({
3307                            "pool_size": replica_status.max_size,
3308                            "active_connections": (replica_status.size as u64)
3309                                .saturating_sub(replica_status.available as u64),
3310                            "idle_connections": replica_status.available,
3311                        }),
3312                    );
3313                }
3314            }
3315            shard_stats.insert(shard.name().to_owned(), entry);
3316        }
3317        if let serde_json::Value::Object(ref mut map) = result {
3318            map.insert(
3319                "database_shards".to_string(),
3320                serde_json::Value::Object(shard_stats),
3321            );
3322        }
3323    }
3324
3325    insert_app_metrics(&mut result);
3326
3327    // Include plugin-contributed sources under the "sources" key
3328    if let Some(registry) = state.metrics_source_registry() {
3329        let all = registry.collect_all();
3330        let mut sources = serde_json::Map::new();
3331        for (source_name, families) in all {
3332            let families_json: Vec<serde_json::Value> = families
3333                .iter()
3334                .map(|f| {
3335                    serde_json::json!({
3336                        "name": f.name,
3337                        "help": f.help,
3338                        "kind": f.kind.as_str(),
3339                        "samples": f.samples.iter().map(|s| {
3340                            let labels: serde_json::Map<String, serde_json::Value> = s.labels
3341                                .iter()
3342                                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
3343                                .collect();
3344                            serde_json::json!({
3345                                "labels": labels,
3346                                "value": s.value,
3347                            })
3348                        }).collect::<Vec<_>>(),
3349                    })
3350                })
3351                .collect();
3352            sources.insert(source_name, serde_json::Value::Array(families_json));
3353        }
3354        if let serde_json::Value::Object(ref mut map) = result {
3355            map.insert("sources".to_string(), serde_json::Value::Object(sources));
3356        }
3357    }
3358
3359    Json(result)
3360}
3361
3362#[derive(Serialize)]
3363pub(crate) struct CircuitBreakerActuatorResponse {
3364    pub name: String,
3365    pub state: &'static str,
3366    pub failure_ratio: f64,
3367    pub slow_call_ratio: f64,
3368    #[serde(skip_serializing_if = "Option::is_none")]
3369    pub failure_ratio_threshold: Option<f64>,
3370    #[serde(skip_serializing_if = "Option::is_none")]
3371    pub sample_window_secs: Option<u64>,
3372    #[serde(skip_serializing_if = "Option::is_none")]
3373    pub minimum_sample_count: Option<u64>,
3374    #[serde(skip_serializing_if = "Option::is_none")]
3375    pub open_duration_secs: Option<u64>,
3376    #[serde(skip_serializing_if = "Option::is_none")]
3377    pub half_open_trial_count: Option<u64>,
3378    /// Absent when slow-call detection is off.
3379    #[serde(skip_serializing_if = "Option::is_none")]
3380    pub slow_call_duration_threshold_ms: Option<u64>,
3381    #[serde(skip_serializing_if = "Option::is_none")]
3382    pub slow_call_rate_threshold: Option<f64>,
3383    #[serde(skip_serializing_if = "Option::is_none")]
3384    pub cancelled_call_outcome: Option<&'static str>,
3385}
3386
3387/// `GET <actuator-prefix>/circuitbreakers`
3388pub(crate) async fn circuitbreakers_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3389    State(state): State<S>,
3390) -> Json<Vec<CircuitBreakerActuatorResponse>> {
3391    let detailed = state.health_detailed();
3392    let mut responses = Vec::new();
3393
3394    for breaker in crate::circuit_breaker::global_registry().all_breakers() {
3395        let policy = breaker.config();
3396        responses.push(CircuitBreakerActuatorResponse {
3397            name: breaker.name().to_string(),
3398            state: breaker.state().as_str(),
3399            failure_ratio: breaker.failure_ratio(),
3400            slow_call_ratio: breaker.slow_call_ratio(),
3401            failure_ratio_threshold: detailed.then_some(policy.failure_ratio_threshold),
3402            sample_window_secs: detailed.then_some(policy.sample_window.as_secs()),
3403            minimum_sample_count: detailed.then_some(policy.minimum_sample_count),
3404            open_duration_secs: detailed.then_some(policy.open_duration.as_secs()),
3405            half_open_trial_count: detailed.then_some(policy.half_open_trial_count),
3406            slow_call_duration_threshold_ms: policy
3407                .slow_call_duration_threshold
3408                .filter(|_| detailed)
3409                .map(|t| u64::try_from(t.as_millis()).unwrap_or(u64::MAX)),
3410            slow_call_rate_threshold: detailed.then_some(policy.slow_call_rate_threshold),
3411            cancelled_call_outcome: detailed.then_some(policy.cancelled_call_outcome.as_str()),
3412        });
3413    }
3414
3415    Json(responses)
3416}
3417
3418/// Add app-defined [`crate::metrics`] facade instruments to the JSON metrics
3419/// document under the top-level `app` key — the same snapshot the Prometheus
3420/// endpoint renders.
3421///
3422/// The key is absent (rather than an empty array) when the app has recorded
3423/// nothing.
3424fn insert_app_metrics(result: &mut serde_json::Value) {
3425    let app_metrics = crate::metrics::snapshot();
3426    if app_metrics.is_empty() {
3427        return;
3428    }
3429    let serde_json::Value::Object(map) = result else {
3430        return;
3431    };
3432    // On the (unreachable) serialization failure, skip the key rather than emit
3433    // `"app": null`: absent already means "nothing recorded", while a null
3434    // would look like a metric that exists and has no data.
3435    match serde_json::to_value(&app_metrics) {
3436        Ok(value) => {
3437            map.insert("app".to_string(), value);
3438        }
3439        Err(error) => tracing::warn!(
3440            %error,
3441            "failed to serialize app metrics; omitting the `app` key"
3442        ),
3443    }
3444}
3445
3446// ── Prometheus ─────────────────────────────────────────────────
3447
3448/// Render label set `{k="v",...}` or empty string for no labels.
3449///
3450/// Writes directly into a pre-allocated `String` to avoid per-pair heap allocations.
3451fn render_labels(labels: &[(String, String)]) -> String {
3452    if labels.is_empty() {
3453        return String::new();
3454    }
3455    let mut out = String::with_capacity(64);
3456    out.push('{');
3457    for (i, (k, v)) in labels.iter().enumerate() {
3458        if i > 0 {
3459            out.push(',');
3460        }
3461        out.push_str(k);
3462        out.push_str("=\"");
3463        for c in v.chars() {
3464            match c {
3465                '\\' => out.push_str("\\\\"),
3466                '\n' => out.push_str("\\n"),
3467                '"' => out.push_str("\\\""),
3468                other => out.push(other),
3469            }
3470        }
3471        out.push('"');
3472    }
3473    out.push('}');
3474    out
3475}
3476
3477/// The family reporting how many label sets each app metric lost to its
3478/// cardinality cap; emitted by `write_app_metrics` when any were dropped.
3479pub(crate) const SERIES_DROPPED_FAMILY: &str = "autumn_metrics_series_dropped_total";
3480
3481/// Redis dead letters removed to obey `jobs.redis.dead_letter_limit`.
3482pub(crate) const DEAD_LETTER_TRIMMED_FAMILY: &str = "autumn_jobs_dead_letter_trimmed_total";
3483
3484/// Time a `Db` checkout waits for a pool connection (issue #3064).
3485pub(crate) const DB_POOL_WAIT_FAMILY: &str = "autumn_db_pool_wait_seconds";
3486
3487/// HTTP request latency histogram (issue #3064).
3488pub(crate) const HTTP_DURATION_FAMILY: &str = "autumn_http_request_duration_seconds";
3489
3490/// Deprecated latency summary. It keeps the p50/p95/p99 quantile lines that
3491/// `autumn_http_request_duration_seconds` carried before it became a histogram.
3492pub(crate) const HTTP_DURATION_QUANTILES_FAMILY: &str =
3493    "autumn_http_request_duration_quantiles_seconds";
3494
3495/// Slow calls per circuit breaker (issue #3060).
3496pub(crate) const BREAKER_SLOW_CALLS_FAMILY: &str = "autumn_circuit_breaker_slow_calls_total";
3497
3498/// The slow-call ratio in each circuit breaker's window (issue #3060).
3499pub(crate) const BREAKER_SLOW_CALL_RATIO_FAMILY: &str = "autumn_circuit_breaker_slow_call_ratio";
3500
3501/// Every metric family name the framework itself emits on `/actuator/prometheus`.
3502///
3503/// Two callers share this list: `prometheus_endpoint` seeds its
3504/// `emitted_families` set with it so a plugin [`MetricsSource`] cannot shadow a
3505/// built-in family, and [`crate::metrics`] refuses to register an app metric
3506/// under any of these names.
3507pub(crate) const BUILTIN_METRIC_FAMILY_NAMES: [&str; 46] = [
3508    "autumn_http_requests_total",
3509    "autumn_http_requests_active",
3510    "autumn_http_responses_total",
3511    // A histogram owns its derived `_bucket`/`_sum`/`_count` names, and a
3512    // summary owns `_sum`/`_count`, even when this exporter writes only
3513    // quantile lines. A second family under a derived name gives the document
3514    // two `# HELP` lines for one family. Prometheus then rejects the *whole*
3515    // scrape, not just that family.
3516    HTTP_DURATION_FAMILY,
3517    "autumn_http_request_duration_seconds_bucket",
3518    "autumn_http_request_duration_seconds_sum",
3519    "autumn_http_request_duration_seconds_count",
3520    HTTP_DURATION_QUANTILES_FAMILY,
3521    "autumn_http_request_duration_quantiles_seconds_sum",
3522    "autumn_http_request_duration_quantiles_seconds_count",
3523    "autumn_shutdown_aborted_requests_total",
3524    "autumn_request_timeouts_total",
3525    "autumn_read_your_writes_pins_total",
3526    "autumn_requests_shed_total",
3527    "autumn_admission_limit",
3528    "autumn_admission_shed_total",
3529    "autumn_tenant_bulkhead_rejections_total",
3530    "autumn_http_route_requests_total",
3531    "autumn_metrics_source_errors_total",
3532    SERIES_DROPPED_FAMILY,
3533    "autumn_cache_read_through_hits_total",
3534    "autumn_cache_read_through_misses_total",
3535    "autumn_cache_read_through_coalesced_waits_total",
3536    "autumn_cache_read_through_fills_total",
3537    "autumn_cache_read_through_fill_failures_total",
3538    "autumn_cache_read_through_stale_serves_total",
3539    "autumn_cache_fill_lock_acquires_total",
3540    "autumn_cache_fill_lock_contended_total",
3541    "autumn_cache_read_through_stale_if_error_serves_total",
3542    "autumn_cache_invalidation_failures_total",
3543    DEAD_LETTER_TRIMMED_FAMILY,
3544    "autumn_jobs_queue_depth",
3545    "autumn_jobs_oldest_age_seconds",
3546    "autumn_jobs_dead_letter_total",
3547    "autumn_db_pool_max_size",
3548    "autumn_db_pool_size",
3549    "autumn_db_pool_available",
3550    "autumn_db_pool_waiting",
3551    DB_POOL_WAIT_FAMILY,
3552    "autumn_db_pool_wait_seconds_bucket",
3553    "autumn_db_pool_wait_seconds_sum",
3554    "autumn_db_pool_wait_seconds_count",
3555    BREAKER_SLOW_CALLS_FAMILY,
3556    BREAKER_SLOW_CALL_RATIO_FAMILY,
3557    crate::shadow::COMPARISONS_METRIC,
3558    crate::shadow::DIVERGENCES_METRIC,
3559];
3560
3561/// Returns true if `s` is a valid Prometheus metric name (`[a-zA-Z_:][a-zA-Z0-9_:]*`).
3562pub(crate) fn is_valid_metric_name(s: &str) -> bool {
3563    let mut it = s.chars();
3564    matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':')
3565        && it.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
3566}
3567
3568/// Returns true if `s` is a valid Prometheus label name (`[a-zA-Z_][a-zA-Z0-9_]*`).
3569pub(crate) fn is_valid_label_name(s: &str) -> bool {
3570    let mut it = s.chars();
3571    matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
3572        && it.all(|c| c.is_ascii_alphanumeric() || c == '_')
3573}
3574
3575/// Escape a Prometheus label value (backslash, newline, and double-quote).
3576fn escape_prometheus_label_value(value: &str) -> String {
3577    let mut out = String::with_capacity(value.len());
3578    for c in value.chars() {
3579        match c {
3580            '\\' => out.push_str("\\\\"),
3581            '\n' => out.push_str("\\n"),
3582            '"' => out.push_str("\\\""),
3583            other => out.push(other),
3584        }
3585    }
3586    out
3587}
3588
3589/// Escape a Prometheus HELP string (backslash and newline only).
3590fn escape_help_text(text: &str) -> String {
3591    let mut out = String::with_capacity(text.len());
3592    for c in text.chars() {
3593        match c {
3594            '\\' => out.push_str("\\\\"),
3595            '\n' => out.push_str("\\n"),
3596            other => out.push(other),
3597        }
3598    }
3599    out
3600}
3601
3602/// Format a sample value per Prometheus text format (handles ±Inf and NaN).
3603fn format_sample_value(v: f64) -> String {
3604    if v == f64::INFINITY {
3605        "+Inf".to_string()
3606    } else if v == f64::NEG_INFINITY {
3607        "-Inf".to_string()
3608    } else if v.is_nan() {
3609        "NaN".to_string()
3610    } else {
3611        v.to_string()
3612    }
3613}
3614
3615/// Append all plugin-contributed metric families (and their error counters) to `out`.
3616///
3617/// `emitted_families` must be pre-seeded with the names of every built-in
3618/// metric family already written to `out`; families whose names collide are
3619/// skipped with a warning so no duplicate `# HELP`/`# TYPE` blocks are emitted.
3620fn render_plugin_sources(
3621    registry: &MetricsSourceRegistry,
3622    out: &mut String,
3623    emitted_families: &mut std::collections::HashSet<String>,
3624) {
3625    use std::fmt::Write;
3626
3627    let all_sources = registry.collect_all();
3628    for (_source_name, families) in &all_sources {
3629        for family in families {
3630            if !is_valid_metric_name(&family.name) {
3631                tracing::warn!(name = %family.name, "MetricsSource returned invalid metric name; skipping family");
3632                continue;
3633            }
3634            if !emitted_families.insert(family.name.clone()) {
3635                tracing::warn!(name = %family.name, "MetricsSource returned duplicate metric family name; skipping family");
3636                continue;
3637            }
3638            let _ = writeln!(
3639                out,
3640                "# HELP {} {}",
3641                family.name,
3642                escape_help_text(&family.help)
3643            );
3644            let _ = writeln!(out, "# TYPE {} {}", family.name, family.kind.as_str());
3645            let mut emitted_series: std::collections::HashSet<String> =
3646                std::collections::HashSet::new();
3647            for sample in &family.samples {
3648                let mut bad_key = false;
3649                let mut seen_keys = std::collections::HashSet::new();
3650                let mut valid_labels: Vec<(String, String)> = Vec::new();
3651                for (k, v) in &sample.labels {
3652                    if !is_valid_label_name(k) {
3653                        tracing::warn!(
3654                            label_name = %k,
3655                            metric = %family.name,
3656                            "MetricsSource returned invalid label name; skipping sample"
3657                        );
3658                        bad_key = true;
3659                        break;
3660                    }
3661                    if !seen_keys.insert(k.as_str()) {
3662                        tracing::warn!(label_name = %k, "MetricsSource returned duplicate label name; dropping duplicate");
3663                        continue;
3664                    }
3665                    valid_labels.push((k.clone(), v.clone()));
3666                }
3667                if bad_key {
3668                    continue;
3669                }
3670                // Sort by key so {a="1",b="2"} and {b="2",a="1"} produce the
3671                // same canonical string and are treated as one series.
3672                valid_labels.sort_by(|(a, _), (b, _)| a.cmp(b));
3673                let labels = render_labels(&valid_labels);
3674                if !emitted_series.insert(labels.clone()) {
3675                    tracing::warn!(
3676                        metric = %family.name,
3677                        labels = %labels,
3678                        "MetricsSource returned duplicate series; skipping sample"
3679                    );
3680                    continue;
3681                }
3682                let _ = writeln!(
3683                    out,
3684                    "{}{} {}",
3685                    family.name,
3686                    labels,
3687                    format_sample_value(sample.value)
3688                );
3689            }
3690        }
3691    }
3692
3693    let error_counts = registry.error_counts();
3694    if !error_counts.is_empty() {
3695        out.push_str(
3696            "# HELP autumn_metrics_source_errors_total \
3697             Number of scrape errors (panics) per plugin metrics source\n",
3698        );
3699        out.push_str("# TYPE autumn_metrics_source_errors_total counter\n");
3700        let mut names: Vec<&String> = error_counts.keys().collect();
3701        names.sort();
3702        for name in names {
3703            let label = render_labels(&[("source".to_string(), name.clone())]);
3704            let _ = writeln!(
3705                out,
3706                "autumn_metrics_source_errors_total{} {}",
3707                label, error_counts[name]
3708            );
3709        }
3710    }
3711}
3712
3713/// Write `autumn_requests_shed_total`, `autumn_admission_limit` and
3714/// `autumn_admission_shed_total` (#3068).
3715fn write_admission_metrics(
3716    out: &mut String,
3717    labels: &str,
3718    http: &crate::middleware::metrics::HttpMetrics,
3719) {
3720    use std::fmt::Write as _;
3721
3722    // autumn_requests_shed_total
3723    out.push_str(
3724        "# HELP autumn_requests_shed_total \
3725         HTTP requests rejected by admission control because the in-flight count was at its limit\n",
3726    );
3727    out.push_str("# TYPE autumn_requests_shed_total counter\n");
3728    let _ = writeln!(
3729        out,
3730        "autumn_requests_shed_total{{{labels}}} {}",
3731        http.requests_shed_total
3732    );
3733
3734    let admission = &http.admission;
3735    out.push_str(
3736        "# HELP autumn_admission_limit Current admission-control concurrency limit (0 = no limit)\n",
3737    );
3738    out.push_str("# TYPE autumn_admission_limit gauge\n");
3739    let _ = writeln!(
3740        out,
3741        "autumn_admission_limit{{{labels}}} {}",
3742        admission.limit
3743    );
3744    out.push_str(
3745        "# HELP autumn_admission_shed_total HTTP requests rejected by admission control, by criticality\n",
3746    );
3747    out.push_str("# TYPE autumn_admission_shed_total counter\n");
3748    for (criticality, count) in [
3749        ("critical", admission.shed_critical),
3750        ("default", admission.shed_default),
3751        ("sheddable", admission.shed_sheddable),
3752    ] {
3753        let _ = writeln!(
3754            out,
3755            "autumn_admission_shed_total{{{labels},criticality=\"{criticality}\"}} {count}"
3756        );
3757    }
3758    out.push_str(
3759        "# HELP autumn_tenant_bulkhead_rejections_total \
3760         Work rejected by a per-tenant bulkhead, by kind\n",
3761    );
3762    out.push_str("# TYPE autumn_tenant_bulkhead_rejections_total counter\n");
3763    for (kind, count) in [
3764        ("request", admission.tenant_request_rejections),
3765        ("db", admission.tenant_db_rejections),
3766    ] {
3767        let _ = writeln!(
3768            out,
3769            "autumn_tenant_bulkhead_rejections_total{{{labels},kind=\"{kind}\"}} {count}"
3770        );
3771    }
3772}
3773
3774/// Render the built-in `autumn_http_*` metric families into `out`, tagged with
3775/// the replica's deploy `version` label so canary and stable cohorts can be
3776/// compared by a controller scraping both.
3777fn write_builtin_http_metrics(
3778    out: &mut String,
3779    labels: &str,
3780    snapshot: &crate::middleware::metrics::MetricsSnapshot,
3781    duration_series: &[crate::middleware::metrics::DurationSeries],
3782) {
3783    use std::fmt::Write;
3784
3785    // requests_total
3786    out.push_str("# HELP autumn_http_requests_total Total number of HTTP requests\n");
3787    out.push_str("# TYPE autumn_http_requests_total counter\n");
3788    let _ = writeln!(
3789        out,
3790        "autumn_http_requests_total{{{labels}}} {}",
3791        snapshot.http.requests_total
3792    );
3793
3794    // requests_active
3795    out.push_str("# HELP autumn_http_requests_active Currently active HTTP requests\n");
3796    out.push_str("# TYPE autumn_http_requests_active gauge\n");
3797    let _ = writeln!(
3798        out,
3799        "autumn_http_requests_active{{{labels}}} {}",
3800        snapshot.http.requests_active
3801    );
3802
3803    // by_status
3804    out.push_str("# HELP autumn_http_responses_total HTTP responses by status code\n");
3805    out.push_str("# TYPE autumn_http_responses_total counter\n");
3806    for (status, count) in [
3807        ("2xx", snapshot.http.by_status.s2xx),
3808        ("3xx", snapshot.http.by_status.s3xx),
3809        ("4xx", snapshot.http.by_status.s4xx),
3810        ("5xx", snapshot.http.by_status.s5xx),
3811    ] {
3812        let _ = writeln!(
3813            out,
3814            "autumn_http_responses_total{{{labels},status=\"{status}\"}} {count}"
3815        );
3816    }
3817
3818    write_http_duration_histogram(out, labels, duration_series);
3819
3820    write_http_duration_quantiles(out, labels, snapshot);
3821
3822    // autumn_shutdown_aborted_requests_total
3823    out.push_str(
3824        "# HELP autumn_shutdown_aborted_requests_total \
3825         HTTP requests forcibly dropped when the graceful-shutdown drain deadline expired\n",
3826    );
3827    out.push_str("# TYPE autumn_shutdown_aborted_requests_total counter\n");
3828    let _ = writeln!(
3829        out,
3830        "autumn_shutdown_aborted_requests_total{{{labels}}} {}",
3831        snapshot.http.shutdown_aborted_requests_total
3832    );
3833
3834    // autumn_request_timeouts_total
3835    out.push_str(
3836        "# HELP autumn_request_timeouts_total \
3837         HTTP requests that exceeded the configured per-request timeout\n",
3838    );
3839    out.push_str("# TYPE autumn_request_timeouts_total counter\n");
3840    let _ = writeln!(
3841        out,
3842        "autumn_request_timeouts_total{{{labels}}} {}",
3843        snapshot.http.request_timeouts_total
3844    );
3845
3846    // autumn_read_your_writes_pins_total
3847    out.push_str(
3848        "# HELP autumn_read_your_writes_pins_total \
3849         Replica reads redirected to the primary by the read-your-own-writes pin\n",
3850    );
3851    out.push_str("# TYPE autumn_read_your_writes_pins_total counter\n");
3852    let _ = writeln!(
3853        out,
3854        "autumn_read_your_writes_pins_total{{{labels}}} {}",
3855        snapshot.read_your_writes_pins_total
3856    );
3857
3858    write_admission_metrics(out, labels, &snapshot.http);
3859    // by_route
3860    if !snapshot.http.by_route.is_empty() {
3861        out.push_str("# HELP autumn_http_route_requests_total HTTP requests by route and method\n");
3862        out.push_str("# TYPE autumn_http_route_requests_total counter\n");
3863        let mut route_keys: Vec<&String> = snapshot.http.by_route.keys().collect();
3864        route_keys.sort();
3865        for route_key in route_keys {
3866            let metrics = &snapshot.http.by_route[route_key];
3867            // route_key is formatted as "METHOD /path"
3868            if let Some((method, path)) = route_key.split_once(' ') {
3869                let _ = writeln!(
3870                    out,
3871                    "autumn_http_route_requests_total{{{labels},method=\"{method}\",route=\"{path}\"}} {}",
3872                    metrics.count
3873                );
3874            }
3875        }
3876    }
3877}
3878
3879/// Render the deprecated global p50/p95/p99 summary (issue #3064). You cannot
3880/// add summaries from different replicas. Use the histogram instead.
3881fn write_http_duration_quantiles(
3882    out: &mut String,
3883    labels: &str,
3884    snapshot: &crate::middleware::metrics::MetricsSnapshot,
3885) {
3886    use std::fmt::Write;
3887
3888    let name = HTTP_DURATION_QUANTILES_FAMILY;
3889    let _ = writeln!(
3890        out,
3891        "# HELP {name} Deprecated: HTTP request latency quantiles in seconds. \
3892         Use autumn_http_request_duration_seconds"
3893    );
3894    let _ = writeln!(out, "# TYPE {name} summary");
3895    for (quantile, millis) in [
3896        ("0.5", snapshot.http.latency_ms.p50),
3897        ("0.95", snapshot.http.latency_ms.p95),
3898        ("0.99", snapshot.http.latency_ms.p99),
3899    ] {
3900        #[allow(clippy::cast_precision_loss)]
3901        let seconds = millis as f64 / 1000.0;
3902        let _ = writeln!(out, "{name}{{{labels},quantile=\"{quantile}\"}} {seconds}");
3903    }
3904}
3905
3906/// Render `autumn_http_request_duration_seconds` as a histogram (issue #3064).
3907///
3908/// Labels: `version`, `method`, `route`, `status_class`. Every label has a
3909/// bounded value set. `route` is the matched route pattern, or `_unmatched`.
3910/// `method` is a standard method, or `_other`.
3911fn write_http_duration_histogram(
3912    out: &mut String,
3913    labels: &str,
3914    series: &[crate::middleware::metrics::DurationSeries],
3915) {
3916    use std::fmt::Write;
3917
3918    let name = HTTP_DURATION_FAMILY;
3919    let _ = writeln!(out, "# HELP {name} HTTP request latency in seconds");
3920    let _ = writeln!(out, "# TYPE {name} histogram");
3921    for entry in series {
3922        let labels = format!(
3923            "{labels},method=\"{}\",route=\"{}\",status_class=\"{}\"",
3924            escape_prometheus_label_value(&entry.method),
3925            escape_prometheus_label_value(&entry.route),
3926            entry.status_class,
3927        );
3928        write_histogram_samples(out, name, &labels, &entry.histogram);
3929    }
3930}
3931
3932/// Write the `_bucket`, `_sum` and `_count` lines of one histogram series.
3933/// `labels` is the rendered label list without braces.
3934fn write_histogram_samples(
3935    out: &mut String,
3936    name: &str,
3937    labels: &str,
3938    histogram: &crate::middleware::metrics::HistogramSnapshot,
3939) {
3940    use std::fmt::Write;
3941
3942    let separator = if labels.is_empty() { "" } else { "," };
3943    for (le, count) in crate::middleware::metrics::BUCKET_LABELS
3944        .iter()
3945        .zip(histogram.cumulative)
3946    {
3947        let _ = writeln!(
3948            out,
3949            "{name}_bucket{{{labels}{separator}le=\"{le}\"}} {count}"
3950        );
3951    }
3952    let _ = writeln!(
3953        out,
3954        "{name}_sum{{{labels}}} {}",
3955        format_sample_value(histogram.sum_seconds)
3956    );
3957    let _ = writeln!(out, "{name}_count{{{labels}}} {}", histogram.count());
3958}
3959
3960/// Render the built-in `autumn_cache_*` read-through stampede-protection
3961/// counters into `out`, tagged with the replica's deploy `version` label.
3962/// These counters are process-wide (the read-through API works standalone
3963/// without app state), unlike the HTTP metrics which come from per-app state.
3964fn write_builtin_cache_metrics(
3965    out: &mut String,
3966    labels: &str,
3967    snapshot: &crate::cache::ReadThroughMetricsSnapshot,
3968) {
3969    use std::fmt::Write;
3970
3971    for (name, help, value) in [
3972        (
3973            "autumn_cache_read_through_hits_total",
3974            "Read-through cache reads served from a fresh cached value",
3975            snapshot.hits,
3976        ),
3977        (
3978            "autumn_cache_read_through_misses_total",
3979            "Read-through cache reads that found no fresh cached value",
3980            snapshot.misses,
3981        ),
3982        (
3983            "autumn_cache_read_through_coalesced_waits_total",
3984            "Read-through callers that awaited a concurrent in-process fill instead of \
3985             computing their own (single-flight coalescing)",
3986            snapshot.coalesced_waits,
3987        ),
3988        (
3989            "autumn_cache_read_through_fills_total",
3990            "Read-through fill closures that completed successfully",
3991            snapshot.fills,
3992        ),
3993        (
3994            "autumn_cache_read_through_fill_failures_total",
3995            "Read-through fill closures that returned an error",
3996            snapshot.fill_failures,
3997        ),
3998        (
3999            "autumn_cache_read_through_stale_serves_total",
4000            "Stale-while-revalidate reads that served a stale value while a background \
4001             refresh ran",
4002            snapshot.stale_serves,
4003        ),
4004        (
4005            "autumn_cache_fill_lock_acquires_total",
4006            "Distributed cache fill locks acquired by this process",
4007            snapshot.fill_lock_acquires,
4008        ),
4009        (
4010            "autumn_cache_fill_lock_contended_total",
4011            "Distributed cache fill lock attempts that found the lock held by another replica",
4012            snapshot.fill_lock_contended,
4013        ),
4014        (
4015            "autumn_cache_read_through_stale_if_error_serves_total",
4016            "Read-through fills that failed and served the last value instead (stale-if-error)",
4017            crate::cache::read_through_metrics().stale_if_error_serves(),
4018        ),
4019        (
4020            "autumn_cache_invalidation_failures_total",
4021            "Cache invalidations that failed after all retries; stale data can be served",
4022            crate::cache::invalidation_failures_total(),
4023        ),
4024    ] {
4025        let _ = writeln!(out, "# HELP {name} {help}");
4026        let _ = writeln!(out, "# TYPE {name} counter");
4027        let _ = writeln!(out, "{name}{{{labels}}} {value}");
4028    }
4029}
4030
4031/// Render the built-in job families into `out` (issues #3055, #3064).
4032///
4033/// Every family is always present, so an alert on `increase()` sees a `0`
4034/// sample before the first event. The `queue` and `job` label values come from
4035/// registered jobs and surveyed queues, so the label set is bounded.
4036fn write_builtin_job_metrics(out: &mut String, labels: &str, registry: &JobRegistry) {
4037    use std::fmt::Write;
4038
4039    let name = DEAD_LETTER_TRIMMED_FAMILY;
4040    let _ = writeln!(
4041        out,
4042        "# HELP {name} Redis dead letters removed to obey jobs.redis.dead_letter_limit"
4043    );
4044    let _ = writeln!(out, "# TYPE {name} counter");
4045    let _ = writeln!(
4046        out,
4047        "{name}{{{labels}}} {}",
4048        registry.dead_letter_trimmed_total()
4049    );
4050
4051    let mut queues: Vec<(String, QueueStatus)> = registry.queue_snapshot().into_iter().collect();
4052    queues.sort_by(|a, b| a.0.cmp(&b.0));
4053    let _ = writeln!(
4054        out,
4055        "# HELP autumn_jobs_queue_depth Jobs ready to run and waiting on each queue"
4056    );
4057    let _ = writeln!(out, "# TYPE autumn_jobs_queue_depth gauge");
4058    for (queue, status) in &queues {
4059        let queue = escape_prometheus_label_value(queue);
4060        let _ = writeln!(
4061            out,
4062            "autumn_jobs_queue_depth{{{labels},queue=\"{queue}\"}} {}",
4063            status.depth
4064        );
4065    }
4066    let _ = writeln!(
4067        out,
4068        "# HELP autumn_jobs_oldest_age_seconds Age of the oldest ready job on each queue"
4069    );
4070    let _ = writeln!(out, "# TYPE autumn_jobs_oldest_age_seconds gauge");
4071    for (queue, status) in &queues {
4072        let queue = escape_prometheus_label_value(queue);
4073        #[allow(clippy::cast_precision_loss, reason = "Prometheus samples are f64")]
4074        let seconds = status.oldest_waiting_age_ms as f64 / 1000.0;
4075        let _ = writeln!(
4076            out,
4077            "autumn_jobs_oldest_age_seconds{{{labels},queue=\"{queue}\"}} {}",
4078            format_sample_value(seconds)
4079        );
4080    }
4081
4082    let mut jobs: Vec<(String, JobStatus)> = registry.snapshot().into_iter().collect();
4083    jobs.sort_by(|a, b| a.0.cmp(&b.0));
4084    let _ = writeln!(
4085        out,
4086        "# HELP autumn_jobs_dead_letter_total Job executions moved to the dead-letter queue"
4087    );
4088    let _ = writeln!(out, "# TYPE autumn_jobs_dead_letter_total counter");
4089    for (job, status) in &jobs {
4090        let job = escape_prometheus_label_value(job);
4091        let _ = writeln!(
4092            out,
4093            "autumn_jobs_dead_letter_total{{{labels},job=\"{job}\"}} {}",
4094            status.dead_letters
4095        );
4096    }
4097}
4098
4099/// A pool gauge: metric name, help text, and the `Status` field it reads.
4100#[cfg(feature = "db")]
4101type PoolGauge = (&'static str, &'static str, fn(&deadpool::Status) -> usize);
4102
4103/// Render the database pool families into `out` (issue #3064).
4104///
4105/// The wait histogram is always present. The gauges are present when the app
4106/// has a pool. The `pool` label is `primary` or `replica`, or
4107/// `shard:<name>:primary` and `shard:<name>:replica` for a sharded app.
4108fn write_builtin_db_pool_metrics<S: ProvideActuatorState>(
4109    out: &mut String,
4110    labels: &str,
4111    state: &S,
4112) {
4113    use std::fmt::Write;
4114
4115    let name = DB_POOL_WAIT_FAMILY;
4116    let _ = writeln!(
4117        out,
4118        "# HELP {name} Time a Db checkout took to get a pool connection, connect time included"
4119    );
4120    let _ = writeln!(out, "# TYPE {name} histogram");
4121    write_histogram_samples(out, name, labels, &state.metrics().db_pool_wait());
4122
4123    #[cfg(feature = "db")]
4124    {
4125        let mut pools: Vec<(String, deadpool::Status)> = Vec::new();
4126        if let Some(pool) = state.pool() {
4127            pools.push(("primary".to_owned(), pool.status()));
4128        }
4129        if let Some(pool) = state.replica_pool() {
4130            pools.push(("replica".to_owned(), pool.status()));
4131        }
4132        if let Some(shards) = state.shards() {
4133            for shard in shards.iter() {
4134                pools.push((
4135                    format!("shard:{}:primary", shard.name()),
4136                    shard.primary_pool().status(),
4137                ));
4138                if let Some(replica) = shard.replica_pool() {
4139                    pools.push((format!("shard:{}:replica", shard.name()), replica.status()));
4140                }
4141            }
4142        }
4143        if pools.is_empty() {
4144            return;
4145        }
4146        let gauges: [PoolGauge; 4] = [
4147            (
4148                "autumn_db_pool_max_size",
4149                "Maximum connections in the pool",
4150                |s| s.max_size,
4151            ),
4152            ("autumn_db_pool_size", "Open connections in the pool", |s| {
4153                s.size
4154            }),
4155            (
4156                "autumn_db_pool_available",
4157                "Idle connections in the pool",
4158                |s| s.available,
4159            ),
4160            (
4161                "autumn_db_pool_waiting",
4162                "Tasks waiting for a pool connection",
4163                |s| s.waiting,
4164            ),
4165        ];
4166        for (gauge, help, read) in gauges {
4167            let _ = writeln!(out, "# HELP {gauge} {help}");
4168            let _ = writeln!(out, "# TYPE {gauge} gauge");
4169            for (label, status) in &pools {
4170                let label = escape_prometheus_label_value(label);
4171                let _ = writeln!(out, "{gauge}{{{labels},pool=\"{label}\"}} {}", read(status));
4172            }
4173        }
4174    }
4175}
4176
4177/// Render the per-breaker slow-call families (issue #3060) into `out`.
4178///
4179/// The families are absent while there is no breaker. One series for each
4180/// breaker, sorted by name. Names are escaped: a host name is a breaker name.
4181fn write_builtin_circuit_breaker_metrics(
4182    out: &mut String,
4183    labels: &str,
4184    breakers: &[crate::circuit_breaker::CircuitBreaker],
4185) {
4186    use std::fmt::Write;
4187
4188    if breakers.is_empty() {
4189        return;
4190    }
4191    let mut breakers: Vec<_> = breakers.iter().collect();
4192    breakers.sort_by(|a, b| a.name().cmp(b.name()));
4193
4194    let name = BREAKER_SLOW_CALLS_FAMILY;
4195    let _ = writeln!(
4196        out,
4197        "# HELP {name} Calls each circuit breaker counted as slow, cancelled slow calls included"
4198    );
4199    let _ = writeln!(out, "# TYPE {name} counter");
4200    for breaker in &breakers {
4201        let label = escape_prometheus_label_value(breaker.name());
4202        let _ = writeln!(
4203            out,
4204            "{name}{{{labels},name=\"{label}\"}} {}",
4205            breaker.slow_calls_total()
4206        );
4207    }
4208
4209    let name = BREAKER_SLOW_CALL_RATIO_FAMILY;
4210    let _ = writeln!(
4211        out,
4212        "# HELP {name} Share of slow calls in each circuit breaker sample window"
4213    );
4214    let _ = writeln!(out, "# TYPE {name} gauge");
4215    for breaker in &breakers {
4216        let label = escape_prometheus_label_value(breaker.name());
4217        let _ = writeln!(
4218            out,
4219            "{name}{{{labels},name=\"{label}\"}} {}",
4220            breaker.slow_call_ratio()
4221        );
4222    }
4223}
4224
4225/// Render the shadow-mirroring families (issue #1653) into `out`.
4226///
4227/// Written as built-in families rather than through the [`crate::metrics`]
4228/// facade because that facade reserves the `autumn_` namespace for exactly
4229/// these — a framework family registered through it would be silently inert.
4230///
4231/// A replica with no mirror writes nothing at all, so the families stay absent
4232/// from the scrape until an operator turns mirroring on. Route labels are
4233/// bounded by the registry (see its `MAX_ROUTE_SERIES`), and both label values
4234/// are escaped defensively: the route can come from an app's own route
4235/// template.
4236fn write_builtin_shadow_metrics(
4237    out: &mut String,
4238    labels: &str,
4239    snapshot: &crate::shadow::ShadowSnapshot,
4240) {
4241    use std::fmt::Write;
4242
4243    for (name, help, label_name, series) in [
4244        (
4245            crate::shadow::COMPARISONS_METRIC,
4246            "Mirrored requests by route and comparison outcome",
4247            "outcome",
4248            &snapshot.comparisons_by_route,
4249        ),
4250        (
4251            crate::shadow::DIVERGENCES_METRIC,
4252            "Primary/shadow response divergences by route and kind",
4253            "kind",
4254            &snapshot.divergences_by_route,
4255        ),
4256    ] {
4257        if series.is_empty() {
4258            continue;
4259        }
4260        let _ = writeln!(out, "# HELP {name} {help}");
4261        let _ = writeln!(out, "# TYPE {name} counter");
4262        for entry in series {
4263            let route = escape_prometheus_label_value(&entry.route);
4264            let label = escape_prometheus_label_value(&entry.label);
4265            let _ = writeln!(
4266                out,
4267                "{name}{{{labels},route=\"{route}\",{label_name}=\"{label}\"}} {}",
4268                entry.count
4269            );
4270        }
4271    }
4272}
4273
4274/// Render the app-defined [`crate::metrics`] facade instruments into `out`.
4275///
4276/// Every family name written here is inserted into `emitted_families` —
4277/// including a histogram's derived `_bucket`/`_sum`/`_count` families — so a
4278/// plugin [`MetricsSource`] cannot later shadow one of them.
4279///
4280/// Deliberately unlike the built-in families, app metrics carry **no implicit
4281/// `version` label**: the label set belongs entirely to the call site, so the
4282/// framework cannot collide with a `version` label an app chose itself.
4283///
4284/// An empty snapshot writes zero bytes, keeping the facade invisible in the
4285/// scrape output until an app actually records something.
4286fn write_app_metrics(
4287    out: &mut String,
4288    snapshot: &[crate::metrics::InstrumentSnapshot],
4289    emitted_families: &mut std::collections::HashSet<String>,
4290) {
4291    // Instruments that dropped samples at the cardinality cap, in the
4292    // snapshot's (name-sorted) order.
4293    let mut dropped: Vec<(&str, u64)> = Vec::new();
4294
4295    for instrument in snapshot {
4296        if !claim_app_family_names(instrument, emitted_families) {
4297            continue;
4298        }
4299        if instrument.dropped_series > 0 {
4300            dropped.push((&instrument.name, instrument.dropped_series));
4301        }
4302        write_app_instrument(out, instrument);
4303    }
4304
4305    write_app_dropped_family(out, &dropped);
4306}
4307
4308/// Claim every family name `instrument` occupies, returning whether it may be
4309/// rendered.
4310///
4311/// Both rejections are already enforced at registration; they are re-checked
4312/// here so a scrape can never emit a malformed or duplicated family.
4313fn claim_app_family_names(
4314    instrument: &crate::metrics::InstrumentSnapshot,
4315    emitted_families: &mut std::collections::HashSet<String>,
4316) -> bool {
4317    if !is_valid_metric_name(&instrument.name) {
4318        tracing::warn!(name = %instrument.name, "app metric has an invalid metric name; skipping family");
4319        return false;
4320    }
4321    if !emitted_families.insert(instrument.name.clone()) {
4322        tracing::warn!(name = %instrument.name, "app metric collides with an already-emitted family; skipping family");
4323        return false;
4324    }
4325    if matches!(instrument.kind, crate::metrics::InstrumentKind::Histogram) {
4326        for suffix in crate::metrics::HISTOGRAM_SUFFIXES {
4327            emitted_families.insert(format!("{}{suffix}", instrument.name));
4328        }
4329    }
4330    true
4331}
4332
4333/// Write one app instrument's `# HELP`/`# TYPE` header and every sample line.
4334fn write_app_instrument(out: &mut String, instrument: &crate::metrics::InstrumentSnapshot) {
4335    use std::fmt::Write;
4336
4337    use crate::metrics::InstrumentKind;
4338
4339    if !instrument.help.is_empty() {
4340        let _ = writeln!(
4341            out,
4342            "# HELP {} {}",
4343            instrument.name,
4344            escape_help_text(&instrument.help)
4345        );
4346    }
4347    let kind = match instrument.kind {
4348        InstrumentKind::Counter => "counter",
4349        InstrumentKind::Gauge => "gauge",
4350        InstrumentKind::Histogram => "histogram",
4351    };
4352    let _ = writeln!(out, "# TYPE {} {kind}", instrument.name);
4353
4354    for series in &instrument.series {
4355        write_app_series(out, &instrument.name, series);
4356    }
4357}
4358
4359/// Write the sample line(s) for a single series of the app metric `name`.
4360fn write_app_series(out: &mut String, name: &str, series: &crate::metrics::SeriesSnapshot) {
4361    use std::fmt::Write;
4362
4363    use crate::metrics::SeriesValue;
4364
4365    // `series.labels` is a `BTreeMap`, so this is already sorted by key. The
4366    // rendered label set is built once per series, not once per output line.
4367    let labels: Vec<(String, String)> = series
4368        .labels
4369        .iter()
4370        .map(|(k, v)| (k.clone(), v.clone()))
4371        .collect();
4372    let rendered = render_labels(&labels);
4373
4374    match series.value {
4375        SeriesValue::Counter { value } => {
4376            let _ = writeln!(out, "{name}{rendered} {value}");
4377        }
4378        SeriesValue::Gauge { value } => {
4379            let _ = writeln!(out, "{name}{rendered} {}", format_sample_value(value));
4380        }
4381        SeriesValue::Histogram {
4382            count,
4383            sum,
4384            ref buckets,
4385        } => {
4386            // Every bucket line carries the same user labels with `le` appended
4387            // last, so the shared prefix — everything up to but not including
4388            // the closing brace — is rendered once and reused. `rendered` is
4389            // either empty or ends in the ASCII `}`, so the slice is always on
4390            // a character boundary. `le` values are generated by the facade
4391            // (decimal bounds and the literal `+Inf`) and need no escaping.
4392            let prefix = rendered
4393                .strip_suffix('}')
4394                .map_or_else(|| "{le=\"".to_string(), |inner| format!("{inner},le=\""));
4395            for (le, cumulative) in buckets {
4396                let _ = writeln!(out, "{name}_bucket{prefix}{le}\"}} {cumulative}");
4397            }
4398            let _ = writeln!(out, "{name}_sum{rendered} {}", format_sample_value(sum));
4399            let _ = writeln!(out, "{name}_count{rendered} {count}");
4400        }
4401    }
4402}
4403
4404/// Write the cardinality-cap drop counter, one series per instrument that
4405/// dropped samples. Writes nothing when `dropped` is empty.
4406fn write_app_dropped_family(out: &mut String, dropped: &[(&str, u64)]) {
4407    use std::fmt::Write;
4408
4409    if dropped.is_empty() {
4410        return;
4411    }
4412    let _ = writeln!(
4413        out,
4414        "# HELP {SERIES_DROPPED_FAMILY} \
4415         App metric samples dropped because the metric had already hit its series cardinality cap"
4416    );
4417    let _ = writeln!(out, "# TYPE {SERIES_DROPPED_FAMILY} counter");
4418    for (name, count) in dropped {
4419        let labels = render_labels(&[("metric".to_string(), (*name).to_string())]);
4420        let _ = writeln!(out, "{SERIES_DROPPED_FAMILY}{labels} {count}");
4421    }
4422}
4423
4424/// The labels every framework metric sample carries, without braces:
4425/// `version="…"`, then `role="…"` for a `[roles.<name>]` preset (#3543). A
4426/// built-in role adds no label, so its series stay as they were.
4427fn framework_metric_labels(version: &str, role: Option<&crate::config::Role>) -> String {
4428    let version = escape_prometheus_label_value(version);
4429    role.filter(|role| role.is_preset()).map_or_else(
4430        || format!("version=\"{version}\""),
4431        |role| {
4432            format!(
4433                "version=\"{version}\",role=\"{}\"",
4434                escape_prometheus_label_value(role.name())
4435            )
4436        },
4437    )
4438}
4439
4440/// `GET <actuator-prefix>/prometheus` -- export metrics in Prometheus format.
4441pub(crate) async fn prometheus_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4442    State(state): State<S>,
4443) -> impl IntoResponse {
4444    let snapshot = state.metrics().snapshot();
4445    // Deploy-version label so a canary controller can compare canary vs. stable
4446    // cohorts, plus the role label of a preset. Escaped defensively in case an
4447    // operator sets an exotic value via AUTUMN_DEPLOY_VERSION.
4448    let labels = framework_metric_labels(&state.deploy_version(), state.process_role().as_ref());
4449    let mut out = String::with_capacity(2048);
4450
4451    write_builtin_http_metrics(
4452        &mut out,
4453        &labels,
4454        &snapshot,
4455        &state.metrics().duration_series(),
4456    );
4457    write_builtin_cache_metrics(
4458        &mut out,
4459        &labels,
4460        &crate::cache::read_through_metrics().snapshot(),
4461    );
4462    write_builtin_job_metrics(&mut out, &labels, state.job_registry());
4463    write_builtin_db_pool_metrics(&mut out, &labels, &state);
4464    write_builtin_circuit_breaker_metrics(
4465        &mut out,
4466        &labels,
4467        &crate::circuit_breaker::global_registry().all_breakers(),
4468    );
4469    if let Some(handle) = state.shadow() {
4470        write_builtin_shadow_metrics(&mut out, &labels, &handle.snapshot());
4471    }
4472
4473    // Name ownership, in precedence order: built-in families first, then the
4474    // app-metrics facade, then plugin-contributed sources — so neither the
4475    // facade nor a plugin can shadow or duplicate a name already emitted.
4476    let mut emitted_families: std::collections::HashSet<String> = BUILTIN_METRIC_FAMILY_NAMES
4477        .iter()
4478        .map(|s| (*s).to_string())
4479        .collect();
4480    write_app_metrics(&mut out, &crate::metrics::snapshot(), &mut emitted_families);
4481    if let Some(registry) = state.metrics_source_registry() {
4482        render_plugin_sources(registry, &mut out, &mut emitted_families);
4483    }
4484
4485    (
4486        [(
4487            axum::http::header::CONTENT_TYPE,
4488            "text/plain; version=0.0.4",
4489        )],
4490        out,
4491    )
4492}
4493
4494// ── Config Properties (sensitive) ──────────────────────────────
4495
4496/// `GET <actuator-prefix>/configprops` -- all config properties with source tracking.
4497pub(crate) async fn configprops_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4498    State(state): State<S>,
4499) -> Json<serde_json::Value> {
4500    let props = state.config_props().snapshot();
4501
4502    Json(serde_json::json!({
4503        "active_profile": state.profile(),
4504        "properties": props,
4505    }))
4506}
4507
4508// ── Loggers (sensitive) ────────────────────────────────────────
4509
4510/// Available log levels for the loggers endpoint.
4511const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
4512
4513/// Response for `GET <actuator-prefix>/loggers`.
4514#[derive(Serialize)]
4515pub(crate) struct LoggersResponse {
4516    current_level: String,
4517    available_levels: Vec<&'static str>,
4518    loggers: HashMap<String, String>,
4519}
4520
4521/// `GET <actuator-prefix>/loggers` -- view current log levels.
4522pub(crate) async fn loggers_get<S: ProvideActuatorState + Send + Sync + 'static>(
4523    State(state): State<S>,
4524) -> Json<LoggersResponse> {
4525    Json(LoggersResponse {
4526        current_level: state.log_levels().current_level(),
4527        available_levels: AVAILABLE_LEVELS.to_vec(),
4528        loggers: state.log_levels().logger_overrides(),
4529    })
4530}
4531
4532/// Request body for `PUT <actuator-prefix>/loggers/{name}`.
4533#[derive(Deserialize)]
4534pub(crate) struct SetLoggerRequest {
4535    level: String,
4536}
4537
4538/// Whether `name` is a valid `tracing` directive target.
4539///
4540/// A directive target is a module path: ASCII alphanumerics plus `_`, `:`, `.`
4541/// and `-` (e.g. `my_app::module`, `tower-http`, `my.custom.target`). `root`
4542/// (and the empty string, treated as root) are special-cased. `.` and `-` are
4543/// valid inside a `tracing` target and are *not* `EnvFilter` directive
4544/// metacharacters. Anything carrying an `EnvFilter` metacharacter — `=`, `,`,
4545/// whitespace, `[`, `]`, `{`, `}` — is rejected so a malformed target never
4546/// reaches the subscriber and the endpoint cannot lie about applying it
4547/// (issue #1044).
4548fn is_valid_logger_name(name: &str) -> bool {
4549    if name.is_empty() || name == "root" {
4550        return true;
4551    }
4552    name.chars()
4553        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':' || ch == '.' || ch == '-')
4554}
4555
4556/// `PUT <actuator-prefix>/loggers/{name}` -- change a logger's level at runtime.
4557pub(crate) async fn loggers_put<S: ProvideActuatorState + Send + Sync + 'static>(
4558    State(state): State<S>,
4559    Path(name): Path<String>,
4560    Json(body): Json<SetLoggerRequest>,
4561) -> impl IntoResponse {
4562    let level = body.level.to_lowercase();
4563
4564    // Validate the level
4565    if !AVAILABLE_LEVELS.contains(&level.as_str()) {
4566        return (
4567            StatusCode::BAD_REQUEST,
4568            Json(serde_json::json!({
4569                "status": "error",
4570                "message": format!(
4571                    "Invalid level '{}'. Available levels: {}",
4572                    level,
4573                    AVAILABLE_LEVELS.join(", ")
4574                ),
4575            })),
4576        );
4577    }
4578
4579    // Validate the target name the same way, so a name carrying an `EnvFilter`
4580    // metacharacter is rejected before it can reach the subscriber (issue #1044).
4581    if !is_valid_logger_name(&name) {
4582        return (
4583            StatusCode::BAD_REQUEST,
4584            Json(serde_json::json!({
4585                "status": "error",
4586                "message": format!(
4587                    "Invalid logger name '{name}'. Names may contain only \
4588                     alphanumerics, '_', ':', '.' and '-' (or 'root')."
4589                ),
4590            })),
4591        );
4592    }
4593
4594    // Base the response on the *actual* apply outcome, never on handle presence:
4595    // a change that failed to reach the live subscriber must not report `ok`
4596    // (issue #1044).
4597    match state.log_levels().set_logger_level(&name, &level) {
4598        LogLevelChange::Applied { previous } => (
4599            StatusCode::OK,
4600            Json(serde_json::json!({
4601                "status": "ok",
4602                "message": format!("Logger '{name}' set to '{level}'"),
4603                "previous": previous,
4604                "applied": true,
4605            })),
4606        ),
4607        LogLevelChange::Recorded { previous } => (
4608            StatusCode::OK,
4609            Json(serde_json::json!({
4610                "status": "recorded",
4611                "message": format!(
4612                    "Logger '{name}' recorded as '{level}' but not applied: no reload-capable subscriber is installed"
4613                ),
4614                "previous": previous,
4615                "applied": false,
4616            })),
4617        ),
4618        LogLevelChange::Rejected { reason } => (
4619            StatusCode::INTERNAL_SERVER_ERROR,
4620            Json(serde_json::json!({
4621                "status": "error",
4622                "message": format!("Logger '{name}' could not be set to '{level}': {reason}"),
4623                "applied": false,
4624            })),
4625        ),
4626    }
4627}
4628
4629// ── Logfile (sensitive) ────────────────────────────────────────
4630
4631/// Query parameters for `GET <actuator-prefix>/logfile`.
4632#[derive(Debug, Deserialize, Default)]
4633pub(crate) struct LogfileQuery {
4634    /// Minimum log level to include (case-insensitive).
4635    ///
4636    /// Valid values: `trace`, `debug`, `info`, `warn`, `error`.
4637    /// When absent all levels are returned.
4638    pub level: Option<String>,
4639    /// Maximum number of entries to return (most-recent N, newest-last).
4640    pub limit: Option<usize>,
4641}
4642
4643/// JSON response shape for `GET <actuator-prefix>/logfile`.
4644#[derive(Debug, Serialize)]
4645pub(crate) struct LogfileResponse {
4646    /// Captured log entries, oldest first.
4647    pub entries: Vec<crate::log::capture::CapturedLogEntry>,
4648    /// Total entries in the buffer (before `limit` is applied).
4649    pub total: usize,
4650    /// `true` when the capture buffer is enabled and populated by the layer.
4651    pub capture_enabled: bool,
4652}
4653
4654/// `GET <actuator-prefix>/logfile` — recent structured log entries.
4655///
4656/// Returns entries from the in-memory capture buffer, filtered by `?level=`
4657/// and capped by `?limit=`. Only available when `actuator.sensitive = true`
4658/// and `log.capture.enabled = true`.  When capture is disabled the endpoint
4659/// still responds with `200` and an empty list so API consumers can handle
4660/// the case uniformly.
4661///
4662/// Returns `400 Bad Request` when an unrecognised `?level=` value is supplied
4663/// so that typos (e.g. `?level=warning`) are rejected rather than silently
4664/// broadening the response to all captured entries.
4665pub(crate) async fn logfile_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4666    State(state): State<S>,
4667    axum::extract::Query(query): axum::extract::Query<LogfileQuery>,
4668) -> Result<axum::Json<LogfileResponse>, (StatusCode, axum::Json<serde_json::Value>)> {
4669    let min_level = match query.level.as_deref() {
4670        None => None,
4671        Some(s) => match crate::log::capture::level_from_str(s) {
4672            Some(level) => Some(level),
4673            None => {
4674                return Err((
4675                    StatusCode::BAD_REQUEST,
4676                    axum::Json(serde_json::json!({
4677                        "error": format!(
4678                            "invalid level {:?}; valid values: TRACE, DEBUG, INFO, WARN, ERROR",
4679                            s
4680                        )
4681                    })),
4682                ));
4683            }
4684        },
4685    };
4686
4687    Ok(match state.log_buffer() {
4688        None => axum::Json(LogfileResponse {
4689            entries: vec![],
4690            total: 0,
4691            capture_enabled: false,
4692        }),
4693        Some(buf) => {
4694            let total = buf.len();
4695            let entries = buf.snapshot(min_level, query.limit);
4696            axum::Json(LogfileResponse {
4697                entries,
4698                total,
4699                capture_enabled: true,
4700            })
4701        }
4702    })
4703}
4704
4705// ── Tasks (sensitive) ──────────────────────────────────────────
4706
4707/// `GET <actuator-prefix>/tasks` -- scheduled task status.
4708pub(crate) async fn tasks_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4709    State(state): State<S>,
4710) -> Json<serde_json::Value> {
4711    let tasks = state.task_registry().snapshot();
4712
4713    Json(serde_json::json!({
4714        "scheduled_tasks": tasks,
4715    }))
4716}
4717
4718/// `GET <actuator-prefix>/jobs` -- ad-hoc background job status.
4719pub(crate) async fn jobs_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4720    State(state): State<S>,
4721) -> Json<serde_json::Value> {
4722    let jobs = state.job_registry().snapshot();
4723    let queues = state.job_registry().queue_snapshot();
4724    let mut body = serde_json::json!({ "jobs": jobs, "queues": queues });
4725    if let Some(role) = state.process_role() {
4726        let mut info = serde_json::json!({ "name": role.name(), "base": role.base().as_str() });
4727        if let Some(pin) = role.pin() {
4728            info["pin"] = serde_json::json!(pin);
4729        }
4730        body["role"] = info;
4731    }
4732    Json(body)
4733}
4734
4735/// `GET <actuator-prefix>/derivations` -- maintained derived read models
4736/// (issue #1769).
4737///
4738/// Reports every `#[derivation]` this binary declares: its definition hash, the
4739/// hash and backfill state recorded in `_autumn_derivations`, and its current
4740/// drift from the source of truth. `drift: 0` on every row is the healthy
4741/// answer; a nonzero one names the derivation to recompute.
4742///
4743/// Sensitive-gated, like `/env` and `/graph`: the document names parent tables,
4744/// child tables and the columns joining them.
4745///
4746/// Each drift figure is one aggregate over a parent table, so this is an
4747/// operator endpoint rather than a monitoring one. Do not scrape it.
4748///
4749/// A process with no database pool answers `503` rather than `404`, so an
4750/// operator can tell "this build has no such endpoint" apart from "this process
4751/// has no database to report against".
4752#[cfg(feature = "db")]
4753pub(crate) async fn derivations_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4754    State(state): State<S>,
4755) -> axum::response::Response {
4756    // The control pool is one target among several: a shard-only deployment
4757    // (`[[database.shards]]` with no control role, which the config accepts)
4758    // has none, and its derivations live on the shards. Only a process with
4759    // no database at all is a 503.
4760    let mut report = Vec::new();
4761    if let Some(pool) = state.pool() {
4762        // The control target fails the same way a shard does: one error row,
4763        // so a control pool that cannot be reached does not hide the shards
4764        // that maintain the derivations. Only a process with no shards at all
4765        // turns a control failure into the response's own status.
4766        let statuses = match pool.get().await {
4767            Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4768            Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4769                error.to_string(),
4770            ))),
4771        };
4772        match statuses {
4773            Ok(statuses) => report.extend(derivation_report("control", statuses)),
4774            Err(error) if state.shards().is_some() => report.push(serde_json::json!({
4775                "target": "control",
4776                "error": "could not read derivation state",
4777                "detail": error.to_string(),
4778            })),
4779            Err(error) => {
4780                return (
4781                    StatusCode::INTERNAL_SERVER_ERROR,
4782                    Json(serde_json::json!({
4783                        "error": "could not read derivation state",
4784                        "target": "control",
4785                        "detail": error.to_string(),
4786                    })),
4787                )
4788                    .into_response();
4789            }
4790        }
4791    } else if state.shards().is_none() {
4792        return (
4793            StatusCode::SERVICE_UNAVAILABLE,
4794            Json(serde_json::json!({
4795                "error": "no database pool is configured in this process",
4796                "hint": "derivation state lives in the `_autumn_derivations` table, so \
4797                         reporting it needs a database connection",
4798            })),
4799        )
4800            .into_response();
4801    }
4802    // A sharded app maintains its derivations on every shard primary (that is
4803    // where the tenant rows live, and where startup reconciles and sweeps), so
4804    // the control database alone would report a clean slate while a shard sat
4805    // pending or drifted. One shard that cannot answer is reported as such
4806    // rather than hiding the rest.
4807    if let Some(shards) = state.shards() {
4808        for shard in shards.iter() {
4809            let target = shard.name();
4810            let statuses = match shard.primary_pool().get().await {
4811                Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4812                Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4813                    error.to_string(),
4814                ))),
4815            };
4816            match statuses {
4817                Ok(statuses) => report.extend(derivation_report(target, statuses)),
4818                Err(error) => report.push(serde_json::json!({
4819                    "target": target,
4820                    "error": "could not read derivation state",
4821                    "detail": error.to_string(),
4822                })),
4823            }
4824        }
4825    }
4826    (StatusCode::OK, Json(report)).into_response()
4827}
4828
4829/// One database's derivation statuses as the endpoint's rows, each naming the
4830/// `target` it was read from (`"control"`, or a shard's name).
4831#[cfg(feature = "db")]
4832fn derivation_report(
4833    target: &str,
4834    statuses: Vec<crate::derivation::DerivationStatus>,
4835) -> Vec<serde_json::Value> {
4836    statuses
4837        .into_iter()
4838        .map(|status| {
4839            let mut row = serde_json::to_value(status).unwrap_or_else(
4840                |error| serde_json::json!({ "error": format!("unserialisable status: {error}") }),
4841            );
4842            if let serde_json::Value::Object(ref mut map) = row {
4843                map.insert(
4844                    "target".to_owned(),
4845                    serde_json::Value::String(target.to_owned()),
4846                );
4847            }
4848            row
4849        })
4850        .collect()
4851}
4852
4853#[cfg(feature = "graph")]
4854/// `GET <actuator-prefix>/graph` -- the application's architecture graph
4855/// (issue #1747).
4856///
4857/// This is the "retrievable from the running binary" surface: the graph is
4858/// assembled from the binary's own link-time registrations at startup, so there
4859/// is no side file to fetch and nothing that can be stale relative to the code
4860/// that is actually running.
4861///
4862/// Sensitive-gated, like `/env` and `/configprops`. The document names every
4863/// route, its auth requirement, and which table each one touches -- a map of
4864/// exactly where an attacker would look first, so it is not a public surface.
4865///
4866/// The handler reads the graph from the state. Each application serves its
4867/// own graph (#2520 item 17).
4868///
4869/// The endpoint answers `503`, not `404`, when the state has no graph. An
4870/// operator can then tell "this app published no graph" from "this build has
4871/// no such endpoint".
4872pub(crate) async fn graph_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4873    State(state): State<S>,
4874) -> axum::response::Response {
4875    graph_response(state.architecture_graph_json())
4876}
4877
4878#[cfg(feature = "graph")]
4879/// Render the `/actuator/graph` response for a given installed graph.
4880///
4881/// This function is separate from the handler. Tests use it to examine the
4882/// two branches.
4883fn graph_response(graph: Option<bytes::Bytes>) -> axum::response::Response {
4884    graph.map_or_else(
4885        || {
4886            (
4887                StatusCode::SERVICE_UNAVAILABLE,
4888                Json(serde_json::json!({
4889                    "error": "no architecture graph is installed for this application",
4890                    "hint": "the graph is published when the application router is built; a \
4891                             process that serves requests without building one (a bare \
4892                             `axum::Router` in a test, say) has none to report",
4893                })),
4894            )
4895                .into_response()
4896        },
4897        |json| {
4898            (
4899                StatusCode::OK,
4900                [(axum::http::header::CONTENT_TYPE, "application/json")],
4901                json,
4902            )
4903                .into_response()
4904        },
4905    )
4906}
4907
4908/// `GET <actuator-prefix>/cost` -- the cost signal, the request cost of
4909/// each tenant, and the cost of job runs and task ticks (issue #1720).
4910///
4911/// Sensitive-gated: tenant ids and their costs are not public. With
4912/// `[cost] enabled = false` the endpoint answers `{"enabled": false, ...}`.
4913pub(crate) async fn cost_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4914    State(state): State<S>,
4915) -> Json<serde_json::Value> {
4916    let signal = state.cost_signal();
4917    let accountant = state.cost_accountant();
4918    let snapshot = accountant
4919        .as_ref()
4920        .map(crate::cost::CostAccountant::snapshot)
4921        .unwrap_or_default();
4922    Json(serde_json::json!({
4923        "enabled": accountant.is_some(),
4924        "signal": signal.map(|signal| signal.snapshot()),
4925        "total": snapshot.total,
4926        "tenants": snapshot.tenants,
4927        "jobs": snapshot.jobs,
4928        "tasks": snapshot.tasks,
4929    }))
4930}
4931
4932/// `GET <actuator-prefix>/shadow` -- shadow-mirroring counters and the most
4933/// recent primary-vs-shadow divergences (issue #1653).
4934///
4935/// Sensitive-gated, like `/tasks` and `/jobs`: the recorded samples are
4936/// redacted excerpts of real production responses, so this is not a public
4937/// surface. A replica with mirroring switched off answers with
4938/// `{"enabled": false, ...}` rather than `404`, so an operator can tell
4939/// "mirroring is off here" apart from "this build has no such endpoint".
4940pub(crate) async fn shadow_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4941    State(state): State<S>,
4942) -> Json<crate::shadow::ShadowSnapshot> {
4943    Json(
4944        state
4945            .shadow()
4946            .map_or_else(crate::shadow::ShadowHandle::disabled_snapshot, |handle| {
4947                handle.snapshot()
4948            }),
4949    )
4950}
4951
4952// ── Data capsules (issue #1811) ─────────────────────────────────────────────
4953
4954#[cfg(feature = "gdpr")]
4955/// Request body for `POST <actuator-prefix>/capsules/export`.
4956#[derive(Deserialize)]
4957pub(crate) struct CapsuleExportRequest {
4958    subject: String,
4959}
4960
4961#[cfg(feature = "gdpr")]
4962/// Request body for `POST <actuator-prefix>/capsules/{import,verify}`.
4963#[derive(Deserialize)]
4964pub(crate) struct CapsuleNameRequest {
4965    capsule: String,
4966}
4967
4968#[cfg(feature = "gdpr")]
4969fn capsule_error(error: &crate::gdpr::portability::DataCapsuleError) -> axum::response::Response {
4970    use crate::gdpr::portability::DataCapsuleError as E;
4971    let status = match error {
4972        E::NotConfigured(_) | E::MissingSigningSecret => StatusCode::NOT_IMPLEMENTED,
4973        E::InvalidName(_) | E::InvalidInput(_) => StatusCode::BAD_REQUEST,
4974        E::NotEmpty(_) | E::Conflict(_) => StatusCode::CONFLICT,
4975        E::Integrity(_)
4976        | E::UnsupportedFormat(_)
4977        | E::UnknownTable(_)
4978        | E::RelationshipCycle(_)
4979        | E::Json { .. } => StatusCode::UNPROCESSABLE_ENTITY,
4980        _ => StatusCode::INTERNAL_SERVER_ERROR,
4981    };
4982    (
4983        status,
4984        Json(serde_json::json!({"status": "error", "message": error.to_string()})),
4985    )
4986        .into_response()
4987}
4988
4989#[cfg(feature = "gdpr")]
4990/// The service and the path of an existing capsule, or the error response.
4991#[allow(
4992    clippy::result_large_err,
4993    reason = "the error is the response itself; it is made once per request"
4994)]
4995fn existing_capsule<S: ProvideActuatorState>(
4996    state: &S,
4997    name: &str,
4998) -> Result<(crate::gdpr::portability::CapsuleService, std::path::PathBuf), axum::response::Response>
4999{
5000    let service = state.data_capsules().map_err(|e| capsule_error(&e))?;
5001    let path = service.capsule_path(name).map_err(|e| capsule_error(&e))?;
5002    // `symlink_metadata` does not follow a link, so a link to a capsule
5003    // outside the directory is "not found".
5004    let is_real_dir = std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_dir());
5005    if !is_real_dir {
5006        return Err((
5007            StatusCode::NOT_FOUND,
5008            Json(serde_json::json!({"status": "error", "message": "capsule not found"})),
5009        )
5010            .into_response());
5011    }
5012    Ok((service, path))
5013}
5014
5015#[cfg(feature = "gdpr")]
5016/// `POST <actuator-prefix>/capsules/export` -- write a capsule for a subject.
5017pub(crate) async fn capsules_export_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5018    State(state): State<S>,
5019    Json(body): Json<CapsuleExportRequest>,
5020) -> axum::response::Response {
5021    let service = match state.data_capsules() {
5022        Ok(service) => service,
5023        Err(e) => return capsule_error(&e),
5024    };
5025    let name = crate::gdpr::portability::CapsuleService::capsule_name();
5026    let path = match service.capsule_path(&name) {
5027        Ok(path) => path,
5028        Err(e) => return capsule_error(&e),
5029    };
5030    match service.export_to(&body.subject, &path).await {
5031        Ok(report) => (
5032            StatusCode::OK,
5033            Json(serde_json::json!({"status": "ok", "capsule": name, "report": report})),
5034        )
5035            .into_response(),
5036        Err(e) => capsule_error(&e),
5037    }
5038}
5039
5040#[cfg(feature = "gdpr")]
5041/// `POST <actuator-prefix>/capsules/verify` -- check a capsule signature and hashes.
5042pub(crate) async fn capsules_verify_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5043    State(state): State<S>,
5044    Json(body): Json<CapsuleNameRequest>,
5045) -> axum::response::Response {
5046    let (service, path) = match existing_capsule(&state, &body.capsule) {
5047        Ok(found) => found,
5048        Err(response) => return response,
5049    };
5050    match service.verify(&path).await {
5051        Ok(report) => (
5052            StatusCode::OK,
5053            Json(serde_json::json!({"status": "ok", "report": report})),
5054        )
5055            .into_response(),
5056        Err(e) => capsule_error(&e),
5057    }
5058}
5059
5060#[cfg(feature = "gdpr")]
5061/// `POST <actuator-prefix>/capsules/import` -- verify, then import a capsule.
5062pub(crate) async fn capsules_import_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5063    State(state): State<S>,
5064    Json(body): Json<CapsuleNameRequest>,
5065) -> axum::response::Response {
5066    let (service, path) = match existing_capsule(&state, &body.capsule) {
5067        Ok(found) => found,
5068        Err(response) => return response,
5069    };
5070    match service.import_from(&path).await {
5071        Ok(summary) => (
5072            StatusCode::OK,
5073            Json(serde_json::json!({"status": "ok", "summary": summary})),
5074        )
5075            .into_response(),
5076        Err(e) => capsule_error(&e),
5077    }
5078}
5079
5080#[cfg(feature = "http-client")]
5081/// Request body for `POST <actuator-prefix>/webhooks/replay`.
5082#[derive(Deserialize)]
5083pub(crate) struct ReplayRequest {
5084    log_id: String,
5085}
5086
5087#[cfg(feature = "http-client")]
5088async fn enqueue_webhook_replay_job(log_id: &str) -> Result<(), String> {
5089    let job_payload = serde_json::json!({
5090        "log_id": log_id,
5091        "replay": true,
5092    });
5093
5094    let Some(job_client) = crate::job::global_job_client() else {
5095        return Err("Global job client is not available".to_string());
5096    };
5097
5098    job_client
5099        .enqueue("autumn_webhook_delivery", job_payload)
5100        .await
5101        .map_err(|e| format!("Failed to enqueue job: {e}"))
5102}
5103
5104#[cfg(feature = "http-client")]
5105/// `GET <actuator-prefix>/webhooks/dlq` -- list dead-lettered webhook logs.
5106pub(crate) async fn webhooks_dlq_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5107    State(state): State<S>,
5108) -> impl IntoResponse {
5109    let Some(manager) = state.webhook_outbound() else {
5110        return (
5111            StatusCode::NOT_IMPLEMENTED,
5112            Json(serde_json::json!({
5113                "status": "error",
5114                "message": "Outbound webhook support is not configured or enabled"
5115            })),
5116        )
5117            .into_response();
5118    };
5119
5120    match manager.store().get_dlq_logs().await {
5121        Ok(logs) => (StatusCode::OK, Json(logs)).into_response(),
5122        Err(e) => (
5123            StatusCode::INTERNAL_SERVER_ERROR,
5124            Json(serde_json::json!({
5125                "status": "error",
5126                "message": format!("Failed to fetch DLQ logs: {}", e)
5127            })),
5128        )
5129            .into_response(),
5130    }
5131}
5132
5133#[cfg(feature = "http-client")]
5134/// `POST <actuator-prefix>/webhooks/replay` -- replay a dead-lettered webhook log.
5135pub(crate) async fn webhooks_replay_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5136    State(state): State<S>,
5137    Json(body): Json<ReplayRequest>,
5138) -> impl IntoResponse {
5139    let Some(manager) = state.webhook_outbound() else {
5140        return (
5141            StatusCode::NOT_IMPLEMENTED,
5142            Json(serde_json::json!({
5143                "status": "error",
5144                "message": "Outbound webhook support is not configured or enabled"
5145            })),
5146        )
5147            .into_response();
5148    };
5149
5150    let log_opt = match manager.store().get_delivery_log(&body.log_id).await {
5151        Ok(log) => log,
5152        Err(e) => {
5153            return (
5154                StatusCode::INTERNAL_SERVER_ERROR,
5155                Json(serde_json::json!({
5156                    "status": "error",
5157                    "message": format!("Failed to retrieve log: {}", e)
5158                })),
5159            )
5160                .into_response();
5161        }
5162    };
5163
5164    let Some(log) = log_opt else {
5165        return (
5166            StatusCode::NOT_FOUND,
5167            Json(serde_json::json!({
5168                "status": "error",
5169                "message": format!("Log with ID {} not found", body.log_id)
5170            })),
5171        )
5172            .into_response();
5173    };
5174
5175    if !log.is_dlq {
5176        return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
5177            "status": "error",
5178            "message": format!("Log with ID {} is not in the Dead Letter Queue (DLQ)", body.log_id)
5179        }))).into_response();
5180    }
5181
5182    if let Some(response) = blocked_webhook_replay_response(&manager, &log, &body.log_id).await {
5183        return response;
5184    }
5185
5186    let subscription_id = log.subscription_id.clone();
5187    let original_log = log.clone();
5188    let log = reset_webhook_replay_log(log);
5189
5190    if let Err(e) = manager.store().log_delivery(log).await {
5191        return (
5192            StatusCode::INTERNAL_SERVER_ERROR,
5193            Json(serde_json::json!({
5194                "status": "error",
5195                "message": format!("Failed to update delivery log state: {}", e)
5196            })),
5197        )
5198            .into_response();
5199    }
5200
5201    // Enqueue background delivery job now that the log state is safely reset in the store
5202    if let Err(message) = enqueue_webhook_replay_job(&body.log_id).await {
5203        if let Err(rollback_error) = manager.store().replace_delivery_log(original_log).await {
5204            tracing::error!(
5205                log_id = %body.log_id,
5206                "Failed to roll back webhook replay log after enqueue failure: {}",
5207                rollback_error
5208            );
5209            return (
5210                StatusCode::INTERNAL_SERVER_ERROR,
5211                Json(serde_json::json!({
5212                    "status": "error",
5213                    "message": format!("{message}; failed to restore DLQ log state: {rollback_error}")
5214                })),
5215            )
5216                .into_response();
5217        }
5218
5219        return (
5220            StatusCode::INTERNAL_SERVER_ERROR,
5221            Json(serde_json::json!({
5222                "status": "error",
5223                "message": message
5224            })),
5225        )
5226            .into_response();
5227    }
5228
5229    // Reactivate auto-failed subscriptions only after the replay job is queued.
5230    if let Err(e) = manager
5231        .store()
5232        .reactivate_failed_subscription(&subscription_id)
5233        .await
5234    {
5235        tracing::warn!(subscription_id = %subscription_id, "Failed to reactivate subscription during replay: {}", e);
5236    }
5237
5238    (
5239        StatusCode::OK,
5240        Json(serde_json::json!({
5241            "status": "ok",
5242            "message": format!("Replay successfully enqueued for log {}", body.log_id)
5243        })),
5244    )
5245        .into_response()
5246}
5247
5248#[cfg(feature = "http-client")]
5249fn reset_webhook_replay_log(
5250    mut log: crate::webhook_outbound::WebhookDeliveryLog,
5251) -> crate::webhook_outbound::WebhookDeliveryLog {
5252    log.is_dlq = false;
5253    log.attempt = 1;
5254    log.last_error = None;
5255    log.response_status = None;
5256    log.response_body = None;
5257    log.timestamp = crate::time::ambient_now();
5258    log
5259}
5260
5261#[cfg(feature = "http-client")]
5262async fn blocked_webhook_replay_response(
5263    manager: &crate::webhook_outbound::WebhookOutboundManager,
5264    log: &crate::webhook_outbound::WebhookDeliveryLog,
5265    log_id: &str,
5266) -> Option<axum::response::Response> {
5267    let subscription = match manager.store().get_subscription(&log.subscription_id).await {
5268        Ok(subscription) => subscription,
5269        Err(e) => {
5270            return Some(
5271                (
5272                    StatusCode::INTERNAL_SERVER_ERROR,
5273                    Json(serde_json::json!({
5274                        "status": "error",
5275                        "message": format!("Failed to retrieve subscription: {}", e)
5276                    })),
5277                )
5278                    .into_response(),
5279            );
5280        }
5281    };
5282
5283    let Some(subscription) = subscription else {
5284        return Some(
5285            (
5286                StatusCode::NOT_FOUND,
5287                Json(serde_json::json!({
5288                    "status": "error",
5289                    "message": format!(
5290                        "Subscription {} for replay log {} was not found",
5291                        log.subscription_id, log_id
5292                    )
5293                })),
5294            )
5295                .into_response(),
5296        );
5297    };
5298
5299    if subscription.status != crate::webhook_outbound::WebhookSubscriptionStatus::Disabled {
5300        return None;
5301    }
5302
5303    Some(
5304        (
5305            StatusCode::CONFLICT,
5306            Json(serde_json::json!({
5307                "status": "error",
5308                "message": format!(
5309                    "Subscription {} is disabled; re-enable it before replaying log {}",
5310                    log.subscription_id, log_id
5311                )
5312            })),
5313        )
5314            .into_response(),
5315    )
5316}
5317
5318// ── A11y ───────────────────────────────────────────────────────
5319
5320/// `GET <actuator-prefix>/a11y` -- scaffold-level accessibility posture.
5321///
5322/// Returns a JSON object describing which WCAG 2.1 AA scaffold concerns the
5323/// application addresses.  Available in all profiles (like `/actuator/health`).
5324pub(crate) async fn a11y_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5325    State(state): State<S>,
5326) -> Json<A11yPosture> {
5327    Json(state.a11y_posture())
5328}
5329
5330// ── Channels (sensitive) ───────────────────────────────────────
5331
5332/// `GET <actuator-prefix>/channels` -- get current channel snapshots.
5333#[cfg(feature = "ws")]
5334pub(crate) async fn channels_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5335    State(state): State<S>,
5336) -> Json<serde_json::Value> {
5337    let channels = state.channels().snapshot();
5338    Json(serde_json::json!({
5339        "channels": channels,
5340    }))
5341}
5342
5343// ── Tasks Stream (WebSocket) ───────────────────────────────────
5344
5345/// `GET <actuator-prefix>/tasks/stream` -- stream scheduled task events.
5346#[cfg(feature = "ws")]
5347pub(crate) async fn tasks_stream_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5348    State(state): State<S>,
5349    tunnel: crate::http_server::KeepTunnel,
5350    ws: axum::extract::ws::WebSocketUpgrade,
5351) -> impl IntoResponse {
5352    ws.on_upgrade(move |mut socket| async move {
5353        // Over HTTP/2, the server keeps the connection while this is held.
5354        let _tunnel = tunnel;
5355        let mut rx = state.channels().subscribe("sys:tasks");
5356        let shutdown = state.shutdown_token();
5357
5358        loop {
5359            tokio::select! {
5360                res = rx.recv() => {
5361                    match res {
5362                        Ok(msg) => {
5363                            let ws_msg = axum::extract::ws::Message::Text(msg.into_string().into());
5364                            if socket.send(ws_msg).await.is_err() {
5365                                break;
5366                            }
5367                        }
5368                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
5369                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
5370                    }
5371                }
5372                () = shutdown.cancelled() => {
5373                    let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
5374                    break;
5375                }
5376                else => break,
5377            }
5378        }
5379    })
5380}
5381
5382// ── Router builder ──────────────────────────────────────────────
5383
5384pub(crate) fn normalize_actuator_prefix(prefix: &str) -> String {
5385    let trimmed = prefix.trim();
5386    if trimmed.is_empty() || trimmed == "/" {
5387        String::new()
5388    } else {
5389        let trimmed = trimmed.trim_end_matches('/');
5390        if trimmed.starts_with('/') {
5391            trimmed.to_owned()
5392        } else {
5393            format!("/{trimmed}")
5394        }
5395    }
5396}
5397
5398pub(crate) fn actuator_route_glob(prefix: &str) -> String {
5399    let prefix = normalize_actuator_prefix(prefix);
5400    if prefix.is_empty() {
5401        "/*".to_owned()
5402    } else {
5403        format!("{prefix}/*")
5404    }
5405}
5406
5407pub(crate) fn actuator_route_path(prefix: &str, suffix: &str) -> String {
5408    let prefix = normalize_actuator_prefix(prefix);
5409    if prefix.is_empty() {
5410        suffix.to_owned()
5411    } else {
5412        format!("{prefix}{suffix}")
5413    }
5414}
5415
5416#[cfg(feature = "gdpr")]
5417/// The data-capsule routes (issue #1811). All are `POST` and sensitive.
5418const CAPSULE_ROUTES: [&str; 3] = ["/capsules/export", "/capsules/import", "/capsules/verify"];
5419
5420pub(crate) fn actuator_endpoint_paths(
5421    prefix: &str,
5422    sensitive: bool,
5423    prometheus_enabled: bool,
5424) -> Vec<String> {
5425    let mut paths = vec![
5426        actuator_route_path(prefix, "/health"),
5427        actuator_route_path(prefix, "/info"),
5428        actuator_route_path(prefix, "/metrics"),
5429        actuator_route_path(prefix, "/a11y"),
5430        actuator_route_path(prefix, "/ui"),
5431        actuator_route_path(prefix, "/ui/metrics"),
5432    ];
5433
5434    if prometheus_enabled {
5435        paths.push(actuator_route_path(prefix, "/prometheus"));
5436    }
5437
5438    if sensitive {
5439        paths.push(actuator_route_path(prefix, "/circuitbreakers"));
5440        paths.push(actuator_route_path(prefix, "/env"));
5441        paths.push(actuator_route_path(prefix, "/configprops"));
5442        paths.push(actuator_route_path(prefix, "/loggers"));
5443        paths.push(actuator_route_path(prefix, "/logfile"));
5444        paths.push(actuator_route_path(prefix, "/tasks"));
5445        paths.push(actuator_route_path(prefix, "/jobs"));
5446        paths.push(actuator_route_path(prefix, "/ui/tasks"));
5447        paths.push(actuator_route_path(prefix, "/shadow"));
5448        paths.push(actuator_route_path(prefix, "/cost"));
5449        #[cfg(feature = "graph")]
5450        paths.push(actuator_route_path(prefix, "/graph"));
5451        // POST-only, like `/webhooks/replay` below: listed so the startup
5452        // barrier lets them through.
5453        #[cfg(feature = "gdpr")]
5454        for suffix in CAPSULE_ROUTES {
5455            paths.push(actuator_route_path(prefix, suffix));
5456        }
5457        #[cfg(feature = "db")]
5458        {
5459            paths.push(actuator_route_path(prefix, "/derivations"));
5460        }
5461        #[cfg(feature = "system-info")]
5462        {
5463            paths.push(actuator_route_path(prefix, "/system"));
5464        }
5465        #[cfg(feature = "http-client")]
5466        {
5467            paths.push(actuator_route_path(prefix, "/webhooks/dlq"));
5468            // `/webhooks/replay` is mounted as `POST` (see
5469            // `actuator_mutating_routes`), not `GET`, but it is included in this
5470            // path set because the runtime startup barrier seeds its actuator
5471            // allow-list from this helper (`StartupBarrierState::from_config`).
5472            // Without it, the `POST {prefix}/webhooks/replay` mount would no
5473            // longer bypass the startup barrier. The GET-only route listing
5474            // (`append_framework_routes`) excludes any path also produced by
5475            // `actuator_mutating_routes`, so this does not surface as a phantom
5476            // GET there.
5477            paths.push(actuator_route_path(prefix, "/webhooks/replay"));
5478        }
5479        #[cfg(feature = "ws")]
5480        {
5481            paths.push(actuator_route_path(prefix, "/channels"));
5482            paths.push(actuator_route_path(prefix, "/tasks/stream"));
5483        }
5484    }
5485
5486    paths
5487}
5488
5489/// Enumerate the actuator's mutating (non-`GET`) framework routes, gated to
5490/// match the mounts in [`actuator_router_with_prefix`].
5491///
5492/// Kept separate from [`actuator_endpoint_paths`] (which is `GET`-only and
5493/// paths-only) so the route listing can classify these with their real HTTP
5494/// method. Returns `(method, path)` pairs using the same
5495/// [`actuator_route_path`] helper as the mounting code so paths match
5496/// byte-for-byte.
5497pub(crate) fn actuator_mutating_routes(
5498    prefix: &str,
5499    sensitive: bool,
5500) -> Vec<(&'static str, String)> {
5501    let mut routes: Vec<(&'static str, String)> = Vec::new();
5502
5503    if sensitive {
5504        routes.push(("PUT", actuator_route_path(prefix, "/loggers/{name}")));
5505        #[cfg(feature = "gdpr")]
5506        for suffix in CAPSULE_ROUTES {
5507            routes.push(("POST", actuator_route_path(prefix, suffix)));
5508        }
5509        #[cfg(feature = "http-client")]
5510        {
5511            routes.push(("POST", actuator_route_path(prefix, "/webhooks/replay")));
5512        }
5513    }
5514
5515    routes
5516}
5517
5518/// Build the actuator router with profile-aware endpoint exposure.
5519///
5520/// In dev mode (or when `actuator.sensitive = true`), all endpoints are
5521/// exposed. In prod mode, only health, info, and metrics are available.
5522///
5523/// The Prometheus scrape endpoint is mounted unconditionally here (independent
5524/// of `sensitive`). The framework router mounts the actuator from configuration
5525/// and gates `/actuator/prometheus` on the `actuator.prometheus` flag.
5526pub fn actuator_router<S: ProvideActuatorState + Send + Sync + Clone + 'static>(
5527    sensitive: bool,
5528) -> axum::Router<S> {
5529    actuator_router_with_prefix("/actuator", sensitive, true)
5530}
5531
5532/// Build the actuator router at a configured prefix.
5533///
5534/// This is the prefix-aware variant used by the framework router.
5535///
5536/// `prometheus_enabled` controls the `/actuator/prometheus` scrape endpoint
5537/// independently of `sensitive`, so platform metrics scraping can be exposed
5538/// without also exposing sensitive actuator surfaces.
5539#[allow(clippy::too_many_lines)]
5540pub(crate) fn actuator_router_with_prefix<
5541    S: ProvideActuatorState + Send + Sync + Clone + 'static,
5542>(
5543    prefix: &str,
5544    sensitive: bool,
5545    prometheus_enabled: bool,
5546) -> axum::Router<S> {
5547    let mut router = axum::Router::new()
5548        .route(
5549            &actuator_route_path(prefix, "/health"),
5550            axum::routing::get(health::<S>),
5551        )
5552        .route(
5553            &actuator_route_path(prefix, "/info"),
5554            axum::routing::get(info::<S>),
5555        )
5556        .route(
5557            &actuator_route_path(prefix, "/metrics"),
5558            axum::routing::get(metrics_endpoint::<S>),
5559        )
5560        .route(
5561            &actuator_route_path(prefix, "/a11y"),
5562            axum::routing::get(a11y_endpoint::<S>),
5563        );
5564
5565    if prometheus_enabled {
5566        router = router.route(
5567            &actuator_route_path(prefix, "/prometheus"),
5568            axum::routing::get(prometheus_endpoint::<S>),
5569        );
5570    }
5571
5572    if sensitive {
5573        router = router
5574            .route(
5575                &actuator_route_path(prefix, "/circuitbreakers"),
5576                axum::routing::get(circuitbreakers_endpoint::<S>),
5577            )
5578            .route(
5579                &actuator_route_path(prefix, "/env"),
5580                axum::routing::get(env_endpoint::<S>),
5581            )
5582            .route(
5583                &actuator_route_path(prefix, "/configprops"),
5584                axum::routing::get(configprops_endpoint::<S>),
5585            )
5586            .route(
5587                &actuator_route_path(prefix, "/loggers"),
5588                axum::routing::get(loggers_get::<S>),
5589            )
5590            .route(
5591                &actuator_route_path(prefix, "/loggers/{name}"),
5592                axum::routing::put(loggers_put::<S>),
5593            )
5594            .route(
5595                &actuator_route_path(prefix, "/logfile"),
5596                axum::routing::get(logfile_endpoint::<S>),
5597            )
5598            .route(
5599                &actuator_route_path(prefix, "/tasks"),
5600                axum::routing::get(tasks_endpoint::<S>),
5601            )
5602            .route(
5603                &actuator_route_path(prefix, "/jobs"),
5604                axum::routing::get(jobs_endpoint::<S>),
5605            )
5606            .route(
5607                &actuator_route_path(prefix, "/ui/tasks"),
5608                axum::routing::get(ui_tasks::<S>),
5609            )
5610            .route(
5611                &actuator_route_path(prefix, "/shadow"),
5612                axum::routing::get(shadow_endpoint::<S>),
5613            )
5614            .route(
5615                &actuator_route_path(prefix, "/cost"),
5616                axum::routing::get(cost_endpoint::<S>),
5617            );
5618        #[cfg(feature = "graph")]
5619        {
5620            router = router.route(
5621                &actuator_route_path(prefix, "/graph"),
5622                axum::routing::get(graph_endpoint::<S>),
5623            );
5624        }
5625        #[cfg(feature = "gdpr")]
5626        {
5627            router = router
5628                .route(
5629                    &actuator_route_path(prefix, "/capsules/export"),
5630                    axum::routing::post(capsules_export_endpoint::<S>),
5631                )
5632                .route(
5633                    &actuator_route_path(prefix, "/capsules/import"),
5634                    axum::routing::post(capsules_import_endpoint::<S>),
5635                )
5636                .route(
5637                    &actuator_route_path(prefix, "/capsules/verify"),
5638                    axum::routing::post(capsules_verify_endpoint::<S>),
5639                );
5640        }
5641        #[cfg(feature = "db")]
5642        {
5643            router = router.route(
5644                &actuator_route_path(prefix, "/derivations"),
5645                axum::routing::get(derivations_endpoint::<S>),
5646            );
5647        }
5648        #[cfg(feature = "http-client")]
5649        {
5650            router = router
5651                .route(
5652                    &actuator_route_path(prefix, "/webhooks/dlq"),
5653                    axum::routing::get(webhooks_dlq_endpoint::<S>),
5654                )
5655                .route(
5656                    &actuator_route_path(prefix, "/webhooks/replay"),
5657                    axum::routing::post(webhooks_replay_endpoint::<S>),
5658                );
5659        }
5660
5661        #[cfg(feature = "system-info")]
5662        {
5663            router = router.route(
5664                &actuator_route_path(prefix, "/system"),
5665                axum::routing::get(crate::system_info::system_info_handler),
5666            );
5667        }
5668
5669        #[cfg(feature = "ws")]
5670        {
5671            router = router
5672                .route(
5673                    &actuator_route_path(prefix, "/channels"),
5674                    axum::routing::get(channels_endpoint::<S>),
5675                )
5676                .route(
5677                    &actuator_route_path(prefix, "/tasks/stream"),
5678                    axum::routing::get(tasks_stream_endpoint::<S>)
5679                        .connect(tasks_stream_endpoint::<S>),
5680                );
5681        }
5682    }
5683
5684    // Nova: Add HTMX UI endpoints available unconditionally like metrics
5685    router
5686        .route(
5687            &actuator_route_path(prefix, "/ui"),
5688            axum::routing::get(ui_dashboard),
5689        )
5690        .route(
5691            &actuator_route_path(prefix, "/ui/metrics"),
5692            axum::routing::get(ui_metrics::<S>),
5693        )
5694}
5695
5696#[cfg(test)]
5697mod tests {
5698    use super::*;
5699    use crate::config::AutumnConfig;
5700
5701    #[test]
5702    fn task_registry_flow() {
5703        let registry = TaskRegistry::new();
5704
5705        registry.register_scheduled(
5706            "my_task",
5707            "0 * * * * *",
5708            crate::task::TaskCoordination::Fleet,
5709            "mock",
5710            "node-1",
5711        );
5712        let snap1 = registry.snapshot();
5713        assert_eq!(snap1.get("my_task").unwrap().total_runs, 0);
5714
5715        registry.record_leader("my_task", "node-1", "mock_tick");
5716        let snap3 = registry.snapshot();
5717        assert_eq!(
5718            snap3.get("my_task").unwrap().current_leader.as_deref(),
5719            Some("node-1")
5720        );
5721
5722        registry.record_start("my_task");
5723        let snap4 = registry.snapshot();
5724        assert_eq!(snap4.get("my_task").unwrap().status, "running");
5725
5726        registry.record_next_run_at("my_task", "tomorrow");
5727        let snap5 = registry.snapshot();
5728        assert_eq!(
5729            snap5.get("my_task").unwrap().next_run_at.as_deref(),
5730            Some("tomorrow")
5731        );
5732
5733        registry.record_success("my_task", 100);
5734        let snap6 = registry.snapshot();
5735        assert_eq!(snap6.get("my_task").unwrap().total_runs, 1);
5736        assert_eq!(snap6.get("my_task").unwrap().last_error, None);
5737
5738        registry.record_failure("my_task", 150, "error message");
5739        let snap7 = registry.snapshot();
5740        assert_eq!(snap7.get("my_task").unwrap().total_runs, 2);
5741        assert_eq!(snap7.get("my_task").unwrap().total_failures, 1);
5742        assert_eq!(
5743            snap7.get("my_task").unwrap().last_error.as_deref(),
5744            Some("error message")
5745        );
5746
5747        let registry2 = TaskRegistry::default();
5748        assert!(registry2.snapshot().is_empty());
5749    }
5750    #[test]
5751    fn job_registry_flow() {
5752        let registry = JobRegistry::new();
5753
5754        registry.register("my_job");
5755        let snap1 = registry.snapshot();
5756        assert_eq!(snap1.get("my_job").unwrap().queued, 0);
5757
5758        registry.record_enqueue("my_job");
5759        let snap2 = registry.snapshot();
5760        assert_eq!(snap2.get("my_job").unwrap().queued, 1);
5761
5762        registry.record_start("my_job");
5763        let snap3 = registry.snapshot();
5764        assert_eq!(snap3.get("my_job").unwrap().queued, 0);
5765        assert_eq!(snap3.get("my_job").unwrap().in_flight, 1);
5766
5767        registry.record_retry("my_job", "timeout", 1);
5768        let snap4 = registry.snapshot();
5769        assert_eq!(snap4.get("my_job").unwrap().in_flight, 0);
5770        assert_eq!(
5771            snap4.get("my_job").unwrap().last_error.as_deref(),
5772            Some("timeout")
5773        );
5774
5775        registry.record_enqueue("my_job");
5776        registry.record_start("my_job");
5777        registry.record_success("my_job");
5778        let snap5 = registry.snapshot();
5779        assert_eq!(snap5.get("my_job").unwrap().in_flight, 0);
5780        assert_eq!(snap5.get("my_job").unwrap().total_successes, 1);
5781        assert_eq!(snap5.get("my_job").unwrap().last_error, None);
5782
5783        registry.record_enqueue("my_job");
5784        registry.record_cancel("my_job");
5785        let snap6 = registry.snapshot();
5786        assert_eq!(snap6.get("my_job").unwrap().queued, 0);
5787        assert_eq!(snap6.get("my_job").unwrap().in_flight, 0);
5788
5789        registry.record_enqueue("my_job");
5790        registry.record_start("my_job");
5791        registry.record_failure("my_job", "failure".to_string(), true);
5792        let snap7 = registry.snapshot();
5793        assert_eq!(snap7.get("my_job").unwrap().in_flight, 0);
5794        assert_eq!(snap7.get("my_job").unwrap().total_failures, 1);
5795        assert_eq!(snap7.get("my_job").unwrap().dead_letters, 1);
5796        assert_eq!(
5797            snap7.get("my_job").unwrap().last_error.as_deref(),
5798            Some("failure")
5799        );
5800
5801        let registry2 = JobRegistry::default();
5802        let snap8 = registry2.snapshot();
5803        assert!(snap8.is_empty());
5804    }
5805
5806    #[test]
5807    fn job_registry_tracks_per_queue_depth_and_oldest_age() {
5808        let registry = JobRegistry::new();
5809        registry.register_on_queue("reset_email", "critical");
5810        registry.register_on_queue("reindex", "bulk");
5811
5812        // Enqueue two on `critical`, one on `bulk`.
5813        registry.record_enqueue("reset_email");
5814        registry.record_enqueue("reset_email");
5815        registry.record_enqueue("reindex");
5816
5817        let queues = registry.queue_snapshot();
5818        assert_eq!(queues.get("critical").unwrap().depth, 2);
5819        assert_eq!(queues.get("bulk").unwrap().depth, 1);
5820        // After a real (small) interval, the oldest waiting job's age is
5821        // strictly positive — the snapshot measures elapsed wait time.
5822        std::thread::sleep(std::time::Duration::from_millis(5));
5823        assert!(
5824            registry
5825                .queue_snapshot()
5826                .get("critical")
5827                .unwrap()
5828                .oldest_waiting_age_ms
5829                > 0,
5830            "a job waiting for ~5ms must report a positive age"
5831        );
5832
5833        // Starting a critical job drops the queue depth.
5834        registry.record_start("reset_email");
5835        assert_eq!(registry.queue_snapshot().get("critical").unwrap().depth, 1);
5836
5837        // Draining everything leaves depth 0 and age 0.
5838        registry.record_start("reset_email");
5839        registry.record_start("reindex");
5840        let drained = registry.queue_snapshot();
5841        assert_eq!(drained.get("critical").unwrap().depth, 0);
5842        assert_eq!(drained.get("critical").unwrap().oldest_waiting_age_ms, 0);
5843        assert_eq!(drained.get("bulk").unwrap().depth, 0);
5844    }
5845
5846    #[test]
5847    fn job_registry_unregistered_name_counts_on_the_default_queue() {
5848        // Never registered: the first enqueue inserts the name's status and
5849        // the `default` queue's marks; later ones hit both by borrow.
5850        let registry = JobRegistry::new();
5851        registry.record_enqueue("ghost");
5852        registry.record_enqueue("ghost");
5853        assert_eq!(registry.snapshot().get("ghost").unwrap().queued, 2);
5854        assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 2);
5855        assert_eq!(registry.waiting_marks_for_test("ghost").len(), 2);
5856        registry.record_start("ghost");
5857        assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 1);
5858        let status = registry.snapshot().get("ghost").cloned().unwrap();
5859        assert_eq!((status.queued, status.in_flight), (1, 1));
5860        // A first enqueue lands as exactly one queued job, all else empty.
5861        registry.record_enqueue("fresh");
5862        let fresh = registry.snapshot().get("fresh").cloned().unwrap();
5863        assert_eq!(fresh.queued, 1);
5864        assert_eq!(fresh.in_flight, 0);
5865        assert_eq!(fresh.total_successes, 0);
5866        assert!(fresh.last_error.is_none());
5867    }
5868
5869    #[test]
5870    fn job_registry_registered_queue_marks_stay_on_that_queue() {
5871        let registry = JobRegistry::new();
5872        registry.register_on_queue("send_email", "mail");
5873        // The queue's mark list exists from registration, so every enqueue is
5874        // a borrowed hit, and nothing leaks onto `default`.
5875        for _ in 0..3 {
5876            registry.record_enqueue("send_email");
5877        }
5878        let queues = registry.queue_snapshot();
5879        assert_eq!(queues.get("mail").unwrap().depth, 3);
5880        assert!(!queues.contains_key("default"));
5881        assert_eq!(registry.waiting_marks_for_test("send_email").len(), 3);
5882        registry.record_start("send_email");
5883        registry.record_start("send_email");
5884        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
5885        assert_eq!(registry.snapshot().get("send_email").unwrap().in_flight, 2);
5886    }
5887
5888    /// Clones share marks, so they must share the clock that judges them.
5889    ///
5890    /// `queues` is behind an `Arc`, so every clone of a registry sees one set of
5891    /// waiting marks. When the clock was a per-handle field, installing one on a
5892    /// handle cloned *after* the fact left the older clone reading those same
5893    /// marks against `SystemClock` — the two then reported different ready
5894    /// depths, and years of waiting age, for identical state.
5895    #[test]
5896    fn installing_a_clock_reaches_registry_clones() {
5897        use chrono::{TimeZone, Utc};
5898
5899        // Clone first, install second — the order that used to diverge.
5900        let registry = JobRegistry::new();
5901        let earlier_clone = registry.clone();
5902        registry.register_on_queue("probe", "default");
5903
5904        // Behind real time, like a sim epoch. That direction is what separates
5905        // the two handles: a deadline one virtual minute past a 2020 epoch is
5906        // still years behind a real-clock reading, so a stale clone calls it
5907        // ready while the injected one correctly calls it scheduled. An epoch
5908        // *ahead* of real time would have both answer "not yet" and prove
5909        // nothing.
5910        let epoch = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
5911        let registry = registry.with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5912
5913        // A job due a minute after the injected epoch: scheduled, not ready.
5914        let ready_at = u64::try_from(epoch.timestamp_millis()).unwrap() + 60_000;
5915        registry.record_enqueue_scheduled("probe", ready_at);
5916
5917        let through_new = registry.queue_snapshot();
5918        let through_old = earlier_clone.queue_snapshot();
5919        assert_eq!(
5920            through_new["default"].depth, 0,
5921            "the handle that installed the clock sees a scheduled job"
5922        );
5923        assert_eq!(
5924            through_old["default"].depth, through_new["default"].depth,
5925            "a clone taken before the clock was installed must judge the shared \
5926             marks identically; it read them against the real clock and saw a \
5927             ready job that cannot run"
5928        );
5929        assert_eq!(
5930            through_old["default"].oldest_waiting_age_ms,
5931            through_new["default"].oldest_waiting_age_ms,
5932            "and must report the same waiting age, not a decade of it"
5933        );
5934    }
5935
5936    /// A surveyed age must survive the round trip onto the registry's clock,
5937    /// however far that clock sits from real time.
5938    ///
5939    /// The Postgres survey computes the age in SQL (`NOW() - MIN(run_at)`, both
5940    /// operands on the database clock) and rebases it with `ready_at_from_age`.
5941    /// The registry then freshens it as `registry_now - ready_at`. Subtracting a
5942    /// raw database `MIN(run_at)` from the injected clock instead — what this
5943    /// branch briefly did — reports years of waiting age when the clock is
5944    /// pinned ahead of the database, or zero when it is behind.
5945    #[test]
5946    fn a_surveyed_age_survives_the_rebase_onto_the_registry_clock() {
5947        use chrono::{TimeZone, Utc};
5948
5949        // The database reports its oldest ready job has waited 5s.
5950        const DB_AGE_MS: u64 = 5_000;
5951
5952        // A clock pinned far from real time in each direction, so a leaked
5953        // real-vs-injected subtraction cannot coincidentally pass.
5954        for year in [1999, 2036] {
5955            let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
5956            let registry = JobRegistry::new()
5957                .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5958            registry.register_on_queue("probe", "default");
5959
5960            let registry_now = u64::try_from(epoch.timestamp_millis()).unwrap();
5961            let survey = HashMap::from([(
5962                "default".to_string(),
5963                (3u64, Some(ready_at_from_age(registry_now, DB_AGE_MS))),
5964            )]);
5965            registry.set_queue_depth_gauges(&survey);
5966
5967            let snapshot = registry.queue_snapshot();
5968            let status = &snapshot["default"];
5969            assert_eq!(status.depth, 3, "the survey's depth is authoritative");
5970            assert_eq!(
5971                status.oldest_waiting_age_ms, DB_AGE_MS,
5972                "the database-computed age must come back unchanged with the \
5973                 registry clock at {year}; got {}ms",
5974                status.oldest_waiting_age_ms
5975            );
5976        }
5977    }
5978
5979    /// When no exact mark was recorded for a job's id (the fallback path —
5980    /// e.g. it was never noted, or was evicted from
5981    /// [`JobRegistry::note_pg_job_mark`]'s bounded table), an admin-cancel
5982    /// must still find the correct waiting mark whichever of three
5983    /// timelines it lives on: an absolute `enqueue_at` mark (matched exactly
5984    /// by `run_at`'s own value), a relative-delay `enqueue_in` mark (stamped
5985    /// from real time via `due_origin()`, reconstructed from
5986    /// `real_reference_ms + offset_ms`), or an immediate-enqueue mark
5987    /// (stamped from this registry's own clock, reconstructed from
5988    /// `self.now_ms() + offset_ms`) — regardless of what this registry's own
5989    /// injected clock reads, since only the immediate case actually lives on
5990    /// it.
5991    ///
5992    /// Mirrors `ready_at_from_age`'s translate-onto-a-known-timeline
5993    /// approach, tried against all three candidates. A category decided on
5994    /// the backend's clock and matched against marks judged on the
5995    /// registry's own *separate* clock (`pop_waiting`) can disagree about a
5996    /// boundary; trying each mark's own plausible timeline directly cannot.
5997    #[test]
5998    fn cancel_at_backend_offset_falls_back_to_the_nearest_of_its_three_timelines() {
5999        use chrono::{TimeZone, Utc};
6000
6001        // An arbitrary "backend now" reference, unrelated to either pinned
6002        // registry clock below — standing in for a Postgres `clock_timestamp()`
6003        // reading, which a real cancel would supply.
6004        const BACKEND_NOW_MS: u64 = 1_800_000_000_000;
6005        // An arbitrary absolute due-at instant, far from every other value
6006        // used here, standing in for an `enqueue_at(some_instant)` mark.
6007        const ABSOLUTE_MARK_MS: u64 = 9_999_999_999_999;
6008
6009        for year in [1999, 2036] {
6010            let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
6011            let epoch_ms = u64::try_from(epoch.timestamp_millis()).unwrap();
6012            let registry = JobRegistry::new()
6013                .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
6014            registry.register_on_queue("immediate_job", "mail");
6015            registry.register_on_queue("relative_job", "mail");
6016            registry.register_on_queue("absolute_job", "mail");
6017
6018            // Three marks, three timelines — mirroring how the three enqueue
6019            // shapes actually stamp a Postgres-backed job's registry mark.
6020            // Pushed via the plain (non-`_pg_`) enqueue methods, so no exact
6021            // mark is noted under any id: every cancel below must resolve
6022            // through the candidate fallback, not the exact lookup. Asserted
6023            // via `waiting_marks_for_test` rather than `queue_snapshot`'s
6024            // depth: that method buckets marks against its own `now_ms()`
6025            // (the registry's pinned clock here), which — unrelated to the
6026            // fix under test — would itself misjudge a mark stamped on a
6027            // wildly different timeline as "not yet ready".
6028            registry.record_enqueue("immediate_job");
6029            registry.record_enqueue_scheduled("relative_job", BACKEND_NOW_MS + 3_600_000);
6030            registry.record_enqueue_scheduled("absolute_job", ABSOLUTE_MARK_MS);
6031            assert_eq!(
6032                registry.waiting_marks_for_test("immediate_job"),
6033                vec![epoch_ms, BACKEND_NOW_MS + 3_600_000, ABSOLUTE_MARK_MS],
6034                "all three marks pushed onto the shared queue"
6035            );
6036
6037            // Cancel the relative-delay job: offset ~+1h from BACKEND_NOW_MS
6038            // (real time). No `run_at` supplied (irrelevant to this branch).
6039            // "no-exact-mark" is an unregistered id, forcing the fallback.
6040            registry.record_cancel_at_backend_offset(
6041                "relative_job",
6042                "no-exact-mark",
6043                None,
6044                BACKEND_NOW_MS,
6045                3_600_000,
6046            );
6047            assert_eq!(
6048                registry.waiting_marks_for_test("immediate_job"),
6049                vec![epoch_ms, ABSOLUTE_MARK_MS],
6050                "canceling the relative-delay job (registry clock pinned to {year}) must \
6051                 remove its own real-time mark and leave the other two intact"
6052            );
6053
6054            // Cancel the immediate job: offset ~0, no `run_at`. The only
6055            // candidate that can match is `self.now_ms() + 0` — this
6056            // registry's own (pinned) clock.
6057            registry.record_cancel_at_backend_offset(
6058                "immediate_job",
6059                "no-exact-mark",
6060                None,
6061                BACKEND_NOW_MS,
6062                0,
6063            );
6064            assert_eq!(
6065                registry.waiting_marks_for_test("immediate_job"),
6066                vec![ABSOLUTE_MARK_MS],
6067                "canceling the immediate job (registry clock pinned to {year}) must remove \
6068                 its own registry-clock mark and leave the absolute mark intact"
6069            );
6070
6071            // Cancel the absolute job: `run_at` supplied verbatim, matching
6072            // ABSOLUTE_MARK_MS exactly regardless of the (irrelevant, and
6073            // here deliberately wrong) offset/real-reference pair.
6074            registry.record_cancel_at_backend_offset(
6075                "absolute_job",
6076                "no-exact-mark",
6077                Some(ABSOLUTE_MARK_MS),
6078                BACKEND_NOW_MS,
6079                0,
6080            );
6081            assert_eq!(
6082                registry.waiting_marks_for_test("immediate_job"),
6083                Vec::<u64>::new(),
6084                "canceling the absolute job (registry clock pinned to {year}) must remove \
6085                 its own mark via the exact run_at match and drain the queue to zero"
6086            );
6087        }
6088    }
6089
6090    /// A coincidental collision between two co-queued jobs' candidate
6091    /// targets — one job's true mark landing exactly on a *different* job's
6092    /// fallback candidate — must not make the wrong mark the "nearest" one.
6093    ///
6094    /// `record_pg_enqueue` remembers each job's own exact mark by id
6095    /// (`note_pg_job_mark`), and `record_cancel_at_backend_offset` looks that
6096    /// up before ever computing a candidate, so this can't happen for a job
6097    /// enqueued that way: the exact lookup is unambiguous by construction,
6098    /// unlike nearest-match over candidates that only approximate a mark's
6099    /// true timeline.
6100    #[test]
6101    fn cancel_at_backend_offset_prefers_the_exact_mark_over_a_coincidental_candidate_collision() {
6102        let registry = JobRegistry::new();
6103        registry.register_on_queue("relative_job", "mail");
6104        registry.register_on_queue("absolute_job", "mail");
6105
6106        // job-a's own real-time mark; job-b's own absolute mark, chosen so
6107        // that job-a's *fallback* candidate (computed below) would land
6108        // exactly on job-b's mark instead of job-a's own.
6109        registry.record_pg_enqueue(
6110            "relative_job",
6111            "job-a-id",
6112            Some(5_000),
6113            PgMarkTimeline::Real,
6114        );
6115        registry.record_pg_enqueue(
6116            "absolute_job",
6117            "job-b-id",
6118            Some(9_999),
6119            PgMarkTimeline::Real,
6120        );
6121        assert_eq!(
6122            registry.waiting_marks_for_test("relative_job"),
6123            vec![5_000, 9_999],
6124            "both marks pushed onto the shared queue"
6125        );
6126
6127        // Cancel job-a: its offset/real-reference pair is deliberately
6128        // chosen so the *fallback* candidate (9_999 + 0 = 9_999) collides
6129        // exactly with job-b's mark, not job-a's own (5_000). Without the
6130        // exact-by-id lookup, nearest-match would remove job-b's mark
6131        // instead (distance 0 beats 5_000's distance of 4_999).
6132        registry.record_cancel_at_backend_offset("relative_job", "job-a-id", None, 9_999, 0);
6133        assert_eq!(
6134            registry.waiting_marks_for_test("relative_job"),
6135            vec![9_999],
6136            "canceling job-a must remove its own exact mark (5_000), not job-b's mark that \
6137             its fallback candidate coincidentally collides with"
6138        );
6139    }
6140
6141    /// `record_pg_start` removes a job's entry once it actually starts (see
6142    /// below), but a job enqueued and never claimed at all — a crashed
6143    /// worker, a queue with no consumer — never reaches that call, so
6144    /// without a cap `note_pg_job_mark` would grow by one entry per such
6145    /// enqueue for the lifetime of the process.
6146    #[test]
6147    fn note_pg_job_mark_is_bounded_so_uncancelled_jobs_cannot_leak_memory_forever() {
6148        let registry = JobRegistry::new();
6149        registry.register_on_queue("never_canceled", "mail");
6150        for i in 0..(PG_MARKS_BY_JOB_ID_CAP + 5) {
6151            registry.record_pg_enqueue(
6152                "never_canceled",
6153                &format!("job-{i}"),
6154                Some(u64::try_from(i).unwrap()),
6155                PgMarkTimeline::Real,
6156            );
6157        }
6158        assert_eq!(
6159            registry.pg_marks_len_for_test(),
6160            PG_MARKS_BY_JOB_ID_CAP,
6161            "the exact-mark table must never grow past its cap, even when every job it \
6162             tracks is enqueued and never claimed at all"
6163        );
6164    }
6165
6166    /// Regression for the Codex P2 raised on commit 763da93: in a split
6167    /// web/worker deployment, a web replica that only enqueues never calls
6168    /// `record_pg_start` (it runs no worker loop at all), so every entry it
6169    /// inserts sits here until capacity eviction regardless of whether some
6170    /// other replica has long since claimed and finished the job. Plain
6171    /// oldest-inserted-first eviction could just as easily pick an older job
6172    /// that is still genuinely delayed — and therefore, by construction
6173    /// (Postgres never hands out a row before its own `run_at`), definitely
6174    /// NOT yet claimed by anyone, anywhere — over one that is already due
6175    /// and plausibly claimed elsewhere already.
6176    #[test]
6177    fn note_pg_job_mark_eviction_protects_entries_still_in_the_future() {
6178        let registry = JobRegistry::new();
6179        registry.register_on_queue("mixed_cap", "mail");
6180
6181        let now = now_epoch_ms();
6182        let far_future = now + 3_600_000; // an hour out: cannot possibly be claimed yet.
6183
6184        // Inserted first, so plain FIFO eviction would pick it first — but
6185        // it is still genuinely delayed.
6186        registry.record_pg_enqueue(
6187            "mixed_cap",
6188            "still-delayed",
6189            Some(far_future),
6190            PgMarkTimeline::Real,
6191        );
6192        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6193            registry.record_pg_enqueue(
6194                "mixed_cap",
6195                &format!("already-due-{i}"),
6196                Some(0),
6197                PgMarkTimeline::Real,
6198            );
6199        }
6200        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6201
6202        // One more enqueue forces an eviction.
6203        registry.record_pg_enqueue("mixed_cap", "one-more", Some(0), PgMarkTimeline::Real);
6204
6205        assert_eq!(
6206            registry.pg_marks_len_for_test(),
6207            PG_MARKS_BY_JOB_ID_CAP,
6208            "the cap must still hold"
6209        );
6210        assert!(
6211            registry.pg_mark_contains_id_for_test("still-delayed"),
6212            "an entry still in the future must never be evicted while an already-due \
6213             entry is available to evict instead"
6214        );
6215        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6216    }
6217
6218    /// Regression for the Codex P2 raised on commit a460209: comparing every
6219    /// mark against a single clock misjudges the other kind whenever the
6220    /// registry's own (possibly injected/virtual) clock and real time
6221    /// disagree. Pins the registry's clock far in the future — as an app
6222    /// injecting a `FixedClock`/`TickingClock` ahead of wall time would —
6223    /// and confirms a genuinely-still-delayed real-time mark is never
6224    /// mistaken for "due" just because it looks small next to the
6225    /// registry's far-future reading. (Marks are now tagged with the one
6226    /// clock that actually measures them — see [`PgMarkTimeline`] — rather
6227    /// than judged against both; this still exercises the same scenario.)
6228    #[test]
6229    fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_against_real_time_only() {
6230        use chrono::{TimeZone, Utc};
6231
6232        let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6233            crate::time::FixedClock::at(Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap()),
6234        ));
6235        registry.register_on_queue("skewed", "mail");
6236
6237        let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6238        // 5 real-world minutes out — genuinely not due yet — but a "small"
6239        // number next to the registry's year-2100 reading, which is exactly
6240        // what made the single-clock check call it due in error.
6241        let genuinely_delayed = real_now + 300_000;
6242
6243        // Inserted first, so plain FIFO (and the single-clock check) would
6244        // pick it first.
6245        registry.record_pg_enqueue(
6246            "skewed",
6247            "still-delayed",
6248            Some(genuinely_delayed),
6249            PgMarkTimeline::Real,
6250        );
6251        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6252            // Genuinely due under both clocks (epoch 0 is in the past on any
6253            // clock), so these are always legitimate eviction candidates.
6254            registry.record_pg_enqueue(
6255                "skewed",
6256                &format!("filler-{i}"),
6257                Some(0),
6258                PgMarkTimeline::Real,
6259            );
6260        }
6261        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6262
6263        // One more enqueue forces an eviction.
6264        registry.record_pg_enqueue("skewed", "one-more", Some(0), PgMarkTimeline::Real);
6265
6266        assert_eq!(
6267            registry.pg_marks_len_for_test(),
6268            PG_MARKS_BY_JOB_ID_CAP,
6269            "the cap must still hold"
6270        );
6271        assert!(
6272            registry.pg_mark_contains_id_for_test("still-delayed"),
6273            "a mark that is not yet due in real time must never be evicted just because \
6274             the registry's own (far-future) clock alone would call it due"
6275        );
6276        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6277    }
6278
6279    /// Regression for the Codex P2 raised on commit d3e86d6: requiring both
6280    /// clocks to agree an entry is due protects a still-delayed entry from
6281    /// being evicted, but it can equally blind eviction to every genuinely
6282    /// stale entry when the registry clock has drifted *behind* real time
6283    /// (the mirror image of the far-future case above) — a real-timeline
6284    /// mark (large real epoch ms) then never compares as `<=` a small,
6285    /// stuck-in-the-past registry clock reading, however far in the real
6286    /// past it actually is. Pins the registry's clock far in the past and
6287    /// confirms a pile of already-claimed real-time marks are still
6288    /// eviction candidates, protecting a genuinely-future one inserted
6289    /// first — each entry is now judged only against the one clock
6290    /// ([`PgMarkTimeline`]) that actually measures it.
6291    #[test]
6292    fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_even_when_the_registry_clock_lags() {
6293        use chrono::{TimeZone, Utc};
6294
6295        let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6296            crate::time::FixedClock::at(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap()),
6297        ));
6298        registry.register_on_queue("lagging", "mail");
6299
6300        let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6301        // An hour out in real time — genuinely not due yet — inserted
6302        // first, so plain FIFO would evict it first.
6303        let far_future = real_now + 3_600_000;
6304
6305        registry.record_pg_enqueue(
6306            "lagging",
6307            "still-delayed",
6308            Some(far_future),
6309            PgMarkTimeline::Real,
6310        );
6311        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6312            // Real-timeline marks already due in real time (epoch 0), but a
6313            // registry clock stuck at year 2000 would never call these
6314            // "due" if compared against it too.
6315            registry.record_pg_enqueue(
6316                "lagging",
6317                &format!("filler-{i}"),
6318                Some(0),
6319                PgMarkTimeline::Real,
6320            );
6321        }
6322        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6323
6324        // One more enqueue forces an eviction.
6325        registry.record_pg_enqueue("lagging", "one-more", Some(0), PgMarkTimeline::Real);
6326
6327        assert_eq!(
6328            registry.pg_marks_len_for_test(),
6329            PG_MARKS_BY_JOB_ID_CAP,
6330            "the cap must still hold"
6331        );
6332        assert!(
6333            registry.pg_mark_contains_id_for_test("still-delayed"),
6334            "a genuinely-future real-timeline entry must never be evicted while an \
6335             already-due real-timeline entry is available, even when the registry's own \
6336             clock lags behind real time and would call every real-timeline mark 'not \
6337             due' if it were consulted for them too"
6338        );
6339        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6340    }
6341
6342    /// Regression for the Codex P2 raised on commit c0cbfd3: before
6343    /// `record_pg_start` existed, an entry stuck around until evicted by
6344    /// [`PG_MARKS_BY_JOB_ID_CAP`] regardless of whether its job had long
6345    /// since started and completed, so a busy queue's finished jobs could
6346    /// push out a still-genuinely-queued long-delay job's entry well before
6347    /// the cap's raw count would suggest — degrading that cancel to the
6348    /// (weaker) candidate-nearest-match fallback. `record_pg_start` removing
6349    /// the entry the instant its mark is consumed means the table tracks
6350    /// live queue residency instead of lifetime enqueue count: churning far
6351    /// more jobs through start than the cap must never make the table grow
6352    /// at all, since none of them are still waiting.
6353    #[test]
6354    fn record_pg_start_keeps_the_exact_mark_table_bounded_by_live_residency_not_lifetime_enqueues()
6355    {
6356        let registry = JobRegistry::new();
6357        registry.register_on_queue("churn", "mail");
6358        for i in 0..(PG_MARKS_BY_JOB_ID_CAP * 3) {
6359            let id = format!("job-{i}");
6360            registry.record_pg_enqueue(
6361                "churn",
6362                &id,
6363                Some(u64::try_from(i).unwrap()),
6364                PgMarkTimeline::Real,
6365            );
6366            registry.record_pg_start("churn", &id);
6367        }
6368        assert_eq!(
6369            registry.pg_marks_len_for_test(),
6370            0,
6371            "a job whose mark was consumed by record_pg_start must not linger in the \
6372             exact-mark table just because the cap has not been reached"
6373        );
6374        assert!(
6375            registry.pg_marks_seq_order_is_consistent_for_test(),
6376            "pg_marks_seq_order (the O(1)-eviction seq index) must mirror \
6377             pg_marks_by_job_id exactly after a long run of inserts and removals, \
6378             not just at a single snapshot"
6379        );
6380    }
6381
6382    /// Regression for the Codex P2 raised on commit 034e47a:
6383    /// `record_start`'s generic `pop_waiting` removes whichever ready mark
6384    /// comes first in the queue's internal order, not necessarily the mark
6385    /// belonging to the job actually starting — Postgres can start a
6386    /// later-enqueued job ahead of an older one this process still has
6387    /// concurrency-blocked. The first cut of `record_pg_start` popped
6388    /// generically and then unconditionally forgot the starting job's exact
6389    /// entry regardless, which could tear down its mapping while its real
6390    /// mark stayed in the queue and a different, still-waiting job's mark
6391    /// vanished in its place.
6392    #[test]
6393    fn record_pg_start_removes_its_own_mark_even_when_an_older_job_is_still_first_in_queue() {
6394        const OLDER_MARK: u64 = 1_000;
6395        const NEWER_MARK: u64 = 2_000;
6396
6397        let registry = JobRegistry::new();
6398        registry.register_on_queue("mixed", "work");
6399
6400        // job-a enqueued first (older, still queued/concurrency-blocked);
6401        // job-b enqueued second but starts first.
6402        registry.record_pg_enqueue("mixed", "job-a", Some(OLDER_MARK), PgMarkTimeline::Real);
6403        registry.record_pg_enqueue("mixed", "job-b", Some(NEWER_MARK), PgMarkTimeline::Real);
6404
6405        registry.record_pg_start("mixed", "job-b");
6406
6407        assert_eq!(
6408            registry.waiting_marks_for_test("mixed"),
6409            vec![OLDER_MARK],
6410            "starting job-b must remove its own mark, leaving job-a's still-queued \
6411             mark untouched even though job-a's mark sits first in the queue"
6412        );
6413        assert_eq!(
6414            registry.pg_marks_len_for_test(),
6415            1,
6416            "job-a's exact entry must survive; only job-b's own entry is removed"
6417        );
6418    }
6419
6420    /// A stale `pg_marks_by_job_id` entry must not swallow the cancel: if the
6421    /// exact-looked-up value is no longer in the queue, the cancel has to
6422    /// fall back to the candidate heuristic, not silently remove nothing.
6423    ///
6424    /// Simulates staleness via the plain `record_start` (which knows nothing
6425    /// about `pg_marks_by_job_id`) rather than `record_pg_start` (which keeps
6426    /// the entry in sync): a non-terminal retry then pushes a *new* mark
6427    /// under the same job name without updating that entry either — so a
6428    /// cancel racing a retry finds a value (`pop_waiting_exact`) that is no
6429    /// longer in the queue at all.
6430    #[test]
6431    fn cancel_at_backend_offset_falls_back_when_the_exact_mark_is_stale() {
6432        const MARK_A: u64 = 1_000;
6433        const MARK_B: u64 = 2_000;
6434
6435        let registry = JobRegistry::new();
6436        registry.register_on_queue("flaky_job", "mail");
6437
6438        // Original enqueue: pushes mark_a and notes it under "job-id".
6439        registry.record_pg_enqueue("flaky_job", "job-id", Some(MARK_A), PgMarkTimeline::Real);
6440
6441        // The job starts: use plain `record_start` (not `record_pg_start`)
6442        // to pop mark_a from the queue while deliberately leaving "job-id"'s
6443        // exact entry in place, simulating a caller that pushed a waiting
6444        // mark outside the `record_pg_start`/`record_pg_enqueue` pair — the
6445        // one way an entry can still go stale — so this test exercises the
6446        // fallback in isolation from that pair's own bookkeeping.
6447        registry.record_start("flaky_job");
6448        assert_eq!(
6449            registry.waiting_marks_for_test("flaky_job"),
6450            Vec::<u64>::new(),
6451            "record_start must have popped mark_a"
6452        );
6453
6454        // The job fails non-terminally and is requeued as a retry: a new
6455        // mark is pushed under the same job name via the plain
6456        // `record_enqueue_scheduled` primitive, without updating "job-id"'s
6457        // exact entry — exercising the fallback directly, for whichever
6458        // caller pushes a retry mark this way rather than through
6459        // `record_pg_enqueue` (see `pg_retry_refreshes_the_exact_mark_so_a_racing_cancel_does_not_hit_a_coincidental_collision`
6460        // in job.rs for the production retry-lifecycle path, which now does).
6461        registry.record_enqueue_scheduled("flaky_job", MARK_B);
6462
6463        // Cancel "job-id": the exact lookup finds the stale mark_a, which
6464        // `pop_waiting_exact` cannot find in the queue (only mark_b is
6465        // there) — the fallback candidate (real_reference_ms + offset_ms)
6466        // is set up to land exactly on mark_b.
6467        registry.record_cancel_at_backend_offset("flaky_job", "job-id", None, MARK_B, 0);
6468        assert_eq!(
6469            registry.waiting_marks_for_test("flaky_job"),
6470            Vec::<u64>::new(),
6471            "the stale exact entry must not stop the cancel from removing the job's real, \
6472             current mark (mark_b) via the candidate fallback"
6473        );
6474    }
6475
6476    #[test]
6477    fn survey_setter_overwrites_queue_gauges_and_resets_absent_queues() {
6478        let registry = JobRegistry::new();
6479        registry.register_on_queue("reset_email", "critical");
6480        registry.register_on_queue("reindex", "bulk");
6481
6482        // Local marks exist, but once a survey is published it is authoritative:
6483        // the durable backend, not this process's enqueue marks, drives the
6484        // reported depth/age (issue #1752).
6485        registry.record_enqueue("reset_email");
6486
6487        let now = now_epoch_ms();
6488        let mut survey = HashMap::new();
6489        // `critical` has 4 ready jobs; oldest became ready 5s ago.
6490        survey.insert(
6491            "critical".to_string(),
6492            (4_u64, Some(now.saturating_sub(5_000))),
6493        );
6494        // `bulk` is empty in the survey (absent oldest → age 0).
6495        survey.insert("bulk".to_string(), (0_u64, None));
6496        registry.set_queue_depth_gauges(&survey);
6497
6498        let snap = registry.queue_snapshot();
6499        assert_eq!(
6500            snap.get("critical").unwrap().depth,
6501            4,
6502            "survey depth overrides the local enqueue mark"
6503        );
6504        let age = snap.get("critical").unwrap().oldest_waiting_age_ms;
6505        assert!(
6506            (5_000..=6_000).contains(&age),
6507            "age is derived from the surveyed oldest ready-at timestamp, got {age}"
6508        );
6509        assert_eq!(snap.get("bulk").unwrap().depth, 0);
6510        assert_eq!(snap.get("bulk").unwrap().oldest_waiting_age_ms, 0);
6511
6512        // A later survey that omits `critical` resets it to 0 (no leak), even
6513        // though a known/registered queue keeps appearing in the snapshot.
6514        let mut survey2 = HashMap::new();
6515        survey2.insert("bulk".to_string(), (2_u64, Some(now)));
6516        registry.set_queue_depth_gauges(&survey2);
6517        let snap2 = registry.queue_snapshot();
6518        assert_eq!(
6519            snap2.get("critical").unwrap().depth,
6520            0,
6521            "a queue absent from the newest survey resets to 0"
6522        );
6523        assert_eq!(snap2.get("bulk").unwrap().depth, 2);
6524    }
6525
6526    #[test]
6527    fn survey_setter_overwrites_per_job_queued_and_resets_absent_names() {
6528        let registry = JobRegistry::new();
6529        registry.register("reset_email");
6530        registry.register("reindex");
6531
6532        registry.record_enqueue("reset_email");
6533        registry.record_enqueue("reset_email");
6534
6535        let mut counts = HashMap::new();
6536        counts.insert("reset_email".to_string(), 7_u64);
6537        registry.set_queued_counts(&counts);
6538        assert_eq!(
6539            registry.snapshot()["reset_email"].queued,
6540            7,
6541            "survey overwrites the local enqueue-driven queued count"
6542        );
6543        assert_eq!(
6544            registry.snapshot()["reindex"].queued,
6545            0,
6546            "a name absent from the survey resets to 0"
6547        );
6548
6549        registry.set_queued_counts(&HashMap::new());
6550        assert_eq!(registry.snapshot()["reset_email"].queued, 0);
6551    }
6552
6553    #[test]
6554    fn future_scheduled_jobs_do_not_inflate_ready_queue_depth() {
6555        let registry = JobRegistry::new();
6556        registry.register_on_queue("nightly_report", "reports");
6557        registry.register_on_queue("send_email", "reports");
6558
6559        // A job scheduled for the future is not yet claimable, so it must not
6560        // count toward ready queue depth or age the queue: future-dated jobs
6561        // enqueued via enqueue_in/enqueue_at were reporting phantom backlog and
6562        // could trip false autoscaling/alerting on /actuator/jobs.
6563        let far_future = now_epoch_ms() + 60_000;
6564        registry.record_enqueue_scheduled("nightly_report", far_future);
6565
6566        let scheduled_only = registry.queue_snapshot();
6567        let reports = scheduled_only
6568            .get("reports")
6569            .expect("reports queue tracked");
6570        assert_eq!(
6571            reports.depth, 0,
6572            "a future-scheduled job is not ready backlog"
6573        );
6574        assert_eq!(
6575            reports.oldest_waiting_age_ms, 0,
6576            "a future-scheduled job must not age the ready queue"
6577        );
6578
6579        // A due-now enqueue on the same queue still counts immediately.
6580        registry.record_enqueue("send_email");
6581        assert_eq!(
6582            registry.queue_snapshot().get("reports").unwrap().depth,
6583            1,
6584            "an immediately-runnable job still counts toward ready depth"
6585        );
6586
6587        // Once a scheduled job's ready time has passed it joins ready depth and
6588        // contributes to oldest-waiting age.
6589        let already_ready = now_epoch_ms().saturating_sub(5);
6590        registry.record_enqueue_scheduled("nightly_report", already_ready);
6591        let promoted = registry.queue_snapshot();
6592        assert_eq!(
6593            promoted.get("reports").unwrap().depth,
6594            2,
6595            "a scheduled job counts once its ready time has passed"
6596        );
6597        assert!(
6598            promoted.get("reports").unwrap().oldest_waiting_age_ms > 0,
6599            "a job whose ready time has passed contributes to oldest-waiting age"
6600        );
6601
6602        // Starting the two ready jobs leaves only the still-future scheduled
6603        // mark, which reports as no ready backlog.
6604        registry.record_start("send_email");
6605        registry.record_start("nightly_report");
6606        let drained = registry.queue_snapshot();
6607        assert_eq!(
6608            drained.get("reports").unwrap().depth,
6609            0,
6610            "with both ready jobs started only the future mark remains, counting as 0"
6611        );
6612        assert_eq!(
6613            drained.get("reports").unwrap().oldest_waiting_age_ms,
6614            0,
6615            "a lone future-scheduled mark ages nothing"
6616        );
6617    }
6618
6619    #[test]
6620    fn canceling_a_scheduled_job_preserves_a_coqueued_ready_mark() {
6621        // Reproduces the durable admin-cancel gap: when an operator cancels a
6622        // still-scheduled (delayed) job that shares a queue with a ready job,
6623        // the cancel must remove the *scheduled* waiting mark, not the ready
6624        // one. Popping the ready mark (via the ready removal path) would report
6625        // the queue depth one too low while the ready job is still waiting.
6626        let registry = JobRegistry::new();
6627        registry.register_on_queue("nightly_report", "reports");
6628        registry.register_on_queue("send_email", "reports");
6629
6630        // One ready job and one future-scheduled job share the queue; only the
6631        // ready job counts toward ready depth.
6632        registry.record_enqueue("send_email");
6633        let far_future = now_epoch_ms() + 60_000;
6634        registry.record_enqueue_scheduled("nightly_report", far_future);
6635        assert_eq!(
6636            registry.queue_snapshot().get("reports").unwrap().depth,
6637            1,
6638            "only the ready job counts toward ready depth"
6639        );
6640
6641        // Cancel the scheduled job (the durable backend signals this because it
6642        // removed the job from its delayed set). The scheduled removal path must
6643        // consume the future mark and leave the ready job's mark intact.
6644        registry.record_cancel_scheduled("nightly_report");
6645        assert_eq!(
6646            registry.queue_snapshot().get("reports").unwrap().depth,
6647            1,
6648            "canceling the scheduled job must not steal the co-queued ready job's mark"
6649        );
6650
6651        // The surviving ready job still drains to zero — exactly one mark left,
6652        // so no scheduled mark leaked.
6653        registry.record_start("send_email");
6654        assert_eq!(
6655            registry.queue_snapshot().get("reports").unwrap().depth,
6656            0,
6657            "starting the ready job drains the queue; the scheduled mark was the one removed"
6658        );
6659    }
6660
6661    #[test]
6662    fn canceling_a_ready_job_removes_a_ready_mark() {
6663        // No-regression companion: canceling a ready (immediately-runnable) job
6664        // removes a ready mark, so the queue depth drops by exactly one.
6665        let registry = JobRegistry::new();
6666        registry.register_on_queue("send_email", "mail");
6667
6668        registry.record_enqueue("send_email");
6669        registry.record_enqueue("send_email");
6670        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6671
6672        registry.record_cancel("send_email");
6673        assert_eq!(
6674            registry.queue_snapshot().get("mail").unwrap().depth,
6675            1,
6676            "canceling a ready job removes exactly one ready mark"
6677        );
6678
6679        registry.record_start("send_email");
6680        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6681    }
6682
6683    #[test]
6684    fn retry_dedup_without_enqueue_mark_keeps_real_duplicate_waiting() {
6685        let registry = JobRegistry::new();
6686        registry.register_on_queue("send_email", "mail");
6687
6688        // A real duplicate is enqueued and waiting: its per-queue mark is present.
6689        registry.record_enqueue("send_email");
6690        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
6691
6692        // A retry that failed and coalesced into that duplicate never re-recorded
6693        // an enqueue mark, so its dedup must NOT pop the real duplicate's waiting
6694        // mark (doing so would report depth 0 while work is still waiting).
6695        registry.record_deduplicated("send_email", false, false);
6696        assert_eq!(
6697            registry.queue_snapshot().get("mail").unwrap().depth,
6698            1,
6699            "retry-dedup with no prior enqueue mark must not steal a waiting duplicate's mark"
6700        );
6701
6702        // A normal enqueue→dedup pair (the coalesced job DID record an enqueue
6703        // mark) still nets to zero: its own mark is removed, no leak.
6704        registry.record_enqueue("send_email");
6705        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6706        registry.record_deduplicated("send_email", true, false);
6707        assert_eq!(
6708            registry.queue_snapshot().get("mail").unwrap().depth,
6709            1,
6710            "a normal coalesced enqueue removes exactly its own mark (no leak)"
6711        );
6712
6713        // The surviving real duplicate still drains normally.
6714        registry.record_start("send_email");
6715        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6716    }
6717
6718    #[test]
6719    fn deduplicating_a_scheduled_duplicate_preserves_a_coqueued_ready_mark() {
6720        // Reproduces the dedup category gap (sibling of the #965 cancel fix):
6721        // when a delayed (scheduled) duplicate coalesces on a queue that also
6722        // holds a ready job, the dedup must remove the *scheduled* waiting mark,
6723        // not the ready one. The failing order is "delayed duplicate enqueued
6724        // first, ready job second": the old unconditional `pop_back` removed the
6725        // most-recent (ready) mark, reporting ready depth 0 while ready work was
6726        // still waiting.
6727        let registry = JobRegistry::new();
6728        registry.register_on_queue("nightly_report", "reports");
6729        registry.register_on_queue("send_email", "reports");
6730
6731        // The delayed duplicate records a future (scheduled) mark FIRST...
6732        let far_future = now_epoch_ms() + 60_000;
6733        registry.record_enqueue_scheduled("nightly_report", far_future);
6734        // ...then a ready job enqueues on the same queue (its mark is the most
6735        // recent). Recorded a few ms in the past so it deterministically ages.
6736        let already_ready = now_epoch_ms().saturating_sub(5);
6737        registry.record_enqueue_scheduled("send_email", already_ready);
6738
6739        let before = registry.queue_snapshot();
6740        assert_eq!(
6741            before.get("reports").unwrap().depth,
6742            1,
6743            "only the ready job counts toward ready depth"
6744        );
6745        assert!(
6746            before.get("reports").unwrap().oldest_waiting_age_ms > 0,
6747            "the ready job ages the queue before the dedup"
6748        );
6749
6750        // The delayed duplicate coalesces: it recorded a scheduled enqueue mark
6751        // (had_enqueue_mark = true, was_scheduled = true), so the removal must
6752        // consume the future mark and leave the ready job's mark intact. Under
6753        // the old `pop_back` this stole the ready mark (depth 0) — the RED case.
6754        registry.record_deduplicated("nightly_report", true, true);
6755        let after = registry.queue_snapshot();
6756        assert_eq!(
6757            after.get("reports").unwrap().depth,
6758            1,
6759            "deduplicating the scheduled duplicate must not steal the ready job's mark"
6760        );
6761        assert!(
6762            after.get("reports").unwrap().oldest_waiting_age_ms > 0,
6763            "the ready job's mark is preserved, so it still ages the queue"
6764        );
6765
6766        // The surviving ready job drains to zero — exactly one mark remained, so
6767        // the scheduled mark was the one removed (no leak).
6768        registry.record_start("send_email");
6769        assert_eq!(
6770            registry.queue_snapshot().get("reports").unwrap().depth,
6771            0,
6772            "starting the ready job drains the queue; the scheduled mark was removed"
6773        );
6774    }
6775
6776    use axum::body::Body;
6777    use axum::http::Request;
6778    use tower::ServiceExt;
6779
6780    #[derive(Clone)]
6781    struct TestActuatorState {
6782        profile: String,
6783        deploy_version: String,
6784        metrics: crate::middleware::MetricsCollector,
6785        log_levels: LogLevels,
6786        task_registry: TaskRegistry,
6787        job_registry: JobRegistry,
6788        config_props: ConfigProperties,
6789        metrics_source_registry: MetricsSourceRegistry,
6790        health_indicator_registry: HealthIndicatorRegistry,
6791        health_detailed: bool,
6792        log_buffer: Option<crate::log::capture::LogBuffer>,
6793        #[cfg(feature = "http-client")]
6794        webhook_outbound: Option<crate::webhook_outbound::WebhookOutboundManager>,
6795        #[cfg(feature = "db")]
6796        pool: Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6797        #[cfg(feature = "db")]
6798        shards: Option<crate::sharding::ShardSet>,
6799        #[cfg(feature = "db")]
6800        replica_pool:
6801            Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6802        #[cfg(feature = "ws")]
6803        channels: crate::channels::Channels,
6804        #[cfg(feature = "ws")]
6805        shutdown: tokio_util::sync::CancellationToken,
6806        data_capsules: Option<crate::gdpr::portability::CapsuleService>,
6807    }
6808
6809    impl ProvideActuatorState for TestActuatorState {
6810        fn metrics(&self) -> &crate::middleware::MetricsCollector {
6811            &self.metrics
6812        }
6813        fn log_levels(&self) -> &LogLevels {
6814            &self.log_levels
6815        }
6816        fn task_registry(&self) -> &TaskRegistry {
6817            &self.task_registry
6818        }
6819        fn job_registry(&self) -> &JobRegistry {
6820            &self.job_registry
6821        }
6822        fn config_props(&self) -> &ConfigProperties {
6823            &self.config_props
6824        }
6825        fn profile(&self) -> &str {
6826            &self.profile
6827        }
6828        fn uptime_display(&self) -> String {
6829            "test_uptime".to_string()
6830        }
6831        fn deploy_version(&self) -> String {
6832            self.deploy_version.clone()
6833        }
6834        fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
6835            Some(&self.metrics_source_registry)
6836        }
6837        #[cfg(feature = "http-client")]
6838        fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
6839            self.webhook_outbound.clone()
6840        }
6841        #[cfg(feature = "db")]
6842        fn pool(
6843            &self,
6844        ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6845        {
6846            self.pool.as_ref()
6847        }
6848        #[cfg(feature = "db")]
6849        fn shards(&self) -> Option<&crate::sharding::ShardSet> {
6850            self.shards.as_ref()
6851        }
6852        #[cfg(feature = "db")]
6853        fn replica_pool(
6854            &self,
6855        ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6856        {
6857            self.replica_pool.as_ref()
6858        }
6859        #[cfg(feature = "ws")]
6860        fn channels(&self) -> &crate::channels::Channels {
6861            &self.channels
6862        }
6863        #[cfg(feature = "ws")]
6864        fn shutdown_token(&self) -> tokio_util::sync::CancellationToken {
6865            self.shutdown.clone()
6866        }
6867        fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
6868            Some(&self.health_indicator_registry)
6869        }
6870        fn health_detailed(&self) -> bool {
6871            self.health_detailed
6872        }
6873        fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
6874            self.log_buffer.clone()
6875        }
6876        fn data_capsules(
6877            &self,
6878        ) -> Result<
6879            crate::gdpr::portability::CapsuleService,
6880            crate::gdpr::portability::DataCapsuleError,
6881        > {
6882            self.data_capsules.clone().ok_or_else(|| {
6883                crate::gdpr::portability::DataCapsuleError::NotConfigured("test".to_owned())
6884            })
6885        }
6886    }
6887
6888    fn test_state() -> TestActuatorState {
6889        test_state_with_config(&AutumnConfig::default())
6890    }
6891
6892    fn test_state_with_config(config: &AutumnConfig) -> TestActuatorState {
6893        TestActuatorState {
6894            profile: config.profile.clone().unwrap_or_else(|| "dev".into()),
6895            deploy_version: crate::canary::STABLE.to_owned(),
6896            metrics: crate::middleware::MetricsCollector::new(),
6897            log_levels: LogLevels::new("info"),
6898            task_registry: TaskRegistry::new(),
6899            job_registry: JobRegistry::new(),
6900            config_props: ConfigProperties::from_config(config),
6901            metrics_source_registry: MetricsSourceRegistry::new(),
6902            health_indicator_registry: HealthIndicatorRegistry::new(),
6903            health_detailed: config.health.detailed,
6904            log_buffer: None,
6905            #[cfg(feature = "http-client")]
6906            webhook_outbound: None,
6907            #[cfg(feature = "db")]
6908            pool: None,
6909            #[cfg(feature = "db")]
6910            shards: None,
6911            #[cfg(feature = "db")]
6912            replica_pool: None,
6913            #[cfg(feature = "ws")]
6914            channels: crate::channels::Channels::new(32),
6915            #[cfg(feature = "ws")]
6916            shutdown: tokio_util::sync::CancellationToken::new(),
6917            data_capsules: None,
6918        }
6919    }
6920
6921    #[cfg(feature = "http-client")]
6922    fn test_state_with_webhook_outbound(
6923        manager: crate::webhook_outbound::WebhookOutboundManager,
6924    ) -> TestActuatorState {
6925        let mut state = test_state();
6926        state.webhook_outbound = Some(manager);
6927        state
6928    }
6929
6930    #[cfg(feature = "http-client")]
6931    fn replay_test_subscription() -> crate::webhook_outbound::WebhookSubscription {
6932        crate::webhook_outbound::WebhookSubscription {
6933            id: "sub-replay".to_string(),
6934            target_url: "https://example.test/webhook".to_string(),
6935            event_topics: vec!["order.created".to_string()],
6936            secret: "secret".to_string(),
6937            status: crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
6938            consecutive_failures: 50,
6939        }
6940    }
6941
6942    #[cfg(feature = "http-client")]
6943    fn replay_test_dlq_log() -> crate::webhook_outbound::WebhookDeliveryLog {
6944        crate::webhook_outbound::WebhookDeliveryLog {
6945            id: "log-replay".to_string(),
6946            subscription_id: "sub-replay".to_string(),
6947            topic: "order.created".to_string(),
6948            payload: "{\"id\":123}".to_string(),
6949            request_headers: std::collections::HashMap::new(),
6950            response_status: Some(503),
6951            response_body: Some("unavailable".to_string()),
6952            elapsed_ms: 42,
6953            attempt: 5,
6954            max_attempts: 5,
6955            is_dlq: true,
6956            last_error: Some("server returned status: 503".to_string()),
6957            timestamp: chrono::Utc::now(),
6958        }
6959    }
6960
6961    #[cfg(feature = "http-client")]
6962    #[tokio::test]
6963    async fn webhooks_replay_preserves_dlq_log_and_failures_when_enqueue_is_unavailable() {
6964        use crate::webhook_outbound::{
6965            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
6966        };
6967
6968        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
6969        crate::job::clear_global_job_client();
6970
6971        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
6972        handler
6973            .create_subscription(replay_test_subscription())
6974            .await
6975            .expect("subscription setup");
6976        let original_log = replay_test_dlq_log();
6977        handler
6978            .log_delivery(original_log.clone())
6979            .await
6980            .expect("dlq log setup");
6981        let failures_before_replay = handler
6982            .get_subscription("sub-replay")
6983            .await
6984            .expect("subscription lookup")
6985            .expect("subscription should exist")
6986            .consecutive_failures;
6987
6988        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
6989        let response = webhooks_replay_endpoint(
6990            State(state),
6991            Json(ReplayRequest {
6992                log_id: original_log.id.clone(),
6993            }),
6994        )
6995        .await
6996        .into_response();
6997
6998        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
6999
7000        let stored_log = handler
7001            .get_delivery_log(&original_log.id)
7002            .await
7003            .expect("delivery log lookup")
7004            .expect("delivery log should still exist");
7005        assert!(stored_log.is_dlq, "failed enqueue must keep log in DLQ");
7006        assert_eq!(stored_log.attempt, original_log.attempt);
7007        assert_eq!(stored_log.last_error, original_log.last_error);
7008        assert_eq!(stored_log.response_status, original_log.response_status);
7009        assert_eq!(stored_log.response_body, original_log.response_body);
7010
7011        let subscription = handler
7012            .get_subscription("sub-replay")
7013            .await
7014            .expect("subscription lookup")
7015            .expect("subscription should exist");
7016        assert_eq!(
7017            subscription.consecutive_failures, failures_before_replay,
7018            "failed enqueue must not reset subscription failure history"
7019        );
7020        assert_eq!(
7021            subscription.status,
7022            crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
7023            "failed enqueue must not reactivate an auto-failed subscription"
7024        );
7025
7026        crate::job::clear_global_job_client();
7027    }
7028
7029    #[cfg(feature = "http-client")]
7030    #[tokio::test]
7031    async fn webhooks_replay_rejects_disabled_subscription_without_removing_dlq() {
7032        use crate::webhook_outbound::{
7033            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7034            WebhookSubscriptionStatus,
7035        };
7036
7037        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7038        crate::job::clear_global_job_client();
7039
7040        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7041        let mut subscription = replay_test_subscription();
7042        subscription.status = WebhookSubscriptionStatus::Disabled;
7043        subscription.consecutive_failures = 0;
7044        handler
7045            .create_subscription(subscription)
7046            .await
7047            .expect("subscription setup");
7048        let original_log = replay_test_dlq_log();
7049        handler
7050            .log_delivery(original_log.clone())
7051            .await
7052            .expect("dlq log setup");
7053
7054        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7055        let response = webhooks_replay_endpoint(
7056            State(state),
7057            Json(ReplayRequest {
7058                log_id: original_log.id.clone(),
7059            }),
7060        )
7061        .await
7062        .into_response();
7063
7064        assert_eq!(response.status(), StatusCode::CONFLICT);
7065
7066        let stored_log = handler
7067            .get_delivery_log(&original_log.id)
7068            .await
7069            .expect("delivery log lookup")
7070            .expect("delivery log should still exist");
7071        assert!(stored_log.is_dlq);
7072        assert_eq!(stored_log.attempt, original_log.attempt);
7073        assert_eq!(stored_log.response_status, original_log.response_status);
7074        assert_eq!(stored_log.last_error, original_log.last_error);
7075
7076        let subscription = handler
7077            .get_subscription("sub-replay")
7078            .await
7079            .expect("subscription lookup")
7080            .expect("subscription should exist");
7081        assert_eq!(subscription.status, WebhookSubscriptionStatus::Disabled);
7082
7083        crate::job::clear_global_job_client();
7084    }
7085
7086    #[cfg(feature = "http-client")]
7087    #[tokio::test]
7088    async fn webhooks_replay_rejects_missing_subscription_without_removing_dlq() {
7089        use crate::webhook_outbound::{
7090            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7091        };
7092
7093        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7094        crate::job::clear_global_job_client();
7095
7096        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7097        let original_log = replay_test_dlq_log();
7098        handler
7099            .log_delivery(original_log.clone())
7100            .await
7101            .expect("dlq log setup");
7102
7103        let runtime_state = crate::AppState::for_test().with_profile("test");
7104        let shutdown = tokio_util::sync::CancellationToken::new();
7105        crate::job::start_runtime(
7106            vec![crate::job::JobInfo {
7107                version: 1,
7108                name: "autumn_webhook_delivery".to_string(),
7109                max_attempts: 1,
7110                initial_backoff_ms: 1,
7111                queue: "default".to_string(),
7112                uniqueness: None,
7113                concurrency: None,
7114                handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7115                timeout: None,
7116            }],
7117            &runtime_state,
7118            &shutdown,
7119            &crate::config::JobConfig::default(),
7120            true,
7121        )
7122        .expect("job runtime should start");
7123
7124        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7125        let response = webhooks_replay_endpoint(
7126            State(state),
7127            Json(ReplayRequest {
7128                log_id: original_log.id.clone(),
7129            }),
7130        )
7131        .await
7132        .into_response();
7133
7134        assert_eq!(response.status(), StatusCode::NOT_FOUND);
7135
7136        let stored_log = handler
7137            .get_delivery_log(&original_log.id)
7138            .await
7139            .expect("delivery log lookup")
7140            .expect("delivery log should still exist");
7141        assert!(stored_log.is_dlq);
7142        assert_eq!(stored_log.attempt, original_log.attempt);
7143        assert_eq!(stored_log.response_status, original_log.response_status);
7144        assert_eq!(stored_log.response_body, original_log.response_body);
7145        assert_eq!(stored_log.last_error, original_log.last_error);
7146
7147        assert!(
7148            handler
7149                .get_subscription("sub-replay")
7150                .await
7151                .expect("subscription lookup")
7152                .is_none(),
7153            "test setup should leave the subscription missing"
7154        );
7155
7156        shutdown.cancel();
7157        crate::job::clear_global_job_client();
7158    }
7159
7160    #[cfg(feature = "http-client")]
7161    #[tokio::test]
7162    async fn webhooks_replay_resets_log_and_failures_after_enqueue_succeeds() {
7163        use crate::webhook_outbound::{
7164            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7165        };
7166
7167        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7168        crate::job::clear_global_job_client();
7169
7170        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7171        handler
7172            .create_subscription(replay_test_subscription())
7173            .await
7174            .expect("subscription setup");
7175        let original_log = replay_test_dlq_log();
7176        handler
7177            .log_delivery(original_log.clone())
7178            .await
7179            .expect("dlq log setup");
7180
7181        let runtime_state = crate::AppState::for_test().with_profile("test");
7182        let shutdown = tokio_util::sync::CancellationToken::new();
7183        crate::job::start_runtime(
7184            vec![crate::job::JobInfo {
7185                version: 1,
7186                name: "autumn_webhook_delivery".to_string(),
7187                max_attempts: 1,
7188                initial_backoff_ms: 1,
7189                queue: "default".to_string(),
7190                uniqueness: None,
7191                concurrency: None,
7192                handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7193                timeout: None,
7194            }],
7195            &runtime_state,
7196            &shutdown,
7197            &crate::config::JobConfig::default(),
7198            true,
7199        )
7200        .expect("job runtime should start");
7201
7202        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7203        let response = webhooks_replay_endpoint(
7204            State(state),
7205            Json(ReplayRequest {
7206                log_id: original_log.id.clone(),
7207            }),
7208        )
7209        .await
7210        .into_response();
7211
7212        assert_eq!(response.status(), StatusCode::OK);
7213
7214        let stored_log = handler
7215            .get_delivery_log(&original_log.id)
7216            .await
7217            .expect("delivery log lookup")
7218            .expect("delivery log should still exist");
7219        assert!(!stored_log.is_dlq);
7220        assert_eq!(stored_log.attempt, 1);
7221        assert_eq!(stored_log.last_error, None);
7222        assert_eq!(stored_log.response_status, None);
7223        assert_eq!(stored_log.response_body, None);
7224
7225        let subscription = handler
7226            .get_subscription("sub-replay")
7227            .await
7228            .expect("subscription lookup")
7229            .expect("subscription should exist");
7230        assert_eq!(subscription.consecutive_failures, 0);
7231        assert_eq!(
7232            subscription.status,
7233            crate::webhook_outbound::WebhookSubscriptionStatus::Active
7234        );
7235
7236        shutdown.cancel();
7237        crate::job::clear_global_job_client();
7238    }
7239
7240    /// A pool whose server accepts each connection and closes it at once.
7241    ///
7242    /// Not a closed port: on Windows a connect to a closed loopback port is
7243    /// retried for about 2 s, so the ping times out instead of failing.
7244    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7245    async fn refused_pool()
7246    -> diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection> {
7247        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7248            .await
7249            .expect("bind");
7250        let addr = listener.local_addr().expect("local addr");
7251        tokio::spawn(async move {
7252            while let Ok((socket, _)) = listener.accept().await {
7253                drop(socket);
7254            }
7255        });
7256        let config = crate::config::DatabaseConfig {
7257            url: Some(format!("postgres://secret_user@{addr}/autumn")),
7258            pool_size: 1,
7259            connect_timeout_secs: 1,
7260            ..Default::default()
7261        };
7262        crate::db::create_pool(&config)
7263            .expect("valid pool config")
7264            .expect("pool url is set")
7265    }
7266
7267    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7268    async fn health_json(state: TestActuatorState) -> (StatusCode, serde_json::Value) {
7269        let resp = actuator_router(true)
7270            .with_state(state)
7271            .oneshot(
7272                Request::builder()
7273                    .uri("/actuator/health")
7274                    .body(Body::empty())
7275                    .unwrap(),
7276            )
7277            .await
7278            .unwrap();
7279        let status = resp.status();
7280        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7281            .await
7282            .unwrap();
7283        (status, serde_json::from_slice(&body).unwrap())
7284    }
7285
7286    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7287    #[tokio::test]
7288    async fn health_db_is_down_without_error_text_when_not_detailed() {
7289        let mut state = test_state();
7290        state.health_detailed = false;
7291        state.pool = Some(refused_pool().await);
7292
7293        let (status, json) = health_json(state).await;
7294
7295        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7296        assert_eq!(json["components"]["db"]["status"], "DOWN");
7297        assert_eq!(json["checks"]["database"]["status"], "down");
7298        // The error text can name the host or the user: not for a public body.
7299        assert!(json["checks"]["database"].get("error").is_none(), "{json}");
7300        assert!(!json.to_string().contains("secret_user"), "{json}");
7301    }
7302
7303    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7304    #[tokio::test]
7305    async fn health_db_error_is_shown_when_detailed() {
7306        let mut state = test_state();
7307        state.health_detailed = true;
7308        state.pool = Some(refused_pool().await);
7309
7310        let (status, json) = health_json(state).await;
7311
7312        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7313        let error = json["checks"]["database"]["error"]
7314            .as_str()
7315            .unwrap_or_default();
7316        assert!(error.starts_with("primary connection failed"), "{json}");
7317        assert_eq!(json["components"]["db"]["details"]["error"], error);
7318    }
7319
7320    #[tokio::test]
7321    async fn actuator_health_returns_ok() {
7322        let app = actuator_router(true).with_state(test_state());
7323        let resp = app
7324            .oneshot(
7325                Request::builder()
7326                    .uri("/actuator/health")
7327                    .body(Body::empty())
7328                    .unwrap(),
7329            )
7330            .await
7331            .unwrap();
7332
7333        assert_eq!(resp.status(), StatusCode::OK);
7334        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7335            .await
7336            .unwrap();
7337        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7338        assert_eq!(json["status"], "UP");
7339        assert_eq!(json["profile"], "dev");
7340        assert!(json["uptime"].is_string());
7341    }
7342
7343    #[cfg(feature = "db")]
7344    #[tokio::test]
7345    async fn actuator_health_exposes_after_commit_failure_counter() {
7346        let app = actuator_router(true).with_state(test_state());
7347        let resp = app
7348            .oneshot(
7349                Request::builder()
7350                    .uri("/actuator/health")
7351                    .body(Body::empty())
7352                    .unwrap(),
7353            )
7354            .await
7355            .unwrap();
7356
7357        assert_eq!(resp.status(), StatusCode::OK);
7358        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7359            .await
7360            .unwrap();
7361        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7362        assert_eq!(
7363            json["autumn_after_commit_failures_total"],
7364            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed),
7365            "/actuator/health should expose the documented after_commit counter"
7366        );
7367    }
7368
7369    #[tokio::test]
7370    #[allow(clippy::await_holding_lock)]
7371    async fn actuator_circuitbreakers_returns_breakers() {
7372        let _lock = crate::circuit_breaker::TEST_LOCK
7373            .lock()
7374            .unwrap_or_else(std::sync::PoisonError::into_inner);
7375        crate::circuit_breaker::global_registry().clear();
7376        let breaker = crate::circuit_breaker::global_registry().get_or_create(
7377            "actuator_endpoint_test_breaker",
7378            crate::circuit_breaker::CircuitBreakerPolicy {
7379                failure_ratio_threshold: 0.5,
7380                sample_window: std::time::Duration::from_secs(10),
7381                minimum_sample_count: 2,
7382                open_duration: std::time::Duration::from_secs(60),
7383                half_open_trial_count: 2,
7384                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7385            },
7386        );
7387        assert_eq!(
7388            breaker.state(),
7389            crate::circuit_breaker::CircuitState::Closed
7390        );
7391
7392        let mut detailed_config = AutumnConfig::default();
7393        detailed_config.health.detailed = true;
7394        let state = test_state_with_config(&detailed_config);
7395        let app = actuator_router(true).with_state(state);
7396        let resp = app
7397            .clone()
7398            .oneshot(
7399                Request::builder()
7400                    .uri("/actuator/circuitbreakers")
7401                    .body(Body::empty())
7402                    .unwrap(),
7403            )
7404            .await
7405            .unwrap();
7406
7407        assert_eq!(resp.status(), StatusCode::OK);
7408        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7409            .await
7410            .unwrap();
7411        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7412        let list = json.as_array().expect("Should be a JSON array");
7413        let item = list
7414            .iter()
7415            .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7416            .expect("Should find our breaker");
7417        assert_eq!(item["state"], "CLOSED");
7418        assert_eq!(item["failure_ratio_threshold"], 0.5);
7419        assert_eq!(item["minimum_sample_count"], 2);
7420        assert_eq!(item["slow_call_ratio"], 0.0);
7421        assert_eq!(item["slow_call_duration_threshold_ms"], 60_000);
7422        assert_eq!(item["slow_call_rate_threshold"], 1.0);
7423        assert_eq!(item["cancelled_call_outcome"], "slow");
7424
7425        let mut undetailed_config = AutumnConfig::default();
7426        undetailed_config.health.detailed = false;
7427        let undetailed_state = test_state_with_config(&undetailed_config);
7428        let app_undetailed = actuator_router(true).with_state(undetailed_state);
7429        let resp_undetailed = app_undetailed
7430            .oneshot(
7431                Request::builder()
7432                    .uri("/actuator/circuitbreakers")
7433                    .body(Body::empty())
7434                    .unwrap(),
7435            )
7436            .await
7437            .unwrap();
7438
7439        assert_eq!(resp_undetailed.status(), StatusCode::OK);
7440        let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7441            .await
7442            .unwrap();
7443        let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7444        let list_undetailed = json_undetailed.as_array().expect("Should be a JSON array");
7445        let item_undetailed = list_undetailed
7446            .iter()
7447            .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7448            .expect("Should find our breaker");
7449        assert_eq!(item_undetailed["state"], "CLOSED");
7450        assert!(item_undetailed.get("failure_ratio_threshold").is_none());
7451        assert!(item_undetailed.get("minimum_sample_count").is_none());
7452        assert_eq!(item_undetailed["slow_call_ratio"], 0.0);
7453        assert!(item_undetailed.get("slow_call_rate_threshold").is_none());
7454        assert!(
7455            item_undetailed
7456                .get("slow_call_duration_threshold_ms")
7457                .is_none()
7458        );
7459        assert!(item_undetailed.get("cancelled_call_outcome").is_none());
7460        crate::circuit_breaker::global_registry().clear();
7461    }
7462
7463    #[test]
7464    fn prometheus_renders_breaker_slow_call_families() {
7465        let mut out = String::new();
7466        write_builtin_circuit_breaker_metrics(&mut out, "version=\"stable\"", &[]);
7467        assert!(out.is_empty(), "no breaker: no family");
7468
7469        let policy = crate::circuit_breaker::CircuitBreakerPolicy {
7470            slow_call_duration_threshold: Some(std::time::Duration::ZERO),
7471            minimum_sample_count: 100,
7472            ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7473        };
7474        let slow = crate::circuit_breaker::CircuitBreaker::new("b\"slow", policy);
7475        crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7476        crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7477        let idle = crate::circuit_breaker::CircuitBreaker::new(
7478            "a.idle",
7479            crate::circuit_breaker::CircuitBreakerPolicy::default(),
7480        );
7481
7482        write_builtin_circuit_breaker_metrics(&mut out, "version=\"stable\"", &[slow, idle]);
7483        let want = "\
7484# HELP autumn_circuit_breaker_slow_calls_total Calls each circuit breaker counted as slow, cancelled slow calls included
7485# TYPE autumn_circuit_breaker_slow_calls_total counter
7486autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"a.idle\"} 0
7487autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"b\\\"slow\"} 2
7488# HELP autumn_circuit_breaker_slow_call_ratio Share of slow calls in each circuit breaker sample window
7489# TYPE autumn_circuit_breaker_slow_call_ratio gauge
7490autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"a.idle\"} 0
7491autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"b\\\"slow\"} 1
7492";
7493        assert_eq!(out, want);
7494    }
7495
7496    #[tokio::test]
7497    #[allow(clippy::await_holding_lock)]
7498    async fn actuator_prometheus_shows_breaker_slow_call_families() {
7499        let _lock = crate::circuit_breaker::TEST_LOCK
7500            .lock()
7501            .unwrap_or_else(std::sync::PoisonError::into_inner);
7502        crate::circuit_breaker::global_registry().clear();
7503        let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7504            "prometheus_slow_breaker",
7505            crate::circuit_breaker::CircuitBreakerPolicy::default(),
7506        );
7507
7508        let app = actuator_router(true).with_state(test_state());
7509        let resp = app
7510            .oneshot(
7511                Request::builder()
7512                    .uri("/actuator/prometheus")
7513                    .body(Body::empty())
7514                    .unwrap(),
7515            )
7516            .await
7517            .unwrap();
7518        assert_eq!(resp.status(), StatusCode::OK);
7519        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7520            .await
7521            .unwrap();
7522        let text = String::from_utf8(body.to_vec()).unwrap();
7523        assert!(text.contains(
7524            "autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7525        ));
7526        assert!(text.contains(
7527            "autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7528        ));
7529        crate::circuit_breaker::global_registry().clear();
7530    }
7531
7532    #[tokio::test]
7533    #[allow(clippy::await_holding_lock)]
7534    async fn test_health_hides_circuit_breakers_when_undetailed() {
7535        let _lock = crate::circuit_breaker::TEST_LOCK
7536            .lock()
7537            .unwrap_or_else(std::sync::PoisonError::into_inner);
7538        crate::circuit_breaker::global_registry().clear();
7539
7540        let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7541            "test_health_hide_breaker",
7542            crate::circuit_breaker::CircuitBreakerPolicy {
7543                failure_ratio_threshold: 0.5,
7544                sample_window: std::time::Duration::from_secs(10),
7545                minimum_sample_count: 2,
7546                open_duration: std::time::Duration::from_secs(60),
7547                half_open_trial_count: 2,
7548                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7549            },
7550        );
7551
7552        let mut detailed_config = AutumnConfig::default();
7553        detailed_config.health.detailed = true;
7554        let state = test_state_with_config(&detailed_config);
7555        let app = actuator_router(true).with_state(state);
7556        let resp = app
7557            .oneshot(
7558                Request::builder()
7559                    .uri("/actuator/health")
7560                    .body(Body::empty())
7561                    .unwrap(),
7562            )
7563            .await
7564            .unwrap();
7565
7566        assert_eq!(resp.status(), StatusCode::OK);
7567        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7568            .await
7569            .unwrap();
7570        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7571        assert!(json["components"]["circuit_breaker.test_health_hide_breaker"].is_object());
7572
7573        let mut undetailed_config = AutumnConfig::default();
7574        undetailed_config.health.detailed = false;
7575        let undetailed_state = test_state_with_config(&undetailed_config);
7576        let app_undetailed = actuator_router(true).with_state(undetailed_state);
7577        let resp_undetailed = app_undetailed
7578            .oneshot(
7579                Request::builder()
7580                    .uri("/actuator/health")
7581                    .body(Body::empty())
7582                    .unwrap(),
7583            )
7584            .await
7585            .unwrap();
7586
7587        assert_eq!(resp_undetailed.status(), StatusCode::OK);
7588        let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7589            .await
7590            .unwrap();
7591        let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7592
7593        if let Some(components) = json_undetailed.get("components") {
7594            assert!(
7595                components
7596                    .get("circuit_breaker.test_health_hide_breaker")
7597                    .is_none()
7598            );
7599        }
7600
7601        crate::circuit_breaker::global_registry().clear();
7602    }
7603
7604    #[tokio::test]
7605    async fn actuator_routes_respect_custom_prefix() {
7606        let app = actuator_router_with_prefix("/ops", true, true).with_state(test_state());
7607
7608        let prefixed = app
7609            .clone()
7610            .oneshot(
7611                Request::builder()
7612                    .uri("/ops/health")
7613                    .body(Body::empty())
7614                    .unwrap(),
7615            )
7616            .await
7617            .unwrap();
7618        assert_eq!(prefixed.status(), StatusCode::OK);
7619
7620        let legacy = app
7621            .oneshot(
7622                Request::builder()
7623                    .uri("/actuator/health")
7624                    .body(Body::empty())
7625                    .unwrap(),
7626            )
7627            .await
7628            .unwrap();
7629        assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
7630    }
7631
7632    #[test]
7633    fn actuator_route_helpers_normalize_prefixes() {
7634        assert_eq!(actuator_route_glob("ops/"), "/ops/*");
7635        assert_eq!(actuator_route_path("ops/", "/health"), "/ops/health");
7636        assert_eq!(actuator_route_glob("/"), "/*");
7637    }
7638
7639    #[tokio::test]
7640    async fn actuator_info_returns_metadata() {
7641        let app = actuator_router(true).with_state(test_state());
7642        let resp = app
7643            .oneshot(
7644                Request::builder()
7645                    .uri("/actuator/info")
7646                    .body(Body::empty())
7647                    .unwrap(),
7648            )
7649            .await
7650            .unwrap();
7651
7652        assert_eq!(resp.status(), StatusCode::OK);
7653        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7654            .await
7655            .unwrap();
7656        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7657        assert!(json["autumn"]["version"].is_string());
7658        assert_eq!(json["autumn"]["profile"], "dev");
7659        // A state that does not report its role omits the key.
7660        assert!(json.get("role").is_none(), "{json}");
7661    }
7662
7663    async fn info_json_for(state: crate::AppState) -> serde_json::Value {
7664        let app = actuator_router(true).with_state(state);
7665        let resp = app
7666            .oneshot(
7667                Request::builder()
7668                    .uri("/actuator/info")
7669                    .body(Body::empty())
7670                    .unwrap(),
7671            )
7672            .await
7673            .unwrap();
7674        assert_eq!(resp.status(), StatusCode::OK);
7675        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7676            .await
7677            .unwrap();
7678        serde_json::from_slice(&body).unwrap()
7679    }
7680
7681    // Issue #3542: operators see what this process runs.
7682    #[tokio::test]
7683    async fn actuator_info_reports_the_role_and_its_capabilities() {
7684        use crate::config::{Capability, ProcessRole};
7685        for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
7686            let mut state = crate::AppState::for_test();
7687            state.role = role.into();
7688            let json = info_json_for(state).await;
7689            assert_eq!(json["role"]["name"], role.as_str(), "{json}");
7690            assert_eq!(json["role"]["base"], role.as_str(), "{json}");
7691            assert_eq!(json["role"]["preset"], false, "{json}");
7692            let caps = json["role"]["capabilities"]
7693                .as_object()
7694                .expect("capabilities object");
7695            assert_eq!(caps.len(), Capability::ALL.len());
7696            for &capability in Capability::ALL {
7697                assert_eq!(
7698                    caps[capability.as_str()],
7699                    serde_json::Value::Bool(role.runs(capability)),
7700                    "role {} capability {}",
7701                    role.as_str(),
7702                    capability.as_str()
7703                );
7704            }
7705            // The existing keys stay.
7706            for key in ["app", "autumn", "runtime", "build"] {
7707                assert!(json.get(key).is_some(), "missing {key}: {json}");
7708            }
7709        }
7710    }
7711
7712    /// A resolved `[roles.critical]` preset over `worker` (#3543).
7713    fn critical_role_3543() -> crate::config::Role {
7714        let config: AutumnConfig = toml::from_str(
7715            "role = \"critical\"\n[roles.critical]\nbase = \"worker\"\n\
7716             pin = [\"critical\"]\nscheduler = false\n",
7717        )
7718        .expect("roles config");
7719        config.resolve_role().expect("critical resolves")
7720    }
7721
7722    async fn get_body(state: crate::AppState, uri: &str) -> String {
7723        let app = actuator_router(true).with_state(state);
7724        let resp = app
7725            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
7726            .await
7727            .unwrap();
7728        assert_eq!(resp.status(), StatusCode::OK, "{uri}");
7729        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7730            .await
7731            .unwrap();
7732        String::from_utf8(body.to_vec()).unwrap()
7733    }
7734
7735    #[tokio::test]
7736    async fn actuator_info_reports_a_named_role() {
7737        let mut state = crate::AppState::for_test();
7738        state.role = critical_role_3543();
7739        let json = info_json_for(state).await;
7740        assert_eq!(json["role"]["name"], "critical", "{json}");
7741        assert_eq!(json["role"]["base"], "worker", "{json}");
7742        assert_eq!(json["role"]["preset"], true, "{json}");
7743        assert_eq!(json["role"]["capabilities"]["scheduler"], false, "{json}");
7744        assert_eq!(json["role"]["capabilities"]["job_workers"], true, "{json}");
7745    }
7746
7747    #[tokio::test]
7748    async fn actuator_info_reports_the_role_mount() {
7749        let config: AutumnConfig = toml::from_str(
7750            "role = \"marketing\"\n[roles.marketing]\nbase = \"web\"\n\
7751             mount = [\"marketing\"]\n",
7752        )
7753        .expect("roles config");
7754        let mut state = crate::AppState::for_test();
7755        state.role = config.resolve_role().expect("marketing resolves");
7756        let json = info_json_for(state).await;
7757        assert_eq!(
7758            json["role"]["mount"],
7759            serde_json::json!(["marketing"]),
7760            "{json}"
7761        );
7762
7763        // No `mount`: the key is omitted.
7764        let json = info_json_for(crate::AppState::for_test()).await;
7765        assert!(json["role"].get("mount").is_none(), "{json}");
7766    }
7767
7768    #[tokio::test]
7769    async fn actuator_jobs_reports_the_role() {
7770        let mut state = crate::AppState::for_test();
7771        state.role = critical_role_3543();
7772        let body = get_body(state, "/actuator/jobs").await;
7773        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
7774        assert_eq!(json["role"]["name"], "critical", "{json}");
7775        assert_eq!(json["role"]["base"], "worker", "{json}");
7776        assert_eq!(
7777            json["role"]["pin"],
7778            serde_json::json!(["critical"]),
7779            "{json}"
7780        );
7781        assert!(
7782            json.get("jobs").is_some() && json.get("queues").is_some(),
7783            "{json}"
7784        );
7785
7786        let body = get_body(crate::AppState::for_test(), "/actuator/jobs").await;
7787        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
7788        assert_eq!(json["role"]["name"], "combined", "{json}");
7789        assert!(json["role"].get("pin").is_none(), "{json}");
7790    }
7791
7792    /// A preset adds a `role` label to the framework metric families. A
7793    /// built-in role keeps the label set it had.
7794    #[tokio::test]
7795    async fn actuator_prometheus_labels_framework_metrics_with_a_named_role() {
7796        let mut state = crate::AppState::for_test();
7797        state.role = critical_role_3543();
7798        state.metrics().record("GET", "/x", 200, 10);
7799        let text = get_body(state, "/actuator/prometheus").await;
7800        assert!(
7801            text.contains("autumn_http_requests_total{version=\"stable\",role=\"critical\"} 1"),
7802            "{text}"
7803        );
7804        assert!(
7805            text.contains(
7806                "autumn_http_responses_total{version=\"stable\",role=\"critical\",status=\"2xx\"} 1"
7807            ),
7808            "{text}"
7809        );
7810        // Every framework sample that carries `version` carries `role` too.
7811        for line in text.lines().filter(|l| l.contains("version=\"stable\"")) {
7812            assert!(line.contains("role=\"critical\""), "{line}");
7813        }
7814
7815        let state = crate::AppState::for_test();
7816        state.metrics().record("GET", "/x", 200, 10);
7817        let text = get_body(state, "/actuator/prometheus").await;
7818        assert!(
7819            text.contains("autumn_http_requests_total{version=\"stable\"} 1"),
7820            "{text}"
7821        );
7822        assert!(!text.contains("role="), "{text}");
7823    }
7824
7825    #[tokio::test]
7826    async fn actuator_info_reports_build_and_git_provenance() {
7827        // This is the only test in the lib test binary that touches the
7828        // process-global build context (`__set_build_context` is first-wins),
7829        // so the injected values below are guaranteed to be the ones rendered.
7830        fn leak(value: String) -> &'static str {
7831            Box::leak(value.into_boxed_str())
7832        }
7833
7834        // Use the repo's real HEAD so this exercises AC #2's contract: the
7835        // reported commit equals `git rev-parse HEAD` of the source tree.
7836        // During cargo mutants runs, the .git directory is stripped, so fallback
7837        // to a default value if git rev-parse fails.
7838        let head = std::process::Command::new("git")
7839            .args(["rev-parse", "HEAD"])
7840            .output()
7841            .ok()
7842            .filter(|out| out.status.success())
7843            .and_then(|out| String::from_utf8(out.stdout).ok())
7844            .map_or_else(
7845                || "179f1aa78077816d50af3bbed337a9328bc0609b".to_string(),
7846                |out| out.trim().to_owned(),
7847            );
7848        let short: String = head.chars().take(7).collect();
7849
7850        crate::build_info::__set_build_context(
7851            "provenance_probe_app",
7852            "9.9.9",
7853            Some(leak(head.clone())),
7854            Some(leak(short.clone())),
7855            Some("provenance-branch"),
7856            Some("false"),
7857            Some("2026-07-09T00:00:00Z"),
7858        );
7859
7860        let app = actuator_router(true).with_state(test_state());
7861        let resp = app
7862            .oneshot(
7863                Request::builder()
7864                    .uri("/actuator/info")
7865                    .body(Body::empty())
7866                    .unwrap(),
7867            )
7868            .await
7869            .unwrap();
7870        assert_eq!(resp.status(), StatusCode::OK);
7871        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7872            .await
7873            .unwrap();
7874        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7875
7876        // AC #3: app.name/version reflect the consuming app's compile-time
7877        // values, not "unknown".
7878        assert_eq!(json["app"]["name"], "provenance_probe_app");
7879        assert_eq!(json["app"]["version"], "9.9.9");
7880        assert_ne!(json["app"]["version"], "unknown");
7881
7882        // AC #1/#2: build object carries full + short SHA, branch, dirty bool,
7883        // and an ISO-8601 UTC build timestamp; commit equals real HEAD.
7884        assert_eq!(json["build"]["git"]["commit"], head);
7885        assert_eq!(json["build"]["git"]["commit_short"], short);
7886        assert_eq!(json["build"]["git"]["branch"], "provenance-branch");
7887        assert_eq!(json["build"]["git"]["dirty"], false);
7888        assert_eq!(json["build"]["timestamp"], "2026-07-09T00:00:00Z");
7889        assert_eq!(json["build"]["version"], "9.9.9");
7890    }
7891
7892    #[tokio::test]
7893    async fn actuator_env_available_in_sensitive_mode() {
7894        let config = AutumnConfig {
7895            profile: Some("prod".into()),
7896            server: crate::config::ServerConfig {
7897                port: 4100,
7898                ..crate::config::ServerConfig::default()
7899            },
7900            telemetry: crate::config::TelemetryConfig {
7901                enabled: true,
7902                service_name: "cloud-app".into(),
7903                ..crate::config::TelemetryConfig::default()
7904            },
7905            health: crate::config::HealthConfig {
7906                path: "/healthz".into(),
7907                ..crate::config::HealthConfig::default()
7908            },
7909            ..AutumnConfig::default()
7910        };
7911
7912        let app = actuator_router(true).with_state(test_state_with_config(&config));
7913        let resp = app
7914            .oneshot(
7915                Request::builder()
7916                    .uri("/actuator/env")
7917                    .body(Body::empty())
7918                    .unwrap(),
7919            )
7920            .await
7921            .unwrap();
7922        assert_eq!(resp.status(), StatusCode::OK);
7923        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7924            .await
7925            .unwrap();
7926        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7927        assert_eq!(json["active_profile"], "prod");
7928        assert_eq!(json["properties"]["server.port"], "4100");
7929        assert_eq!(json["properties"]["telemetry.enabled"], "true");
7930        assert_eq!(json["properties"]["telemetry.service_name"], "cloud-app");
7931        assert_eq!(json["properties"]["health.path"], "/healthz");
7932    }
7933
7934    #[tokio::test]
7935    async fn actuator_env_hidden_in_nonsensitive_mode() {
7936        let app = actuator_router(false).with_state(test_state());
7937        let resp = app
7938            .oneshot(
7939                Request::builder()
7940                    .uri("/actuator/env")
7941                    .body(Body::empty())
7942                    .unwrap(),
7943            )
7944            .await
7945            .unwrap();
7946        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7947    }
7948
7949    #[tokio::test]
7950    async fn actuator_graph_hidden_in_nonsensitive_mode() {
7951        // The graph names every route, its auth requirement and the table it
7952        // touches — a map of where to look first. It is sensitive-gated like
7953        // `/env`, not public like `/health`.
7954        let app = actuator_router(false).with_state(test_state());
7955        let resp = app
7956            .oneshot(
7957                Request::builder()
7958                    .uri("/actuator/graph")
7959                    .body(Body::empty())
7960                    .unwrap(),
7961            )
7962            .await
7963            .unwrap();
7964        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7965    }
7966
7967    #[test]
7968    fn actuator_graph_path_is_listed_only_in_sensitive_mode() {
7969        assert!(
7970            actuator_endpoint_paths("/actuator", true, true)
7971                .contains(&"/actuator/graph".to_owned())
7972        );
7973        assert!(
7974            !actuator_endpoint_paths("/actuator", false, true)
7975                .contains(&"/actuator/graph".to_owned()),
7976            "the listing must match the mounts, or the startup barrier seeds a path \
7977             that is not served"
7978        );
7979    }
7980
7981    #[cfg(feature = "db")]
7982    #[test]
7983    fn actuator_derivations_path_is_listed_only_in_sensitive_mode() {
7984        // The listing seeds the startup barrier's allow-list, so a mount without
7985        // a listed path is a route the barrier holds shut. The document names
7986        // parent and child tables, so it is sensitive-gated like `/graph`.
7987        assert!(
7988            actuator_endpoint_paths("/actuator", true, true)
7989                .contains(&"/actuator/derivations".to_owned())
7990        );
7991        assert!(
7992            !actuator_endpoint_paths("/actuator", false, true)
7993                .contains(&"/actuator/derivations".to_owned()),
7994            "the listing must match the mounts, or the startup barrier seeds a path \
7995             that is not served"
7996        );
7997    }
7998
7999    #[cfg(feature = "db")]
8000    #[tokio::test]
8001    async fn actuator_derivations_hidden_in_nonsensitive_mode() {
8002        let app = actuator_router(false).with_state(test_state());
8003        let resp = app
8004            .oneshot(
8005                Request::builder()
8006                    .uri("/actuator/derivations")
8007                    .body(Body::empty())
8008                    .unwrap(),
8009            )
8010            .await
8011            .unwrap();
8012        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8013    }
8014
8015    #[cfg(feature = "db")]
8016    #[test]
8017    fn actuator_derivations_200_body_is_an_array_of_status_objects() {
8018        // The endpoint answers `Json(Vec<DerivationStatus>)`, so this pins the
8019        // body an operator and a dashboard parse. The no-pool and hidden cases
8020        // are covered below; this is the success shape, which needs no database.
8021        let statuses = vec![
8022            crate::derivation::DerivationStatus {
8023                name: "posts.published_comment_count".to_owned(),
8024                definition_hash: Some("a".repeat(64)),
8025                stored_hash: Some("a".repeat(64)),
8026                backfill_state: Some(crate::derivation::BackfillState::Complete),
8027                checkpoint: Some(42),
8028                backfilled_rows: 7,
8029                updated_at: Some("2026-09-07 00:00:00+00".to_owned()),
8030                drift: Some(0),
8031                drift_error: None,
8032            },
8033            crate::derivation::DerivationStatus {
8034                name: "posts.removed".to_owned(),
8035                definition_hash: None,
8036                stored_hash: Some("b".repeat(64)),
8037                backfill_state: Some(crate::derivation::BackfillState::Unregistered),
8038                checkpoint: None,
8039                backfilled_rows: 0,
8040                updated_at: None,
8041                drift: None,
8042                drift_error: Some("column does not exist".to_owned()),
8043            },
8044        ];
8045        let body = serde_json::Value::Array(derivation_report("control", statuses));
8046        let rows = body.as_array().expect("the body is a JSON array");
8047        assert_eq!(rows.len(), 2);
8048        for row in rows {
8049            assert_eq!(row["target"], "control", "every row names its database");
8050        }
8051        for row in rows {
8052            let mut keys: Vec<&str> = row
8053                .as_object()
8054                .expect("each row is an object")
8055                .keys()
8056                .map(String::as_str)
8057                .collect();
8058            keys.sort_unstable();
8059            assert_eq!(
8060                keys,
8061                vec![
8062                    "backfill_state",
8063                    "backfilled_rows",
8064                    "checkpoint",
8065                    "definition_hash",
8066                    "drift",
8067                    "drift_error",
8068                    "name",
8069                    "stored_hash",
8070                    "target",
8071                    "updated_at",
8072                ],
8073                "{row}"
8074            );
8075        }
8076        assert_eq!(rows[0]["backfill_state"], serde_json::json!("complete"));
8077        assert_eq!(rows[0]["drift"], serde_json::json!(0));
8078        // A state row this binary declares no derivation for: reported, with no
8079        // definition hash and no drift figure.
8080        assert_eq!(rows[1]["backfill_state"], serde_json::json!("unregistered"));
8081        assert!(rows[1]["definition_hash"].is_null());
8082        assert!(rows[1]["drift"].is_null());
8083    }
8084
8085    #[cfg(feature = "db")]
8086    #[tokio::test]
8087    async fn actuator_derivations_reports_no_pool_as_unavailable() {
8088        // 503, not 404: an operator has to be able to tell "this build has no
8089        // such endpoint" from "this process has no database to report against".
8090        let app = actuator_router(true).with_state(test_state());
8091        let resp = app
8092            .oneshot(
8093                Request::builder()
8094                    .uri("/actuator/derivations")
8095                    .body(Body::empty())
8096                    .unwrap(),
8097            )
8098            .await
8099            .unwrap();
8100        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8101    }
8102
8103    /// GET `/actuator/graph` on a real sensitive router over `state`.
8104    async fn get_graph(state: crate::AppState) -> axum::response::Response {
8105        actuator_router(true)
8106            .with_state(state)
8107            .oneshot(
8108                Request::builder()
8109                    .uri("/actuator/graph")
8110                    .body(Body::empty())
8111                    .unwrap(),
8112            )
8113            .await
8114            .unwrap()
8115    }
8116
8117    #[tokio::test]
8118    async fn actuator_graph_serves_the_installed_graph_over_http() {
8119        // End to end: install a graph, mount the real sensitive router, GET it.
8120        // The install-site guard is
8121        // `app::tests::graph_installed_before_every_router_build`.
8122        let state = crate::AppState::test_default();
8123        crate::graph::install(
8124            &state,
8125            crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]),
8126        );
8127
8128        let resp = get_graph(state).await;
8129        assert_eq!(resp.status(), StatusCode::OK);
8130        assert_eq!(
8131            resp.headers()
8132                .get(axum::http::header::CONTENT_TYPE)
8133                .and_then(|v| v.to_str().ok()),
8134            Some("application/json")
8135        );
8136        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8137            .await
8138            .expect("body");
8139        let decoded: crate::graph::manifest::ArchitectureGraph =
8140            serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
8141        assert_eq!(
8142            decoded.schema_version,
8143            crate::graph::manifest::MANIFEST_SCHEMA_VERSION
8144        );
8145    }
8146
8147    /// Item 17 of #2520: the second app in a process serves its own graph,
8148    /// not the first app's.
8149    #[tokio::test]
8150    async fn two_apps_in_one_process_serve_their_own_graphs() {
8151        let graph_with = |jobs: usize| {
8152            let mut graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
8153            graph.completeness.jobs = jobs;
8154            graph
8155        };
8156        let first = crate::AppState::test_default();
8157        let second = crate::AppState::test_default();
8158        crate::graph::install(&first, graph_with(1));
8159        crate::graph::install(&second, graph_with(2));
8160
8161        for (state, jobs) in [(first, 1), (second, 2)] {
8162            let resp = get_graph(state).await;
8163            assert_eq!(resp.status(), StatusCode::OK);
8164            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8165                .await
8166                .expect("body");
8167            let decoded: crate::graph::manifest::ArchitectureGraph =
8168                serde_json::from_slice(&body).expect("graph document");
8169            assert_eq!(decoded.completeness.jobs, jobs);
8170        }
8171    }
8172
8173    /// A state with no installed graph answers 503. This includes a custom
8174    /// `ProvideActuatorState` that keeps the default method.
8175    #[tokio::test]
8176    async fn actuator_graph_is_unavailable_for_a_state_with_no_graph() {
8177        let resp = get_graph(crate::AppState::test_default()).await;
8178        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8179
8180        let resp = actuator_router(true)
8181            .with_state(test_state())
8182            .oneshot(
8183                Request::builder()
8184                    .uri("/actuator/graph")
8185                    .body(Body::empty())
8186                    .unwrap(),
8187            )
8188            .await
8189            .unwrap();
8190        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8191    }
8192
8193    #[tokio::test]
8194    async fn actuator_graph_serves_the_installed_graph() {
8195        let graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
8196        let json = serde_json::to_vec(&graph).expect("serialize");
8197        let resp = graph_response(Some(bytes::Bytes::from(json)));
8198        assert_eq!(resp.status(), StatusCode::OK);
8199        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8200            .await
8201            .expect("body");
8202        let decoded: crate::graph::manifest::ArchitectureGraph =
8203            serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
8204        assert_eq!(decoded, graph);
8205    }
8206
8207    #[tokio::test]
8208    async fn actuator_graph_explains_itself_when_none_is_installed() {
8209        let resp = graph_response(None);
8210        assert_eq!(
8211            resp.status(),
8212            StatusCode::SERVICE_UNAVAILABLE,
8213            "an app with no graph must say so rather than 404, which would read as \
8214             'this build has no such endpoint'"
8215        );
8216    }
8217
8218    #[tokio::test]
8219    async fn actuator_circuitbreakers_hidden_in_nonsensitive_mode() {
8220        let app = actuator_router(false).with_state(test_state());
8221        let resp = app
8222            .oneshot(
8223                Request::builder()
8224                    .uri("/actuator/circuitbreakers")
8225                    .body(Body::empty())
8226                    .unwrap(),
8227            )
8228            .await
8229            .unwrap();
8230        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8231    }
8232
8233    #[test]
8234    fn redaction_patterns() {
8235        assert!(should_redact("database.url"));
8236        assert!(should_redact("api_token"));
8237        assert!(should_redact("secret_key"));
8238        assert!(!should_redact("server.port"));
8239        assert!(!should_redact("log.level"));
8240    }
8241
8242    // ── Metrics endpoint tests ─────────────────────────────────
8243
8244    #[tokio::test]
8245    async fn actuator_metrics_returns_http_stats() {
8246        let state = test_state();
8247        state.metrics().record("GET", "/test", 200, 10);
8248        state.metrics().record("POST", "/test", 500, 50);
8249
8250        let app = actuator_router(true).with_state(state);
8251        let resp = app
8252            .oneshot(
8253                Request::builder()
8254                    .uri("/actuator/metrics")
8255                    .body(Body::empty())
8256                    .unwrap(),
8257            )
8258            .await
8259            .unwrap();
8260
8261        assert_eq!(resp.status(), StatusCode::OK);
8262        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8263            .await
8264            .unwrap();
8265        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8266        assert_eq!(json["http"]["requests_total"], 2);
8267        assert_eq!(json["http"]["by_status"]["2xx"], 1);
8268        assert_eq!(json["http"]["by_status"]["5xx"], 1);
8269    }
8270
8271    #[tokio::test]
8272    async fn actuator_metrics_available_in_nonsensitive_mode() {
8273        let app = actuator_router(false).with_state(test_state());
8274        let resp = app
8275            .oneshot(
8276                Request::builder()
8277                    .uri("/actuator/metrics")
8278                    .body(Body::empty())
8279                    .unwrap(),
8280            )
8281            .await
8282            .unwrap();
8283        assert_eq!(resp.status(), StatusCode::OK);
8284    }
8285
8286    // Postgres-only: the fixture gives one shard a DISTINCT replica_url, and
8287    // both layers refuse that on SQLite — `database_backend_consistency` at
8288    // config time, `reject_unusable_sqlite_replica` at topology build. The
8289    // replica is the blocker: native sharding is Postgres-only too, but it is
8290    // refused at BOOT rather than by the topology builder, so the shards alone
8291    // would not fail here. Same treatment `db::`'s replica tests got, rather
8292    // than a fixture swap that would assert a configuration production refuses.
8293    #[cfg(not(feature = "sqlite"))]
8294    #[tokio::test]
8295    #[cfg(feature = "db")]
8296    async fn actuator_metrics_returns_per_shard_stats_when_sharded() {
8297        let mut state = test_state();
8298        let config = crate::config::DatabaseConfig {
8299            shards: vec![
8300                crate::config::ShardConfig {
8301                    name: "alpha".to_owned(),
8302                    primary_url: "postgres://localhost/alpha".to_owned(),
8303                    slots: None,
8304                    replica_url: None,
8305                    primary_pool_size: Some(4),
8306                    replica_pool_size: None,
8307                    replica_fallback: None,
8308                },
8309                crate::config::ShardConfig {
8310                    name: "beta".to_owned(),
8311                    primary_url: "postgres://localhost/beta".to_owned(),
8312                    slots: None,
8313                    replica_url: Some("postgres://localhost/beta_ro".to_owned()),
8314                    primary_pool_size: None,
8315                    replica_pool_size: Some(2),
8316                    replica_fallback: None,
8317                },
8318            ],
8319            ..Default::default()
8320        };
8321        state.shards = crate::sharding::create_shard_set(
8322            &config,
8323            std::sync::Arc::new(crate::sharding::HashShardRouter),
8324        )
8325        .expect("lazy pools build");
8326
8327        let app = actuator_router(true).with_state(state);
8328        let resp = app
8329            .oneshot(
8330                Request::builder()
8331                    .uri("/actuator/metrics")
8332                    .body(Body::empty())
8333                    .unwrap(),
8334            )
8335            .await
8336            .unwrap();
8337
8338        assert_eq!(resp.status(), StatusCode::OK);
8339        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8340            .await
8341            .unwrap();
8342        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8343
8344        let shards = json
8345            .get("database_shards")
8346            .expect("sharded state exposes database_shards");
8347        assert_eq!(shards["alpha"]["pool_size"], 4);
8348        assert_eq!(shards["alpha"]["slots"], 8192);
8349        assert_eq!(shards["beta"]["replica"]["pool_size"], 2);
8350    }
8351
8352    #[tokio::test]
8353    #[cfg(feature = "db")]
8354    async fn actuator_metrics_returns_db_stats_when_pool_present() {
8355        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8356        use diesel_async::pooled_connection::deadpool::Pool;
8357
8358        let mut state = test_state();
8359
8360        // `RuntimeConnection` is `AsyncPgConnection` in the default build and a
8361        // SQLite connection under `--features sqlite`, and this test RUNS on
8362        // both. It builds the manager directly rather than through
8363        // `create_pool`, so nothing would refuse a target meant for the other
8364        // backend — hence the target comes from `test_urls`, not a literal.
8365        // Only pool metrics are exercised; deadpool is lazy, so no connection
8366        // is opened.
8367        let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8368            crate::test_urls::primary("actuator_metrics"),
8369        );
8370        let pool = Pool::builder(manager).build().unwrap();
8371
8372        state.pool = Some(pool);
8373
8374        let app = actuator_router(true).with_state(state);
8375        let resp = app
8376            .oneshot(
8377                Request::builder()
8378                    .uri("/actuator/metrics")
8379                    .body(Body::empty())
8380                    .unwrap(),
8381            )
8382            .await
8383            .unwrap();
8384
8385        assert_eq!(resp.status(), StatusCode::OK);
8386        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8387            .await
8388            .unwrap();
8389        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8390
8391        assert!(json.get("database").is_some());
8392    }
8393
8394    async fn scrape_prometheus(state: TestActuatorState) -> String {
8395        let app = actuator_router(true).with_state(state);
8396        let resp = app
8397            .oneshot(
8398                Request::builder()
8399                    .uri("/actuator/prometheus")
8400                    .body(Body::empty())
8401                    .unwrap(),
8402            )
8403            .await
8404            .unwrap();
8405        assert_eq!(resp.status(), StatusCode::OK);
8406        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8407            .await
8408            .unwrap();
8409        String::from_utf8(body.to_vec()).unwrap()
8410    }
8411
8412    /// The value of the one sample line that starts with `prefix`.
8413    fn sample_value(text: &str, prefix: &str) -> f64 {
8414        let line = text
8415            .lines()
8416            .find(|l| l.starts_with(prefix))
8417            .unwrap_or_else(|| panic!("no sample `{prefix}` in:\n{text}"));
8418        line.rsplit(' ').next().unwrap().parse().unwrap()
8419    }
8420
8421    #[tokio::test]
8422    async fn prometheus_exports_job_queue_gauges_and_dead_letters() {
8423        let state = test_state();
8424        let jobs = state.job_registry.clone();
8425        jobs.register_on_queue("send_mail", "mail");
8426        jobs.register_on_queue("resize", "media");
8427        let now = jobs.now_ms();
8428        jobs.record_enqueue_scheduled("send_mail", now - 5_000);
8429        jobs.record_enqueue_scheduled("send_mail", now - 1_000);
8430        jobs.record_enqueue("resize");
8431        jobs.record_start("resize");
8432        jobs.record_failure("resize", "boom".into(), true);
8433
8434        let text = scrape_prometheus(state).await;
8435
8436        assert!(
8437            text.contains("# TYPE autumn_jobs_queue_depth gauge\n"),
8438            "{text}"
8439        );
8440        assert!(
8441            text.contains("# TYPE autumn_jobs_oldest_age_seconds gauge\n"),
8442            "{text}"
8443        );
8444        assert!(
8445            text.contains("# TYPE autumn_jobs_dead_letter_total counter\n"),
8446            "{text}"
8447        );
8448        let depth = "autumn_jobs_queue_depth{version=\"stable\",queue=\"mail\"} ";
8449        assert!((sample_value(&text, depth) - 2.0).abs() < f64::EPSILON);
8450        let empty = "autumn_jobs_queue_depth{version=\"stable\",queue=\"media\"} ";
8451        assert!(sample_value(&text, empty).abs() < f64::EPSILON);
8452        let age = "autumn_jobs_oldest_age_seconds{version=\"stable\",queue=\"mail\"} ";
8453        assert!(sample_value(&text, age) >= 4.9, "oldest job waited 5s");
8454        let dead = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"resize\"} ";
8455        assert!((sample_value(&text, dead) - 1.0).abs() < f64::EPSILON);
8456        let none = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"send_mail\"} ";
8457        assert!(sample_value(&text, none).abs() < f64::EPSILON);
8458    }
8459
8460    #[tokio::test]
8461    async fn prometheus_exports_db_pool_wait_histogram() {
8462        let state = test_state();
8463        state
8464            .metrics
8465            .record_db_pool_wait(std::time::Duration::from_micros(300));
8466        state
8467            .metrics
8468            .record_db_pool_wait(std::time::Duration::from_millis(120));
8469
8470        let text = scrape_prometheus(state).await;
8471
8472        assert!(
8473            text.contains("# TYPE autumn_db_pool_wait_seconds histogram\n"),
8474            "{text}"
8475        );
8476        let first = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"0.001\"} ";
8477        assert!((sample_value(&text, first) - 1.0).abs() < f64::EPSILON);
8478        let inf = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"+Inf\"} ";
8479        assert!((sample_value(&text, inf) - 2.0).abs() < f64::EPSILON);
8480        let count = "autumn_db_pool_wait_seconds_count{version=\"stable\"} ";
8481        assert!((sample_value(&text, count) - 2.0).abs() < f64::EPSILON);
8482        let sum = "autumn_db_pool_wait_seconds_sum{version=\"stable\"} ";
8483        assert!((sample_value(&text, sum) - 0.1203).abs() < 1e-9);
8484    }
8485
8486    #[tokio::test]
8487    #[cfg(feature = "db")]
8488    async fn prometheus_exports_db_pool_gauges() {
8489        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8490        use diesel_async::pooled_connection::deadpool::Pool;
8491
8492        let mut state = test_state();
8493        // deadpool is lazy, so no connection is opened.
8494        let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8495            crate::test_urls::primary("actuator_prometheus_pool"),
8496        );
8497        state.pool = Some(Pool::builder(manager).max_size(7).build().unwrap());
8498        let replica = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8499            crate::test_urls::primary("actuator_prometheus_replica"),
8500        );
8501        state.replica_pool = Some(Pool::builder(replica).max_size(3).build().unwrap());
8502
8503        let text = scrape_prometheus(state).await;
8504        let replica_max = "autumn_db_pool_max_size{version=\"stable\",pool=\"replica\"} ";
8505        assert!((sample_value(&text, replica_max) - 3.0).abs() < f64::EPSILON);
8506
8507        for (name, value) in [
8508            ("autumn_db_pool_max_size", 7.0),
8509            ("autumn_db_pool_size", 0.0),
8510            ("autumn_db_pool_available", 0.0),
8511            ("autumn_db_pool_waiting", 0.0),
8512        ] {
8513            assert!(text.contains(&format!("# TYPE {name} gauge\n")), "{text}");
8514            let prefix = format!("{name}{{version=\"stable\",pool=\"primary\"}} ");
8515            assert!((sample_value(&text, &prefix) - value).abs() < f64::EPSILON);
8516        }
8517    }
8518
8519    #[test]
8520    fn builtin_family_names_cover_queue_and_pool_metrics() {
8521        for name in [
8522            "autumn_jobs_queue_depth",
8523            "autumn_jobs_oldest_age_seconds",
8524            "autumn_jobs_dead_letter_total",
8525            "autumn_db_pool_max_size",
8526            "autumn_db_pool_size",
8527            "autumn_db_pool_available",
8528            "autumn_db_pool_waiting",
8529            "autumn_db_pool_wait_seconds",
8530            "autumn_db_pool_wait_seconds_bucket",
8531            "autumn_db_pool_wait_seconds_sum",
8532            "autumn_db_pool_wait_seconds_count",
8533        ] {
8534            assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&name), "{name}");
8535        }
8536    }
8537
8538    // ── Config properties endpoint tests ───────────────────────
8539
8540    #[tokio::test]
8541    async fn actuator_configprops_returns_properties() {
8542        let app = actuator_router(true).with_state(test_state());
8543        let resp = app
8544            .oneshot(
8545                Request::builder()
8546                    .uri("/actuator/configprops")
8547                    .body(Body::empty())
8548                    .unwrap(),
8549            )
8550            .await
8551            .unwrap();
8552
8553        assert_eq!(resp.status(), StatusCode::OK);
8554        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8555            .await
8556            .unwrap();
8557        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8558        assert_eq!(json["active_profile"], "dev");
8559        assert!(json["properties"].is_object());
8560    }
8561
8562    #[tokio::test]
8563    async fn actuator_configprops_hidden_in_nonsensitive_mode() {
8564        let app = actuator_router(false).with_state(test_state());
8565        let resp = app
8566            .oneshot(
8567                Request::builder()
8568                    .uri("/actuator/configprops")
8569                    .body(Body::empty())
8570                    .unwrap(),
8571            )
8572            .await
8573            .unwrap();
8574        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8575    }
8576
8577    #[test]
8578    fn configprops_redacts_sensitive_values() {
8579        let mut props = HashMap::new();
8580        ConfigProperties::track_property(
8581            &mut props,
8582            "database.url",
8583            "postgres://user:pass@host/db",
8584            "",
8585            "dev",
8586        );
8587        assert_eq!(props["database.url"].value, "****");
8588    }
8589
8590    #[test]
8591    fn configprops_tracks_default_source() {
8592        let mut props = HashMap::new();
8593        ConfigProperties::track_property(&mut props, "server.port", "3000", "3000", "dev");
8594        assert_eq!(props["server.port"].source, "default");
8595        assert_eq!(props["server.port"].value, "3000");
8596    }
8597
8598    #[test]
8599    fn configprops_tracks_profile_source() {
8600        let mut props = HashMap::new();
8601        ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
8602        assert_eq!(props["log.level"].source, "profile_default:dev");
8603    }
8604
8605    // ── Loggers endpoint tests ─────────────────────────────────
8606
8607    #[tokio::test]
8608    async fn actuator_loggers_get_returns_levels() {
8609        let app = actuator_router(true).with_state(test_state());
8610        let resp = app
8611            .oneshot(
8612                Request::builder()
8613                    .uri("/actuator/loggers")
8614                    .body(Body::empty())
8615                    .unwrap(),
8616            )
8617            .await
8618            .unwrap();
8619
8620        assert_eq!(resp.status(), StatusCode::OK);
8621        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8622            .await
8623            .unwrap();
8624        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8625        assert_eq!(json["current_level"], "info");
8626        assert!(json["available_levels"].is_array());
8627    }
8628
8629    #[tokio::test]
8630    async fn actuator_loggers_put_changes_level() {
8631        let state = test_state();
8632        let app = actuator_router(true).with_state(state.clone());
8633        let resp = app
8634            .oneshot(
8635                Request::builder()
8636                    .method("PUT")
8637                    .uri("/actuator/loggers/autumn_web")
8638                    .header("content-type", "application/json")
8639                    .body(Body::from(r#"{"level": "debug"}"#))
8640                    .unwrap(),
8641            )
8642            .await
8643            .unwrap();
8644
8645        assert_eq!(resp.status(), StatusCode::OK);
8646        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8647            .await
8648            .unwrap();
8649        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8650        // `test_state()` has no reload-capable subscriber wired in, so the
8651        // endpoint honestly reports the change was recorded but not applied
8652        // rather than a false-positive `ok` (issue #1044). A live-subscriber
8653        // integration test (`actuator_loggers_live_reload`) covers the `ok`
8654        // path with a real reloadable subscriber.
8655        assert_eq!(json["status"], "recorded");
8656        assert_eq!(json["applied"], false);
8657
8658        let overrides = state.log_levels().logger_overrides();
8659        assert_eq!(
8660            overrides.get("autumn_web").map(String::as_str),
8661            Some("debug")
8662        );
8663    }
8664
8665    #[tokio::test]
8666    async fn actuator_loggers_put_rejects_invalid_level() {
8667        let app = actuator_router(true).with_state(test_state());
8668        let resp = app
8669            .oneshot(
8670                Request::builder()
8671                    .method("PUT")
8672                    .uri("/actuator/loggers/autumn_web")
8673                    .header("content-type", "application/json")
8674                    .body(Body::from(r#"{"level": "banana"}"#))
8675                    .unwrap(),
8676            )
8677            .await
8678            .unwrap();
8679
8680        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8681        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8682            .await
8683            .unwrap();
8684        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8685        assert_eq!(json["status"], "error");
8686    }
8687
8688    #[tokio::test]
8689    async fn actuator_loggers_put_rejects_invalid_name() {
8690        // A logger name carrying an `EnvFilter` metacharacter (`=`) must be
8691        // rejected up front (400) — never applied, never recorded — so the
8692        // endpoint cannot claim success for a directive the subscriber would
8693        // reject (issue #1044). Other metacharacters (`,`, whitespace) too.
8694        // `has%20space` is percent-encoded whitespace: axum's `Path` extractor
8695        // decodes it back to a space, which the validator must still reject.
8696        let state = test_state();
8697        for bogus in ["a=b", "a,b", "has%20space", "a::b=trace"] {
8698            let app = actuator_router(true).with_state(state.clone());
8699            let resp = app
8700                .oneshot(
8701                    Request::builder()
8702                        .method("PUT")
8703                        .uri(format!("/actuator/loggers/{bogus}"))
8704                        .header("content-type", "application/json")
8705                        .body(Body::from(r#"{"level": "debug"}"#))
8706                        .unwrap(),
8707                )
8708                .await
8709                .unwrap();
8710
8711            assert_eq!(
8712                resp.status(),
8713                StatusCode::BAD_REQUEST,
8714                "logger name {bogus:?} should be rejected"
8715            );
8716            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8717                .await
8718                .unwrap();
8719            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8720            assert_eq!(json["status"], "error");
8721        }
8722
8723        // And GET /loggers must NOT then list any of the bogus overrides.
8724        let app = actuator_router(true).with_state(state.clone());
8725        let resp = app
8726            .oneshot(
8727                Request::builder()
8728                    .uri("/actuator/loggers")
8729                    .body(Body::empty())
8730                    .unwrap(),
8731            )
8732            .await
8733            .unwrap();
8734        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8735            .await
8736            .unwrap();
8737        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8738        let loggers = json["loggers"].as_object().unwrap();
8739        assert!(
8740            loggers.is_empty(),
8741            "no bogus override should be recorded, got {loggers:?}"
8742        );
8743    }
8744
8745    #[tokio::test]
8746    async fn actuator_loggers_put_accepts_dotted_and_hyphenated_names() {
8747        // Real-world `tracing` targets from third-party crates and custom
8748        // targets carry `.` and `-` (e.g. `tower-http`, `my.custom.target`,
8749        // `h2::proto`). These are valid inside a target and are *not*
8750        // `EnvFilter` directive metacharacters, so a PUT to such a name must
8751        // NOT be rejected as invalid (400) — it reaches the normal
8752        // apply/record path (200) and is recorded as an override.
8753        let state = test_state();
8754        for name in ["tower-http", "my.custom.target", "h2::proto"] {
8755            let app = actuator_router(true).with_state(state.clone());
8756            let resp = app
8757                .oneshot(
8758                    Request::builder()
8759                        .method("PUT")
8760                        .uri(format!("/actuator/loggers/{name}"))
8761                        .header("content-type", "application/json")
8762                        .body(Body::from(r#"{"level": "debug"}"#))
8763                        .unwrap(),
8764                )
8765                .await
8766                .unwrap();
8767
8768            assert_eq!(
8769                resp.status(),
8770                StatusCode::OK,
8771                "logger name {name:?} should be accepted, not rejected as invalid"
8772            );
8773            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8774                .await
8775                .unwrap();
8776            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8777            // No live subscriber is attached in `test_state()`, so a valid name
8778            // takes the "recorded" path — the point is it is not the invalid
8779            // "error" path.
8780            assert_ne!(
8781                json["status"], "error",
8782                "valid name {name:?} must not hit the invalid-name error path"
8783            );
8784        }
8785
8786        // GET /loggers must now list the accepted overrides.
8787        let app = actuator_router(true).with_state(state.clone());
8788        let resp = app
8789            .oneshot(
8790                Request::builder()
8791                    .uri("/actuator/loggers")
8792                    .body(Body::empty())
8793                    .unwrap(),
8794            )
8795            .await
8796            .unwrap();
8797        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8798            .await
8799            .unwrap();
8800        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8801        let loggers = json["loggers"].as_object().unwrap();
8802        for name in ["tower-http", "my.custom.target", "h2::proto"] {
8803            assert!(
8804                loggers.contains_key(name),
8805                "accepted override {name:?} should be recorded, got {loggers:?}"
8806            );
8807        }
8808    }
8809
8810    #[tokio::test]
8811    async fn actuator_loggers_put_applied_ok_with_live_subscriber() {
8812        // Positive path: with a reload-capable subscriber installed, a valid
8813        // change reports `{"status":"ok","applied":true}` end-to-end (issue
8814        // #1044 AC7). Uses a no-op reload handle that accepts any valid
8815        // directive, standing in for a live subscriber.
8816        let state = test_state();
8817        state
8818            .log_levels()
8819            .attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8820
8821        let app = actuator_router(true).with_state(state.clone());
8822        let resp = app
8823            .oneshot(
8824                Request::builder()
8825                    .method("PUT")
8826                    .uri("/actuator/loggers/autumn_web")
8827                    .header("content-type", "application/json")
8828                    .body(Body::from(r#"{"level": "debug"}"#))
8829                    .unwrap(),
8830            )
8831            .await
8832            .unwrap();
8833
8834        assert_eq!(resp.status(), StatusCode::OK);
8835        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8836            .await
8837            .unwrap();
8838        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8839        assert_eq!(json["status"], "ok");
8840        assert_eq!(json["applied"], true);
8841
8842        let overrides = state.log_levels().logger_overrides();
8843        assert_eq!(
8844            overrides.get("autumn_web").map(String::as_str),
8845            Some("debug")
8846        );
8847    }
8848
8849    #[tokio::test]
8850    async fn actuator_loggers_hidden_in_nonsensitive_mode() {
8851        let app = actuator_router(false).with_state(test_state());
8852        let resp = app
8853            .oneshot(
8854                Request::builder()
8855                    .uri("/actuator/loggers")
8856                    .body(Body::empty())
8857                    .unwrap(),
8858            )
8859            .await
8860            .unwrap();
8861        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8862    }
8863
8864    #[test]
8865    fn log_levels_set_and_get() {
8866        let levels = LogLevels::new("info");
8867        assert_eq!(levels.current_level(), "info");
8868
8869        let _ = levels.set_logger_level("my_crate", "debug");
8870        let overrides = levels.logger_overrides();
8871        assert_eq!(overrides.get("my_crate").map(String::as_str), Some("debug"));
8872    }
8873
8874    #[test]
8875    fn log_levels_root_updates_current() {
8876        let levels = LogLevels::new("info");
8877        let change = levels.set_logger_level("root", "trace");
8878        assert_eq!(change.previous(), Some("info"));
8879        assert_eq!(levels.current_level(), "trace");
8880    }
8881
8882    #[test]
8883    fn root_level_change_preserves_startup_per_target_directives() {
8884        // Regression: an app configured with a full `EnvFilter` directive at
8885        // startup must not lose its per-target directives when
8886        // `PUT /actuator/loggers/root` replaces only the global level.
8887        let levels = LogLevels::new("info,tower_http=warn,my_app=debug");
8888        levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8889
8890        // The startup per-target directives are seeded as overrides, leaving
8891        // only the bare global level in `current_level`.
8892        let seeded = levels.logger_overrides();
8893        assert_eq!(seeded.get("tower_http").map(String::as_str), Some("warn"));
8894        assert_eq!(seeded.get("my_app").map(String::as_str), Some("debug"));
8895        assert_eq!(levels.current_level(), "info");
8896
8897        // Raising the root level must NOT wipe the module-specific directives.
8898        let change = levels.set_logger_level("root", "warn");
8899        assert!(matches!(change, LogLevelChange::Applied { .. }));
8900        assert_eq!(levels.current_level(), "warn");
8901
8902        let overrides = levels.logger_overrides();
8903        assert_eq!(
8904            overrides.get("tower_http").map(String::as_str),
8905            Some("warn"),
8906            "tower_http directive must survive a root-level change"
8907        );
8908        assert_eq!(
8909            overrides.get("my_app").map(String::as_str),
8910            Some("debug"),
8911            "my_app directive must survive a root-level change"
8912        );
8913
8914        // The rebuilt live directive still carries both per-target directives.
8915        let directive = levels.rebuilt_directive_for_test();
8916        assert!(
8917            directive.contains("tower_http=warn"),
8918            "rebuilt directive dropped tower_http: {directive}"
8919        );
8920        assert!(
8921            directive.contains("my_app=debug"),
8922            "rebuilt directive dropped my_app: {directive}"
8923        );
8924    }
8925
8926    #[test]
8927    fn bare_non_level_segment_is_a_trace_target_not_the_global_level() {
8928        // `EnvFilter` semantics: in `info,my_app`, `info` is the global level
8929        // and the bare `my_app` is a *target directive at trace*, NOT the global
8930        // level. The old code took the last bare segment as the global level,
8931        // which both lost `info` and set an invalid global of `my_app`.
8932        let levels = LogLevels::new("info,my_app");
8933        assert_eq!(levels.current_level(), "info");
8934        let overrides = levels.logger_overrides();
8935        assert_eq!(
8936            overrides.get("my_app").map(String::as_str),
8937            Some("trace"),
8938            "bare non-level segment must become a trace target"
8939        );
8940
8941        // The rebuilt directive is equivalent to `info,my_app=trace`.
8942        assert_eq!(levels.rebuilt_directive_for_test(), "info,my_app=trace");
8943
8944        // A subsequent root PUT to `warn` keeps `my_app` as a target.
8945        levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8946        let change = levels.set_logger_level("root", "warn");
8947        assert!(matches!(change, LogLevelChange::Applied { .. }));
8948        assert_eq!(levels.current_level(), "warn");
8949        assert_eq!(
8950            levels.logger_overrides().get("my_app").map(String::as_str),
8951            Some("trace"),
8952            "my_app target must survive a root-level change"
8953        );
8954    }
8955
8956    #[test]
8957    fn mixed_bare_level_explicit_target_and_bare_target() {
8958        // `debug,tower_http=warn,my_app`: global=debug, explicit tower_http=warn,
8959        // and the bare `my_app` becomes a trace target.
8960        let levels = LogLevels::new("debug,tower_http=warn,my_app");
8961        assert_eq!(levels.current_level(), "debug");
8962        let overrides = levels.logger_overrides();
8963        assert_eq!(
8964            overrides.get("tower_http").map(String::as_str),
8965            Some("warn")
8966        );
8967        assert_eq!(overrides.get("my_app").map(String::as_str), Some("trace"));
8968
8969        // Targets are emitted sorted after the global level.
8970        assert_eq!(
8971            levels.rebuilt_directive_for_test(),
8972            "debug,my_app=trace,tower_http=warn"
8973        );
8974    }
8975
8976    #[test]
8977    fn purely_bare_level_config_has_no_target_overrides() {
8978        // Regression guard: a plain level stays the global level with no
8979        // spurious target overrides.
8980        let levels = LogLevels::new("info");
8981        assert_eq!(levels.current_level(), "info");
8982        assert!(levels.logger_overrides().is_empty());
8983        assert_eq!(levels.rebuilt_directive_for_test(), "info");
8984    }
8985
8986    // ── Prometheus endpoint tests ──────────────────────────────
8987
8988    #[tokio::test]
8989    async fn actuator_prometheus_returns_metrics() {
8990        let state = test_state();
8991        state.metrics().record("GET", "/test", 200, 10);
8992        state.metrics().record("POST", "/test", 500, 50);
8993
8994        let app = actuator_router(true).with_state(state);
8995        let resp = app
8996            .oneshot(
8997                Request::builder()
8998                    .uri("/actuator/prometheus")
8999                    .body(Body::empty())
9000                    .unwrap(),
9001            )
9002            .await
9003            .unwrap();
9004
9005        assert_eq!(resp.status(), StatusCode::OK);
9006        assert_eq!(
9007            resp.headers().get("content-type").unwrap(),
9008            "text/plain; version=0.0.4"
9009        );
9010
9011        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9012            .await
9013            .unwrap();
9014        let text = String::from_utf8(body.to_vec()).unwrap();
9015
9016        assert!(text.contains("# HELP autumn_http_requests_total Total number of HTTP requests"));
9017        assert!(text.contains("# TYPE autumn_http_requests_total counter"));
9018        assert!(text.contains("autumn_http_requests_total{version=\"stable\"} 2"));
9019
9020        assert!(text.contains("autumn_http_requests_active{version=\"stable\"} "));
9021        assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"2xx\"} 1"));
9022        assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"5xx\"} 1"));
9023
9024        // Latency is a histogram in seconds, labelled by version, method,
9025        // route and status class. The deprecated summary keeps the quantiles.
9026        assert!(text.contains("# TYPE autumn_http_request_duration_seconds histogram"));
9027        assert!(text.contains(
9028            "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"GET\",route=\"/test\",status_class=\"2xx\"} 1"
9029        ));
9030        assert!(text.contains(
9031            "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"POST\",route=\"/test\",status_class=\"5xx\"} 1"
9032        ));
9033        assert!(text.contains("# TYPE autumn_http_request_duration_quantiles_seconds summary"));
9034        assert!(text.contains(
9035            "autumn_http_request_duration_quantiles_seconds{version=\"stable\",quantile=\"0.99\"}"
9036        ));
9037
9038        assert!(text.contains(
9039            "autumn_http_route_requests_total{version=\"stable\",method=\"GET\",route=\"/test\"} 1"
9040        ));
9041        assert!(text.contains(
9042            "autumn_http_route_requests_total{version=\"stable\",method=\"POST\",route=\"/test\"} 1"
9043        ));
9044
9045        assert!(text.contains("# HELP autumn_request_timeouts_total"));
9046        assert!(text.contains("# TYPE autumn_request_timeouts_total counter"));
9047        assert!(text.contains("autumn_request_timeouts_total{version=\"stable\"} 0"));
9048
9049        assert!(text.contains("# HELP autumn_read_your_writes_pins_total"));
9050        assert!(text.contains("# TYPE autumn_read_your_writes_pins_total counter"));
9051        assert!(text.contains("autumn_read_your_writes_pins_total{version=\"stable\"} 0"));
9052
9053        assert!(text.contains("# HELP autumn_requests_shed_total"));
9054        assert!(text.contains("# TYPE autumn_requests_shed_total counter"));
9055        assert!(text.contains("autumn_requests_shed_total{version=\"stable\"} 0"));
9056
9057        assert!(text.contains("# TYPE autumn_admission_limit gauge"));
9058        assert!(text.contains("autumn_admission_limit{version=\"stable\"} 0"));
9059        assert!(text.contains("# TYPE autumn_admission_shed_total counter"));
9060        assert!(text.contains(
9061            "autumn_admission_shed_total{version=\"stable\",criticality=\"sheddable\"} 0"
9062        ));
9063        assert!(text.contains("# TYPE autumn_tenant_bulkhead_rejections_total counter"));
9064        assert!(text.contains(
9065            "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"request\"} 0"
9066        ));
9067        assert!(
9068            text.contains(
9069                "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"db\"} 0"
9070            )
9071        );
9072    }
9073
9074    /// Issue #3055: clones share one dead-letter trim counter.
9075    #[test]
9076    fn job_registry_dead_letter_trim_counter_is_shared() {
9077        let registry = JobRegistry::new();
9078        let clone = registry.clone();
9079        assert_eq!(registry.dead_letter_trimmed_total(), 0);
9080
9081        clone.record_dead_letter_trimmed(3);
9082        registry.record_dead_letter_trimmed(0);
9083        registry.record_dead_letter_trimmed(2);
9084        assert_eq!(registry.dead_letter_trimmed_total(), 5);
9085        assert_eq!(clone.dead_letter_trimmed_total(), 5);
9086    }
9087
9088    /// Issue #3055: the trim counter is a built-in Prometheus family.
9089    #[tokio::test]
9090    async fn prometheus_includes_dead_letter_trimmed_counter() {
9091        const NAME: &str = "autumn_jobs_dead_letter_trimmed_total";
9092        assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&NAME));
9093
9094        let state = test_state();
9095        let scrape = |state: TestActuatorState| async move {
9096            let resp = actuator_router(true)
9097                .with_state(state)
9098                .oneshot(
9099                    Request::builder()
9100                        .uri("/actuator/prometheus")
9101                        .body(Body::empty())
9102                        .unwrap(),
9103                )
9104                .await
9105                .unwrap();
9106            assert_eq!(resp.status(), StatusCode::OK);
9107            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9108                .await
9109                .unwrap();
9110            String::from_utf8(body.to_vec()).unwrap()
9111        };
9112
9113        // A zero sample exists before the first trim, so an alert can use rate().
9114        let text = scrape(state.clone()).await;
9115        assert!(text.contains(&format!("# TYPE {NAME} counter")), "{text}");
9116        assert!(
9117            text.contains(&format!("{NAME}{{version=\"stable\"}} 0\n")),
9118            "{text}"
9119        );
9120
9121        state.job_registry.record_dead_letter_trimmed(7);
9122        let text = scrape(state).await;
9123        assert!(
9124            text.contains(&format!("{NAME}{{version=\"stable\"}} 7\n")),
9125            "{text}"
9126        );
9127    }
9128
9129    #[cfg(feature = "cache-moka")]
9130    #[tokio::test]
9131    async fn prometheus_includes_cache_read_through_counters() {
9132        // read_through_metrics() is a process-wide singleton shared with other
9133        // tests in this crate; assert presence and a monotonic delta rather
9134        // than an absolute value.
9135        let before = crate::cache::read_through_metrics().snapshot();
9136        let cache: std::sync::Arc<dyn crate::cache::Cache> =
9137            std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
9138        let key = format!("actuator-prometheus-cache-test-{before:?}");
9139        let _: i32 =
9140            crate::cache::get_or_compute(
9141                &cache,
9142                &key,
9143                None,
9144                || async move { Ok::<i32, String>(1) },
9145            )
9146            .await
9147            .unwrap();
9148        let after = crate::cache::read_through_metrics().snapshot();
9149        assert!(after.fills > before.fills, "fills counter must increase");
9150
9151        let state = test_state();
9152        let app = actuator_router(true).with_state(state);
9153        let resp = app
9154            .oneshot(
9155                Request::builder()
9156                    .uri("/actuator/prometheus")
9157                    .body(Body::empty())
9158                    .unwrap(),
9159            )
9160            .await
9161            .unwrap();
9162
9163        assert_eq!(resp.status(), StatusCode::OK);
9164        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9165            .await
9166            .unwrap();
9167        let text = String::from_utf8(body.to_vec()).unwrap();
9168
9169        for name in [
9170            "autumn_cache_read_through_hits_total",
9171            "autumn_cache_read_through_misses_total",
9172            "autumn_cache_read_through_coalesced_waits_total",
9173            "autumn_cache_read_through_fills_total",
9174            "autumn_cache_read_through_fill_failures_total",
9175            "autumn_cache_read_through_stale_serves_total",
9176            "autumn_cache_fill_lock_acquires_total",
9177            "autumn_cache_fill_lock_contended_total",
9178            "autumn_cache_read_through_stale_if_error_serves_total",
9179            "autumn_cache_invalidation_failures_total",
9180        ] {
9181            assert!(
9182                text.contains(&format!("# TYPE {name} counter")),
9183                "missing TYPE line for {name} in:\n{text}"
9184            );
9185            assert!(
9186                text.contains(&format!("{name}{{version=\"stable\"}}")),
9187                "missing value line for {name} in:\n{text}"
9188            );
9189        }
9190    }
9191
9192    #[cfg(feature = "cache-moka")]
9193    #[tokio::test]
9194    async fn metrics_json_includes_cache_section() {
9195        let cache: std::sync::Arc<dyn crate::cache::Cache> =
9196            std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
9197        let key = "actuator-metrics-json-cache-test";
9198        let _: i32 =
9199            crate::cache::get_or_compute(&cache, key, None, || async move { Ok::<i32, String>(1) })
9200                .await
9201                .unwrap();
9202
9203        let state = test_state();
9204        let app = actuator_router(true).with_state(state);
9205        let resp = app
9206            .oneshot(
9207                Request::builder()
9208                    .uri("/actuator/metrics")
9209                    .body(Body::empty())
9210                    .unwrap(),
9211            )
9212            .await
9213            .unwrap();
9214
9215        assert_eq!(resp.status(), StatusCode::OK);
9216        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9217            .await
9218            .unwrap();
9219        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9220        assert!(json["cache"]["fills"].as_u64().unwrap() >= 1);
9221        assert!(json["cache"]["hits"].is_u64());
9222        assert!(json["cache"]["misses"].is_u64());
9223        assert!(json["cache"]["coalesced_waits"].is_u64());
9224        assert!(json["cache"]["fill_failures"].is_u64());
9225        assert!(json["cache"]["stale_serves"].is_u64());
9226        assert!(json["cache"]["fill_lock_acquires"].is_u64());
9227        assert!(json["cache"]["fill_lock_contended"].is_u64());
9228        assert!(json["cache"]["stale_if_error_serves"].is_u64());
9229        assert!(json["cache"]["invalidation_failures"].is_u64());
9230    }
9231
9232    #[tokio::test]
9233    async fn actuator_prometheus_labels_metrics_with_canary_version() {
9234        // A replica whose deploy_version() is "canary" must tag its metric
9235        // families with version="canary" so a controller can compare cohorts.
9236        let mut state = test_state();
9237        state.deploy_version = crate::canary::CANARY.to_owned();
9238        // Latencies in ms: spread so p50 < p95/p99 and the slowest is 1200 ms.
9239        state.metrics().record("GET", "/test", 200, 10);
9240        state.metrics().record("GET", "/test", 200, 20);
9241        state.metrics().record("GET", "/test", 500, 1200);
9242
9243        let app = actuator_router(true).with_state(state);
9244        let resp = app
9245            .oneshot(
9246                Request::builder()
9247                    .uri("/actuator/prometheus")
9248                    .body(Body::empty())
9249                    .unwrap(),
9250            )
9251            .await
9252            .unwrap();
9253        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9254            .await
9255            .unwrap();
9256        let text = String::from_utf8(body.to_vec()).unwrap();
9257
9258        assert!(text.contains("autumn_http_requests_total{version=\"canary\"} 3"));
9259        assert!(text.contains("autumn_http_responses_total{version=\"canary\",status=\"5xx\"} 1"));
9260        // Must not leak the default "stable" label when running as canary.
9261        assert!(!text.contains("version=\"stable\""));
9262
9263        // Verify the percentile math: values are reported in seconds (ms / 1000)
9264        // and satisfy the quantile invariant p50 <= p95 <= p99.
9265        let quantile = |q: &str| -> f64 {
9266            let needle = format!(
9267                "autumn_http_request_duration_quantiles_seconds{{version=\"canary\",quantile=\"{q}\"}} "
9268            );
9269            let line = text
9270                .lines()
9271                .find(|l| l.starts_with(&needle))
9272                .unwrap_or_else(|| panic!("missing duration line for quantile {q}"));
9273            line[needle.len()..].trim().parse().unwrap()
9274        };
9275        let (p50, p95, p99) = (quantile("0.5"), quantile("0.95"), quantile("0.99"));
9276        assert!(p50 <= p95, "p50 ({p50}) must be <= p95 ({p95})");
9277        assert!(p95 <= p99, "p95 ({p95}) must be <= p99 ({p99})");
9278        // Slowest sample was 1200 ms, so the top quantile must read 1.2 seconds.
9279        assert!(
9280            (p99 - 1.2).abs() < f64::EPSILON,
9281            "p99 should be 1.2s, got {p99}"
9282        );
9283    }
9284
9285    #[tokio::test]
9286    async fn actuator_prometheus_available_in_nonsensitive_mode() {
9287        let app = actuator_router(false).with_state(test_state());
9288        let resp = app
9289            .oneshot(
9290                Request::builder()
9291                    .uri("/actuator/prometheus")
9292                    .body(Body::empty())
9293                    .unwrap(),
9294            )
9295            .await
9296            .unwrap();
9297        assert_eq!(resp.status(), StatusCode::OK);
9298    }
9299
9300    #[tokio::test]
9301    async fn actuator_prometheus_available_when_export_enabled_and_nonsensitive() {
9302        // Metrics export decoupled from sensitive: prometheus is reachable even
9303        // though sensitive endpoints (env/configprops/loggers/tasks) are not.
9304        let app = actuator_router_with_prefix("/actuator", false, true).with_state(test_state());
9305        let resp = app
9306            .clone()
9307            .oneshot(
9308                Request::builder()
9309                    .uri("/actuator/prometheus")
9310                    .body(Body::empty())
9311                    .unwrap(),
9312            )
9313            .await
9314            .unwrap();
9315        assert_eq!(resp.status(), StatusCode::OK);
9316
9317        // Sensitive surfaces stay closed under the non-sensitive metrics config.
9318        for sensitive_path in [
9319            "/actuator/env",
9320            "/actuator/configprops",
9321            "/actuator/loggers",
9322            "/actuator/tasks",
9323            "/actuator/jobs",
9324            "/actuator/ui/tasks",
9325        ] {
9326            let resp = app
9327                .clone()
9328                .oneshot(
9329                    Request::builder()
9330                        .uri(sensitive_path)
9331                        .body(Body::empty())
9332                        .unwrap(),
9333                )
9334                .await
9335                .unwrap();
9336            assert_eq!(
9337                resp.status(),
9338                StatusCode::NOT_FOUND,
9339                "{sensitive_path} should be unavailable when actuator is non-sensitive"
9340            );
9341        }
9342    }
9343
9344    #[tokio::test]
9345    async fn actuator_prometheus_unavailable_when_export_disabled() {
9346        // Regression: with metrics export disabled, the scrape endpoint is gone.
9347        let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
9348        let resp = app
9349            .oneshot(
9350                Request::builder()
9351                    .uri("/actuator/prometheus")
9352                    .body(Body::empty())
9353                    .unwrap(),
9354            )
9355            .await
9356            .unwrap();
9357        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9358    }
9359
9360    #[tokio::test]
9361    async fn actuator_prometheus_unavailable_when_export_disabled_even_if_sensitive() {
9362        // Disabling export wins even when sensitive endpoints are enabled.
9363        let app = actuator_router_with_prefix("/actuator", true, false).with_state(test_state());
9364        let resp = app
9365            .oneshot(
9366                Request::builder()
9367                    .uri("/actuator/prometheus")
9368                    .body(Body::empty())
9369                    .unwrap(),
9370            )
9371            .await
9372            .unwrap();
9373        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9374    }
9375
9376    #[test]
9377    fn actuator_endpoint_paths_respects_prometheus_toggle() {
9378        let enabled = actuator_endpoint_paths("/actuator", false, true);
9379        assert!(
9380            enabled.iter().any(|p| p == "/actuator/prometheus"),
9381            "prometheus path should be listed when export is enabled: {enabled:?}"
9382        );
9383
9384        let disabled = actuator_endpoint_paths("/actuator", false, false);
9385        assert!(
9386            !disabled.iter().any(|p| p == "/actuator/prometheus"),
9387            "prometheus path should be absent when export is disabled: {disabled:?}"
9388        );
9389    }
9390
9391    // ── Tasks endpoint tests ───────────────────────────────────
9392
9393    #[tokio::test]
9394    async fn actuator_tasks_returns_registered_tasks() {
9395        let state = test_state();
9396        state.task_registry().register("cleanup", "every 5m");
9397        state.task_registry().record_start("cleanup");
9398        state.task_registry().record_success("cleanup", 150);
9399
9400        let app = actuator_router(true).with_state(state);
9401        let resp = app
9402            .oneshot(
9403                Request::builder()
9404                    .uri("/actuator/tasks")
9405                    .body(Body::empty())
9406                    .unwrap(),
9407            )
9408            .await
9409            .unwrap();
9410
9411        assert_eq!(resp.status(), StatusCode::OK);
9412        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9413            .await
9414            .unwrap();
9415        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9416        let task = &json["scheduled_tasks"]["cleanup"];
9417        assert_eq!(task["schedule"], "every 5m");
9418        assert_eq!(task["status"], "idle");
9419        assert_eq!(task["total_runs"], 1);
9420        assert_eq!(task["total_failures"], 0);
9421        assert_eq!(task["last_result"], "ok");
9422        assert_eq!(task["last_duration_ms"], 150);
9423    }
9424
9425    #[tokio::test]
9426    async fn actuator_jobs_returns_registered_jobs() {
9427        let state = test_state();
9428        state.job_registry().register("send_email");
9429        state.job_registry().record_enqueue("send_email");
9430        state.job_registry().record_start("send_email");
9431        state.job_registry().record_success("send_email");
9432
9433        let app = actuator_router(true).with_state(state);
9434        let resp = app
9435            .oneshot(
9436                Request::builder()
9437                    .uri("/actuator/jobs")
9438                    .body(Body::empty())
9439                    .unwrap(),
9440            )
9441            .await
9442            .unwrap();
9443
9444        assert_eq!(resp.status(), StatusCode::OK);
9445        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9446            .await
9447            .unwrap();
9448        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9449        let job = &json["jobs"]["send_email"];
9450        assert_eq!(job["queued"], 0);
9451        assert_eq!(job["in_flight"], 0);
9452        assert_eq!(job["total_successes"], 1);
9453        assert_eq!(job["total_failures"], 0);
9454    }
9455
9456    #[cfg(feature = "ws")]
9457    #[tokio::test]
9458    async fn actuator_channels_returns_metrics() {
9459        let state = test_state();
9460        let mut rx = state.channels().subscribe("feed");
9461        state
9462            .channels()
9463            .broadcast()
9464            .publish("feed", "hello")
9465            .expect("publish should succeed");
9466        rx.try_recv().expect("subscriber should receive payload");
9467
9468        let app = actuator_router(true).with_state(state);
9469        let resp = app
9470            .oneshot(
9471                Request::builder()
9472                    .uri("/actuator/channels")
9473                    .body(Body::empty())
9474                    .unwrap(),
9475            )
9476            .await
9477            .unwrap();
9478
9479        assert_eq!(resp.status(), StatusCode::OK);
9480        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9481            .await
9482            .unwrap();
9483        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9484        let feed = &json["channels"]["feed"];
9485        assert_eq!(feed["subscriber_count"], 1);
9486        assert_eq!(feed["lifetime_publish_count"], 1);
9487        assert_eq!(feed["dropped_count"], 0);
9488        assert_eq!(feed["lagged_count"], 0);
9489    }
9490
9491    #[tokio::test]
9492    async fn actuator_tasks_hidden_in_nonsensitive_mode() {
9493        let app = actuator_router(false).with_state(test_state());
9494        let resp = app
9495            .oneshot(
9496                Request::builder()
9497                    .uri("/actuator/tasks")
9498                    .body(Body::empty())
9499                    .unwrap(),
9500            )
9501            .await
9502            .unwrap();
9503        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9504    }
9505
9506    #[test]
9507    fn task_registry_records_failure() {
9508        let registry = TaskRegistry::new();
9509        registry.register("my_task", "cron 0 * * * *");
9510        registry.record_start("my_task");
9511        registry.record_failure("my_task", 200, "connection refused");
9512
9513        let snapshot = registry.snapshot();
9514        let task = &snapshot["my_task"];
9515        assert_eq!(task.status, "idle");
9516        assert_eq!(task.total_runs, 1);
9517        assert_eq!(task.total_failures, 1);
9518        assert_eq!(task.last_result.as_deref(), Some("failed"));
9519        assert_eq!(task.last_error.as_deref(), Some("connection refused"));
9520    }
9521
9522    #[test]
9523    fn task_registry_empty_snapshot() {
9524        let registry = TaskRegistry::new();
9525        assert!(registry.snapshot().is_empty());
9526    }
9527    #[test]
9528    fn log_levels_rejects_new_key_at_capacity() {
9529        let levels = LogLevels::new("info");
9530        // Fill to capacity
9531        for i in 0..1000 {
9532            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9533        }
9534
9535        // Try to add a new key, should be rejected
9536        let result = levels.set_logger_level("logger_1000", "warn");
9537        assert!(matches!(result, LogLevelChange::Rejected { .. }));
9538        assert_eq!(levels.logger_overrides().len(), 1000);
9539        assert_eq!(levels.logger_overrides().get("logger_1000"), None);
9540    }
9541
9542    #[test]
9543    fn log_levels_accepts_existing_key_at_capacity() {
9544        let levels = LogLevels::new("info");
9545        // Fill to capacity
9546        for i in 0..1000 {
9547            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9548        }
9549
9550        // Try to update an existing key, should succeed
9551        let change = levels.set_logger_level("logger_999", "warn");
9552        assert_eq!(change.previous(), Some("debug"));
9553        assert_eq!(levels.logger_overrides().len(), 1000);
9554        assert_eq!(
9555            levels
9556                .logger_overrides()
9557                .get("logger_999")
9558                .map(String::as_str),
9559            Some("warn")
9560        );
9561    }
9562
9563    #[test]
9564    fn task_registry_records_multiple_successes_and_failures() {
9565        let registry = TaskRegistry::new();
9566        registry.register("my_task", "cron * * * * *");
9567
9568        // 1st success
9569        registry.record_start("my_task");
9570        registry.record_success("my_task", 100);
9571
9572        // 2nd success
9573        registry.record_start("my_task");
9574        registry.record_success("my_task", 110);
9575
9576        let snapshot = registry.snapshot();
9577        let task = &snapshot["my_task"];
9578        assert_eq!(task.total_runs, 2);
9579        assert_eq!(task.total_failures, 0);
9580
9581        // 1st failure
9582        registry.record_start("my_task");
9583        registry.record_failure("my_task", 50, "failed");
9584
9585        let snapshot2 = registry.snapshot();
9586        let task2 = &snapshot2["my_task"];
9587        assert_eq!(task2.total_runs, 3);
9588        assert_eq!(task2.total_failures, 1);
9589    }
9590
9591    #[test]
9592    fn configprops_tracks_custom_profile() {
9593        let mut props = HashMap::new();
9594        ConfigProperties::track_property(
9595            &mut props,
9596            "log.level",
9597            "debug",
9598            "info",
9599            "custom_profile",
9600        );
9601        assert_eq!(props["log.level"].source, "autumn.toml");
9602    }
9603
9604    #[test]
9605    fn configprops_tracks_dev_prod_profiles() {
9606        let mut props = HashMap::new();
9607        ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
9608        assert_eq!(props["log.level"].source, "profile_default:dev");
9609
9610        ConfigProperties::track_property(&mut props, "log.format", "json", "text", "prod");
9611        assert_eq!(props["log.format"].source, "profile_default:prod");
9612    }
9613
9614    #[test]
9615    fn configprops_returns_default_when_values_match() {
9616        let mut props = HashMap::new();
9617        ConfigProperties::track_property(&mut props, "log.level", "info", "info", "dev");
9618        assert_eq!(props["log.level"].source, "default");
9619    }
9620
9621    #[tokio::test]
9622    async fn actuator_ui_dashboard_returns_html_or_unimplemented() {
9623        let app = actuator_router(true).with_state(test_state());
9624
9625        let res = app
9626            .oneshot(
9627                Request::builder()
9628                    .uri("/actuator/ui")
9629                    .body(Body::empty())
9630                    .unwrap(),
9631            )
9632            .await
9633            .unwrap();
9634
9635        if cfg!(feature = "maud") {
9636            assert_eq!(res.status(), StatusCode::OK);
9637            assert_eq!(
9638                res.headers().get("content-type").unwrap(),
9639                "text/html; charset=utf-8"
9640            );
9641        } else {
9642            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9643        }
9644    }
9645
9646    #[tokio::test]
9647    async fn actuator_ui_metrics_returns_html_or_unimplemented() {
9648        let app = actuator_router(true).with_state(test_state());
9649
9650        let res = app
9651            .oneshot(
9652                Request::builder()
9653                    .uri("/actuator/ui/metrics")
9654                    .body(Body::empty())
9655                    .unwrap(),
9656            )
9657            .await
9658            .unwrap();
9659
9660        if cfg!(feature = "maud") {
9661            assert_eq!(res.status(), StatusCode::OK);
9662            assert_eq!(
9663                res.headers().get("content-type").unwrap(),
9664                "text/html; charset=utf-8"
9665            );
9666        } else {
9667            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9668        }
9669    }
9670
9671    #[tokio::test]
9672    async fn actuator_ui_tasks_returns_html_or_unimplemented() {
9673        let app = actuator_router(true).with_state(test_state());
9674
9675        let res = app
9676            .oneshot(
9677                Request::builder()
9678                    .uri("/actuator/ui/tasks")
9679                    .body(Body::empty())
9680                    .unwrap(),
9681            )
9682            .await
9683            .unwrap();
9684
9685        if cfg!(feature = "maud") {
9686            assert_eq!(res.status(), StatusCode::OK);
9687            assert_eq!(
9688                res.headers().get("content-type").unwrap(),
9689                "text/html; charset=utf-8"
9690            );
9691        } else {
9692            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9693        }
9694    }
9695
9696    #[tokio::test]
9697    async fn test_actuator_router_calls_prefix_variant() {
9698        // The `actuator_router` function is a convenience wrapper around `actuator_router_with_prefix`
9699        // using "/actuator" as the prefix. We can test it by building it and hitting one of the endpoints.
9700        let app = actuator_router(false).with_state(test_state());
9701        let resp = app
9702            .oneshot(
9703                Request::builder()
9704                    .uri("/actuator/health")
9705                    .body(Body::empty())
9706                    .unwrap(),
9707            )
9708            .await
9709            .unwrap();
9710
9711        assert_eq!(resp.status(), StatusCode::OK);
9712    }
9713
9714    // ── RED: /actuator/a11y endpoint ───────────────────────────────
9715
9716    #[tokio::test]
9717    async fn actuator_a11y_returns_posture_json() {
9718        let app = actuator_router(false).with_state(test_state());
9719        let resp = app
9720            .oneshot(
9721                Request::builder()
9722                    .uri("/actuator/a11y")
9723                    .body(Body::empty())
9724                    .unwrap(),
9725            )
9726            .await
9727            .unwrap();
9728
9729        assert_eq!(resp.status(), StatusCode::OK);
9730        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9731            .await
9732            .unwrap();
9733        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9734        assert!(json["lang_set"].is_boolean(), "{json}");
9735        assert!(json["skip_link_present"].is_boolean(), "{json}");
9736        assert!(json["landmark_regions_present"].is_boolean(), "{json}");
9737    }
9738
9739    #[tokio::test]
9740    async fn actuator_a11y_available_in_nonsensitive_mode() {
9741        let app = actuator_router(false).with_state(test_state());
9742        let resp = app
9743            .oneshot(
9744                Request::builder()
9745                    .uri("/actuator/a11y")
9746                    .body(Body::empty())
9747                    .unwrap(),
9748            )
9749            .await
9750            .unwrap();
9751        assert_eq!(resp.status(), StatusCode::OK);
9752    }
9753
9754    #[tokio::test]
9755    async fn actuator_a11y_posture_default_values() {
9756        let app = actuator_router(true).with_state(test_state());
9757        let resp = app
9758            .oneshot(
9759                Request::builder()
9760                    .uri("/actuator/a11y")
9761                    .body(Body::empty())
9762                    .unwrap(),
9763            )
9764            .await
9765            .unwrap();
9766
9767        assert_eq!(resp.status(), StatusCode::OK);
9768        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9769            .await
9770            .unwrap();
9771        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9772        // Default test state should report false for all posture fields
9773        assert_eq!(json["lang_set"], false, "{json}");
9774        assert_eq!(json["skip_link_present"], false, "{json}");
9775        assert_eq!(json["landmark_regions_present"], false, "{json}");
9776    }
9777
9778    #[test]
9779    fn a11y_posture_all_passing_is_compliant() {
9780        let posture = A11yPosture {
9781            lang_set: true,
9782            skip_link_present: true,
9783            landmark_regions_present: true,
9784        };
9785        assert!(posture.is_compliant());
9786    }
9787
9788    #[test]
9789    fn a11y_posture_missing_lang_is_not_compliant() {
9790        let posture = A11yPosture {
9791            lang_set: false,
9792            skip_link_present: true,
9793            landmark_regions_present: true,
9794        };
9795        assert!(!posture.is_compliant());
9796    }
9797
9798    #[tokio::test]
9799    async fn actuator_a11y_endpoint_paths_includes_a11y() {
9800        let paths = actuator_endpoint_paths("/actuator", false, true);
9801        assert!(
9802            paths.iter().any(|p| p == "/actuator/a11y"),
9803            "a11y path not found in: {paths:?}"
9804        );
9805    }
9806
9807    // ── MetricsSource / MetricsSourceRegistry tests ────────────
9808
9809    #[test]
9810    fn metrics_source_registry_registers_and_collects() {
9811        struct FixedSource;
9812        impl MetricsSource for FixedSource {
9813            fn collect(&self) -> Vec<MetricFamily> {
9814                vec![MetricFamily {
9815                    name: "plugin_requests_total".to_string(),
9816                    help: "Plugin request count".to_string(),
9817                    kind: MetricKind::Counter,
9818                    samples: vec![MetricSample {
9819                        labels: vec![],
9820                        value: 42.0,
9821                    }],
9822                }]
9823            }
9824        }
9825
9826        let registry = MetricsSourceRegistry::new();
9827        registry
9828            .register("myplugin", Arc::new(FixedSource))
9829            .unwrap();
9830
9831        let all = registry.collect_all();
9832        assert_eq!(all.len(), 1);
9833        assert_eq!(all[0].0, "myplugin");
9834        assert_eq!(all[0].1[0].name, "plugin_requests_total");
9835        assert!((all[0].1[0].samples[0].value - 42.0).abs() < f64::EPSILON);
9836    }
9837
9838    #[test]
9839    fn metrics_source_registry_rejects_duplicate_name() {
9840        struct EmptySource;
9841        impl MetricsSource for EmptySource {
9842            fn collect(&self) -> Vec<MetricFamily> {
9843                vec![]
9844            }
9845        }
9846
9847        let registry = MetricsSourceRegistry::new();
9848        registry.register("dup", Arc::new(EmptySource)).unwrap();
9849        let result = registry.register("dup", Arc::new(EmptySource));
9850        assert!(result.is_err());
9851        assert!(result.unwrap_err().contains("dup"));
9852    }
9853
9854    #[test]
9855    fn metrics_source_registry_isolates_panicking_source() {
9856        struct PanickingSource;
9857        impl MetricsSource for PanickingSource {
9858            fn collect(&self) -> Vec<MetricFamily> {
9859                panic!("source panicked!")
9860            }
9861        }
9862
9863        let registry = MetricsSourceRegistry::new();
9864        registry
9865            .register("panicker", Arc::new(PanickingSource))
9866            .unwrap();
9867
9868        let all = registry.collect_all();
9869        assert_eq!(all.len(), 1);
9870        assert_eq!(
9871            all[0].1.len(),
9872            0,
9873            "panicking source should yield no families"
9874        );
9875
9876        let errors = registry.error_counts();
9877        assert_eq!(errors.get("panicker"), Some(&1));
9878    }
9879
9880    #[tokio::test]
9881    async fn prometheus_endpoint_includes_plugin_source_families() {
9882        struct GaugeSource;
9883        impl MetricsSource for GaugeSource {
9884            fn collect(&self) -> Vec<MetricFamily> {
9885                vec![MetricFamily {
9886                    name: "plugin_queue_depth".to_string(),
9887                    help: "Plugin queue depth".to_string(),
9888                    kind: MetricKind::Gauge,
9889                    samples: vec![MetricSample {
9890                        labels: vec![("shard".to_string(), "a".to_string())],
9891                        value: 7.0,
9892                    }],
9893                }]
9894            }
9895        }
9896
9897        let state = test_state();
9898        state
9899            .metrics_source_registry
9900            .register("gauge_plugin", Arc::new(GaugeSource))
9901            .unwrap();
9902
9903        let app = actuator_router(true).with_state(state);
9904        let resp = app
9905            .oneshot(
9906                Request::builder()
9907                    .uri("/actuator/prometheus")
9908                    .body(Body::empty())
9909                    .unwrap(),
9910            )
9911            .await
9912            .unwrap();
9913
9914        assert_eq!(resp.status(), StatusCode::OK);
9915        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9916            .await
9917            .unwrap();
9918        let text = String::from_utf8(body.to_vec()).unwrap();
9919
9920        assert!(
9921            text.contains("# HELP plugin_queue_depth Plugin queue depth"),
9922            "missing HELP line in:\n{text}"
9923        );
9924        assert!(
9925            text.contains("# TYPE plugin_queue_depth gauge"),
9926            "missing TYPE line in:\n{text}"
9927        );
9928        assert!(
9929            text.contains("plugin_queue_depth{shard=\"a\"} 7"),
9930            "missing sample line in:\n{text}"
9931        );
9932    }
9933
9934    #[tokio::test]
9935    async fn prometheus_endpoint_emits_error_counter_for_panicking_source() {
9936        struct PanickingSource;
9937        impl MetricsSource for PanickingSource {
9938            fn collect(&self) -> Vec<MetricFamily> {
9939                panic!("oops")
9940            }
9941        }
9942
9943        let state = test_state();
9944        state
9945            .metrics_source_registry
9946            .register("panic_src", Arc::new(PanickingSource))
9947            .unwrap();
9948
9949        let app = actuator_router(true).with_state(state);
9950        let resp = app
9951            .oneshot(
9952                Request::builder()
9953                    .uri("/actuator/prometheus")
9954                    .body(Body::empty())
9955                    .unwrap(),
9956            )
9957            .await
9958            .unwrap();
9959
9960        assert_eq!(resp.status(), StatusCode::OK);
9961        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9962            .await
9963            .unwrap();
9964        let text = String::from_utf8(body.to_vec()).unwrap();
9965
9966        assert!(
9967            text.contains("autumn_metrics_source_errors_total{source=\"panic_src\"} 1"),
9968            "missing error counter in:\n{text}"
9969        );
9970    }
9971
9972    #[tokio::test]
9973    async fn metrics_endpoint_includes_sources_section() {
9974        struct SampleSource;
9975        impl MetricsSource for SampleSource {
9976            fn collect(&self) -> Vec<MetricFamily> {
9977                vec![MetricFamily {
9978                    name: "custom_counter".to_string(),
9979                    help: "A custom counter".to_string(),
9980                    kind: MetricKind::Counter,
9981                    samples: vec![MetricSample {
9982                        labels: vec![],
9983                        value: 5.0,
9984                    }],
9985                }]
9986            }
9987        }
9988
9989        let state = test_state();
9990        state
9991            .metrics_source_registry
9992            .register("my_source", Arc::new(SampleSource))
9993            .unwrap();
9994
9995        let app = actuator_router(true).with_state(state);
9996        let resp = app
9997            .oneshot(
9998                Request::builder()
9999                    .uri("/actuator/metrics")
10000                    .body(Body::empty())
10001                    .unwrap(),
10002            )
10003            .await
10004            .unwrap();
10005
10006        assert_eq!(resp.status(), StatusCode::OK);
10007        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10008            .await
10009            .unwrap();
10010        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
10011
10012        assert!(
10013            json.get("sources").is_some(),
10014            "metrics JSON missing 'sources' key"
10015        );
10016        assert!(
10017            json["sources"].get("my_source").is_some(),
10018            "sources missing 'my_source' key"
10019        );
10020    }
10021
10022    #[test]
10023    fn metrics_source_registry_preserves_insertion_order() {
10024        struct NamedSource(&'static str);
10025        impl MetricsSource for NamedSource {
10026            fn collect(&self) -> Vec<MetricFamily> {
10027                vec![MetricFamily {
10028                    name: self.0.to_string(),
10029                    help: String::new(),
10030                    kind: MetricKind::Counter,
10031                    samples: vec![],
10032                }]
10033            }
10034        }
10035
10036        let registry = MetricsSourceRegistry::new();
10037        registry
10038            .register("alpha", Arc::new(NamedSource("alpha_metric")))
10039            .unwrap();
10040        registry
10041            .register("beta", Arc::new(NamedSource("beta_metric")))
10042            .unwrap();
10043        registry
10044            .register("gamma", Arc::new(NamedSource("gamma_metric")))
10045            .unwrap();
10046
10047        let all = registry.collect_all();
10048        assert_eq!(all[0].0, "alpha");
10049        assert_eq!(all[1].0, "beta");
10050        assert_eq!(all[2].0, "gamma");
10051    }
10052
10053    // ── render_plugin_sources edge-case coverage ──────────────────────────
10054
10055    #[test]
10056    fn escape_help_text_escapes_backslash_and_newline() {
10057        assert_eq!(escape_help_text("a\\b\nc"), "a\\\\b\\nc");
10058        assert_eq!(escape_help_text("plain"), "plain");
10059        assert_eq!(escape_help_text(""), "");
10060    }
10061
10062    #[test]
10063    fn format_sample_value_handles_special_floats() {
10064        assert_eq!(format_sample_value(f64::INFINITY), "+Inf");
10065        assert_eq!(format_sample_value(f64::NEG_INFINITY), "-Inf");
10066        assert_eq!(format_sample_value(f64::NAN), "NaN");
10067        assert_eq!(format_sample_value(0.0), "0");
10068        assert_eq!(format_sample_value(1.5), "1.5");
10069    }
10070
10071    #[test]
10072    fn is_valid_metric_name_accepts_valid_and_rejects_invalid() {
10073        assert!(is_valid_metric_name("http_requests_total"));
10074        assert!(is_valid_metric_name("_private"));
10075        assert!(is_valid_metric_name("ns:metric"));
10076        assert!(!is_valid_metric_name(""));
10077        assert!(!is_valid_metric_name("0starts_with_digit"));
10078        assert!(!is_valid_metric_name("has-hyphen"));
10079    }
10080
10081    #[test]
10082    fn is_valid_label_name_accepts_valid_and_rejects_invalid() {
10083        assert!(is_valid_label_name("shard"));
10084        assert!(is_valid_label_name("_internal"));
10085        assert!(is_valid_label_name("a1"));
10086        assert!(!is_valid_label_name(""));
10087        assert!(!is_valid_label_name("0starts_digit"));
10088        assert!(!is_valid_label_name("has-hyphen"));
10089        assert!(!is_valid_label_name("has.dot"));
10090    }
10091
10092    #[tokio::test]
10093    async fn prometheus_endpoint_skips_family_with_invalid_metric_name() {
10094        struct BadNameSource;
10095        impl MetricsSource for BadNameSource {
10096            fn collect(&self) -> Vec<MetricFamily> {
10097                vec![
10098                    MetricFamily {
10099                        name: "invalid-name".to_string(),
10100                        help: "should be skipped".to_string(),
10101                        kind: MetricKind::Counter,
10102                        samples: vec![],
10103                    },
10104                    MetricFamily {
10105                        name: "valid_name".to_string(),
10106                        help: "should appear".to_string(),
10107                        kind: MetricKind::Counter,
10108                        samples: vec![MetricSample {
10109                            labels: vec![],
10110                            value: 1.0,
10111                        }],
10112                    },
10113                ]
10114            }
10115        }
10116
10117        let state = test_state();
10118        state
10119            .metrics_source_registry
10120            .register("bad_name_src", Arc::new(BadNameSource))
10121            .unwrap();
10122
10123        let app = actuator_router(true).with_state(state);
10124        let resp = app
10125            .oneshot(
10126                Request::builder()
10127                    .uri("/actuator/prometheus")
10128                    .body(Body::empty())
10129                    .unwrap(),
10130            )
10131            .await
10132            .unwrap();
10133        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10134            .await
10135            .unwrap();
10136        let text = String::from_utf8(body.to_vec()).unwrap();
10137
10138        assert!(
10139            !text.contains("invalid-name"),
10140            "invalid family must be skipped:\n{text}"
10141        );
10142        assert!(
10143            text.contains("valid_name"),
10144            "valid family must appear:\n{text}"
10145        );
10146    }
10147
10148    #[tokio::test]
10149    async fn prometheus_endpoint_skips_sample_with_invalid_label_key() {
10150        // A sample containing an invalid label key (bad-key) must be skipped
10151        // entirely — not emitted with the bad key dropped — to avoid creating
10152        // a phantom duplicate series in the Prometheus scrape.
10153        struct DirtyLabelsSource;
10154        impl MetricsSource for DirtyLabelsSource {
10155            fn collect(&self) -> Vec<MetricFamily> {
10156                vec![MetricFamily {
10157                    name: "dirty_labels_metric".to_string(),
10158                    help: "test".to_string(),
10159                    kind: MetricKind::Counter,
10160                    samples: vec![
10161                        MetricSample {
10162                            labels: vec![
10163                                ("good".to_string(), "a".to_string()),
10164                                ("bad-key".to_string(), "b".to_string()),
10165                            ],
10166                            value: 1.0,
10167                        },
10168                        MetricSample {
10169                            labels: vec![("good".to_string(), "a".to_string())],
10170                            value: 2.0,
10171                        },
10172                    ],
10173                }]
10174            }
10175        }
10176
10177        let state = test_state();
10178        state
10179            .metrics_source_registry
10180            .register("dirty", Arc::new(DirtyLabelsSource))
10181            .unwrap();
10182
10183        let app = actuator_router(true).with_state(state);
10184        let resp = app
10185            .oneshot(
10186                Request::builder()
10187                    .uri("/actuator/prometheus")
10188                    .body(Body::empty())
10189                    .unwrap(),
10190            )
10191            .await
10192            .unwrap();
10193        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10194            .await
10195            .unwrap();
10196        let text = String::from_utf8(body.to_vec()).unwrap();
10197
10198        // First sample (bad-key) must be absent entirely
10199        assert!(
10200            !text.contains("dirty_labels_metric{good=\"a\"} 1"),
10201            "sample with invalid label key must be skipped:\n{text}"
10202        );
10203        // Second (clean) sample must still appear
10204        assert!(
10205            text.contains("dirty_labels_metric{good=\"a\"} 2"),
10206            "clean sample must appear:\n{text}"
10207        );
10208    }
10209
10210    #[tokio::test]
10211    async fn prometheus_endpoint_deduplicates_label_keys() {
10212        struct DupLabelSource;
10213        impl MetricsSource for DupLabelSource {
10214            fn collect(&self) -> Vec<MetricFamily> {
10215                vec![MetricFamily {
10216                    name: "dup_label_metric".to_string(),
10217                    help: "test".to_string(),
10218                    kind: MetricKind::Counter,
10219                    samples: vec![MetricSample {
10220                        labels: vec![
10221                            ("env".to_string(), "prod".to_string()),
10222                            ("env".to_string(), "staging".to_string()),
10223                        ],
10224                        value: 5.0,
10225                    }],
10226                }]
10227            }
10228        }
10229
10230        let state = test_state();
10231        state
10232            .metrics_source_registry
10233            .register("dup_src", Arc::new(DupLabelSource))
10234            .unwrap();
10235
10236        let app = actuator_router(true).with_state(state);
10237        let resp = app
10238            .oneshot(
10239                Request::builder()
10240                    .uri("/actuator/prometheus")
10241                    .body(Body::empty())
10242                    .unwrap(),
10243            )
10244            .await
10245            .unwrap();
10246        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10247            .await
10248            .unwrap();
10249        let text = String::from_utf8(body.to_vec()).unwrap();
10250
10251        // Only the first occurrence of `env` is kept
10252        assert!(
10253            text.contains("dup_label_metric{env=\"prod\"} 5"),
10254            "first env value must be kept:\n{text}"
10255        );
10256        assert!(
10257            !text.contains("staging"),
10258            "duplicate env key value must be dropped:\n{text}"
10259        );
10260    }
10261
10262    #[tokio::test]
10263    async fn prometheus_endpoint_escapes_help_text_and_formats_inf() {
10264        struct SpecialSource;
10265        impl MetricsSource for SpecialSource {
10266            fn collect(&self) -> Vec<MetricFamily> {
10267                vec![MetricFamily {
10268                    name: "inf_gauge".to_string(),
10269                    help: "has\\backslash and\nnewline".to_string(),
10270                    kind: MetricKind::Gauge,
10271                    samples: vec![
10272                        MetricSample {
10273                            labels: vec![("dir".to_string(), "pos".to_string())],
10274                            value: f64::INFINITY,
10275                        },
10276                        MetricSample {
10277                            labels: vec![("dir".to_string(), "neg".to_string())],
10278                            value: f64::NEG_INFINITY,
10279                        },
10280                    ],
10281                }]
10282            }
10283        }
10284
10285        let state = test_state();
10286        state
10287            .metrics_source_registry
10288            .register("special", Arc::new(SpecialSource))
10289            .unwrap();
10290
10291        let app = actuator_router(true).with_state(state);
10292        let resp = app
10293            .oneshot(
10294                Request::builder()
10295                    .uri("/actuator/prometheus")
10296                    .body(Body::empty())
10297                    .unwrap(),
10298            )
10299            .await
10300            .unwrap();
10301        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10302            .await
10303            .unwrap();
10304        let text = String::from_utf8(body.to_vec()).unwrap();
10305
10306        assert!(
10307            text.contains("# HELP inf_gauge has\\\\backslash and\\nnewline"),
10308            "help text must be escaped in:\n{text}"
10309        );
10310        assert!(
10311            text.contains("inf_gauge{dir=\"pos\"} +Inf"),
10312            "must render +Inf in:\n{text}"
10313        );
10314        assert!(
10315            text.contains("inf_gauge{dir=\"neg\"} -Inf"),
10316            "must render -Inf in:\n{text}"
10317        );
10318    }
10319
10320    #[tokio::test]
10321    async fn prometheus_endpoint_skips_duplicate_family_name_across_sources() {
10322        struct FirstSource;
10323        impl MetricsSource for FirstSource {
10324            fn collect(&self) -> Vec<MetricFamily> {
10325                vec![MetricFamily {
10326                    name: "shared_counter".to_string(),
10327                    help: "from first".to_string(),
10328                    kind: MetricKind::Counter,
10329                    samples: vec![MetricSample {
10330                        labels: vec![],
10331                        value: 1.0,
10332                    }],
10333                }]
10334            }
10335        }
10336        struct SecondSource;
10337        impl MetricsSource for SecondSource {
10338            fn collect(&self) -> Vec<MetricFamily> {
10339                vec![MetricFamily {
10340                    name: "shared_counter".to_string(),
10341                    help: "from second".to_string(),
10342                    kind: MetricKind::Counter,
10343                    samples: vec![MetricSample {
10344                        labels: vec![],
10345                        value: 2.0,
10346                    }],
10347                }]
10348            }
10349        }
10350
10351        let state = test_state();
10352        state
10353            .metrics_source_registry
10354            .register("first", Arc::new(FirstSource))
10355            .unwrap();
10356        state
10357            .metrics_source_registry
10358            .register("second", Arc::new(SecondSource))
10359            .unwrap();
10360
10361        let app = actuator_router(true).with_state(state);
10362        let resp = app
10363            .oneshot(
10364                Request::builder()
10365                    .uri("/actuator/prometheus")
10366                    .body(Body::empty())
10367                    .unwrap(),
10368            )
10369            .await
10370            .unwrap();
10371        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10372            .await
10373            .unwrap();
10374        let text = String::from_utf8(body.to_vec()).unwrap();
10375
10376        let occurrences = text.matches("# HELP shared_counter").count();
10377        assert_eq!(
10378            occurrences, 1,
10379            "must emit exactly one HELP block for shared_counter:\n{text}"
10380        );
10381    }
10382
10383    #[tokio::test]
10384    async fn prometheus_endpoint_skips_builtin_name_collision() {
10385        struct ShadowSource;
10386        impl MetricsSource for ShadowSource {
10387            fn collect(&self) -> Vec<MetricFamily> {
10388                vec![MetricFamily {
10389                    name: "autumn_http_requests_total".to_string(),
10390                    help: "plugin trying to shadow built-in".to_string(),
10391                    kind: MetricKind::Counter,
10392                    samples: vec![MetricSample {
10393                        labels: vec![],
10394                        value: 999.0,
10395                    }],
10396                }]
10397            }
10398        }
10399
10400        let state = test_state();
10401        state
10402            .metrics_source_registry
10403            .register("shadow", Arc::new(ShadowSource))
10404            .unwrap();
10405
10406        let app = actuator_router(true).with_state(state);
10407        let resp = app
10408            .oneshot(
10409                Request::builder()
10410                    .uri("/actuator/prometheus")
10411                    .body(Body::empty())
10412                    .unwrap(),
10413            )
10414            .await
10415            .unwrap();
10416        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10417            .await
10418            .unwrap();
10419        let text = String::from_utf8(body.to_vec()).unwrap();
10420
10421        let occurrences = text.matches("# HELP autumn_http_requests_total").count();
10422        assert_eq!(
10423            occurrences, 1,
10424            "built-in must not be shadowed by plugin:\n{text}"
10425        );
10426        assert!(
10427            !text.contains("999"),
10428            "plugin shadow value must not appear:\n{text}"
10429        );
10430    }
10431
10432    #[tokio::test]
10433    async fn prometheus_endpoint_skips_builtin_duration_family_collision() {
10434        // The new built-in latency family must be in the duplicate guard so a
10435        // plugin emitting the same family cannot produce a second HELP/TYPE block.
10436        struct ShadowLatency;
10437        impl MetricsSource for ShadowLatency {
10438            fn collect(&self) -> Vec<MetricFamily> {
10439                vec![MetricFamily {
10440                    name: "autumn_http_request_duration_seconds".to_string(),
10441                    help: "plugin trying to shadow built-in latency".to_string(),
10442                    kind: MetricKind::Gauge,
10443                    samples: vec![MetricSample {
10444                        labels: vec![],
10445                        value: 999.0,
10446                    }],
10447                }]
10448            }
10449        }
10450
10451        let state = test_state();
10452        state
10453            .metrics_source_registry
10454            .register("shadow_latency", Arc::new(ShadowLatency))
10455            .unwrap();
10456
10457        let app = actuator_router(true).with_state(state);
10458        let resp = app
10459            .oneshot(
10460                Request::builder()
10461                    .uri("/actuator/prometheus")
10462                    .body(Body::empty())
10463                    .unwrap(),
10464            )
10465            .await
10466            .unwrap();
10467        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10468            .await
10469            .unwrap();
10470        let text = String::from_utf8(body.to_vec()).unwrap();
10471
10472        let occurrences = text
10473            .matches("# HELP autumn_http_request_duration_seconds")
10474            .count();
10475        assert_eq!(
10476            occurrences, 1,
10477            "built-in latency family must not be shadowed by plugin:\n{text}"
10478        );
10479        assert!(
10480            !text.contains("999"),
10481            "plugin shadow value must not appear:\n{text}"
10482        );
10483    }
10484
10485    #[tokio::test]
10486    async fn prometheus_endpoint_skips_duplicate_series_within_family() {
10487        struct DupSeriesSource;
10488        impl MetricsSource for DupSeriesSource {
10489            fn collect(&self) -> Vec<MetricFamily> {
10490                vec![MetricFamily {
10491                    name: "dup_series_metric".to_string(),
10492                    help: "test".to_string(),
10493                    kind: MetricKind::Counter,
10494                    samples: vec![
10495                        MetricSample {
10496                            labels: vec![("region".to_string(), "us".to_string())],
10497                            value: 10.0,
10498                        },
10499                        MetricSample {
10500                            labels: vec![("region".to_string(), "us".to_string())],
10501                            value: 20.0,
10502                        },
10503                    ],
10504                }]
10505            }
10506        }
10507
10508        let state = test_state();
10509        state
10510            .metrics_source_registry
10511            .register("dup_series", Arc::new(DupSeriesSource))
10512            .unwrap();
10513
10514        let app = actuator_router(true).with_state(state);
10515        let resp = app
10516            .oneshot(
10517                Request::builder()
10518                    .uri("/actuator/prometheus")
10519                    .body(Body::empty())
10520                    .unwrap(),
10521            )
10522            .await
10523            .unwrap();
10524        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10525            .await
10526            .unwrap();
10527        let text = String::from_utf8(body.to_vec()).unwrap();
10528
10529        // First occurrence kept, second skipped
10530        assert!(
10531            text.contains("dup_series_metric{region=\"us\"} 10"),
10532            "first sample must appear:\n{text}"
10533        );
10534        assert!(
10535            !text.contains("dup_series_metric{region=\"us\"} 20"),
10536            "duplicate series must be dropped:\n{text}"
10537        );
10538    }
10539
10540    // ── RED then GREEN: /actuator/logfile endpoint ─────────────
10541
10542    fn make_log_buffer_with_entries() -> crate::log::capture::LogBuffer {
10543        use crate::log::capture::{CapturedLogEntry, LogBuffer};
10544        use crate::log::filter::ParameterFilter;
10545        let buf = LogBuffer::new(100, ParameterFilter::default());
10546        buf.push(CapturedLogEntry {
10547            timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10548            level: "INFO".to_owned(),
10549            target: "myapp::orders".to_owned(),
10550            message: "order created".to_owned(),
10551            fields: {
10552                let mut m = serde_json::Map::new();
10553                m.insert("order_id".to_owned(), serde_json::json!("A-1001"));
10554                m
10555            },
10556            request_id: Some("req-abc".to_owned()),
10557        });
10558        buf.push(CapturedLogEntry {
10559            timestamp: "2024-01-01T00:00:01.000Z".to_owned(),
10560            level: "WARN".to_owned(),
10561            target: "myapp::payments".to_owned(),
10562            message: "payment slow".to_owned(),
10563            fields: serde_json::Map::new(),
10564            request_id: None,
10565        });
10566        buf.push(CapturedLogEntry {
10567            timestamp: "2024-01-01T00:00:02.000Z".to_owned(),
10568            level: "ERROR".to_owned(),
10569            target: "myapp::payments".to_owned(),
10570            message: "payment failed".to_owned(),
10571            fields: serde_json::Map::new(),
10572            request_id: None,
10573        });
10574        buf
10575    }
10576
10577    #[tokio::test]
10578    async fn green_logfile_returns_empty_when_capture_disabled() {
10579        let state = test_state(); // log_buffer = None
10580        let response =
10581            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10582                .await
10583                .unwrap();
10584        let body = response.0;
10585        assert!(!body.capture_enabled);
10586        assert!(body.entries.is_empty());
10587        assert_eq!(body.total, 0);
10588    }
10589
10590    #[tokio::test]
10591    async fn green_logfile_returns_all_entries_when_no_filter() {
10592        let mut state = test_state();
10593        state.log_buffer = Some(make_log_buffer_with_entries());
10594
10595        let response =
10596            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10597                .await
10598                .unwrap();
10599        let body = response.0;
10600        assert!(body.capture_enabled);
10601        assert_eq!(body.total, 3);
10602        assert_eq!(body.entries.len(), 3);
10603        // newest-last ordering
10604        assert_eq!(body.entries[0].level, "INFO");
10605        assert_eq!(body.entries[2].level, "ERROR");
10606    }
10607
10608    #[tokio::test]
10609    async fn green_logfile_level_filter_excludes_info_when_min_warn() {
10610        let mut state = test_state();
10611        state.log_buffer = Some(make_log_buffer_with_entries());
10612
10613        let response = logfile_endpoint(
10614            State(state),
10615            axum::extract::Query(LogfileQuery {
10616                level: Some("warn".to_owned()),
10617                limit: None,
10618            }),
10619        )
10620        .await
10621        .unwrap();
10622        let body = response.0;
10623        assert_eq!(body.entries.len(), 2);
10624        assert!(body.entries.iter().all(|e| e.level != "INFO"));
10625    }
10626
10627    #[tokio::test]
10628    async fn green_logfile_limit_returns_most_recent_n() {
10629        let mut state = test_state();
10630        state.log_buffer = Some(make_log_buffer_with_entries());
10631
10632        let response = logfile_endpoint(
10633            State(state),
10634            axum::extract::Query(LogfileQuery {
10635                level: None,
10636                limit: Some(2),
10637            }),
10638        )
10639        .await
10640        .unwrap();
10641        let body = response.0;
10642        assert_eq!(body.entries.len(), 2);
10643        // Most recent 2 = WARN and ERROR
10644        assert_eq!(body.entries[0].level, "WARN");
10645        assert_eq!(body.entries[1].level, "ERROR");
10646    }
10647
10648    #[tokio::test]
10649    async fn green_logfile_sensitive_fields_in_response_are_served_scrubbed() {
10650        use crate::log::capture::{CapturedLogEntry, LogBuffer};
10651        use crate::log::filter::{FILTERED_PLACEHOLDER, ParameterFilter};
10652        let buf = LogBuffer::new(10, ParameterFilter::default());
10653        // The layer scrubs before storage; simulate stored entry with scrubbed value.
10654        buf.push(CapturedLogEntry {
10655            timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10656            level: "INFO".to_owned(),
10657            target: "auth".to_owned(),
10658            message: "login attempt".to_owned(),
10659            fields: {
10660                let mut m = serde_json::Map::new();
10661                m.insert(
10662                    "password".to_owned(),
10663                    serde_json::Value::String(FILTERED_PLACEHOLDER.to_owned()),
10664                );
10665                m
10666            },
10667            request_id: None,
10668        });
10669
10670        let mut state = test_state();
10671        state.log_buffer = Some(buf);
10672
10673        let response =
10674            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10675                .await
10676                .unwrap();
10677        let body = response.0;
10678        assert_eq!(
10679            body.entries[0].fields["password"].as_str().unwrap(),
10680            FILTERED_PLACEHOLDER,
10681            "sensitive value must remain scrubbed in the response"
10682        );
10683    }
10684
10685    #[tokio::test]
10686    async fn green_logfile_invalid_level_returns_400() {
10687        let state = test_state();
10688        let result = logfile_endpoint(
10689            State(state),
10690            axum::extract::Query(LogfileQuery {
10691                level: Some("warning".to_owned()), // invalid — should be "warn"
10692                limit: None,
10693            }),
10694        )
10695        .await;
10696        let (status, _body) = result.unwrap_err();
10697        assert_eq!(status, StatusCode::BAD_REQUEST);
10698    }
10699
10700    #[tokio::test]
10701    async fn green_logfile_endpoint_in_sensitive_router() {
10702        // The endpoint must be reachable when sensitive=true.
10703        let state = test_state();
10704        let app = actuator_router::<TestActuatorState>(true).with_state(state);
10705        let resp = app
10706            .oneshot(
10707                Request::builder()
10708                    .uri("/actuator/logfile")
10709                    .body(Body::empty())
10710                    .unwrap(),
10711            )
10712            .await
10713            .unwrap();
10714        assert_eq!(resp.status(), StatusCode::OK);
10715    }
10716
10717    #[tokio::test]
10718    async fn green_logfile_endpoint_not_in_non_sensitive_router() {
10719        // The endpoint must NOT be reachable when sensitive=false.
10720        let state = test_state();
10721        let app = actuator_router::<TestActuatorState>(false).with_state(state);
10722        let resp = app
10723            .oneshot(
10724                Request::builder()
10725                    .uri("/actuator/logfile")
10726                    .body(Body::empty())
10727                    .unwrap(),
10728            )
10729            .await
10730            .unwrap();
10731        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10732    }
10733
10734    #[tokio::test]
10735    async fn green_logfile_structured_fields_preserved() {
10736        let mut state = test_state();
10737        state.log_buffer = Some(make_log_buffer_with_entries());
10738
10739        let response =
10740            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10741                .await
10742                .unwrap();
10743        let body = response.0;
10744        let first = &body.entries[0];
10745        assert_eq!(first.target, "myapp::orders");
10746        assert_eq!(first.fields["order_id"].as_str().unwrap(), "A-1001");
10747        assert_eq!(first.request_id.as_deref(), Some("req-abc"));
10748    }
10749
10750    // ── App-metrics facade exposition (issue #1378) ───────────────────────
10751    //
10752    // The facade registry is process-global, so every test here records into
10753    // instrument names built with `metrics::testing::unique_name` and asserts
10754    // with `contains()` on those names. Never assert whole-body equality or
10755    // the absence of unrelated families.
10756
10757    /// Fetch a body from the actuator router mounted at `/actuator`.
10758    async fn actuator_body(uri: &str) -> String {
10759        let app = actuator_router(true).with_state(test_state());
10760        let resp = app
10761            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
10762            .await
10763            .unwrap();
10764        assert_eq!(resp.status(), StatusCode::OK);
10765        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10766            .await
10767            .unwrap();
10768        String::from_utf8(body.to_vec()).unwrap()
10769    }
10770
10771    #[tokio::test]
10772    async fn prometheus_endpoint_renders_facade_counter_gauge_and_histogram() {
10773        use crate::metrics::testing::unique_name;
10774
10775        let counter_name = unique_name("facade_expo_orders_total");
10776        crate::metrics::describe_counter(&counter_name, "Orders placed");
10777        crate::metrics::counter(&counter_name)
10778            .with_label("status", "paid")
10779            .with_label("region", "eu")
10780            .increment(3);
10781
10782        let gauge_name = unique_name("facade_expo_queue_depth");
10783        crate::metrics::describe_gauge(&gauge_name, "Queue depth");
10784        crate::metrics::gauge(&gauge_name).set(2.5);
10785
10786        let hist_name = unique_name("facade_expo_work_seconds");
10787        crate::metrics::describe_histogram(&hist_name, "Work duration");
10788        let hist = crate::metrics::histogram(&hist_name).with_label("route", "/x");
10789        hist.record(0.25);
10790        hist.record(0.75);
10791
10792        let text = actuator_body("/actuator/prometheus").await;
10793
10794        // Counter: HELP/TYPE plus an integer-rendered sample with sorted labels.
10795        assert!(
10796            text.contains(&format!("# HELP {counter_name} Orders placed")),
10797            "missing counter HELP in:\n{text}"
10798        );
10799        assert!(
10800            text.contains(&format!("# TYPE {counter_name} counter")),
10801            "missing counter TYPE in:\n{text}"
10802        );
10803        assert!(
10804            text.contains(&format!(
10805                "{counter_name}{{region=\"eu\",status=\"paid\"}} 3"
10806            )),
10807            "counter must render as an integer with sorted labels in:\n{text}"
10808        );
10809
10810        // Gauge.
10811        assert!(
10812            text.contains(&format!("# TYPE {gauge_name} gauge")),
10813            "missing gauge TYPE in:\n{text}"
10814        );
10815        assert!(
10816            text.contains(&format!("{gauge_name} 2.5")),
10817            "missing gauge sample in:\n{text}"
10818        );
10819
10820        // Histogram: one TYPE line for the base name, cumulative buckets with
10821        // `le` appended after the user labels, then `_sum` and `_count`.
10822        assert!(
10823            text.contains(&format!("# HELP {hist_name} Work duration")),
10824            "missing histogram HELP in:\n{text}"
10825        );
10826        assert!(
10827            text.contains(&format!("# TYPE {hist_name} histogram")),
10828            "missing histogram TYPE in:\n{text}"
10829        );
10830        for (le, value) in [
10831            ("0.005", 0),
10832            ("0.01", 0),
10833            ("0.025", 0),
10834            ("0.05", 0),
10835            ("0.1", 0),
10836            ("0.25", 1),
10837            ("0.5", 1),
10838            ("1", 2),
10839            ("2.5", 2),
10840            ("5", 2),
10841            ("10", 2),
10842            ("+Inf", 2),
10843        ] {
10844            let line = format!("{hist_name}_bucket{{route=\"/x\",le=\"{le}\"}} {value}");
10845            assert!(
10846                text.contains(&line),
10847                "missing bucket line `{line}` in:\n{text}"
10848            );
10849        }
10850        assert!(
10851            text.contains(&format!("{hist_name}_sum{{route=\"/x\"}} 1")),
10852            "missing histogram sum in:\n{text}"
10853        );
10854        assert!(
10855            text.contains(&format!("{hist_name}_count{{route=\"/x\"}} 2")),
10856            "missing histogram count in:\n{text}"
10857        );
10858        // `le="+Inf"` must structurally equal `_count`.
10859        assert!(
10860            text.contains(&format!("{hist_name}_bucket{{route=\"/x\",le=\"+Inf\"}} 2")),
10861            "+Inf bucket must equal _count in:\n{text}"
10862        );
10863        // Exactly one TYPE block per family, no duplicates.
10864        assert_eq!(
10865            text.matches(&format!("# TYPE {counter_name} counter"))
10866                .count(),
10867            1,
10868            "duplicate TYPE line for {counter_name} in:\n{text}"
10869        );
10870    }
10871
10872    #[tokio::test]
10873    async fn prometheus_endpoint_prefers_facade_over_colliding_plugin_family() {
10874        use crate::metrics::testing::unique_name;
10875
10876        struct CollidingSource(String);
10877        impl MetricsSource for CollidingSource {
10878            fn collect(&self) -> Vec<MetricFamily> {
10879                vec![MetricFamily {
10880                    name: self.0.clone(),
10881                    help: "From the plugin".to_string(),
10882                    kind: MetricKind::Gauge,
10883                    samples: vec![MetricSample {
10884                        labels: vec![],
10885                        value: 99.0,
10886                    }],
10887                }]
10888            }
10889        }
10890
10891        let name = unique_name("facade_expo_collision_total");
10892        crate::metrics::describe_counter(&name, "From the facade");
10893        crate::metrics::counter(&name).increment(1);
10894
10895        let state = test_state();
10896        state
10897            .metrics_source_registry
10898            .register("colliding_plugin", Arc::new(CollidingSource(name.clone())))
10899            .unwrap();
10900
10901        let app = actuator_router(true).with_state(state);
10902        let resp = app
10903            .oneshot(
10904                Request::builder()
10905                    .uri("/actuator/prometheus")
10906                    .body(Body::empty())
10907                    .unwrap(),
10908            )
10909            .await
10910            .unwrap();
10911        assert_eq!(resp.status(), StatusCode::OK);
10912        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10913            .await
10914            .unwrap();
10915        let text = String::from_utf8(body.to_vec()).unwrap();
10916
10917        assert!(
10918            text.contains(&format!("# HELP {name} From the facade")),
10919            "the facade family must win the name in:\n{text}"
10920        );
10921        assert!(
10922            !text.contains(&format!("# HELP {name} From the plugin")),
10923            "the plugin family must be skipped in:\n{text}"
10924        );
10925        assert_eq!(
10926            text.matches(&format!("# TYPE {name} ")).count(),
10927            1,
10928            "exactly one TYPE line for {name} in:\n{text}"
10929        );
10930        assert!(
10931            !text.contains(&format!("{name} 99")),
10932            "the plugin sample must not be emitted in:\n{text}"
10933        );
10934    }
10935
10936    #[tokio::test]
10937    async fn prometheus_endpoint_skips_plugin_family_colliding_with_facade_histogram_count() {
10938        use crate::metrics::testing::unique_name;
10939
10940        struct DerivedSource(String);
10941        impl MetricsSource for DerivedSource {
10942            fn collect(&self) -> Vec<MetricFamily> {
10943                vec![MetricFamily {
10944                    name: self.0.clone(),
10945                    help: "Plugin shadowing a derived name".to_string(),
10946                    kind: MetricKind::Counter,
10947                    samples: vec![MetricSample {
10948                        labels: vec![],
10949                        value: 77.0,
10950                    }],
10951                }]
10952            }
10953        }
10954
10955        let base = unique_name("facade_expo_derived_seconds");
10956        crate::metrics::histogram(&base).record(0.25);
10957        let derived = format!("{base}_count");
10958
10959        let state = test_state();
10960        state
10961            .metrics_source_registry
10962            .register("derived_plugin", Arc::new(DerivedSource(derived.clone())))
10963            .unwrap();
10964
10965        let app = actuator_router(true).with_state(state);
10966        let resp = app
10967            .oneshot(
10968                Request::builder()
10969                    .uri("/actuator/prometheus")
10970                    .body(Body::empty())
10971                    .unwrap(),
10972            )
10973            .await
10974            .unwrap();
10975        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10976            .await
10977            .unwrap();
10978        let text = String::from_utf8(body.to_vec()).unwrap();
10979
10980        assert!(
10981            !text.contains("Plugin shadowing a derived name"),
10982            "a plugin family named after a facade histogram's derived family must be skipped in:\n{text}"
10983        );
10984        assert!(
10985            !text.contains(&format!("{derived} 77")),
10986            "the plugin sample must not be emitted in:\n{text}"
10987        );
10988        assert!(
10989            text.contains(&format!("{derived} 1")),
10990            "the facade's own derived count must still be present in:\n{text}"
10991        );
10992    }
10993
10994    #[tokio::test]
10995    async fn prometheus_endpoint_skips_plugin_family_colliding_with_builtin_summary_derived_name() {
10996        // A histogram owns `_bucket`, `_sum` and `_count`. A summary owns
10997        // `_sum` and `_count`. A plugin family under any of these names puts a
10998        // second `# HELP` line for that family into the document, and
10999        // Prometheus' own parser rejects the whole scrape when that happens.
11000        struct DerivedSummarySource(&'static str);
11001        impl MetricsSource for DerivedSummarySource {
11002            fn collect(&self) -> Vec<MetricFamily> {
11003                vec![MetricFamily {
11004                    name: self.0.to_string(),
11005                    help: "Plugin shadowing a builtin summary's derived family".to_string(),
11006                    kind: MetricKind::Counter,
11007                    samples: vec![MetricSample {
11008                        labels: vec![],
11009                        value: 4242.0,
11010                    }],
11011                }]
11012            }
11013        }
11014
11015        for derived in [
11016            "autumn_http_request_duration_seconds_bucket",
11017            "autumn_http_request_duration_seconds_sum",
11018            "autumn_http_request_duration_seconds_count",
11019            "autumn_http_request_duration_quantiles_seconds_sum",
11020            "autumn_http_request_duration_quantiles_seconds_count",
11021        ] {
11022            let state = test_state();
11023            state
11024                .metrics_source_registry
11025                .register(
11026                    "derived_summary_plugin",
11027                    Arc::new(DerivedSummarySource(derived)),
11028                )
11029                .unwrap();
11030
11031            let app = actuator_router(true).with_state(state);
11032            let resp = app
11033                .oneshot(
11034                    Request::builder()
11035                        .uri("/actuator/prometheus")
11036                        .body(Body::empty())
11037                        .unwrap(),
11038                )
11039                .await
11040                .unwrap();
11041            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11042                .await
11043                .unwrap();
11044            let text = String::from_utf8(body.to_vec()).unwrap();
11045
11046            assert!(
11047                !text.contains(&format!("# HELP {derived} ")),
11048                "a plugin family named `{derived}` must be skipped in:\n{text}"
11049            );
11050            assert!(
11051                !text.contains("4242"),
11052                "the plugin sample must not be emitted in:\n{text}"
11053            );
11054        }
11055    }
11056
11057    #[tokio::test]
11058    async fn metrics_endpoint_includes_app_section_for_facade_metrics() {
11059        use crate::metrics::testing::unique_name;
11060
11061        let counter_name = unique_name("facade_json_orders_total");
11062        crate::metrics::describe_counter(&counter_name, "Orders placed");
11063        crate::metrics::counter(&counter_name)
11064            .with_label("status", "paid")
11065            .increment(3);
11066
11067        let hist_name = unique_name("facade_json_work_seconds");
11068        crate::metrics::histogram(&hist_name).record(0.25);
11069        crate::metrics::histogram(&hist_name).record(0.75);
11070
11071        let text = actuator_body("/actuator/metrics").await;
11072        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11073
11074        let app = json
11075            .get("app")
11076            .unwrap_or_else(|| panic!("missing top-level `app` key in:\n{text}"));
11077        let instruments = app
11078            .as_array()
11079            .unwrap_or_else(|| panic!("`app` must be an array in:\n{text}"));
11080
11081        let entry = instruments
11082            .iter()
11083            .find(|i| i["name"] == serde_json::json!(counter_name))
11084            .unwrap_or_else(|| panic!("missing {counter_name} in:\n{text}"));
11085        assert_eq!(entry["kind"], serde_json::json!("counter"));
11086        assert_eq!(entry["help"], serde_json::json!("Orders placed"));
11087        assert_eq!(entry["dropped_series"], serde_json::json!(0));
11088        assert_eq!(
11089            entry["series"][0]["labels"]["status"],
11090            serde_json::json!("paid")
11091        );
11092        assert_eq!(
11093            entry["series"][0]["value"]["value"].as_u64(),
11094            Some(3),
11095            "the JSON view must match the prometheus view"
11096        );
11097
11098        let hist = instruments
11099            .iter()
11100            .find(|i| i["name"] == serde_json::json!(hist_name))
11101            .unwrap_or_else(|| panic!("missing {hist_name} in:\n{text}"));
11102        assert_eq!(hist["kind"], serde_json::json!("histogram"));
11103        let value = &hist["series"][0]["value"];
11104        assert_eq!(value["count"].as_u64(), Some(2));
11105        assert!(
11106            (value["sum"].as_f64().unwrap() - 1.0).abs() < f64::EPSILON,
11107            "unexpected histogram sum in:\n{text}"
11108        );
11109        let buckets = value["buckets"]
11110            .as_array()
11111            .unwrap_or_else(|| panic!("missing histogram buckets in:\n{text}"));
11112        let last = buckets.last().unwrap();
11113        assert_eq!(last[0], serde_json::json!("+Inf"));
11114        assert_eq!(last[1].as_u64(), Some(2), "+Inf must equal count");
11115    }
11116
11117    #[tokio::test]
11118    async fn actuator_prometheus_disabled_hides_facade_metrics() {
11119        use crate::metrics::testing::unique_name;
11120
11121        // Recording always works; only exposure is gated. With the scrape
11122        // endpoint disabled there must be no route that leaks the facade.
11123        let name = unique_name("facade_gated_total");
11124        crate::metrics::counter(&name).increment(1);
11125
11126        let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
11127        let resp = app
11128            .oneshot(
11129                Request::builder()
11130                    .uri("/actuator/prometheus")
11131                    .body(Body::empty())
11132                    .unwrap(),
11133            )
11134            .await
11135            .unwrap();
11136        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11137    }
11138
11139    // ── Data capsules (issue #1811) ─────────────────────────────────────────
11140
11141    fn capsule_state(dir: &std::path::Path) -> TestActuatorState {
11142        use crate::gdpr::portability::{
11143            CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
11144        };
11145        let store = MemoryCapsuleStore::new().table(
11146            "users",
11147            vec![
11148                FieldSpec::new("id", "bigint"),
11149                FieldSpec::new("email", "text"),
11150            ],
11151        );
11152        store.insert(
11153            "users",
11154            serde_json::json!({"id": 7, "email": "ada@example.com"}),
11155        );
11156        let service = CapsuleService::new(
11157            vec![CapsuleModel::new("users", "id")],
11158            std::sync::Arc::new(store),
11159            CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
11160        )
11161        .with_dir(dir);
11162        let mut state = test_state();
11163        state.data_capsules = Some(service);
11164        state
11165    }
11166
11167    async fn post_json(
11168        state: TestActuatorState,
11169        sensitive: bool,
11170        path: &str,
11171        body: serde_json::Value,
11172    ) -> (StatusCode, serde_json::Value) {
11173        let resp = actuator_router(sensitive)
11174            .with_state(state)
11175            .oneshot(
11176                Request::builder()
11177                    .method("POST")
11178                    .uri(path)
11179                    .header("content-type", "application/json")
11180                    .body(Body::from(body.to_string()))
11181                    .unwrap(),
11182            )
11183            .await
11184            .unwrap();
11185        let status = resp.status();
11186        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
11187            .await
11188            .unwrap();
11189        (
11190            status,
11191            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
11192        )
11193    }
11194
11195    #[tokio::test]
11196    async fn actuator_capsules_are_not_mounted_when_not_sensitive() {
11197        let dir = tempfile::tempdir().unwrap();
11198        let (status, _) = post_json(
11199            capsule_state(dir.path()),
11200            false,
11201            "/actuator/capsules/export",
11202            serde_json::json!({"subject": "7"}),
11203        )
11204        .await;
11205        assert_eq!(status, StatusCode::NOT_FOUND);
11206    }
11207
11208    #[tokio::test]
11209    async fn actuator_capsules_answer_501_when_not_configured() {
11210        let (status, json) = post_json(
11211            test_state(),
11212            true,
11213            "/actuator/capsules/export",
11214            serde_json::json!({"subject": "7"}),
11215        )
11216        .await;
11217        assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{json}");
11218        assert_eq!(json["status"], "error");
11219    }
11220
11221    #[tokio::test]
11222    async fn actuator_capsule_export_verify_import() {
11223        let dir = tempfile::tempdir().unwrap();
11224        let (status, json) = post_json(
11225            capsule_state(dir.path()),
11226            true,
11227            "/actuator/capsules/export",
11228            serde_json::json!({"subject": "7"}),
11229        )
11230        .await;
11231        assert_eq!(status, StatusCode::OK, "{json}");
11232        assert_eq!(json["report"]["records"], 1);
11233        let name = json["capsule"].as_str().expect("capsule name").to_owned();
11234        assert!(dir.path().join(&name).join("viewer/index.html").is_file());
11235
11236        let (status, json) = post_json(
11237            capsule_state(dir.path()),
11238            true,
11239            "/actuator/capsules/verify",
11240            serde_json::json!({"capsule": name}),
11241        )
11242        .await;
11243        assert_eq!(status, StatusCode::OK, "{json}");
11244        assert_eq!(json["report"]["subject"], "7");
11245
11246        // The row is already in the store, so import conflicts.
11247        let (status, json) = post_json(
11248            capsule_state(dir.path()),
11249            true,
11250            "/actuator/capsules/import",
11251            serde_json::json!({"capsule": name}),
11252        )
11253        .await;
11254        assert_eq!(status, StatusCode::CONFLICT, "{json}");
11255    }
11256
11257    #[tokio::test]
11258    async fn actuator_capsule_import_into_an_empty_store_succeeds() {
11259        use crate::gdpr::portability::{
11260            CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
11261        };
11262        let dir = tempfile::tempdir().unwrap();
11263        let (_, json) = post_json(
11264            capsule_state(dir.path()),
11265            true,
11266            "/actuator/capsules/export",
11267            serde_json::json!({"subject": "7"}),
11268        )
11269        .await;
11270        let name = json["capsule"].as_str().unwrap().to_owned();
11271
11272        let empty = std::sync::Arc::new(MemoryCapsuleStore::new().table(
11273            "users",
11274            vec![
11275                FieldSpec::new("id", "bigint"),
11276                FieldSpec::new("email", "text"),
11277            ],
11278        ));
11279        let mut state = test_state();
11280        state.data_capsules = Some(
11281            CapsuleService::new(
11282                vec![CapsuleModel::new("users", "id")],
11283                empty.clone(),
11284                CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
11285            )
11286            .with_dir(dir.path()),
11287        );
11288        let (status, json) = post_json(
11289            state,
11290            true,
11291            "/actuator/capsules/import",
11292            serde_json::json!({"capsule": name}),
11293        )
11294        .await;
11295        assert_eq!(status, StatusCode::OK, "{json}");
11296        assert_eq!(json["summary"]["records"], 1);
11297        assert_eq!(json["summary"]["tables"][0]["table"], "users");
11298        assert_eq!(empty.rows("users").len(), 1);
11299    }
11300
11301    #[tokio::test]
11302    async fn actuator_capsule_export_rejects_an_empty_subject_with_400() {
11303        let dir = tempfile::tempdir().unwrap();
11304        let (status, json) = post_json(
11305            capsule_state(dir.path()),
11306            true,
11307            "/actuator/capsules/export",
11308            serde_json::json!({"subject": ""}),
11309        )
11310        .await;
11311        assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
11312    }
11313
11314    #[cfg(unix)]
11315    #[tokio::test]
11316    async fn actuator_capsule_verify_does_not_follow_a_symlinked_entry() {
11317        // A signed capsule outside the directory, linked in by name.
11318        let outside = tempfile::tempdir().unwrap();
11319        let (_, json) = post_json(
11320            capsule_state(outside.path()),
11321            true,
11322            "/actuator/capsules/export",
11323            serde_json::json!({"subject": "7"}),
11324        )
11325        .await;
11326        let name = json["capsule"].as_str().unwrap().to_owned();
11327        let dir = tempfile::tempdir().unwrap();
11328        std::os::unix::fs::symlink(outside.path().join(&name), dir.path().join("linked")).unwrap();
11329        for endpoint in ["verify", "import"] {
11330            let (status, json) = post_json(
11331                capsule_state(dir.path()),
11332                true,
11333                &format!("/actuator/capsules/{endpoint}"),
11334                serde_json::json!({"capsule": "linked"}),
11335            )
11336            .await;
11337            assert_eq!(status, StatusCode::NOT_FOUND, "{endpoint}: {json}");
11338        }
11339    }
11340
11341    #[tokio::test]
11342    async fn actuator_capsule_import_accepts_only_a_plain_name() {
11343        let dir = tempfile::tempdir().unwrap();
11344        for name in ["../etc", "/tmp/x", "a/b"] {
11345            let (status, json) = post_json(
11346                capsule_state(dir.path()),
11347                true,
11348                "/actuator/capsules/import",
11349                serde_json::json!({"capsule": name}),
11350            )
11351            .await;
11352            assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {json}");
11353        }
11354    }
11355
11356    #[tokio::test]
11357    async fn actuator_capsule_verify_answers_404_for_an_unknown_capsule() {
11358        let dir = tempfile::tempdir().unwrap();
11359        let (status, _) = post_json(
11360            capsule_state(dir.path()),
11361            true,
11362            "/actuator/capsules/verify",
11363            serde_json::json!({"capsule": "missing"}),
11364        )
11365        .await;
11366        assert_eq!(status, StatusCode::NOT_FOUND);
11367    }
11368
11369    #[tokio::test]
11370    async fn actuator_capsule_verify_reports_tampering_as_422() {
11371        let dir = tempfile::tempdir().unwrap();
11372        let (_, json) = post_json(
11373            capsule_state(dir.path()),
11374            true,
11375            "/actuator/capsules/export",
11376            serde_json::json!({"subject": "7"}),
11377        )
11378        .await;
11379        let name = json["capsule"].as_str().unwrap().to_owned();
11380        std::fs::write(dir.path().join(&name).join("records/users.json"), "[]").unwrap();
11381        let (status, json) = post_json(
11382            capsule_state(dir.path()),
11383            true,
11384            "/actuator/capsules/verify",
11385            serde_json::json!({"capsule": name}),
11386        )
11387        .await;
11388        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{json}");
11389    }
11390
11391    #[test]
11392    fn actuator_capsule_routes_are_listed_only_in_sensitive_mode() {
11393        for suffix in ["export", "import", "verify"] {
11394            let path = format!("/actuator/capsules/{suffix}");
11395            assert!(actuator_endpoint_paths("/actuator", true, false).contains(&path));
11396            assert!(!actuator_endpoint_paths("/actuator", false, false).contains(&path));
11397            assert!(actuator_mutating_routes("/actuator", true).contains(&("POST", path.clone())));
11398            assert!(!actuator_mutating_routes("/actuator", false).contains(&("POST", path)));
11399        }
11400    }
11401}
11402
11403#[cfg(test)]
11404mod health_indicator_tests {
11405    use super::*;
11406
11407    struct AlwaysUp;
11408    impl HealthIndicator for AlwaysUp {
11409        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11410            Box::pin(async { HealthCheckOutput::up() })
11411        }
11412    }
11413
11414    struct AlwaysDown;
11415    impl HealthIndicator for AlwaysDown {
11416        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11417            Box::pin(async { HealthCheckOutput::down() })
11418        }
11419    }
11420
11421    #[test]
11422    fn health_status_as_str_values() {
11423        assert_eq!(HealthStatus::Up.as_str(), "UP");
11424        assert_eq!(HealthStatus::Down.as_str(), "DOWN");
11425        assert_eq!(HealthStatus::OutOfService.as_str(), "OUT_OF_SERVICE");
11426        assert_eq!(HealthStatus::Unknown.as_str(), "UNKNOWN");
11427    }
11428
11429    #[test]
11430    fn health_status_is_healthy() {
11431        assert!(HealthStatus::Up.is_healthy());
11432        assert!(HealthStatus::Unknown.is_healthy());
11433        assert!(!HealthStatus::Down.is_healthy());
11434        assert!(!HealthStatus::OutOfService.is_healthy());
11435    }
11436
11437    #[test]
11438    fn aggregate_status_precedence() {
11439        assert_eq!(
11440            HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up]),
11441            HealthStatus::Up
11442        );
11443        assert_eq!(
11444            HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up, HealthStatus::Unknown]),
11445            HealthStatus::Unknown
11446        );
11447        assert_eq!(
11448            HealthIndicatorRegistry::aggregate_status(&[
11449                HealthStatus::Unknown,
11450                HealthStatus::OutOfService
11451            ]),
11452            HealthStatus::OutOfService
11453        );
11454        assert_eq!(
11455            HealthIndicatorRegistry::aggregate_status(&[
11456                HealthStatus::OutOfService,
11457                HealthStatus::Down
11458            ]),
11459            HealthStatus::Down
11460        );
11461        assert_eq!(
11462            HealthIndicatorRegistry::aggregate_status(&[]),
11463            HealthStatus::Up
11464        );
11465    }
11466
11467    #[tokio::test]
11468    async fn registry_run_all_collects_results() {
11469        let registry = HealthIndicatorRegistry::new();
11470        registry
11471            .register("svc_a", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11472            .unwrap();
11473        registry
11474            .register("svc_b", IndicatorGroup::HealthOnly, Arc::new(AlwaysDown))
11475            .unwrap();
11476
11477        let results = registry.run_all().await;
11478        assert!(
11479            results
11480                .iter()
11481                .any(|r| r.name == "svc_a" && r.output.status == HealthStatus::Up)
11482        );
11483        assert!(
11484            results
11485                .iter()
11486                .any(|r| r.name == "svc_b" && r.output.status == HealthStatus::Down)
11487        );
11488    }
11489
11490    #[tokio::test]
11491    async fn registry_run_readiness_filters_health_only() {
11492        let registry = HealthIndicatorRegistry::new();
11493        registry
11494            .register("probe_check", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11495            .unwrap();
11496        registry
11497            .register(
11498                "health_only",
11499                IndicatorGroup::HealthOnly,
11500                Arc::new(AlwaysDown),
11501            )
11502            .unwrap();
11503
11504        let results = registry.run_readiness().await;
11505        assert_eq!(results.len(), 1);
11506        assert_eq!(results[0].name, "probe_check");
11507    }
11508
11509    /// Counts checks. Each check waits `delay`.
11510    struct CountingIndicator {
11511        calls: std::sync::atomic::AtomicUsize,
11512        delay: std::time::Duration,
11513    }
11514
11515    impl CountingIndicator {
11516        fn new(delay: std::time::Duration) -> Arc<Self> {
11517            Arc::new(Self {
11518                calls: std::sync::atomic::AtomicUsize::new(0),
11519                delay,
11520            })
11521        }
11522
11523        fn calls(&self) -> usize {
11524            self.calls.load(std::sync::atomic::Ordering::SeqCst)
11525        }
11526    }
11527
11528    impl HealthIndicator for CountingIndicator {
11529        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11530            Box::pin(async move {
11531                self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11532                tokio::time::sleep(self.delay).await;
11533                HealthCheckOutput::up()
11534            })
11535        }
11536    }
11537
11538    #[tokio::test(start_paused = true)]
11539    async fn concurrent_runs_check_each_indicator_once_per_ttl() {
11540        let registry = HealthIndicatorRegistry::new();
11541        registry.set_cache_ttl(std::time::Duration::from_secs(1));
11542        let indicator = CountingIndicator::new(std::time::Duration::from_millis(20));
11543        registry
11544            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11545            .unwrap();
11546
11547        let runs: Vec<_> = (0..32)
11548            .map(|i| {
11549                let registry = registry.clone();
11550                tokio::spawn(async move {
11551                    if i % 2 == 0 {
11552                        registry.run_readiness().await
11553                    } else {
11554                        registry.run_all().await
11555                    }
11556                })
11557            })
11558            .collect();
11559        for run in runs {
11560            let results = run.await.unwrap();
11561            let svc = results.iter().find(|r| r.name == "svc").unwrap();
11562            assert_eq!(svc.output.status, HealthStatus::Up);
11563        }
11564        assert_eq!(indicator.calls(), 1);
11565
11566        tokio::time::advance(std::time::Duration::from_millis(1_001)).await;
11567        registry.run_readiness().await;
11568        assert_eq!(indicator.calls(), 2);
11569    }
11570
11571    #[tokio::test(start_paused = true)]
11572    async fn new_registry_does_not_cache() {
11573        let registry = HealthIndicatorRegistry::new();
11574        let indicator = CountingIndicator::new(std::time::Duration::ZERO);
11575        registry
11576            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11577            .unwrap();
11578
11579        registry.run_readiness().await;
11580        registry.run_readiness().await;
11581
11582        assert_eq!(indicator.calls(), 2);
11583    }
11584
11585    #[tokio::test(start_paused = true)]
11586    async fn timed_out_result_is_cached_too() {
11587        let registry = HealthIndicatorRegistry::new();
11588        registry.set_cache_ttl(std::time::Duration::from_secs(1));
11589        let indicator = CountingIndicator::new(std::time::Duration::from_secs(3_600));
11590        registry
11591            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11592            .unwrap();
11593
11594        let first = registry.run_readiness().await;
11595        let second = registry.run_readiness().await;
11596
11597        assert_eq!(first[0].output.status, HealthStatus::Unknown);
11598        assert_eq!(second[0].output.status, HealthStatus::Unknown);
11599        assert_eq!(indicator.calls(), 1);
11600    }
11601
11602    #[tokio::test]
11603    async fn timed_out_indicator_reports_unknown_with_flag() {
11604        struct SlowIndicator;
11605        impl HealthIndicator for SlowIndicator {
11606            fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11607                Box::pin(async {
11608                    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
11609                    HealthCheckOutput::up()
11610                })
11611            }
11612            fn timeout_ms(&self) -> u64 {
11613                5
11614            }
11615        }
11616        let registry = HealthIndicatorRegistry::new();
11617        registry
11618            .register("slow", IndicatorGroup::Readiness, Arc::new(SlowIndicator))
11619            .unwrap();
11620        let results = registry.run_all().await;
11621        let slow_res = results
11622            .iter()
11623            .find(|r| r.name == "slow")
11624            .expect("slow indicator not found");
11625        assert_eq!(slow_res.output.status, HealthStatus::Unknown);
11626        assert_eq!(
11627            slow_res.output.details.get("timed_out"),
11628            Some(&serde_json::Value::Bool(true))
11629        );
11630    }
11631
11632    #[tokio::test]
11633    #[allow(clippy::await_holding_lock)]
11634    async fn test_circuit_breakers_in_health_indicator_registry() {
11635        let _lock = crate::circuit_breaker::TEST_LOCK
11636            .lock()
11637            .unwrap_or_else(std::sync::PoisonError::into_inner);
11638        crate::circuit_breaker::global_registry().clear();
11639        let registry = HealthIndicatorRegistry::new();
11640        let breaker = crate::circuit_breaker::global_registry().get_or_create(
11641            "actuator_test_breaker",
11642            crate::circuit_breaker::CircuitBreakerPolicy {
11643                failure_ratio_threshold: 0.5,
11644                sample_window: std::time::Duration::from_secs(10),
11645                minimum_sample_count: 2,
11646                open_duration: std::time::Duration::from_secs(60),
11647                half_open_trial_count: 2,
11648                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
11649            },
11650        );
11651
11652        let results = registry.run_all().await;
11653        let found = results
11654            .iter()
11655            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11656        assert!(found.is_some(), "Should find circuit breaker in run_all");
11657        let result = found.unwrap();
11658        assert_eq!(result.group, IndicatorGroup::HealthOnly);
11659        assert_eq!(result.output.status, HealthStatus::Up);
11660        assert_eq!(result.output.details.get("state").unwrap(), "CLOSED");
11661        assert_eq!(
11662            result.output.details.get("slow_call_ratio").unwrap(),
11663            &serde_json::json!(0.0)
11664        );
11665
11666        breaker.after_call(false);
11667        breaker.after_call(false);
11668        assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
11669
11670        let results = registry.run_all().await;
11671        let found = results
11672            .iter()
11673            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11674        assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11675        assert_eq!(found.unwrap().output.details.get("state").unwrap(), "OPEN");
11676
11677        // Transition to HalfOpen manually to check status
11678        {
11679            let mut inner = breaker.inner.lock().unwrap();
11680            inner.state = crate::circuit_breaker::CircuitState::HalfOpen;
11681            inner.half_open_in_flight = 0;
11682            inner.half_open_successes = 0;
11683            inner.half_open_failures = 0;
11684        }
11685        assert_eq!(
11686            breaker.state(),
11687            crate::circuit_breaker::CircuitState::HalfOpen
11688        );
11689
11690        let results = registry.run_all().await;
11691        let found = results
11692            .iter()
11693            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11694        assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11695        assert_eq!(
11696            found.unwrap().output.details.get("state").unwrap(),
11697            "HALF_OPEN"
11698        );
11699
11700        let readiness_results = registry.run_readiness().await;
11701        let found_readiness = readiness_results
11702            .iter()
11703            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11704        assert!(
11705            found_readiness.is_none(),
11706            "Should NOT find circuit breaker in run_readiness"
11707        );
11708        crate::circuit_breaker::global_registry().clear();
11709    }
11710}
11711
11712#[cfg(test)]
11713mod havoc_proptest {
11714    use super::*;
11715    use proptest::prelude::*;
11716
11717    proptest! {
11718        #![proptest_config(ProptestConfig::with_cases(1))]
11719        #[test]
11720        fn log_levels_memory_exhaustion(names in proptest::collection::vec(".*", 5000)) {
11721            let levels = LogLevels::new("info");
11722            for name in names {
11723                let _ = levels.set_logger_level(&name, "debug");
11724            }
11725            assert!(levels.logger_overrides().len() <= 1000, "Memory leak: unbounded loggers inserted");
11726        }
11727    }
11728}
11729
11730// ── Nova: Actuator HTMX Dashboard UI ──────────────────────────
11731
11732#[cfg(all(feature = "maud", feature = "htmx"))]
11733async fn ui_dashboard() -> impl IntoResponse {
11734    let html = maud::html! {
11735        (maud::DOCTYPE)
11736        html lang="en" {
11737            head {
11738                meta charset="utf-8";
11739                meta name="viewport" content="width=device-width, initial-scale=1";
11740                title { "Autumn Actuator Dashboard" }
11741                script src="/static/js/htmx.min.js" {}
11742                style {
11743                    (crate::ui::tokens::TOKENS_CSS)
11744                    "body { font-family: var(--font-family); background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }"
11745                    "h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }"
11746                    ".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }"
11747                    ".card { background: var(--surface); padding: 1.5rem; border-radius: var(--radius); box-shadow: var(--shadow); }"
11748                    ".card h2 { font-size: 1.125rem; font-weight: 500; margin-top: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }"
11749                    ".stat { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }"
11750                    ".stat-label { color: var(--text-muted); }"
11751                    ".stat-value { font-weight: 500; }"
11752                    ".task-item { border: 1px solid var(--border); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 0.75rem; }"
11753                    ".task-name { font-weight: 600; display: block; margin-bottom: 0.25rem; }"
11754                    ".task-meta { font-size: 0.875rem; color: var(--text-muted); }"
11755                    ".badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }"
11756                    ".badge-green { background: #dcfce7; color: #166534; }"
11757                    ".badge-gray { background: #f3f4f6; color: #374151; }"
11758                    ".badge-red { background: #fee2e2; color: #991b1b; }"
11759                }
11760            }
11761            body {
11762                h1 { "🍂 Autumn Actuator Dashboard" }
11763                div class="grid" {
11764                    div class="card" hx-get="ui/metrics" hx-trigger="load, every 2s" {
11765                        "Loading metrics..."
11766                    }
11767                    div class="card" hx-get="ui/tasks" hx-trigger="load, every 2s" {
11768                        "Loading tasks..."
11769                    }
11770                }
11771            }
11772        }
11773    };
11774    (
11775        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11776        html.into_string(),
11777    )
11778}
11779
11780#[cfg(not(all(feature = "maud", feature = "htmx")))]
11781async fn ui_dashboard() -> impl IntoResponse {
11782    (
11783        StatusCode::NOT_IMPLEMENTED,
11784        "Maud feature is required for the UI dashboard",
11785    )
11786}
11787
11788#[cfg(all(feature = "maud", feature = "htmx"))]
11789async fn ui_metrics<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11790    let metrics = state.metrics().snapshot();
11791    let uptime = state.uptime_display();
11792
11793    let html = maud::html! {
11794        h2 { "System Metrics" }
11795        div class="stat" {
11796            span class="stat-label" { "Uptime" }
11797            span class="stat-value" { (uptime) }
11798        }
11799        div class="stat" {
11800            span class="stat-label" { "Total Requests" }
11801            span class="stat-value" { (metrics.http.requests_total) }
11802        }
11803        div class="stat" {
11804            span class="stat-label" { "Active Requests" }
11805            span class="stat-value" { (metrics.http.requests_active) }
11806        }
11807        div class="stat" {
11808            span class="stat-label" { "P95 Latency" }
11809            span class="stat-value" { (metrics.http.latency_ms.p95) " ms" }
11810        }
11811        div class="stat" {
11812            span class="stat-label" { "P99 Latency" }
11813            span class="stat-value" { (metrics.http.latency_ms.p99) " ms" }
11814        }
11815    };
11816    (
11817        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11818        html.into_string(),
11819    )
11820}
11821
11822#[cfg(not(all(feature = "maud", feature = "htmx")))]
11823async fn ui_metrics<S: ProvideActuatorState>() -> impl IntoResponse {
11824    (
11825        StatusCode::NOT_IMPLEMENTED,
11826        "Maud feature is required for the UI dashboard",
11827    )
11828}
11829
11830#[cfg(all(feature = "maud", feature = "htmx"))]
11831async fn ui_tasks<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11832    let tasks = state.task_registry().snapshot();
11833
11834    let html = maud::html! {
11835        h2 { "Background Tasks" }
11836        @if tasks.is_empty() {
11837            p class="stat-label" { "No tasks registered." }
11838        } @else {
11839            @for (name, task) in tasks.iter() {
11840                div class="task-item" {
11841                    span class="task-name" { (name) }
11842                    div class="task-meta" {
11843                        @if task.status == "running" {
11844                            span class="badge badge-green" { "Running" }
11845                        } @else {
11846                            span class="badge badge-gray" { "Idle" }
11847                        }
11848                        " "
11849                        "Runs: " (task.total_runs)
11850                        @if task.total_failures > 0 {
11851                            " " span class="badge badge-red" { "Failures: " (task.total_failures) }
11852                        }
11853                    }
11854                }
11855            }
11856        }
11857    };
11858    (
11859        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11860        html.into_string(),
11861    )
11862}
11863
11864#[cfg(not(all(feature = "maud", feature = "htmx")))]
11865async fn ui_tasks<S: ProvideActuatorState>() -> impl IntoResponse {
11866    (
11867        StatusCode::NOT_IMPLEMENTED,
11868        "Maud feature is required for the UI dashboard",
11869    )
11870}