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 a reference to the system [`crate::channels::Channels`] which
313    /// broadcasts operational events to WebSocket streams.
314    #[cfg(feature = "ws")]
315    fn channels(&self) -> &crate::channels::Channels;
316
317    /// Returns the main cancellation token that triggers a graceful framework shutdown.
318    #[cfg(feature = "ws")]
319    fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
320
321    /// Returns an optional reference to the database connection pool,
322    /// used to expose database connection metrics in the `/actuator/metrics` endpoint.
323    #[cfg(feature = "db")]
324    fn pool(
325        &self,
326    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>;
327
328    /// Returns the probe state that holds the cached primary database ping.
329    ///
330    /// `/actuator/health` uses this state to share one ping cache with
331    /// `/ready`. The default returns `None`. Then each request opens a new
332    /// connection and pings the primary with a 2 s limit. Override this
333    /// method to return the same state as `ProvideProbeState::probes`.
334    #[cfg(feature = "db")]
335    fn probe_state(&self) -> Option<&crate::probe::ProbeState> {
336        None
337    }
338
339    /// Returns the configured shard set, used to expose per-shard pool
340    /// metrics in the `/actuator/metrics` endpoint. Defaults to `None`.
341    #[cfg(feature = "db")]
342    fn shards(&self) -> Option<&crate::sharding::ShardSet> {
343        None
344    }
345
346    /// Returns the read-replica pool, used to export its gauges on
347    /// `/actuator/prometheus` with `pool="replica"`. Defaults to `None`.
348    #[cfg(feature = "db")]
349    fn replica_pool(
350        &self,
351    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
352    {
353        None
354    }
355
356    /// Returns the scaffold-level accessibility posture reported by `/actuator/a11y`.
357    ///
358    /// Override this in your `AppState` implementation to declare which
359    /// WCAG 2.1 AA scaffold concerns your application addresses.  The default
360    /// returns all-false (no concerns addressed) — a conservative safe default.
361    fn a11y_posture(&self) -> A11yPosture {
362        A11yPosture::default()
363    }
364
365    /// Returns the registry of plugin-contributed [`MetricsSource`] implementations.
366    ///
367    /// The default returns `None`, meaning no plugin sources are consulted.
368    /// [`crate::AppState`] overrides this to return its registry, which is
369    /// populated by [`crate::app::AppBuilder::metrics_source`].
370    fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
371        None
372    }
373
374    /// Returns the registry of [`HealthIndicator`] implementations.
375    ///
376    /// The default returns `None`, meaning no custom indicators are consulted.
377    /// [`crate::AppState`] overrides this to return its registry, which is
378    /// populated by [`crate::app::AppBuilder::health_indicator`].
379    fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
380        None
381    }
382
383    /// Returns whether detailed health information should be included in responses.
384    ///
385    /// When `false`, per-component `details` maps are omitted from
386    /// `/actuator/health` output. Defaults to `true`.
387    fn health_detailed(&self) -> bool {
388        true
389    }
390
391    /// Returns the deploy-version label for this replica (e.g. `"stable"` or
392    /// `"canary"`), used to tag Prometheus metrics so a canary controller can
393    /// compare canary vs. stable cohorts.
394    ///
395    /// Defaults to [`crate::canary::STABLE`]. [`crate::AppState`] overrides this
396    /// to return the value resolved from `AUTUMN_DEPLOY_VERSION` /
397    /// `AUTUMN_CANARY` (see [`crate::canary`]).
398    fn deploy_version(&self) -> String {
399        crate::canary::STABLE.to_owned()
400    }
401
402    #[cfg(feature = "http-client")]
403    /// Returns the optional webhook outbound manager if enabled/registered.
404    fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
405        None
406    }
407
408    /// Returns the in-memory log capture buffer, if capture is enabled.
409    ///
410    /// The default returns `None` (capture disabled). [`crate::AppState`]
411    /// overrides this to return the buffer installed at startup when
412    /// `log.capture.enabled = true`.
413    fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
414        None
415    }
416
417    /// Returns the serialized architecture graph served at `/actuator/graph`.
418    ///
419    /// The default returns `None`, and the endpoint answers `503`.
420    /// [`crate::AppState`] overrides this to return the graph that the
421    /// framework installed when it built this application's router.
422    fn architecture_graph_json(&self) -> Option<bytes::Bytes> {
423        None
424    }
425
426    /// Returns the shadow-mirroring handle, when this replica assembled a
427    /// mirror (`[shadow] enabled = true` with a reachable target configured).
428    ///
429    /// The default returns `None` — mirroring is off — and
430    /// `{prefix}/shadow` then reports a disabled mirror rather than 404ing, so
431    /// an operator turning the feature on can tell "not enabled here" apart
432    /// from "endpoint not mounted".
433    fn shadow(&self) -> Option<crate::shadow::ShadowHandle> {
434        None
435    }
436
437    /// Returns the data-capsule service of `{prefix}/capsules/*` (issue #1811).
438    ///
439    /// The default gives an error, so the endpoints answer `501`.
440    /// [`crate::AppState`] uses
441    /// [`CapsuleService::from_state`](crate::gdpr::portability::CapsuleService::from_state).
442    ///
443    /// # Errors
444    ///
445    /// [`CapsuleError`](crate::gdpr::portability::DataCapsuleError) when the
446    /// service is not configured.
447    fn data_capsules(
448        &self,
449    ) -> Result<crate::gdpr::portability::CapsuleService, crate::gdpr::portability::DataCapsuleError>
450    {
451        Err(crate::gdpr::portability::DataCapsuleError::NotConfigured(
452            "this state has no data-capsule service".to_owned(),
453        ))
454    }
455
456    /// Returns the cost signal (issue #1720). The default returns `None`.
457    fn cost_signal(&self) -> Option<crate::cost::CostSignal> {
458        None
459    }
460
461    /// Returns the cost accountant (issue #1720). The default returns `None`,
462    /// and `{prefix}/cost` then reports `"enabled": false`.
463    fn cost_accountant(&self) -> Option<crate::cost::CostAccountant> {
464        None
465    }
466}
467
468// ── Shared types for AppState ──────────────────────────────────
469
470/// Runtime log level management for the loggers actuator endpoint.
471///
472/// Stores the current effective log level and per-logger overrides.
473/// Changes are ephemeral -- they reset on restart.
474#[derive(Clone)]
475pub struct LogLevels {
476    inner: Arc<RwLock<LogLevelsInner>>,
477}
478
479struct LogLevelsInner {
480    /// The current global log level.
481    current_level: String,
482    /// Per-logger level overrides applied at runtime.
483    logger_overrides: HashMap<String, String>,
484    /// Handle for pushing level changes to the live `tracing` subscriber.
485    ///
486    /// `Some` once `AppBuilder` wires in the reload handle from the telemetry
487    /// guard; `None` in bare `LogLevels` (e.g. unit tests) where no
488    /// reload-capable subscriber is installed. When `None`, a level change is
489    /// recorded but cannot affect emission — the endpoint reports this honestly
490    /// rather than returning a false-positive `ok` (issue #1044).
491    reload_handle: Option<crate::telemetry::FilterReloadHandle>,
492}
493
494impl LogLevelsInner {
495    /// Build a combined `EnvFilter` directive from the global level plus every
496    /// per-target override, e.g. `"info,my_app::module=trace"`. Targets are
497    /// sorted for deterministic output.
498    fn build_directive(&self) -> String {
499        let mut parts = Vec::new();
500        if !self.current_level.is_empty() {
501            parts.push(self.current_level.clone());
502        }
503        let mut targets: Vec<String> = self
504            .logger_overrides
505            .iter()
506            .filter(|(name, _)| name.as_str() != "root" && !name.is_empty())
507            .map(|(name, level)| format!("{name}={level}"))
508            .collect();
509        targets.sort();
510        parts.extend(targets);
511        parts.join(",")
512    }
513}
514
515/// Split a startup `log.level` value — which may be a full `EnvFilter`
516/// directive such as `"info,tower_http=warn,my_app=debug"` — into a bare
517/// global level plus per-target overrides.
518///
519/// Seeding the overrides map at construction ensures that a later
520/// `PUT /actuator/loggers/root` (which replaces only the global level) does
521/// not silently drop the module-specific directives configured at startup.
522///
523/// Classification follows `EnvFilter` semantics:
524/// - A segment containing `=` is a per-target override (keyed on the part
525///   before the final `=`, so span-field directives round-trip). `root=<level>`
526///   and `=<level>` fold into the global level.
527/// - A bare segment that IS a tracing level (`trace|debug|info|warn|error|off`,
528///   case-insensitive) updates the global level, last one winning to match
529///   `EnvFilter` precedence.
530/// - A bare segment that is NOT a level is a *target directive at trace* (e.g.
531///   `my_app` means "enable target `my_app` at trace"), so it is stored as a
532///   per-target override with the implicit level `trace`.
533fn parse_initial_directive(directive: &str) -> (String, HashMap<String, String>) {
534    let mut global = String::new();
535    let mut overrides = HashMap::new();
536    for segment in directive.split(',') {
537        let segment = segment.trim();
538        if segment.is_empty() {
539            continue;
540        }
541        if let Some((target, level)) = segment.rsplit_once('=') {
542            let target = target.trim();
543            let level = level.trim();
544            // `root=<level>` / `=<level>` are just the global level in disguise.
545            if target.is_empty() || target == "root" {
546                global = level.to_string();
547            } else {
548                overrides.insert(target.to_string(), level.to_string());
549            }
550        } else if is_tracing_level(segment) {
551            // A bare level sets the global level (last-level-wins).
552            global = segment.to_string();
553        } else {
554            // A bare non-level segment is a target enabled at trace.
555            overrides.insert(segment.to_string(), "trace".to_string());
556        }
557    }
558    (global, overrides)
559}
560
561/// Returns `true` when `segment` is a bare `tracing` level keyword
562/// (case-insensitive), i.e. a global-level directive rather than a target.
563fn is_tracing_level(segment: &str) -> bool {
564    matches!(
565        segment.to_ascii_lowercase().as_str(),
566        "trace" | "debug" | "info" | "warn" | "error" | "off"
567    )
568}
569
570impl LogLevels {
571    /// Create a new `LogLevels` with the given initial level.
572    #[must_use]
573    pub fn new(initial_level: &str) -> Self {
574        let (current_level, logger_overrides) = parse_initial_directive(initial_level);
575        Self {
576            inner: Arc::new(RwLock::new(LogLevelsInner {
577                current_level,
578                logger_overrides,
579                reload_handle: None,
580            })),
581        }
582    }
583
584    /// Attach the live-subscriber reload handle produced by telemetry init.
585    ///
586    /// Called once by `AppBuilder` after the tracing subscriber is installed so
587    /// subsequent [`Self::set_logger_level`] calls take effect on the running
588    /// process (issue #1044).
589    pub fn attach_reload_handle(&self, handle: crate::telemetry::FilterReloadHandle) {
590        if let Ok(mut guard) = self.inner.write() {
591            guard.reload_handle = Some(handle);
592        }
593    }
594
595    /// Returns `true` when a reload-capable subscriber is installed, i.e. level
596    /// changes made via [`Self::set_logger_level`] actually reach the live
597    /// `tracing` subscriber.
598    #[must_use]
599    pub fn reload_available(&self) -> bool {
600        self.inner
601            .read()
602            .is_ok_and(|guard| guard.reload_handle.is_some())
603    }
604
605    /// Get the current global log level.
606    #[must_use]
607    pub fn current_level(&self) -> String {
608        self.inner
609            .read()
610            .map_or_else(|_| "info".to_string(), |guard| guard.current_level.clone())
611    }
612
613    /// Get all per-logger overrides.
614    #[must_use]
615    pub fn logger_overrides(&self) -> HashMap<String, String> {
616        self.inner
617            .read()
618            .map(|guard| guard.logger_overrides.clone())
619            .unwrap_or_default()
620    }
621
622    /// The combined `EnvFilter` directive currently pushed to the live
623    /// subscriber (global level plus per-target overrides). Test-only.
624    #[cfg(test)]
625    fn rebuilt_directive_for_test(&self) -> String {
626        self.inner
627            .read()
628            .map(|guard| guard.build_directive())
629            .unwrap_or_default()
630    }
631
632    /// Set the level for a specific logger.
633    ///
634    /// When a reload-capable subscriber is installed (see
635    /// [`Self::attach_reload_handle`]), the rebuilt filter directive is pushed
636    /// to the live `tracing` subscriber so the change takes effect immediately
637    /// on the next event (issue #1044). Overrides remain ephemeral — they live
638    /// only in this in-memory state and reset on process restart.
639    ///
640    /// The returned [`LogLevelChange`] reflects the **actual** outcome, not
641    /// merely handle presence:
642    /// - [`LogLevelChange::Applied`] — pushed to a live subscriber.
643    /// - [`LogLevelChange::Recorded`] — stored, but no reload-capable subscriber
644    ///   is installed (bare state / tests).
645    /// - [`LogLevelChange::Rejected`] — the map is at capacity, or the directive
646    ///   failed to apply and the override was **rolled back** so the map never
647    ///   claims a live override that isn't (issue #1044).
648    ///
649    /// The directive is applied while the write lock is held so that concurrent
650    /// callers apply directives in the same order they mutate the map — the live
651    /// subscriber can never disagree with `GET /loggers` (issue #1044 AC4).
652    /// `reload`/`apply` never re-enters `LogLevels`, so this is deadlock-free.
653    pub fn set_logger_level(&self, name: &str, level: &str) -> LogLevelChange {
654        let Ok(mut guard) = self.inner.write() else {
655            return LogLevelChange::Rejected {
656                reason: "log level state lock poisoned".to_string(),
657            };
658        };
659        // Prevent unbounded memory growth from arbitrary logger names
660        if guard.logger_overrides.len() >= 1000 && !guard.logger_overrides.contains_key(name) {
661            return LogLevelChange::Rejected {
662                reason: "too many logger overrides".to_string(),
663            };
664        }
665
666        let is_root = name == "root" || name.is_empty();
667        let previous_override = guard.logger_overrides.get(name).cloned();
668        let previous_current = guard.current_level.clone();
669
670        guard
671            .logger_overrides
672            .insert(name.to_string(), level.to_string());
673        // If setting the root level, update current_level too.
674        let returned = if is_root {
675            guard.current_level = level.to_string();
676            Some(previous_current.clone())
677        } else {
678            previous_override.clone()
679        };
680
681        let directive = guard.build_directive();
682        // Clone the handle out so the rollback path below can mutate `guard`
683        // without holding a borrow of `guard.reload_handle`.
684        let Some(handle) = guard.reload_handle.clone() else {
685            return LogLevelChange::Recorded { previous: returned };
686        };
687
688        match handle.apply_directive(&directive) {
689            Ok(()) => LogLevelChange::Applied { previous: returned },
690            Err(error) => {
691                // Roll back so `GET /loggers` never advertises an override that
692                // failed to reach the live subscriber.
693                match previous_override {
694                    Some(prev) => {
695                        guard.logger_overrides.insert(name.to_string(), prev);
696                    }
697                    None => {
698                        guard.logger_overrides.remove(name);
699                    }
700                }
701                if is_root {
702                    guard.current_level = previous_current;
703                }
704                LogLevelChange::Rejected { reason: error }
705            }
706        }
707    }
708}
709
710/// Outcome of [`LogLevels::set_logger_level`].
711#[derive(Debug, Clone, PartialEq, Eq)]
712#[must_use]
713pub enum LogLevelChange {
714    /// The change was pushed to a live, reload-capable subscriber. Carries the
715    /// previous level for that target, if any.
716    Applied {
717        /// Previous level for the target, if it had one.
718        previous: Option<String>,
719    },
720    /// The change was stored in the in-memory map, but no reload-capable
721    /// subscriber is installed, so it does not affect emission.
722    Recorded {
723        /// Previous level for the target, if it had one.
724        previous: Option<String>,
725    },
726    /// The change was not stored: the map was at capacity, or applying the
727    /// directive to the live subscriber failed and the override was rolled back.
728    Rejected {
729        /// Human-readable reason.
730        reason: String,
731    },
732}
733
734impl LogLevelChange {
735    /// The previous level for the target, if the change was stored.
736    #[must_use]
737    pub fn previous(&self) -> Option<&str> {
738        match self {
739            Self::Applied { previous } | Self::Recorded { previous } => previous.as_deref(),
740            Self::Rejected { .. } => None,
741        }
742    }
743}
744
745impl std::fmt::Debug for LogLevels {
746    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
747        f.debug_struct("LogLevels")
748            .field("current_level", &self.current_level())
749            .finish()
750    }
751}
752
753/// Scheduled task status information.
754#[derive(Debug, Clone, Serialize)]
755pub struct TaskStatus {
756    /// The schedule description (e.g., "every 5m" or "cron 0 0 * * *").
757    pub schedule: String,
758    /// Whether this task is coordinated across the fleet or per replica.
759    pub coordination: crate::task::TaskCoordination,
760    /// Scheduler backend currently coordinating this task.
761    pub scheduler_backend: String,
762    /// Replica id for this process.
763    pub replica_id: String,
764    /// Replica id that last acquired leadership for this task.
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub current_leader: Option<String>,
767    /// Last global tick key observed for this task.
768    #[serde(skip_serializing_if = "Option::is_none")]
769    pub last_tick: Option<String>,
770    /// Last time this task fired (ISO 8601), if ever.
771    #[serde(skip_serializing_if = "Option::is_none")]
772    pub last_fired_at: Option<String>,
773    /// Next scheduled run time (ISO 8601), if known.
774    #[serde(skip_serializing_if = "Option::is_none")]
775    pub next_run_at: Option<String>,
776    /// Current task state.
777    pub status: String,
778    /// Last time the task ran (ISO 8601), if ever.
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub last_run: Option<String>,
781    /// Duration of last run in milliseconds.
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub last_duration_ms: Option<u64>,
784    /// Result of last run.
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub last_result: Option<String>,
787    /// Last error message, if the task failed.
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub last_error: Option<String>,
790    /// Total number of times the task has run.
791    pub total_runs: u64,
792    /// Total number of failures.
793    pub total_failures: u64,
794}
795
796/// Registry of scheduled tasks and their runtime status.
797#[derive(Clone)]
798pub struct TaskRegistry {
799    inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
800}
801
802/// On-demand background job status information.
803#[derive(Debug, Clone, Serialize)]
804pub struct JobStatus {
805    /// Approximate queued jobs waiting to run.
806    pub queued: u64,
807    /// Number of currently running jobs.
808    pub in_flight: u64,
809    /// Approximate jobs currently waiting on a free concurrency slot.
810    pub blocked_on_concurrency: u64,
811    /// Total successful executions.
812    pub total_successes: u64,
813    /// Total failed executions.
814    pub total_failures: u64,
815    /// Total dead-lettered executions.
816    pub dead_letters: u64,
817    /// Total enqueues coalesced because a matching unique job was already held.
818    pub total_deduplicated: u64,
819    /// Last observed error for this job, if any.
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub last_error: Option<String>,
822}
823
824impl JobStatus {
825    const fn empty() -> Self {
826        Self {
827            queued: 0,
828            in_flight: 0,
829            blocked_on_concurrency: 0,
830            total_successes: 0,
831            total_failures: 0,
832            dead_letters: 0,
833            total_deduplicated: 0,
834            last_error: None,
835        }
836    }
837}
838
839/// Per-queue observability gauges for the actuator jobs endpoint (issue #1623,
840/// AC7): queue depth and the age of the oldest still-waiting job.
841///
842/// On the **local** backend (single process, single registry) these reflect
843/// enqueue/start events observed in this process via the `record_*` marks.
844/// On the **durable** backends (Postgres/Redis) they are backend-derived and
845/// authoritative (issue #1752): a periodic survey of the durable store
846/// wholesale-replaces this snapshot each tick, so an enqueue-only web replica
847/// reports the true shared backlog rather than its own local enqueue marks.
848#[derive(Debug, Clone, Serialize)]
849pub struct QueueStatus {
850    /// Jobs waiting to run on this queue.
851    pub depth: u64,
852    /// Age in milliseconds of the oldest still-waiting job on this queue
853    /// (`0` when the queue is empty).
854    pub oldest_waiting_age_ms: u64,
855}
856
857/// Per-queue depth/age bookkeeping backing [`QueueStatus`].
858#[derive(Default)]
859struct QueueGaugeState {
860    /// Job name → the queue it drains from.
861    name_to_queue: HashMap<String, String>,
862    /// Queue → ready-at timestamps (epoch ms) of jobs still waiting to start.
863    /// A mark whose ready-at is in the future belongs to a scheduled (delayed)
864    /// job that is not yet claimable, so it is excluded from ready depth/age
865    /// until its ready-at time passes.
866    ///
867    /// Only authoritative on the local backend. On the durable backends this
868    /// still records marks (harmless) but [`Self::surveyed`] overrides them.
869    waiting: HashMap<String, std::collections::VecDeque<u64>>,
870    /// Backend-derived per-queue gauges from the durable survey (issue #1752).
871    /// When `Some`, this authoritative snapshot — refreshed each survey tick
872    /// from the durable store — backs [`JobRegistry::queue_snapshot`] instead
873    /// of the per-process `waiting` marks. `None` on the local backend, which
874    /// keeps the in-memory mark path. Each value is `(ready depth, oldest
875    /// ready-at epoch ms)`; the reported age is derived from the timestamp at
876    /// snapshot time so it stays fresh between surveys.
877    ///
878    /// **The timestamps here must be on the registry's clock.** The age is
879    /// freshened as `registry_now - ready_at`, so a surveyor whose marks live on
880    /// a different clock has to rebase them before calling
881    /// [`JobRegistry::set_queue_depth_gauges`] — the Postgres survey asks the
882    /// database for an *age* and subtracts it from its own reading of the
883    /// injected clock for exactly this reason, while the redis survey already
884    /// stamps from that clock and passes its marks through unchanged.
885    surveyed: Option<HashMap<String, (u64, Option<u64>)>>,
886    /// Postgres-backed jobs' own waiting mark, by job id, so an admin-cancel
887    /// (`JobRegistry::record_cancel_at_backend_offset`) can remove precisely
888    /// the mark it pushed instead of guessing which of several co-queued
889    /// marks belongs to it — a guess that can land on the wrong job when two
890    /// marks happen to sit at (or near) the same instant. `Self::record_pg_start`
891    /// removes a job's entry the moment its mark is popped from the waiting
892    /// queue (a non-terminal retry re-adds it with the retry's own mark), so
893    /// growth tracks live queue residency, not lifetime enqueue count.
894    /// Bounded by [`PG_MARKS_BY_JOB_ID_CAP`] regardless, as a backstop for
895    /// jobs enqueued but never claimed (a crashed worker, a queue with no
896    /// consumer) — without it those would still leak one entry each forever.
897    ///
898    /// Removed with [`indexmap::IndexMap::swap_remove_index`], not
899    /// `shift_remove_index`: this map's *physical* order is never read (each
900    /// [`PgMark`] carries its own [`PgMark::seq`] for that), so there is no
901    /// reason to pay `shift_remove_index`'s O(n) shift-every-later-entry cost
902    /// on every capacity eviction — under sustained enqueue-only load past
903    /// the cap, that is *every* subsequent enqueue.
904    pg_marks_by_job_id: indexmap::IndexMap<String, PgMark>,
905    /// Monotonic counter handing out each new [`PgMark`]'s [`PgMark::seq`].
906    pg_marks_next_seq: u64,
907    /// `seq` → id, mirroring [`Self::pg_marks_by_job_id`]'s keys in
908    /// insertion order. Eviction walks this ascending (oldest first) and
909    /// stops at the first entry that is actually due, rather than scanning
910    /// every entry in [`Self::pg_marks_by_job_id`] to find the *provably
911    /// oldest* due one: any due entry is an equally valid eviction target
912    /// (nothing here relies on evicting the single oldest of them), and an
913    /// older entry has had more time to become due, so this walk is O(1) in
914    /// the common case — a long-lived backlog past the cap keeps its oldest
915    /// entries genuinely overdue, not its newest ones. Only a workload where
916    /// no entry is ever due (every mark carries a far-future instant) still
917    /// walks every entry, matching the fallback's own already-O(n) oldest
918    /// lookup in that case.
919    pg_marks_seq_order: std::collections::BTreeMap<u64, String>,
920}
921
922impl QueueGaugeState {
923    /// The queue `name` drains from (`default` when unregistered), borrowed
924    /// from the registration map rather than copied out per lookup.
925    fn queue_of(&self, name: &str) -> &str {
926        self.name_to_queue
927            .get(name)
928            .map_or("default", String::as_str)
929    }
930
931    /// The waiting marks of `name`'s queue, when that queue has an entry.
932    fn waiting_for_mut(&mut self, name: &str) -> Option<&mut std::collections::VecDeque<u64>> {
933        // Disjoint fields: `name_to_queue` is read while `waiting` is borrowed
934        // mutably, so the queue name is never copied out.
935        let queue = self
936            .name_to_queue
937            .get(name)
938            .map_or("default", String::as_str);
939        self.waiting.get_mut(queue)
940    }
941}
942
943/// Cap on [`QueueGaugeState::pg_marks_by_job_id`]. Comfortably above any
944/// realistic count of Postgres jobs sitting `enqueued` at once — enough that
945/// a job an operator wants to cancel is essentially always still tracked —
946/// while keeping the map's memory bounded no matter how large an unclaimed
947/// backlog grows.
948const PG_MARKS_BY_JOB_ID_CAP: usize = 10_000;
949
950/// Which clock a [`PgMark`]'s `ms` value is measured on.
951///
952/// `note_pg_job_mark`'s eviction used to compare every entry against both
953/// the registry's own clock and real time, requiring both to agree an entry
954/// was due before evicting it — safe against wrongly evicting a
955/// still-delayed entry, but it can also make eviction blind: a `FixedClock`/
956/// `TickingClock` registry clock that has drifted *behind* real time (not
957/// just ahead, the case that motivated the both-clocks check) makes every
958/// real-timeline mark compare as "not yet due" against the registry clock
959/// forever, however far in the real past it actually is, so eviction can
960/// never recognize any of them as safe to evict and falls back to
961/// oldest-inserted — which can just as easily be the one genuinely-delayed
962/// entry sitting first among thousands of already-claimed ones. Tagging each
963/// mark with the one clock that actually measures it (known unambiguously at
964/// insertion time: [`JobRegistry::record_pg_enqueue`] passes a real instant
965/// for an absolute/relative-delay enqueue, `None` — resolved from the
966/// registry's own [`JobRegistry::now_ms`] — for an immediate one) lets
967/// eviction judge each entry correctly instead of guessing from its
968/// magnitude against a clock that may not be its own.
969#[derive(Clone, Copy, Debug, PartialEq, Eq)]
970pub(crate) enum PgMarkTimeline {
971    /// A real epoch-ms instant: an `enqueue_at`/`enqueue_in` mark, forwarded
972    /// unchanged or reconstructed via [`JobClient::due_origin`]'s real-time
973    /// reading — the same timeline Postgres's own `clock_timestamp()`
974    /// measures `run_at` against.
975    Real,
976    /// The registry's own (possibly injected/virtual) clock reading at
977    /// enqueue time — an immediate enqueue's mark.
978    Registry,
979}
980
981/// A [`QueueGaugeState::pg_marks_by_job_id`] entry: the raw waiting-queue
982/// mark plus which clock ([`PgMarkTimeline`]) it is measured on, so eviction
983/// can judge "due" against the one clock that actually applies to it.
984#[derive(Clone, Copy, Debug)]
985struct PgMark {
986    ms: u64,
987    timeline: PgMarkTimeline,
988    /// This entry's position in insertion order, from
989    /// [`QueueGaugeState::pg_marks_next_seq`] — capacity eviction's
990    /// "oldest" fallback reads this instead of the map's physical position,
991    /// so removal can use O(1) `swap_remove_index` instead of an
992    /// order-preserving (and so O(n)) shift.
993    seq: u64,
994}
995
996/// Rebase a surveyed age onto the registry's timeline.
997///
998/// A surveyor whose marks live on another clock — the Postgres survey, whose
999/// `run_at` values and readiness filter are both the database's — asks its own
1000/// source how long the oldest ready job has waited, then converts that age into
1001/// a ready-at instant on the registry's clock with this. Both subtractions then
1002/// stay within one timeline: the database's for the age, the registry's for the
1003/// freshening at read time.
1004#[must_use]
1005pub const fn ready_at_from_age(registry_now_ms: u64, age_ms: u64) -> u64 {
1006    registry_now_ms.saturating_sub(age_ms)
1007}
1008
1009/// Real epoch milliseconds, for tests that build fixture timestamps against a
1010/// default (real-clock) registry.
1011///
1012/// **Test-only on purpose.** Production gauge code must read
1013/// [`JobRegistry::now_ms`] instead: the waiting marks are stamped from the job
1014/// runtime's injected clock, and comparing them against the real clock is the
1015/// mixed-timeline bug this is confined to prevent.
1016#[cfg(test)]
1017fn now_epoch_ms() -> u64 {
1018    std::time::SystemTime::now()
1019        .duration_since(std::time::UNIX_EPOCH)
1020        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
1021}
1022
1023/// Registry of ad-hoc jobs and their runtime status.
1024#[derive(Clone)]
1025pub struct JobRegistry {
1026    inner: Arc<RwLock<HashMap<String, JobStatus>>>,
1027    queues: Arc<RwLock<QueueGaugeState>>,
1028    /// Clock the queue gauges stamp and compare their epoch-ms marks on.
1029    ///
1030    /// The waiting marks hold a *ready-at* instant supplied by the job runtime
1031    /// (`record_enqueue_scheduled`), which reads the runtime's injected clock.
1032    /// A gauge that compared those marks against `SystemTime::now()` would be
1033    /// mixing timelines: under a `#[sim_test]` a job delayed from the 2020 sim
1034    /// epoch reads as ready immediately on a 2026 host, and `/actuator/jobs`
1035    /// reports a nonzero ready depth with roughly six years of waiting age
1036    /// before `Sim::advance` ever reaches the deadline.
1037    ///
1038    /// Defaults to the real clock; [`AppState::with_clock`](crate::AppState)
1039    /// installs the app's.
1040    ///
1041    /// **Shared, exactly like `queues`.** A clone of this registry keeps the
1042    /// same marks, so it has to keep the same clock — a per-handle clock lets
1043    /// one handle stamp a ready-at on a virtual timeline that another reads back
1044    /// against `SystemClock`, and the two report different depths and ages for
1045    /// one set of marks. Installing a clock therefore travels to every clone.
1046    clock: Arc<RwLock<Arc<dyn crate::time::ClockSource>>>,
1047    /// Redis dead letters this process removed to obey
1048    /// `jobs.redis.dead_letter_limit` (issue #3055). Shared by all clones.
1049    dead_letter_trimmed: Arc<std::sync::atomic::AtomicU64>,
1050}
1051
1052impl JobRegistry {
1053    /// Create a new empty job registry reading the real system clock.
1054    ///
1055    /// Use [`Self::with_clock`] to put its gauges on an injected timeline.
1056    #[must_use]
1057    pub fn new() -> Self {
1058        Self {
1059            inner: Arc::new(RwLock::new(HashMap::new())),
1060            queues: Arc::new(RwLock::new(QueueGaugeState::default())),
1061            clock: Arc::new(RwLock::new(
1062                Arc::new(crate::time::SystemClock) as Arc<dyn crate::time::ClockSource>
1063            )),
1064            dead_letter_trimmed: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1065        }
1066    }
1067
1068    /// Return this registry with its queue gauges reading `clock`.
1069    ///
1070    /// Builder form, for wiring a registry at construction. It reaches **every
1071    /// clone** of this registry, since they all share one set of marks and must
1072    /// judge them on one clock — the clock lives in a shared cell beside the
1073    /// marks, not on the handle.
1074    #[must_use]
1075    pub fn with_clock(self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
1076        self.install_clock(clock);
1077        self
1078    }
1079
1080    /// Put this registry's queue gauges on `clock`.
1081    ///
1082    /// Writes through the shared clock cell, so a registry already cloned into
1083    /// a running job client or another `AppState` moves onto the new timeline
1084    /// too. That is the point: the clones share `queues`, and marks stamped by
1085    /// one must be judged the same way by all of them.
1086    pub(crate) fn install_clock(&self, clock: Arc<dyn crate::time::ClockSource>) {
1087        let mut slot = self
1088            .clock
1089            .write()
1090            .unwrap_or_else(std::sync::PoisonError::into_inner);
1091        *slot = clock;
1092    }
1093
1094    /// The gauges' current instant in epoch milliseconds, on the injected
1095    /// clock — the timeline every mark this registry holds is stamped on.
1096    ///
1097    /// Both mark sources have to agree with this: the local `waiting` marks come
1098    /// from the job runtime's injected clock, and a surveyed mark is rebased
1099    /// onto that clock by its surveyor (see [`QueueGaugeState::surveyed`]).
1100    /// Mixing in a mark stamped elsewhere — a database `run_at`, a real
1101    /// `SystemTime` reading — is what makes an age come back as years or zero.
1102    fn now_ms(&self) -> u64 {
1103        // Recover rather than drop the reading on a poisoned lock: the cell holds
1104        // an immutable `Arc` handle, so a panic elsewhere leaves it perfectly
1105        // readable, and falling back to real time here would reintroduce exactly
1106        // the mixed timeline this field exists to prevent.
1107        let clock = self
1108            .clock
1109            .read()
1110            .unwrap_or_else(std::sync::PoisonError::into_inner);
1111        u64::try_from(clock.now().timestamp_millis()).unwrap_or(0)
1112    }
1113
1114    /// Whether any job name has been registered against this registry.
1115    ///
1116    /// Job names are registered when a job runtime starts, so this doubles as
1117    /// "has the state owning this registry moved past construction?" — see
1118    /// [`AppState::with_clock`](crate::AppState::with_clock), which is only
1119    /// meaningful before that point.
1120    #[must_use]
1121    pub fn is_initialized(&self) -> bool {
1122        !self
1123            .inner
1124            .read()
1125            .unwrap_or_else(std::sync::PoisonError::into_inner)
1126            .is_empty()
1127    }
1128
1129    /// Register a job name with initial counters.
1130    pub fn register(&self, name: &str) {
1131        if let Ok(mut guard) = self.inner.write() {
1132            guard.entry(name.to_string()).or_insert(JobStatus::empty());
1133        }
1134    }
1135
1136    /// Register a job name and the queue it drains from, so per-queue depth and
1137    /// oldest-waiting-age gauges (AC7) can be attributed to the right queue.
1138    pub fn register_on_queue(&self, name: &str, queue: &str) {
1139        self.register(name);
1140        if let Ok(mut guard) = self.queues.write() {
1141            guard
1142                .name_to_queue
1143                .insert(name.to_string(), queue.to_string());
1144            guard.waiting.entry(queue.to_string()).or_default();
1145        }
1146    }
1147
1148    /// Snapshot per-queue depth and oldest-waiting-job age.
1149    ///
1150    /// On the durable backends a periodic survey has populated an authoritative
1151    /// snapshot (via [`Self::set_queue_depth_gauges`]); it takes precedence over
1152    /// the per-process `waiting` marks so an enqueue-only replica reports the
1153    /// true shared backlog. On the local backend the survey is absent and the
1154    /// in-memory marks drive the gauges.
1155    #[must_use]
1156    pub fn queue_snapshot(&self) -> HashMap<String, QueueStatus> {
1157        let now = self.now_ms();
1158        self.queues
1159            .read()
1160            .map(|g| {
1161                if let Some(surveyed) = &g.surveyed {
1162                    // Durable backend: the survey is authoritative. Report every
1163                    // known queue (registered locally or seen in the survey);
1164                    // queues absent from the latest survey reset to depth 0 so
1165                    // stale backlog never leaks between ticks.
1166                    return g
1167                        .waiting
1168                        .keys()
1169                        .chain(surveyed.keys())
1170                        .cloned()
1171                        .collect::<std::collections::HashSet<String>>()
1172                        .into_iter()
1173                        .map(|queue| {
1174                            let (depth, oldest_ready_at) =
1175                                surveyed.get(&queue).copied().unwrap_or((0, None));
1176                            let oldest_waiting_age_ms =
1177                                oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1178                            (
1179                                queue,
1180                                QueueStatus {
1181                                    depth,
1182                                    oldest_waiting_age_ms,
1183                                },
1184                            )
1185                        })
1186                        .collect();
1187                }
1188                g.waiting
1189                    .iter()
1190                    .map(|(queue, waiting)| {
1191                        // Count only marks whose ready-at time has arrived; a
1192                        // future ready-at is a scheduled job that is not yet
1193                        // claimable and must not read as ready backlog.
1194                        let mut depth = 0u64;
1195                        let mut oldest_ready_at: Option<u64> = None;
1196                        for ready_at in waiting {
1197                            if *ready_at <= now {
1198                                depth += 1;
1199                                oldest_ready_at =
1200                                    Some(oldest_ready_at.map_or(*ready_at, |o| o.min(*ready_at)));
1201                            }
1202                        }
1203                        let oldest_waiting_age_ms =
1204                            oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1205                        (
1206                            queue.clone(),
1207                            QueueStatus {
1208                                depth,
1209                                oldest_waiting_age_ms,
1210                            },
1211                        )
1212                    })
1213                    .collect()
1214            })
1215            .unwrap_or_default()
1216    }
1217
1218    /// Record that a new job instance was enqueued and is immediately runnable.
1219    pub fn record_enqueue(&self, name: &str) {
1220        self.record_enqueue_at(name, self.now_ms());
1221    }
1222
1223    /// Record a delayed enqueue whose job only becomes claimable at
1224    /// `ready_at_ms` (epoch ms). Until that instant the job is tracked as
1225    /// scheduled rather than ready queue depth, so future-dated jobs enqueued
1226    /// via `enqueue_in`/`enqueue_at` do not inflate `queues.<name>.depth` or
1227    /// `oldest_waiting_age_ms` (which would fire false backlog alerts).
1228    pub fn record_enqueue_scheduled(&self, name: &str, ready_at_ms: u64) {
1229        self.record_enqueue_at(name, ready_at_ms);
1230    }
1231
1232    /// [`Self::record_enqueue`]/[`Self::record_enqueue_scheduled`] for a
1233    /// Postgres-backed job, additionally remembering the exact mark pushed
1234    /// under `id` (`note_pg_job_mark`) for a later
1235    /// [`Self::record_cancel_at_backend_offset`]'s exact lookup.
1236    ///
1237    /// `ready_at_ms` is `Some` for a scheduled enqueue (its mark, stamped
1238    /// from whichever clock `timeline` names) or `None` for an immediate
1239    /// one (always this registry's own clock, read once here rather than
1240    /// inside [`Self::record_enqueue`] so the exact same value can be
1241    /// remembered — `timeline` is ignored in this case).
1242    ///
1243    /// `timeline` must name the clock `ready_at_ms` was actually measured
1244    /// on — the caller's job, not something this method can infer from
1245    /// `ready_at_ms` being present. A Postgres retry mirroring the nack
1246    /// UPDATE's `run_at = NOW() + backoff` locally, for instance, still
1247    /// reads `state.clock()` (the registry's own, [`PgMarkTimeline::Registry`])
1248    /// even though it always supplies `Some`, unlike an `enqueue_at`/
1249    /// `enqueue_in` mark, which is real time ([`PgMarkTimeline::Real`]) —
1250    /// `Some`-ness alone does not say which.
1251    pub(crate) fn record_pg_enqueue(
1252        &self,
1253        name: &str,
1254        id: &str,
1255        ready_at_ms: Option<u64>,
1256        timeline: PgMarkTimeline,
1257    ) {
1258        let (ready_at_ms, timeline) = ready_at_ms.map_or_else(
1259            || (self.now_ms(), PgMarkTimeline::Registry),
1260            |ms| (ms, timeline),
1261        );
1262        self.record_enqueue_at(name, ready_at_ms);
1263        self.note_pg_job_mark(id, ready_at_ms, timeline);
1264    }
1265
1266    /// Shared enqueue bookkeeping: bump the per-name `queued` counter and push a
1267    /// per-queue waiting mark stamped with the job's ready-at time.
1268    fn record_enqueue_at(&self, name: &str, ready_at_ms: u64) {
1269        // Probe both maps by borrow: the name is registered at runtime start,
1270        // so owned keys are only built for a never-seen name or queue.
1271        if let Ok(mut guard) = self.inner.write() {
1272            #[allow(clippy::option_if_let_else)] // the else branch re-borrows the map mutably
1273            if let Some(status) = guard.get_mut(name) {
1274                status.queued = status.queued.saturating_add(1);
1275            } else {
1276                guard.insert(
1277                    name.to_owned(),
1278                    JobStatus {
1279                        queued: 1,
1280                        ..JobStatus::empty()
1281                    },
1282                );
1283            }
1284        }
1285        if let Ok(mut guard) = self.queues.write() {
1286            #[allow(clippy::option_if_let_else)] // the else branch re-borrows the map mutably
1287            if let Some(waiting) = guard.waiting_for_mut(name) {
1288                waiting.push_back(ready_at_ms);
1289            } else {
1290                let queue = guard.queue_of(name).to_owned();
1291                guard
1292                    .waiting
1293                    .entry(queue)
1294                    .or_default()
1295                    .push_back(ready_at_ms);
1296            }
1297        }
1298    }
1299
1300    /// Record that an enqueue was coalesced into an existing unique job.
1301    ///
1302    /// Reverses the `record_enqueue` bookkeeping for the coalesced instance
1303    /// and bumps the deduplication counter.
1304    ///
1305    /// `had_enqueue_mark` says whether this coalesced job previously pushed a
1306    /// per-queue waiting mark (via `record_enqueue`/`record_enqueue_scheduled`).
1307    /// Only then is a mark popped: retry-dedup paths coalesce a job that already
1308    /// left the ready set at start time and never re-recorded an enqueue, so
1309    /// popping there would steal a *different* waiting job's mark and under-report
1310    /// that queue's depth. Every pop must correspond to a prior push.
1311    ///
1312    /// `was_scheduled` says which category the coalesced enqueue recorded: a
1313    /// delayed enqueue (`record_enqueue_scheduled`, future ready-at) pushed a
1314    /// *scheduled* mark, an immediate one (`record_enqueue`) a *ready* mark. The
1315    /// removal must target that same category — otherwise a delayed duplicate's
1316    /// dedup could pop a co-queued *ready* job's mark, reporting queue depth 0
1317    /// while ready work is still waiting (and vice versa). Mirrors the
1318    /// category-aware cancel path (`record_cancel`/`record_cancel_scheduled`).
1319    /// Ignored when `had_enqueue_mark` is false (no mark is popped).
1320    pub fn record_deduplicated(&self, name: &str, had_enqueue_mark: bool, was_scheduled: bool) {
1321        if let Ok(mut guard) = self.inner.write()
1322            && let Some(status) = guard.get_mut(name)
1323        {
1324            status.queued = status.queued.saturating_sub(1);
1325            status.total_deduplicated = status.total_deduplicated.saturating_add(1);
1326        }
1327        if !had_enqueue_mark {
1328            return;
1329        }
1330        // The coalesced enqueue never runs; drop its waiting mark from the SAME
1331        // ready/scheduled category it recorded (prefer_ready = !was_scheduled),
1332        // so it cannot steal a co-queued mark from the other category.
1333        self.pop_waiting(name, !was_scheduled);
1334    }
1335
1336    /// Record that a job is parked waiting on a free concurrency slot.
1337    pub fn record_concurrency_blocked(&self, name: &str) {
1338        if let Ok(mut guard) = self.inner.write()
1339            && let Some(status) = guard.get_mut(name)
1340        {
1341            status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_add(1);
1342        }
1343    }
1344
1345    /// Record that a parked job was released back to the queue.
1346    pub fn record_concurrency_unblocked(&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_sub(1);
1351        }
1352    }
1353
1354    /// Replace the blocked-on-concurrency gauges from a backend-wide survey.
1355    ///
1356    /// Names absent from `counts` are reset to zero. Used by the durable
1357    /// backends whose blocked set is observed periodically rather than
1358    /// tracked per event.
1359    pub fn set_concurrency_blocked_counts(&self, counts: &HashMap<String, u64>) {
1360        if let Ok(mut guard) = self.inner.write() {
1361            for (name, status) in guard.iter_mut() {
1362                status.blocked_on_concurrency = counts.get(name).copied().unwrap_or(0);
1363            }
1364        }
1365    }
1366
1367    /// Replace the per-queue depth/oldest-age gauges from a backend-wide survey
1368    /// (issue #1752).
1369    ///
1370    /// On the durable backends (Postgres/Redis) a queue is drained by other
1371    /// processes, so the authoritative ready depth and oldest-waiting age come
1372    /// from a periodic survey of the durable store rather than this process's
1373    /// local enqueue marks. This wholesale-replaces the snapshot each tick:
1374    /// queues absent from `per_queue` reset to depth 0 (no leaks). Each value
1375    /// is `(ready depth, oldest ready-at epoch ms)`; the reported age is
1376    /// derived from the timestamp at [`Self::queue_snapshot`] time so it stays
1377    /// fresh between surveys. Once called, the survey overrides the in-memory
1378    /// `record_*` marks for the `queues` gauge family.
1379    pub fn set_queue_depth_gauges(&self, per_queue: &HashMap<String, (u64, Option<u64>)>) {
1380        if let Ok(mut guard) = self.queues.write() {
1381            guard.surveyed = Some(per_queue.clone());
1382        }
1383    }
1384
1385    /// Replace the per-job-type `queued` gauge from a backend-wide survey
1386    /// (issue #1752).
1387    ///
1388    /// Names absent from `counts` reset to zero, mirroring
1389    /// [`Self::set_concurrency_blocked_counts`]. Used by the durable backends
1390    /// whose ready depth is observed periodically rather than tracked per
1391    /// enqueue/start event, so the reported `jobs.<name>.queued` reflects the
1392    /// shared durable backlog instead of this process's local enqueue marks.
1393    pub fn set_queued_counts(&self, counts: &HashMap<String, u64>) {
1394        if let Ok(mut guard) = self.inner.write() {
1395            for (name, status) in guard.iter_mut() {
1396                status.queued = counts.get(name).copied().unwrap_or(0);
1397            }
1398        }
1399    }
1400
1401    /// Record that a queued job started execution.
1402    pub fn record_start(&self, name: &str) {
1403        if let Ok(mut guard) = self.inner.write()
1404            && let Some(status) = guard.get_mut(name)
1405        {
1406            status.queued = status.queued.saturating_sub(1);
1407            status.in_flight = status.in_flight.saturating_add(1);
1408        }
1409        self.pop_waiting(name, true);
1410    }
1411
1412    /// [`Self::record_start`] for a Postgres-backed job, additionally
1413    /// removing `id`'s own `pg_marks_by_job_id` entry — precisely, not via
1414    /// the generic [`Self::pop_waiting`] plain `record_start` delegates to.
1415    ///
1416    /// `pop_waiting` does not know about job identity: it removes whichever
1417    /// ready mark comes first in this queue's internal order, which is not
1418    /// necessarily `id`'s own mark — Postgres can start a later-enqueued job
1419    /// ahead of an older one this process still has concurrency-blocked.
1420    /// Popping generically and then unconditionally forgetting `id`'s entry
1421    /// would tear down `id`'s mapping even when its actual mark never left
1422    /// the queue, while some other still-waiting job's mark vanished in its
1423    /// place. Look up and remove `id`'s own mark first instead, falling back
1424    /// to the generic pop (matching plain `record_start`) only when no exact
1425    /// entry exists or it is already stale — the same stale-degrades-to-
1426    /// fallback pattern [`Self::record_cancel_at_backend_offset`] uses — so a
1427    /// cancel racing this start never finds a torn-down mapping for a mark
1428    /// that in fact never left the queue. A non-terminal retry re-adds the
1429    /// entry with the retry's own mark via [`Self::record_pg_enqueue`]; a
1430    /// job that succeeds or terminally fails never needs it again.
1431    #[cfg_attr(not(feature = "postgres"), allow(dead_code))]
1432    pub(crate) fn record_pg_start(&self, name: &str, id: &str) {
1433        if let Ok(mut guard) = self.inner.write()
1434            && let Some(status) = guard.get_mut(name)
1435        {
1436            status.queued = status.queued.saturating_sub(1);
1437            status.in_flight = status.in_flight.saturating_add(1);
1438        }
1439        let exact_ms = self
1440            .queues
1441            .write()
1442            .ok()
1443            .and_then(|mut guard| {
1444                let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1445                guard.pg_marks_seq_order.remove(&removed.seq);
1446                Some(removed)
1447            })
1448            .map(|mark| mark.ms);
1449        let removed_exact = exact_ms.is_some_and(|exact_ms| self.pop_waiting_exact(name, exact_ms));
1450        if !removed_exact {
1451            self.pop_waiting(name, true);
1452        }
1453    }
1454
1455    /// Forget `id`'s `pg_marks_by_job_id` entry without touching any waiting
1456    /// mark — for an enqueue attempt that [`Self::record_pg_enqueue`]
1457    /// registered provisionally but that never became a real queued row: a
1458    /// dedup coalesce into an existing unique job, a backend error, or an
1459    /// interceptor that skipped the actual enqueue. That `id` will never be
1460    /// looked up by an admin-cancel — no row was ever inserted under it — so
1461    /// leaving the entry in place would only crowd out a genuinely
1462    /// still-queued job's mapping under [`PG_MARKS_BY_JOB_ID_CAP`] for no
1463    /// benefit. A no-op if `id` was never entered (a non-Postgres backend,
1464    /// or an entry already removed).
1465    pub(crate) fn forget_pg_job_mark(&self, id: &str) {
1466        if let Ok(mut guard) = self.queues.write()
1467            && let Some(removed) = guard.pg_marks_by_job_id.swap_remove(id)
1468        {
1469            guard.pg_marks_seq_order.remove(&removed.seq);
1470        }
1471    }
1472
1473    /// Record that a queued job was canceled before execution.
1474    pub fn record_cancel(&self, name: &str) {
1475        if let Ok(mut guard) = self.inner.write()
1476            && let Some(status) = guard.get_mut(name)
1477        {
1478            status.queued = status.queued.saturating_sub(1);
1479        }
1480        self.pop_waiting(name, true);
1481    }
1482
1483    /// Record that a scheduled (delayed) job was canceled before its ready time.
1484    ///
1485    /// Unlike [`Self::record_cancel`], this removes a *scheduled* waiting mark
1486    /// (ready-at still in the future) so canceling a not-yet-runnable job does
1487    /// not consume a ready job's mark and under-report queue depth.
1488    pub fn record_cancel_scheduled(&self, name: &str) {
1489        if let Ok(mut guard) = self.inner.write()
1490            && let Some(status) = guard.get_mut(name)
1491        {
1492            status.queued = status.queued.saturating_sub(1);
1493        }
1494        self.pop_waiting(name, false);
1495    }
1496
1497    /// Remember the exact per-queue waiting mark a Postgres-backed enqueue
1498    /// pushed, keyed by job id, so a later [`Self::record_cancel_at_backend_offset`]
1499    /// can remove precisely that mark instead of guessing which of several
1500    /// co-queued marks belongs to it.
1501    ///
1502    /// Bounded by [`PG_MARKS_BY_JOB_ID_CAP`]: a job that falls out (or was
1503    /// never entered — see that method's fallback) is not lost, it just
1504    /// falls back to candidate-based matching there.
1505    ///
1506    /// This table is per-process, in-memory state — in a split web/worker
1507    /// deployment, a web replica that only enqueues (`run_workers == false`,
1508    /// see `start_postgres_runtime`) never calls [`Self::record_pg_start`],
1509    /// so every entry it inserts would otherwise sit here until capacity
1510    /// eviction, regardless of whether some other replica has long since
1511    /// claimed and finished the job. Eviction therefore prefers an entry
1512    /// that is already due: Postgres never hands a row to a claimer before
1513    /// its own `run_at`, so an entry still in the future is *guaranteed* to
1514    /// be a live, uncontested "enqueued but not yet claimable" mark, while
1515    /// an already-due one may well have been claimed by this process or
1516    /// another already.
1517    ///
1518    /// "Already due" has to be judged carefully: values in this table sit on
1519    /// two different timelines depending on how they were enqueued — an
1520    /// absolute (`enqueue_at`) or relative-delay (`enqueue_in`) mark is real
1521    /// time (`JobClient::due_origin`'s Postgres convention), while an
1522    /// immediate one is this registry's own (possibly injected/virtual)
1523    /// clock (see [`Self::record_pg_enqueue`]). Each entry carries a
1524    /// [`PgMarkTimeline`] tag saying which, fixed unambiguously at
1525    /// insertion time, so eviction judges every mark against the one clock
1526    /// that actually measures it instead of guessing from its magnitude —
1527    /// comparing a mark against the *other*, irrelevant clock (or requiring
1528    /// both to agree, an earlier version of this check) can misjudge it
1529    /// whenever the registry clock diverges from wall time in either
1530    /// direction: a `FixedClock`/`TickingClock` far ahead of real time can
1531    /// make a genuinely-still-delayed real-time mark look "due" against the
1532    /// registry clock alone, while one far *behind* real time can make an
1533    /// already-claimed real-time mark look "not yet due" against the
1534    /// registry clock forever, blinding eviction to every real-timeline
1535    /// entry and leaving it to fall back on oldest-inserted regardless of
1536    /// which entry that happens to be. Falling back to oldest-inserted
1537    /// (matching this method's pre-existing behavior) still happens when no
1538    /// entry's own clock calls it due.
1539    pub(crate) fn note_pg_job_mark(&self, id: &str, ready_at_ms: u64, timeline: PgMarkTimeline) {
1540        if let Ok(mut guard) = self.queues.write() {
1541            if guard.pg_marks_by_job_id.len() >= PG_MARKS_BY_JOB_ID_CAP
1542                && !guard.pg_marks_by_job_id.contains_key(id)
1543            {
1544                let registry_now = self.now_ms();
1545                // Real-timeline marks follow Postgres's own clock, so read the real clock.
1546                #[allow(
1547                    clippy::disallowed_methods,
1548                    reason = "Real marks are measured by Postgres clock_timestamp(), a real clock"
1549                )]
1550                let real_now =
1551                    u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(u64::MAX);
1552                // Walk oldest-first (ascending `seq`) and stop at the first
1553                // entry that is actually due; only when none is found (the
1554                // loop runs to completion) fall back to the oldest entry
1555                // overall (`pg_marks_seq_order`'s own first key). See
1556                // `pg_marks_seq_order`'s doc comment for why this is not a
1557                // full scan in the common case.
1558                let evict_id = guard
1559                    .pg_marks_seq_order
1560                    .iter()
1561                    .find_map(|(_, candidate_id)| {
1562                        let mark = guard.pg_marks_by_job_id.get(candidate_id)?;
1563                        let due = match mark.timeline {
1564                            PgMarkTimeline::Real => mark.ms <= real_now,
1565                            PgMarkTimeline::Registry => mark.ms <= registry_now,
1566                        };
1567                        due.then(|| candidate_id.clone())
1568                    })
1569                    .or_else(|| guard.pg_marks_seq_order.values().next().cloned());
1570                if let Some(evict_id) = evict_id
1571                    && let Some(evicted) = guard.pg_marks_by_job_id.swap_remove(&evict_id)
1572                {
1573                    guard.pg_marks_seq_order.remove(&evicted.seq);
1574                }
1575            }
1576            let seq = guard.pg_marks_next_seq;
1577            guard.pg_marks_next_seq = guard.pg_marks_next_seq.wrapping_add(1);
1578            guard.pg_marks_seq_order.insert(seq, id.to_string());
1579            guard.pg_marks_by_job_id.insert(
1580                id.to_string(),
1581                PgMark {
1582                    ms: ready_at_ms,
1583                    timeline,
1584                    seq,
1585                },
1586            );
1587        }
1588    }
1589
1590    /// Record that an enqueued Postgres-backed job was canceled, given the
1591    /// database's own measurement of its due time, and remove precisely the
1592    /// waiting mark that job pushed.
1593    ///
1594    /// Looks up `id` in the exact-mark table `note_pg_job_mark` populated
1595    /// at enqueue time first — this is unambiguous by
1596    /// construction, immune to two co-queued marks coincidentally landing at
1597    /// (or near) the same instant. Falls back to nearest-match over three
1598    /// candidate timelines only when no exact entry exists (evicted for
1599    /// capacity, or never recorded — the transactional `enqueue_on_conn`
1600    /// path skips registry bookkeeping entirely, by design, until its
1601    /// surrounding transaction commits):
1602    ///
1603    /// * an **absolute** enqueue (`enqueue_at`) stamps the registry mark with
1604    ///   the caller's own instant verbatim, byte-identical to `absolute_ms`
1605    ///   (Postgres's stored `run_at`, forwarded unchanged) — no clock read
1606    ///   in between, so this candidate matches it exactly;
1607    /// * a **relative-delay** enqueue (`enqueue_in`) stamps the mark from
1608    ///   `JobClient::due_origin`'s real-time reading at enqueue time, so
1609    ///   `real_reference_ms + offset_ms` (translating the database's
1610    ///   `run_at - clock_timestamp()` onto that same real timeline)
1611    ///   reconstructs it;
1612    /// * an **immediate** enqueue stamps the mark from this registry's own
1613    ///   injected clock (`record_enqueue`), so `self.now_ms() + offset_ms`
1614    ///   reconstructs it instead.
1615    ///
1616    /// The fallback mirrors [`ready_at_from_age`]'s translate-onto-a-known-timeline
1617    /// approach, tried against all three candidates: the nearest existing
1618    /// mark to *any* candidate is removed, instead of deciding a
1619    /// ready/scheduled category from the offset and asking this registry's
1620    /// internal category search to find any mark sharing it, which is only
1621    /// correct when every mark in the queue shares one timeline with the
1622    /// offset. It can still, rarely, pick an unrelated co-queued job's mark
1623    /// (the reason the exact lookup exists at all) — that mismatch corrects
1624    /// itself at the next durable-backend survey tick.
1625    ///
1626    /// An exact entry can still, in principle, be stale — a caller that
1627    /// pushes a waiting mark without going through `record_pg_start`/
1628    /// `record_pg_enqueue`'s matched clear-then-reset pair (`Self::record_start`
1629    /// itself, used directly by the local/redis backends, never touches
1630    /// `pg_marks_by_job_id` at all) could leave an entry pointing at a value
1631    /// no longer in the queue. The exact removal reports whether it actually
1632    /// removed something, and this only trusts the exact lookup when it did
1633    /// — a stale entry falls through to the same candidate fallback as no
1634    /// entry at all, rather than silently leaving the job's real mark in
1635    /// place.
1636    pub fn record_cancel_at_backend_offset(
1637        &self,
1638        name: &str,
1639        id: &str,
1640        absolute_ms: Option<u64>,
1641        real_reference_ms: u64,
1642        offset_ms: i64,
1643    ) {
1644        if let Ok(mut guard) = self.inner.write()
1645            && let Some(status) = guard.get_mut(name)
1646        {
1647            status.queued = status.queued.saturating_sub(1);
1648        }
1649        let exact_ms = self
1650            .queues
1651            .write()
1652            .ok()
1653            .and_then(|mut guard| {
1654                let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1655                guard.pg_marks_seq_order.remove(&removed.seq);
1656                Some(removed)
1657            })
1658            .map(|mark| mark.ms);
1659        if let Some(exact_ms) = exact_ms
1660            && self.pop_waiting_exact(name, exact_ms)
1661        {
1662            return;
1663        }
1664        let mut candidates: Vec<u64> = Vec::with_capacity(3);
1665        candidates.extend(absolute_ms);
1666        candidates.push(real_reference_ms.saturating_add_signed(offset_ms));
1667        candidates.push(self.now_ms().saturating_add_signed(offset_ms));
1668        self.pop_waiting_nearest(name, &candidates);
1669    }
1670
1671    /// Drop one waiting mark for this job's queue (its wait is over).
1672    ///
1673    /// `prefer_ready` picks which mark to remove when the queue holds a mix of
1674    /// ready and still-scheduled marks: a starting/canceled ready job removes an
1675    /// already-claimable mark, while a canceled scheduled job removes a future
1676    /// one. If no mark in the preferred category exists we fall back to the
1677    /// oldest mark so every enqueue still has a matching removal (no leak).
1678    fn pop_waiting(&self, name: &str, prefer_ready: bool) {
1679        let now = self.now_ms();
1680        // The queue is looked up by borrow under the write lock taken here
1681        // anyway, instead of copied out under a separate read lock first.
1682        if let Ok(mut guard) = self.queues.write()
1683            && let Some(waiting) = guard.waiting_for_mut(name)
1684        {
1685            let idx = waiting
1686                .iter()
1687                .position(|ready_at| (*ready_at <= now) == prefer_ready)
1688                .or(if waiting.is_empty() { None } else { Some(0) });
1689            if let Some(idx) = idx {
1690                waiting.remove(idx);
1691            }
1692        }
1693    }
1694
1695    /// Drop the waiting mark closest to any of `candidates`, each expressed
1696    /// on whatever timeline the caller resolved it against. A queue with no
1697    /// marks removes nothing — there is nothing to leak.
1698    fn pop_waiting_nearest(&self, name: &str, candidates: &[u64]) {
1699        if let Ok(mut guard) = self.queues.write()
1700            && let Some(waiting) = guard.waiting_for_mut(name)
1701        {
1702            let idx = waiting
1703                .iter()
1704                .enumerate()
1705                .min_by_key(|(_, ready_at)| {
1706                    candidates
1707                        .iter()
1708                        .map(|target| ready_at.abs_diff(*target))
1709                        .min()
1710                })
1711                .map(|(idx, _)| idx);
1712            if let Some(idx) = idx {
1713                waiting.remove(idx);
1714            }
1715        }
1716    }
1717
1718    /// Drop the waiting mark equal to `exact_ms`, returning whether one was
1719    /// found. A no-op (returning `false`) when `exact_ms` is not present —
1720    /// e.g. it is a stale entry pointing at a mark something else already
1721    /// removed — so the caller can fall back to a heuristic guess rather
1722    /// than silently leaving the job's real, current mark in place.
1723    fn pop_waiting_exact(&self, name: &str, exact_ms: u64) -> bool {
1724        if let Ok(mut guard) = self.queues.write()
1725            && let Some(waiting) = guard.waiting_for_mut(name)
1726            && let Some(idx) = waiting.iter().position(|mark| *mark == exact_ms)
1727        {
1728            waiting.remove(idx);
1729            return true;
1730        }
1731        false
1732    }
1733
1734    /// The raw waiting marks (epoch ms) for `name`'s queue, in push order.
1735    ///
1736    /// Test-only: asserting on this instead of [`Self::queue_snapshot`]'s
1737    /// depth avoids that method's own `now_ms()`-relative bucketing, which a
1738    /// test deliberately pinning the registry's clock far from a mark's
1739    /// timeline would otherwise contaminate.
1740    #[cfg(test)]
1741    pub(crate) fn waiting_marks_for_test(&self, name: &str) -> Vec<u64> {
1742        self.queues
1743            .read()
1744            .ok()
1745            .and_then(|g| {
1746                g.waiting
1747                    .get(g.queue_of(name))
1748                    .map(|w| w.iter().copied().collect())
1749            })
1750            .unwrap_or_default()
1751    }
1752
1753    /// The number of entries currently held in `pg_marks_by_job_id`.
1754    /// Test-only: proves the bound in [`PG_MARKS_BY_JOB_ID_CAP`] actually
1755    /// holds without exposing the table itself.
1756    #[cfg(test)]
1757    pub(crate) fn pg_marks_len_for_test(&self) -> usize {
1758        self.queues.read().map_or(0, |g| g.pg_marks_by_job_id.len())
1759    }
1760
1761    /// Whether `pg_marks_seq_order` currently mirrors `pg_marks_by_job_id`'s
1762    /// keys exactly (same length, same ids) — the invariant every insert and
1763    /// removal on that table must maintain. Test-only.
1764    #[cfg(test)]
1765    pub(crate) fn pg_marks_seq_order_is_consistent_for_test(&self) -> bool {
1766        self.queues.read().is_ok_and(|g| {
1767            g.pg_marks_seq_order.len() == g.pg_marks_by_job_id.len()
1768                && g.pg_marks_seq_order
1769                    .values()
1770                    .all(|id| g.pg_marks_by_job_id.contains_key(id))
1771        })
1772    }
1773
1774    /// Whether `pg_marks_by_job_id` currently holds an entry for `id`.
1775    /// Test-only.
1776    #[cfg(test)]
1777    pub(crate) fn pg_mark_contains_id_for_test(&self, id: &str) -> bool {
1778        self.queues
1779            .read()
1780            .is_ok_and(|g| g.pg_marks_by_job_id.contains_key(id))
1781    }
1782
1783    /// [`PG_MARKS_BY_JOB_ID_CAP`], for a test in another module (e.g.
1784    /// `job.rs`) that needs to force capacity eviction without duplicating
1785    /// the constant. Test-only.
1786    #[cfg(test)]
1787    #[allow(dead_code, reason = "used only by feature-gated job tests")]
1788    pub(crate) const fn pg_marks_cap_for_test() -> usize {
1789        PG_MARKS_BY_JOB_ID_CAP
1790    }
1791
1792    /// Record a successful execution.
1793    pub fn record_success(&self, name: &str) {
1794        if let Ok(mut guard) = self.inner.write()
1795            && let Some(status) = guard.get_mut(name)
1796        {
1797            status.in_flight = status.in_flight.saturating_sub(1);
1798            status.total_successes = status.total_successes.saturating_add(1);
1799            status.last_error = None;
1800        }
1801    }
1802
1803    /// Record a retriable failure.
1804    pub fn record_retry(&self, name: &str, error: &str, _attempt: u32) {
1805        if let Ok(mut guard) = self.inner.write()
1806            && let Some(status) = guard.get_mut(name)
1807        {
1808            status.in_flight = status.in_flight.saturating_sub(1);
1809            status.last_error = Some(error.to_string());
1810        }
1811    }
1812
1813    /// Record a terminal failure.
1814    pub fn record_failure(&self, name: &str, error: String, dead_lettered: bool) {
1815        self.record_failure_inner(name, error, dead_lettered, true);
1816    }
1817
1818    /// Count a start again that a newer attempt already balanced. The caller
1819    /// then makes the `record_*` call that ends the run, which decrements once.
1820    #[cfg(any(feature = "db", feature = "redis"))]
1821    pub(crate) fn restore_in_flight(&self, name: &str) {
1822        if let Ok(mut guard) = self.inner.write()
1823            && let Some(status) = guard.get_mut(name)
1824        {
1825            status.in_flight = status.in_flight.saturating_add(1);
1826        }
1827    }
1828
1829    /// Record a terminal failure of a run this process did not start.
1830    /// Count the failure. Leave `in_flight` alone.
1831    #[cfg(any(feature = "db", feature = "redis"))]
1832    pub(crate) fn record_failure_not_started(
1833        &self,
1834        name: &str,
1835        error: String,
1836        dead_lettered: bool,
1837    ) {
1838        self.record_failure_inner(name, error, dead_lettered, false);
1839    }
1840
1841    fn record_failure_inner(&self, name: &str, error: String, dead_lettered: bool, started: bool) {
1842        if let Ok(mut guard) = self.inner.write()
1843            && let Some(status) = guard.get_mut(name)
1844        {
1845            if started {
1846                status.in_flight = status.in_flight.saturating_sub(1);
1847            }
1848            status.total_failures = status.total_failures.saturating_add(1);
1849            status.last_error = Some(error);
1850            if dead_lettered {
1851                status.dead_letters = status.dead_letters.saturating_add(1);
1852            }
1853        }
1854    }
1855
1856    /// Snapshot all registered jobs.
1857    #[must_use]
1858    pub fn snapshot(&self) -> HashMap<String, JobStatus> {
1859        self.inner.read().map(|g| g.clone()).unwrap_or_default()
1860    }
1861
1862    /// Add `count` dead letters that a trim removed.
1863    pub fn record_dead_letter_trimmed(&self, count: u64) {
1864        self.dead_letter_trimmed
1865            .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
1866    }
1867
1868    /// Total dead letters that trims removed in this process.
1869    #[must_use]
1870    pub fn dead_letter_trimmed_total(&self) -> u64 {
1871        self.dead_letter_trimmed
1872            .load(std::sync::atomic::Ordering::Relaxed)
1873    }
1874}
1875
1876impl Default for JobRegistry {
1877    fn default() -> Self {
1878        Self::new()
1879    }
1880}
1881
1882impl TaskRegistry {
1883    /// Create a new empty task registry.
1884    #[must_use]
1885    pub fn new() -> Self {
1886        Self {
1887            inner: Arc::new(RwLock::new(HashMap::new())),
1888        }
1889    }
1890
1891    /// Register a task with its schedule description.
1892    pub fn register(&self, name: &str, schedule: &str) {
1893        self.register_scheduled(
1894            name,
1895            schedule,
1896            crate::task::TaskCoordination::Fleet,
1897            "in_process",
1898            "unknown",
1899        );
1900    }
1901
1902    /// Register a scheduled task with scheduler coordination metadata.
1903    pub fn register_scheduled(
1904        &self,
1905        name: &str,
1906        schedule: &str,
1907        coordination: crate::task::TaskCoordination,
1908        scheduler_backend: &str,
1909        replica_id: &str,
1910    ) {
1911        let Ok(mut guard) = self.inner.write() else {
1912            return;
1913        };
1914        guard.insert(
1915            name.to_string(),
1916            TaskStatus {
1917                schedule: schedule.to_string(),
1918                coordination,
1919                scheduler_backend: scheduler_backend.to_string(),
1920                replica_id: replica_id.to_string(),
1921                current_leader: None,
1922                last_tick: None,
1923                last_fired_at: None,
1924                next_run_at: None,
1925                status: "idle".to_string(),
1926                last_run: None,
1927                last_duration_ms: None,
1928                last_result: None,
1929                last_error: None,
1930                total_runs: 0,
1931                total_failures: 0,
1932            },
1933        );
1934    }
1935
1936    /// Record the replica that acquired leadership for a global task tick.
1937    pub fn record_leader(&self, name: &str, leader_id: &str, tick_key: &str) {
1938        let Ok(mut guard) = self.inner.write() else {
1939            return;
1940        };
1941        let Some(task) = guard.get_mut(name) else {
1942            return;
1943        };
1944        task.current_leader = Some(leader_id.to_string());
1945        task.last_tick = Some(tick_key.to_string());
1946    }
1947
1948    /// Record that a task started running.
1949    pub fn record_start(&self, name: &str) {
1950        let Ok(mut guard) = self.inner.write() else {
1951            return;
1952        };
1953        let Some(task) = guard.get_mut(name) else {
1954            return;
1955        };
1956        task.status = "running".to_string();
1957        task.next_run_at = None;
1958    }
1959
1960    /// Record the next scheduled run time for an idle task.
1961    pub fn record_next_run_at(&self, name: &str, next_run_at: &str) {
1962        let Ok(mut guard) = self.inner.write() else {
1963            return;
1964        };
1965        let Some(task) = guard.get_mut(name) else {
1966            return;
1967        };
1968        task.next_run_at = Some(next_run_at.to_string());
1969    }
1970
1971    /// Record that a task completed successfully.
1972    pub fn record_success(&self, name: &str, duration_ms: u64) {
1973        let Ok(mut guard) = self.inner.write() else {
1974            return;
1975        };
1976        let Some(task) = guard.get_mut(name) else {
1977            return;
1978        };
1979        task.status = "idle".to_string();
1980        let now = crate::time::ambient_now().to_rfc3339();
1981        task.last_run = Some(now.clone());
1982        task.last_fired_at = Some(now);
1983        task.last_duration_ms = Some(duration_ms);
1984        task.last_result = Some("ok".to_string());
1985        task.last_error = None;
1986        task.total_runs += 1;
1987    }
1988
1989    /// Record that a task failed.
1990    pub fn record_failure(&self, name: &str, duration_ms: u64, error: &str) {
1991        let Ok(mut guard) = self.inner.write() else {
1992            return;
1993        };
1994        let Some(task) = guard.get_mut(name) else {
1995            return;
1996        };
1997        task.status = "idle".to_string();
1998        let now = crate::time::ambient_now().to_rfc3339();
1999        task.last_run = Some(now.clone());
2000        task.last_fired_at = Some(now);
2001        task.last_duration_ms = Some(duration_ms);
2002        task.last_result = Some("failed".to_string());
2003        task.last_error = Some(error.to_string());
2004        task.total_runs += 1;
2005        task.total_failures += 1;
2006    }
2007
2008    /// Get a snapshot of all task statuses.
2009    #[must_use]
2010    pub fn snapshot(&self) -> HashMap<String, TaskStatus> {
2011        self.inner
2012            .read()
2013            .map(|guard| guard.clone())
2014            .unwrap_or_default()
2015    }
2016}
2017
2018impl Default for TaskRegistry {
2019    fn default() -> Self {
2020        Self::new()
2021    }
2022}
2023
2024impl std::fmt::Debug for TaskRegistry {
2025    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2026        f.debug_struct("TaskRegistry")
2027            .field("count", &self.snapshot().len())
2028            .finish()
2029    }
2030}
2031
2032/// Resolved config property with source provenance.
2033#[derive(Debug, Clone, Serialize, Deserialize)]
2034pub struct ConfigProperty {
2035    /// The resolved value (redacted if sensitive).
2036    pub value: serde_json::Value,
2037    /// Where the value came from.
2038    pub source: String,
2039}
2040
2041/// Collection of resolved config properties with source tracking.
2042#[derive(Debug, Clone, Default)]
2043pub struct ConfigProperties {
2044    inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
2045}
2046
2047impl ConfigProperties {
2048    /// Build config properties with source tracking from the loaded config.
2049    #[must_use]
2050    #[allow(clippy::too_many_lines)]
2051    pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
2052        let profile = config.profile.as_deref().unwrap_or("default");
2053        let defaults = crate::config::AutumnConfig::default();
2054
2055        // Avoids dynamic reallocation since we know roughly how many config properties are tracked.
2056        let mut props = HashMap::with_capacity(32);
2057        let profile_str = profile.to_string();
2058
2059        Self::track_server_props(&mut props, config, &defaults, &profile_str);
2060        Self::track_db_props(&mut props, config, &defaults, &profile_str);
2061        Self::track_log_props(&mut props, config, &defaults, &profile_str);
2062        Self::track_telemetry_props(&mut props, config, &defaults, &profile_str);
2063        Self::track_health_props(&mut props, config, &defaults, &profile_str);
2064        Self::track_actuator_props(&mut props, config, &defaults, &profile_str);
2065        Self::track_metrics_props(&mut props, config, &defaults, &profile_str);
2066        Self::track_session_props(&mut props, config, &defaults, &profile_str);
2067        Self::track_channels_props(&mut props, config, &defaults, &profile_str);
2068
2069        Self {
2070            inner: Arc::new(RwLock::new(props)),
2071        }
2072    }
2073
2074    fn track_server_props(
2075        props: &mut HashMap<String, ConfigProperty>,
2076        config: &crate::config::AutumnConfig,
2077        defaults: &crate::config::AutumnConfig,
2078        profile_str: &str,
2079    ) {
2080        Self::track_property(
2081            props,
2082            "server.host",
2083            &config.server.host,
2084            &defaults.server.host,
2085            profile_str,
2086        );
2087        Self::track_property(
2088            props,
2089            "server.port",
2090            &config.server.port.to_string(),
2091            &defaults.server.port.to_string(),
2092            profile_str,
2093        );
2094        Self::track_property(
2095            props,
2096            "server.shutdown_timeout_secs",
2097            &config.server.shutdown_timeout_secs.to_string(),
2098            &defaults.server.shutdown_timeout_secs.to_string(),
2099            profile_str,
2100        );
2101    }
2102
2103    fn track_db_props(
2104        props: &mut HashMap<String, ConfigProperty>,
2105        config: &crate::config::AutumnConfig,
2106        defaults: &crate::config::AutumnConfig,
2107        profile_str: &str,
2108    ) {
2109        let db_url = config.database.url.as_deref().unwrap_or("").to_string();
2110        let primary_url = config
2111            .database
2112            .primary_url
2113            .as_deref()
2114            .unwrap_or("")
2115            .to_string();
2116        let replica_url = config
2117            .database
2118            .replica_url
2119            .as_deref()
2120            .unwrap_or("")
2121            .to_string();
2122        Self::track_property(props, "database.url", &db_url, "", profile_str);
2123        Self::track_property(props, "database.primary_url", &primary_url, "", profile_str);
2124        Self::track_property(props, "database.replica_url", &replica_url, "", profile_str);
2125        Self::track_property(
2126            props,
2127            "database.pool_size",
2128            &config.database.pool_size.to_string(),
2129            &defaults.database.pool_size.to_string(),
2130            profile_str,
2131        );
2132        Self::track_property(
2133            props,
2134            "database.primary_pool_size",
2135            &config.database.effective_primary_pool_size().to_string(),
2136            &defaults.database.effective_primary_pool_size().to_string(),
2137            profile_str,
2138        );
2139        Self::track_property(
2140            props,
2141            "database.replica_pool_size",
2142            &config.database.effective_replica_pool_size().to_string(),
2143            &defaults.database.effective_replica_pool_size().to_string(),
2144            profile_str,
2145        );
2146        Self::track_property(
2147            props,
2148            "database.replica_fallback",
2149            &format!("{:?}", config.database.replica_fallback),
2150            &format!("{:?}", defaults.database.replica_fallback),
2151            profile_str,
2152        );
2153        Self::track_property(
2154            props,
2155            "database.replica_max_lag_ms",
2156            &format!("{:?}", config.database.replica_max_lag_ms),
2157            &format!("{:?}", defaults.database.replica_max_lag_ms),
2158            profile_str,
2159        );
2160    }
2161
2162    fn track_log_props(
2163        props: &mut HashMap<String, ConfigProperty>,
2164        config: &crate::config::AutumnConfig,
2165        defaults: &crate::config::AutumnConfig,
2166        profile_str: &str,
2167    ) {
2168        Self::track_property(
2169            props,
2170            "log.level",
2171            &config.log.level,
2172            &defaults.log.level,
2173            profile_str,
2174        );
2175        Self::track_property(
2176            props,
2177            "log.format",
2178            &format!("{:?}", config.log.format),
2179            &format!("{:?}", defaults.log.format),
2180            profile_str,
2181        );
2182        Self::track_property(
2183            props,
2184            "log.capture.enabled",
2185            &config.log.capture.enabled.to_string(),
2186            &defaults.log.capture.enabled.to_string(),
2187            profile_str,
2188        );
2189        Self::track_property(
2190            props,
2191            "log.capture.capacity",
2192            &config.log.capture.capacity.to_string(),
2193            &defaults.log.capture.capacity.to_string(),
2194            profile_str,
2195        );
2196    }
2197
2198    fn track_telemetry_props(
2199        props: &mut HashMap<String, ConfigProperty>,
2200        config: &crate::config::AutumnConfig,
2201        defaults: &crate::config::AutumnConfig,
2202        profile_str: &str,
2203    ) {
2204        Self::track_property(
2205            props,
2206            "telemetry.enabled",
2207            &config.telemetry.enabled.to_string(),
2208            &defaults.telemetry.enabled.to_string(),
2209            profile_str,
2210        );
2211        Self::track_property(
2212            props,
2213            "telemetry.service_name",
2214            &config.telemetry.service_name,
2215            &defaults.telemetry.service_name,
2216            profile_str,
2217        );
2218        Self::track_property(
2219            props,
2220            "telemetry.service_namespace",
2221            config.telemetry.service_namespace.as_deref().unwrap_or(""),
2222            defaults
2223                .telemetry
2224                .service_namespace
2225                .as_deref()
2226                .unwrap_or(""),
2227            profile_str,
2228        );
2229        Self::track_property(
2230            props,
2231            "telemetry.service_version",
2232            &config.telemetry.service_version,
2233            &defaults.telemetry.service_version,
2234            profile_str,
2235        );
2236        Self::track_property(
2237            props,
2238            "telemetry.environment",
2239            &config.telemetry.environment,
2240            &defaults.telemetry.environment,
2241            profile_str,
2242        );
2243        Self::track_property(
2244            props,
2245            "telemetry.otlp_endpoint",
2246            config.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2247            defaults.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2248            profile_str,
2249        );
2250        Self::track_property(
2251            props,
2252            "telemetry.protocol",
2253            &format!("{:?}", config.telemetry.protocol),
2254            &format!("{:?}", defaults.telemetry.protocol),
2255            profile_str,
2256        );
2257        Self::track_property(
2258            props,
2259            "telemetry.strict",
2260            &config.telemetry.strict.to_string(),
2261            &defaults.telemetry.strict.to_string(),
2262            profile_str,
2263        );
2264    }
2265
2266    fn track_health_props(
2267        props: &mut HashMap<String, ConfigProperty>,
2268        config: &crate::config::AutumnConfig,
2269        defaults: &crate::config::AutumnConfig,
2270        profile_str: &str,
2271    ) {
2272        Self::track_property(
2273            props,
2274            "health.path",
2275            &config.health.path,
2276            &defaults.health.path,
2277            profile_str,
2278        );
2279        Self::track_property(
2280            props,
2281            "health.live_path",
2282            &config.health.live_path,
2283            &defaults.health.live_path,
2284            profile_str,
2285        );
2286        Self::track_property(
2287            props,
2288            "health.ready_path",
2289            &config.health.ready_path,
2290            &defaults.health.ready_path,
2291            profile_str,
2292        );
2293        Self::track_property(
2294            props,
2295            "health.startup_path",
2296            &config.health.startup_path,
2297            &defaults.health.startup_path,
2298            profile_str,
2299        );
2300        Self::track_property(
2301            props,
2302            "health.detailed",
2303            &config.health.detailed.to_string(),
2304            &defaults.health.detailed.to_string(),
2305            profile_str,
2306        );
2307    }
2308
2309    fn track_actuator_props(
2310        props: &mut HashMap<String, ConfigProperty>,
2311        config: &crate::config::AutumnConfig,
2312        defaults: &crate::config::AutumnConfig,
2313        profile_str: &str,
2314    ) {
2315        Self::track_property(
2316            props,
2317            "actuator.prefix",
2318            &config.actuator.prefix,
2319            &defaults.actuator.prefix,
2320            profile_str,
2321        );
2322        Self::track_property(
2323            props,
2324            "actuator.sensitive",
2325            &config.actuator.sensitive.to_string(),
2326            &defaults.actuator.sensitive.to_string(),
2327            profile_str,
2328        );
2329        Self::track_property(
2330            props,
2331            "actuator.prometheus",
2332            &config.actuator.prometheus.to_string(),
2333            &defaults.actuator.prometheus.to_string(),
2334            profile_str,
2335        );
2336    }
2337
2338    fn track_metrics_props(
2339        props: &mut HashMap<String, ConfigProperty>,
2340        config: &crate::config::AutumnConfig,
2341        defaults: &crate::config::AutumnConfig,
2342        profile_str: &str,
2343    ) {
2344        Self::track_property(
2345            props,
2346            "metrics.max_series_per_metric",
2347            &config.metrics.max_series_per_metric.to_string(),
2348            &defaults.metrics.max_series_per_metric.to_string(),
2349            profile_str,
2350        );
2351        Self::track_property(
2352            props,
2353            "metrics.max_instruments",
2354            &config.metrics.max_instruments.to_string(),
2355            &defaults.metrics.max_instruments.to_string(),
2356            profile_str,
2357        );
2358        Self::track_property(
2359            props,
2360            "metrics.max_labels_per_series",
2361            &config.metrics.max_labels_per_series.to_string(),
2362            &defaults.metrics.max_labels_per_series.to_string(),
2363            profile_str,
2364        );
2365    }
2366
2367    fn track_session_props(
2368        props: &mut HashMap<String, ConfigProperty>,
2369        config: &crate::config::AutumnConfig,
2370        defaults: &crate::config::AutumnConfig,
2371        profile_str: &str,
2372    ) {
2373        Self::track_property(
2374            props,
2375            "session.backend",
2376            &format!("{:?}", config.session.backend),
2377            &format!("{:?}", defaults.session.backend),
2378            profile_str,
2379        );
2380        Self::track_property(
2381            props,
2382            "session.cookie_name",
2383            &config.session.cookie_name,
2384            &defaults.session.cookie_name,
2385            profile_str,
2386        );
2387        Self::track_property(
2388            props,
2389            "session.max_age_secs",
2390            &config.session.max_age_secs.to_string(),
2391            &defaults.session.max_age_secs.to_string(),
2392            profile_str,
2393        );
2394        Self::track_property(
2395            props,
2396            "session.secure",
2397            &config.session.secure.to_string(),
2398            &defaults.session.secure.to_string(),
2399            profile_str,
2400        );
2401        Self::track_property(
2402            props,
2403            "session.same_site",
2404            &config.session.same_site,
2405            &defaults.session.same_site,
2406            profile_str,
2407        );
2408        Self::track_property(
2409            props,
2410            "session.http_only",
2411            &config.session.http_only.to_string(),
2412            &defaults.session.http_only.to_string(),
2413            profile_str,
2414        );
2415        Self::track_property(
2416            props,
2417            "session.path",
2418            &config.session.path,
2419            &defaults.session.path,
2420            profile_str,
2421        );
2422        Self::track_property(
2423            props,
2424            "session.allow_memory_in_production",
2425            &config.session.allow_memory_in_production.to_string(),
2426            &defaults.session.allow_memory_in_production.to_string(),
2427            profile_str,
2428        );
2429        Self::track_property(
2430            props,
2431            "session.redis.url",
2432            config.session.redis.url.as_deref().unwrap_or(""),
2433            defaults.session.redis.url.as_deref().unwrap_or(""),
2434            profile_str,
2435        );
2436        Self::track_property(
2437            props,
2438            "session.redis.key_prefix",
2439            &config.session.redis.key_prefix,
2440            &defaults.session.redis.key_prefix,
2441            profile_str,
2442        );
2443    }
2444
2445    fn track_channels_props(
2446        props: &mut HashMap<String, ConfigProperty>,
2447        config: &crate::config::AutumnConfig,
2448        defaults: &crate::config::AutumnConfig,
2449        profile_str: &str,
2450    ) {
2451        Self::track_property(
2452            props,
2453            "channels.backend",
2454            &format!("{:?}", config.channels.backend),
2455            &format!("{:?}", defaults.channels.backend),
2456            profile_str,
2457        );
2458        Self::track_property(
2459            props,
2460            "channels.capacity",
2461            &config.channels.capacity.to_string(),
2462            &defaults.channels.capacity.to_string(),
2463            profile_str,
2464        );
2465        Self::track_property(
2466            props,
2467            "channels.redis.url",
2468            config.channels.redis.url.as_deref().unwrap_or(""),
2469            defaults.channels.redis.url.as_deref().unwrap_or(""),
2470            profile_str,
2471        );
2472        Self::track_property(
2473            props,
2474            "channels.redis.key_prefix",
2475            &config.channels.redis.key_prefix,
2476            &defaults.channels.redis.key_prefix,
2477            profile_str,
2478        );
2479    }
2480
2481    fn track_property(
2482        props: &mut HashMap<String, ConfigProperty>,
2483        key: &str,
2484        value: &str,
2485        default_value: &str,
2486        profile: &str,
2487    ) {
2488        // Check if there's an env var override
2489        let env_key = format!("AUTUMN_{}", key.replace('.', "__").to_uppercase());
2490        let source = if std::env::var(&env_key).is_ok() {
2491            env_key
2492        } else if value != default_value && (profile == "dev" || profile == "prod") {
2493            format!("profile_default:{profile}")
2494        } else if value != default_value {
2495            "autumn.toml".to_string()
2496        } else {
2497            "default".to_string()
2498        };
2499
2500        let display_value = if should_redact(key) {
2501            serde_json::Value::String("****".into())
2502        } else {
2503            serde_json::Value::String(value.to_string())
2504        };
2505
2506        props.insert(
2507            key.to_string(),
2508            ConfigProperty {
2509                value: display_value,
2510                source,
2511            },
2512        );
2513    }
2514
2515    /// Get a snapshot of all properties.
2516    #[must_use]
2517    pub fn snapshot(&self) -> HashMap<String, ConfigProperty> {
2518        self.inner
2519            .read()
2520            .map(|guard| guard.clone())
2521            .unwrap_or_default()
2522    }
2523}
2524
2525// ── Health Indicator ─────────────────────────────────────────────
2526
2527/// Health status reported by a [`HealthIndicator`].
2528///
2529/// Follows Spring Boot precedence:
2530/// `Down` > `OutOfService` > `Unknown` > `Up`
2531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2532pub enum HealthStatus {
2533    /// The component is functioning normally.
2534    #[serde(rename = "UP")]
2535    Up,
2536    /// The component is unavailable.
2537    #[serde(rename = "DOWN")]
2538    Down,
2539    /// The component is out of service (maintenance, etc.).
2540    #[serde(rename = "OUT_OF_SERVICE")]
2541    OutOfService,
2542    /// The component status cannot be determined.
2543    #[serde(rename = "UNKNOWN")]
2544    Unknown,
2545}
2546
2547impl HealthStatus {
2548    /// Human-readable string for this status.
2549    #[must_use]
2550    pub const fn as_str(self) -> &'static str {
2551        match self {
2552            Self::Up => "UP",
2553            Self::Down => "DOWN",
2554            Self::OutOfService => "OUT_OF_SERVICE",
2555            Self::Unknown => "UNKNOWN",
2556        }
2557    }
2558
2559    /// Returns `true` when this status does not indicate a failure
2560    /// (`Up` and `Unknown` are healthy; `Down` and `OutOfService` are not).
2561    #[must_use]
2562    pub const fn is_healthy(self) -> bool {
2563        matches!(self, Self::Up | Self::Unknown)
2564    }
2565}
2566
2567/// Which group a [`HealthIndicator`] belongs to.
2568///
2569/// `Readiness` indicators gate both `/ready` and `/actuator/health`.
2570/// `HealthOnly` indicators appear only in `/actuator/health`.
2571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2572pub enum IndicatorGroup {
2573    /// Participates in `/ready` and `/actuator/health`.
2574    Readiness,
2575    /// Participates only in `/actuator/health`.
2576    HealthOnly,
2577}
2578
2579/// Output from a single [`HealthIndicator::check`] call.
2580#[derive(Debug, Clone)]
2581pub struct HealthCheckOutput {
2582    /// The health status of this component.
2583    pub status: HealthStatus,
2584    /// Optional human-readable key-value detail map.
2585    pub details: HashMap<String, serde_json::Value>,
2586}
2587
2588impl HealthCheckOutput {
2589    /// Create an `Up` output with no details.
2590    #[must_use]
2591    pub fn up() -> Self {
2592        Self {
2593            status: HealthStatus::Up,
2594            details: HashMap::new(),
2595        }
2596    }
2597
2598    /// Create a `Down` output with no details.
2599    #[must_use]
2600    pub fn down() -> Self {
2601        Self {
2602            status: HealthStatus::Down,
2603            details: HashMap::new(),
2604        }
2605    }
2606
2607    /// Attach a detail map to this output.
2608    #[must_use]
2609    pub fn with_details(mut self, details: HashMap<String, serde_json::Value>) -> Self {
2610        self.details = details;
2611        self
2612    }
2613}
2614
2615/// Contract for a custom health check.
2616///
2617/// Implement this trait and register it via [`crate::app::AppBuilder::health_indicator`]
2618/// to surface the health of an external dependency in `/actuator/health` and optionally
2619/// in `/ready`.
2620///
2621/// # Example
2622///
2623/// ```rust
2624/// use autumn_web::actuator::{HealthCheckOutput, HealthIndicator, HealthStatus};
2625///
2626/// pub struct StripeIndicator;
2627///
2628/// impl HealthIndicator for StripeIndicator {
2629///     fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
2630///         Box::pin(async move {
2631///             // TODO: ping Stripe API
2632///             HealthCheckOutput::up()
2633///         })
2634///     }
2635/// }
2636/// ```
2637pub trait HealthIndicator: Send + Sync + 'static {
2638    /// Run the check and return the current health output.
2639    ///
2640    /// The future is polled inside a per-indicator timeout; if it does not
2641    /// resolve within [`Self::timeout_ms`] milliseconds it is cancelled and
2642    /// the indicator is reported as `Unknown` with `timed_out: true`.
2643    fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput>;
2644
2645    /// Per-indicator timeout in milliseconds. Default: 2 000 ms.
2646    fn timeout_ms(&self) -> u64 {
2647        2000
2648    }
2649
2650    /// Which probe group this indicator belongs to. Default: [`IndicatorGroup::Readiness`].
2651    fn group(&self) -> IndicatorGroup {
2652        IndicatorGroup::Readiness
2653    }
2654}
2655
2656/// A single result returned from [`HealthIndicatorRegistry::run_all`] or
2657/// [`HealthIndicatorRegistry::run_readiness`].
2658#[derive(Debug, Clone)]
2659pub struct HealthRunResult {
2660    /// The registration name of this indicator.
2661    pub name: String,
2662    /// Which probe group this indicator belongs to.
2663    pub group: IndicatorGroup,
2664    /// The output of the check (possibly timed-out).
2665    pub output: HealthCheckOutput,
2666}
2667
2668/// One registered indicator and its result cache.
2669#[derive(Clone)]
2670struct IndicatorEntry {
2671    name: String,
2672    group: IndicatorGroup,
2673    indicator: Arc<dyn HealthIndicator>,
2674    cache: Arc<crate::health_cache::SingleFlightCache<HealthCheckOutput>>,
2675}
2676
2677impl IndicatorEntry {
2678    /// Run the check with its timeout, or return the cached result.
2679    async fn run(self) -> HealthRunResult {
2680        let indicator = Arc::clone(&self.indicator);
2681        let output = self
2682            .cache
2683            .get_or_refresh(
2684                move || async move { run_with_timeout(indicator.as_ref()).await },
2685                || {
2686                    let mut details = HashMap::new();
2687                    details.insert("panicked".to_owned(), serde_json::Value::Bool(true));
2688                    HealthCheckOutput::down().with_details(details)
2689                },
2690            )
2691            .await;
2692        HealthRunResult {
2693            name: self.name,
2694            group: self.group,
2695            output,
2696        }
2697    }
2698}
2699
2700#[derive(Default)]
2701struct IndicatorList {
2702    entries: Vec<IndicatorEntry>,
2703    cache_ttl: std::time::Duration,
2704}
2705
2706/// Registry of named [`HealthIndicator`] implementations.
2707///
2708/// Populated by [`crate::app::AppBuilder::health_indicator`] and stored on
2709/// [`crate::AppState`]. Provides duplicate-registration detection at startup
2710/// and per-indicator timeout enforcement at request time. Results can be
2711/// cached for a short TTL (see [`Self::set_cache_ttl`]).
2712#[derive(Clone, Default)]
2713pub struct HealthIndicatorRegistry {
2714    inner: Arc<RwLock<IndicatorList>>,
2715}
2716
2717impl HealthIndicatorRegistry {
2718    /// Create a new, empty registry.
2719    #[must_use]
2720    pub fn new() -> Self {
2721        Self::default()
2722    }
2723
2724    /// Register a named indicator with its group.
2725    ///
2726    /// Returns `Err` if a indicator with `name` was already registered.
2727    ///
2728    /// # Errors
2729    ///
2730    /// Returns an error string when `name` is already registered.
2731    pub fn register(
2732        &self,
2733        name: impl Into<String>,
2734        group: IndicatorGroup,
2735        indicator: Arc<dyn HealthIndicator>,
2736    ) -> Result<(), String> {
2737        let name = name.into();
2738        let mut inner = self
2739            .inner
2740            .write()
2741            .unwrap_or_else(std::sync::PoisonError::into_inner);
2742        if inner.entries.iter().any(|entry| entry.name == name) {
2743            return Err(format!(
2744                "HealthIndicator '{name}' is already registered; skipping duplicate"
2745            ));
2746        }
2747        let cache = Arc::new(crate::health_cache::SingleFlightCache::new(inner.cache_ttl));
2748        inner.entries.push(IndicatorEntry {
2749            name,
2750            group,
2751            indicator,
2752            cache,
2753        });
2754        drop(inner);
2755        Ok(())
2756    }
2757
2758    /// Set how long one indicator result stays valid. Zero (the default for
2759    /// [`Self::new`]) turns the cache off. Applies to indicators registered
2760    /// before and after this call.
2761    ///
2762    /// When a result is stale, one caller runs the check and the other
2763    /// callers wait for its result (single-flight). A timed-out result is
2764    /// cached too, so a hung dependency is not called again by each prober.
2765    pub fn set_cache_ttl(&self, ttl: std::time::Duration) {
2766        let mut inner = self
2767            .inner
2768            .write()
2769            .unwrap_or_else(std::sync::PoisonError::into_inner);
2770        inner.cache_ttl = ttl;
2771        for entry in &inner.entries {
2772            entry.cache.set_ttl(ttl);
2773        }
2774    }
2775
2776    /// Returns `true` when no indicators have been registered.
2777    #[must_use]
2778    pub fn is_empty(&self) -> bool {
2779        self.inner
2780            .read()
2781            .unwrap_or_else(std::sync::PoisonError::into_inner)
2782            .entries
2783            .is_empty()
2784    }
2785
2786    /// Whether `name` is already registered.
2787    ///
2788    /// For subsystems that register into more than one registry and must not
2789    /// leave half of themselves behind: check every name first, then register.
2790    /// Registration has no undo, so a caller that registers its indicator and
2791    /// then collides on its metrics source would strand an indicator nothing
2792    /// owns — permanently `UP`, and blocking the retry that would fix it.
2793    #[must_use]
2794    pub fn contains(&self, name: &str) -> bool {
2795        self.inner
2796            .read()
2797            .unwrap_or_else(std::sync::PoisonError::into_inner)
2798            .entries
2799            .iter()
2800            .any(|entry| entry.name == name)
2801    }
2802
2803    /// Clone the entries so the read lock is released before async work.
2804    fn entries(&self) -> Vec<IndicatorEntry> {
2805        self.inner
2806            .read()
2807            .unwrap_or_else(std::sync::PoisonError::into_inner)
2808            .entries
2809            .clone()
2810    }
2811
2812    /// Run all registered indicators (both groups) with per-indicator timeouts.
2813    ///
2814    /// All indicators execute **concurrently**; total wall time is bounded by
2815    /// the slowest single indicator rather than N × timeout.
2816    pub async fn run_all(&self) -> Vec<HealthRunResult> {
2817        let mut results =
2818            futures::future::join_all(self.entries().into_iter().map(IndicatorEntry::run)).await;
2819
2820        for breaker in crate::circuit_breaker::global_registry().all_breakers() {
2821            let state = breaker.state();
2822            let status = match state {
2823                crate::circuit_breaker::CircuitState::Open
2824                | crate::circuit_breaker::CircuitState::HalfOpen => HealthStatus::Down,
2825                crate::circuit_breaker::CircuitState::Closed => HealthStatus::Up,
2826            };
2827
2828            let mut details = HashMap::new();
2829            details.insert(
2830                "state".to_string(),
2831                serde_json::Value::String(state.as_str().to_string()),
2832            );
2833            for (key, ratio) in [
2834                ("failure_ratio", breaker.failure_ratio()),
2835                ("slow_call_ratio", breaker.slow_call_ratio()),
2836            ] {
2837                if let Some(ratio_num) = serde_json::Number::from_f64(ratio) {
2838                    details.insert(key.to_string(), serde_json::Value::Number(ratio_num));
2839                }
2840            }
2841
2842            results.push(HealthRunResult {
2843                name: format!("circuit_breaker.{}", breaker.name()),
2844                group: IndicatorGroup::HealthOnly,
2845                output: HealthCheckOutput { status, details },
2846            });
2847        }
2848
2849        results
2850    }
2851
2852    /// Run only `Readiness`-group indicators with per-indicator timeouts.
2853    ///
2854    /// All indicators execute **concurrently**; total wall time is bounded by
2855    /// the slowest single indicator rather than N × timeout.
2856    pub async fn run_readiness(&self) -> Vec<HealthRunResult> {
2857        futures::future::join_all(
2858            self.entries()
2859                .into_iter()
2860                .filter(|entry| entry.group == IndicatorGroup::Readiness)
2861                .map(IndicatorEntry::run),
2862        )
2863        .await
2864    }
2865
2866    /// Compute the aggregate status following Spring Boot precedence.
2867    ///
2868    /// Precedence: `Down` > `OutOfService` > `Unknown` > `Up`.
2869    /// An empty slice returns `Up`.
2870    #[must_use]
2871    pub fn aggregate_status(statuses: &[HealthStatus]) -> HealthStatus {
2872        let mut overall = HealthStatus::Up;
2873        for &s in statuses {
2874            overall = match (overall, s) {
2875                (_, HealthStatus::Down) | (HealthStatus::Down, _) => HealthStatus::Down,
2876                (_, HealthStatus::OutOfService) | (HealthStatus::OutOfService, _) => {
2877                    HealthStatus::OutOfService
2878                }
2879                (_, HealthStatus::Unknown) | (HealthStatus::Unknown, _) => HealthStatus::Unknown,
2880                _ => HealthStatus::Up,
2881            };
2882        }
2883        overall
2884    }
2885}
2886
2887/// Run a single indicator with its declared timeout. Returns `Unknown` with
2888/// `timed_out: true` when the future does not resolve in time.
2889async fn run_with_timeout(indicator: &dyn HealthIndicator) -> HealthCheckOutput {
2890    let duration = tokio::time::Duration::from_millis(indicator.timeout_ms());
2891    match tokio::time::timeout(duration, indicator.check()).await {
2892        Ok(output) => output,
2893        Err(_elapsed) => {
2894            let mut details = HashMap::new();
2895            details.insert("timed_out".to_string(), serde_json::Value::Bool(true));
2896            HealthCheckOutput {
2897                status: HealthStatus::Unknown,
2898                details,
2899            }
2900        }
2901    }
2902}
2903
2904// ── Health ──────────────────────────────────────────────────────
2905
2906/// Enhanced health response for the actuator health endpoint.
2907#[derive(Serialize)]
2908struct ActuatorHealth {
2909    /// Overall aggregate status following Spring Boot precedence.
2910    status: &'static str,
2911    version: &'static str,
2912    profile: String,
2913    uptime: String,
2914    #[cfg(feature = "db")]
2915    autumn_after_commit_failures_total: u64,
2916    /// Total transaction retries triggered by a `40001`/`40P01` (issue #1202).
2917    #[cfg(feature = "db")]
2918    autumn_tx_retries_total: u64,
2919    /// Total transactions that exhausted their retry budget (issue #1202).
2920    #[cfg(feature = "db")]
2921    autumn_tx_retry_exhausted_total: u64,
2922    /// Per-component health, keyed by indicator name.
2923    #[serde(skip_serializing_if = "HashMap::is_empty")]
2924    components: HashMap<String, ComponentHealth>,
2925    /// Backwards-compatible checks block (populated by built-in db indicator).
2926    #[serde(skip_serializing_if = "Option::is_none")]
2927    checks: Option<HealthChecks>,
2928}
2929
2930#[derive(Serialize, Clone)]
2931struct ComponentHealth {
2932    status: &'static str,
2933    #[serde(skip_serializing_if = "Option::is_none")]
2934    details: Option<serde_json::Value>,
2935}
2936
2937#[derive(Serialize)]
2938struct HealthChecks {
2939    #[serde(skip_serializing_if = "Option::is_none")]
2940    database: Option<DatabaseCheck>,
2941}
2942
2943#[derive(Serialize)]
2944struct DatabaseCheck {
2945    status: &'static str,
2946    pool_size: u64,
2947    active_connections: u64,
2948    idle_connections: u64,
2949    #[serde(skip_serializing_if = "Option::is_none")]
2950    error: Option<String>,
2951}
2952
2953fn build_health_components(
2954    db_status: Option<HealthStatus>,
2955    db_check: Option<&DatabaseCheck>,
2956    indicator_results: &[HealthRunResult],
2957    detailed: bool,
2958) -> HashMap<String, ComponentHealth> {
2959    let mut components: HashMap<String, ComponentHealth> = HashMap::new();
2960    // Custom indicators first so the built-in "db" key inserted below can never
2961    // be overwritten by a user-registered indicator with the same name.
2962    for result in indicator_results {
2963        if !detailed
2964            && result.name.starts_with("circuit_breaker.")
2965            && result.output.status.is_healthy()
2966        {
2967            continue;
2968        }
2969        let details = (detailed && !result.output.details.is_empty())
2970            .then(|| serde_json::to_value(&result.output.details).unwrap_or_default());
2971        components.insert(
2972            result.name.clone(),
2973            ComponentHealth {
2974                status: result.output.status.as_str(),
2975                details,
2976            },
2977        );
2978    }
2979    if let Some(s) = db_status {
2980        let details = detailed
2981            .then(|| {
2982                db_check.map(|d| {
2983                    let mut details = serde_json::json!({
2984                        "status": d.status,
2985                        "pool_size": d.pool_size,
2986                        "active_connections": d.active_connections,
2987                        "idle_connections": d.idle_connections,
2988                    });
2989                    if let Some(error) = &d.error {
2990                        details["error"] = serde_json::json!(error);
2991                    }
2992                    details
2993                })
2994            })
2995            .flatten();
2996        components.insert(
2997            "db".to_string(),
2998            ComponentHealth {
2999                status: s.as_str(),
3000                details,
3001            },
3002        );
3003    }
3004    components
3005}
3006
3007/// `GET <actuator-prefix>/health`
3008pub async fn health<S: ProvideActuatorState + Send + Sync + 'static>(
3009    State(state): State<S>,
3010) -> impl IntoResponse {
3011    let detailed = state.health_detailed();
3012
3013    // ── built-in db component ────────────────────────────────────
3014    let db_component = async {
3015        #[cfg(feature = "db")]
3016        {
3017            if let Some(pool) = state.pool() {
3018                // Readiness is a `SELECT 1` on the primary, not pool
3019                // saturation (#3059). Pool numbers stay as details.
3020                let primary = match state.probe_state() {
3021                    Some(probes) => probes.check_primary_db(pool).await,
3022                    None => crate::probe::ProbeState::check_primary_db_uncached(pool).await,
3023                };
3024                let status = pool.status();
3025                let size = status.max_size as u64;
3026                let idle = status.available as u64;
3027                let db_status = if primary.up {
3028                    HealthStatus::Up
3029                } else {
3030                    HealthStatus::Down
3031                };
3032                let db_check = Some(DatabaseCheck {
3033                    status: if primary.up { "ok" } else { "down" },
3034                    pool_size: size,
3035                    active_connections: size.saturating_sub(idle),
3036                    idle_connections: idle,
3037                    // The error can name the host or the user. `checks` is
3038                    // always in the body, so show it only when detailed.
3039                    error: primary.error.filter(|_| detailed),
3040                });
3041                (Some(db_status), db_check)
3042            } else {
3043                (None, None)
3044            }
3045        }
3046        #[cfg(not(feature = "db"))]
3047        {
3048            (None::<HealthStatus>, None::<DatabaseCheck>)
3049        }
3050    };
3051
3052    // ── registered custom indicators ───────────────────────────
3053    let indicators = async {
3054        if let Some(registry) = state.health_indicator_registry() {
3055            registry.run_all().await
3056        } else {
3057            Vec::new()
3058        }
3059    };
3060
3061    // Run both at the same time: the wall time is the slower one, not the sum.
3062    let ((db_component_status, db_check), indicator_results) =
3063        tokio::join!(db_component, indicators);
3064
3065    // ── aggregate status ────────────────────────────────────────
3066    let mut all_statuses: Vec<HealthStatus> =
3067        indicator_results.iter().map(|r| r.output.status).collect();
3068    if let Some(s) = db_component_status {
3069        all_statuses.push(s);
3070    }
3071    let overall = HealthIndicatorRegistry::aggregate_status(&all_statuses);
3072
3073    // ── build components map ────────────────────────────────────
3074    let components = build_health_components(
3075        db_component_status,
3076        db_check.as_ref(),
3077        &indicator_results,
3078        detailed,
3079    );
3080
3081    let checks = db_check.map(|db| HealthChecks { database: Some(db) });
3082
3083    let body = ActuatorHealth {
3084        status: overall.as_str(),
3085        version: env!("CARGO_PKG_VERSION"),
3086        profile: state.profile().to_owned(),
3087        uptime: state.uptime_display(),
3088        #[cfg(feature = "db")]
3089        autumn_after_commit_failures_total: crate::db::AFTER_COMMIT_FAILURES_TOTAL
3090            .load(std::sync::atomic::Ordering::Relaxed),
3091        #[cfg(feature = "db")]
3092        autumn_tx_retries_total: crate::db::TX_RETRIES_TOTAL
3093            .load(std::sync::atomic::Ordering::Relaxed),
3094        #[cfg(feature = "db")]
3095        autumn_tx_retry_exhausted_total: crate::db::TX_RETRY_EXHAUSTED_TOTAL
3096            .load(std::sync::atomic::Ordering::Relaxed),
3097        components,
3098        checks,
3099    };
3100
3101    let code = if overall.is_healthy() {
3102        StatusCode::OK
3103    } else {
3104        StatusCode::SERVICE_UNAVAILABLE
3105    };
3106    (code, Json(body))
3107}
3108
3109// ── Info ────────────────────────────────────────────────────────
3110
3111/// Application info response.
3112#[derive(Serialize)]
3113pub(crate) struct ActuatorInfo {
3114    app: AppInfo,
3115    autumn: FrameworkInfo,
3116    runtime: RuntimeInfo,
3117    /// Build + git provenance baked into the running binary (issue #1242).
3118    build: crate::build_info::BuildProvenance,
3119}
3120
3121#[derive(Serialize)]
3122struct AppInfo {
3123    name: String,
3124    version: String,
3125}
3126
3127#[derive(Serialize)]
3128struct FrameworkInfo {
3129    version: &'static str,
3130    profile: String,
3131}
3132
3133#[derive(Serialize)]
3134struct RuntimeInfo {
3135    uptime: String,
3136}
3137
3138/// `GET <actuator-prefix>/info`
3139pub(crate) async fn info<S: ProvideActuatorState + Send + Sync + 'static>(
3140    State(state): State<S>,
3141) -> Json<ActuatorInfo> {
3142    Json(ActuatorInfo {
3143        app: AppInfo {
3144            // Read the consuming app's compile-time name/version (baked in by
3145            // `#[autumn_web::main]`), not a runtime `std::env::var` lookup that
3146            // always failed in a released binary (issue #1242).
3147            name: crate::build_info::app_name(),
3148            version: crate::build_info::app_version(),
3149        },
3150        autumn: FrameworkInfo {
3151            version: env!("CARGO_PKG_VERSION"),
3152            profile: state.profile().to_owned(),
3153        },
3154        runtime: RuntimeInfo {
3155            uptime: state.uptime_display(),
3156        },
3157        build: crate::build_info::build_provenance(),
3158    })
3159}
3160
3161// ── Env (sensitive) ─────────────────────────────────────────────
3162
3163/// Config environment response with redacted secrets.
3164#[derive(Serialize)]
3165pub(crate) struct ActuatorEnv {
3166    active_profile: String,
3167    properties: std::collections::HashMap<String, serde_json::Value>,
3168}
3169
3170/// Keys that trigger value redaction.
3171const REDACT_PATTERNS: &[&str] = &[
3172    "password",
3173    "secret",
3174    "key",
3175    "token",
3176    "credential",
3177    "auth",
3178    "url",
3179];
3180
3181fn should_redact(key: &str) -> bool {
3182    let lower = key.to_lowercase();
3183    REDACT_PATTERNS.iter().any(|p| lower.contains(p))
3184}
3185
3186/// `GET /actuator/env` — only available when actuator sensitive mode is enabled.
3187pub(crate) async fn env_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3188    State(state): State<S>,
3189) -> Json<ActuatorEnv> {
3190    let properties = state
3191        .config_props()
3192        .snapshot()
3193        .into_iter()
3194        .map(|(key, prop)| (key, prop.value))
3195        .collect();
3196
3197    Json(ActuatorEnv {
3198        active_profile: state.profile().to_owned(),
3199        properties,
3200    })
3201}
3202
3203// ── Metrics ────────────────────────────────────────────────────
3204
3205/// `GET <actuator-prefix>/metrics` -- request metrics, latency, status codes, DB pool stats.
3206/// The `cache` section of `/actuator/metrics`: the read-through snapshot, plus
3207/// the stale-if-error and invalidation-failure counters (#3056).
3208fn cache_metrics_json() -> serde_json::Value {
3209    let mut cache =
3210        serde_json::to_value(crate::cache::read_through_metrics().snapshot()).unwrap_or_default();
3211    if let serde_json::Value::Object(ref mut map) = cache {
3212        map.insert(
3213            "stale_if_error_serves".to_string(),
3214            crate::cache::read_through_metrics()
3215                .stale_if_error_serves()
3216                .into(),
3217        );
3218        map.insert(
3219            "invalidation_failures".to_string(),
3220            crate::cache::invalidation_failures_total().into(),
3221        );
3222    }
3223    cache
3224}
3225
3226pub(crate) async fn metrics_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3227    State(state): State<S>,
3228) -> Json<serde_json::Value> {
3229    let snapshot = state.metrics().snapshot();
3230    let mut result = serde_json::to_value(&snapshot).unwrap_or_default();
3231
3232    // Include read-through cache stampede-protection counters (always
3233    // present; the read-through API works standalone without app state).
3234    if let serde_json::Value::Object(ref mut map) = result {
3235        map.insert("cache".to_string(), cache_metrics_json());
3236    }
3237
3238    // Include DB pool stats if available
3239    #[cfg(feature = "db")]
3240    if let Some(pool) = state.pool() {
3241        let status = pool.status();
3242        let db_stats = serde_json::json!({
3243            "pool_size": status.max_size,
3244            "active_connections": (status.size as u64).saturating_sub(status.available as u64),
3245            "idle_connections": status.available,
3246        });
3247        if let serde_json::Value::Object(ref mut map) = result {
3248            map.insert("database".to_string(), db_stats);
3249        }
3250    }
3251
3252    // Include per-shard pool stats keyed by shard name
3253    #[cfg(feature = "db")]
3254    if let Some(shards) = state.shards() {
3255        let mut shard_stats = serde_json::Map::new();
3256        for shard in shards.iter() {
3257            let status = shard.primary_pool().status();
3258            let mut entry = serde_json::json!({
3259                "pool_size": status.max_size,
3260                "active_connections":
3261                    (status.size as u64).saturating_sub(status.available as u64),
3262                "idle_connections": status.available,
3263                "slots": shard.slots().len(),
3264            });
3265            if let Some(replica) = shard.replica_pool() {
3266                let replica_status = replica.status();
3267                if let serde_json::Value::Object(ref mut entry_map) = entry {
3268                    entry_map.insert(
3269                        "replica".to_string(),
3270                        serde_json::json!({
3271                            "pool_size": replica_status.max_size,
3272                            "active_connections": (replica_status.size as u64)
3273                                .saturating_sub(replica_status.available as u64),
3274                            "idle_connections": replica_status.available,
3275                        }),
3276                    );
3277                }
3278            }
3279            shard_stats.insert(shard.name().to_owned(), entry);
3280        }
3281        if let serde_json::Value::Object(ref mut map) = result {
3282            map.insert(
3283                "database_shards".to_string(),
3284                serde_json::Value::Object(shard_stats),
3285            );
3286        }
3287    }
3288
3289    insert_app_metrics(&mut result);
3290
3291    // Include plugin-contributed sources under the "sources" key
3292    if let Some(registry) = state.metrics_source_registry() {
3293        let all = registry.collect_all();
3294        let mut sources = serde_json::Map::new();
3295        for (source_name, families) in all {
3296            let families_json: Vec<serde_json::Value> = families
3297                .iter()
3298                .map(|f| {
3299                    serde_json::json!({
3300                        "name": f.name,
3301                        "help": f.help,
3302                        "kind": f.kind.as_str(),
3303                        "samples": f.samples.iter().map(|s| {
3304                            let labels: serde_json::Map<String, serde_json::Value> = s.labels
3305                                .iter()
3306                                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
3307                                .collect();
3308                            serde_json::json!({
3309                                "labels": labels,
3310                                "value": s.value,
3311                            })
3312                        }).collect::<Vec<_>>(),
3313                    })
3314                })
3315                .collect();
3316            sources.insert(source_name, serde_json::Value::Array(families_json));
3317        }
3318        if let serde_json::Value::Object(ref mut map) = result {
3319            map.insert("sources".to_string(), serde_json::Value::Object(sources));
3320        }
3321    }
3322
3323    Json(result)
3324}
3325
3326#[derive(Serialize)]
3327pub(crate) struct CircuitBreakerActuatorResponse {
3328    pub name: String,
3329    pub state: &'static str,
3330    pub failure_ratio: f64,
3331    pub slow_call_ratio: f64,
3332    #[serde(skip_serializing_if = "Option::is_none")]
3333    pub failure_ratio_threshold: Option<f64>,
3334    #[serde(skip_serializing_if = "Option::is_none")]
3335    pub sample_window_secs: Option<u64>,
3336    #[serde(skip_serializing_if = "Option::is_none")]
3337    pub minimum_sample_count: Option<u64>,
3338    #[serde(skip_serializing_if = "Option::is_none")]
3339    pub open_duration_secs: Option<u64>,
3340    #[serde(skip_serializing_if = "Option::is_none")]
3341    pub half_open_trial_count: Option<u64>,
3342    /// Absent when slow-call detection is off.
3343    #[serde(skip_serializing_if = "Option::is_none")]
3344    pub slow_call_duration_threshold_ms: Option<u64>,
3345    #[serde(skip_serializing_if = "Option::is_none")]
3346    pub slow_call_rate_threshold: Option<f64>,
3347    #[serde(skip_serializing_if = "Option::is_none")]
3348    pub cancelled_call_outcome: Option<&'static str>,
3349}
3350
3351/// `GET <actuator-prefix>/circuitbreakers`
3352pub(crate) async fn circuitbreakers_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3353    State(state): State<S>,
3354) -> Json<Vec<CircuitBreakerActuatorResponse>> {
3355    let detailed = state.health_detailed();
3356    let mut responses = Vec::new();
3357
3358    for breaker in crate::circuit_breaker::global_registry().all_breakers() {
3359        let policy = breaker.config();
3360        responses.push(CircuitBreakerActuatorResponse {
3361            name: breaker.name().to_string(),
3362            state: breaker.state().as_str(),
3363            failure_ratio: breaker.failure_ratio(),
3364            slow_call_ratio: breaker.slow_call_ratio(),
3365            failure_ratio_threshold: detailed.then_some(policy.failure_ratio_threshold),
3366            sample_window_secs: detailed.then_some(policy.sample_window.as_secs()),
3367            minimum_sample_count: detailed.then_some(policy.minimum_sample_count),
3368            open_duration_secs: detailed.then_some(policy.open_duration.as_secs()),
3369            half_open_trial_count: detailed.then_some(policy.half_open_trial_count),
3370            slow_call_duration_threshold_ms: policy
3371                .slow_call_duration_threshold
3372                .filter(|_| detailed)
3373                .map(|t| u64::try_from(t.as_millis()).unwrap_or(u64::MAX)),
3374            slow_call_rate_threshold: detailed.then_some(policy.slow_call_rate_threshold),
3375            cancelled_call_outcome: detailed.then_some(policy.cancelled_call_outcome.as_str()),
3376        });
3377    }
3378
3379    Json(responses)
3380}
3381
3382/// Add app-defined [`crate::metrics`] facade instruments to the JSON metrics
3383/// document under the top-level `app` key — the same snapshot the Prometheus
3384/// endpoint renders.
3385///
3386/// The key is absent (rather than an empty array) when the app has recorded
3387/// nothing.
3388fn insert_app_metrics(result: &mut serde_json::Value) {
3389    let app_metrics = crate::metrics::snapshot();
3390    if app_metrics.is_empty() {
3391        return;
3392    }
3393    let serde_json::Value::Object(map) = result else {
3394        return;
3395    };
3396    // On the (unreachable) serialization failure, skip the key rather than emit
3397    // `"app": null`: absent already means "nothing recorded", while a null
3398    // would look like a metric that exists and has no data.
3399    match serde_json::to_value(&app_metrics) {
3400        Ok(value) => {
3401            map.insert("app".to_string(), value);
3402        }
3403        Err(error) => tracing::warn!(
3404            %error,
3405            "failed to serialize app metrics; omitting the `app` key"
3406        ),
3407    }
3408}
3409
3410// ── Prometheus ─────────────────────────────────────────────────
3411
3412/// Render label set `{k="v",...}` or empty string for no labels.
3413///
3414/// Writes directly into a pre-allocated `String` to avoid per-pair heap allocations.
3415fn render_labels(labels: &[(String, String)]) -> String {
3416    if labels.is_empty() {
3417        return String::new();
3418    }
3419    let mut out = String::with_capacity(64);
3420    out.push('{');
3421    for (i, (k, v)) in labels.iter().enumerate() {
3422        if i > 0 {
3423            out.push(',');
3424        }
3425        out.push_str(k);
3426        out.push_str("=\"");
3427        for c in v.chars() {
3428            match c {
3429                '\\' => out.push_str("\\\\"),
3430                '\n' => out.push_str("\\n"),
3431                '"' => out.push_str("\\\""),
3432                other => out.push(other),
3433            }
3434        }
3435        out.push('"');
3436    }
3437    out.push('}');
3438    out
3439}
3440
3441/// The family reporting how many label sets each app metric lost to its
3442/// cardinality cap; emitted by `write_app_metrics` when any were dropped.
3443pub(crate) const SERIES_DROPPED_FAMILY: &str = "autumn_metrics_series_dropped_total";
3444
3445/// Redis dead letters removed to obey `jobs.redis.dead_letter_limit`.
3446pub(crate) const DEAD_LETTER_TRIMMED_FAMILY: &str = "autumn_jobs_dead_letter_trimmed_total";
3447
3448/// Time a `Db` checkout waits for a pool connection (issue #3064).
3449pub(crate) const DB_POOL_WAIT_FAMILY: &str = "autumn_db_pool_wait_seconds";
3450
3451/// HTTP request latency histogram (issue #3064).
3452pub(crate) const HTTP_DURATION_FAMILY: &str = "autumn_http_request_duration_seconds";
3453
3454/// Deprecated latency summary. It keeps the p50/p95/p99 quantile lines that
3455/// `autumn_http_request_duration_seconds` carried before it became a histogram.
3456pub(crate) const HTTP_DURATION_QUANTILES_FAMILY: &str =
3457    "autumn_http_request_duration_quantiles_seconds";
3458
3459/// Slow calls per circuit breaker (issue #3060).
3460pub(crate) const BREAKER_SLOW_CALLS_FAMILY: &str = "autumn_circuit_breaker_slow_calls_total";
3461
3462/// The slow-call ratio in each circuit breaker's window (issue #3060).
3463pub(crate) const BREAKER_SLOW_CALL_RATIO_FAMILY: &str = "autumn_circuit_breaker_slow_call_ratio";
3464
3465/// Every metric family name the framework itself emits on `/actuator/prometheus`.
3466///
3467/// Two callers share this list: `prometheus_endpoint` seeds its
3468/// `emitted_families` set with it so a plugin [`MetricsSource`] cannot shadow a
3469/// built-in family, and [`crate::metrics`] refuses to register an app metric
3470/// under any of these names.
3471pub(crate) const BUILTIN_METRIC_FAMILY_NAMES: [&str; 46] = [
3472    "autumn_http_requests_total",
3473    "autumn_http_requests_active",
3474    "autumn_http_responses_total",
3475    // A histogram owns its derived `_bucket`/`_sum`/`_count` names, and a
3476    // summary owns `_sum`/`_count`, even when this exporter writes only
3477    // quantile lines. A second family under a derived name gives the document
3478    // two `# HELP` lines for one family. Prometheus then rejects the *whole*
3479    // scrape, not just that family.
3480    HTTP_DURATION_FAMILY,
3481    "autumn_http_request_duration_seconds_bucket",
3482    "autumn_http_request_duration_seconds_sum",
3483    "autumn_http_request_duration_seconds_count",
3484    HTTP_DURATION_QUANTILES_FAMILY,
3485    "autumn_http_request_duration_quantiles_seconds_sum",
3486    "autumn_http_request_duration_quantiles_seconds_count",
3487    "autumn_shutdown_aborted_requests_total",
3488    "autumn_request_timeouts_total",
3489    "autumn_read_your_writes_pins_total",
3490    "autumn_requests_shed_total",
3491    "autumn_admission_limit",
3492    "autumn_admission_shed_total",
3493    "autumn_tenant_bulkhead_rejections_total",
3494    "autumn_http_route_requests_total",
3495    "autumn_metrics_source_errors_total",
3496    SERIES_DROPPED_FAMILY,
3497    "autumn_cache_read_through_hits_total",
3498    "autumn_cache_read_through_misses_total",
3499    "autumn_cache_read_through_coalesced_waits_total",
3500    "autumn_cache_read_through_fills_total",
3501    "autumn_cache_read_through_fill_failures_total",
3502    "autumn_cache_read_through_stale_serves_total",
3503    "autumn_cache_fill_lock_acquires_total",
3504    "autumn_cache_fill_lock_contended_total",
3505    "autumn_cache_read_through_stale_if_error_serves_total",
3506    "autumn_cache_invalidation_failures_total",
3507    DEAD_LETTER_TRIMMED_FAMILY,
3508    "autumn_jobs_queue_depth",
3509    "autumn_jobs_oldest_age_seconds",
3510    "autumn_jobs_dead_letter_total",
3511    "autumn_db_pool_max_size",
3512    "autumn_db_pool_size",
3513    "autumn_db_pool_available",
3514    "autumn_db_pool_waiting",
3515    DB_POOL_WAIT_FAMILY,
3516    "autumn_db_pool_wait_seconds_bucket",
3517    "autumn_db_pool_wait_seconds_sum",
3518    "autumn_db_pool_wait_seconds_count",
3519    BREAKER_SLOW_CALLS_FAMILY,
3520    BREAKER_SLOW_CALL_RATIO_FAMILY,
3521    crate::shadow::COMPARISONS_METRIC,
3522    crate::shadow::DIVERGENCES_METRIC,
3523];
3524
3525/// Returns true if `s` is a valid Prometheus metric name (`[a-zA-Z_:][a-zA-Z0-9_:]*`).
3526pub(crate) fn is_valid_metric_name(s: &str) -> bool {
3527    let mut it = s.chars();
3528    matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':')
3529        && it.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
3530}
3531
3532/// Returns true if `s` is a valid Prometheus label name (`[a-zA-Z_][a-zA-Z0-9_]*`).
3533pub(crate) fn is_valid_label_name(s: &str) -> bool {
3534    let mut it = s.chars();
3535    matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
3536        && it.all(|c| c.is_ascii_alphanumeric() || c == '_')
3537}
3538
3539/// Escape a Prometheus label value (backslash, newline, and double-quote).
3540fn escape_prometheus_label_value(value: &str) -> String {
3541    let mut out = String::with_capacity(value.len());
3542    for c in value.chars() {
3543        match c {
3544            '\\' => out.push_str("\\\\"),
3545            '\n' => out.push_str("\\n"),
3546            '"' => out.push_str("\\\""),
3547            other => out.push(other),
3548        }
3549    }
3550    out
3551}
3552
3553/// Escape a Prometheus HELP string (backslash and newline only).
3554fn escape_help_text(text: &str) -> String {
3555    let mut out = String::with_capacity(text.len());
3556    for c in text.chars() {
3557        match c {
3558            '\\' => out.push_str("\\\\"),
3559            '\n' => out.push_str("\\n"),
3560            other => out.push(other),
3561        }
3562    }
3563    out
3564}
3565
3566/// Format a sample value per Prometheus text format (handles ±Inf and NaN).
3567fn format_sample_value(v: f64) -> String {
3568    if v == f64::INFINITY {
3569        "+Inf".to_string()
3570    } else if v == f64::NEG_INFINITY {
3571        "-Inf".to_string()
3572    } else if v.is_nan() {
3573        "NaN".to_string()
3574    } else {
3575        v.to_string()
3576    }
3577}
3578
3579/// Append all plugin-contributed metric families (and their error counters) to `out`.
3580///
3581/// `emitted_families` must be pre-seeded with the names of every built-in
3582/// metric family already written to `out`; families whose names collide are
3583/// skipped with a warning so no duplicate `# HELP`/`# TYPE` blocks are emitted.
3584fn render_plugin_sources(
3585    registry: &MetricsSourceRegistry,
3586    out: &mut String,
3587    emitted_families: &mut std::collections::HashSet<String>,
3588) {
3589    use std::fmt::Write;
3590
3591    let all_sources = registry.collect_all();
3592    for (_source_name, families) in &all_sources {
3593        for family in families {
3594            if !is_valid_metric_name(&family.name) {
3595                tracing::warn!(name = %family.name, "MetricsSource returned invalid metric name; skipping family");
3596                continue;
3597            }
3598            if !emitted_families.insert(family.name.clone()) {
3599                tracing::warn!(name = %family.name, "MetricsSource returned duplicate metric family name; skipping family");
3600                continue;
3601            }
3602            let _ = writeln!(
3603                out,
3604                "# HELP {} {}",
3605                family.name,
3606                escape_help_text(&family.help)
3607            );
3608            let _ = writeln!(out, "# TYPE {} {}", family.name, family.kind.as_str());
3609            let mut emitted_series: std::collections::HashSet<String> =
3610                std::collections::HashSet::new();
3611            for sample in &family.samples {
3612                let mut bad_key = false;
3613                let mut seen_keys = std::collections::HashSet::new();
3614                let mut valid_labels: Vec<(String, String)> = Vec::new();
3615                for (k, v) in &sample.labels {
3616                    if !is_valid_label_name(k) {
3617                        tracing::warn!(
3618                            label_name = %k,
3619                            metric = %family.name,
3620                            "MetricsSource returned invalid label name; skipping sample"
3621                        );
3622                        bad_key = true;
3623                        break;
3624                    }
3625                    if !seen_keys.insert(k.as_str()) {
3626                        tracing::warn!(label_name = %k, "MetricsSource returned duplicate label name; dropping duplicate");
3627                        continue;
3628                    }
3629                    valid_labels.push((k.clone(), v.clone()));
3630                }
3631                if bad_key {
3632                    continue;
3633                }
3634                // Sort by key so {a="1",b="2"} and {b="2",a="1"} produce the
3635                // same canonical string and are treated as one series.
3636                valid_labels.sort_by(|(a, _), (b, _)| a.cmp(b));
3637                let labels = render_labels(&valid_labels);
3638                if !emitted_series.insert(labels.clone()) {
3639                    tracing::warn!(
3640                        metric = %family.name,
3641                        labels = %labels,
3642                        "MetricsSource returned duplicate series; skipping sample"
3643                    );
3644                    continue;
3645                }
3646                let _ = writeln!(
3647                    out,
3648                    "{}{} {}",
3649                    family.name,
3650                    labels,
3651                    format_sample_value(sample.value)
3652                );
3653            }
3654        }
3655    }
3656
3657    let error_counts = registry.error_counts();
3658    if !error_counts.is_empty() {
3659        out.push_str(
3660            "# HELP autumn_metrics_source_errors_total \
3661             Number of scrape errors (panics) per plugin metrics source\n",
3662        );
3663        out.push_str("# TYPE autumn_metrics_source_errors_total counter\n");
3664        let mut names: Vec<&String> = error_counts.keys().collect();
3665        names.sort();
3666        for name in names {
3667            let label = render_labels(&[("source".to_string(), name.clone())]);
3668            let _ = writeln!(
3669                out,
3670                "autumn_metrics_source_errors_total{} {}",
3671                label, error_counts[name]
3672            );
3673        }
3674    }
3675}
3676
3677/// Write `autumn_requests_shed_total`, `autumn_admission_limit` and
3678/// `autumn_admission_shed_total` (#3068).
3679fn write_admission_metrics(
3680    out: &mut String,
3681    version: &str,
3682    http: &crate::middleware::metrics::HttpMetrics,
3683) {
3684    use std::fmt::Write as _;
3685
3686    // autumn_requests_shed_total
3687    out.push_str(
3688        "# HELP autumn_requests_shed_total \
3689         HTTP requests rejected by admission control because the in-flight count was at its limit\n",
3690    );
3691    out.push_str("# TYPE autumn_requests_shed_total counter\n");
3692    let _ = writeln!(
3693        out,
3694        "autumn_requests_shed_total{{version=\"{version}\"}} {}",
3695        http.requests_shed_total
3696    );
3697
3698    let admission = &http.admission;
3699    out.push_str(
3700        "# HELP autumn_admission_limit Current admission-control concurrency limit (0 = no limit)\n",
3701    );
3702    out.push_str("# TYPE autumn_admission_limit gauge\n");
3703    let _ = writeln!(
3704        out,
3705        "autumn_admission_limit{{version=\"{version}\"}} {}",
3706        admission.limit
3707    );
3708    out.push_str(
3709        "# HELP autumn_admission_shed_total HTTP requests rejected by admission control, by criticality\n",
3710    );
3711    out.push_str("# TYPE autumn_admission_shed_total counter\n");
3712    for (criticality, count) in [
3713        ("critical", admission.shed_critical),
3714        ("default", admission.shed_default),
3715        ("sheddable", admission.shed_sheddable),
3716    ] {
3717        let _ = writeln!(
3718            out,
3719            "autumn_admission_shed_total{{version=\"{version}\",criticality=\"{criticality}\"}} {count}"
3720        );
3721    }
3722    out.push_str(
3723        "# HELP autumn_tenant_bulkhead_rejections_total \
3724         Work rejected by a per-tenant bulkhead, by kind\n",
3725    );
3726    out.push_str("# TYPE autumn_tenant_bulkhead_rejections_total counter\n");
3727    for (kind, count) in [
3728        ("request", admission.tenant_request_rejections),
3729        ("db", admission.tenant_db_rejections),
3730    ] {
3731        let _ = writeln!(
3732            out,
3733            "autumn_tenant_bulkhead_rejections_total{{version=\"{version}\",kind=\"{kind}\"}} {count}"
3734        );
3735    }
3736}
3737
3738/// Render the built-in `autumn_http_*` metric families into `out`, tagged with
3739/// the replica's deploy `version` label so canary and stable cohorts can be
3740/// compared by a controller scraping both.
3741fn write_builtin_http_metrics(
3742    out: &mut String,
3743    version: &str,
3744    snapshot: &crate::middleware::metrics::MetricsSnapshot,
3745    duration_series: &[crate::middleware::metrics::DurationSeries],
3746) {
3747    use std::fmt::Write;
3748
3749    // requests_total
3750    out.push_str("# HELP autumn_http_requests_total Total number of HTTP requests\n");
3751    out.push_str("# TYPE autumn_http_requests_total counter\n");
3752    let _ = writeln!(
3753        out,
3754        "autumn_http_requests_total{{version=\"{version}\"}} {}",
3755        snapshot.http.requests_total
3756    );
3757
3758    // requests_active
3759    out.push_str("# HELP autumn_http_requests_active Currently active HTTP requests\n");
3760    out.push_str("# TYPE autumn_http_requests_active gauge\n");
3761    let _ = writeln!(
3762        out,
3763        "autumn_http_requests_active{{version=\"{version}\"}} {}",
3764        snapshot.http.requests_active
3765    );
3766
3767    // by_status
3768    out.push_str("# HELP autumn_http_responses_total HTTP responses by status code\n");
3769    out.push_str("# TYPE autumn_http_responses_total counter\n");
3770    for (status, count) in [
3771        ("2xx", snapshot.http.by_status.s2xx),
3772        ("3xx", snapshot.http.by_status.s3xx),
3773        ("4xx", snapshot.http.by_status.s4xx),
3774        ("5xx", snapshot.http.by_status.s5xx),
3775    ] {
3776        let _ = writeln!(
3777            out,
3778            "autumn_http_responses_total{{version=\"{version}\",status=\"{status}\"}} {count}"
3779        );
3780    }
3781
3782    write_http_duration_histogram(out, version, duration_series);
3783
3784    write_http_duration_quantiles(out, version, snapshot);
3785
3786    // autumn_shutdown_aborted_requests_total
3787    out.push_str(
3788        "# HELP autumn_shutdown_aborted_requests_total \
3789         HTTP requests forcibly dropped when the graceful-shutdown drain deadline expired\n",
3790    );
3791    out.push_str("# TYPE autumn_shutdown_aborted_requests_total counter\n");
3792    let _ = writeln!(
3793        out,
3794        "autumn_shutdown_aborted_requests_total{{version=\"{version}\"}} {}",
3795        snapshot.http.shutdown_aborted_requests_total
3796    );
3797
3798    // autumn_request_timeouts_total
3799    out.push_str(
3800        "# HELP autumn_request_timeouts_total \
3801         HTTP requests that exceeded the configured per-request timeout\n",
3802    );
3803    out.push_str("# TYPE autumn_request_timeouts_total counter\n");
3804    let _ = writeln!(
3805        out,
3806        "autumn_request_timeouts_total{{version=\"{version}\"}} {}",
3807        snapshot.http.request_timeouts_total
3808    );
3809
3810    // autumn_read_your_writes_pins_total
3811    out.push_str(
3812        "# HELP autumn_read_your_writes_pins_total \
3813         Replica reads redirected to the primary by the read-your-own-writes pin\n",
3814    );
3815    out.push_str("# TYPE autumn_read_your_writes_pins_total counter\n");
3816    let _ = writeln!(
3817        out,
3818        "autumn_read_your_writes_pins_total{{version=\"{version}\"}} {}",
3819        snapshot.read_your_writes_pins_total
3820    );
3821
3822    write_admission_metrics(out, version, &snapshot.http);
3823    // by_route
3824    if !snapshot.http.by_route.is_empty() {
3825        out.push_str("# HELP autumn_http_route_requests_total HTTP requests by route and method\n");
3826        out.push_str("# TYPE autumn_http_route_requests_total counter\n");
3827        let mut route_keys: Vec<&String> = snapshot.http.by_route.keys().collect();
3828        route_keys.sort();
3829        for route_key in route_keys {
3830            let metrics = &snapshot.http.by_route[route_key];
3831            // route_key is formatted as "METHOD /path"
3832            if let Some((method, path)) = route_key.split_once(' ') {
3833                let _ = writeln!(
3834                    out,
3835                    "autumn_http_route_requests_total{{version=\"{version}\",method=\"{method}\",route=\"{path}\"}} {}",
3836                    metrics.count
3837                );
3838            }
3839        }
3840    }
3841}
3842
3843/// Render the deprecated global p50/p95/p99 summary (issue #3064). You cannot
3844/// add summaries from different replicas. Use the histogram instead.
3845fn write_http_duration_quantiles(
3846    out: &mut String,
3847    version: &str,
3848    snapshot: &crate::middleware::metrics::MetricsSnapshot,
3849) {
3850    use std::fmt::Write;
3851
3852    let name = HTTP_DURATION_QUANTILES_FAMILY;
3853    let _ = writeln!(
3854        out,
3855        "# HELP {name} Deprecated: HTTP request latency quantiles in seconds. \
3856         Use autumn_http_request_duration_seconds"
3857    );
3858    let _ = writeln!(out, "# TYPE {name} summary");
3859    for (quantile, millis) in [
3860        ("0.5", snapshot.http.latency_ms.p50),
3861        ("0.95", snapshot.http.latency_ms.p95),
3862        ("0.99", snapshot.http.latency_ms.p99),
3863    ] {
3864        #[allow(clippy::cast_precision_loss)]
3865        let seconds = millis as f64 / 1000.0;
3866        let _ = writeln!(
3867            out,
3868            "{name}{{version=\"{version}\",quantile=\"{quantile}\"}} {seconds}"
3869        );
3870    }
3871}
3872
3873/// Render `autumn_http_request_duration_seconds` as a histogram (issue #3064).
3874///
3875/// Labels: `version`, `method`, `route`, `status_class`. Every label has a
3876/// bounded value set. `route` is the matched route pattern, or `_unmatched`.
3877/// `method` is a standard method, or `_other`.
3878fn write_http_duration_histogram(
3879    out: &mut String,
3880    version: &str,
3881    series: &[crate::middleware::metrics::DurationSeries],
3882) {
3883    use std::fmt::Write;
3884
3885    let name = HTTP_DURATION_FAMILY;
3886    let _ = writeln!(out, "# HELP {name} HTTP request latency in seconds");
3887    let _ = writeln!(out, "# TYPE {name} histogram");
3888    for entry in series {
3889        let labels = format!(
3890            "version=\"{version}\",method=\"{}\",route=\"{}\",status_class=\"{}\"",
3891            escape_prometheus_label_value(&entry.method),
3892            escape_prometheus_label_value(&entry.route),
3893            entry.status_class,
3894        );
3895        write_histogram_samples(out, name, &labels, &entry.histogram);
3896    }
3897}
3898
3899/// Write the `_bucket`, `_sum` and `_count` lines of one histogram series.
3900/// `labels` is the rendered label list without braces.
3901fn write_histogram_samples(
3902    out: &mut String,
3903    name: &str,
3904    labels: &str,
3905    histogram: &crate::middleware::metrics::HistogramSnapshot,
3906) {
3907    use std::fmt::Write;
3908
3909    let separator = if labels.is_empty() { "" } else { "," };
3910    for (le, count) in crate::middleware::metrics::BUCKET_LABELS
3911        .iter()
3912        .zip(histogram.cumulative)
3913    {
3914        let _ = writeln!(
3915            out,
3916            "{name}_bucket{{{labels}{separator}le=\"{le}\"}} {count}"
3917        );
3918    }
3919    let _ = writeln!(
3920        out,
3921        "{name}_sum{{{labels}}} {}",
3922        format_sample_value(histogram.sum_seconds)
3923    );
3924    let _ = writeln!(out, "{name}_count{{{labels}}} {}", histogram.count());
3925}
3926
3927/// Render the built-in `autumn_cache_*` read-through stampede-protection
3928/// counters into `out`, tagged with the replica's deploy `version` label.
3929/// These counters are process-wide (the read-through API works standalone
3930/// without app state), unlike the HTTP metrics which come from per-app state.
3931fn write_builtin_cache_metrics(
3932    out: &mut String,
3933    version: &str,
3934    snapshot: &crate::cache::ReadThroughMetricsSnapshot,
3935) {
3936    use std::fmt::Write;
3937
3938    for (name, help, value) in [
3939        (
3940            "autumn_cache_read_through_hits_total",
3941            "Read-through cache reads served from a fresh cached value",
3942            snapshot.hits,
3943        ),
3944        (
3945            "autumn_cache_read_through_misses_total",
3946            "Read-through cache reads that found no fresh cached value",
3947            snapshot.misses,
3948        ),
3949        (
3950            "autumn_cache_read_through_coalesced_waits_total",
3951            "Read-through callers that awaited a concurrent in-process fill instead of \
3952             computing their own (single-flight coalescing)",
3953            snapshot.coalesced_waits,
3954        ),
3955        (
3956            "autumn_cache_read_through_fills_total",
3957            "Read-through fill closures that completed successfully",
3958            snapshot.fills,
3959        ),
3960        (
3961            "autumn_cache_read_through_fill_failures_total",
3962            "Read-through fill closures that returned an error",
3963            snapshot.fill_failures,
3964        ),
3965        (
3966            "autumn_cache_read_through_stale_serves_total",
3967            "Stale-while-revalidate reads that served a stale value while a background \
3968             refresh ran",
3969            snapshot.stale_serves,
3970        ),
3971        (
3972            "autumn_cache_fill_lock_acquires_total",
3973            "Distributed cache fill locks acquired by this process",
3974            snapshot.fill_lock_acquires,
3975        ),
3976        (
3977            "autumn_cache_fill_lock_contended_total",
3978            "Distributed cache fill lock attempts that found the lock held by another replica",
3979            snapshot.fill_lock_contended,
3980        ),
3981        (
3982            "autumn_cache_read_through_stale_if_error_serves_total",
3983            "Read-through fills that failed and served the last value instead (stale-if-error)",
3984            crate::cache::read_through_metrics().stale_if_error_serves(),
3985        ),
3986        (
3987            "autumn_cache_invalidation_failures_total",
3988            "Cache invalidations that failed after all retries; stale data can be served",
3989            crate::cache::invalidation_failures_total(),
3990        ),
3991    ] {
3992        let _ = writeln!(out, "# HELP {name} {help}");
3993        let _ = writeln!(out, "# TYPE {name} counter");
3994        let _ = writeln!(out, "{name}{{version=\"{version}\"}} {value}");
3995    }
3996}
3997
3998/// Render the built-in job families into `out` (issues #3055, #3064).
3999///
4000/// Every family is always present, so an alert on `increase()` sees a `0`
4001/// sample before the first event. The `queue` and `job` label values come from
4002/// registered jobs and surveyed queues, so the label set is bounded.
4003fn write_builtin_job_metrics(out: &mut String, version: &str, registry: &JobRegistry) {
4004    use std::fmt::Write;
4005
4006    let name = DEAD_LETTER_TRIMMED_FAMILY;
4007    let _ = writeln!(
4008        out,
4009        "# HELP {name} Redis dead letters removed to obey jobs.redis.dead_letter_limit"
4010    );
4011    let _ = writeln!(out, "# TYPE {name} counter");
4012    let _ = writeln!(
4013        out,
4014        "{name}{{version=\"{version}\"}} {}",
4015        registry.dead_letter_trimmed_total()
4016    );
4017
4018    let mut queues: Vec<(String, QueueStatus)> = registry.queue_snapshot().into_iter().collect();
4019    queues.sort_by(|a, b| a.0.cmp(&b.0));
4020    let _ = writeln!(
4021        out,
4022        "# HELP autumn_jobs_queue_depth Jobs ready to run and waiting on each queue"
4023    );
4024    let _ = writeln!(out, "# TYPE autumn_jobs_queue_depth gauge");
4025    for (queue, status) in &queues {
4026        let queue = escape_prometheus_label_value(queue);
4027        let _ = writeln!(
4028            out,
4029            "autumn_jobs_queue_depth{{version=\"{version}\",queue=\"{queue}\"}} {}",
4030            status.depth
4031        );
4032    }
4033    let _ = writeln!(
4034        out,
4035        "# HELP autumn_jobs_oldest_age_seconds Age of the oldest ready job on each queue"
4036    );
4037    let _ = writeln!(out, "# TYPE autumn_jobs_oldest_age_seconds gauge");
4038    for (queue, status) in &queues {
4039        let queue = escape_prometheus_label_value(queue);
4040        #[allow(clippy::cast_precision_loss, reason = "Prometheus samples are f64")]
4041        let seconds = status.oldest_waiting_age_ms as f64 / 1000.0;
4042        let _ = writeln!(
4043            out,
4044            "autumn_jobs_oldest_age_seconds{{version=\"{version}\",queue=\"{queue}\"}} {}",
4045            format_sample_value(seconds)
4046        );
4047    }
4048
4049    let mut jobs: Vec<(String, JobStatus)> = registry.snapshot().into_iter().collect();
4050    jobs.sort_by(|a, b| a.0.cmp(&b.0));
4051    let _ = writeln!(
4052        out,
4053        "# HELP autumn_jobs_dead_letter_total Job executions moved to the dead-letter queue"
4054    );
4055    let _ = writeln!(out, "# TYPE autumn_jobs_dead_letter_total counter");
4056    for (job, status) in &jobs {
4057        let job = escape_prometheus_label_value(job);
4058        let _ = writeln!(
4059            out,
4060            "autumn_jobs_dead_letter_total{{version=\"{version}\",job=\"{job}\"}} {}",
4061            status.dead_letters
4062        );
4063    }
4064}
4065
4066/// A pool gauge: metric name, help text, and the `Status` field it reads.
4067#[cfg(feature = "db")]
4068type PoolGauge = (&'static str, &'static str, fn(&deadpool::Status) -> usize);
4069
4070/// Render the database pool families into `out` (issue #3064).
4071///
4072/// The wait histogram is always present. The gauges are present when the app
4073/// has a pool. The `pool` label is `primary` or `replica`, or
4074/// `shard:<name>:primary` and `shard:<name>:replica` for a sharded app.
4075fn write_builtin_db_pool_metrics<S: ProvideActuatorState>(
4076    out: &mut String,
4077    version: &str,
4078    state: &S,
4079) {
4080    use std::fmt::Write;
4081
4082    let name = DB_POOL_WAIT_FAMILY;
4083    let _ = writeln!(
4084        out,
4085        "# HELP {name} Time a Db checkout took to get a pool connection, connect time included"
4086    );
4087    let _ = writeln!(out, "# TYPE {name} histogram");
4088    write_histogram_samples(
4089        out,
4090        name,
4091        &format!("version=\"{version}\""),
4092        &state.metrics().db_pool_wait(),
4093    );
4094
4095    #[cfg(feature = "db")]
4096    {
4097        let mut pools: Vec<(String, deadpool::Status)> = Vec::new();
4098        if let Some(pool) = state.pool() {
4099            pools.push(("primary".to_owned(), pool.status()));
4100        }
4101        if let Some(pool) = state.replica_pool() {
4102            pools.push(("replica".to_owned(), pool.status()));
4103        }
4104        if let Some(shards) = state.shards() {
4105            for shard in shards.iter() {
4106                pools.push((
4107                    format!("shard:{}:primary", shard.name()),
4108                    shard.primary_pool().status(),
4109                ));
4110                if let Some(replica) = shard.replica_pool() {
4111                    pools.push((format!("shard:{}:replica", shard.name()), replica.status()));
4112                }
4113            }
4114        }
4115        if pools.is_empty() {
4116            return;
4117        }
4118        let gauges: [PoolGauge; 4] = [
4119            (
4120                "autumn_db_pool_max_size",
4121                "Maximum connections in the pool",
4122                |s| s.max_size,
4123            ),
4124            ("autumn_db_pool_size", "Open connections in the pool", |s| {
4125                s.size
4126            }),
4127            (
4128                "autumn_db_pool_available",
4129                "Idle connections in the pool",
4130                |s| s.available,
4131            ),
4132            (
4133                "autumn_db_pool_waiting",
4134                "Tasks waiting for a pool connection",
4135                |s| s.waiting,
4136            ),
4137        ];
4138        for (gauge, help, read) in gauges {
4139            let _ = writeln!(out, "# HELP {gauge} {help}");
4140            let _ = writeln!(out, "# TYPE {gauge} gauge");
4141            for (label, status) in &pools {
4142                let label = escape_prometheus_label_value(label);
4143                let _ = writeln!(
4144                    out,
4145                    "{gauge}{{version=\"{version}\",pool=\"{label}\"}} {}",
4146                    read(status)
4147                );
4148            }
4149        }
4150    }
4151}
4152
4153/// Render the per-breaker slow-call families (issue #3060) into `out`.
4154///
4155/// The families are absent while there is no breaker. One series for each
4156/// breaker, sorted by name. Names are escaped: a host name is a breaker name.
4157fn write_builtin_circuit_breaker_metrics(
4158    out: &mut String,
4159    version: &str,
4160    breakers: &[crate::circuit_breaker::CircuitBreaker],
4161) {
4162    use std::fmt::Write;
4163
4164    if breakers.is_empty() {
4165        return;
4166    }
4167    let mut breakers: Vec<_> = breakers.iter().collect();
4168    breakers.sort_by(|a, b| a.name().cmp(b.name()));
4169
4170    let name = BREAKER_SLOW_CALLS_FAMILY;
4171    let _ = writeln!(
4172        out,
4173        "# HELP {name} Calls each circuit breaker counted as slow, cancelled slow calls included"
4174    );
4175    let _ = writeln!(out, "# TYPE {name} counter");
4176    for breaker in &breakers {
4177        let label = escape_prometheus_label_value(breaker.name());
4178        let _ = writeln!(
4179            out,
4180            "{name}{{version=\"{version}\",name=\"{label}\"}} {}",
4181            breaker.slow_calls_total()
4182        );
4183    }
4184
4185    let name = BREAKER_SLOW_CALL_RATIO_FAMILY;
4186    let _ = writeln!(
4187        out,
4188        "# HELP {name} Share of slow calls in each circuit breaker sample window"
4189    );
4190    let _ = writeln!(out, "# TYPE {name} gauge");
4191    for breaker in &breakers {
4192        let label = escape_prometheus_label_value(breaker.name());
4193        let _ = writeln!(
4194            out,
4195            "{name}{{version=\"{version}\",name=\"{label}\"}} {}",
4196            breaker.slow_call_ratio()
4197        );
4198    }
4199}
4200
4201/// Render the shadow-mirroring families (issue #1653) into `out`.
4202///
4203/// Written as built-in families rather than through the [`crate::metrics`]
4204/// facade because that facade reserves the `autumn_` namespace for exactly
4205/// these — a framework family registered through it would be silently inert.
4206///
4207/// A replica with no mirror writes nothing at all, so the families stay absent
4208/// from the scrape until an operator turns mirroring on. Route labels are
4209/// bounded by the registry (see its `MAX_ROUTE_SERIES`), and both label values
4210/// are escaped defensively: the route can come from an app's own route
4211/// template.
4212fn write_builtin_shadow_metrics(
4213    out: &mut String,
4214    version: &str,
4215    snapshot: &crate::shadow::ShadowSnapshot,
4216) {
4217    use std::fmt::Write;
4218
4219    for (name, help, label_name, series) in [
4220        (
4221            crate::shadow::COMPARISONS_METRIC,
4222            "Mirrored requests by route and comparison outcome",
4223            "outcome",
4224            &snapshot.comparisons_by_route,
4225        ),
4226        (
4227            crate::shadow::DIVERGENCES_METRIC,
4228            "Primary/shadow response divergences by route and kind",
4229            "kind",
4230            &snapshot.divergences_by_route,
4231        ),
4232    ] {
4233        if series.is_empty() {
4234            continue;
4235        }
4236        let _ = writeln!(out, "# HELP {name} {help}");
4237        let _ = writeln!(out, "# TYPE {name} counter");
4238        for entry in series {
4239            let route = escape_prometheus_label_value(&entry.route);
4240            let label = escape_prometheus_label_value(&entry.label);
4241            let _ = writeln!(
4242                out,
4243                "{name}{{version=\"{version}\",route=\"{route}\",{label_name}=\"{label}\"}} {}",
4244                entry.count
4245            );
4246        }
4247    }
4248}
4249
4250/// Render the app-defined [`crate::metrics`] facade instruments into `out`.
4251///
4252/// Every family name written here is inserted into `emitted_families` —
4253/// including a histogram's derived `_bucket`/`_sum`/`_count` families — so a
4254/// plugin [`MetricsSource`] cannot later shadow one of them.
4255///
4256/// Deliberately unlike the built-in families, app metrics carry **no implicit
4257/// `version` label**: the label set belongs entirely to the call site, so the
4258/// framework cannot collide with a `version` label an app chose itself.
4259///
4260/// An empty snapshot writes zero bytes, keeping the facade invisible in the
4261/// scrape output until an app actually records something.
4262fn write_app_metrics(
4263    out: &mut String,
4264    snapshot: &[crate::metrics::InstrumentSnapshot],
4265    emitted_families: &mut std::collections::HashSet<String>,
4266) {
4267    // Instruments that dropped samples at the cardinality cap, in the
4268    // snapshot's (name-sorted) order.
4269    let mut dropped: Vec<(&str, u64)> = Vec::new();
4270
4271    for instrument in snapshot {
4272        if !claim_app_family_names(instrument, emitted_families) {
4273            continue;
4274        }
4275        if instrument.dropped_series > 0 {
4276            dropped.push((&instrument.name, instrument.dropped_series));
4277        }
4278        write_app_instrument(out, instrument);
4279    }
4280
4281    write_app_dropped_family(out, &dropped);
4282}
4283
4284/// Claim every family name `instrument` occupies, returning whether it may be
4285/// rendered.
4286///
4287/// Both rejections are already enforced at registration; they are re-checked
4288/// here so a scrape can never emit a malformed or duplicated family.
4289fn claim_app_family_names(
4290    instrument: &crate::metrics::InstrumentSnapshot,
4291    emitted_families: &mut std::collections::HashSet<String>,
4292) -> bool {
4293    if !is_valid_metric_name(&instrument.name) {
4294        tracing::warn!(name = %instrument.name, "app metric has an invalid metric name; skipping family");
4295        return false;
4296    }
4297    if !emitted_families.insert(instrument.name.clone()) {
4298        tracing::warn!(name = %instrument.name, "app metric collides with an already-emitted family; skipping family");
4299        return false;
4300    }
4301    if matches!(instrument.kind, crate::metrics::InstrumentKind::Histogram) {
4302        for suffix in crate::metrics::HISTOGRAM_SUFFIXES {
4303            emitted_families.insert(format!("{}{suffix}", instrument.name));
4304        }
4305    }
4306    true
4307}
4308
4309/// Write one app instrument's `# HELP`/`# TYPE` header and every sample line.
4310fn write_app_instrument(out: &mut String, instrument: &crate::metrics::InstrumentSnapshot) {
4311    use std::fmt::Write;
4312
4313    use crate::metrics::InstrumentKind;
4314
4315    if !instrument.help.is_empty() {
4316        let _ = writeln!(
4317            out,
4318            "# HELP {} {}",
4319            instrument.name,
4320            escape_help_text(&instrument.help)
4321        );
4322    }
4323    let kind = match instrument.kind {
4324        InstrumentKind::Counter => "counter",
4325        InstrumentKind::Gauge => "gauge",
4326        InstrumentKind::Histogram => "histogram",
4327    };
4328    let _ = writeln!(out, "# TYPE {} {kind}", instrument.name);
4329
4330    for series in &instrument.series {
4331        write_app_series(out, &instrument.name, series);
4332    }
4333}
4334
4335/// Write the sample line(s) for a single series of the app metric `name`.
4336fn write_app_series(out: &mut String, name: &str, series: &crate::metrics::SeriesSnapshot) {
4337    use std::fmt::Write;
4338
4339    use crate::metrics::SeriesValue;
4340
4341    // `series.labels` is a `BTreeMap`, so this is already sorted by key. The
4342    // rendered label set is built once per series, not once per output line.
4343    let labels: Vec<(String, String)> = series
4344        .labels
4345        .iter()
4346        .map(|(k, v)| (k.clone(), v.clone()))
4347        .collect();
4348    let rendered = render_labels(&labels);
4349
4350    match series.value {
4351        SeriesValue::Counter { value } => {
4352            let _ = writeln!(out, "{name}{rendered} {value}");
4353        }
4354        SeriesValue::Gauge { value } => {
4355            let _ = writeln!(out, "{name}{rendered} {}", format_sample_value(value));
4356        }
4357        SeriesValue::Histogram {
4358            count,
4359            sum,
4360            ref buckets,
4361        } => {
4362            // Every bucket line carries the same user labels with `le` appended
4363            // last, so the shared prefix — everything up to but not including
4364            // the closing brace — is rendered once and reused. `rendered` is
4365            // either empty or ends in the ASCII `}`, so the slice is always on
4366            // a character boundary. `le` values are generated by the facade
4367            // (decimal bounds and the literal `+Inf`) and need no escaping.
4368            let prefix = rendered
4369                .strip_suffix('}')
4370                .map_or_else(|| "{le=\"".to_string(), |inner| format!("{inner},le=\""));
4371            for (le, cumulative) in buckets {
4372                let _ = writeln!(out, "{name}_bucket{prefix}{le}\"}} {cumulative}");
4373            }
4374            let _ = writeln!(out, "{name}_sum{rendered} {}", format_sample_value(sum));
4375            let _ = writeln!(out, "{name}_count{rendered} {count}");
4376        }
4377    }
4378}
4379
4380/// Write the cardinality-cap drop counter, one series per instrument that
4381/// dropped samples. Writes nothing when `dropped` is empty.
4382fn write_app_dropped_family(out: &mut String, dropped: &[(&str, u64)]) {
4383    use std::fmt::Write;
4384
4385    if dropped.is_empty() {
4386        return;
4387    }
4388    let _ = writeln!(
4389        out,
4390        "# HELP {SERIES_DROPPED_FAMILY} \
4391         App metric samples dropped because the metric had already hit its series cardinality cap"
4392    );
4393    let _ = writeln!(out, "# TYPE {SERIES_DROPPED_FAMILY} counter");
4394    for (name, count) in dropped {
4395        let labels = render_labels(&[("metric".to_string(), (*name).to_string())]);
4396        let _ = writeln!(out, "{SERIES_DROPPED_FAMILY}{labels} {count}");
4397    }
4398}
4399
4400/// `GET <actuator-prefix>/prometheus` -- export metrics in Prometheus format.
4401pub(crate) async fn prometheus_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4402    State(state): State<S>,
4403) -> impl IntoResponse {
4404    let snapshot = state.metrics().snapshot();
4405    // Deploy-version label so a canary controller can compare canary vs. stable
4406    // cohorts. Escaped defensively in case an operator sets an exotic value via
4407    // AUTUMN_DEPLOY_VERSION.
4408    let version = escape_prometheus_label_value(&state.deploy_version());
4409    let mut out = String::with_capacity(2048);
4410
4411    write_builtin_http_metrics(
4412        &mut out,
4413        &version,
4414        &snapshot,
4415        &state.metrics().duration_series(),
4416    );
4417    write_builtin_cache_metrics(
4418        &mut out,
4419        &version,
4420        &crate::cache::read_through_metrics().snapshot(),
4421    );
4422    write_builtin_job_metrics(&mut out, &version, state.job_registry());
4423    write_builtin_db_pool_metrics(&mut out, &version, &state);
4424    write_builtin_circuit_breaker_metrics(
4425        &mut out,
4426        &version,
4427        &crate::circuit_breaker::global_registry().all_breakers(),
4428    );
4429    if let Some(handle) = state.shadow() {
4430        write_builtin_shadow_metrics(&mut out, &version, &handle.snapshot());
4431    }
4432
4433    // Name ownership, in precedence order: built-in families first, then the
4434    // app-metrics facade, then plugin-contributed sources — so neither the
4435    // facade nor a plugin can shadow or duplicate a name already emitted.
4436    let mut emitted_families: std::collections::HashSet<String> = BUILTIN_METRIC_FAMILY_NAMES
4437        .iter()
4438        .map(|s| (*s).to_string())
4439        .collect();
4440    write_app_metrics(&mut out, &crate::metrics::snapshot(), &mut emitted_families);
4441    if let Some(registry) = state.metrics_source_registry() {
4442        render_plugin_sources(registry, &mut out, &mut emitted_families);
4443    }
4444
4445    (
4446        [(
4447            axum::http::header::CONTENT_TYPE,
4448            "text/plain; version=0.0.4",
4449        )],
4450        out,
4451    )
4452}
4453
4454// ── Config Properties (sensitive) ──────────────────────────────
4455
4456/// `GET <actuator-prefix>/configprops` -- all config properties with source tracking.
4457pub(crate) async fn configprops_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4458    State(state): State<S>,
4459) -> Json<serde_json::Value> {
4460    let props = state.config_props().snapshot();
4461
4462    Json(serde_json::json!({
4463        "active_profile": state.profile(),
4464        "properties": props,
4465    }))
4466}
4467
4468// ── Loggers (sensitive) ────────────────────────────────────────
4469
4470/// Available log levels for the loggers endpoint.
4471const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
4472
4473/// Response for `GET <actuator-prefix>/loggers`.
4474#[derive(Serialize)]
4475pub(crate) struct LoggersResponse {
4476    current_level: String,
4477    available_levels: Vec<&'static str>,
4478    loggers: HashMap<String, String>,
4479}
4480
4481/// `GET <actuator-prefix>/loggers` -- view current log levels.
4482pub(crate) async fn loggers_get<S: ProvideActuatorState + Send + Sync + 'static>(
4483    State(state): State<S>,
4484) -> Json<LoggersResponse> {
4485    Json(LoggersResponse {
4486        current_level: state.log_levels().current_level(),
4487        available_levels: AVAILABLE_LEVELS.to_vec(),
4488        loggers: state.log_levels().logger_overrides(),
4489    })
4490}
4491
4492/// Request body for `PUT <actuator-prefix>/loggers/{name}`.
4493#[derive(Deserialize)]
4494pub(crate) struct SetLoggerRequest {
4495    level: String,
4496}
4497
4498/// Whether `name` is a valid `tracing` directive target.
4499///
4500/// A directive target is a module path: ASCII alphanumerics plus `_`, `:`, `.`
4501/// and `-` (e.g. `my_app::module`, `tower-http`, `my.custom.target`). `root`
4502/// (and the empty string, treated as root) are special-cased. `.` and `-` are
4503/// valid inside a `tracing` target and are *not* `EnvFilter` directive
4504/// metacharacters. Anything carrying an `EnvFilter` metacharacter — `=`, `,`,
4505/// whitespace, `[`, `]`, `{`, `}` — is rejected so a malformed target never
4506/// reaches the subscriber and the endpoint cannot lie about applying it
4507/// (issue #1044).
4508fn is_valid_logger_name(name: &str) -> bool {
4509    if name.is_empty() || name == "root" {
4510        return true;
4511    }
4512    name.chars()
4513        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':' || ch == '.' || ch == '-')
4514}
4515
4516/// `PUT <actuator-prefix>/loggers/{name}` -- change a logger's level at runtime.
4517pub(crate) async fn loggers_put<S: ProvideActuatorState + Send + Sync + 'static>(
4518    State(state): State<S>,
4519    Path(name): Path<String>,
4520    Json(body): Json<SetLoggerRequest>,
4521) -> impl IntoResponse {
4522    let level = body.level.to_lowercase();
4523
4524    // Validate the level
4525    if !AVAILABLE_LEVELS.contains(&level.as_str()) {
4526        return (
4527            StatusCode::BAD_REQUEST,
4528            Json(serde_json::json!({
4529                "status": "error",
4530                "message": format!(
4531                    "Invalid level '{}'. Available levels: {}",
4532                    level,
4533                    AVAILABLE_LEVELS.join(", ")
4534                ),
4535            })),
4536        );
4537    }
4538
4539    // Validate the target name the same way, so a name carrying an `EnvFilter`
4540    // metacharacter is rejected before it can reach the subscriber (issue #1044).
4541    if !is_valid_logger_name(&name) {
4542        return (
4543            StatusCode::BAD_REQUEST,
4544            Json(serde_json::json!({
4545                "status": "error",
4546                "message": format!(
4547                    "Invalid logger name '{name}'. Names may contain only \
4548                     alphanumerics, '_', ':', '.' and '-' (or 'root')."
4549                ),
4550            })),
4551        );
4552    }
4553
4554    // Base the response on the *actual* apply outcome, never on handle presence:
4555    // a change that failed to reach the live subscriber must not report `ok`
4556    // (issue #1044).
4557    match state.log_levels().set_logger_level(&name, &level) {
4558        LogLevelChange::Applied { previous } => (
4559            StatusCode::OK,
4560            Json(serde_json::json!({
4561                "status": "ok",
4562                "message": format!("Logger '{name}' set to '{level}'"),
4563                "previous": previous,
4564                "applied": true,
4565            })),
4566        ),
4567        LogLevelChange::Recorded { previous } => (
4568            StatusCode::OK,
4569            Json(serde_json::json!({
4570                "status": "recorded",
4571                "message": format!(
4572                    "Logger '{name}' recorded as '{level}' but not applied: no reload-capable subscriber is installed"
4573                ),
4574                "previous": previous,
4575                "applied": false,
4576            })),
4577        ),
4578        LogLevelChange::Rejected { reason } => (
4579            StatusCode::INTERNAL_SERVER_ERROR,
4580            Json(serde_json::json!({
4581                "status": "error",
4582                "message": format!("Logger '{name}' could not be set to '{level}': {reason}"),
4583                "applied": false,
4584            })),
4585        ),
4586    }
4587}
4588
4589// ── Logfile (sensitive) ────────────────────────────────────────
4590
4591/// Query parameters for `GET <actuator-prefix>/logfile`.
4592#[derive(Debug, Deserialize, Default)]
4593pub(crate) struct LogfileQuery {
4594    /// Minimum log level to include (case-insensitive).
4595    ///
4596    /// Valid values: `trace`, `debug`, `info`, `warn`, `error`.
4597    /// When absent all levels are returned.
4598    pub level: Option<String>,
4599    /// Maximum number of entries to return (most-recent N, newest-last).
4600    pub limit: Option<usize>,
4601}
4602
4603/// JSON response shape for `GET <actuator-prefix>/logfile`.
4604#[derive(Debug, Serialize)]
4605pub(crate) struct LogfileResponse {
4606    /// Captured log entries, oldest first.
4607    pub entries: Vec<crate::log::capture::CapturedLogEntry>,
4608    /// Total entries in the buffer (before `limit` is applied).
4609    pub total: usize,
4610    /// `true` when the capture buffer is enabled and populated by the layer.
4611    pub capture_enabled: bool,
4612}
4613
4614/// `GET <actuator-prefix>/logfile` — recent structured log entries.
4615///
4616/// Returns entries from the in-memory capture buffer, filtered by `?level=`
4617/// and capped by `?limit=`. Only available when `actuator.sensitive = true`
4618/// and `log.capture.enabled = true`.  When capture is disabled the endpoint
4619/// still responds with `200` and an empty list so API consumers can handle
4620/// the case uniformly.
4621///
4622/// Returns `400 Bad Request` when an unrecognised `?level=` value is supplied
4623/// so that typos (e.g. `?level=warning`) are rejected rather than silently
4624/// broadening the response to all captured entries.
4625pub(crate) async fn logfile_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4626    State(state): State<S>,
4627    axum::extract::Query(query): axum::extract::Query<LogfileQuery>,
4628) -> Result<axum::Json<LogfileResponse>, (StatusCode, axum::Json<serde_json::Value>)> {
4629    let min_level = match query.level.as_deref() {
4630        None => None,
4631        Some(s) => match crate::log::capture::level_from_str(s) {
4632            Some(level) => Some(level),
4633            None => {
4634                return Err((
4635                    StatusCode::BAD_REQUEST,
4636                    axum::Json(serde_json::json!({
4637                        "error": format!(
4638                            "invalid level {:?}; valid values: TRACE, DEBUG, INFO, WARN, ERROR",
4639                            s
4640                        )
4641                    })),
4642                ));
4643            }
4644        },
4645    };
4646
4647    Ok(match state.log_buffer() {
4648        None => axum::Json(LogfileResponse {
4649            entries: vec![],
4650            total: 0,
4651            capture_enabled: false,
4652        }),
4653        Some(buf) => {
4654            let total = buf.len();
4655            let entries = buf.snapshot(min_level, query.limit);
4656            axum::Json(LogfileResponse {
4657                entries,
4658                total,
4659                capture_enabled: true,
4660            })
4661        }
4662    })
4663}
4664
4665// ── Tasks (sensitive) ──────────────────────────────────────────
4666
4667/// `GET <actuator-prefix>/tasks` -- scheduled task status.
4668pub(crate) async fn tasks_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4669    State(state): State<S>,
4670) -> Json<serde_json::Value> {
4671    let tasks = state.task_registry().snapshot();
4672
4673    Json(serde_json::json!({
4674        "scheduled_tasks": tasks,
4675    }))
4676}
4677
4678/// `GET <actuator-prefix>/jobs` -- ad-hoc background job status.
4679pub(crate) async fn jobs_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4680    State(state): State<S>,
4681) -> Json<serde_json::Value> {
4682    let jobs = state.job_registry().snapshot();
4683    let queues = state.job_registry().queue_snapshot();
4684    Json(serde_json::json!({ "jobs": jobs, "queues": queues }))
4685}
4686
4687/// `GET <actuator-prefix>/derivations` -- maintained derived read models
4688/// (issue #1769).
4689///
4690/// Reports every `#[derivation]` this binary declares: its definition hash, the
4691/// hash and backfill state recorded in `_autumn_derivations`, and its current
4692/// drift from the source of truth. `drift: 0` on every row is the healthy
4693/// answer; a nonzero one names the derivation to recompute.
4694///
4695/// Sensitive-gated, like `/env` and `/graph`: the document names parent tables,
4696/// child tables and the columns joining them.
4697///
4698/// Each drift figure is one aggregate over a parent table, so this is an
4699/// operator endpoint rather than a monitoring one. Do not scrape it.
4700///
4701/// A process with no database pool answers `503` rather than `404`, so an
4702/// operator can tell "this build has no such endpoint" apart from "this process
4703/// has no database to report against".
4704#[cfg(feature = "db")]
4705pub(crate) async fn derivations_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4706    State(state): State<S>,
4707) -> axum::response::Response {
4708    // The control pool is one target among several: a shard-only deployment
4709    // (`[[database.shards]]` with no control role, which the config accepts)
4710    // has none, and its derivations live on the shards. Only a process with
4711    // no database at all is a 503.
4712    let mut report = Vec::new();
4713    if let Some(pool) = state.pool() {
4714        // The control target fails the same way a shard does: one error row,
4715        // so a control pool that cannot be reached does not hide the shards
4716        // that maintain the derivations. Only a process with no shards at all
4717        // turns a control failure into the response's own status.
4718        let statuses = match pool.get().await {
4719            Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4720            Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4721                error.to_string(),
4722            ))),
4723        };
4724        match statuses {
4725            Ok(statuses) => report.extend(derivation_report("control", statuses)),
4726            Err(error) if state.shards().is_some() => report.push(serde_json::json!({
4727                "target": "control",
4728                "error": "could not read derivation state",
4729                "detail": error.to_string(),
4730            })),
4731            Err(error) => {
4732                return (
4733                    StatusCode::INTERNAL_SERVER_ERROR,
4734                    Json(serde_json::json!({
4735                        "error": "could not read derivation state",
4736                        "target": "control",
4737                        "detail": error.to_string(),
4738                    })),
4739                )
4740                    .into_response();
4741            }
4742        }
4743    } else if state.shards().is_none() {
4744        return (
4745            StatusCode::SERVICE_UNAVAILABLE,
4746            Json(serde_json::json!({
4747                "error": "no database pool is configured in this process",
4748                "hint": "derivation state lives in the `_autumn_derivations` table, so \
4749                         reporting it needs a database connection",
4750            })),
4751        )
4752            .into_response();
4753    }
4754    // A sharded app maintains its derivations on every shard primary (that is
4755    // where the tenant rows live, and where startup reconciles and sweeps), so
4756    // the control database alone would report a clean slate while a shard sat
4757    // pending or drifted. One shard that cannot answer is reported as such
4758    // rather than hiding the rest.
4759    if let Some(shards) = state.shards() {
4760        for shard in shards.iter() {
4761            let target = shard.name();
4762            let statuses = match shard.primary_pool().get().await {
4763                Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4764                Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4765                    error.to_string(),
4766                ))),
4767            };
4768            match statuses {
4769                Ok(statuses) => report.extend(derivation_report(target, statuses)),
4770                Err(error) => report.push(serde_json::json!({
4771                    "target": target,
4772                    "error": "could not read derivation state",
4773                    "detail": error.to_string(),
4774                })),
4775            }
4776        }
4777    }
4778    (StatusCode::OK, Json(report)).into_response()
4779}
4780
4781/// One database's derivation statuses as the endpoint's rows, each naming the
4782/// `target` it was read from (`"control"`, or a shard's name).
4783#[cfg(feature = "db")]
4784fn derivation_report(
4785    target: &str,
4786    statuses: Vec<crate::derivation::DerivationStatus>,
4787) -> Vec<serde_json::Value> {
4788    statuses
4789        .into_iter()
4790        .map(|status| {
4791            let mut row = serde_json::to_value(status).unwrap_or_else(
4792                |error| serde_json::json!({ "error": format!("unserialisable status: {error}") }),
4793            );
4794            if let serde_json::Value::Object(ref mut map) = row {
4795                map.insert(
4796                    "target".to_owned(),
4797                    serde_json::Value::String(target.to_owned()),
4798                );
4799            }
4800            row
4801        })
4802        .collect()
4803}
4804
4805/// `GET <actuator-prefix>/graph` -- the application's architecture graph
4806/// (issue #1747).
4807///
4808/// This is the "retrievable from the running binary" surface: the graph is
4809/// assembled from the binary's own link-time registrations at startup, so there
4810/// is no side file to fetch and nothing that can be stale relative to the code
4811/// that is actually running.
4812///
4813/// Sensitive-gated, like `/env` and `/configprops`. The document names every
4814/// route, its auth requirement, and which table each one touches -- a map of
4815/// exactly where an attacker would look first, so it is not a public surface.
4816///
4817/// The handler reads the graph from the state. Each application serves its
4818/// own graph (#2520 item 17).
4819///
4820/// The endpoint answers `503`, not `404`, when the state has no graph. An
4821/// operator can then tell "this app published no graph" from "this build has
4822/// no such endpoint".
4823pub(crate) async fn graph_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4824    State(state): State<S>,
4825) -> axum::response::Response {
4826    graph_response(state.architecture_graph_json())
4827}
4828
4829/// Render the `/actuator/graph` response for a given installed graph.
4830///
4831/// This function is separate from the handler. Tests use it to examine the
4832/// two branches.
4833fn graph_response(graph: Option<bytes::Bytes>) -> axum::response::Response {
4834    graph.map_or_else(
4835        || {
4836            (
4837                StatusCode::SERVICE_UNAVAILABLE,
4838                Json(serde_json::json!({
4839                    "error": "no architecture graph is installed for this application",
4840                    "hint": "the graph is published when the application router is built; a \
4841                             process that serves requests without building one (a bare \
4842                             `axum::Router` in a test, say) has none to report",
4843                })),
4844            )
4845                .into_response()
4846        },
4847        |json| {
4848            (
4849                StatusCode::OK,
4850                [(axum::http::header::CONTENT_TYPE, "application/json")],
4851                json,
4852            )
4853                .into_response()
4854        },
4855    )
4856}
4857
4858/// `GET <actuator-prefix>/cost` -- the cost signal, the request cost of
4859/// each tenant, and the cost of job runs and task ticks (issue #1720).
4860///
4861/// Sensitive-gated: tenant ids and their costs are not public. With
4862/// `[cost] enabled = false` the endpoint answers `{"enabled": false, ...}`.
4863pub(crate) async fn cost_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4864    State(state): State<S>,
4865) -> Json<serde_json::Value> {
4866    let signal = state.cost_signal();
4867    let accountant = state.cost_accountant();
4868    let snapshot = accountant
4869        .as_ref()
4870        .map(crate::cost::CostAccountant::snapshot)
4871        .unwrap_or_default();
4872    Json(serde_json::json!({
4873        "enabled": accountant.is_some(),
4874        "signal": signal.map(|signal| signal.snapshot()),
4875        "total": snapshot.total,
4876        "tenants": snapshot.tenants,
4877        "jobs": snapshot.jobs,
4878        "tasks": snapshot.tasks,
4879    }))
4880}
4881
4882/// `GET <actuator-prefix>/shadow` -- shadow-mirroring counters and the most
4883/// recent primary-vs-shadow divergences (issue #1653).
4884///
4885/// Sensitive-gated, like `/tasks` and `/jobs`: the recorded samples are
4886/// redacted excerpts of real production responses, so this is not a public
4887/// surface. A replica with mirroring switched off answers with
4888/// `{"enabled": false, ...}` rather than `404`, so an operator can tell
4889/// "mirroring is off here" apart from "this build has no such endpoint".
4890pub(crate) async fn shadow_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4891    State(state): State<S>,
4892) -> Json<crate::shadow::ShadowSnapshot> {
4893    Json(
4894        state
4895            .shadow()
4896            .map_or_else(crate::shadow::ShadowHandle::disabled_snapshot, |handle| {
4897                handle.snapshot()
4898            }),
4899    )
4900}
4901
4902// ── Data capsules (issue #1811) ─────────────────────────────────────────────
4903
4904/// Request body for `POST <actuator-prefix>/capsules/export`.
4905#[derive(Deserialize)]
4906pub(crate) struct CapsuleExportRequest {
4907    subject: String,
4908}
4909
4910/// Request body for `POST <actuator-prefix>/capsules/{import,verify}`.
4911#[derive(Deserialize)]
4912pub(crate) struct CapsuleNameRequest {
4913    capsule: String,
4914}
4915
4916fn capsule_error(error: &crate::gdpr::portability::DataCapsuleError) -> axum::response::Response {
4917    use crate::gdpr::portability::DataCapsuleError as E;
4918    let status = match error {
4919        E::NotConfigured(_) | E::MissingSigningSecret => StatusCode::NOT_IMPLEMENTED,
4920        E::InvalidName(_) | E::InvalidInput(_) => StatusCode::BAD_REQUEST,
4921        E::NotEmpty(_) | E::Conflict(_) => StatusCode::CONFLICT,
4922        E::Integrity(_)
4923        | E::UnsupportedFormat(_)
4924        | E::UnknownTable(_)
4925        | E::RelationshipCycle(_)
4926        | E::Json { .. } => StatusCode::UNPROCESSABLE_ENTITY,
4927        _ => StatusCode::INTERNAL_SERVER_ERROR,
4928    };
4929    (
4930        status,
4931        Json(serde_json::json!({"status": "error", "message": error.to_string()})),
4932    )
4933        .into_response()
4934}
4935
4936/// The service and the path of an existing capsule, or the error response.
4937#[allow(
4938    clippy::result_large_err,
4939    reason = "the error is the response itself; it is made once per request"
4940)]
4941fn existing_capsule<S: ProvideActuatorState>(
4942    state: &S,
4943    name: &str,
4944) -> Result<(crate::gdpr::portability::CapsuleService, std::path::PathBuf), axum::response::Response>
4945{
4946    let service = state.data_capsules().map_err(|e| capsule_error(&e))?;
4947    let path = service.capsule_path(name).map_err(|e| capsule_error(&e))?;
4948    // `symlink_metadata` does not follow a link, so a link to a capsule
4949    // outside the directory is "not found".
4950    let is_real_dir = std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_dir());
4951    if !is_real_dir {
4952        return Err((
4953            StatusCode::NOT_FOUND,
4954            Json(serde_json::json!({"status": "error", "message": "capsule not found"})),
4955        )
4956            .into_response());
4957    }
4958    Ok((service, path))
4959}
4960
4961/// `POST <actuator-prefix>/capsules/export` -- write a capsule for a subject.
4962pub(crate) async fn capsules_export_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4963    State(state): State<S>,
4964    Json(body): Json<CapsuleExportRequest>,
4965) -> axum::response::Response {
4966    let service = match state.data_capsules() {
4967        Ok(service) => service,
4968        Err(e) => return capsule_error(&e),
4969    };
4970    let name = crate::gdpr::portability::CapsuleService::capsule_name();
4971    let path = match service.capsule_path(&name) {
4972        Ok(path) => path,
4973        Err(e) => return capsule_error(&e),
4974    };
4975    match service.export_to(&body.subject, &path).await {
4976        Ok(report) => (
4977            StatusCode::OK,
4978            Json(serde_json::json!({"status": "ok", "capsule": name, "report": report})),
4979        )
4980            .into_response(),
4981        Err(e) => capsule_error(&e),
4982    }
4983}
4984
4985/// `POST <actuator-prefix>/capsules/verify` -- check a capsule signature and hashes.
4986pub(crate) async fn capsules_verify_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4987    State(state): State<S>,
4988    Json(body): Json<CapsuleNameRequest>,
4989) -> axum::response::Response {
4990    let (service, path) = match existing_capsule(&state, &body.capsule) {
4991        Ok(found) => found,
4992        Err(response) => return response,
4993    };
4994    match service.verify(&path).await {
4995        Ok(report) => (
4996            StatusCode::OK,
4997            Json(serde_json::json!({"status": "ok", "report": report})),
4998        )
4999            .into_response(),
5000        Err(e) => capsule_error(&e),
5001    }
5002}
5003
5004/// `POST <actuator-prefix>/capsules/import` -- verify, then import a capsule.
5005pub(crate) async fn capsules_import_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5006    State(state): State<S>,
5007    Json(body): Json<CapsuleNameRequest>,
5008) -> axum::response::Response {
5009    let (service, path) = match existing_capsule(&state, &body.capsule) {
5010        Ok(found) => found,
5011        Err(response) => return response,
5012    };
5013    match service.import_from(&path).await {
5014        Ok(summary) => (
5015            StatusCode::OK,
5016            Json(serde_json::json!({"status": "ok", "summary": summary})),
5017        )
5018            .into_response(),
5019        Err(e) => capsule_error(&e),
5020    }
5021}
5022
5023#[cfg(feature = "http-client")]
5024/// Request body for `POST <actuator-prefix>/webhooks/replay`.
5025#[derive(Deserialize)]
5026pub(crate) struct ReplayRequest {
5027    log_id: String,
5028}
5029
5030#[cfg(feature = "http-client")]
5031async fn enqueue_webhook_replay_job(log_id: &str) -> Result<(), String> {
5032    let job_payload = serde_json::json!({
5033        "log_id": log_id,
5034        "replay": true,
5035    });
5036
5037    let Some(job_client) = crate::job::global_job_client() else {
5038        return Err("Global job client is not available".to_string());
5039    };
5040
5041    job_client
5042        .enqueue("autumn_webhook_delivery", job_payload)
5043        .await
5044        .map_err(|e| format!("Failed to enqueue job: {e}"))
5045}
5046
5047#[cfg(feature = "http-client")]
5048/// `GET <actuator-prefix>/webhooks/dlq` -- list dead-lettered webhook logs.
5049pub(crate) async fn webhooks_dlq_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5050    State(state): State<S>,
5051) -> impl IntoResponse {
5052    let Some(manager) = state.webhook_outbound() else {
5053        return (
5054            StatusCode::NOT_IMPLEMENTED,
5055            Json(serde_json::json!({
5056                "status": "error",
5057                "message": "Outbound webhook support is not configured or enabled"
5058            })),
5059        )
5060            .into_response();
5061    };
5062
5063    match manager.store().get_dlq_logs().await {
5064        Ok(logs) => (StatusCode::OK, Json(logs)).into_response(),
5065        Err(e) => (
5066            StatusCode::INTERNAL_SERVER_ERROR,
5067            Json(serde_json::json!({
5068                "status": "error",
5069                "message": format!("Failed to fetch DLQ logs: {}", e)
5070            })),
5071        )
5072            .into_response(),
5073    }
5074}
5075
5076#[cfg(feature = "http-client")]
5077/// `POST <actuator-prefix>/webhooks/replay` -- replay a dead-lettered webhook log.
5078pub(crate) async fn webhooks_replay_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5079    State(state): State<S>,
5080    Json(body): Json<ReplayRequest>,
5081) -> impl IntoResponse {
5082    let Some(manager) = state.webhook_outbound() else {
5083        return (
5084            StatusCode::NOT_IMPLEMENTED,
5085            Json(serde_json::json!({
5086                "status": "error",
5087                "message": "Outbound webhook support is not configured or enabled"
5088            })),
5089        )
5090            .into_response();
5091    };
5092
5093    let log_opt = match manager.store().get_delivery_log(&body.log_id).await {
5094        Ok(log) => log,
5095        Err(e) => {
5096            return (
5097                StatusCode::INTERNAL_SERVER_ERROR,
5098                Json(serde_json::json!({
5099                    "status": "error",
5100                    "message": format!("Failed to retrieve log: {}", e)
5101                })),
5102            )
5103                .into_response();
5104        }
5105    };
5106
5107    let Some(log) = log_opt else {
5108        return (
5109            StatusCode::NOT_FOUND,
5110            Json(serde_json::json!({
5111                "status": "error",
5112                "message": format!("Log with ID {} not found", body.log_id)
5113            })),
5114        )
5115            .into_response();
5116    };
5117
5118    if !log.is_dlq {
5119        return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
5120            "status": "error",
5121            "message": format!("Log with ID {} is not in the Dead Letter Queue (DLQ)", body.log_id)
5122        }))).into_response();
5123    }
5124
5125    if let Some(response) = blocked_webhook_replay_response(&manager, &log, &body.log_id).await {
5126        return response;
5127    }
5128
5129    let subscription_id = log.subscription_id.clone();
5130    let original_log = log.clone();
5131    let log = reset_webhook_replay_log(log);
5132
5133    if let Err(e) = manager.store().log_delivery(log).await {
5134        return (
5135            StatusCode::INTERNAL_SERVER_ERROR,
5136            Json(serde_json::json!({
5137                "status": "error",
5138                "message": format!("Failed to update delivery log state: {}", e)
5139            })),
5140        )
5141            .into_response();
5142    }
5143
5144    // Enqueue background delivery job now that the log state is safely reset in the store
5145    if let Err(message) = enqueue_webhook_replay_job(&body.log_id).await {
5146        if let Err(rollback_error) = manager.store().replace_delivery_log(original_log).await {
5147            tracing::error!(
5148                log_id = %body.log_id,
5149                "Failed to roll back webhook replay log after enqueue failure: {}",
5150                rollback_error
5151            );
5152            return (
5153                StatusCode::INTERNAL_SERVER_ERROR,
5154                Json(serde_json::json!({
5155                    "status": "error",
5156                    "message": format!("{message}; failed to restore DLQ log state: {rollback_error}")
5157                })),
5158            )
5159                .into_response();
5160        }
5161
5162        return (
5163            StatusCode::INTERNAL_SERVER_ERROR,
5164            Json(serde_json::json!({
5165                "status": "error",
5166                "message": message
5167            })),
5168        )
5169            .into_response();
5170    }
5171
5172    // Reactivate auto-failed subscriptions only after the replay job is queued.
5173    if let Err(e) = manager
5174        .store()
5175        .reactivate_failed_subscription(&subscription_id)
5176        .await
5177    {
5178        tracing::warn!(subscription_id = %subscription_id, "Failed to reactivate subscription during replay: {}", e);
5179    }
5180
5181    (
5182        StatusCode::OK,
5183        Json(serde_json::json!({
5184            "status": "ok",
5185            "message": format!("Replay successfully enqueued for log {}", body.log_id)
5186        })),
5187    )
5188        .into_response()
5189}
5190
5191#[cfg(feature = "http-client")]
5192fn reset_webhook_replay_log(
5193    mut log: crate::webhook_outbound::WebhookDeliveryLog,
5194) -> crate::webhook_outbound::WebhookDeliveryLog {
5195    log.is_dlq = false;
5196    log.attempt = 1;
5197    log.last_error = None;
5198    log.response_status = None;
5199    log.response_body = None;
5200    log.timestamp = crate::time::ambient_now();
5201    log
5202}
5203
5204#[cfg(feature = "http-client")]
5205async fn blocked_webhook_replay_response(
5206    manager: &crate::webhook_outbound::WebhookOutboundManager,
5207    log: &crate::webhook_outbound::WebhookDeliveryLog,
5208    log_id: &str,
5209) -> Option<axum::response::Response> {
5210    let subscription = match manager.store().get_subscription(&log.subscription_id).await {
5211        Ok(subscription) => subscription,
5212        Err(e) => {
5213            return Some(
5214                (
5215                    StatusCode::INTERNAL_SERVER_ERROR,
5216                    Json(serde_json::json!({
5217                        "status": "error",
5218                        "message": format!("Failed to retrieve subscription: {}", e)
5219                    })),
5220                )
5221                    .into_response(),
5222            );
5223        }
5224    };
5225
5226    let Some(subscription) = subscription else {
5227        return Some(
5228            (
5229                StatusCode::NOT_FOUND,
5230                Json(serde_json::json!({
5231                    "status": "error",
5232                    "message": format!(
5233                        "Subscription {} for replay log {} was not found",
5234                        log.subscription_id, log_id
5235                    )
5236                })),
5237            )
5238                .into_response(),
5239        );
5240    };
5241
5242    if subscription.status != crate::webhook_outbound::WebhookSubscriptionStatus::Disabled {
5243        return None;
5244    }
5245
5246    Some(
5247        (
5248            StatusCode::CONFLICT,
5249            Json(serde_json::json!({
5250                "status": "error",
5251                "message": format!(
5252                    "Subscription {} is disabled; re-enable it before replaying log {}",
5253                    log.subscription_id, log_id
5254                )
5255            })),
5256        )
5257            .into_response(),
5258    )
5259}
5260
5261// ── A11y ───────────────────────────────────────────────────────
5262
5263/// `GET <actuator-prefix>/a11y` -- scaffold-level accessibility posture.
5264///
5265/// Returns a JSON object describing which WCAG 2.1 AA scaffold concerns the
5266/// application addresses.  Available in all profiles (like `/actuator/health`).
5267pub(crate) async fn a11y_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5268    State(state): State<S>,
5269) -> Json<A11yPosture> {
5270    Json(state.a11y_posture())
5271}
5272
5273// ── Channels (sensitive) ───────────────────────────────────────
5274
5275/// `GET <actuator-prefix>/channels` -- get current channel snapshots.
5276#[cfg(feature = "ws")]
5277pub(crate) async fn channels_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5278    State(state): State<S>,
5279) -> Json<serde_json::Value> {
5280    let channels = state.channels().snapshot();
5281    Json(serde_json::json!({
5282        "channels": channels,
5283    }))
5284}
5285
5286// ── Tasks Stream (WebSocket) ───────────────────────────────────
5287
5288/// `GET <actuator-prefix>/tasks/stream` -- stream scheduled task events.
5289#[cfg(feature = "ws")]
5290pub(crate) async fn tasks_stream_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5291    State(state): State<S>,
5292    tunnel: crate::http_server::KeepTunnel,
5293    ws: axum::extract::ws::WebSocketUpgrade,
5294) -> impl IntoResponse {
5295    ws.on_upgrade(move |mut socket| async move {
5296        // Over HTTP/2, the server keeps the connection while this is held.
5297        let _tunnel = tunnel;
5298        let mut rx = state.channels().subscribe("sys:tasks");
5299        let shutdown = state.shutdown_token();
5300
5301        loop {
5302            tokio::select! {
5303                res = rx.recv() => {
5304                    match res {
5305                        Ok(msg) => {
5306                            let ws_msg = axum::extract::ws::Message::Text(msg.into_string().into());
5307                            if socket.send(ws_msg).await.is_err() {
5308                                break;
5309                            }
5310                        }
5311                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
5312                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
5313                    }
5314                }
5315                () = shutdown.cancelled() => {
5316                    let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
5317                    break;
5318                }
5319                else => break,
5320            }
5321        }
5322    })
5323}
5324
5325// ── Router builder ──────────────────────────────────────────────
5326
5327pub(crate) fn normalize_actuator_prefix(prefix: &str) -> String {
5328    let trimmed = prefix.trim();
5329    if trimmed.is_empty() || trimmed == "/" {
5330        String::new()
5331    } else {
5332        let trimmed = trimmed.trim_end_matches('/');
5333        if trimmed.starts_with('/') {
5334            trimmed.to_owned()
5335        } else {
5336            format!("/{trimmed}")
5337        }
5338    }
5339}
5340
5341pub(crate) fn actuator_route_glob(prefix: &str) -> String {
5342    let prefix = normalize_actuator_prefix(prefix);
5343    if prefix.is_empty() {
5344        "/*".to_owned()
5345    } else {
5346        format!("{prefix}/*")
5347    }
5348}
5349
5350pub(crate) fn actuator_route_path(prefix: &str, suffix: &str) -> String {
5351    let prefix = normalize_actuator_prefix(prefix);
5352    if prefix.is_empty() {
5353        suffix.to_owned()
5354    } else {
5355        format!("{prefix}{suffix}")
5356    }
5357}
5358
5359/// The data-capsule routes (issue #1811). All are `POST` and sensitive.
5360const CAPSULE_ROUTES: [&str; 3] = ["/capsules/export", "/capsules/import", "/capsules/verify"];
5361
5362pub(crate) fn actuator_endpoint_paths(
5363    prefix: &str,
5364    sensitive: bool,
5365    prometheus_enabled: bool,
5366) -> Vec<String> {
5367    let mut paths = vec![
5368        actuator_route_path(prefix, "/health"),
5369        actuator_route_path(prefix, "/info"),
5370        actuator_route_path(prefix, "/metrics"),
5371        actuator_route_path(prefix, "/a11y"),
5372        actuator_route_path(prefix, "/ui"),
5373        actuator_route_path(prefix, "/ui/metrics"),
5374    ];
5375
5376    if prometheus_enabled {
5377        paths.push(actuator_route_path(prefix, "/prometheus"));
5378    }
5379
5380    if sensitive {
5381        paths.push(actuator_route_path(prefix, "/circuitbreakers"));
5382        paths.push(actuator_route_path(prefix, "/env"));
5383        paths.push(actuator_route_path(prefix, "/configprops"));
5384        paths.push(actuator_route_path(prefix, "/loggers"));
5385        paths.push(actuator_route_path(prefix, "/logfile"));
5386        paths.push(actuator_route_path(prefix, "/tasks"));
5387        paths.push(actuator_route_path(prefix, "/jobs"));
5388        paths.push(actuator_route_path(prefix, "/ui/tasks"));
5389        paths.push(actuator_route_path(prefix, "/shadow"));
5390        paths.push(actuator_route_path(prefix, "/cost"));
5391        paths.push(actuator_route_path(prefix, "/graph"));
5392        // POST-only, like `/webhooks/replay` below: listed so the startup
5393        // barrier lets them through.
5394        for suffix in CAPSULE_ROUTES {
5395            paths.push(actuator_route_path(prefix, suffix));
5396        }
5397        #[cfg(feature = "db")]
5398        {
5399            paths.push(actuator_route_path(prefix, "/derivations"));
5400        }
5401        #[cfg(feature = "system-info")]
5402        {
5403            paths.push(actuator_route_path(prefix, "/system"));
5404        }
5405        #[cfg(feature = "http-client")]
5406        {
5407            paths.push(actuator_route_path(prefix, "/webhooks/dlq"));
5408            // `/webhooks/replay` is mounted as `POST` (see
5409            // `actuator_mutating_routes`), not `GET`, but it is included in this
5410            // path set because the runtime startup barrier seeds its actuator
5411            // allow-list from this helper (`StartupBarrierState::from_config`).
5412            // Without it, the `POST {prefix}/webhooks/replay` mount would no
5413            // longer bypass the startup barrier. The GET-only route listing
5414            // (`append_framework_routes`) excludes any path also produced by
5415            // `actuator_mutating_routes`, so this does not surface as a phantom
5416            // GET there.
5417            paths.push(actuator_route_path(prefix, "/webhooks/replay"));
5418        }
5419        #[cfg(feature = "ws")]
5420        {
5421            paths.push(actuator_route_path(prefix, "/channels"));
5422            paths.push(actuator_route_path(prefix, "/tasks/stream"));
5423        }
5424    }
5425
5426    paths
5427}
5428
5429/// Enumerate the actuator's mutating (non-`GET`) framework routes, gated to
5430/// match the mounts in [`actuator_router_with_prefix`].
5431///
5432/// Kept separate from [`actuator_endpoint_paths`] (which is `GET`-only and
5433/// paths-only) so the route listing can classify these with their real HTTP
5434/// method. Returns `(method, path)` pairs using the same
5435/// [`actuator_route_path`] helper as the mounting code so paths match
5436/// byte-for-byte.
5437pub(crate) fn actuator_mutating_routes(
5438    prefix: &str,
5439    sensitive: bool,
5440) -> Vec<(&'static str, String)> {
5441    let mut routes: Vec<(&'static str, String)> = Vec::new();
5442
5443    if sensitive {
5444        routes.push(("PUT", actuator_route_path(prefix, "/loggers/{name}")));
5445        for suffix in CAPSULE_ROUTES {
5446            routes.push(("POST", actuator_route_path(prefix, suffix)));
5447        }
5448        #[cfg(feature = "http-client")]
5449        {
5450            routes.push(("POST", actuator_route_path(prefix, "/webhooks/replay")));
5451        }
5452    }
5453
5454    routes
5455}
5456
5457/// Build the actuator router with profile-aware endpoint exposure.
5458///
5459/// In dev mode (or when `actuator.sensitive = true`), all endpoints are
5460/// exposed. In prod mode, only health, info, and metrics are available.
5461///
5462/// The Prometheus scrape endpoint is mounted unconditionally here (independent
5463/// of `sensitive`). The framework router mounts the actuator from configuration
5464/// and gates `/actuator/prometheus` on the `actuator.prometheus` flag.
5465pub fn actuator_router<S: ProvideActuatorState + Send + Sync + Clone + 'static>(
5466    sensitive: bool,
5467) -> axum::Router<S> {
5468    actuator_router_with_prefix("/actuator", sensitive, true)
5469}
5470
5471/// Build the actuator router at a configured prefix.
5472///
5473/// This is the prefix-aware variant used by the framework router.
5474///
5475/// `prometheus_enabled` controls the `/actuator/prometheus` scrape endpoint
5476/// independently of `sensitive`, so platform metrics scraping can be exposed
5477/// without also exposing sensitive actuator surfaces.
5478#[allow(clippy::too_many_lines)]
5479pub(crate) fn actuator_router_with_prefix<
5480    S: ProvideActuatorState + Send + Sync + Clone + 'static,
5481>(
5482    prefix: &str,
5483    sensitive: bool,
5484    prometheus_enabled: bool,
5485) -> axum::Router<S> {
5486    let mut router = axum::Router::new()
5487        .route(
5488            &actuator_route_path(prefix, "/health"),
5489            axum::routing::get(health::<S>),
5490        )
5491        .route(
5492            &actuator_route_path(prefix, "/info"),
5493            axum::routing::get(info::<S>),
5494        )
5495        .route(
5496            &actuator_route_path(prefix, "/metrics"),
5497            axum::routing::get(metrics_endpoint::<S>),
5498        )
5499        .route(
5500            &actuator_route_path(prefix, "/a11y"),
5501            axum::routing::get(a11y_endpoint::<S>),
5502        );
5503
5504    if prometheus_enabled {
5505        router = router.route(
5506            &actuator_route_path(prefix, "/prometheus"),
5507            axum::routing::get(prometheus_endpoint::<S>),
5508        );
5509    }
5510
5511    if sensitive {
5512        router = router
5513            .route(
5514                &actuator_route_path(prefix, "/circuitbreakers"),
5515                axum::routing::get(circuitbreakers_endpoint::<S>),
5516            )
5517            .route(
5518                &actuator_route_path(prefix, "/env"),
5519                axum::routing::get(env_endpoint::<S>),
5520            )
5521            .route(
5522                &actuator_route_path(prefix, "/configprops"),
5523                axum::routing::get(configprops_endpoint::<S>),
5524            )
5525            .route(
5526                &actuator_route_path(prefix, "/loggers"),
5527                axum::routing::get(loggers_get::<S>),
5528            )
5529            .route(
5530                &actuator_route_path(prefix, "/loggers/{name}"),
5531                axum::routing::put(loggers_put::<S>),
5532            )
5533            .route(
5534                &actuator_route_path(prefix, "/logfile"),
5535                axum::routing::get(logfile_endpoint::<S>),
5536            )
5537            .route(
5538                &actuator_route_path(prefix, "/tasks"),
5539                axum::routing::get(tasks_endpoint::<S>),
5540            )
5541            .route(
5542                &actuator_route_path(prefix, "/jobs"),
5543                axum::routing::get(jobs_endpoint::<S>),
5544            )
5545            .route(
5546                &actuator_route_path(prefix, "/ui/tasks"),
5547                axum::routing::get(ui_tasks::<S>),
5548            )
5549            .route(
5550                &actuator_route_path(prefix, "/shadow"),
5551                axum::routing::get(shadow_endpoint::<S>),
5552            )
5553            .route(
5554                &actuator_route_path(prefix, "/cost"),
5555                axum::routing::get(cost_endpoint::<S>),
5556            )
5557            .route(
5558                &actuator_route_path(prefix, "/graph"),
5559                axum::routing::get(graph_endpoint::<S>),
5560            )
5561            .route(
5562                &actuator_route_path(prefix, "/capsules/export"),
5563                axum::routing::post(capsules_export_endpoint::<S>),
5564            )
5565            .route(
5566                &actuator_route_path(prefix, "/capsules/import"),
5567                axum::routing::post(capsules_import_endpoint::<S>),
5568            )
5569            .route(
5570                &actuator_route_path(prefix, "/capsules/verify"),
5571                axum::routing::post(capsules_verify_endpoint::<S>),
5572            );
5573        #[cfg(feature = "db")]
5574        {
5575            router = router.route(
5576                &actuator_route_path(prefix, "/derivations"),
5577                axum::routing::get(derivations_endpoint::<S>),
5578            );
5579        }
5580        #[cfg(feature = "http-client")]
5581        {
5582            router = router
5583                .route(
5584                    &actuator_route_path(prefix, "/webhooks/dlq"),
5585                    axum::routing::get(webhooks_dlq_endpoint::<S>),
5586                )
5587                .route(
5588                    &actuator_route_path(prefix, "/webhooks/replay"),
5589                    axum::routing::post(webhooks_replay_endpoint::<S>),
5590                );
5591        }
5592
5593        #[cfg(feature = "system-info")]
5594        {
5595            router = router.route(
5596                &actuator_route_path(prefix, "/system"),
5597                axum::routing::get(crate::system_info::system_info_handler),
5598            );
5599        }
5600
5601        #[cfg(feature = "ws")]
5602        {
5603            router = router
5604                .route(
5605                    &actuator_route_path(prefix, "/channels"),
5606                    axum::routing::get(channels_endpoint::<S>),
5607                )
5608                .route(
5609                    &actuator_route_path(prefix, "/tasks/stream"),
5610                    axum::routing::get(tasks_stream_endpoint::<S>)
5611                        .connect(tasks_stream_endpoint::<S>),
5612                );
5613        }
5614    }
5615
5616    // Nova: Add HTMX UI endpoints available unconditionally like metrics
5617    router
5618        .route(
5619            &actuator_route_path(prefix, "/ui"),
5620            axum::routing::get(ui_dashboard),
5621        )
5622        .route(
5623            &actuator_route_path(prefix, "/ui/metrics"),
5624            axum::routing::get(ui_metrics::<S>),
5625        )
5626}
5627
5628#[cfg(test)]
5629mod tests {
5630    use super::*;
5631    use crate::config::AutumnConfig;
5632
5633    #[test]
5634    fn task_registry_flow() {
5635        let registry = TaskRegistry::new();
5636
5637        registry.register_scheduled(
5638            "my_task",
5639            "0 * * * * *",
5640            crate::task::TaskCoordination::Fleet,
5641            "mock",
5642            "node-1",
5643        );
5644        let snap1 = registry.snapshot();
5645        assert_eq!(snap1.get("my_task").unwrap().total_runs, 0);
5646
5647        registry.record_leader("my_task", "node-1", "mock_tick");
5648        let snap3 = registry.snapshot();
5649        assert_eq!(
5650            snap3.get("my_task").unwrap().current_leader.as_deref(),
5651            Some("node-1")
5652        );
5653
5654        registry.record_start("my_task");
5655        let snap4 = registry.snapshot();
5656        assert_eq!(snap4.get("my_task").unwrap().status, "running");
5657
5658        registry.record_next_run_at("my_task", "tomorrow");
5659        let snap5 = registry.snapshot();
5660        assert_eq!(
5661            snap5.get("my_task").unwrap().next_run_at.as_deref(),
5662            Some("tomorrow")
5663        );
5664
5665        registry.record_success("my_task", 100);
5666        let snap6 = registry.snapshot();
5667        assert_eq!(snap6.get("my_task").unwrap().total_runs, 1);
5668        assert_eq!(snap6.get("my_task").unwrap().last_error, None);
5669
5670        registry.record_failure("my_task", 150, "error message");
5671        let snap7 = registry.snapshot();
5672        assert_eq!(snap7.get("my_task").unwrap().total_runs, 2);
5673        assert_eq!(snap7.get("my_task").unwrap().total_failures, 1);
5674        assert_eq!(
5675            snap7.get("my_task").unwrap().last_error.as_deref(),
5676            Some("error message")
5677        );
5678
5679        let registry2 = TaskRegistry::default();
5680        assert!(registry2.snapshot().is_empty());
5681    }
5682    #[test]
5683    fn job_registry_flow() {
5684        let registry = JobRegistry::new();
5685
5686        registry.register("my_job");
5687        let snap1 = registry.snapshot();
5688        assert_eq!(snap1.get("my_job").unwrap().queued, 0);
5689
5690        registry.record_enqueue("my_job");
5691        let snap2 = registry.snapshot();
5692        assert_eq!(snap2.get("my_job").unwrap().queued, 1);
5693
5694        registry.record_start("my_job");
5695        let snap3 = registry.snapshot();
5696        assert_eq!(snap3.get("my_job").unwrap().queued, 0);
5697        assert_eq!(snap3.get("my_job").unwrap().in_flight, 1);
5698
5699        registry.record_retry("my_job", "timeout", 1);
5700        let snap4 = registry.snapshot();
5701        assert_eq!(snap4.get("my_job").unwrap().in_flight, 0);
5702        assert_eq!(
5703            snap4.get("my_job").unwrap().last_error.as_deref(),
5704            Some("timeout")
5705        );
5706
5707        registry.record_enqueue("my_job");
5708        registry.record_start("my_job");
5709        registry.record_success("my_job");
5710        let snap5 = registry.snapshot();
5711        assert_eq!(snap5.get("my_job").unwrap().in_flight, 0);
5712        assert_eq!(snap5.get("my_job").unwrap().total_successes, 1);
5713        assert_eq!(snap5.get("my_job").unwrap().last_error, None);
5714
5715        registry.record_enqueue("my_job");
5716        registry.record_cancel("my_job");
5717        let snap6 = registry.snapshot();
5718        assert_eq!(snap6.get("my_job").unwrap().queued, 0);
5719        assert_eq!(snap6.get("my_job").unwrap().in_flight, 0);
5720
5721        registry.record_enqueue("my_job");
5722        registry.record_start("my_job");
5723        registry.record_failure("my_job", "failure".to_string(), true);
5724        let snap7 = registry.snapshot();
5725        assert_eq!(snap7.get("my_job").unwrap().in_flight, 0);
5726        assert_eq!(snap7.get("my_job").unwrap().total_failures, 1);
5727        assert_eq!(snap7.get("my_job").unwrap().dead_letters, 1);
5728        assert_eq!(
5729            snap7.get("my_job").unwrap().last_error.as_deref(),
5730            Some("failure")
5731        );
5732
5733        let registry2 = JobRegistry::default();
5734        let snap8 = registry2.snapshot();
5735        assert!(snap8.is_empty());
5736    }
5737
5738    #[test]
5739    fn job_registry_tracks_per_queue_depth_and_oldest_age() {
5740        let registry = JobRegistry::new();
5741        registry.register_on_queue("reset_email", "critical");
5742        registry.register_on_queue("reindex", "bulk");
5743
5744        // Enqueue two on `critical`, one on `bulk`.
5745        registry.record_enqueue("reset_email");
5746        registry.record_enqueue("reset_email");
5747        registry.record_enqueue("reindex");
5748
5749        let queues = registry.queue_snapshot();
5750        assert_eq!(queues.get("critical").unwrap().depth, 2);
5751        assert_eq!(queues.get("bulk").unwrap().depth, 1);
5752        // After a real (small) interval, the oldest waiting job's age is
5753        // strictly positive — the snapshot measures elapsed wait time.
5754        std::thread::sleep(std::time::Duration::from_millis(5));
5755        assert!(
5756            registry
5757                .queue_snapshot()
5758                .get("critical")
5759                .unwrap()
5760                .oldest_waiting_age_ms
5761                > 0,
5762            "a job waiting for ~5ms must report a positive age"
5763        );
5764
5765        // Starting a critical job drops the queue depth.
5766        registry.record_start("reset_email");
5767        assert_eq!(registry.queue_snapshot().get("critical").unwrap().depth, 1);
5768
5769        // Draining everything leaves depth 0 and age 0.
5770        registry.record_start("reset_email");
5771        registry.record_start("reindex");
5772        let drained = registry.queue_snapshot();
5773        assert_eq!(drained.get("critical").unwrap().depth, 0);
5774        assert_eq!(drained.get("critical").unwrap().oldest_waiting_age_ms, 0);
5775        assert_eq!(drained.get("bulk").unwrap().depth, 0);
5776    }
5777
5778    #[test]
5779    fn job_registry_unregistered_name_counts_on_the_default_queue() {
5780        // Never registered: the first enqueue inserts the name's status and
5781        // the `default` queue's marks; later ones hit both by borrow.
5782        let registry = JobRegistry::new();
5783        registry.record_enqueue("ghost");
5784        registry.record_enqueue("ghost");
5785        assert_eq!(registry.snapshot().get("ghost").unwrap().queued, 2);
5786        assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 2);
5787        assert_eq!(registry.waiting_marks_for_test("ghost").len(), 2);
5788        registry.record_start("ghost");
5789        assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 1);
5790        let status = registry.snapshot().get("ghost").cloned().unwrap();
5791        assert_eq!((status.queued, status.in_flight), (1, 1));
5792        // A first enqueue lands as exactly one queued job, all else empty.
5793        registry.record_enqueue("fresh");
5794        let fresh = registry.snapshot().get("fresh").cloned().unwrap();
5795        assert_eq!(fresh.queued, 1);
5796        assert_eq!(fresh.in_flight, 0);
5797        assert_eq!(fresh.total_successes, 0);
5798        assert!(fresh.last_error.is_none());
5799    }
5800
5801    #[test]
5802    fn job_registry_registered_queue_marks_stay_on_that_queue() {
5803        let registry = JobRegistry::new();
5804        registry.register_on_queue("send_email", "mail");
5805        // The queue's mark list exists from registration, so every enqueue is
5806        // a borrowed hit, and nothing leaks onto `default`.
5807        for _ in 0..3 {
5808            registry.record_enqueue("send_email");
5809        }
5810        let queues = registry.queue_snapshot();
5811        assert_eq!(queues.get("mail").unwrap().depth, 3);
5812        assert!(!queues.contains_key("default"));
5813        assert_eq!(registry.waiting_marks_for_test("send_email").len(), 3);
5814        registry.record_start("send_email");
5815        registry.record_start("send_email");
5816        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
5817        assert_eq!(registry.snapshot().get("send_email").unwrap().in_flight, 2);
5818    }
5819
5820    /// Clones share marks, so they must share the clock that judges them.
5821    ///
5822    /// `queues` is behind an `Arc`, so every clone of a registry sees one set of
5823    /// waiting marks. When the clock was a per-handle field, installing one on a
5824    /// handle cloned *after* the fact left the older clone reading those same
5825    /// marks against `SystemClock` — the two then reported different ready
5826    /// depths, and years of waiting age, for identical state.
5827    #[test]
5828    fn installing_a_clock_reaches_registry_clones() {
5829        use chrono::{TimeZone, Utc};
5830
5831        // Clone first, install second — the order that used to diverge.
5832        let registry = JobRegistry::new();
5833        let earlier_clone = registry.clone();
5834        registry.register_on_queue("probe", "default");
5835
5836        // Behind real time, like a sim epoch. That direction is what separates
5837        // the two handles: a deadline one virtual minute past a 2020 epoch is
5838        // still years behind a real-clock reading, so a stale clone calls it
5839        // ready while the injected one correctly calls it scheduled. An epoch
5840        // *ahead* of real time would have both answer "not yet" and prove
5841        // nothing.
5842        let epoch = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
5843        let registry = registry.with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5844
5845        // A job due a minute after the injected epoch: scheduled, not ready.
5846        let ready_at = u64::try_from(epoch.timestamp_millis()).unwrap() + 60_000;
5847        registry.record_enqueue_scheduled("probe", ready_at);
5848
5849        let through_new = registry.queue_snapshot();
5850        let through_old = earlier_clone.queue_snapshot();
5851        assert_eq!(
5852            through_new["default"].depth, 0,
5853            "the handle that installed the clock sees a scheduled job"
5854        );
5855        assert_eq!(
5856            through_old["default"].depth, through_new["default"].depth,
5857            "a clone taken before the clock was installed must judge the shared \
5858             marks identically; it read them against the real clock and saw a \
5859             ready job that cannot run"
5860        );
5861        assert_eq!(
5862            through_old["default"].oldest_waiting_age_ms,
5863            through_new["default"].oldest_waiting_age_ms,
5864            "and must report the same waiting age, not a decade of it"
5865        );
5866    }
5867
5868    /// A surveyed age must survive the round trip onto the registry's clock,
5869    /// however far that clock sits from real time.
5870    ///
5871    /// The Postgres survey computes the age in SQL (`NOW() - MIN(run_at)`, both
5872    /// operands on the database clock) and rebases it with `ready_at_from_age`.
5873    /// The registry then freshens it as `registry_now - ready_at`. Subtracting a
5874    /// raw database `MIN(run_at)` from the injected clock instead — what this
5875    /// branch briefly did — reports years of waiting age when the clock is
5876    /// pinned ahead of the database, or zero when it is behind.
5877    #[test]
5878    fn a_surveyed_age_survives_the_rebase_onto_the_registry_clock() {
5879        use chrono::{TimeZone, Utc};
5880
5881        // The database reports its oldest ready job has waited 5s.
5882        const DB_AGE_MS: u64 = 5_000;
5883
5884        // A clock pinned far from real time in each direction, so a leaked
5885        // real-vs-injected subtraction cannot coincidentally pass.
5886        for year in [1999, 2036] {
5887            let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
5888            let registry = JobRegistry::new()
5889                .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5890            registry.register_on_queue("probe", "default");
5891
5892            let registry_now = u64::try_from(epoch.timestamp_millis()).unwrap();
5893            let survey = HashMap::from([(
5894                "default".to_string(),
5895                (3u64, Some(ready_at_from_age(registry_now, DB_AGE_MS))),
5896            )]);
5897            registry.set_queue_depth_gauges(&survey);
5898
5899            let snapshot = registry.queue_snapshot();
5900            let status = &snapshot["default"];
5901            assert_eq!(status.depth, 3, "the survey's depth is authoritative");
5902            assert_eq!(
5903                status.oldest_waiting_age_ms, DB_AGE_MS,
5904                "the database-computed age must come back unchanged with the \
5905                 registry clock at {year}; got {}ms",
5906                status.oldest_waiting_age_ms
5907            );
5908        }
5909    }
5910
5911    /// When no exact mark was recorded for a job's id (the fallback path —
5912    /// e.g. it was never noted, or was evicted from
5913    /// [`JobRegistry::note_pg_job_mark`]'s bounded table), an admin-cancel
5914    /// must still find the correct waiting mark whichever of three
5915    /// timelines it lives on: an absolute `enqueue_at` mark (matched exactly
5916    /// by `run_at`'s own value), a relative-delay `enqueue_in` mark (stamped
5917    /// from real time via `due_origin()`, reconstructed from
5918    /// `real_reference_ms + offset_ms`), or an immediate-enqueue mark
5919    /// (stamped from this registry's own clock, reconstructed from
5920    /// `self.now_ms() + offset_ms`) — regardless of what this registry's own
5921    /// injected clock reads, since only the immediate case actually lives on
5922    /// it.
5923    ///
5924    /// Mirrors `ready_at_from_age`'s translate-onto-a-known-timeline
5925    /// approach, tried against all three candidates. A category decided on
5926    /// the backend's clock and matched against marks judged on the
5927    /// registry's own *separate* clock (`pop_waiting`) can disagree about a
5928    /// boundary; trying each mark's own plausible timeline directly cannot.
5929    #[test]
5930    fn cancel_at_backend_offset_falls_back_to_the_nearest_of_its_three_timelines() {
5931        use chrono::{TimeZone, Utc};
5932
5933        // An arbitrary "backend now" reference, unrelated to either pinned
5934        // registry clock below — standing in for a Postgres `clock_timestamp()`
5935        // reading, which a real cancel would supply.
5936        const BACKEND_NOW_MS: u64 = 1_800_000_000_000;
5937        // An arbitrary absolute due-at instant, far from every other value
5938        // used here, standing in for an `enqueue_at(some_instant)` mark.
5939        const ABSOLUTE_MARK_MS: u64 = 9_999_999_999_999;
5940
5941        for year in [1999, 2036] {
5942            let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
5943            let epoch_ms = u64::try_from(epoch.timestamp_millis()).unwrap();
5944            let registry = JobRegistry::new()
5945                .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5946            registry.register_on_queue("immediate_job", "mail");
5947            registry.register_on_queue("relative_job", "mail");
5948            registry.register_on_queue("absolute_job", "mail");
5949
5950            // Three marks, three timelines — mirroring how the three enqueue
5951            // shapes actually stamp a Postgres-backed job's registry mark.
5952            // Pushed via the plain (non-`_pg_`) enqueue methods, so no exact
5953            // mark is noted under any id: every cancel below must resolve
5954            // through the candidate fallback, not the exact lookup. Asserted
5955            // via `waiting_marks_for_test` rather than `queue_snapshot`'s
5956            // depth: that method buckets marks against its own `now_ms()`
5957            // (the registry's pinned clock here), which — unrelated to the
5958            // fix under test — would itself misjudge a mark stamped on a
5959            // wildly different timeline as "not yet ready".
5960            registry.record_enqueue("immediate_job");
5961            registry.record_enqueue_scheduled("relative_job", BACKEND_NOW_MS + 3_600_000);
5962            registry.record_enqueue_scheduled("absolute_job", ABSOLUTE_MARK_MS);
5963            assert_eq!(
5964                registry.waiting_marks_for_test("immediate_job"),
5965                vec![epoch_ms, BACKEND_NOW_MS + 3_600_000, ABSOLUTE_MARK_MS],
5966                "all three marks pushed onto the shared queue"
5967            );
5968
5969            // Cancel the relative-delay job: offset ~+1h from BACKEND_NOW_MS
5970            // (real time). No `run_at` supplied (irrelevant to this branch).
5971            // "no-exact-mark" is an unregistered id, forcing the fallback.
5972            registry.record_cancel_at_backend_offset(
5973                "relative_job",
5974                "no-exact-mark",
5975                None,
5976                BACKEND_NOW_MS,
5977                3_600_000,
5978            );
5979            assert_eq!(
5980                registry.waiting_marks_for_test("immediate_job"),
5981                vec![epoch_ms, ABSOLUTE_MARK_MS],
5982                "canceling the relative-delay job (registry clock pinned to {year}) must \
5983                 remove its own real-time mark and leave the other two intact"
5984            );
5985
5986            // Cancel the immediate job: offset ~0, no `run_at`. The only
5987            // candidate that can match is `self.now_ms() + 0` — this
5988            // registry's own (pinned) clock.
5989            registry.record_cancel_at_backend_offset(
5990                "immediate_job",
5991                "no-exact-mark",
5992                None,
5993                BACKEND_NOW_MS,
5994                0,
5995            );
5996            assert_eq!(
5997                registry.waiting_marks_for_test("immediate_job"),
5998                vec![ABSOLUTE_MARK_MS],
5999                "canceling the immediate job (registry clock pinned to {year}) must remove \
6000                 its own registry-clock mark and leave the absolute mark intact"
6001            );
6002
6003            // Cancel the absolute job: `run_at` supplied verbatim, matching
6004            // ABSOLUTE_MARK_MS exactly regardless of the (irrelevant, and
6005            // here deliberately wrong) offset/real-reference pair.
6006            registry.record_cancel_at_backend_offset(
6007                "absolute_job",
6008                "no-exact-mark",
6009                Some(ABSOLUTE_MARK_MS),
6010                BACKEND_NOW_MS,
6011                0,
6012            );
6013            assert_eq!(
6014                registry.waiting_marks_for_test("immediate_job"),
6015                Vec::<u64>::new(),
6016                "canceling the absolute job (registry clock pinned to {year}) must remove \
6017                 its own mark via the exact run_at match and drain the queue to zero"
6018            );
6019        }
6020    }
6021
6022    /// A coincidental collision between two co-queued jobs' candidate
6023    /// targets — one job's true mark landing exactly on a *different* job's
6024    /// fallback candidate — must not make the wrong mark the "nearest" one.
6025    ///
6026    /// `record_pg_enqueue` remembers each job's own exact mark by id
6027    /// (`note_pg_job_mark`), and `record_cancel_at_backend_offset` looks that
6028    /// up before ever computing a candidate, so this can't happen for a job
6029    /// enqueued that way: the exact lookup is unambiguous by construction,
6030    /// unlike nearest-match over candidates that only approximate a mark's
6031    /// true timeline.
6032    #[test]
6033    fn cancel_at_backend_offset_prefers_the_exact_mark_over_a_coincidental_candidate_collision() {
6034        let registry = JobRegistry::new();
6035        registry.register_on_queue("relative_job", "mail");
6036        registry.register_on_queue("absolute_job", "mail");
6037
6038        // job-a's own real-time mark; job-b's own absolute mark, chosen so
6039        // that job-a's *fallback* candidate (computed below) would land
6040        // exactly on job-b's mark instead of job-a's own.
6041        registry.record_pg_enqueue(
6042            "relative_job",
6043            "job-a-id",
6044            Some(5_000),
6045            PgMarkTimeline::Real,
6046        );
6047        registry.record_pg_enqueue(
6048            "absolute_job",
6049            "job-b-id",
6050            Some(9_999),
6051            PgMarkTimeline::Real,
6052        );
6053        assert_eq!(
6054            registry.waiting_marks_for_test("relative_job"),
6055            vec![5_000, 9_999],
6056            "both marks pushed onto the shared queue"
6057        );
6058
6059        // Cancel job-a: its offset/real-reference pair is deliberately
6060        // chosen so the *fallback* candidate (9_999 + 0 = 9_999) collides
6061        // exactly with job-b's mark, not job-a's own (5_000). Without the
6062        // exact-by-id lookup, nearest-match would remove job-b's mark
6063        // instead (distance 0 beats 5_000's distance of 4_999).
6064        registry.record_cancel_at_backend_offset("relative_job", "job-a-id", None, 9_999, 0);
6065        assert_eq!(
6066            registry.waiting_marks_for_test("relative_job"),
6067            vec![9_999],
6068            "canceling job-a must remove its own exact mark (5_000), not job-b's mark that \
6069             its fallback candidate coincidentally collides with"
6070        );
6071    }
6072
6073    /// `record_pg_start` removes a job's entry once it actually starts (see
6074    /// below), but a job enqueued and never claimed at all — a crashed
6075    /// worker, a queue with no consumer — never reaches that call, so
6076    /// without a cap `note_pg_job_mark` would grow by one entry per such
6077    /// enqueue for the lifetime of the process.
6078    #[test]
6079    fn note_pg_job_mark_is_bounded_so_uncancelled_jobs_cannot_leak_memory_forever() {
6080        let registry = JobRegistry::new();
6081        registry.register_on_queue("never_canceled", "mail");
6082        for i in 0..(PG_MARKS_BY_JOB_ID_CAP + 5) {
6083            registry.record_pg_enqueue(
6084                "never_canceled",
6085                &format!("job-{i}"),
6086                Some(u64::try_from(i).unwrap()),
6087                PgMarkTimeline::Real,
6088            );
6089        }
6090        assert_eq!(
6091            registry.pg_marks_len_for_test(),
6092            PG_MARKS_BY_JOB_ID_CAP,
6093            "the exact-mark table must never grow past its cap, even when every job it \
6094             tracks is enqueued and never claimed at all"
6095        );
6096    }
6097
6098    /// Regression for the Codex P2 raised on commit 763da93: in a split
6099    /// web/worker deployment, a web replica that only enqueues never calls
6100    /// `record_pg_start` (it runs no worker loop at all), so every entry it
6101    /// inserts sits here until capacity eviction regardless of whether some
6102    /// other replica has long since claimed and finished the job. Plain
6103    /// oldest-inserted-first eviction could just as easily pick an older job
6104    /// that is still genuinely delayed — and therefore, by construction
6105    /// (Postgres never hands out a row before its own `run_at`), definitely
6106    /// NOT yet claimed by anyone, anywhere — over one that is already due
6107    /// and plausibly claimed elsewhere already.
6108    #[test]
6109    fn note_pg_job_mark_eviction_protects_entries_still_in_the_future() {
6110        let registry = JobRegistry::new();
6111        registry.register_on_queue("mixed_cap", "mail");
6112
6113        let now = now_epoch_ms();
6114        let far_future = now + 3_600_000; // an hour out: cannot possibly be claimed yet.
6115
6116        // Inserted first, so plain FIFO eviction would pick it first — but
6117        // it is still genuinely delayed.
6118        registry.record_pg_enqueue(
6119            "mixed_cap",
6120            "still-delayed",
6121            Some(far_future),
6122            PgMarkTimeline::Real,
6123        );
6124        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6125            registry.record_pg_enqueue(
6126                "mixed_cap",
6127                &format!("already-due-{i}"),
6128                Some(0),
6129                PgMarkTimeline::Real,
6130            );
6131        }
6132        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6133
6134        // One more enqueue forces an eviction.
6135        registry.record_pg_enqueue("mixed_cap", "one-more", Some(0), PgMarkTimeline::Real);
6136
6137        assert_eq!(
6138            registry.pg_marks_len_for_test(),
6139            PG_MARKS_BY_JOB_ID_CAP,
6140            "the cap must still hold"
6141        );
6142        assert!(
6143            registry.pg_mark_contains_id_for_test("still-delayed"),
6144            "an entry still in the future must never be evicted while an already-due \
6145             entry is available to evict instead"
6146        );
6147        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6148    }
6149
6150    /// Regression for the Codex P2 raised on commit a460209: comparing every
6151    /// mark against a single clock misjudges the other kind whenever the
6152    /// registry's own (possibly injected/virtual) clock and real time
6153    /// disagree. Pins the registry's clock far in the future — as an app
6154    /// injecting a `FixedClock`/`TickingClock` ahead of wall time would —
6155    /// and confirms a genuinely-still-delayed real-time mark is never
6156    /// mistaken for "due" just because it looks small next to the
6157    /// registry's far-future reading. (Marks are now tagged with the one
6158    /// clock that actually measures them — see [`PgMarkTimeline`] — rather
6159    /// than judged against both; this still exercises the same scenario.)
6160    #[test]
6161    fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_against_real_time_only() {
6162        use chrono::{TimeZone, Utc};
6163
6164        let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6165            crate::time::FixedClock::at(Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap()),
6166        ));
6167        registry.register_on_queue("skewed", "mail");
6168
6169        let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6170        // 5 real-world minutes out — genuinely not due yet — but a "small"
6171        // number next to the registry's year-2100 reading, which is exactly
6172        // what made the single-clock check call it due in error.
6173        let genuinely_delayed = real_now + 300_000;
6174
6175        // Inserted first, so plain FIFO (and the single-clock check) would
6176        // pick it first.
6177        registry.record_pg_enqueue(
6178            "skewed",
6179            "still-delayed",
6180            Some(genuinely_delayed),
6181            PgMarkTimeline::Real,
6182        );
6183        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6184            // Genuinely due under both clocks (epoch 0 is in the past on any
6185            // clock), so these are always legitimate eviction candidates.
6186            registry.record_pg_enqueue(
6187                "skewed",
6188                &format!("filler-{i}"),
6189                Some(0),
6190                PgMarkTimeline::Real,
6191            );
6192        }
6193        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6194
6195        // One more enqueue forces an eviction.
6196        registry.record_pg_enqueue("skewed", "one-more", Some(0), PgMarkTimeline::Real);
6197
6198        assert_eq!(
6199            registry.pg_marks_len_for_test(),
6200            PG_MARKS_BY_JOB_ID_CAP,
6201            "the cap must still hold"
6202        );
6203        assert!(
6204            registry.pg_mark_contains_id_for_test("still-delayed"),
6205            "a mark that is not yet due in real time must never be evicted just because \
6206             the registry's own (far-future) clock alone would call it due"
6207        );
6208        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6209    }
6210
6211    /// Regression for the Codex P2 raised on commit d3e86d6: requiring both
6212    /// clocks to agree an entry is due protects a still-delayed entry from
6213    /// being evicted, but it can equally blind eviction to every genuinely
6214    /// stale entry when the registry clock has drifted *behind* real time
6215    /// (the mirror image of the far-future case above) — a real-timeline
6216    /// mark (large real epoch ms) then never compares as `<=` a small,
6217    /// stuck-in-the-past registry clock reading, however far in the real
6218    /// past it actually is. Pins the registry's clock far in the past and
6219    /// confirms a pile of already-claimed real-time marks are still
6220    /// eviction candidates, protecting a genuinely-future one inserted
6221    /// first — each entry is now judged only against the one clock
6222    /// ([`PgMarkTimeline`]) that actually measures it.
6223    #[test]
6224    fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_even_when_the_registry_clock_lags() {
6225        use chrono::{TimeZone, Utc};
6226
6227        let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6228            crate::time::FixedClock::at(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap()),
6229        ));
6230        registry.register_on_queue("lagging", "mail");
6231
6232        let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6233        // An hour out in real time — genuinely not due yet — inserted
6234        // first, so plain FIFO would evict it first.
6235        let far_future = real_now + 3_600_000;
6236
6237        registry.record_pg_enqueue(
6238            "lagging",
6239            "still-delayed",
6240            Some(far_future),
6241            PgMarkTimeline::Real,
6242        );
6243        for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6244            // Real-timeline marks already due in real time (epoch 0), but a
6245            // registry clock stuck at year 2000 would never call these
6246            // "due" if compared against it too.
6247            registry.record_pg_enqueue(
6248                "lagging",
6249                &format!("filler-{i}"),
6250                Some(0),
6251                PgMarkTimeline::Real,
6252            );
6253        }
6254        assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6255
6256        // One more enqueue forces an eviction.
6257        registry.record_pg_enqueue("lagging", "one-more", Some(0), PgMarkTimeline::Real);
6258
6259        assert_eq!(
6260            registry.pg_marks_len_for_test(),
6261            PG_MARKS_BY_JOB_ID_CAP,
6262            "the cap must still hold"
6263        );
6264        assert!(
6265            registry.pg_mark_contains_id_for_test("still-delayed"),
6266            "a genuinely-future real-timeline entry must never be evicted while an \
6267             already-due real-timeline entry is available, even when the registry's own \
6268             clock lags behind real time and would call every real-timeline mark 'not \
6269             due' if it were consulted for them too"
6270        );
6271        assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6272    }
6273
6274    /// Regression for the Codex P2 raised on commit c0cbfd3: before
6275    /// `record_pg_start` existed, an entry stuck around until evicted by
6276    /// [`PG_MARKS_BY_JOB_ID_CAP`] regardless of whether its job had long
6277    /// since started and completed, so a busy queue's finished jobs could
6278    /// push out a still-genuinely-queued long-delay job's entry well before
6279    /// the cap's raw count would suggest — degrading that cancel to the
6280    /// (weaker) candidate-nearest-match fallback. `record_pg_start` removing
6281    /// the entry the instant its mark is consumed means the table tracks
6282    /// live queue residency instead of lifetime enqueue count: churning far
6283    /// more jobs through start than the cap must never make the table grow
6284    /// at all, since none of them are still waiting.
6285    #[test]
6286    fn record_pg_start_keeps_the_exact_mark_table_bounded_by_live_residency_not_lifetime_enqueues()
6287    {
6288        let registry = JobRegistry::new();
6289        registry.register_on_queue("churn", "mail");
6290        for i in 0..(PG_MARKS_BY_JOB_ID_CAP * 3) {
6291            let id = format!("job-{i}");
6292            registry.record_pg_enqueue(
6293                "churn",
6294                &id,
6295                Some(u64::try_from(i).unwrap()),
6296                PgMarkTimeline::Real,
6297            );
6298            registry.record_pg_start("churn", &id);
6299        }
6300        assert_eq!(
6301            registry.pg_marks_len_for_test(),
6302            0,
6303            "a job whose mark was consumed by record_pg_start must not linger in the \
6304             exact-mark table just because the cap has not been reached"
6305        );
6306        assert!(
6307            registry.pg_marks_seq_order_is_consistent_for_test(),
6308            "pg_marks_seq_order (the O(1)-eviction seq index) must mirror \
6309             pg_marks_by_job_id exactly after a long run of inserts and removals, \
6310             not just at a single snapshot"
6311        );
6312    }
6313
6314    /// Regression for the Codex P2 raised on commit 034e47a:
6315    /// `record_start`'s generic `pop_waiting` removes whichever ready mark
6316    /// comes first in the queue's internal order, not necessarily the mark
6317    /// belonging to the job actually starting — Postgres can start a
6318    /// later-enqueued job ahead of an older one this process still has
6319    /// concurrency-blocked. The first cut of `record_pg_start` popped
6320    /// generically and then unconditionally forgot the starting job's exact
6321    /// entry regardless, which could tear down its mapping while its real
6322    /// mark stayed in the queue and a different, still-waiting job's mark
6323    /// vanished in its place.
6324    #[test]
6325    fn record_pg_start_removes_its_own_mark_even_when_an_older_job_is_still_first_in_queue() {
6326        const OLDER_MARK: u64 = 1_000;
6327        const NEWER_MARK: u64 = 2_000;
6328
6329        let registry = JobRegistry::new();
6330        registry.register_on_queue("mixed", "work");
6331
6332        // job-a enqueued first (older, still queued/concurrency-blocked);
6333        // job-b enqueued second but starts first.
6334        registry.record_pg_enqueue("mixed", "job-a", Some(OLDER_MARK), PgMarkTimeline::Real);
6335        registry.record_pg_enqueue("mixed", "job-b", Some(NEWER_MARK), PgMarkTimeline::Real);
6336
6337        registry.record_pg_start("mixed", "job-b");
6338
6339        assert_eq!(
6340            registry.waiting_marks_for_test("mixed"),
6341            vec![OLDER_MARK],
6342            "starting job-b must remove its own mark, leaving job-a's still-queued \
6343             mark untouched even though job-a's mark sits first in the queue"
6344        );
6345        assert_eq!(
6346            registry.pg_marks_len_for_test(),
6347            1,
6348            "job-a's exact entry must survive; only job-b's own entry is removed"
6349        );
6350    }
6351
6352    /// A stale `pg_marks_by_job_id` entry must not swallow the cancel: if the
6353    /// exact-looked-up value is no longer in the queue, the cancel has to
6354    /// fall back to the candidate heuristic, not silently remove nothing.
6355    ///
6356    /// Simulates staleness via the plain `record_start` (which knows nothing
6357    /// about `pg_marks_by_job_id`) rather than `record_pg_start` (which keeps
6358    /// the entry in sync): a non-terminal retry then pushes a *new* mark
6359    /// under the same job name without updating that entry either — so a
6360    /// cancel racing a retry finds a value (`pop_waiting_exact`) that is no
6361    /// longer in the queue at all.
6362    #[test]
6363    fn cancel_at_backend_offset_falls_back_when_the_exact_mark_is_stale() {
6364        const MARK_A: u64 = 1_000;
6365        const MARK_B: u64 = 2_000;
6366
6367        let registry = JobRegistry::new();
6368        registry.register_on_queue("flaky_job", "mail");
6369
6370        // Original enqueue: pushes mark_a and notes it under "job-id".
6371        registry.record_pg_enqueue("flaky_job", "job-id", Some(MARK_A), PgMarkTimeline::Real);
6372
6373        // The job starts: use plain `record_start` (not `record_pg_start`)
6374        // to pop mark_a from the queue while deliberately leaving "job-id"'s
6375        // exact entry in place, simulating a caller that pushed a waiting
6376        // mark outside the `record_pg_start`/`record_pg_enqueue` pair — the
6377        // one way an entry can still go stale — so this test exercises the
6378        // fallback in isolation from that pair's own bookkeeping.
6379        registry.record_start("flaky_job");
6380        assert_eq!(
6381            registry.waiting_marks_for_test("flaky_job"),
6382            Vec::<u64>::new(),
6383            "record_start must have popped mark_a"
6384        );
6385
6386        // The job fails non-terminally and is requeued as a retry: a new
6387        // mark is pushed under the same job name via the plain
6388        // `record_enqueue_scheduled` primitive, without updating "job-id"'s
6389        // exact entry — exercising the fallback directly, for whichever
6390        // caller pushes a retry mark this way rather than through
6391        // `record_pg_enqueue` (see `pg_retry_refreshes_the_exact_mark_so_a_racing_cancel_does_not_hit_a_coincidental_collision`
6392        // in job.rs for the production retry-lifecycle path, which now does).
6393        registry.record_enqueue_scheduled("flaky_job", MARK_B);
6394
6395        // Cancel "job-id": the exact lookup finds the stale mark_a, which
6396        // `pop_waiting_exact` cannot find in the queue (only mark_b is
6397        // there) — the fallback candidate (real_reference_ms + offset_ms)
6398        // is set up to land exactly on mark_b.
6399        registry.record_cancel_at_backend_offset("flaky_job", "job-id", None, MARK_B, 0);
6400        assert_eq!(
6401            registry.waiting_marks_for_test("flaky_job"),
6402            Vec::<u64>::new(),
6403            "the stale exact entry must not stop the cancel from removing the job's real, \
6404             current mark (mark_b) via the candidate fallback"
6405        );
6406    }
6407
6408    #[test]
6409    fn survey_setter_overwrites_queue_gauges_and_resets_absent_queues() {
6410        let registry = JobRegistry::new();
6411        registry.register_on_queue("reset_email", "critical");
6412        registry.register_on_queue("reindex", "bulk");
6413
6414        // Local marks exist, but once a survey is published it is authoritative:
6415        // the durable backend, not this process's enqueue marks, drives the
6416        // reported depth/age (issue #1752).
6417        registry.record_enqueue("reset_email");
6418
6419        let now = now_epoch_ms();
6420        let mut survey = HashMap::new();
6421        // `critical` has 4 ready jobs; oldest became ready 5s ago.
6422        survey.insert(
6423            "critical".to_string(),
6424            (4_u64, Some(now.saturating_sub(5_000))),
6425        );
6426        // `bulk` is empty in the survey (absent oldest → age 0).
6427        survey.insert("bulk".to_string(), (0_u64, None));
6428        registry.set_queue_depth_gauges(&survey);
6429
6430        let snap = registry.queue_snapshot();
6431        assert_eq!(
6432            snap.get("critical").unwrap().depth,
6433            4,
6434            "survey depth overrides the local enqueue mark"
6435        );
6436        let age = snap.get("critical").unwrap().oldest_waiting_age_ms;
6437        assert!(
6438            (5_000..=6_000).contains(&age),
6439            "age is derived from the surveyed oldest ready-at timestamp, got {age}"
6440        );
6441        assert_eq!(snap.get("bulk").unwrap().depth, 0);
6442        assert_eq!(snap.get("bulk").unwrap().oldest_waiting_age_ms, 0);
6443
6444        // A later survey that omits `critical` resets it to 0 (no leak), even
6445        // though a known/registered queue keeps appearing in the snapshot.
6446        let mut survey2 = HashMap::new();
6447        survey2.insert("bulk".to_string(), (2_u64, Some(now)));
6448        registry.set_queue_depth_gauges(&survey2);
6449        let snap2 = registry.queue_snapshot();
6450        assert_eq!(
6451            snap2.get("critical").unwrap().depth,
6452            0,
6453            "a queue absent from the newest survey resets to 0"
6454        );
6455        assert_eq!(snap2.get("bulk").unwrap().depth, 2);
6456    }
6457
6458    #[test]
6459    fn survey_setter_overwrites_per_job_queued_and_resets_absent_names() {
6460        let registry = JobRegistry::new();
6461        registry.register("reset_email");
6462        registry.register("reindex");
6463
6464        registry.record_enqueue("reset_email");
6465        registry.record_enqueue("reset_email");
6466
6467        let mut counts = HashMap::new();
6468        counts.insert("reset_email".to_string(), 7_u64);
6469        registry.set_queued_counts(&counts);
6470        assert_eq!(
6471            registry.snapshot()["reset_email"].queued,
6472            7,
6473            "survey overwrites the local enqueue-driven queued count"
6474        );
6475        assert_eq!(
6476            registry.snapshot()["reindex"].queued,
6477            0,
6478            "a name absent from the survey resets to 0"
6479        );
6480
6481        registry.set_queued_counts(&HashMap::new());
6482        assert_eq!(registry.snapshot()["reset_email"].queued, 0);
6483    }
6484
6485    #[test]
6486    fn future_scheduled_jobs_do_not_inflate_ready_queue_depth() {
6487        let registry = JobRegistry::new();
6488        registry.register_on_queue("nightly_report", "reports");
6489        registry.register_on_queue("send_email", "reports");
6490
6491        // A job scheduled for the future is not yet claimable, so it must not
6492        // count toward ready queue depth or age the queue: future-dated jobs
6493        // enqueued via enqueue_in/enqueue_at were reporting phantom backlog and
6494        // could trip false autoscaling/alerting on /actuator/jobs.
6495        let far_future = now_epoch_ms() + 60_000;
6496        registry.record_enqueue_scheduled("nightly_report", far_future);
6497
6498        let scheduled_only = registry.queue_snapshot();
6499        let reports = scheduled_only
6500            .get("reports")
6501            .expect("reports queue tracked");
6502        assert_eq!(
6503            reports.depth, 0,
6504            "a future-scheduled job is not ready backlog"
6505        );
6506        assert_eq!(
6507            reports.oldest_waiting_age_ms, 0,
6508            "a future-scheduled job must not age the ready queue"
6509        );
6510
6511        // A due-now enqueue on the same queue still counts immediately.
6512        registry.record_enqueue("send_email");
6513        assert_eq!(
6514            registry.queue_snapshot().get("reports").unwrap().depth,
6515            1,
6516            "an immediately-runnable job still counts toward ready depth"
6517        );
6518
6519        // Once a scheduled job's ready time has passed it joins ready depth and
6520        // contributes to oldest-waiting age.
6521        let already_ready = now_epoch_ms().saturating_sub(5);
6522        registry.record_enqueue_scheduled("nightly_report", already_ready);
6523        let promoted = registry.queue_snapshot();
6524        assert_eq!(
6525            promoted.get("reports").unwrap().depth,
6526            2,
6527            "a scheduled job counts once its ready time has passed"
6528        );
6529        assert!(
6530            promoted.get("reports").unwrap().oldest_waiting_age_ms > 0,
6531            "a job whose ready time has passed contributes to oldest-waiting age"
6532        );
6533
6534        // Starting the two ready jobs leaves only the still-future scheduled
6535        // mark, which reports as no ready backlog.
6536        registry.record_start("send_email");
6537        registry.record_start("nightly_report");
6538        let drained = registry.queue_snapshot();
6539        assert_eq!(
6540            drained.get("reports").unwrap().depth,
6541            0,
6542            "with both ready jobs started only the future mark remains, counting as 0"
6543        );
6544        assert_eq!(
6545            drained.get("reports").unwrap().oldest_waiting_age_ms,
6546            0,
6547            "a lone future-scheduled mark ages nothing"
6548        );
6549    }
6550
6551    #[test]
6552    fn canceling_a_scheduled_job_preserves_a_coqueued_ready_mark() {
6553        // Reproduces the durable admin-cancel gap: when an operator cancels a
6554        // still-scheduled (delayed) job that shares a queue with a ready job,
6555        // the cancel must remove the *scheduled* waiting mark, not the ready
6556        // one. Popping the ready mark (via the ready removal path) would report
6557        // the queue depth one too low while the ready job is still waiting.
6558        let registry = JobRegistry::new();
6559        registry.register_on_queue("nightly_report", "reports");
6560        registry.register_on_queue("send_email", "reports");
6561
6562        // One ready job and one future-scheduled job share the queue; only the
6563        // ready job counts toward ready depth.
6564        registry.record_enqueue("send_email");
6565        let far_future = now_epoch_ms() + 60_000;
6566        registry.record_enqueue_scheduled("nightly_report", far_future);
6567        assert_eq!(
6568            registry.queue_snapshot().get("reports").unwrap().depth,
6569            1,
6570            "only the ready job counts toward ready depth"
6571        );
6572
6573        // Cancel the scheduled job (the durable backend signals this because it
6574        // removed the job from its delayed set). The scheduled removal path must
6575        // consume the future mark and leave the ready job's mark intact.
6576        registry.record_cancel_scheduled("nightly_report");
6577        assert_eq!(
6578            registry.queue_snapshot().get("reports").unwrap().depth,
6579            1,
6580            "canceling the scheduled job must not steal the co-queued ready job's mark"
6581        );
6582
6583        // The surviving ready job still drains to zero — exactly one mark left,
6584        // so no scheduled mark leaked.
6585        registry.record_start("send_email");
6586        assert_eq!(
6587            registry.queue_snapshot().get("reports").unwrap().depth,
6588            0,
6589            "starting the ready job drains the queue; the scheduled mark was the one removed"
6590        );
6591    }
6592
6593    #[test]
6594    fn canceling_a_ready_job_removes_a_ready_mark() {
6595        // No-regression companion: canceling a ready (immediately-runnable) job
6596        // removes a ready mark, so the queue depth drops by exactly one.
6597        let registry = JobRegistry::new();
6598        registry.register_on_queue("send_email", "mail");
6599
6600        registry.record_enqueue("send_email");
6601        registry.record_enqueue("send_email");
6602        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6603
6604        registry.record_cancel("send_email");
6605        assert_eq!(
6606            registry.queue_snapshot().get("mail").unwrap().depth,
6607            1,
6608            "canceling a ready job removes exactly one ready mark"
6609        );
6610
6611        registry.record_start("send_email");
6612        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6613    }
6614
6615    #[test]
6616    fn retry_dedup_without_enqueue_mark_keeps_real_duplicate_waiting() {
6617        let registry = JobRegistry::new();
6618        registry.register_on_queue("send_email", "mail");
6619
6620        // A real duplicate is enqueued and waiting: its per-queue mark is present.
6621        registry.record_enqueue("send_email");
6622        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
6623
6624        // A retry that failed and coalesced into that duplicate never re-recorded
6625        // an enqueue mark, so its dedup must NOT pop the real duplicate's waiting
6626        // mark (doing so would report depth 0 while work is still waiting).
6627        registry.record_deduplicated("send_email", false, false);
6628        assert_eq!(
6629            registry.queue_snapshot().get("mail").unwrap().depth,
6630            1,
6631            "retry-dedup with no prior enqueue mark must not steal a waiting duplicate's mark"
6632        );
6633
6634        // A normal enqueue→dedup pair (the coalesced job DID record an enqueue
6635        // mark) still nets to zero: its own mark is removed, no leak.
6636        registry.record_enqueue("send_email");
6637        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6638        registry.record_deduplicated("send_email", true, false);
6639        assert_eq!(
6640            registry.queue_snapshot().get("mail").unwrap().depth,
6641            1,
6642            "a normal coalesced enqueue removes exactly its own mark (no leak)"
6643        );
6644
6645        // The surviving real duplicate still drains normally.
6646        registry.record_start("send_email");
6647        assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6648    }
6649
6650    #[test]
6651    fn deduplicating_a_scheduled_duplicate_preserves_a_coqueued_ready_mark() {
6652        // Reproduces the dedup category gap (sibling of the #965 cancel fix):
6653        // when a delayed (scheduled) duplicate coalesces on a queue that also
6654        // holds a ready job, the dedup must remove the *scheduled* waiting mark,
6655        // not the ready one. The failing order is "delayed duplicate enqueued
6656        // first, ready job second": the old unconditional `pop_back` removed the
6657        // most-recent (ready) mark, reporting ready depth 0 while ready work was
6658        // still waiting.
6659        let registry = JobRegistry::new();
6660        registry.register_on_queue("nightly_report", "reports");
6661        registry.register_on_queue("send_email", "reports");
6662
6663        // The delayed duplicate records a future (scheduled) mark FIRST...
6664        let far_future = now_epoch_ms() + 60_000;
6665        registry.record_enqueue_scheduled("nightly_report", far_future);
6666        // ...then a ready job enqueues on the same queue (its mark is the most
6667        // recent). Recorded a few ms in the past so it deterministically ages.
6668        let already_ready = now_epoch_ms().saturating_sub(5);
6669        registry.record_enqueue_scheduled("send_email", already_ready);
6670
6671        let before = registry.queue_snapshot();
6672        assert_eq!(
6673            before.get("reports").unwrap().depth,
6674            1,
6675            "only the ready job counts toward ready depth"
6676        );
6677        assert!(
6678            before.get("reports").unwrap().oldest_waiting_age_ms > 0,
6679            "the ready job ages the queue before the dedup"
6680        );
6681
6682        // The delayed duplicate coalesces: it recorded a scheduled enqueue mark
6683        // (had_enqueue_mark = true, was_scheduled = true), so the removal must
6684        // consume the future mark and leave the ready job's mark intact. Under
6685        // the old `pop_back` this stole the ready mark (depth 0) — the RED case.
6686        registry.record_deduplicated("nightly_report", true, true);
6687        let after = registry.queue_snapshot();
6688        assert_eq!(
6689            after.get("reports").unwrap().depth,
6690            1,
6691            "deduplicating the scheduled duplicate must not steal the ready job's mark"
6692        );
6693        assert!(
6694            after.get("reports").unwrap().oldest_waiting_age_ms > 0,
6695            "the ready job's mark is preserved, so it still ages the queue"
6696        );
6697
6698        // The surviving ready job drains to zero — exactly one mark remained, so
6699        // the scheduled mark was the one removed (no leak).
6700        registry.record_start("send_email");
6701        assert_eq!(
6702            registry.queue_snapshot().get("reports").unwrap().depth,
6703            0,
6704            "starting the ready job drains the queue; the scheduled mark was removed"
6705        );
6706    }
6707
6708    use axum::body::Body;
6709    use axum::http::Request;
6710    use tower::ServiceExt;
6711
6712    #[derive(Clone)]
6713    struct TestActuatorState {
6714        profile: String,
6715        deploy_version: String,
6716        metrics: crate::middleware::MetricsCollector,
6717        log_levels: LogLevels,
6718        task_registry: TaskRegistry,
6719        job_registry: JobRegistry,
6720        config_props: ConfigProperties,
6721        metrics_source_registry: MetricsSourceRegistry,
6722        health_indicator_registry: HealthIndicatorRegistry,
6723        health_detailed: bool,
6724        log_buffer: Option<crate::log::capture::LogBuffer>,
6725        #[cfg(feature = "http-client")]
6726        webhook_outbound: Option<crate::webhook_outbound::WebhookOutboundManager>,
6727        #[cfg(feature = "db")]
6728        pool: Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6729        #[cfg(feature = "db")]
6730        shards: Option<crate::sharding::ShardSet>,
6731        #[cfg(feature = "db")]
6732        replica_pool:
6733            Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6734        #[cfg(feature = "ws")]
6735        channels: crate::channels::Channels,
6736        #[cfg(feature = "ws")]
6737        shutdown: tokio_util::sync::CancellationToken,
6738        data_capsules: Option<crate::gdpr::portability::CapsuleService>,
6739    }
6740
6741    impl ProvideActuatorState for TestActuatorState {
6742        fn metrics(&self) -> &crate::middleware::MetricsCollector {
6743            &self.metrics
6744        }
6745        fn log_levels(&self) -> &LogLevels {
6746            &self.log_levels
6747        }
6748        fn task_registry(&self) -> &TaskRegistry {
6749            &self.task_registry
6750        }
6751        fn job_registry(&self) -> &JobRegistry {
6752            &self.job_registry
6753        }
6754        fn config_props(&self) -> &ConfigProperties {
6755            &self.config_props
6756        }
6757        fn profile(&self) -> &str {
6758            &self.profile
6759        }
6760        fn uptime_display(&self) -> String {
6761            "test_uptime".to_string()
6762        }
6763        fn deploy_version(&self) -> String {
6764            self.deploy_version.clone()
6765        }
6766        fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
6767            Some(&self.metrics_source_registry)
6768        }
6769        #[cfg(feature = "http-client")]
6770        fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
6771            self.webhook_outbound.clone()
6772        }
6773        #[cfg(feature = "db")]
6774        fn pool(
6775            &self,
6776        ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6777        {
6778            self.pool.as_ref()
6779        }
6780        #[cfg(feature = "db")]
6781        fn shards(&self) -> Option<&crate::sharding::ShardSet> {
6782            self.shards.as_ref()
6783        }
6784        #[cfg(feature = "db")]
6785        fn replica_pool(
6786            &self,
6787        ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6788        {
6789            self.replica_pool.as_ref()
6790        }
6791        #[cfg(feature = "ws")]
6792        fn channels(&self) -> &crate::channels::Channels {
6793            &self.channels
6794        }
6795        #[cfg(feature = "ws")]
6796        fn shutdown_token(&self) -> tokio_util::sync::CancellationToken {
6797            self.shutdown.clone()
6798        }
6799        fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
6800            Some(&self.health_indicator_registry)
6801        }
6802        fn health_detailed(&self) -> bool {
6803            self.health_detailed
6804        }
6805        fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
6806            self.log_buffer.clone()
6807        }
6808        fn data_capsules(
6809            &self,
6810        ) -> Result<
6811            crate::gdpr::portability::CapsuleService,
6812            crate::gdpr::portability::DataCapsuleError,
6813        > {
6814            self.data_capsules.clone().ok_or_else(|| {
6815                crate::gdpr::portability::DataCapsuleError::NotConfigured("test".to_owned())
6816            })
6817        }
6818    }
6819
6820    fn test_state() -> TestActuatorState {
6821        test_state_with_config(&AutumnConfig::default())
6822    }
6823
6824    fn test_state_with_config(config: &AutumnConfig) -> TestActuatorState {
6825        TestActuatorState {
6826            profile: config.profile.clone().unwrap_or_else(|| "dev".into()),
6827            deploy_version: crate::canary::STABLE.to_owned(),
6828            metrics: crate::middleware::MetricsCollector::new(),
6829            log_levels: LogLevels::new("info"),
6830            task_registry: TaskRegistry::new(),
6831            job_registry: JobRegistry::new(),
6832            config_props: ConfigProperties::from_config(config),
6833            metrics_source_registry: MetricsSourceRegistry::new(),
6834            health_indicator_registry: HealthIndicatorRegistry::new(),
6835            health_detailed: config.health.detailed,
6836            log_buffer: None,
6837            #[cfg(feature = "http-client")]
6838            webhook_outbound: None,
6839            #[cfg(feature = "db")]
6840            pool: None,
6841            #[cfg(feature = "db")]
6842            shards: None,
6843            #[cfg(feature = "db")]
6844            replica_pool: None,
6845            #[cfg(feature = "ws")]
6846            channels: crate::channels::Channels::new(32),
6847            #[cfg(feature = "ws")]
6848            shutdown: tokio_util::sync::CancellationToken::new(),
6849            data_capsules: None,
6850        }
6851    }
6852
6853    #[cfg(feature = "http-client")]
6854    fn test_state_with_webhook_outbound(
6855        manager: crate::webhook_outbound::WebhookOutboundManager,
6856    ) -> TestActuatorState {
6857        let mut state = test_state();
6858        state.webhook_outbound = Some(manager);
6859        state
6860    }
6861
6862    #[cfg(feature = "http-client")]
6863    fn replay_test_subscription() -> crate::webhook_outbound::WebhookSubscription {
6864        crate::webhook_outbound::WebhookSubscription {
6865            id: "sub-replay".to_string(),
6866            target_url: "https://example.test/webhook".to_string(),
6867            event_topics: vec!["order.created".to_string()],
6868            secret: "secret".to_string(),
6869            status: crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
6870            consecutive_failures: 50,
6871        }
6872    }
6873
6874    #[cfg(feature = "http-client")]
6875    fn replay_test_dlq_log() -> crate::webhook_outbound::WebhookDeliveryLog {
6876        crate::webhook_outbound::WebhookDeliveryLog {
6877            id: "log-replay".to_string(),
6878            subscription_id: "sub-replay".to_string(),
6879            topic: "order.created".to_string(),
6880            payload: "{\"id\":123}".to_string(),
6881            request_headers: std::collections::HashMap::new(),
6882            response_status: Some(503),
6883            response_body: Some("unavailable".to_string()),
6884            elapsed_ms: 42,
6885            attempt: 5,
6886            max_attempts: 5,
6887            is_dlq: true,
6888            last_error: Some("server returned status: 503".to_string()),
6889            timestamp: chrono::Utc::now(),
6890        }
6891    }
6892
6893    #[cfg(feature = "http-client")]
6894    #[tokio::test]
6895    async fn webhooks_replay_preserves_dlq_log_and_failures_when_enqueue_is_unavailable() {
6896        use crate::webhook_outbound::{
6897            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
6898        };
6899
6900        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
6901        crate::job::clear_global_job_client();
6902
6903        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
6904        handler
6905            .create_subscription(replay_test_subscription())
6906            .await
6907            .expect("subscription setup");
6908        let original_log = replay_test_dlq_log();
6909        handler
6910            .log_delivery(original_log.clone())
6911            .await
6912            .expect("dlq log setup");
6913        let failures_before_replay = handler
6914            .get_subscription("sub-replay")
6915            .await
6916            .expect("subscription lookup")
6917            .expect("subscription should exist")
6918            .consecutive_failures;
6919
6920        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
6921        let response = webhooks_replay_endpoint(
6922            State(state),
6923            Json(ReplayRequest {
6924                log_id: original_log.id.clone(),
6925            }),
6926        )
6927        .await
6928        .into_response();
6929
6930        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
6931
6932        let stored_log = handler
6933            .get_delivery_log(&original_log.id)
6934            .await
6935            .expect("delivery log lookup")
6936            .expect("delivery log should still exist");
6937        assert!(stored_log.is_dlq, "failed enqueue must keep log in DLQ");
6938        assert_eq!(stored_log.attempt, original_log.attempt);
6939        assert_eq!(stored_log.last_error, original_log.last_error);
6940        assert_eq!(stored_log.response_status, original_log.response_status);
6941        assert_eq!(stored_log.response_body, original_log.response_body);
6942
6943        let subscription = handler
6944            .get_subscription("sub-replay")
6945            .await
6946            .expect("subscription lookup")
6947            .expect("subscription should exist");
6948        assert_eq!(
6949            subscription.consecutive_failures, failures_before_replay,
6950            "failed enqueue must not reset subscription failure history"
6951        );
6952        assert_eq!(
6953            subscription.status,
6954            crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
6955            "failed enqueue must not reactivate an auto-failed subscription"
6956        );
6957
6958        crate::job::clear_global_job_client();
6959    }
6960
6961    #[cfg(feature = "http-client")]
6962    #[tokio::test]
6963    async fn webhooks_replay_rejects_disabled_subscription_without_removing_dlq() {
6964        use crate::webhook_outbound::{
6965            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
6966            WebhookSubscriptionStatus,
6967        };
6968
6969        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
6970        crate::job::clear_global_job_client();
6971
6972        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
6973        let mut subscription = replay_test_subscription();
6974        subscription.status = WebhookSubscriptionStatus::Disabled;
6975        subscription.consecutive_failures = 0;
6976        handler
6977            .create_subscription(subscription)
6978            .await
6979            .expect("subscription setup");
6980        let original_log = replay_test_dlq_log();
6981        handler
6982            .log_delivery(original_log.clone())
6983            .await
6984            .expect("dlq log setup");
6985
6986        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
6987        let response = webhooks_replay_endpoint(
6988            State(state),
6989            Json(ReplayRequest {
6990                log_id: original_log.id.clone(),
6991            }),
6992        )
6993        .await
6994        .into_response();
6995
6996        assert_eq!(response.status(), StatusCode::CONFLICT);
6997
6998        let stored_log = handler
6999            .get_delivery_log(&original_log.id)
7000            .await
7001            .expect("delivery log lookup")
7002            .expect("delivery log should still exist");
7003        assert!(stored_log.is_dlq);
7004        assert_eq!(stored_log.attempt, original_log.attempt);
7005        assert_eq!(stored_log.response_status, original_log.response_status);
7006        assert_eq!(stored_log.last_error, original_log.last_error);
7007
7008        let subscription = handler
7009            .get_subscription("sub-replay")
7010            .await
7011            .expect("subscription lookup")
7012            .expect("subscription should exist");
7013        assert_eq!(subscription.status, WebhookSubscriptionStatus::Disabled);
7014
7015        crate::job::clear_global_job_client();
7016    }
7017
7018    #[cfg(feature = "http-client")]
7019    #[tokio::test]
7020    async fn webhooks_replay_rejects_missing_subscription_without_removing_dlq() {
7021        use crate::webhook_outbound::{
7022            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7023        };
7024
7025        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7026        crate::job::clear_global_job_client();
7027
7028        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7029        let original_log = replay_test_dlq_log();
7030        handler
7031            .log_delivery(original_log.clone())
7032            .await
7033            .expect("dlq log setup");
7034
7035        let runtime_state = crate::AppState::for_test().with_profile("test");
7036        let shutdown = tokio_util::sync::CancellationToken::new();
7037        crate::job::start_runtime(
7038            vec![crate::job::JobInfo {
7039                version: 1,
7040                name: "autumn_webhook_delivery".to_string(),
7041                max_attempts: 1,
7042                initial_backoff_ms: 1,
7043                queue: "default".to_string(),
7044                uniqueness: None,
7045                concurrency: None,
7046                handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7047                timeout: None,
7048            }],
7049            &runtime_state,
7050            &shutdown,
7051            &crate::config::JobConfig::default(),
7052            true,
7053        )
7054        .expect("job runtime should start");
7055
7056        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7057        let response = webhooks_replay_endpoint(
7058            State(state),
7059            Json(ReplayRequest {
7060                log_id: original_log.id.clone(),
7061            }),
7062        )
7063        .await
7064        .into_response();
7065
7066        assert_eq!(response.status(), StatusCode::NOT_FOUND);
7067
7068        let stored_log = handler
7069            .get_delivery_log(&original_log.id)
7070            .await
7071            .expect("delivery log lookup")
7072            .expect("delivery log should still exist");
7073        assert!(stored_log.is_dlq);
7074        assert_eq!(stored_log.attempt, original_log.attempt);
7075        assert_eq!(stored_log.response_status, original_log.response_status);
7076        assert_eq!(stored_log.response_body, original_log.response_body);
7077        assert_eq!(stored_log.last_error, original_log.last_error);
7078
7079        assert!(
7080            handler
7081                .get_subscription("sub-replay")
7082                .await
7083                .expect("subscription lookup")
7084                .is_none(),
7085            "test setup should leave the subscription missing"
7086        );
7087
7088        shutdown.cancel();
7089        crate::job::clear_global_job_client();
7090    }
7091
7092    #[cfg(feature = "http-client")]
7093    #[tokio::test]
7094    async fn webhooks_replay_resets_log_and_failures_after_enqueue_succeeds() {
7095        use crate::webhook_outbound::{
7096            InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7097        };
7098
7099        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7100        crate::job::clear_global_job_client();
7101
7102        let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7103        handler
7104            .create_subscription(replay_test_subscription())
7105            .await
7106            .expect("subscription setup");
7107        let original_log = replay_test_dlq_log();
7108        handler
7109            .log_delivery(original_log.clone())
7110            .await
7111            .expect("dlq log setup");
7112
7113        let runtime_state = crate::AppState::for_test().with_profile("test");
7114        let shutdown = tokio_util::sync::CancellationToken::new();
7115        crate::job::start_runtime(
7116            vec![crate::job::JobInfo {
7117                version: 1,
7118                name: "autumn_webhook_delivery".to_string(),
7119                max_attempts: 1,
7120                initial_backoff_ms: 1,
7121                queue: "default".to_string(),
7122                uniqueness: None,
7123                concurrency: None,
7124                handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7125                timeout: None,
7126            }],
7127            &runtime_state,
7128            &shutdown,
7129            &crate::config::JobConfig::default(),
7130            true,
7131        )
7132        .expect("job runtime should start");
7133
7134        let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7135        let response = webhooks_replay_endpoint(
7136            State(state),
7137            Json(ReplayRequest {
7138                log_id: original_log.id.clone(),
7139            }),
7140        )
7141        .await
7142        .into_response();
7143
7144        assert_eq!(response.status(), StatusCode::OK);
7145
7146        let stored_log = handler
7147            .get_delivery_log(&original_log.id)
7148            .await
7149            .expect("delivery log lookup")
7150            .expect("delivery log should still exist");
7151        assert!(!stored_log.is_dlq);
7152        assert_eq!(stored_log.attempt, 1);
7153        assert_eq!(stored_log.last_error, None);
7154        assert_eq!(stored_log.response_status, None);
7155        assert_eq!(stored_log.response_body, None);
7156
7157        let subscription = handler
7158            .get_subscription("sub-replay")
7159            .await
7160            .expect("subscription lookup")
7161            .expect("subscription should exist");
7162        assert_eq!(subscription.consecutive_failures, 0);
7163        assert_eq!(
7164            subscription.status,
7165            crate::webhook_outbound::WebhookSubscriptionStatus::Active
7166        );
7167
7168        shutdown.cancel();
7169        crate::job::clear_global_job_client();
7170    }
7171
7172    /// A pool whose server accepts each connection and closes it at once.
7173    ///
7174    /// Not a closed port: on Windows a connect to a closed loopback port is
7175    /// retried for about 2 s, so the ping times out instead of failing.
7176    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7177    async fn refused_pool()
7178    -> diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection> {
7179        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7180            .await
7181            .expect("bind");
7182        let addr = listener.local_addr().expect("local addr");
7183        tokio::spawn(async move {
7184            while let Ok((socket, _)) = listener.accept().await {
7185                drop(socket);
7186            }
7187        });
7188        let config = crate::config::DatabaseConfig {
7189            url: Some(format!("postgres://secret_user@{addr}/autumn")),
7190            pool_size: 1,
7191            connect_timeout_secs: 1,
7192            ..Default::default()
7193        };
7194        crate::db::create_pool(&config)
7195            .expect("valid pool config")
7196            .expect("pool url is set")
7197    }
7198
7199    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7200    async fn health_json(state: TestActuatorState) -> (StatusCode, serde_json::Value) {
7201        let resp = actuator_router(true)
7202            .with_state(state)
7203            .oneshot(
7204                Request::builder()
7205                    .uri("/actuator/health")
7206                    .body(Body::empty())
7207                    .unwrap(),
7208            )
7209            .await
7210            .unwrap();
7211        let status = resp.status();
7212        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7213            .await
7214            .unwrap();
7215        (status, serde_json::from_slice(&body).unwrap())
7216    }
7217
7218    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7219    #[tokio::test]
7220    async fn health_db_is_down_without_error_text_when_not_detailed() {
7221        let mut state = test_state();
7222        state.health_detailed = false;
7223        state.pool = Some(refused_pool().await);
7224
7225        let (status, json) = health_json(state).await;
7226
7227        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7228        assert_eq!(json["components"]["db"]["status"], "DOWN");
7229        assert_eq!(json["checks"]["database"]["status"], "down");
7230        // The error text can name the host or the user: not for a public body.
7231        assert!(json["checks"]["database"].get("error").is_none(), "{json}");
7232        assert!(!json.to_string().contains("secret_user"), "{json}");
7233    }
7234
7235    #[cfg(all(feature = "db", not(feature = "sqlite")))]
7236    #[tokio::test]
7237    async fn health_db_error_is_shown_when_detailed() {
7238        let mut state = test_state();
7239        state.health_detailed = true;
7240        state.pool = Some(refused_pool().await);
7241
7242        let (status, json) = health_json(state).await;
7243
7244        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7245        let error = json["checks"]["database"]["error"]
7246            .as_str()
7247            .unwrap_or_default();
7248        assert!(error.starts_with("primary connection failed"), "{json}");
7249        assert_eq!(json["components"]["db"]["details"]["error"], error);
7250    }
7251
7252    #[tokio::test]
7253    async fn actuator_health_returns_ok() {
7254        let app = actuator_router(true).with_state(test_state());
7255        let resp = app
7256            .oneshot(
7257                Request::builder()
7258                    .uri("/actuator/health")
7259                    .body(Body::empty())
7260                    .unwrap(),
7261            )
7262            .await
7263            .unwrap();
7264
7265        assert_eq!(resp.status(), StatusCode::OK);
7266        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7267            .await
7268            .unwrap();
7269        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7270        assert_eq!(json["status"], "UP");
7271        assert_eq!(json["profile"], "dev");
7272        assert!(json["uptime"].is_string());
7273    }
7274
7275    #[cfg(feature = "db")]
7276    #[tokio::test]
7277    async fn actuator_health_exposes_after_commit_failure_counter() {
7278        let app = actuator_router(true).with_state(test_state());
7279        let resp = app
7280            .oneshot(
7281                Request::builder()
7282                    .uri("/actuator/health")
7283                    .body(Body::empty())
7284                    .unwrap(),
7285            )
7286            .await
7287            .unwrap();
7288
7289        assert_eq!(resp.status(), StatusCode::OK);
7290        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7291            .await
7292            .unwrap();
7293        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7294        assert_eq!(
7295            json["autumn_after_commit_failures_total"],
7296            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed),
7297            "/actuator/health should expose the documented after_commit counter"
7298        );
7299    }
7300
7301    #[tokio::test]
7302    #[allow(clippy::await_holding_lock)]
7303    async fn actuator_circuitbreakers_returns_breakers() {
7304        let _lock = crate::circuit_breaker::TEST_LOCK
7305            .lock()
7306            .unwrap_or_else(std::sync::PoisonError::into_inner);
7307        crate::circuit_breaker::global_registry().clear();
7308        let breaker = crate::circuit_breaker::global_registry().get_or_create(
7309            "actuator_endpoint_test_breaker",
7310            crate::circuit_breaker::CircuitBreakerPolicy {
7311                failure_ratio_threshold: 0.5,
7312                sample_window: std::time::Duration::from_secs(10),
7313                minimum_sample_count: 2,
7314                open_duration: std::time::Duration::from_secs(60),
7315                half_open_trial_count: 2,
7316                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7317            },
7318        );
7319        assert_eq!(
7320            breaker.state(),
7321            crate::circuit_breaker::CircuitState::Closed
7322        );
7323
7324        let mut detailed_config = AutumnConfig::default();
7325        detailed_config.health.detailed = true;
7326        let state = test_state_with_config(&detailed_config);
7327        let app = actuator_router(true).with_state(state);
7328        let resp = app
7329            .clone()
7330            .oneshot(
7331                Request::builder()
7332                    .uri("/actuator/circuitbreakers")
7333                    .body(Body::empty())
7334                    .unwrap(),
7335            )
7336            .await
7337            .unwrap();
7338
7339        assert_eq!(resp.status(), StatusCode::OK);
7340        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7341            .await
7342            .unwrap();
7343        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7344        let list = json.as_array().expect("Should be a JSON array");
7345        let item = list
7346            .iter()
7347            .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7348            .expect("Should find our breaker");
7349        assert_eq!(item["state"], "CLOSED");
7350        assert_eq!(item["failure_ratio_threshold"], 0.5);
7351        assert_eq!(item["minimum_sample_count"], 2);
7352        assert_eq!(item["slow_call_ratio"], 0.0);
7353        assert_eq!(item["slow_call_duration_threshold_ms"], 60_000);
7354        assert_eq!(item["slow_call_rate_threshold"], 1.0);
7355        assert_eq!(item["cancelled_call_outcome"], "slow");
7356
7357        let mut undetailed_config = AutumnConfig::default();
7358        undetailed_config.health.detailed = false;
7359        let undetailed_state = test_state_with_config(&undetailed_config);
7360        let app_undetailed = actuator_router(true).with_state(undetailed_state);
7361        let resp_undetailed = app_undetailed
7362            .oneshot(
7363                Request::builder()
7364                    .uri("/actuator/circuitbreakers")
7365                    .body(Body::empty())
7366                    .unwrap(),
7367            )
7368            .await
7369            .unwrap();
7370
7371        assert_eq!(resp_undetailed.status(), StatusCode::OK);
7372        let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7373            .await
7374            .unwrap();
7375        let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7376        let list_undetailed = json_undetailed.as_array().expect("Should be a JSON array");
7377        let item_undetailed = list_undetailed
7378            .iter()
7379            .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7380            .expect("Should find our breaker");
7381        assert_eq!(item_undetailed["state"], "CLOSED");
7382        assert!(item_undetailed.get("failure_ratio_threshold").is_none());
7383        assert!(item_undetailed.get("minimum_sample_count").is_none());
7384        assert_eq!(item_undetailed["slow_call_ratio"], 0.0);
7385        assert!(item_undetailed.get("slow_call_rate_threshold").is_none());
7386        assert!(
7387            item_undetailed
7388                .get("slow_call_duration_threshold_ms")
7389                .is_none()
7390        );
7391        assert!(item_undetailed.get("cancelled_call_outcome").is_none());
7392        crate::circuit_breaker::global_registry().clear();
7393    }
7394
7395    #[test]
7396    fn prometheus_renders_breaker_slow_call_families() {
7397        let mut out = String::new();
7398        write_builtin_circuit_breaker_metrics(&mut out, "stable", &[]);
7399        assert!(out.is_empty(), "no breaker: no family");
7400
7401        let policy = crate::circuit_breaker::CircuitBreakerPolicy {
7402            slow_call_duration_threshold: Some(std::time::Duration::ZERO),
7403            minimum_sample_count: 100,
7404            ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7405        };
7406        let slow = crate::circuit_breaker::CircuitBreaker::new("b\"slow", policy);
7407        crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7408        crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7409        let idle = crate::circuit_breaker::CircuitBreaker::new(
7410            "a.idle",
7411            crate::circuit_breaker::CircuitBreakerPolicy::default(),
7412        );
7413
7414        write_builtin_circuit_breaker_metrics(&mut out, "stable", &[slow, idle]);
7415        let want = "\
7416# HELP autumn_circuit_breaker_slow_calls_total Calls each circuit breaker counted as slow, cancelled slow calls included
7417# TYPE autumn_circuit_breaker_slow_calls_total counter
7418autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"a.idle\"} 0
7419autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"b\\\"slow\"} 2
7420# HELP autumn_circuit_breaker_slow_call_ratio Share of slow calls in each circuit breaker sample window
7421# TYPE autumn_circuit_breaker_slow_call_ratio gauge
7422autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"a.idle\"} 0
7423autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"b\\\"slow\"} 1
7424";
7425        assert_eq!(out, want);
7426    }
7427
7428    #[tokio::test]
7429    #[allow(clippy::await_holding_lock)]
7430    async fn actuator_prometheus_shows_breaker_slow_call_families() {
7431        let _lock = crate::circuit_breaker::TEST_LOCK
7432            .lock()
7433            .unwrap_or_else(std::sync::PoisonError::into_inner);
7434        crate::circuit_breaker::global_registry().clear();
7435        let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7436            "prometheus_slow_breaker",
7437            crate::circuit_breaker::CircuitBreakerPolicy::default(),
7438        );
7439
7440        let app = actuator_router(true).with_state(test_state());
7441        let resp = app
7442            .oneshot(
7443                Request::builder()
7444                    .uri("/actuator/prometheus")
7445                    .body(Body::empty())
7446                    .unwrap(),
7447            )
7448            .await
7449            .unwrap();
7450        assert_eq!(resp.status(), StatusCode::OK);
7451        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7452            .await
7453            .unwrap();
7454        let text = String::from_utf8(body.to_vec()).unwrap();
7455        assert!(text.contains(
7456            "autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7457        ));
7458        assert!(text.contains(
7459            "autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7460        ));
7461        crate::circuit_breaker::global_registry().clear();
7462    }
7463
7464    #[tokio::test]
7465    #[allow(clippy::await_holding_lock)]
7466    async fn test_health_hides_circuit_breakers_when_undetailed() {
7467        let _lock = crate::circuit_breaker::TEST_LOCK
7468            .lock()
7469            .unwrap_or_else(std::sync::PoisonError::into_inner);
7470        crate::circuit_breaker::global_registry().clear();
7471
7472        let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7473            "test_health_hide_breaker",
7474            crate::circuit_breaker::CircuitBreakerPolicy {
7475                failure_ratio_threshold: 0.5,
7476                sample_window: std::time::Duration::from_secs(10),
7477                minimum_sample_count: 2,
7478                open_duration: std::time::Duration::from_secs(60),
7479                half_open_trial_count: 2,
7480                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7481            },
7482        );
7483
7484        let mut detailed_config = AutumnConfig::default();
7485        detailed_config.health.detailed = true;
7486        let state = test_state_with_config(&detailed_config);
7487        let app = actuator_router(true).with_state(state);
7488        let resp = app
7489            .oneshot(
7490                Request::builder()
7491                    .uri("/actuator/health")
7492                    .body(Body::empty())
7493                    .unwrap(),
7494            )
7495            .await
7496            .unwrap();
7497
7498        assert_eq!(resp.status(), StatusCode::OK);
7499        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7500            .await
7501            .unwrap();
7502        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7503        assert!(json["components"]["circuit_breaker.test_health_hide_breaker"].is_object());
7504
7505        let mut undetailed_config = AutumnConfig::default();
7506        undetailed_config.health.detailed = false;
7507        let undetailed_state = test_state_with_config(&undetailed_config);
7508        let app_undetailed = actuator_router(true).with_state(undetailed_state);
7509        let resp_undetailed = app_undetailed
7510            .oneshot(
7511                Request::builder()
7512                    .uri("/actuator/health")
7513                    .body(Body::empty())
7514                    .unwrap(),
7515            )
7516            .await
7517            .unwrap();
7518
7519        assert_eq!(resp_undetailed.status(), StatusCode::OK);
7520        let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7521            .await
7522            .unwrap();
7523        let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7524
7525        if let Some(components) = json_undetailed.get("components") {
7526            assert!(
7527                components
7528                    .get("circuit_breaker.test_health_hide_breaker")
7529                    .is_none()
7530            );
7531        }
7532
7533        crate::circuit_breaker::global_registry().clear();
7534    }
7535
7536    #[tokio::test]
7537    async fn actuator_routes_respect_custom_prefix() {
7538        let app = actuator_router_with_prefix("/ops", true, true).with_state(test_state());
7539
7540        let prefixed = app
7541            .clone()
7542            .oneshot(
7543                Request::builder()
7544                    .uri("/ops/health")
7545                    .body(Body::empty())
7546                    .unwrap(),
7547            )
7548            .await
7549            .unwrap();
7550        assert_eq!(prefixed.status(), StatusCode::OK);
7551
7552        let legacy = app
7553            .oneshot(
7554                Request::builder()
7555                    .uri("/actuator/health")
7556                    .body(Body::empty())
7557                    .unwrap(),
7558            )
7559            .await
7560            .unwrap();
7561        assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
7562    }
7563
7564    #[test]
7565    fn actuator_route_helpers_normalize_prefixes() {
7566        assert_eq!(actuator_route_glob("ops/"), "/ops/*");
7567        assert_eq!(actuator_route_path("ops/", "/health"), "/ops/health");
7568        assert_eq!(actuator_route_glob("/"), "/*");
7569    }
7570
7571    #[tokio::test]
7572    async fn actuator_info_returns_metadata() {
7573        let app = actuator_router(true).with_state(test_state());
7574        let resp = app
7575            .oneshot(
7576                Request::builder()
7577                    .uri("/actuator/info")
7578                    .body(Body::empty())
7579                    .unwrap(),
7580            )
7581            .await
7582            .unwrap();
7583
7584        assert_eq!(resp.status(), StatusCode::OK);
7585        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7586            .await
7587            .unwrap();
7588        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7589        assert!(json["autumn"]["version"].is_string());
7590        assert_eq!(json["autumn"]["profile"], "dev");
7591    }
7592
7593    #[tokio::test]
7594    async fn actuator_info_reports_build_and_git_provenance() {
7595        // This is the only test in the lib test binary that touches the
7596        // process-global build context (`__set_build_context` is first-wins),
7597        // so the injected values below are guaranteed to be the ones rendered.
7598        fn leak(value: String) -> &'static str {
7599            Box::leak(value.into_boxed_str())
7600        }
7601
7602        // Use the repo's real HEAD so this exercises AC #2's contract: the
7603        // reported commit equals `git rev-parse HEAD` of the source tree.
7604        // During cargo mutants runs, the .git directory is stripped, so fallback
7605        // to a default value if git rev-parse fails.
7606        let head = std::process::Command::new("git")
7607            .args(["rev-parse", "HEAD"])
7608            .output()
7609            .ok()
7610            .filter(|out| out.status.success())
7611            .and_then(|out| String::from_utf8(out.stdout).ok())
7612            .map_or_else(
7613                || "179f1aa78077816d50af3bbed337a9328bc0609b".to_string(),
7614                |out| out.trim().to_owned(),
7615            );
7616        let short: String = head.chars().take(7).collect();
7617
7618        crate::build_info::__set_build_context(
7619            "provenance_probe_app",
7620            "9.9.9",
7621            Some(leak(head.clone())),
7622            Some(leak(short.clone())),
7623            Some("provenance-branch"),
7624            Some("false"),
7625            Some("2026-07-09T00:00:00Z"),
7626        );
7627
7628        let app = actuator_router(true).with_state(test_state());
7629        let resp = app
7630            .oneshot(
7631                Request::builder()
7632                    .uri("/actuator/info")
7633                    .body(Body::empty())
7634                    .unwrap(),
7635            )
7636            .await
7637            .unwrap();
7638        assert_eq!(resp.status(), StatusCode::OK);
7639        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7640            .await
7641            .unwrap();
7642        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7643
7644        // AC #3: app.name/version reflect the consuming app's compile-time
7645        // values, not "unknown".
7646        assert_eq!(json["app"]["name"], "provenance_probe_app");
7647        assert_eq!(json["app"]["version"], "9.9.9");
7648        assert_ne!(json["app"]["version"], "unknown");
7649
7650        // AC #1/#2: build object carries full + short SHA, branch, dirty bool,
7651        // and an ISO-8601 UTC build timestamp; commit equals real HEAD.
7652        assert_eq!(json["build"]["git"]["commit"], head);
7653        assert_eq!(json["build"]["git"]["commit_short"], short);
7654        assert_eq!(json["build"]["git"]["branch"], "provenance-branch");
7655        assert_eq!(json["build"]["git"]["dirty"], false);
7656        assert_eq!(json["build"]["timestamp"], "2026-07-09T00:00:00Z");
7657        assert_eq!(json["build"]["version"], "9.9.9");
7658    }
7659
7660    #[tokio::test]
7661    async fn actuator_env_available_in_sensitive_mode() {
7662        let config = AutumnConfig {
7663            profile: Some("prod".into()),
7664            server: crate::config::ServerConfig {
7665                port: 4100,
7666                ..crate::config::ServerConfig::default()
7667            },
7668            telemetry: crate::config::TelemetryConfig {
7669                enabled: true,
7670                service_name: "cloud-app".into(),
7671                ..crate::config::TelemetryConfig::default()
7672            },
7673            health: crate::config::HealthConfig {
7674                path: "/healthz".into(),
7675                ..crate::config::HealthConfig::default()
7676            },
7677            ..AutumnConfig::default()
7678        };
7679
7680        let app = actuator_router(true).with_state(test_state_with_config(&config));
7681        let resp = app
7682            .oneshot(
7683                Request::builder()
7684                    .uri("/actuator/env")
7685                    .body(Body::empty())
7686                    .unwrap(),
7687            )
7688            .await
7689            .unwrap();
7690        assert_eq!(resp.status(), StatusCode::OK);
7691        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7692            .await
7693            .unwrap();
7694        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7695        assert_eq!(json["active_profile"], "prod");
7696        assert_eq!(json["properties"]["server.port"], "4100");
7697        assert_eq!(json["properties"]["telemetry.enabled"], "true");
7698        assert_eq!(json["properties"]["telemetry.service_name"], "cloud-app");
7699        assert_eq!(json["properties"]["health.path"], "/healthz");
7700    }
7701
7702    #[tokio::test]
7703    async fn actuator_env_hidden_in_nonsensitive_mode() {
7704        let app = actuator_router(false).with_state(test_state());
7705        let resp = app
7706            .oneshot(
7707                Request::builder()
7708                    .uri("/actuator/env")
7709                    .body(Body::empty())
7710                    .unwrap(),
7711            )
7712            .await
7713            .unwrap();
7714        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7715    }
7716
7717    #[tokio::test]
7718    async fn actuator_graph_hidden_in_nonsensitive_mode() {
7719        // The graph names every route, its auth requirement and the table it
7720        // touches — a map of where to look first. It is sensitive-gated like
7721        // `/env`, not public like `/health`.
7722        let app = actuator_router(false).with_state(test_state());
7723        let resp = app
7724            .oneshot(
7725                Request::builder()
7726                    .uri("/actuator/graph")
7727                    .body(Body::empty())
7728                    .unwrap(),
7729            )
7730            .await
7731            .unwrap();
7732        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7733    }
7734
7735    #[test]
7736    fn actuator_graph_path_is_listed_only_in_sensitive_mode() {
7737        assert!(
7738            actuator_endpoint_paths("/actuator", true, true)
7739                .contains(&"/actuator/graph".to_owned())
7740        );
7741        assert!(
7742            !actuator_endpoint_paths("/actuator", false, true)
7743                .contains(&"/actuator/graph".to_owned()),
7744            "the listing must match the mounts, or the startup barrier seeds a path \
7745             that is not served"
7746        );
7747    }
7748
7749    #[cfg(feature = "db")]
7750    #[test]
7751    fn actuator_derivations_path_is_listed_only_in_sensitive_mode() {
7752        // The listing seeds the startup barrier's allow-list, so a mount without
7753        // a listed path is a route the barrier holds shut. The document names
7754        // parent and child tables, so it is sensitive-gated like `/graph`.
7755        assert!(
7756            actuator_endpoint_paths("/actuator", true, true)
7757                .contains(&"/actuator/derivations".to_owned())
7758        );
7759        assert!(
7760            !actuator_endpoint_paths("/actuator", false, true)
7761                .contains(&"/actuator/derivations".to_owned()),
7762            "the listing must match the mounts, or the startup barrier seeds a path \
7763             that is not served"
7764        );
7765    }
7766
7767    #[cfg(feature = "db")]
7768    #[tokio::test]
7769    async fn actuator_derivations_hidden_in_nonsensitive_mode() {
7770        let app = actuator_router(false).with_state(test_state());
7771        let resp = app
7772            .oneshot(
7773                Request::builder()
7774                    .uri("/actuator/derivations")
7775                    .body(Body::empty())
7776                    .unwrap(),
7777            )
7778            .await
7779            .unwrap();
7780        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7781    }
7782
7783    #[cfg(feature = "db")]
7784    #[test]
7785    fn actuator_derivations_200_body_is_an_array_of_status_objects() {
7786        // The endpoint answers `Json(Vec<DerivationStatus>)`, so this pins the
7787        // body an operator and a dashboard parse. The no-pool and hidden cases
7788        // are covered below; this is the success shape, which needs no database.
7789        let statuses = vec![
7790            crate::derivation::DerivationStatus {
7791                name: "posts.published_comment_count".to_owned(),
7792                definition_hash: Some("a".repeat(64)),
7793                stored_hash: Some("a".repeat(64)),
7794                backfill_state: Some(crate::derivation::BackfillState::Complete),
7795                checkpoint: Some(42),
7796                backfilled_rows: 7,
7797                updated_at: Some("2026-09-07 00:00:00+00".to_owned()),
7798                drift: Some(0),
7799                drift_error: None,
7800            },
7801            crate::derivation::DerivationStatus {
7802                name: "posts.removed".to_owned(),
7803                definition_hash: None,
7804                stored_hash: Some("b".repeat(64)),
7805                backfill_state: Some(crate::derivation::BackfillState::Unregistered),
7806                checkpoint: None,
7807                backfilled_rows: 0,
7808                updated_at: None,
7809                drift: None,
7810                drift_error: Some("column does not exist".to_owned()),
7811            },
7812        ];
7813        let body = serde_json::Value::Array(derivation_report("control", statuses));
7814        let rows = body.as_array().expect("the body is a JSON array");
7815        assert_eq!(rows.len(), 2);
7816        for row in rows {
7817            assert_eq!(row["target"], "control", "every row names its database");
7818        }
7819        for row in rows {
7820            let mut keys: Vec<&str> = row
7821                .as_object()
7822                .expect("each row is an object")
7823                .keys()
7824                .map(String::as_str)
7825                .collect();
7826            keys.sort_unstable();
7827            assert_eq!(
7828                keys,
7829                vec![
7830                    "backfill_state",
7831                    "backfilled_rows",
7832                    "checkpoint",
7833                    "definition_hash",
7834                    "drift",
7835                    "drift_error",
7836                    "name",
7837                    "stored_hash",
7838                    "target",
7839                    "updated_at",
7840                ],
7841                "{row}"
7842            );
7843        }
7844        assert_eq!(rows[0]["backfill_state"], serde_json::json!("complete"));
7845        assert_eq!(rows[0]["drift"], serde_json::json!(0));
7846        // A state row this binary declares no derivation for: reported, with no
7847        // definition hash and no drift figure.
7848        assert_eq!(rows[1]["backfill_state"], serde_json::json!("unregistered"));
7849        assert!(rows[1]["definition_hash"].is_null());
7850        assert!(rows[1]["drift"].is_null());
7851    }
7852
7853    #[cfg(feature = "db")]
7854    #[tokio::test]
7855    async fn actuator_derivations_reports_no_pool_as_unavailable() {
7856        // 503, not 404: an operator has to be able to tell "this build has no
7857        // such endpoint" from "this process has no database to report against".
7858        let app = actuator_router(true).with_state(test_state());
7859        let resp = app
7860            .oneshot(
7861                Request::builder()
7862                    .uri("/actuator/derivations")
7863                    .body(Body::empty())
7864                    .unwrap(),
7865            )
7866            .await
7867            .unwrap();
7868        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7869    }
7870
7871    /// GET `/actuator/graph` on a real sensitive router over `state`.
7872    async fn get_graph(state: crate::AppState) -> axum::response::Response {
7873        actuator_router(true)
7874            .with_state(state)
7875            .oneshot(
7876                Request::builder()
7877                    .uri("/actuator/graph")
7878                    .body(Body::empty())
7879                    .unwrap(),
7880            )
7881            .await
7882            .unwrap()
7883    }
7884
7885    #[tokio::test]
7886    async fn actuator_graph_serves_the_installed_graph_over_http() {
7887        // End to end: install a graph, mount the real sensitive router, GET it.
7888        // The install-site guard is
7889        // `app::tests::graph_installed_before_every_router_build`.
7890        let state = crate::AppState::test_default();
7891        crate::graph::install(
7892            &state,
7893            crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]),
7894        );
7895
7896        let resp = get_graph(state).await;
7897        assert_eq!(resp.status(), StatusCode::OK);
7898        assert_eq!(
7899            resp.headers()
7900                .get(axum::http::header::CONTENT_TYPE)
7901                .and_then(|v| v.to_str().ok()),
7902            Some("application/json")
7903        );
7904        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7905            .await
7906            .expect("body");
7907        let decoded: crate::graph::manifest::ArchitectureGraph =
7908            serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
7909        assert_eq!(
7910            decoded.schema_version,
7911            crate::graph::manifest::MANIFEST_SCHEMA_VERSION
7912        );
7913    }
7914
7915    /// Item 17 of #2520: the second app in a process serves its own graph,
7916    /// not the first app's.
7917    #[tokio::test]
7918    async fn two_apps_in_one_process_serve_their_own_graphs() {
7919        let graph_with = |jobs: usize| {
7920            let mut graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
7921            graph.completeness.jobs = jobs;
7922            graph
7923        };
7924        let first = crate::AppState::test_default();
7925        let second = crate::AppState::test_default();
7926        crate::graph::install(&first, graph_with(1));
7927        crate::graph::install(&second, graph_with(2));
7928
7929        for (state, jobs) in [(first, 1), (second, 2)] {
7930            let resp = get_graph(state).await;
7931            assert_eq!(resp.status(), StatusCode::OK);
7932            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7933                .await
7934                .expect("body");
7935            let decoded: crate::graph::manifest::ArchitectureGraph =
7936                serde_json::from_slice(&body).expect("graph document");
7937            assert_eq!(decoded.completeness.jobs, jobs);
7938        }
7939    }
7940
7941    /// A state with no installed graph answers 503. This includes a custom
7942    /// `ProvideActuatorState` that keeps the default method.
7943    #[tokio::test]
7944    async fn actuator_graph_is_unavailable_for_a_state_with_no_graph() {
7945        let resp = get_graph(crate::AppState::test_default()).await;
7946        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7947
7948        let resp = actuator_router(true)
7949            .with_state(test_state())
7950            .oneshot(
7951                Request::builder()
7952                    .uri("/actuator/graph")
7953                    .body(Body::empty())
7954                    .unwrap(),
7955            )
7956            .await
7957            .unwrap();
7958        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7959    }
7960
7961    #[tokio::test]
7962    async fn actuator_graph_serves_the_installed_graph() {
7963        let graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
7964        let json = serde_json::to_vec(&graph).expect("serialize");
7965        let resp = graph_response(Some(bytes::Bytes::from(json)));
7966        assert_eq!(resp.status(), StatusCode::OK);
7967        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7968            .await
7969            .expect("body");
7970        let decoded: crate::graph::manifest::ArchitectureGraph =
7971            serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
7972        assert_eq!(decoded, graph);
7973    }
7974
7975    #[tokio::test]
7976    async fn actuator_graph_explains_itself_when_none_is_installed() {
7977        let resp = graph_response(None);
7978        assert_eq!(
7979            resp.status(),
7980            StatusCode::SERVICE_UNAVAILABLE,
7981            "an app with no graph must say so rather than 404, which would read as \
7982             'this build has no such endpoint'"
7983        );
7984    }
7985
7986    #[tokio::test]
7987    async fn actuator_circuitbreakers_hidden_in_nonsensitive_mode() {
7988        let app = actuator_router(false).with_state(test_state());
7989        let resp = app
7990            .oneshot(
7991                Request::builder()
7992                    .uri("/actuator/circuitbreakers")
7993                    .body(Body::empty())
7994                    .unwrap(),
7995            )
7996            .await
7997            .unwrap();
7998        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7999    }
8000
8001    #[test]
8002    fn redaction_patterns() {
8003        assert!(should_redact("database.url"));
8004        assert!(should_redact("api_token"));
8005        assert!(should_redact("secret_key"));
8006        assert!(!should_redact("server.port"));
8007        assert!(!should_redact("log.level"));
8008    }
8009
8010    // ── Metrics endpoint tests ─────────────────────────────────
8011
8012    #[tokio::test]
8013    async fn actuator_metrics_returns_http_stats() {
8014        let state = test_state();
8015        state.metrics().record("GET", "/test", 200, 10);
8016        state.metrics().record("POST", "/test", 500, 50);
8017
8018        let app = actuator_router(true).with_state(state);
8019        let resp = app
8020            .oneshot(
8021                Request::builder()
8022                    .uri("/actuator/metrics")
8023                    .body(Body::empty())
8024                    .unwrap(),
8025            )
8026            .await
8027            .unwrap();
8028
8029        assert_eq!(resp.status(), StatusCode::OK);
8030        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8031            .await
8032            .unwrap();
8033        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8034        assert_eq!(json["http"]["requests_total"], 2);
8035        assert_eq!(json["http"]["by_status"]["2xx"], 1);
8036        assert_eq!(json["http"]["by_status"]["5xx"], 1);
8037    }
8038
8039    #[tokio::test]
8040    async fn actuator_metrics_available_in_nonsensitive_mode() {
8041        let app = actuator_router(false).with_state(test_state());
8042        let resp = app
8043            .oneshot(
8044                Request::builder()
8045                    .uri("/actuator/metrics")
8046                    .body(Body::empty())
8047                    .unwrap(),
8048            )
8049            .await
8050            .unwrap();
8051        assert_eq!(resp.status(), StatusCode::OK);
8052    }
8053
8054    // Postgres-only: the fixture gives one shard a DISTINCT replica_url, and
8055    // both layers refuse that on SQLite — `database_backend_consistency` at
8056    // config time, `reject_unusable_sqlite_replica` at topology build. The
8057    // replica is the blocker: native sharding is Postgres-only too, but it is
8058    // refused at BOOT rather than by the topology builder, so the shards alone
8059    // would not fail here. Same treatment `db::`'s replica tests got, rather
8060    // than a fixture swap that would assert a configuration production refuses.
8061    #[cfg(not(feature = "sqlite"))]
8062    #[tokio::test]
8063    #[cfg(feature = "db")]
8064    async fn actuator_metrics_returns_per_shard_stats_when_sharded() {
8065        let mut state = test_state();
8066        let config = crate::config::DatabaseConfig {
8067            shards: vec![
8068                crate::config::ShardConfig {
8069                    name: "alpha".to_owned(),
8070                    primary_url: "postgres://localhost/alpha".to_owned(),
8071                    slots: None,
8072                    replica_url: None,
8073                    primary_pool_size: Some(4),
8074                    replica_pool_size: None,
8075                    replica_fallback: None,
8076                },
8077                crate::config::ShardConfig {
8078                    name: "beta".to_owned(),
8079                    primary_url: "postgres://localhost/beta".to_owned(),
8080                    slots: None,
8081                    replica_url: Some("postgres://localhost/beta_ro".to_owned()),
8082                    primary_pool_size: None,
8083                    replica_pool_size: Some(2),
8084                    replica_fallback: None,
8085                },
8086            ],
8087            ..Default::default()
8088        };
8089        state.shards = crate::sharding::create_shard_set(
8090            &config,
8091            std::sync::Arc::new(crate::sharding::HashShardRouter),
8092        )
8093        .expect("lazy pools build");
8094
8095        let app = actuator_router(true).with_state(state);
8096        let resp = app
8097            .oneshot(
8098                Request::builder()
8099                    .uri("/actuator/metrics")
8100                    .body(Body::empty())
8101                    .unwrap(),
8102            )
8103            .await
8104            .unwrap();
8105
8106        assert_eq!(resp.status(), StatusCode::OK);
8107        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8108            .await
8109            .unwrap();
8110        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8111
8112        let shards = json
8113            .get("database_shards")
8114            .expect("sharded state exposes database_shards");
8115        assert_eq!(shards["alpha"]["pool_size"], 4);
8116        assert_eq!(shards["alpha"]["slots"], 8192);
8117        assert_eq!(shards["beta"]["replica"]["pool_size"], 2);
8118    }
8119
8120    #[tokio::test]
8121    #[cfg(feature = "db")]
8122    async fn actuator_metrics_returns_db_stats_when_pool_present() {
8123        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8124        use diesel_async::pooled_connection::deadpool::Pool;
8125
8126        let mut state = test_state();
8127
8128        // `RuntimeConnection` is `AsyncPgConnection` in the default build and a
8129        // SQLite connection under `--features sqlite`, and this test RUNS on
8130        // both. It builds the manager directly rather than through
8131        // `create_pool`, so nothing would refuse a target meant for the other
8132        // backend — hence the target comes from `test_urls`, not a literal.
8133        // Only pool metrics are exercised; deadpool is lazy, so no connection
8134        // is opened.
8135        let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8136            crate::test_urls::primary("actuator_metrics"),
8137        );
8138        let pool = Pool::builder(manager).build().unwrap();
8139
8140        state.pool = Some(pool);
8141
8142        let app = actuator_router(true).with_state(state);
8143        let resp = app
8144            .oneshot(
8145                Request::builder()
8146                    .uri("/actuator/metrics")
8147                    .body(Body::empty())
8148                    .unwrap(),
8149            )
8150            .await
8151            .unwrap();
8152
8153        assert_eq!(resp.status(), StatusCode::OK);
8154        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8155            .await
8156            .unwrap();
8157        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8158
8159        assert!(json.get("database").is_some());
8160    }
8161
8162    async fn scrape_prometheus(state: TestActuatorState) -> String {
8163        let app = actuator_router(true).with_state(state);
8164        let resp = app
8165            .oneshot(
8166                Request::builder()
8167                    .uri("/actuator/prometheus")
8168                    .body(Body::empty())
8169                    .unwrap(),
8170            )
8171            .await
8172            .unwrap();
8173        assert_eq!(resp.status(), StatusCode::OK);
8174        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8175            .await
8176            .unwrap();
8177        String::from_utf8(body.to_vec()).unwrap()
8178    }
8179
8180    /// The value of the one sample line that starts with `prefix`.
8181    fn sample_value(text: &str, prefix: &str) -> f64 {
8182        let line = text
8183            .lines()
8184            .find(|l| l.starts_with(prefix))
8185            .unwrap_or_else(|| panic!("no sample `{prefix}` in:\n{text}"));
8186        line.rsplit(' ').next().unwrap().parse().unwrap()
8187    }
8188
8189    #[tokio::test]
8190    async fn prometheus_exports_job_queue_gauges_and_dead_letters() {
8191        let state = test_state();
8192        let jobs = state.job_registry.clone();
8193        jobs.register_on_queue("send_mail", "mail");
8194        jobs.register_on_queue("resize", "media");
8195        let now = jobs.now_ms();
8196        jobs.record_enqueue_scheduled("send_mail", now - 5_000);
8197        jobs.record_enqueue_scheduled("send_mail", now - 1_000);
8198        jobs.record_enqueue("resize");
8199        jobs.record_start("resize");
8200        jobs.record_failure("resize", "boom".into(), true);
8201
8202        let text = scrape_prometheus(state).await;
8203
8204        assert!(
8205            text.contains("# TYPE autumn_jobs_queue_depth gauge\n"),
8206            "{text}"
8207        );
8208        assert!(
8209            text.contains("# TYPE autumn_jobs_oldest_age_seconds gauge\n"),
8210            "{text}"
8211        );
8212        assert!(
8213            text.contains("# TYPE autumn_jobs_dead_letter_total counter\n"),
8214            "{text}"
8215        );
8216        let depth = "autumn_jobs_queue_depth{version=\"stable\",queue=\"mail\"} ";
8217        assert!((sample_value(&text, depth) - 2.0).abs() < f64::EPSILON);
8218        let empty = "autumn_jobs_queue_depth{version=\"stable\",queue=\"media\"} ";
8219        assert!(sample_value(&text, empty).abs() < f64::EPSILON);
8220        let age = "autumn_jobs_oldest_age_seconds{version=\"stable\",queue=\"mail\"} ";
8221        assert!(sample_value(&text, age) >= 4.9, "oldest job waited 5s");
8222        let dead = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"resize\"} ";
8223        assert!((sample_value(&text, dead) - 1.0).abs() < f64::EPSILON);
8224        let none = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"send_mail\"} ";
8225        assert!(sample_value(&text, none).abs() < f64::EPSILON);
8226    }
8227
8228    #[tokio::test]
8229    async fn prometheus_exports_db_pool_wait_histogram() {
8230        let state = test_state();
8231        state
8232            .metrics
8233            .record_db_pool_wait(std::time::Duration::from_micros(300));
8234        state
8235            .metrics
8236            .record_db_pool_wait(std::time::Duration::from_millis(120));
8237
8238        let text = scrape_prometheus(state).await;
8239
8240        assert!(
8241            text.contains("# TYPE autumn_db_pool_wait_seconds histogram\n"),
8242            "{text}"
8243        );
8244        let first = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"0.001\"} ";
8245        assert!((sample_value(&text, first) - 1.0).abs() < f64::EPSILON);
8246        let inf = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"+Inf\"} ";
8247        assert!((sample_value(&text, inf) - 2.0).abs() < f64::EPSILON);
8248        let count = "autumn_db_pool_wait_seconds_count{version=\"stable\"} ";
8249        assert!((sample_value(&text, count) - 2.0).abs() < f64::EPSILON);
8250        let sum = "autumn_db_pool_wait_seconds_sum{version=\"stable\"} ";
8251        assert!((sample_value(&text, sum) - 0.1203).abs() < 1e-9);
8252    }
8253
8254    #[tokio::test]
8255    #[cfg(feature = "db")]
8256    async fn prometheus_exports_db_pool_gauges() {
8257        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8258        use diesel_async::pooled_connection::deadpool::Pool;
8259
8260        let mut state = test_state();
8261        // deadpool is lazy, so no connection is opened.
8262        let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8263            crate::test_urls::primary("actuator_prometheus_pool"),
8264        );
8265        state.pool = Some(Pool::builder(manager).max_size(7).build().unwrap());
8266        let replica = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8267            crate::test_urls::primary("actuator_prometheus_replica"),
8268        );
8269        state.replica_pool = Some(Pool::builder(replica).max_size(3).build().unwrap());
8270
8271        let text = scrape_prometheus(state).await;
8272        let replica_max = "autumn_db_pool_max_size{version=\"stable\",pool=\"replica\"} ";
8273        assert!((sample_value(&text, replica_max) - 3.0).abs() < f64::EPSILON);
8274
8275        for (name, value) in [
8276            ("autumn_db_pool_max_size", 7.0),
8277            ("autumn_db_pool_size", 0.0),
8278            ("autumn_db_pool_available", 0.0),
8279            ("autumn_db_pool_waiting", 0.0),
8280        ] {
8281            assert!(text.contains(&format!("# TYPE {name} gauge\n")), "{text}");
8282            let prefix = format!("{name}{{version=\"stable\",pool=\"primary\"}} ");
8283            assert!((sample_value(&text, &prefix) - value).abs() < f64::EPSILON);
8284        }
8285    }
8286
8287    #[test]
8288    fn builtin_family_names_cover_queue_and_pool_metrics() {
8289        for name in [
8290            "autumn_jobs_queue_depth",
8291            "autumn_jobs_oldest_age_seconds",
8292            "autumn_jobs_dead_letter_total",
8293            "autumn_db_pool_max_size",
8294            "autumn_db_pool_size",
8295            "autumn_db_pool_available",
8296            "autumn_db_pool_waiting",
8297            "autumn_db_pool_wait_seconds",
8298            "autumn_db_pool_wait_seconds_bucket",
8299            "autumn_db_pool_wait_seconds_sum",
8300            "autumn_db_pool_wait_seconds_count",
8301        ] {
8302            assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&name), "{name}");
8303        }
8304    }
8305
8306    // ── Config properties endpoint tests ───────────────────────
8307
8308    #[tokio::test]
8309    async fn actuator_configprops_returns_properties() {
8310        let app = actuator_router(true).with_state(test_state());
8311        let resp = app
8312            .oneshot(
8313                Request::builder()
8314                    .uri("/actuator/configprops")
8315                    .body(Body::empty())
8316                    .unwrap(),
8317            )
8318            .await
8319            .unwrap();
8320
8321        assert_eq!(resp.status(), StatusCode::OK);
8322        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8323            .await
8324            .unwrap();
8325        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8326        assert_eq!(json["active_profile"], "dev");
8327        assert!(json["properties"].is_object());
8328    }
8329
8330    #[tokio::test]
8331    async fn actuator_configprops_hidden_in_nonsensitive_mode() {
8332        let app = actuator_router(false).with_state(test_state());
8333        let resp = app
8334            .oneshot(
8335                Request::builder()
8336                    .uri("/actuator/configprops")
8337                    .body(Body::empty())
8338                    .unwrap(),
8339            )
8340            .await
8341            .unwrap();
8342        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8343    }
8344
8345    #[test]
8346    fn configprops_redacts_sensitive_values() {
8347        let mut props = HashMap::new();
8348        ConfigProperties::track_property(
8349            &mut props,
8350            "database.url",
8351            "postgres://user:pass@host/db",
8352            "",
8353            "dev",
8354        );
8355        assert_eq!(props["database.url"].value, "****");
8356    }
8357
8358    #[test]
8359    fn configprops_tracks_default_source() {
8360        let mut props = HashMap::new();
8361        ConfigProperties::track_property(&mut props, "server.port", "3000", "3000", "dev");
8362        assert_eq!(props["server.port"].source, "default");
8363        assert_eq!(props["server.port"].value, "3000");
8364    }
8365
8366    #[test]
8367    fn configprops_tracks_profile_source() {
8368        let mut props = HashMap::new();
8369        ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
8370        assert_eq!(props["log.level"].source, "profile_default:dev");
8371    }
8372
8373    // ── Loggers endpoint tests ─────────────────────────────────
8374
8375    #[tokio::test]
8376    async fn actuator_loggers_get_returns_levels() {
8377        let app = actuator_router(true).with_state(test_state());
8378        let resp = app
8379            .oneshot(
8380                Request::builder()
8381                    .uri("/actuator/loggers")
8382                    .body(Body::empty())
8383                    .unwrap(),
8384            )
8385            .await
8386            .unwrap();
8387
8388        assert_eq!(resp.status(), StatusCode::OK);
8389        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8390            .await
8391            .unwrap();
8392        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8393        assert_eq!(json["current_level"], "info");
8394        assert!(json["available_levels"].is_array());
8395    }
8396
8397    #[tokio::test]
8398    async fn actuator_loggers_put_changes_level() {
8399        let state = test_state();
8400        let app = actuator_router(true).with_state(state.clone());
8401        let resp = app
8402            .oneshot(
8403                Request::builder()
8404                    .method("PUT")
8405                    .uri("/actuator/loggers/autumn_web")
8406                    .header("content-type", "application/json")
8407                    .body(Body::from(r#"{"level": "debug"}"#))
8408                    .unwrap(),
8409            )
8410            .await
8411            .unwrap();
8412
8413        assert_eq!(resp.status(), StatusCode::OK);
8414        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8415            .await
8416            .unwrap();
8417        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8418        // `test_state()` has no reload-capable subscriber wired in, so the
8419        // endpoint honestly reports the change was recorded but not applied
8420        // rather than a false-positive `ok` (issue #1044). A live-subscriber
8421        // integration test (`actuator_loggers_live_reload`) covers the `ok`
8422        // path with a real reloadable subscriber.
8423        assert_eq!(json["status"], "recorded");
8424        assert_eq!(json["applied"], false);
8425
8426        let overrides = state.log_levels().logger_overrides();
8427        assert_eq!(
8428            overrides.get("autumn_web").map(String::as_str),
8429            Some("debug")
8430        );
8431    }
8432
8433    #[tokio::test]
8434    async fn actuator_loggers_put_rejects_invalid_level() {
8435        let app = actuator_router(true).with_state(test_state());
8436        let resp = app
8437            .oneshot(
8438                Request::builder()
8439                    .method("PUT")
8440                    .uri("/actuator/loggers/autumn_web")
8441                    .header("content-type", "application/json")
8442                    .body(Body::from(r#"{"level": "banana"}"#))
8443                    .unwrap(),
8444            )
8445            .await
8446            .unwrap();
8447
8448        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8449        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8450            .await
8451            .unwrap();
8452        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8453        assert_eq!(json["status"], "error");
8454    }
8455
8456    #[tokio::test]
8457    async fn actuator_loggers_put_rejects_invalid_name() {
8458        // A logger name carrying an `EnvFilter` metacharacter (`=`) must be
8459        // rejected up front (400) — never applied, never recorded — so the
8460        // endpoint cannot claim success for a directive the subscriber would
8461        // reject (issue #1044). Other metacharacters (`,`, whitespace) too.
8462        // `has%20space` is percent-encoded whitespace: axum's `Path` extractor
8463        // decodes it back to a space, which the validator must still reject.
8464        let state = test_state();
8465        for bogus in ["a=b", "a,b", "has%20space", "a::b=trace"] {
8466            let app = actuator_router(true).with_state(state.clone());
8467            let resp = app
8468                .oneshot(
8469                    Request::builder()
8470                        .method("PUT")
8471                        .uri(format!("/actuator/loggers/{bogus}"))
8472                        .header("content-type", "application/json")
8473                        .body(Body::from(r#"{"level": "debug"}"#))
8474                        .unwrap(),
8475                )
8476                .await
8477                .unwrap();
8478
8479            assert_eq!(
8480                resp.status(),
8481                StatusCode::BAD_REQUEST,
8482                "logger name {bogus:?} should be rejected"
8483            );
8484            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8485                .await
8486                .unwrap();
8487            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8488            assert_eq!(json["status"], "error");
8489        }
8490
8491        // And GET /loggers must NOT then list any of the bogus overrides.
8492        let app = actuator_router(true).with_state(state.clone());
8493        let resp = app
8494            .oneshot(
8495                Request::builder()
8496                    .uri("/actuator/loggers")
8497                    .body(Body::empty())
8498                    .unwrap(),
8499            )
8500            .await
8501            .unwrap();
8502        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8503            .await
8504            .unwrap();
8505        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8506        let loggers = json["loggers"].as_object().unwrap();
8507        assert!(
8508            loggers.is_empty(),
8509            "no bogus override should be recorded, got {loggers:?}"
8510        );
8511    }
8512
8513    #[tokio::test]
8514    async fn actuator_loggers_put_accepts_dotted_and_hyphenated_names() {
8515        // Real-world `tracing` targets from third-party crates and custom
8516        // targets carry `.` and `-` (e.g. `tower-http`, `my.custom.target`,
8517        // `h2::proto`). These are valid inside a target and are *not*
8518        // `EnvFilter` directive metacharacters, so a PUT to such a name must
8519        // NOT be rejected as invalid (400) — it reaches the normal
8520        // apply/record path (200) and is recorded as an override.
8521        let state = test_state();
8522        for name in ["tower-http", "my.custom.target", "h2::proto"] {
8523            let app = actuator_router(true).with_state(state.clone());
8524            let resp = app
8525                .oneshot(
8526                    Request::builder()
8527                        .method("PUT")
8528                        .uri(format!("/actuator/loggers/{name}"))
8529                        .header("content-type", "application/json")
8530                        .body(Body::from(r#"{"level": "debug"}"#))
8531                        .unwrap(),
8532                )
8533                .await
8534                .unwrap();
8535
8536            assert_eq!(
8537                resp.status(),
8538                StatusCode::OK,
8539                "logger name {name:?} should be accepted, not rejected as invalid"
8540            );
8541            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8542                .await
8543                .unwrap();
8544            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8545            // No live subscriber is attached in `test_state()`, so a valid name
8546            // takes the "recorded" path — the point is it is not the invalid
8547            // "error" path.
8548            assert_ne!(
8549                json["status"], "error",
8550                "valid name {name:?} must not hit the invalid-name error path"
8551            );
8552        }
8553
8554        // GET /loggers must now list the accepted overrides.
8555        let app = actuator_router(true).with_state(state.clone());
8556        let resp = app
8557            .oneshot(
8558                Request::builder()
8559                    .uri("/actuator/loggers")
8560                    .body(Body::empty())
8561                    .unwrap(),
8562            )
8563            .await
8564            .unwrap();
8565        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8566            .await
8567            .unwrap();
8568        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8569        let loggers = json["loggers"].as_object().unwrap();
8570        for name in ["tower-http", "my.custom.target", "h2::proto"] {
8571            assert!(
8572                loggers.contains_key(name),
8573                "accepted override {name:?} should be recorded, got {loggers:?}"
8574            );
8575        }
8576    }
8577
8578    #[tokio::test]
8579    async fn actuator_loggers_put_applied_ok_with_live_subscriber() {
8580        // Positive path: with a reload-capable subscriber installed, a valid
8581        // change reports `{"status":"ok","applied":true}` end-to-end (issue
8582        // #1044 AC7). Uses a no-op reload handle that accepts any valid
8583        // directive, standing in for a live subscriber.
8584        let state = test_state();
8585        state
8586            .log_levels()
8587            .attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8588
8589        let app = actuator_router(true).with_state(state.clone());
8590        let resp = app
8591            .oneshot(
8592                Request::builder()
8593                    .method("PUT")
8594                    .uri("/actuator/loggers/autumn_web")
8595                    .header("content-type", "application/json")
8596                    .body(Body::from(r#"{"level": "debug"}"#))
8597                    .unwrap(),
8598            )
8599            .await
8600            .unwrap();
8601
8602        assert_eq!(resp.status(), StatusCode::OK);
8603        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8604            .await
8605            .unwrap();
8606        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8607        assert_eq!(json["status"], "ok");
8608        assert_eq!(json["applied"], true);
8609
8610        let overrides = state.log_levels().logger_overrides();
8611        assert_eq!(
8612            overrides.get("autumn_web").map(String::as_str),
8613            Some("debug")
8614        );
8615    }
8616
8617    #[tokio::test]
8618    async fn actuator_loggers_hidden_in_nonsensitive_mode() {
8619        let app = actuator_router(false).with_state(test_state());
8620        let resp = app
8621            .oneshot(
8622                Request::builder()
8623                    .uri("/actuator/loggers")
8624                    .body(Body::empty())
8625                    .unwrap(),
8626            )
8627            .await
8628            .unwrap();
8629        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8630    }
8631
8632    #[test]
8633    fn log_levels_set_and_get() {
8634        let levels = LogLevels::new("info");
8635        assert_eq!(levels.current_level(), "info");
8636
8637        let _ = levels.set_logger_level("my_crate", "debug");
8638        let overrides = levels.logger_overrides();
8639        assert_eq!(overrides.get("my_crate").map(String::as_str), Some("debug"));
8640    }
8641
8642    #[test]
8643    fn log_levels_root_updates_current() {
8644        let levels = LogLevels::new("info");
8645        let change = levels.set_logger_level("root", "trace");
8646        assert_eq!(change.previous(), Some("info"));
8647        assert_eq!(levels.current_level(), "trace");
8648    }
8649
8650    #[test]
8651    fn root_level_change_preserves_startup_per_target_directives() {
8652        // Regression: an app configured with a full `EnvFilter` directive at
8653        // startup must not lose its per-target directives when
8654        // `PUT /actuator/loggers/root` replaces only the global level.
8655        let levels = LogLevels::new("info,tower_http=warn,my_app=debug");
8656        levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8657
8658        // The startup per-target directives are seeded as overrides, leaving
8659        // only the bare global level in `current_level`.
8660        let seeded = levels.logger_overrides();
8661        assert_eq!(seeded.get("tower_http").map(String::as_str), Some("warn"));
8662        assert_eq!(seeded.get("my_app").map(String::as_str), Some("debug"));
8663        assert_eq!(levels.current_level(), "info");
8664
8665        // Raising the root level must NOT wipe the module-specific directives.
8666        let change = levels.set_logger_level("root", "warn");
8667        assert!(matches!(change, LogLevelChange::Applied { .. }));
8668        assert_eq!(levels.current_level(), "warn");
8669
8670        let overrides = levels.logger_overrides();
8671        assert_eq!(
8672            overrides.get("tower_http").map(String::as_str),
8673            Some("warn"),
8674            "tower_http directive must survive a root-level change"
8675        );
8676        assert_eq!(
8677            overrides.get("my_app").map(String::as_str),
8678            Some("debug"),
8679            "my_app directive must survive a root-level change"
8680        );
8681
8682        // The rebuilt live directive still carries both per-target directives.
8683        let directive = levels.rebuilt_directive_for_test();
8684        assert!(
8685            directive.contains("tower_http=warn"),
8686            "rebuilt directive dropped tower_http: {directive}"
8687        );
8688        assert!(
8689            directive.contains("my_app=debug"),
8690            "rebuilt directive dropped my_app: {directive}"
8691        );
8692    }
8693
8694    #[test]
8695    fn bare_non_level_segment_is_a_trace_target_not_the_global_level() {
8696        // `EnvFilter` semantics: in `info,my_app`, `info` is the global level
8697        // and the bare `my_app` is a *target directive at trace*, NOT the global
8698        // level. The old code took the last bare segment as the global level,
8699        // which both lost `info` and set an invalid global of `my_app`.
8700        let levels = LogLevels::new("info,my_app");
8701        assert_eq!(levels.current_level(), "info");
8702        let overrides = levels.logger_overrides();
8703        assert_eq!(
8704            overrides.get("my_app").map(String::as_str),
8705            Some("trace"),
8706            "bare non-level segment must become a trace target"
8707        );
8708
8709        // The rebuilt directive is equivalent to `info,my_app=trace`.
8710        assert_eq!(levels.rebuilt_directive_for_test(), "info,my_app=trace");
8711
8712        // A subsequent root PUT to `warn` keeps `my_app` as a target.
8713        levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8714        let change = levels.set_logger_level("root", "warn");
8715        assert!(matches!(change, LogLevelChange::Applied { .. }));
8716        assert_eq!(levels.current_level(), "warn");
8717        assert_eq!(
8718            levels.logger_overrides().get("my_app").map(String::as_str),
8719            Some("trace"),
8720            "my_app target must survive a root-level change"
8721        );
8722    }
8723
8724    #[test]
8725    fn mixed_bare_level_explicit_target_and_bare_target() {
8726        // `debug,tower_http=warn,my_app`: global=debug, explicit tower_http=warn,
8727        // and the bare `my_app` becomes a trace target.
8728        let levels = LogLevels::new("debug,tower_http=warn,my_app");
8729        assert_eq!(levels.current_level(), "debug");
8730        let overrides = levels.logger_overrides();
8731        assert_eq!(
8732            overrides.get("tower_http").map(String::as_str),
8733            Some("warn")
8734        );
8735        assert_eq!(overrides.get("my_app").map(String::as_str), Some("trace"));
8736
8737        // Targets are emitted sorted after the global level.
8738        assert_eq!(
8739            levels.rebuilt_directive_for_test(),
8740            "debug,my_app=trace,tower_http=warn"
8741        );
8742    }
8743
8744    #[test]
8745    fn purely_bare_level_config_has_no_target_overrides() {
8746        // Regression guard: a plain level stays the global level with no
8747        // spurious target overrides.
8748        let levels = LogLevels::new("info");
8749        assert_eq!(levels.current_level(), "info");
8750        assert!(levels.logger_overrides().is_empty());
8751        assert_eq!(levels.rebuilt_directive_for_test(), "info");
8752    }
8753
8754    // ── Prometheus endpoint tests ──────────────────────────────
8755
8756    #[tokio::test]
8757    async fn actuator_prometheus_returns_metrics() {
8758        let state = test_state();
8759        state.metrics().record("GET", "/test", 200, 10);
8760        state.metrics().record("POST", "/test", 500, 50);
8761
8762        let app = actuator_router(true).with_state(state);
8763        let resp = app
8764            .oneshot(
8765                Request::builder()
8766                    .uri("/actuator/prometheus")
8767                    .body(Body::empty())
8768                    .unwrap(),
8769            )
8770            .await
8771            .unwrap();
8772
8773        assert_eq!(resp.status(), StatusCode::OK);
8774        assert_eq!(
8775            resp.headers().get("content-type").unwrap(),
8776            "text/plain; version=0.0.4"
8777        );
8778
8779        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8780            .await
8781            .unwrap();
8782        let text = String::from_utf8(body.to_vec()).unwrap();
8783
8784        assert!(text.contains("# HELP autumn_http_requests_total Total number of HTTP requests"));
8785        assert!(text.contains("# TYPE autumn_http_requests_total counter"));
8786        assert!(text.contains("autumn_http_requests_total{version=\"stable\"} 2"));
8787
8788        assert!(text.contains("autumn_http_requests_active{version=\"stable\"} "));
8789        assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"2xx\"} 1"));
8790        assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"5xx\"} 1"));
8791
8792        // Latency is a histogram in seconds, labelled by version, method,
8793        // route and status class. The deprecated summary keeps the quantiles.
8794        assert!(text.contains("# TYPE autumn_http_request_duration_seconds histogram"));
8795        assert!(text.contains(
8796            "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"GET\",route=\"/test\",status_class=\"2xx\"} 1"
8797        ));
8798        assert!(text.contains(
8799            "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"POST\",route=\"/test\",status_class=\"5xx\"} 1"
8800        ));
8801        assert!(text.contains("# TYPE autumn_http_request_duration_quantiles_seconds summary"));
8802        assert!(text.contains(
8803            "autumn_http_request_duration_quantiles_seconds{version=\"stable\",quantile=\"0.99\"}"
8804        ));
8805
8806        assert!(text.contains(
8807            "autumn_http_route_requests_total{version=\"stable\",method=\"GET\",route=\"/test\"} 1"
8808        ));
8809        assert!(text.contains(
8810            "autumn_http_route_requests_total{version=\"stable\",method=\"POST\",route=\"/test\"} 1"
8811        ));
8812
8813        assert!(text.contains("# HELP autumn_request_timeouts_total"));
8814        assert!(text.contains("# TYPE autumn_request_timeouts_total counter"));
8815        assert!(text.contains("autumn_request_timeouts_total{version=\"stable\"} 0"));
8816
8817        assert!(text.contains("# HELP autumn_read_your_writes_pins_total"));
8818        assert!(text.contains("# TYPE autumn_read_your_writes_pins_total counter"));
8819        assert!(text.contains("autumn_read_your_writes_pins_total{version=\"stable\"} 0"));
8820
8821        assert!(text.contains("# HELP autumn_requests_shed_total"));
8822        assert!(text.contains("# TYPE autumn_requests_shed_total counter"));
8823        assert!(text.contains("autumn_requests_shed_total{version=\"stable\"} 0"));
8824
8825        assert!(text.contains("# TYPE autumn_admission_limit gauge"));
8826        assert!(text.contains("autumn_admission_limit{version=\"stable\"} 0"));
8827        assert!(text.contains("# TYPE autumn_admission_shed_total counter"));
8828        assert!(text.contains(
8829            "autumn_admission_shed_total{version=\"stable\",criticality=\"sheddable\"} 0"
8830        ));
8831        assert!(text.contains("# TYPE autumn_tenant_bulkhead_rejections_total counter"));
8832        assert!(text.contains(
8833            "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"request\"} 0"
8834        ));
8835        assert!(
8836            text.contains(
8837                "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"db\"} 0"
8838            )
8839        );
8840    }
8841
8842    /// Issue #3055: clones share one dead-letter trim counter.
8843    #[test]
8844    fn job_registry_dead_letter_trim_counter_is_shared() {
8845        let registry = JobRegistry::new();
8846        let clone = registry.clone();
8847        assert_eq!(registry.dead_letter_trimmed_total(), 0);
8848
8849        clone.record_dead_letter_trimmed(3);
8850        registry.record_dead_letter_trimmed(0);
8851        registry.record_dead_letter_trimmed(2);
8852        assert_eq!(registry.dead_letter_trimmed_total(), 5);
8853        assert_eq!(clone.dead_letter_trimmed_total(), 5);
8854    }
8855
8856    /// Issue #3055: the trim counter is a built-in Prometheus family.
8857    #[tokio::test]
8858    async fn prometheus_includes_dead_letter_trimmed_counter() {
8859        const NAME: &str = "autumn_jobs_dead_letter_trimmed_total";
8860        assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&NAME));
8861
8862        let state = test_state();
8863        let scrape = |state: TestActuatorState| async move {
8864            let resp = actuator_router(true)
8865                .with_state(state)
8866                .oneshot(
8867                    Request::builder()
8868                        .uri("/actuator/prometheus")
8869                        .body(Body::empty())
8870                        .unwrap(),
8871                )
8872                .await
8873                .unwrap();
8874            assert_eq!(resp.status(), StatusCode::OK);
8875            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8876                .await
8877                .unwrap();
8878            String::from_utf8(body.to_vec()).unwrap()
8879        };
8880
8881        // A zero sample exists before the first trim, so an alert can use rate().
8882        let text = scrape(state.clone()).await;
8883        assert!(text.contains(&format!("# TYPE {NAME} counter")), "{text}");
8884        assert!(
8885            text.contains(&format!("{NAME}{{version=\"stable\"}} 0\n")),
8886            "{text}"
8887        );
8888
8889        state.job_registry.record_dead_letter_trimmed(7);
8890        let text = scrape(state).await;
8891        assert!(
8892            text.contains(&format!("{NAME}{{version=\"stable\"}} 7\n")),
8893            "{text}"
8894        );
8895    }
8896
8897    #[cfg(feature = "cache-moka")]
8898    #[tokio::test]
8899    async fn prometheus_includes_cache_read_through_counters() {
8900        // read_through_metrics() is a process-wide singleton shared with other
8901        // tests in this crate; assert presence and a monotonic delta rather
8902        // than an absolute value.
8903        let before = crate::cache::read_through_metrics().snapshot();
8904        let cache: std::sync::Arc<dyn crate::cache::Cache> =
8905            std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
8906        let key = format!("actuator-prometheus-cache-test-{before:?}");
8907        let _: i32 =
8908            crate::cache::get_or_compute(
8909                &cache,
8910                &key,
8911                None,
8912                || async move { Ok::<i32, String>(1) },
8913            )
8914            .await
8915            .unwrap();
8916        let after = crate::cache::read_through_metrics().snapshot();
8917        assert!(after.fills > before.fills, "fills counter must increase");
8918
8919        let state = test_state();
8920        let app = actuator_router(true).with_state(state);
8921        let resp = app
8922            .oneshot(
8923                Request::builder()
8924                    .uri("/actuator/prometheus")
8925                    .body(Body::empty())
8926                    .unwrap(),
8927            )
8928            .await
8929            .unwrap();
8930
8931        assert_eq!(resp.status(), StatusCode::OK);
8932        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8933            .await
8934            .unwrap();
8935        let text = String::from_utf8(body.to_vec()).unwrap();
8936
8937        for name in [
8938            "autumn_cache_read_through_hits_total",
8939            "autumn_cache_read_through_misses_total",
8940            "autumn_cache_read_through_coalesced_waits_total",
8941            "autumn_cache_read_through_fills_total",
8942            "autumn_cache_read_through_fill_failures_total",
8943            "autumn_cache_read_through_stale_serves_total",
8944            "autumn_cache_fill_lock_acquires_total",
8945            "autumn_cache_fill_lock_contended_total",
8946            "autumn_cache_read_through_stale_if_error_serves_total",
8947            "autumn_cache_invalidation_failures_total",
8948        ] {
8949            assert!(
8950                text.contains(&format!("# TYPE {name} counter")),
8951                "missing TYPE line for {name} in:\n{text}"
8952            );
8953            assert!(
8954                text.contains(&format!("{name}{{version=\"stable\"}}")),
8955                "missing value line for {name} in:\n{text}"
8956            );
8957        }
8958    }
8959
8960    #[cfg(feature = "cache-moka")]
8961    #[tokio::test]
8962    async fn metrics_json_includes_cache_section() {
8963        let cache: std::sync::Arc<dyn crate::cache::Cache> =
8964            std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
8965        let key = "actuator-metrics-json-cache-test";
8966        let _: i32 =
8967            crate::cache::get_or_compute(&cache, key, None, || async move { Ok::<i32, String>(1) })
8968                .await
8969                .unwrap();
8970
8971        let state = test_state();
8972        let app = actuator_router(true).with_state(state);
8973        let resp = app
8974            .oneshot(
8975                Request::builder()
8976                    .uri("/actuator/metrics")
8977                    .body(Body::empty())
8978                    .unwrap(),
8979            )
8980            .await
8981            .unwrap();
8982
8983        assert_eq!(resp.status(), StatusCode::OK);
8984        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8985            .await
8986            .unwrap();
8987        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8988        assert!(json["cache"]["fills"].as_u64().unwrap() >= 1);
8989        assert!(json["cache"]["hits"].is_u64());
8990        assert!(json["cache"]["misses"].is_u64());
8991        assert!(json["cache"]["coalesced_waits"].is_u64());
8992        assert!(json["cache"]["fill_failures"].is_u64());
8993        assert!(json["cache"]["stale_serves"].is_u64());
8994        assert!(json["cache"]["fill_lock_acquires"].is_u64());
8995        assert!(json["cache"]["fill_lock_contended"].is_u64());
8996        assert!(json["cache"]["stale_if_error_serves"].is_u64());
8997        assert!(json["cache"]["invalidation_failures"].is_u64());
8998    }
8999
9000    #[tokio::test]
9001    async fn actuator_prometheus_labels_metrics_with_canary_version() {
9002        // A replica whose deploy_version() is "canary" must tag its metric
9003        // families with version="canary" so a controller can compare cohorts.
9004        let mut state = test_state();
9005        state.deploy_version = crate::canary::CANARY.to_owned();
9006        // Latencies in ms: spread so p50 < p95/p99 and the slowest is 1200 ms.
9007        state.metrics().record("GET", "/test", 200, 10);
9008        state.metrics().record("GET", "/test", 200, 20);
9009        state.metrics().record("GET", "/test", 500, 1200);
9010
9011        let app = actuator_router(true).with_state(state);
9012        let resp = app
9013            .oneshot(
9014                Request::builder()
9015                    .uri("/actuator/prometheus")
9016                    .body(Body::empty())
9017                    .unwrap(),
9018            )
9019            .await
9020            .unwrap();
9021        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9022            .await
9023            .unwrap();
9024        let text = String::from_utf8(body.to_vec()).unwrap();
9025
9026        assert!(text.contains("autumn_http_requests_total{version=\"canary\"} 3"));
9027        assert!(text.contains("autumn_http_responses_total{version=\"canary\",status=\"5xx\"} 1"));
9028        // Must not leak the default "stable" label when running as canary.
9029        assert!(!text.contains("version=\"stable\""));
9030
9031        // Verify the percentile math: values are reported in seconds (ms / 1000)
9032        // and satisfy the quantile invariant p50 <= p95 <= p99.
9033        let quantile = |q: &str| -> f64 {
9034            let needle = format!(
9035                "autumn_http_request_duration_quantiles_seconds{{version=\"canary\",quantile=\"{q}\"}} "
9036            );
9037            let line = text
9038                .lines()
9039                .find(|l| l.starts_with(&needle))
9040                .unwrap_or_else(|| panic!("missing duration line for quantile {q}"));
9041            line[needle.len()..].trim().parse().unwrap()
9042        };
9043        let (p50, p95, p99) = (quantile("0.5"), quantile("0.95"), quantile("0.99"));
9044        assert!(p50 <= p95, "p50 ({p50}) must be <= p95 ({p95})");
9045        assert!(p95 <= p99, "p95 ({p95}) must be <= p99 ({p99})");
9046        // Slowest sample was 1200 ms, so the top quantile must read 1.2 seconds.
9047        assert!(
9048            (p99 - 1.2).abs() < f64::EPSILON,
9049            "p99 should be 1.2s, got {p99}"
9050        );
9051    }
9052
9053    #[tokio::test]
9054    async fn actuator_prometheus_available_in_nonsensitive_mode() {
9055        let app = actuator_router(false).with_state(test_state());
9056        let resp = app
9057            .oneshot(
9058                Request::builder()
9059                    .uri("/actuator/prometheus")
9060                    .body(Body::empty())
9061                    .unwrap(),
9062            )
9063            .await
9064            .unwrap();
9065        assert_eq!(resp.status(), StatusCode::OK);
9066    }
9067
9068    #[tokio::test]
9069    async fn actuator_prometheus_available_when_export_enabled_and_nonsensitive() {
9070        // Metrics export decoupled from sensitive: prometheus is reachable even
9071        // though sensitive endpoints (env/configprops/loggers/tasks) are not.
9072        let app = actuator_router_with_prefix("/actuator", false, true).with_state(test_state());
9073        let resp = app
9074            .clone()
9075            .oneshot(
9076                Request::builder()
9077                    .uri("/actuator/prometheus")
9078                    .body(Body::empty())
9079                    .unwrap(),
9080            )
9081            .await
9082            .unwrap();
9083        assert_eq!(resp.status(), StatusCode::OK);
9084
9085        // Sensitive surfaces stay closed under the non-sensitive metrics config.
9086        for sensitive_path in [
9087            "/actuator/env",
9088            "/actuator/configprops",
9089            "/actuator/loggers",
9090            "/actuator/tasks",
9091            "/actuator/jobs",
9092            "/actuator/ui/tasks",
9093        ] {
9094            let resp = app
9095                .clone()
9096                .oneshot(
9097                    Request::builder()
9098                        .uri(sensitive_path)
9099                        .body(Body::empty())
9100                        .unwrap(),
9101                )
9102                .await
9103                .unwrap();
9104            assert_eq!(
9105                resp.status(),
9106                StatusCode::NOT_FOUND,
9107                "{sensitive_path} should be unavailable when actuator is non-sensitive"
9108            );
9109        }
9110    }
9111
9112    #[tokio::test]
9113    async fn actuator_prometheus_unavailable_when_export_disabled() {
9114        // Regression: with metrics export disabled, the scrape endpoint is gone.
9115        let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
9116        let resp = app
9117            .oneshot(
9118                Request::builder()
9119                    .uri("/actuator/prometheus")
9120                    .body(Body::empty())
9121                    .unwrap(),
9122            )
9123            .await
9124            .unwrap();
9125        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9126    }
9127
9128    #[tokio::test]
9129    async fn actuator_prometheus_unavailable_when_export_disabled_even_if_sensitive() {
9130        // Disabling export wins even when sensitive endpoints are enabled.
9131        let app = actuator_router_with_prefix("/actuator", true, false).with_state(test_state());
9132        let resp = app
9133            .oneshot(
9134                Request::builder()
9135                    .uri("/actuator/prometheus")
9136                    .body(Body::empty())
9137                    .unwrap(),
9138            )
9139            .await
9140            .unwrap();
9141        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9142    }
9143
9144    #[test]
9145    fn actuator_endpoint_paths_respects_prometheus_toggle() {
9146        let enabled = actuator_endpoint_paths("/actuator", false, true);
9147        assert!(
9148            enabled.iter().any(|p| p == "/actuator/prometheus"),
9149            "prometheus path should be listed when export is enabled: {enabled:?}"
9150        );
9151
9152        let disabled = actuator_endpoint_paths("/actuator", false, false);
9153        assert!(
9154            !disabled.iter().any(|p| p == "/actuator/prometheus"),
9155            "prometheus path should be absent when export is disabled: {disabled:?}"
9156        );
9157    }
9158
9159    // ── Tasks endpoint tests ───────────────────────────────────
9160
9161    #[tokio::test]
9162    async fn actuator_tasks_returns_registered_tasks() {
9163        let state = test_state();
9164        state.task_registry().register("cleanup", "every 5m");
9165        state.task_registry().record_start("cleanup");
9166        state.task_registry().record_success("cleanup", 150);
9167
9168        let app = actuator_router(true).with_state(state);
9169        let resp = app
9170            .oneshot(
9171                Request::builder()
9172                    .uri("/actuator/tasks")
9173                    .body(Body::empty())
9174                    .unwrap(),
9175            )
9176            .await
9177            .unwrap();
9178
9179        assert_eq!(resp.status(), StatusCode::OK);
9180        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9181            .await
9182            .unwrap();
9183        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9184        let task = &json["scheduled_tasks"]["cleanup"];
9185        assert_eq!(task["schedule"], "every 5m");
9186        assert_eq!(task["status"], "idle");
9187        assert_eq!(task["total_runs"], 1);
9188        assert_eq!(task["total_failures"], 0);
9189        assert_eq!(task["last_result"], "ok");
9190        assert_eq!(task["last_duration_ms"], 150);
9191    }
9192
9193    #[tokio::test]
9194    async fn actuator_jobs_returns_registered_jobs() {
9195        let state = test_state();
9196        state.job_registry().register("send_email");
9197        state.job_registry().record_enqueue("send_email");
9198        state.job_registry().record_start("send_email");
9199        state.job_registry().record_success("send_email");
9200
9201        let app = actuator_router(true).with_state(state);
9202        let resp = app
9203            .oneshot(
9204                Request::builder()
9205                    .uri("/actuator/jobs")
9206                    .body(Body::empty())
9207                    .unwrap(),
9208            )
9209            .await
9210            .unwrap();
9211
9212        assert_eq!(resp.status(), StatusCode::OK);
9213        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9214            .await
9215            .unwrap();
9216        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9217        let job = &json["jobs"]["send_email"];
9218        assert_eq!(job["queued"], 0);
9219        assert_eq!(job["in_flight"], 0);
9220        assert_eq!(job["total_successes"], 1);
9221        assert_eq!(job["total_failures"], 0);
9222    }
9223
9224    #[cfg(feature = "ws")]
9225    #[tokio::test]
9226    async fn actuator_channels_returns_metrics() {
9227        let state = test_state();
9228        let mut rx = state.channels().subscribe("feed");
9229        state
9230            .channels()
9231            .broadcast()
9232            .publish("feed", "hello")
9233            .expect("publish should succeed");
9234        rx.try_recv().expect("subscriber should receive payload");
9235
9236        let app = actuator_router(true).with_state(state);
9237        let resp = app
9238            .oneshot(
9239                Request::builder()
9240                    .uri("/actuator/channels")
9241                    .body(Body::empty())
9242                    .unwrap(),
9243            )
9244            .await
9245            .unwrap();
9246
9247        assert_eq!(resp.status(), StatusCode::OK);
9248        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9249            .await
9250            .unwrap();
9251        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9252        let feed = &json["channels"]["feed"];
9253        assert_eq!(feed["subscriber_count"], 1);
9254        assert_eq!(feed["lifetime_publish_count"], 1);
9255        assert_eq!(feed["dropped_count"], 0);
9256        assert_eq!(feed["lagged_count"], 0);
9257    }
9258
9259    #[tokio::test]
9260    async fn actuator_tasks_hidden_in_nonsensitive_mode() {
9261        let app = actuator_router(false).with_state(test_state());
9262        let resp = app
9263            .oneshot(
9264                Request::builder()
9265                    .uri("/actuator/tasks")
9266                    .body(Body::empty())
9267                    .unwrap(),
9268            )
9269            .await
9270            .unwrap();
9271        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9272    }
9273
9274    #[test]
9275    fn task_registry_records_failure() {
9276        let registry = TaskRegistry::new();
9277        registry.register("my_task", "cron 0 * * * *");
9278        registry.record_start("my_task");
9279        registry.record_failure("my_task", 200, "connection refused");
9280
9281        let snapshot = registry.snapshot();
9282        let task = &snapshot["my_task"];
9283        assert_eq!(task.status, "idle");
9284        assert_eq!(task.total_runs, 1);
9285        assert_eq!(task.total_failures, 1);
9286        assert_eq!(task.last_result.as_deref(), Some("failed"));
9287        assert_eq!(task.last_error.as_deref(), Some("connection refused"));
9288    }
9289
9290    #[test]
9291    fn task_registry_empty_snapshot() {
9292        let registry = TaskRegistry::new();
9293        assert!(registry.snapshot().is_empty());
9294    }
9295    #[test]
9296    fn log_levels_rejects_new_key_at_capacity() {
9297        let levels = LogLevels::new("info");
9298        // Fill to capacity
9299        for i in 0..1000 {
9300            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9301        }
9302
9303        // Try to add a new key, should be rejected
9304        let result = levels.set_logger_level("logger_1000", "warn");
9305        assert!(matches!(result, LogLevelChange::Rejected { .. }));
9306        assert_eq!(levels.logger_overrides().len(), 1000);
9307        assert_eq!(levels.logger_overrides().get("logger_1000"), None);
9308    }
9309
9310    #[test]
9311    fn log_levels_accepts_existing_key_at_capacity() {
9312        let levels = LogLevels::new("info");
9313        // Fill to capacity
9314        for i in 0..1000 {
9315            let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9316        }
9317
9318        // Try to update an existing key, should succeed
9319        let change = levels.set_logger_level("logger_999", "warn");
9320        assert_eq!(change.previous(), Some("debug"));
9321        assert_eq!(levels.logger_overrides().len(), 1000);
9322        assert_eq!(
9323            levels
9324                .logger_overrides()
9325                .get("logger_999")
9326                .map(String::as_str),
9327            Some("warn")
9328        );
9329    }
9330
9331    #[test]
9332    fn task_registry_records_multiple_successes_and_failures() {
9333        let registry = TaskRegistry::new();
9334        registry.register("my_task", "cron * * * * *");
9335
9336        // 1st success
9337        registry.record_start("my_task");
9338        registry.record_success("my_task", 100);
9339
9340        // 2nd success
9341        registry.record_start("my_task");
9342        registry.record_success("my_task", 110);
9343
9344        let snapshot = registry.snapshot();
9345        let task = &snapshot["my_task"];
9346        assert_eq!(task.total_runs, 2);
9347        assert_eq!(task.total_failures, 0);
9348
9349        // 1st failure
9350        registry.record_start("my_task");
9351        registry.record_failure("my_task", 50, "failed");
9352
9353        let snapshot2 = registry.snapshot();
9354        let task2 = &snapshot2["my_task"];
9355        assert_eq!(task2.total_runs, 3);
9356        assert_eq!(task2.total_failures, 1);
9357    }
9358
9359    #[test]
9360    fn configprops_tracks_custom_profile() {
9361        let mut props = HashMap::new();
9362        ConfigProperties::track_property(
9363            &mut props,
9364            "log.level",
9365            "debug",
9366            "info",
9367            "custom_profile",
9368        );
9369        assert_eq!(props["log.level"].source, "autumn.toml");
9370    }
9371
9372    #[test]
9373    fn configprops_tracks_dev_prod_profiles() {
9374        let mut props = HashMap::new();
9375        ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
9376        assert_eq!(props["log.level"].source, "profile_default:dev");
9377
9378        ConfigProperties::track_property(&mut props, "log.format", "json", "text", "prod");
9379        assert_eq!(props["log.format"].source, "profile_default:prod");
9380    }
9381
9382    #[test]
9383    fn configprops_returns_default_when_values_match() {
9384        let mut props = HashMap::new();
9385        ConfigProperties::track_property(&mut props, "log.level", "info", "info", "dev");
9386        assert_eq!(props["log.level"].source, "default");
9387    }
9388
9389    #[tokio::test]
9390    async fn actuator_ui_dashboard_returns_html_or_unimplemented() {
9391        let app = actuator_router(true).with_state(test_state());
9392
9393        let res = app
9394            .oneshot(
9395                Request::builder()
9396                    .uri("/actuator/ui")
9397                    .body(Body::empty())
9398                    .unwrap(),
9399            )
9400            .await
9401            .unwrap();
9402
9403        if cfg!(feature = "maud") {
9404            assert_eq!(res.status(), StatusCode::OK);
9405            assert_eq!(
9406                res.headers().get("content-type").unwrap(),
9407                "text/html; charset=utf-8"
9408            );
9409        } else {
9410            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9411        }
9412    }
9413
9414    #[tokio::test]
9415    async fn actuator_ui_metrics_returns_html_or_unimplemented() {
9416        let app = actuator_router(true).with_state(test_state());
9417
9418        let res = app
9419            .oneshot(
9420                Request::builder()
9421                    .uri("/actuator/ui/metrics")
9422                    .body(Body::empty())
9423                    .unwrap(),
9424            )
9425            .await
9426            .unwrap();
9427
9428        if cfg!(feature = "maud") {
9429            assert_eq!(res.status(), StatusCode::OK);
9430            assert_eq!(
9431                res.headers().get("content-type").unwrap(),
9432                "text/html; charset=utf-8"
9433            );
9434        } else {
9435            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9436        }
9437    }
9438
9439    #[tokio::test]
9440    async fn actuator_ui_tasks_returns_html_or_unimplemented() {
9441        let app = actuator_router(true).with_state(test_state());
9442
9443        let res = app
9444            .oneshot(
9445                Request::builder()
9446                    .uri("/actuator/ui/tasks")
9447                    .body(Body::empty())
9448                    .unwrap(),
9449            )
9450            .await
9451            .unwrap();
9452
9453        if cfg!(feature = "maud") {
9454            assert_eq!(res.status(), StatusCode::OK);
9455            assert_eq!(
9456                res.headers().get("content-type").unwrap(),
9457                "text/html; charset=utf-8"
9458            );
9459        } else {
9460            assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9461        }
9462    }
9463
9464    #[tokio::test]
9465    async fn test_actuator_router_calls_prefix_variant() {
9466        // The `actuator_router` function is a convenience wrapper around `actuator_router_with_prefix`
9467        // using "/actuator" as the prefix. We can test it by building it and hitting one of the endpoints.
9468        let app = actuator_router(false).with_state(test_state());
9469        let resp = app
9470            .oneshot(
9471                Request::builder()
9472                    .uri("/actuator/health")
9473                    .body(Body::empty())
9474                    .unwrap(),
9475            )
9476            .await
9477            .unwrap();
9478
9479        assert_eq!(resp.status(), StatusCode::OK);
9480    }
9481
9482    // ── RED: /actuator/a11y endpoint ───────────────────────────────
9483
9484    #[tokio::test]
9485    async fn actuator_a11y_returns_posture_json() {
9486        let app = actuator_router(false).with_state(test_state());
9487        let resp = app
9488            .oneshot(
9489                Request::builder()
9490                    .uri("/actuator/a11y")
9491                    .body(Body::empty())
9492                    .unwrap(),
9493            )
9494            .await
9495            .unwrap();
9496
9497        assert_eq!(resp.status(), StatusCode::OK);
9498        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9499            .await
9500            .unwrap();
9501        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9502        assert!(json["lang_set"].is_boolean(), "{json}");
9503        assert!(json["skip_link_present"].is_boolean(), "{json}");
9504        assert!(json["landmark_regions_present"].is_boolean(), "{json}");
9505    }
9506
9507    #[tokio::test]
9508    async fn actuator_a11y_available_in_nonsensitive_mode() {
9509        let app = actuator_router(false).with_state(test_state());
9510        let resp = app
9511            .oneshot(
9512                Request::builder()
9513                    .uri("/actuator/a11y")
9514                    .body(Body::empty())
9515                    .unwrap(),
9516            )
9517            .await
9518            .unwrap();
9519        assert_eq!(resp.status(), StatusCode::OK);
9520    }
9521
9522    #[tokio::test]
9523    async fn actuator_a11y_posture_default_values() {
9524        let app = actuator_router(true).with_state(test_state());
9525        let resp = app
9526            .oneshot(
9527                Request::builder()
9528                    .uri("/actuator/a11y")
9529                    .body(Body::empty())
9530                    .unwrap(),
9531            )
9532            .await
9533            .unwrap();
9534
9535        assert_eq!(resp.status(), StatusCode::OK);
9536        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9537            .await
9538            .unwrap();
9539        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9540        // Default test state should report false for all posture fields
9541        assert_eq!(json["lang_set"], false, "{json}");
9542        assert_eq!(json["skip_link_present"], false, "{json}");
9543        assert_eq!(json["landmark_regions_present"], false, "{json}");
9544    }
9545
9546    #[test]
9547    fn a11y_posture_all_passing_is_compliant() {
9548        let posture = A11yPosture {
9549            lang_set: true,
9550            skip_link_present: true,
9551            landmark_regions_present: true,
9552        };
9553        assert!(posture.is_compliant());
9554    }
9555
9556    #[test]
9557    fn a11y_posture_missing_lang_is_not_compliant() {
9558        let posture = A11yPosture {
9559            lang_set: false,
9560            skip_link_present: true,
9561            landmark_regions_present: true,
9562        };
9563        assert!(!posture.is_compliant());
9564    }
9565
9566    #[tokio::test]
9567    async fn actuator_a11y_endpoint_paths_includes_a11y() {
9568        let paths = actuator_endpoint_paths("/actuator", false, true);
9569        assert!(
9570            paths.iter().any(|p| p == "/actuator/a11y"),
9571            "a11y path not found in: {paths:?}"
9572        );
9573    }
9574
9575    // ── MetricsSource / MetricsSourceRegistry tests ────────────
9576
9577    #[test]
9578    fn metrics_source_registry_registers_and_collects() {
9579        struct FixedSource;
9580        impl MetricsSource for FixedSource {
9581            fn collect(&self) -> Vec<MetricFamily> {
9582                vec![MetricFamily {
9583                    name: "plugin_requests_total".to_string(),
9584                    help: "Plugin request count".to_string(),
9585                    kind: MetricKind::Counter,
9586                    samples: vec![MetricSample {
9587                        labels: vec![],
9588                        value: 42.0,
9589                    }],
9590                }]
9591            }
9592        }
9593
9594        let registry = MetricsSourceRegistry::new();
9595        registry
9596            .register("myplugin", Arc::new(FixedSource))
9597            .unwrap();
9598
9599        let all = registry.collect_all();
9600        assert_eq!(all.len(), 1);
9601        assert_eq!(all[0].0, "myplugin");
9602        assert_eq!(all[0].1[0].name, "plugin_requests_total");
9603        assert!((all[0].1[0].samples[0].value - 42.0).abs() < f64::EPSILON);
9604    }
9605
9606    #[test]
9607    fn metrics_source_registry_rejects_duplicate_name() {
9608        struct EmptySource;
9609        impl MetricsSource for EmptySource {
9610            fn collect(&self) -> Vec<MetricFamily> {
9611                vec![]
9612            }
9613        }
9614
9615        let registry = MetricsSourceRegistry::new();
9616        registry.register("dup", Arc::new(EmptySource)).unwrap();
9617        let result = registry.register("dup", Arc::new(EmptySource));
9618        assert!(result.is_err());
9619        assert!(result.unwrap_err().contains("dup"));
9620    }
9621
9622    #[test]
9623    fn metrics_source_registry_isolates_panicking_source() {
9624        struct PanickingSource;
9625        impl MetricsSource for PanickingSource {
9626            fn collect(&self) -> Vec<MetricFamily> {
9627                panic!("source panicked!")
9628            }
9629        }
9630
9631        let registry = MetricsSourceRegistry::new();
9632        registry
9633            .register("panicker", Arc::new(PanickingSource))
9634            .unwrap();
9635
9636        let all = registry.collect_all();
9637        assert_eq!(all.len(), 1);
9638        assert_eq!(
9639            all[0].1.len(),
9640            0,
9641            "panicking source should yield no families"
9642        );
9643
9644        let errors = registry.error_counts();
9645        assert_eq!(errors.get("panicker"), Some(&1));
9646    }
9647
9648    #[tokio::test]
9649    async fn prometheus_endpoint_includes_plugin_source_families() {
9650        struct GaugeSource;
9651        impl MetricsSource for GaugeSource {
9652            fn collect(&self) -> Vec<MetricFamily> {
9653                vec![MetricFamily {
9654                    name: "plugin_queue_depth".to_string(),
9655                    help: "Plugin queue depth".to_string(),
9656                    kind: MetricKind::Gauge,
9657                    samples: vec![MetricSample {
9658                        labels: vec![("shard".to_string(), "a".to_string())],
9659                        value: 7.0,
9660                    }],
9661                }]
9662            }
9663        }
9664
9665        let state = test_state();
9666        state
9667            .metrics_source_registry
9668            .register("gauge_plugin", Arc::new(GaugeSource))
9669            .unwrap();
9670
9671        let app = actuator_router(true).with_state(state);
9672        let resp = app
9673            .oneshot(
9674                Request::builder()
9675                    .uri("/actuator/prometheus")
9676                    .body(Body::empty())
9677                    .unwrap(),
9678            )
9679            .await
9680            .unwrap();
9681
9682        assert_eq!(resp.status(), StatusCode::OK);
9683        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9684            .await
9685            .unwrap();
9686        let text = String::from_utf8(body.to_vec()).unwrap();
9687
9688        assert!(
9689            text.contains("# HELP plugin_queue_depth Plugin queue depth"),
9690            "missing HELP line in:\n{text}"
9691        );
9692        assert!(
9693            text.contains("# TYPE plugin_queue_depth gauge"),
9694            "missing TYPE line in:\n{text}"
9695        );
9696        assert!(
9697            text.contains("plugin_queue_depth{shard=\"a\"} 7"),
9698            "missing sample line in:\n{text}"
9699        );
9700    }
9701
9702    #[tokio::test]
9703    async fn prometheus_endpoint_emits_error_counter_for_panicking_source() {
9704        struct PanickingSource;
9705        impl MetricsSource for PanickingSource {
9706            fn collect(&self) -> Vec<MetricFamily> {
9707                panic!("oops")
9708            }
9709        }
9710
9711        let state = test_state();
9712        state
9713            .metrics_source_registry
9714            .register("panic_src", Arc::new(PanickingSource))
9715            .unwrap();
9716
9717        let app = actuator_router(true).with_state(state);
9718        let resp = app
9719            .oneshot(
9720                Request::builder()
9721                    .uri("/actuator/prometheus")
9722                    .body(Body::empty())
9723                    .unwrap(),
9724            )
9725            .await
9726            .unwrap();
9727
9728        assert_eq!(resp.status(), StatusCode::OK);
9729        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9730            .await
9731            .unwrap();
9732        let text = String::from_utf8(body.to_vec()).unwrap();
9733
9734        assert!(
9735            text.contains("autumn_metrics_source_errors_total{source=\"panic_src\"} 1"),
9736            "missing error counter in:\n{text}"
9737        );
9738    }
9739
9740    #[tokio::test]
9741    async fn metrics_endpoint_includes_sources_section() {
9742        struct SampleSource;
9743        impl MetricsSource for SampleSource {
9744            fn collect(&self) -> Vec<MetricFamily> {
9745                vec![MetricFamily {
9746                    name: "custom_counter".to_string(),
9747                    help: "A custom counter".to_string(),
9748                    kind: MetricKind::Counter,
9749                    samples: vec![MetricSample {
9750                        labels: vec![],
9751                        value: 5.0,
9752                    }],
9753                }]
9754            }
9755        }
9756
9757        let state = test_state();
9758        state
9759            .metrics_source_registry
9760            .register("my_source", Arc::new(SampleSource))
9761            .unwrap();
9762
9763        let app = actuator_router(true).with_state(state);
9764        let resp = app
9765            .oneshot(
9766                Request::builder()
9767                    .uri("/actuator/metrics")
9768                    .body(Body::empty())
9769                    .unwrap(),
9770            )
9771            .await
9772            .unwrap();
9773
9774        assert_eq!(resp.status(), StatusCode::OK);
9775        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9776            .await
9777            .unwrap();
9778        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9779
9780        assert!(
9781            json.get("sources").is_some(),
9782            "metrics JSON missing 'sources' key"
9783        );
9784        assert!(
9785            json["sources"].get("my_source").is_some(),
9786            "sources missing 'my_source' key"
9787        );
9788    }
9789
9790    #[test]
9791    fn metrics_source_registry_preserves_insertion_order() {
9792        struct NamedSource(&'static str);
9793        impl MetricsSource for NamedSource {
9794            fn collect(&self) -> Vec<MetricFamily> {
9795                vec![MetricFamily {
9796                    name: self.0.to_string(),
9797                    help: String::new(),
9798                    kind: MetricKind::Counter,
9799                    samples: vec![],
9800                }]
9801            }
9802        }
9803
9804        let registry = MetricsSourceRegistry::new();
9805        registry
9806            .register("alpha", Arc::new(NamedSource("alpha_metric")))
9807            .unwrap();
9808        registry
9809            .register("beta", Arc::new(NamedSource("beta_metric")))
9810            .unwrap();
9811        registry
9812            .register("gamma", Arc::new(NamedSource("gamma_metric")))
9813            .unwrap();
9814
9815        let all = registry.collect_all();
9816        assert_eq!(all[0].0, "alpha");
9817        assert_eq!(all[1].0, "beta");
9818        assert_eq!(all[2].0, "gamma");
9819    }
9820
9821    // ── render_plugin_sources edge-case coverage ──────────────────────────
9822
9823    #[test]
9824    fn escape_help_text_escapes_backslash_and_newline() {
9825        assert_eq!(escape_help_text("a\\b\nc"), "a\\\\b\\nc");
9826        assert_eq!(escape_help_text("plain"), "plain");
9827        assert_eq!(escape_help_text(""), "");
9828    }
9829
9830    #[test]
9831    fn format_sample_value_handles_special_floats() {
9832        assert_eq!(format_sample_value(f64::INFINITY), "+Inf");
9833        assert_eq!(format_sample_value(f64::NEG_INFINITY), "-Inf");
9834        assert_eq!(format_sample_value(f64::NAN), "NaN");
9835        assert_eq!(format_sample_value(0.0), "0");
9836        assert_eq!(format_sample_value(1.5), "1.5");
9837    }
9838
9839    #[test]
9840    fn is_valid_metric_name_accepts_valid_and_rejects_invalid() {
9841        assert!(is_valid_metric_name("http_requests_total"));
9842        assert!(is_valid_metric_name("_private"));
9843        assert!(is_valid_metric_name("ns:metric"));
9844        assert!(!is_valid_metric_name(""));
9845        assert!(!is_valid_metric_name("0starts_with_digit"));
9846        assert!(!is_valid_metric_name("has-hyphen"));
9847    }
9848
9849    #[test]
9850    fn is_valid_label_name_accepts_valid_and_rejects_invalid() {
9851        assert!(is_valid_label_name("shard"));
9852        assert!(is_valid_label_name("_internal"));
9853        assert!(is_valid_label_name("a1"));
9854        assert!(!is_valid_label_name(""));
9855        assert!(!is_valid_label_name("0starts_digit"));
9856        assert!(!is_valid_label_name("has-hyphen"));
9857        assert!(!is_valid_label_name("has.dot"));
9858    }
9859
9860    #[tokio::test]
9861    async fn prometheus_endpoint_skips_family_with_invalid_metric_name() {
9862        struct BadNameSource;
9863        impl MetricsSource for BadNameSource {
9864            fn collect(&self) -> Vec<MetricFamily> {
9865                vec![
9866                    MetricFamily {
9867                        name: "invalid-name".to_string(),
9868                        help: "should be skipped".to_string(),
9869                        kind: MetricKind::Counter,
9870                        samples: vec![],
9871                    },
9872                    MetricFamily {
9873                        name: "valid_name".to_string(),
9874                        help: "should appear".to_string(),
9875                        kind: MetricKind::Counter,
9876                        samples: vec![MetricSample {
9877                            labels: vec![],
9878                            value: 1.0,
9879                        }],
9880                    },
9881                ]
9882            }
9883        }
9884
9885        let state = test_state();
9886        state
9887            .metrics_source_registry
9888            .register("bad_name_src", Arc::new(BadNameSource))
9889            .unwrap();
9890
9891        let app = actuator_router(true).with_state(state);
9892        let resp = app
9893            .oneshot(
9894                Request::builder()
9895                    .uri("/actuator/prometheus")
9896                    .body(Body::empty())
9897                    .unwrap(),
9898            )
9899            .await
9900            .unwrap();
9901        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9902            .await
9903            .unwrap();
9904        let text = String::from_utf8(body.to_vec()).unwrap();
9905
9906        assert!(
9907            !text.contains("invalid-name"),
9908            "invalid family must be skipped:\n{text}"
9909        );
9910        assert!(
9911            text.contains("valid_name"),
9912            "valid family must appear:\n{text}"
9913        );
9914    }
9915
9916    #[tokio::test]
9917    async fn prometheus_endpoint_skips_sample_with_invalid_label_key() {
9918        // A sample containing an invalid label key (bad-key) must be skipped
9919        // entirely — not emitted with the bad key dropped — to avoid creating
9920        // a phantom duplicate series in the Prometheus scrape.
9921        struct DirtyLabelsSource;
9922        impl MetricsSource for DirtyLabelsSource {
9923            fn collect(&self) -> Vec<MetricFamily> {
9924                vec![MetricFamily {
9925                    name: "dirty_labels_metric".to_string(),
9926                    help: "test".to_string(),
9927                    kind: MetricKind::Counter,
9928                    samples: vec![
9929                        MetricSample {
9930                            labels: vec![
9931                                ("good".to_string(), "a".to_string()),
9932                                ("bad-key".to_string(), "b".to_string()),
9933                            ],
9934                            value: 1.0,
9935                        },
9936                        MetricSample {
9937                            labels: vec![("good".to_string(), "a".to_string())],
9938                            value: 2.0,
9939                        },
9940                    ],
9941                }]
9942            }
9943        }
9944
9945        let state = test_state();
9946        state
9947            .metrics_source_registry
9948            .register("dirty", Arc::new(DirtyLabelsSource))
9949            .unwrap();
9950
9951        let app = actuator_router(true).with_state(state);
9952        let resp = app
9953            .oneshot(
9954                Request::builder()
9955                    .uri("/actuator/prometheus")
9956                    .body(Body::empty())
9957                    .unwrap(),
9958            )
9959            .await
9960            .unwrap();
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        // First sample (bad-key) must be absent entirely
9967        assert!(
9968            !text.contains("dirty_labels_metric{good=\"a\"} 1"),
9969            "sample with invalid label key must be skipped:\n{text}"
9970        );
9971        // Second (clean) sample must still appear
9972        assert!(
9973            text.contains("dirty_labels_metric{good=\"a\"} 2"),
9974            "clean sample must appear:\n{text}"
9975        );
9976    }
9977
9978    #[tokio::test]
9979    async fn prometheus_endpoint_deduplicates_label_keys() {
9980        struct DupLabelSource;
9981        impl MetricsSource for DupLabelSource {
9982            fn collect(&self) -> Vec<MetricFamily> {
9983                vec![MetricFamily {
9984                    name: "dup_label_metric".to_string(),
9985                    help: "test".to_string(),
9986                    kind: MetricKind::Counter,
9987                    samples: vec![MetricSample {
9988                        labels: vec![
9989                            ("env".to_string(), "prod".to_string()),
9990                            ("env".to_string(), "staging".to_string()),
9991                        ],
9992                        value: 5.0,
9993                    }],
9994                }]
9995            }
9996        }
9997
9998        let state = test_state();
9999        state
10000            .metrics_source_registry
10001            .register("dup_src", Arc::new(DupLabelSource))
10002            .unwrap();
10003
10004        let app = actuator_router(true).with_state(state);
10005        let resp = app
10006            .oneshot(
10007                Request::builder()
10008                    .uri("/actuator/prometheus")
10009                    .body(Body::empty())
10010                    .unwrap(),
10011            )
10012            .await
10013            .unwrap();
10014        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10015            .await
10016            .unwrap();
10017        let text = String::from_utf8(body.to_vec()).unwrap();
10018
10019        // Only the first occurrence of `env` is kept
10020        assert!(
10021            text.contains("dup_label_metric{env=\"prod\"} 5"),
10022            "first env value must be kept:\n{text}"
10023        );
10024        assert!(
10025            !text.contains("staging"),
10026            "duplicate env key value must be dropped:\n{text}"
10027        );
10028    }
10029
10030    #[tokio::test]
10031    async fn prometheus_endpoint_escapes_help_text_and_formats_inf() {
10032        struct SpecialSource;
10033        impl MetricsSource for SpecialSource {
10034            fn collect(&self) -> Vec<MetricFamily> {
10035                vec![MetricFamily {
10036                    name: "inf_gauge".to_string(),
10037                    help: "has\\backslash and\nnewline".to_string(),
10038                    kind: MetricKind::Gauge,
10039                    samples: vec![
10040                        MetricSample {
10041                            labels: vec![("dir".to_string(), "pos".to_string())],
10042                            value: f64::INFINITY,
10043                        },
10044                        MetricSample {
10045                            labels: vec![("dir".to_string(), "neg".to_string())],
10046                            value: f64::NEG_INFINITY,
10047                        },
10048                    ],
10049                }]
10050            }
10051        }
10052
10053        let state = test_state();
10054        state
10055            .metrics_source_registry
10056            .register("special", Arc::new(SpecialSource))
10057            .unwrap();
10058
10059        let app = actuator_router(true).with_state(state);
10060        let resp = app
10061            .oneshot(
10062                Request::builder()
10063                    .uri("/actuator/prometheus")
10064                    .body(Body::empty())
10065                    .unwrap(),
10066            )
10067            .await
10068            .unwrap();
10069        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10070            .await
10071            .unwrap();
10072        let text = String::from_utf8(body.to_vec()).unwrap();
10073
10074        assert!(
10075            text.contains("# HELP inf_gauge has\\\\backslash and\\nnewline"),
10076            "help text must be escaped in:\n{text}"
10077        );
10078        assert!(
10079            text.contains("inf_gauge{dir=\"pos\"} +Inf"),
10080            "must render +Inf in:\n{text}"
10081        );
10082        assert!(
10083            text.contains("inf_gauge{dir=\"neg\"} -Inf"),
10084            "must render -Inf in:\n{text}"
10085        );
10086    }
10087
10088    #[tokio::test]
10089    async fn prometheus_endpoint_skips_duplicate_family_name_across_sources() {
10090        struct FirstSource;
10091        impl MetricsSource for FirstSource {
10092            fn collect(&self) -> Vec<MetricFamily> {
10093                vec![MetricFamily {
10094                    name: "shared_counter".to_string(),
10095                    help: "from first".to_string(),
10096                    kind: MetricKind::Counter,
10097                    samples: vec![MetricSample {
10098                        labels: vec![],
10099                        value: 1.0,
10100                    }],
10101                }]
10102            }
10103        }
10104        struct SecondSource;
10105        impl MetricsSource for SecondSource {
10106            fn collect(&self) -> Vec<MetricFamily> {
10107                vec![MetricFamily {
10108                    name: "shared_counter".to_string(),
10109                    help: "from second".to_string(),
10110                    kind: MetricKind::Counter,
10111                    samples: vec![MetricSample {
10112                        labels: vec![],
10113                        value: 2.0,
10114                    }],
10115                }]
10116            }
10117        }
10118
10119        let state = test_state();
10120        state
10121            .metrics_source_registry
10122            .register("first", Arc::new(FirstSource))
10123            .unwrap();
10124        state
10125            .metrics_source_registry
10126            .register("second", Arc::new(SecondSource))
10127            .unwrap();
10128
10129        let app = actuator_router(true).with_state(state);
10130        let resp = app
10131            .oneshot(
10132                Request::builder()
10133                    .uri("/actuator/prometheus")
10134                    .body(Body::empty())
10135                    .unwrap(),
10136            )
10137            .await
10138            .unwrap();
10139        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10140            .await
10141            .unwrap();
10142        let text = String::from_utf8(body.to_vec()).unwrap();
10143
10144        let occurrences = text.matches("# HELP shared_counter").count();
10145        assert_eq!(
10146            occurrences, 1,
10147            "must emit exactly one HELP block for shared_counter:\n{text}"
10148        );
10149    }
10150
10151    #[tokio::test]
10152    async fn prometheus_endpoint_skips_builtin_name_collision() {
10153        struct ShadowSource;
10154        impl MetricsSource for ShadowSource {
10155            fn collect(&self) -> Vec<MetricFamily> {
10156                vec![MetricFamily {
10157                    name: "autumn_http_requests_total".to_string(),
10158                    help: "plugin trying to shadow built-in".to_string(),
10159                    kind: MetricKind::Counter,
10160                    samples: vec![MetricSample {
10161                        labels: vec![],
10162                        value: 999.0,
10163                    }],
10164                }]
10165            }
10166        }
10167
10168        let state = test_state();
10169        state
10170            .metrics_source_registry
10171            .register("shadow", Arc::new(ShadowSource))
10172            .unwrap();
10173
10174        let app = actuator_router(true).with_state(state);
10175        let resp = app
10176            .oneshot(
10177                Request::builder()
10178                    .uri("/actuator/prometheus")
10179                    .body(Body::empty())
10180                    .unwrap(),
10181            )
10182            .await
10183            .unwrap();
10184        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10185            .await
10186            .unwrap();
10187        let text = String::from_utf8(body.to_vec()).unwrap();
10188
10189        let occurrences = text.matches("# HELP autumn_http_requests_total").count();
10190        assert_eq!(
10191            occurrences, 1,
10192            "built-in must not be shadowed by plugin:\n{text}"
10193        );
10194        assert!(
10195            !text.contains("999"),
10196            "plugin shadow value must not appear:\n{text}"
10197        );
10198    }
10199
10200    #[tokio::test]
10201    async fn prometheus_endpoint_skips_builtin_duration_family_collision() {
10202        // The new built-in latency family must be in the duplicate guard so a
10203        // plugin emitting the same family cannot produce a second HELP/TYPE block.
10204        struct ShadowLatency;
10205        impl MetricsSource for ShadowLatency {
10206            fn collect(&self) -> Vec<MetricFamily> {
10207                vec![MetricFamily {
10208                    name: "autumn_http_request_duration_seconds".to_string(),
10209                    help: "plugin trying to shadow built-in latency".to_string(),
10210                    kind: MetricKind::Gauge,
10211                    samples: vec![MetricSample {
10212                        labels: vec![],
10213                        value: 999.0,
10214                    }],
10215                }]
10216            }
10217        }
10218
10219        let state = test_state();
10220        state
10221            .metrics_source_registry
10222            .register("shadow_latency", Arc::new(ShadowLatency))
10223            .unwrap();
10224
10225        let app = actuator_router(true).with_state(state);
10226        let resp = app
10227            .oneshot(
10228                Request::builder()
10229                    .uri("/actuator/prometheus")
10230                    .body(Body::empty())
10231                    .unwrap(),
10232            )
10233            .await
10234            .unwrap();
10235        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10236            .await
10237            .unwrap();
10238        let text = String::from_utf8(body.to_vec()).unwrap();
10239
10240        let occurrences = text
10241            .matches("# HELP autumn_http_request_duration_seconds")
10242            .count();
10243        assert_eq!(
10244            occurrences, 1,
10245            "built-in latency family must not be shadowed by plugin:\n{text}"
10246        );
10247        assert!(
10248            !text.contains("999"),
10249            "plugin shadow value must not appear:\n{text}"
10250        );
10251    }
10252
10253    #[tokio::test]
10254    async fn prometheus_endpoint_skips_duplicate_series_within_family() {
10255        struct DupSeriesSource;
10256        impl MetricsSource for DupSeriesSource {
10257            fn collect(&self) -> Vec<MetricFamily> {
10258                vec![MetricFamily {
10259                    name: "dup_series_metric".to_string(),
10260                    help: "test".to_string(),
10261                    kind: MetricKind::Counter,
10262                    samples: vec![
10263                        MetricSample {
10264                            labels: vec![("region".to_string(), "us".to_string())],
10265                            value: 10.0,
10266                        },
10267                        MetricSample {
10268                            labels: vec![("region".to_string(), "us".to_string())],
10269                            value: 20.0,
10270                        },
10271                    ],
10272                }]
10273            }
10274        }
10275
10276        let state = test_state();
10277        state
10278            .metrics_source_registry
10279            .register("dup_series", Arc::new(DupSeriesSource))
10280            .unwrap();
10281
10282        let app = actuator_router(true).with_state(state);
10283        let resp = app
10284            .oneshot(
10285                Request::builder()
10286                    .uri("/actuator/prometheus")
10287                    .body(Body::empty())
10288                    .unwrap(),
10289            )
10290            .await
10291            .unwrap();
10292        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10293            .await
10294            .unwrap();
10295        let text = String::from_utf8(body.to_vec()).unwrap();
10296
10297        // First occurrence kept, second skipped
10298        assert!(
10299            text.contains("dup_series_metric{region=\"us\"} 10"),
10300            "first sample must appear:\n{text}"
10301        );
10302        assert!(
10303            !text.contains("dup_series_metric{region=\"us\"} 20"),
10304            "duplicate series must be dropped:\n{text}"
10305        );
10306    }
10307
10308    // ── RED then GREEN: /actuator/logfile endpoint ─────────────
10309
10310    fn make_log_buffer_with_entries() -> crate::log::capture::LogBuffer {
10311        use crate::log::capture::{CapturedLogEntry, LogBuffer};
10312        use crate::log::filter::ParameterFilter;
10313        let buf = LogBuffer::new(100, ParameterFilter::default());
10314        buf.push(CapturedLogEntry {
10315            timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10316            level: "INFO".to_owned(),
10317            target: "myapp::orders".to_owned(),
10318            message: "order created".to_owned(),
10319            fields: {
10320                let mut m = serde_json::Map::new();
10321                m.insert("order_id".to_owned(), serde_json::json!("A-1001"));
10322                m
10323            },
10324            request_id: Some("req-abc".to_owned()),
10325        });
10326        buf.push(CapturedLogEntry {
10327            timestamp: "2024-01-01T00:00:01.000Z".to_owned(),
10328            level: "WARN".to_owned(),
10329            target: "myapp::payments".to_owned(),
10330            message: "payment slow".to_owned(),
10331            fields: serde_json::Map::new(),
10332            request_id: None,
10333        });
10334        buf.push(CapturedLogEntry {
10335            timestamp: "2024-01-01T00:00:02.000Z".to_owned(),
10336            level: "ERROR".to_owned(),
10337            target: "myapp::payments".to_owned(),
10338            message: "payment failed".to_owned(),
10339            fields: serde_json::Map::new(),
10340            request_id: None,
10341        });
10342        buf
10343    }
10344
10345    #[tokio::test]
10346    async fn green_logfile_returns_empty_when_capture_disabled() {
10347        let state = test_state(); // log_buffer = None
10348        let response =
10349            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10350                .await
10351                .unwrap();
10352        let body = response.0;
10353        assert!(!body.capture_enabled);
10354        assert!(body.entries.is_empty());
10355        assert_eq!(body.total, 0);
10356    }
10357
10358    #[tokio::test]
10359    async fn green_logfile_returns_all_entries_when_no_filter() {
10360        let mut state = test_state();
10361        state.log_buffer = Some(make_log_buffer_with_entries());
10362
10363        let response =
10364            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10365                .await
10366                .unwrap();
10367        let body = response.0;
10368        assert!(body.capture_enabled);
10369        assert_eq!(body.total, 3);
10370        assert_eq!(body.entries.len(), 3);
10371        // newest-last ordering
10372        assert_eq!(body.entries[0].level, "INFO");
10373        assert_eq!(body.entries[2].level, "ERROR");
10374    }
10375
10376    #[tokio::test]
10377    async fn green_logfile_level_filter_excludes_info_when_min_warn() {
10378        let mut state = test_state();
10379        state.log_buffer = Some(make_log_buffer_with_entries());
10380
10381        let response = logfile_endpoint(
10382            State(state),
10383            axum::extract::Query(LogfileQuery {
10384                level: Some("warn".to_owned()),
10385                limit: None,
10386            }),
10387        )
10388        .await
10389        .unwrap();
10390        let body = response.0;
10391        assert_eq!(body.entries.len(), 2);
10392        assert!(body.entries.iter().all(|e| e.level != "INFO"));
10393    }
10394
10395    #[tokio::test]
10396    async fn green_logfile_limit_returns_most_recent_n() {
10397        let mut state = test_state();
10398        state.log_buffer = Some(make_log_buffer_with_entries());
10399
10400        let response = logfile_endpoint(
10401            State(state),
10402            axum::extract::Query(LogfileQuery {
10403                level: None,
10404                limit: Some(2),
10405            }),
10406        )
10407        .await
10408        .unwrap();
10409        let body = response.0;
10410        assert_eq!(body.entries.len(), 2);
10411        // Most recent 2 = WARN and ERROR
10412        assert_eq!(body.entries[0].level, "WARN");
10413        assert_eq!(body.entries[1].level, "ERROR");
10414    }
10415
10416    #[tokio::test]
10417    async fn green_logfile_sensitive_fields_in_response_are_served_scrubbed() {
10418        use crate::log::capture::{CapturedLogEntry, LogBuffer};
10419        use crate::log::filter::{FILTERED_PLACEHOLDER, ParameterFilter};
10420        let buf = LogBuffer::new(10, ParameterFilter::default());
10421        // The layer scrubs before storage; simulate stored entry with scrubbed value.
10422        buf.push(CapturedLogEntry {
10423            timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10424            level: "INFO".to_owned(),
10425            target: "auth".to_owned(),
10426            message: "login attempt".to_owned(),
10427            fields: {
10428                let mut m = serde_json::Map::new();
10429                m.insert(
10430                    "password".to_owned(),
10431                    serde_json::Value::String(FILTERED_PLACEHOLDER.to_owned()),
10432                );
10433                m
10434            },
10435            request_id: None,
10436        });
10437
10438        let mut state = test_state();
10439        state.log_buffer = Some(buf);
10440
10441        let response =
10442            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10443                .await
10444                .unwrap();
10445        let body = response.0;
10446        assert_eq!(
10447            body.entries[0].fields["password"].as_str().unwrap(),
10448            FILTERED_PLACEHOLDER,
10449            "sensitive value must remain scrubbed in the response"
10450        );
10451    }
10452
10453    #[tokio::test]
10454    async fn green_logfile_invalid_level_returns_400() {
10455        let state = test_state();
10456        let result = logfile_endpoint(
10457            State(state),
10458            axum::extract::Query(LogfileQuery {
10459                level: Some("warning".to_owned()), // invalid — should be "warn"
10460                limit: None,
10461            }),
10462        )
10463        .await;
10464        let (status, _body) = result.unwrap_err();
10465        assert_eq!(status, StatusCode::BAD_REQUEST);
10466    }
10467
10468    #[tokio::test]
10469    async fn green_logfile_endpoint_in_sensitive_router() {
10470        // The endpoint must be reachable when sensitive=true.
10471        let state = test_state();
10472        let app = actuator_router::<TestActuatorState>(true).with_state(state);
10473        let resp = app
10474            .oneshot(
10475                Request::builder()
10476                    .uri("/actuator/logfile")
10477                    .body(Body::empty())
10478                    .unwrap(),
10479            )
10480            .await
10481            .unwrap();
10482        assert_eq!(resp.status(), StatusCode::OK);
10483    }
10484
10485    #[tokio::test]
10486    async fn green_logfile_endpoint_not_in_non_sensitive_router() {
10487        // The endpoint must NOT be reachable when sensitive=false.
10488        let state = test_state();
10489        let app = actuator_router::<TestActuatorState>(false).with_state(state);
10490        let resp = app
10491            .oneshot(
10492                Request::builder()
10493                    .uri("/actuator/logfile")
10494                    .body(Body::empty())
10495                    .unwrap(),
10496            )
10497            .await
10498            .unwrap();
10499        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10500    }
10501
10502    #[tokio::test]
10503    async fn green_logfile_structured_fields_preserved() {
10504        let mut state = test_state();
10505        state.log_buffer = Some(make_log_buffer_with_entries());
10506
10507        let response =
10508            logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10509                .await
10510                .unwrap();
10511        let body = response.0;
10512        let first = &body.entries[0];
10513        assert_eq!(first.target, "myapp::orders");
10514        assert_eq!(first.fields["order_id"].as_str().unwrap(), "A-1001");
10515        assert_eq!(first.request_id.as_deref(), Some("req-abc"));
10516    }
10517
10518    // ── App-metrics facade exposition (issue #1378) ───────────────────────
10519    //
10520    // The facade registry is process-global, so every test here records into
10521    // instrument names built with `metrics::testing::unique_name` and asserts
10522    // with `contains()` on those names. Never assert whole-body equality or
10523    // the absence of unrelated families.
10524
10525    /// Fetch a body from the actuator router mounted at `/actuator`.
10526    async fn actuator_body(uri: &str) -> String {
10527        let app = actuator_router(true).with_state(test_state());
10528        let resp = app
10529            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
10530            .await
10531            .unwrap();
10532        assert_eq!(resp.status(), StatusCode::OK);
10533        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10534            .await
10535            .unwrap();
10536        String::from_utf8(body.to_vec()).unwrap()
10537    }
10538
10539    #[tokio::test]
10540    async fn prometheus_endpoint_renders_facade_counter_gauge_and_histogram() {
10541        use crate::metrics::testing::unique_name;
10542
10543        let counter_name = unique_name("facade_expo_orders_total");
10544        crate::metrics::describe_counter(&counter_name, "Orders placed");
10545        crate::metrics::counter(&counter_name)
10546            .with_label("status", "paid")
10547            .with_label("region", "eu")
10548            .increment(3);
10549
10550        let gauge_name = unique_name("facade_expo_queue_depth");
10551        crate::metrics::describe_gauge(&gauge_name, "Queue depth");
10552        crate::metrics::gauge(&gauge_name).set(2.5);
10553
10554        let hist_name = unique_name("facade_expo_work_seconds");
10555        crate::metrics::describe_histogram(&hist_name, "Work duration");
10556        let hist = crate::metrics::histogram(&hist_name).with_label("route", "/x");
10557        hist.record(0.25);
10558        hist.record(0.75);
10559
10560        let text = actuator_body("/actuator/prometheus").await;
10561
10562        // Counter: HELP/TYPE plus an integer-rendered sample with sorted labels.
10563        assert!(
10564            text.contains(&format!("# HELP {counter_name} Orders placed")),
10565            "missing counter HELP in:\n{text}"
10566        );
10567        assert!(
10568            text.contains(&format!("# TYPE {counter_name} counter")),
10569            "missing counter TYPE in:\n{text}"
10570        );
10571        assert!(
10572            text.contains(&format!(
10573                "{counter_name}{{region=\"eu\",status=\"paid\"}} 3"
10574            )),
10575            "counter must render as an integer with sorted labels in:\n{text}"
10576        );
10577
10578        // Gauge.
10579        assert!(
10580            text.contains(&format!("# TYPE {gauge_name} gauge")),
10581            "missing gauge TYPE in:\n{text}"
10582        );
10583        assert!(
10584            text.contains(&format!("{gauge_name} 2.5")),
10585            "missing gauge sample in:\n{text}"
10586        );
10587
10588        // Histogram: one TYPE line for the base name, cumulative buckets with
10589        // `le` appended after the user labels, then `_sum` and `_count`.
10590        assert!(
10591            text.contains(&format!("# HELP {hist_name} Work duration")),
10592            "missing histogram HELP in:\n{text}"
10593        );
10594        assert!(
10595            text.contains(&format!("# TYPE {hist_name} histogram")),
10596            "missing histogram TYPE in:\n{text}"
10597        );
10598        for (le, value) in [
10599            ("0.005", 0),
10600            ("0.01", 0),
10601            ("0.025", 0),
10602            ("0.05", 0),
10603            ("0.1", 0),
10604            ("0.25", 1),
10605            ("0.5", 1),
10606            ("1", 2),
10607            ("2.5", 2),
10608            ("5", 2),
10609            ("10", 2),
10610            ("+Inf", 2),
10611        ] {
10612            let line = format!("{hist_name}_bucket{{route=\"/x\",le=\"{le}\"}} {value}");
10613            assert!(
10614                text.contains(&line),
10615                "missing bucket line `{line}` in:\n{text}"
10616            );
10617        }
10618        assert!(
10619            text.contains(&format!("{hist_name}_sum{{route=\"/x\"}} 1")),
10620            "missing histogram sum in:\n{text}"
10621        );
10622        assert!(
10623            text.contains(&format!("{hist_name}_count{{route=\"/x\"}} 2")),
10624            "missing histogram count in:\n{text}"
10625        );
10626        // `le="+Inf"` must structurally equal `_count`.
10627        assert!(
10628            text.contains(&format!("{hist_name}_bucket{{route=\"/x\",le=\"+Inf\"}} 2")),
10629            "+Inf bucket must equal _count in:\n{text}"
10630        );
10631        // Exactly one TYPE block per family, no duplicates.
10632        assert_eq!(
10633            text.matches(&format!("# TYPE {counter_name} counter"))
10634                .count(),
10635            1,
10636            "duplicate TYPE line for {counter_name} in:\n{text}"
10637        );
10638    }
10639
10640    #[tokio::test]
10641    async fn prometheus_endpoint_prefers_facade_over_colliding_plugin_family() {
10642        use crate::metrics::testing::unique_name;
10643
10644        struct CollidingSource(String);
10645        impl MetricsSource for CollidingSource {
10646            fn collect(&self) -> Vec<MetricFamily> {
10647                vec![MetricFamily {
10648                    name: self.0.clone(),
10649                    help: "From the plugin".to_string(),
10650                    kind: MetricKind::Gauge,
10651                    samples: vec![MetricSample {
10652                        labels: vec![],
10653                        value: 99.0,
10654                    }],
10655                }]
10656            }
10657        }
10658
10659        let name = unique_name("facade_expo_collision_total");
10660        crate::metrics::describe_counter(&name, "From the facade");
10661        crate::metrics::counter(&name).increment(1);
10662
10663        let state = test_state();
10664        state
10665            .metrics_source_registry
10666            .register("colliding_plugin", Arc::new(CollidingSource(name.clone())))
10667            .unwrap();
10668
10669        let app = actuator_router(true).with_state(state);
10670        let resp = app
10671            .oneshot(
10672                Request::builder()
10673                    .uri("/actuator/prometheus")
10674                    .body(Body::empty())
10675                    .unwrap(),
10676            )
10677            .await
10678            .unwrap();
10679        assert_eq!(resp.status(), StatusCode::OK);
10680        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10681            .await
10682            .unwrap();
10683        let text = String::from_utf8(body.to_vec()).unwrap();
10684
10685        assert!(
10686            text.contains(&format!("# HELP {name} From the facade")),
10687            "the facade family must win the name in:\n{text}"
10688        );
10689        assert!(
10690            !text.contains(&format!("# HELP {name} From the plugin")),
10691            "the plugin family must be skipped in:\n{text}"
10692        );
10693        assert_eq!(
10694            text.matches(&format!("# TYPE {name} ")).count(),
10695            1,
10696            "exactly one TYPE line for {name} in:\n{text}"
10697        );
10698        assert!(
10699            !text.contains(&format!("{name} 99")),
10700            "the plugin sample must not be emitted in:\n{text}"
10701        );
10702    }
10703
10704    #[tokio::test]
10705    async fn prometheus_endpoint_skips_plugin_family_colliding_with_facade_histogram_count() {
10706        use crate::metrics::testing::unique_name;
10707
10708        struct DerivedSource(String);
10709        impl MetricsSource for DerivedSource {
10710            fn collect(&self) -> Vec<MetricFamily> {
10711                vec![MetricFamily {
10712                    name: self.0.clone(),
10713                    help: "Plugin shadowing a derived name".to_string(),
10714                    kind: MetricKind::Counter,
10715                    samples: vec![MetricSample {
10716                        labels: vec![],
10717                        value: 77.0,
10718                    }],
10719                }]
10720            }
10721        }
10722
10723        let base = unique_name("facade_expo_derived_seconds");
10724        crate::metrics::histogram(&base).record(0.25);
10725        let derived = format!("{base}_count");
10726
10727        let state = test_state();
10728        state
10729            .metrics_source_registry
10730            .register("derived_plugin", Arc::new(DerivedSource(derived.clone())))
10731            .unwrap();
10732
10733        let app = actuator_router(true).with_state(state);
10734        let resp = app
10735            .oneshot(
10736                Request::builder()
10737                    .uri("/actuator/prometheus")
10738                    .body(Body::empty())
10739                    .unwrap(),
10740            )
10741            .await
10742            .unwrap();
10743        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10744            .await
10745            .unwrap();
10746        let text = String::from_utf8(body.to_vec()).unwrap();
10747
10748        assert!(
10749            !text.contains("Plugin shadowing a derived name"),
10750            "a plugin family named after a facade histogram's derived family must be skipped in:\n{text}"
10751        );
10752        assert!(
10753            !text.contains(&format!("{derived} 77")),
10754            "the plugin sample must not be emitted in:\n{text}"
10755        );
10756        assert!(
10757            text.contains(&format!("{derived} 1")),
10758            "the facade's own derived count must still be present in:\n{text}"
10759        );
10760    }
10761
10762    #[tokio::test]
10763    async fn prometheus_endpoint_skips_plugin_family_colliding_with_builtin_summary_derived_name() {
10764        // A histogram owns `_bucket`, `_sum` and `_count`. A summary owns
10765        // `_sum` and `_count`. A plugin family under any of these names puts a
10766        // second `# HELP` line for that family into the document, and
10767        // Prometheus' own parser rejects the whole scrape when that happens.
10768        struct DerivedSummarySource(&'static str);
10769        impl MetricsSource for DerivedSummarySource {
10770            fn collect(&self) -> Vec<MetricFamily> {
10771                vec![MetricFamily {
10772                    name: self.0.to_string(),
10773                    help: "Plugin shadowing a builtin summary's derived family".to_string(),
10774                    kind: MetricKind::Counter,
10775                    samples: vec![MetricSample {
10776                        labels: vec![],
10777                        value: 4242.0,
10778                    }],
10779                }]
10780            }
10781        }
10782
10783        for derived in [
10784            "autumn_http_request_duration_seconds_bucket",
10785            "autumn_http_request_duration_seconds_sum",
10786            "autumn_http_request_duration_seconds_count",
10787            "autumn_http_request_duration_quantiles_seconds_sum",
10788            "autumn_http_request_duration_quantiles_seconds_count",
10789        ] {
10790            let state = test_state();
10791            state
10792                .metrics_source_registry
10793                .register(
10794                    "derived_summary_plugin",
10795                    Arc::new(DerivedSummarySource(derived)),
10796                )
10797                .unwrap();
10798
10799            let app = actuator_router(true).with_state(state);
10800            let resp = app
10801                .oneshot(
10802                    Request::builder()
10803                        .uri("/actuator/prometheus")
10804                        .body(Body::empty())
10805                        .unwrap(),
10806                )
10807                .await
10808                .unwrap();
10809            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10810                .await
10811                .unwrap();
10812            let text = String::from_utf8(body.to_vec()).unwrap();
10813
10814            assert!(
10815                !text.contains(&format!("# HELP {derived} ")),
10816                "a plugin family named `{derived}` must be skipped in:\n{text}"
10817            );
10818            assert!(
10819                !text.contains("4242"),
10820                "the plugin sample must not be emitted in:\n{text}"
10821            );
10822        }
10823    }
10824
10825    #[tokio::test]
10826    async fn metrics_endpoint_includes_app_section_for_facade_metrics() {
10827        use crate::metrics::testing::unique_name;
10828
10829        let counter_name = unique_name("facade_json_orders_total");
10830        crate::metrics::describe_counter(&counter_name, "Orders placed");
10831        crate::metrics::counter(&counter_name)
10832            .with_label("status", "paid")
10833            .increment(3);
10834
10835        let hist_name = unique_name("facade_json_work_seconds");
10836        crate::metrics::histogram(&hist_name).record(0.25);
10837        crate::metrics::histogram(&hist_name).record(0.75);
10838
10839        let text = actuator_body("/actuator/metrics").await;
10840        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
10841
10842        let app = json
10843            .get("app")
10844            .unwrap_or_else(|| panic!("missing top-level `app` key in:\n{text}"));
10845        let instruments = app
10846            .as_array()
10847            .unwrap_or_else(|| panic!("`app` must be an array in:\n{text}"));
10848
10849        let entry = instruments
10850            .iter()
10851            .find(|i| i["name"] == serde_json::json!(counter_name))
10852            .unwrap_or_else(|| panic!("missing {counter_name} in:\n{text}"));
10853        assert_eq!(entry["kind"], serde_json::json!("counter"));
10854        assert_eq!(entry["help"], serde_json::json!("Orders placed"));
10855        assert_eq!(entry["dropped_series"], serde_json::json!(0));
10856        assert_eq!(
10857            entry["series"][0]["labels"]["status"],
10858            serde_json::json!("paid")
10859        );
10860        assert_eq!(
10861            entry["series"][0]["value"]["value"].as_u64(),
10862            Some(3),
10863            "the JSON view must match the prometheus view"
10864        );
10865
10866        let hist = instruments
10867            .iter()
10868            .find(|i| i["name"] == serde_json::json!(hist_name))
10869            .unwrap_or_else(|| panic!("missing {hist_name} in:\n{text}"));
10870        assert_eq!(hist["kind"], serde_json::json!("histogram"));
10871        let value = &hist["series"][0]["value"];
10872        assert_eq!(value["count"].as_u64(), Some(2));
10873        assert!(
10874            (value["sum"].as_f64().unwrap() - 1.0).abs() < f64::EPSILON,
10875            "unexpected histogram sum in:\n{text}"
10876        );
10877        let buckets = value["buckets"]
10878            .as_array()
10879            .unwrap_or_else(|| panic!("missing histogram buckets in:\n{text}"));
10880        let last = buckets.last().unwrap();
10881        assert_eq!(last[0], serde_json::json!("+Inf"));
10882        assert_eq!(last[1].as_u64(), Some(2), "+Inf must equal count");
10883    }
10884
10885    #[tokio::test]
10886    async fn actuator_prometheus_disabled_hides_facade_metrics() {
10887        use crate::metrics::testing::unique_name;
10888
10889        // Recording always works; only exposure is gated. With the scrape
10890        // endpoint disabled there must be no route that leaks the facade.
10891        let name = unique_name("facade_gated_total");
10892        crate::metrics::counter(&name).increment(1);
10893
10894        let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
10895        let resp = app
10896            .oneshot(
10897                Request::builder()
10898                    .uri("/actuator/prometheus")
10899                    .body(Body::empty())
10900                    .unwrap(),
10901            )
10902            .await
10903            .unwrap();
10904        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10905    }
10906
10907    // ── Data capsules (issue #1811) ─────────────────────────────────────────
10908
10909    fn capsule_state(dir: &std::path::Path) -> TestActuatorState {
10910        use crate::gdpr::portability::{
10911            CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
10912        };
10913        let store = MemoryCapsuleStore::new().table(
10914            "users",
10915            vec![
10916                FieldSpec::new("id", "bigint"),
10917                FieldSpec::new("email", "text"),
10918            ],
10919        );
10920        store.insert(
10921            "users",
10922            serde_json::json!({"id": 7, "email": "ada@example.com"}),
10923        );
10924        let service = CapsuleService::new(
10925            vec![CapsuleModel::new("users", "id")],
10926            std::sync::Arc::new(store),
10927            CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
10928        )
10929        .with_dir(dir);
10930        let mut state = test_state();
10931        state.data_capsules = Some(service);
10932        state
10933    }
10934
10935    async fn post_json(
10936        state: TestActuatorState,
10937        sensitive: bool,
10938        path: &str,
10939        body: serde_json::Value,
10940    ) -> (StatusCode, serde_json::Value) {
10941        let resp = actuator_router(sensitive)
10942            .with_state(state)
10943            .oneshot(
10944                Request::builder()
10945                    .method("POST")
10946                    .uri(path)
10947                    .header("content-type", "application/json")
10948                    .body(Body::from(body.to_string()))
10949                    .unwrap(),
10950            )
10951            .await
10952            .unwrap();
10953        let status = resp.status();
10954        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
10955            .await
10956            .unwrap();
10957        (
10958            status,
10959            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
10960        )
10961    }
10962
10963    #[tokio::test]
10964    async fn actuator_capsules_are_not_mounted_when_not_sensitive() {
10965        let dir = tempfile::tempdir().unwrap();
10966        let (status, _) = post_json(
10967            capsule_state(dir.path()),
10968            false,
10969            "/actuator/capsules/export",
10970            serde_json::json!({"subject": "7"}),
10971        )
10972        .await;
10973        assert_eq!(status, StatusCode::NOT_FOUND);
10974    }
10975
10976    #[tokio::test]
10977    async fn actuator_capsules_answer_501_when_not_configured() {
10978        let (status, json) = post_json(
10979            test_state(),
10980            true,
10981            "/actuator/capsules/export",
10982            serde_json::json!({"subject": "7"}),
10983        )
10984        .await;
10985        assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{json}");
10986        assert_eq!(json["status"], "error");
10987    }
10988
10989    #[tokio::test]
10990    async fn actuator_capsule_export_verify_import() {
10991        let dir = tempfile::tempdir().unwrap();
10992        let (status, json) = post_json(
10993            capsule_state(dir.path()),
10994            true,
10995            "/actuator/capsules/export",
10996            serde_json::json!({"subject": "7"}),
10997        )
10998        .await;
10999        assert_eq!(status, StatusCode::OK, "{json}");
11000        assert_eq!(json["report"]["records"], 1);
11001        let name = json["capsule"].as_str().expect("capsule name").to_owned();
11002        assert!(dir.path().join(&name).join("viewer/index.html").is_file());
11003
11004        let (status, json) = post_json(
11005            capsule_state(dir.path()),
11006            true,
11007            "/actuator/capsules/verify",
11008            serde_json::json!({"capsule": name}),
11009        )
11010        .await;
11011        assert_eq!(status, StatusCode::OK, "{json}");
11012        assert_eq!(json["report"]["subject"], "7");
11013
11014        // The row is already in the store, so import conflicts.
11015        let (status, json) = post_json(
11016            capsule_state(dir.path()),
11017            true,
11018            "/actuator/capsules/import",
11019            serde_json::json!({"capsule": name}),
11020        )
11021        .await;
11022        assert_eq!(status, StatusCode::CONFLICT, "{json}");
11023    }
11024
11025    #[tokio::test]
11026    async fn actuator_capsule_import_into_an_empty_store_succeeds() {
11027        use crate::gdpr::portability::{
11028            CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
11029        };
11030        let dir = tempfile::tempdir().unwrap();
11031        let (_, json) = post_json(
11032            capsule_state(dir.path()),
11033            true,
11034            "/actuator/capsules/export",
11035            serde_json::json!({"subject": "7"}),
11036        )
11037        .await;
11038        let name = json["capsule"].as_str().unwrap().to_owned();
11039
11040        let empty = std::sync::Arc::new(MemoryCapsuleStore::new().table(
11041            "users",
11042            vec![
11043                FieldSpec::new("id", "bigint"),
11044                FieldSpec::new("email", "text"),
11045            ],
11046        ));
11047        let mut state = test_state();
11048        state.data_capsules = Some(
11049            CapsuleService::new(
11050                vec![CapsuleModel::new("users", "id")],
11051                empty.clone(),
11052                CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
11053            )
11054            .with_dir(dir.path()),
11055        );
11056        let (status, json) = post_json(
11057            state,
11058            true,
11059            "/actuator/capsules/import",
11060            serde_json::json!({"capsule": name}),
11061        )
11062        .await;
11063        assert_eq!(status, StatusCode::OK, "{json}");
11064        assert_eq!(json["summary"]["records"], 1);
11065        assert_eq!(json["summary"]["tables"][0]["table"], "users");
11066        assert_eq!(empty.rows("users").len(), 1);
11067    }
11068
11069    #[tokio::test]
11070    async fn actuator_capsule_export_rejects_an_empty_subject_with_400() {
11071        let dir = tempfile::tempdir().unwrap();
11072        let (status, json) = post_json(
11073            capsule_state(dir.path()),
11074            true,
11075            "/actuator/capsules/export",
11076            serde_json::json!({"subject": ""}),
11077        )
11078        .await;
11079        assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
11080    }
11081
11082    #[cfg(unix)]
11083    #[tokio::test]
11084    async fn actuator_capsule_verify_does_not_follow_a_symlinked_entry() {
11085        // A signed capsule outside the directory, linked in by name.
11086        let outside = tempfile::tempdir().unwrap();
11087        let (_, json) = post_json(
11088            capsule_state(outside.path()),
11089            true,
11090            "/actuator/capsules/export",
11091            serde_json::json!({"subject": "7"}),
11092        )
11093        .await;
11094        let name = json["capsule"].as_str().unwrap().to_owned();
11095        let dir = tempfile::tempdir().unwrap();
11096        std::os::unix::fs::symlink(outside.path().join(&name), dir.path().join("linked")).unwrap();
11097        for endpoint in ["verify", "import"] {
11098            let (status, json) = post_json(
11099                capsule_state(dir.path()),
11100                true,
11101                &format!("/actuator/capsules/{endpoint}"),
11102                serde_json::json!({"capsule": "linked"}),
11103            )
11104            .await;
11105            assert_eq!(status, StatusCode::NOT_FOUND, "{endpoint}: {json}");
11106        }
11107    }
11108
11109    #[tokio::test]
11110    async fn actuator_capsule_import_accepts_only_a_plain_name() {
11111        let dir = tempfile::tempdir().unwrap();
11112        for name in ["../etc", "/tmp/x", "a/b"] {
11113            let (status, json) = post_json(
11114                capsule_state(dir.path()),
11115                true,
11116                "/actuator/capsules/import",
11117                serde_json::json!({"capsule": name}),
11118            )
11119            .await;
11120            assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {json}");
11121        }
11122    }
11123
11124    #[tokio::test]
11125    async fn actuator_capsule_verify_answers_404_for_an_unknown_capsule() {
11126        let dir = tempfile::tempdir().unwrap();
11127        let (status, _) = post_json(
11128            capsule_state(dir.path()),
11129            true,
11130            "/actuator/capsules/verify",
11131            serde_json::json!({"capsule": "missing"}),
11132        )
11133        .await;
11134        assert_eq!(status, StatusCode::NOT_FOUND);
11135    }
11136
11137    #[tokio::test]
11138    async fn actuator_capsule_verify_reports_tampering_as_422() {
11139        let dir = tempfile::tempdir().unwrap();
11140        let (_, json) = post_json(
11141            capsule_state(dir.path()),
11142            true,
11143            "/actuator/capsules/export",
11144            serde_json::json!({"subject": "7"}),
11145        )
11146        .await;
11147        let name = json["capsule"].as_str().unwrap().to_owned();
11148        std::fs::write(dir.path().join(&name).join("records/users.json"), "[]").unwrap();
11149        let (status, json) = post_json(
11150            capsule_state(dir.path()),
11151            true,
11152            "/actuator/capsules/verify",
11153            serde_json::json!({"capsule": name}),
11154        )
11155        .await;
11156        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{json}");
11157    }
11158
11159    #[test]
11160    fn actuator_capsule_routes_are_listed_only_in_sensitive_mode() {
11161        for suffix in ["export", "import", "verify"] {
11162            let path = format!("/actuator/capsules/{suffix}");
11163            assert!(actuator_endpoint_paths("/actuator", true, false).contains(&path));
11164            assert!(!actuator_endpoint_paths("/actuator", false, false).contains(&path));
11165            assert!(actuator_mutating_routes("/actuator", true).contains(&("POST", path.clone())));
11166            assert!(!actuator_mutating_routes("/actuator", false).contains(&("POST", path)));
11167        }
11168    }
11169}
11170
11171#[cfg(test)]
11172mod health_indicator_tests {
11173    use super::*;
11174
11175    struct AlwaysUp;
11176    impl HealthIndicator for AlwaysUp {
11177        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11178            Box::pin(async { HealthCheckOutput::up() })
11179        }
11180    }
11181
11182    struct AlwaysDown;
11183    impl HealthIndicator for AlwaysDown {
11184        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11185            Box::pin(async { HealthCheckOutput::down() })
11186        }
11187    }
11188
11189    #[test]
11190    fn health_status_as_str_values() {
11191        assert_eq!(HealthStatus::Up.as_str(), "UP");
11192        assert_eq!(HealthStatus::Down.as_str(), "DOWN");
11193        assert_eq!(HealthStatus::OutOfService.as_str(), "OUT_OF_SERVICE");
11194        assert_eq!(HealthStatus::Unknown.as_str(), "UNKNOWN");
11195    }
11196
11197    #[test]
11198    fn health_status_is_healthy() {
11199        assert!(HealthStatus::Up.is_healthy());
11200        assert!(HealthStatus::Unknown.is_healthy());
11201        assert!(!HealthStatus::Down.is_healthy());
11202        assert!(!HealthStatus::OutOfService.is_healthy());
11203    }
11204
11205    #[test]
11206    fn aggregate_status_precedence() {
11207        assert_eq!(
11208            HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up]),
11209            HealthStatus::Up
11210        );
11211        assert_eq!(
11212            HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up, HealthStatus::Unknown]),
11213            HealthStatus::Unknown
11214        );
11215        assert_eq!(
11216            HealthIndicatorRegistry::aggregate_status(&[
11217                HealthStatus::Unknown,
11218                HealthStatus::OutOfService
11219            ]),
11220            HealthStatus::OutOfService
11221        );
11222        assert_eq!(
11223            HealthIndicatorRegistry::aggregate_status(&[
11224                HealthStatus::OutOfService,
11225                HealthStatus::Down
11226            ]),
11227            HealthStatus::Down
11228        );
11229        assert_eq!(
11230            HealthIndicatorRegistry::aggregate_status(&[]),
11231            HealthStatus::Up
11232        );
11233    }
11234
11235    #[tokio::test]
11236    async fn registry_run_all_collects_results() {
11237        let registry = HealthIndicatorRegistry::new();
11238        registry
11239            .register("svc_a", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11240            .unwrap();
11241        registry
11242            .register("svc_b", IndicatorGroup::HealthOnly, Arc::new(AlwaysDown))
11243            .unwrap();
11244
11245        let results = registry.run_all().await;
11246        assert!(
11247            results
11248                .iter()
11249                .any(|r| r.name == "svc_a" && r.output.status == HealthStatus::Up)
11250        );
11251        assert!(
11252            results
11253                .iter()
11254                .any(|r| r.name == "svc_b" && r.output.status == HealthStatus::Down)
11255        );
11256    }
11257
11258    #[tokio::test]
11259    async fn registry_run_readiness_filters_health_only() {
11260        let registry = HealthIndicatorRegistry::new();
11261        registry
11262            .register("probe_check", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11263            .unwrap();
11264        registry
11265            .register(
11266                "health_only",
11267                IndicatorGroup::HealthOnly,
11268                Arc::new(AlwaysDown),
11269            )
11270            .unwrap();
11271
11272        let results = registry.run_readiness().await;
11273        assert_eq!(results.len(), 1);
11274        assert_eq!(results[0].name, "probe_check");
11275    }
11276
11277    /// Counts checks. Each check waits `delay`.
11278    struct CountingIndicator {
11279        calls: std::sync::atomic::AtomicUsize,
11280        delay: std::time::Duration,
11281    }
11282
11283    impl CountingIndicator {
11284        fn new(delay: std::time::Duration) -> Arc<Self> {
11285            Arc::new(Self {
11286                calls: std::sync::atomic::AtomicUsize::new(0),
11287                delay,
11288            })
11289        }
11290
11291        fn calls(&self) -> usize {
11292            self.calls.load(std::sync::atomic::Ordering::SeqCst)
11293        }
11294    }
11295
11296    impl HealthIndicator for CountingIndicator {
11297        fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11298            Box::pin(async move {
11299                self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11300                tokio::time::sleep(self.delay).await;
11301                HealthCheckOutput::up()
11302            })
11303        }
11304    }
11305
11306    #[tokio::test(start_paused = true)]
11307    async fn concurrent_runs_check_each_indicator_once_per_ttl() {
11308        let registry = HealthIndicatorRegistry::new();
11309        registry.set_cache_ttl(std::time::Duration::from_secs(1));
11310        let indicator = CountingIndicator::new(std::time::Duration::from_millis(20));
11311        registry
11312            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11313            .unwrap();
11314
11315        let runs: Vec<_> = (0..32)
11316            .map(|i| {
11317                let registry = registry.clone();
11318                tokio::spawn(async move {
11319                    if i % 2 == 0 {
11320                        registry.run_readiness().await
11321                    } else {
11322                        registry.run_all().await
11323                    }
11324                })
11325            })
11326            .collect();
11327        for run in runs {
11328            let results = run.await.unwrap();
11329            let svc = results.iter().find(|r| r.name == "svc").unwrap();
11330            assert_eq!(svc.output.status, HealthStatus::Up);
11331        }
11332        assert_eq!(indicator.calls(), 1);
11333
11334        tokio::time::advance(std::time::Duration::from_millis(1_001)).await;
11335        registry.run_readiness().await;
11336        assert_eq!(indicator.calls(), 2);
11337    }
11338
11339    #[tokio::test(start_paused = true)]
11340    async fn new_registry_does_not_cache() {
11341        let registry = HealthIndicatorRegistry::new();
11342        let indicator = CountingIndicator::new(std::time::Duration::ZERO);
11343        registry
11344            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11345            .unwrap();
11346
11347        registry.run_readiness().await;
11348        registry.run_readiness().await;
11349
11350        assert_eq!(indicator.calls(), 2);
11351    }
11352
11353    #[tokio::test(start_paused = true)]
11354    async fn timed_out_result_is_cached_too() {
11355        let registry = HealthIndicatorRegistry::new();
11356        registry.set_cache_ttl(std::time::Duration::from_secs(1));
11357        let indicator = CountingIndicator::new(std::time::Duration::from_secs(3_600));
11358        registry
11359            .register("svc", IndicatorGroup::Readiness, indicator.clone())
11360            .unwrap();
11361
11362        let first = registry.run_readiness().await;
11363        let second = registry.run_readiness().await;
11364
11365        assert_eq!(first[0].output.status, HealthStatus::Unknown);
11366        assert_eq!(second[0].output.status, HealthStatus::Unknown);
11367        assert_eq!(indicator.calls(), 1);
11368    }
11369
11370    #[tokio::test]
11371    async fn timed_out_indicator_reports_unknown_with_flag() {
11372        struct SlowIndicator;
11373        impl HealthIndicator for SlowIndicator {
11374            fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11375                Box::pin(async {
11376                    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
11377                    HealthCheckOutput::up()
11378                })
11379            }
11380            fn timeout_ms(&self) -> u64 {
11381                5
11382            }
11383        }
11384        let registry = HealthIndicatorRegistry::new();
11385        registry
11386            .register("slow", IndicatorGroup::Readiness, Arc::new(SlowIndicator))
11387            .unwrap();
11388        let results = registry.run_all().await;
11389        let slow_res = results
11390            .iter()
11391            .find(|r| r.name == "slow")
11392            .expect("slow indicator not found");
11393        assert_eq!(slow_res.output.status, HealthStatus::Unknown);
11394        assert_eq!(
11395            slow_res.output.details.get("timed_out"),
11396            Some(&serde_json::Value::Bool(true))
11397        );
11398    }
11399
11400    #[tokio::test]
11401    #[allow(clippy::await_holding_lock)]
11402    async fn test_circuit_breakers_in_health_indicator_registry() {
11403        let _lock = crate::circuit_breaker::TEST_LOCK
11404            .lock()
11405            .unwrap_or_else(std::sync::PoisonError::into_inner);
11406        crate::circuit_breaker::global_registry().clear();
11407        let registry = HealthIndicatorRegistry::new();
11408        let breaker = crate::circuit_breaker::global_registry().get_or_create(
11409            "actuator_test_breaker",
11410            crate::circuit_breaker::CircuitBreakerPolicy {
11411                failure_ratio_threshold: 0.5,
11412                sample_window: std::time::Duration::from_secs(10),
11413                minimum_sample_count: 2,
11414                open_duration: std::time::Duration::from_secs(60),
11415                half_open_trial_count: 2,
11416                ..crate::circuit_breaker::CircuitBreakerPolicy::default()
11417            },
11418        );
11419
11420        let results = registry.run_all().await;
11421        let found = results
11422            .iter()
11423            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11424        assert!(found.is_some(), "Should find circuit breaker in run_all");
11425        let result = found.unwrap();
11426        assert_eq!(result.group, IndicatorGroup::HealthOnly);
11427        assert_eq!(result.output.status, HealthStatus::Up);
11428        assert_eq!(result.output.details.get("state").unwrap(), "CLOSED");
11429        assert_eq!(
11430            result.output.details.get("slow_call_ratio").unwrap(),
11431            &serde_json::json!(0.0)
11432        );
11433
11434        breaker.after_call(false);
11435        breaker.after_call(false);
11436        assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
11437
11438        let results = registry.run_all().await;
11439        let found = results
11440            .iter()
11441            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11442        assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11443        assert_eq!(found.unwrap().output.details.get("state").unwrap(), "OPEN");
11444
11445        // Transition to HalfOpen manually to check status
11446        {
11447            let mut inner = breaker.inner.lock().unwrap();
11448            inner.state = crate::circuit_breaker::CircuitState::HalfOpen;
11449            inner.half_open_in_flight = 0;
11450            inner.half_open_successes = 0;
11451            inner.half_open_failures = 0;
11452        }
11453        assert_eq!(
11454            breaker.state(),
11455            crate::circuit_breaker::CircuitState::HalfOpen
11456        );
11457
11458        let results = registry.run_all().await;
11459        let found = results
11460            .iter()
11461            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11462        assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11463        assert_eq!(
11464            found.unwrap().output.details.get("state").unwrap(),
11465            "HALF_OPEN"
11466        );
11467
11468        let readiness_results = registry.run_readiness().await;
11469        let found_readiness = readiness_results
11470            .iter()
11471            .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11472        assert!(
11473            found_readiness.is_none(),
11474            "Should NOT find circuit breaker in run_readiness"
11475        );
11476        crate::circuit_breaker::global_registry().clear();
11477    }
11478}
11479
11480#[cfg(test)]
11481mod havoc_proptest {
11482    use super::*;
11483    use proptest::prelude::*;
11484
11485    proptest! {
11486        #![proptest_config(ProptestConfig::with_cases(1))]
11487        #[test]
11488        fn log_levels_memory_exhaustion(names in proptest::collection::vec(".*", 5000)) {
11489            let levels = LogLevels::new("info");
11490            for name in names {
11491                let _ = levels.set_logger_level(&name, "debug");
11492            }
11493            assert!(levels.logger_overrides().len() <= 1000, "Memory leak: unbounded loggers inserted");
11494        }
11495    }
11496}
11497
11498// ── Nova: Actuator HTMX Dashboard UI ──────────────────────────
11499
11500#[cfg(all(feature = "maud", feature = "htmx"))]
11501async fn ui_dashboard() -> impl IntoResponse {
11502    let html = maud::html! {
11503        (maud::DOCTYPE)
11504        html lang="en" {
11505            head {
11506                meta charset="utf-8";
11507                meta name="viewport" content="width=device-width, initial-scale=1";
11508                title { "Autumn Actuator Dashboard" }
11509                script src="/static/js/htmx.min.js" {}
11510                style {
11511                    (crate::ui::tokens::TOKENS_CSS)
11512                    "body { font-family: var(--font-family); background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }"
11513                    "h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }"
11514                    ".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }"
11515                    ".card { background: var(--surface); padding: 1.5rem; border-radius: var(--radius); box-shadow: var(--shadow); }"
11516                    ".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; }"
11517                    ".stat { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }"
11518                    ".stat-label { color: var(--text-muted); }"
11519                    ".stat-value { font-weight: 500; }"
11520                    ".task-item { border: 1px solid var(--border); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 0.75rem; }"
11521                    ".task-name { font-weight: 600; display: block; margin-bottom: 0.25rem; }"
11522                    ".task-meta { font-size: 0.875rem; color: var(--text-muted); }"
11523                    ".badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }"
11524                    ".badge-green { background: #dcfce7; color: #166534; }"
11525                    ".badge-gray { background: #f3f4f6; color: #374151; }"
11526                    ".badge-red { background: #fee2e2; color: #991b1b; }"
11527                }
11528            }
11529            body {
11530                h1 { "🍂 Autumn Actuator Dashboard" }
11531                div class="grid" {
11532                    div class="card" hx-get="ui/metrics" hx-trigger="load, every 2s" {
11533                        "Loading metrics..."
11534                    }
11535                    div class="card" hx-get="ui/tasks" hx-trigger="load, every 2s" {
11536                        "Loading tasks..."
11537                    }
11538                }
11539            }
11540        }
11541    };
11542    (
11543        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11544        html.into_string(),
11545    )
11546}
11547
11548#[cfg(not(all(feature = "maud", feature = "htmx")))]
11549async fn ui_dashboard() -> impl IntoResponse {
11550    (
11551        StatusCode::NOT_IMPLEMENTED,
11552        "Maud feature is required for the UI dashboard",
11553    )
11554}
11555
11556#[cfg(all(feature = "maud", feature = "htmx"))]
11557async fn ui_metrics<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11558    let metrics = state.metrics().snapshot();
11559    let uptime = state.uptime_display();
11560
11561    let html = maud::html! {
11562        h2 { "System Metrics" }
11563        div class="stat" {
11564            span class="stat-label" { "Uptime" }
11565            span class="stat-value" { (uptime) }
11566        }
11567        div class="stat" {
11568            span class="stat-label" { "Total Requests" }
11569            span class="stat-value" { (metrics.http.requests_total) }
11570        }
11571        div class="stat" {
11572            span class="stat-label" { "Active Requests" }
11573            span class="stat-value" { (metrics.http.requests_active) }
11574        }
11575        div class="stat" {
11576            span class="stat-label" { "P95 Latency" }
11577            span class="stat-value" { (metrics.http.latency_ms.p95) " ms" }
11578        }
11579        div class="stat" {
11580            span class="stat-label" { "P99 Latency" }
11581            span class="stat-value" { (metrics.http.latency_ms.p99) " ms" }
11582        }
11583    };
11584    (
11585        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11586        html.into_string(),
11587    )
11588}
11589
11590#[cfg(not(all(feature = "maud", feature = "htmx")))]
11591async fn ui_metrics<S: ProvideActuatorState>() -> impl IntoResponse {
11592    (
11593        StatusCode::NOT_IMPLEMENTED,
11594        "Maud feature is required for the UI dashboard",
11595    )
11596}
11597
11598#[cfg(all(feature = "maud", feature = "htmx"))]
11599async fn ui_tasks<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11600    let tasks = state.task_registry().snapshot();
11601
11602    let html = maud::html! {
11603        h2 { "Background Tasks" }
11604        @if tasks.is_empty() {
11605            p class="stat-label" { "No tasks registered." }
11606        } @else {
11607            @for (name, task) in tasks.iter() {
11608                div class="task-item" {
11609                    span class="task-name" { (name) }
11610                    div class="task-meta" {
11611                        @if task.status == "running" {
11612                            span class="badge badge-green" { "Running" }
11613                        } @else {
11614                            span class="badge badge-gray" { "Idle" }
11615                        }
11616                        " "
11617                        "Runs: " (task.total_runs)
11618                        @if task.total_failures > 0 {
11619                            " " span class="badge badge-red" { "Failures: " (task.total_failures) }
11620                        }
11621                    }
11622                }
11623            }
11624        }
11625    };
11626    (
11627        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11628        html.into_string(),
11629    )
11630}
11631
11632#[cfg(not(all(feature = "maud", feature = "htmx")))]
11633async fn ui_tasks<S: ProvideActuatorState>() -> impl IntoResponse {
11634    (
11635        StatusCode::NOT_IMPLEMENTED,
11636        "Maud feature is required for the UI dashboard",
11637    )
11638}