autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! Observable replication state and the health indicator that publishes it
//! (issue #1628, AC #2 and phase 3).
//!
//! Replication lag is only a durability guarantee if an operator can *see* it,
//! and a replica is only trustworthy if a failure to ship — or a failure to
//! restore what was shipped — is loud. Both come from one shared handle:
//!
//! * the replication thread writes into [`ReplicationStatus`] on every tick;
//! * [`ReplicationHealthIndicator`] reads it, so lag, the current generation, the
//!   last verification and the last error land in `/actuator/health`;
//! * an indicator that stays non-healthy past the alerter's grace period is
//!   escalated onto every channel configured under #1610 — the existing
//!   health-indicator alert path, with its dedup and its recovery notice, rather
//!   than a bespoke alert of our own.

// autumn-determinism-gate: production code in this module must read time and
// mint identifiers through the framework's injected seams (ClockSource /
// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
// (issue #1797). Justify exceptions with
// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
// autumn-panic-gate: durability-critical module — production code path must be
// panic-free. See CONTRIBUTING.md "Request-path panic gate". Justify exceptions
// with #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::panic,
        clippy::unreachable,
        clippy::todo,
        clippy::unimplemented,
        clippy::indexing_slicing,
        clippy::string_slice,
        clippy::arithmetic_side_effects,
    )
)]

use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;

use chrono::{DateTime, Utc};

use crate::actuator::{HealthCheckOutput, HealthIndicator, HealthStatus, IndicatorGroup};

/// The name the replication health indicator registers under.
pub const INDICATOR_NAME: &str = "sqlite-replication";

/// A point-in-time view of the replicator, cheap to clone and to serialize.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReplicationSnapshot {
    /// Credential-free description of the destination.
    pub destination: String,
    /// The generation currently being shipped, once one has been opened.
    pub generation: Option<String>,
    /// Last tick that completed without an error — the point the replica is
    /// known to be current to.
    pub last_success_at: Option<DateTime<Utc>>,
    /// Last tick that actually shipped bytes.
    pub last_shipped_at: Option<DateTime<Utc>>,
    /// Segments shipped since the process started.
    pub segments_shipped: u64,
    /// Base snapshots taken since the process started.
    pub snapshots_taken: u64,
    /// Uncompressed WAL bytes shipped since the process started.
    pub bytes_shipped: u64,
    /// WAL bytes written but not yet shipped, as of the last tick.
    pub pending_bytes: u64,
    /// The most recent failure, if the replicator is not currently healthy.
    pub last_error: Option<String>,
    /// When that failure happened.
    pub last_error_at: Option<DateTime<Utc>>,
    /// Consecutive failing ticks. Reset by any successful tick.
    pub consecutive_failures: u32,
    /// Last time a **real restore** of this replica succeeded.
    pub last_verified_at: Option<DateTime<Utc>>,
    /// Why the last verification failed, when it did.
    pub last_verify_error: Option<String>,
}

impl ReplicationSnapshot {
    /// How far behind the replica is as of `now`: the time since the last tick
    /// that completed cleanly. `None` before the first successful tick.
    #[must_use]
    pub fn lag(&self, now: DateTime<Utc>) -> Option<Duration> {
        let last = self.last_success_at?;
        // A clock that moved backwards yields a negative span, which is not a
        // *stale* replica — report zero lag rather than `None`, which the health
        // indicator would read as "never shipped".
        Some(
            now.signed_duration_since(last)
                .to_std()
                .unwrap_or(Duration::ZERO),
        )
    }
}

/// Shared, mutable replication state.
#[derive(Debug)]
pub struct ReplicationStatus {
    inner: Mutex<ReplicationSnapshot>,
}

impl ReplicationStatus {
    /// A status for a replicator shipping to `destination`.
    #[must_use]
    pub fn new(destination: impl Into<String>) -> Self {
        Self {
            inner: Mutex::new(ReplicationSnapshot {
                destination: destination.into(),
                ..ReplicationSnapshot::default()
            }),
        }
    }

    fn with<R>(&self, f: impl FnOnce(&mut ReplicationSnapshot) -> R) -> R {
        let mut guard = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
        f(&mut guard)
    }

    /// Read the current state.
    #[must_use]
    pub fn snapshot(&self) -> ReplicationSnapshot {
        self.with(|s| s.clone())
    }

    /// Record that a base snapshot opened `generation`.
    pub fn record_generation(&self, generation: &str, at: DateTime<Utc>) {
        self.with(|s| {
            s.generation = Some(generation.to_owned());
            s.snapshots_taken = s.snapshots_taken.saturating_add(1);
            s.last_success_at = Some(at);
            s.last_error = None;
            s.last_error_at = None;
            s.consecutive_failures = 0;
        });
    }

    /// Record a segment landing on the destination.
    pub fn record_segment(&self, bytes: u64, at: DateTime<Utc>) {
        self.with(|s| {
            s.segments_shipped = s.segments_shipped.saturating_add(1);
            s.bytes_shipped = s.bytes_shipped.saturating_add(bytes);
            s.last_shipped_at = Some(at);
        });
    }

    /// Record a tick that succeeded but left committed data un-shipped.
    ///
    /// Deliberately does not touch `last_success_at`: lag is measured from the
    /// last moment the destination was actually caught up, so a run of slow
    /// uploads that never drains the tail keeps lag growing instead of resetting
    /// it on every tick.
    pub fn record_tick_behind(&self, pending_bytes: u64) {
        self.with(|s| s.pending_bytes = pending_bytes);
    }

    /// Record a tick that completed with nothing left un-shipped.
    pub fn record_tick_ok(&self, pending_bytes: u64, at: DateTime<Utc>) {
        self.with(|s| {
            s.pending_bytes = pending_bytes;
            s.last_success_at = Some(at);
            s.last_error = None;
            s.last_error_at = None;
            s.consecutive_failures = 0;
        });
    }

    /// Record a failed tick. Does **not** advance `last_success_at`, so lag keeps
    /// growing for as long as shipping is broken.
    pub fn record_tick_error(&self, error: impl Into<String>, at: DateTime<Utc>) {
        self.with(|s| {
            s.last_error = Some(error.into());
            s.last_error_at = Some(at);
            s.consecutive_failures = s.consecutive_failures.saturating_add(1);
        });
    }

    /// Record the outcome of a periodic restore verification.
    pub fn record_verification(&self, result: Result<(), String>, at: DateTime<Utc>) {
        self.with(|s| match result {
            Ok(()) => {
                s.last_verified_at = Some(at);
                s.last_verify_error = None;
            }
            Err(detail) => s.last_verify_error = Some(detail),
        });
    }
}

/// Thresholds beyond which the replicator is considered unhealthy.
#[derive(Debug, Clone, Copy)]
pub struct HealthThresholds {
    /// Lag beyond which the indicator reports `Down`. Derived from the
    /// configured RPO.
    pub lag_alert_after: Duration,
    /// How long after startup the replicator is allowed to have shipped nothing
    /// before that counts as a failure.
    pub startup_grace: Duration,
}

/// Publishes [`ReplicationStatus`] as an `/actuator/health` indicator.
///
/// Registered in the `HealthOnly` group: replication falling behind is an
/// operator emergency, but it does not make the process unable to serve
/// requests, so it must not pull the pod out of the load balancer via `/ready`.
#[derive(Debug)]
pub struct ReplicationHealthIndicator {
    status: Arc<ReplicationStatus>,
    thresholds: HealthThresholds,
    started_at: DateTime<Utc>,
}

impl ReplicationHealthIndicator {
    /// Build an indicator over `status`.
    #[must_use]
    pub const fn new(
        status: Arc<ReplicationStatus>,
        thresholds: HealthThresholds,
        started_at: DateTime<Utc>,
    ) -> Self {
        Self {
            status,
            thresholds,
            started_at,
        }
    }

    /// Evaluate `snapshot` against the thresholds, as of `now`.
    ///
    /// Pure, so the decision table is unit-testable without a clock or a running
    /// replicator.
    #[must_use]
    pub fn evaluate(
        &self,
        snapshot: &ReplicationSnapshot,
        now: DateTime<Utc>,
    ) -> HealthCheckOutput {
        let mut details: HashMap<String, serde_json::Value> = HashMap::new();
        details.insert(
            "destination".to_owned(),
            serde_json::Value::String(snapshot.destination.clone()),
        );
        if let Some(generation) = &snapshot.generation {
            details.insert(
                "generation".to_owned(),
                serde_json::Value::String(generation.clone()),
            );
        }
        details.insert(
            "segments_shipped".to_owned(),
            serde_json::Value::from(snapshot.segments_shipped),
        );
        details.insert(
            "pending_bytes".to_owned(),
            serde_json::Value::from(snapshot.pending_bytes),
        );
        // Named for what it is. This is `lag_alert_after` — the threshold at
        // which lag turns this check unhealthy, which `replication::build` sets
        // to a multiple of the RPO. Publishing it as `rpo_seconds` reported 30
        // for the default 10-second objective, telling operators and their
        // monitoring the wrong number for the one thing this check exists to
        // guard. (`autumn db replica status` reports the configured RPO itself.)
        details.insert(
            "lag_alert_after_seconds".to_owned(),
            serde_json::Value::from(self.thresholds.lag_alert_after.as_secs()),
        );
        if let Some(at) = snapshot.last_verified_at {
            details.insert(
                "last_verified_at".to_owned(),
                serde_json::Value::String(at.to_rfc3339()),
            );
        }

        let lag = snapshot.lag(now).or_else(|| {
            // Before the first successful tick, measure from process start so a
            // replicator that never got going still trips the threshold.
            now.signed_duration_since(self.started_at).to_std().ok()
        });
        if let Some(lag) = lag {
            details.insert(
                "lag_seconds".to_owned(),
                serde_json::Value::from(lag.as_secs()),
            );
        }

        // A verification failure means the bytes offsite are not restorable —
        // the single most important thing this indicator can say.
        if let Some(detail) = &snapshot.last_verify_error {
            details.insert(
                "verification_error".to_owned(),
                serde_json::Value::String(detail.clone()),
            );
            return HealthCheckOutput {
                status: HealthStatus::Down,
                details,
            };
        }
        if let Some(error) = &snapshot.last_error {
            details.insert("error".to_owned(), serde_json::Value::String(error.clone()));
        }

        let grace = if snapshot.last_success_at.is_some() {
            Duration::ZERO
        } else {
            self.thresholds.startup_grace
        };
        let over_lag =
            lag.is_some_and(|lag| lag > self.thresholds.lag_alert_after.saturating_add(grace));
        if over_lag {
            HealthCheckOutput {
                status: HealthStatus::Down,
                details,
            }
        } else {
            HealthCheckOutput {
                status: HealthStatus::Up,
                details,
            }
        }
    }
}

impl HealthIndicator for ReplicationHealthIndicator {
    fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
        Box::pin(async move {
            let snapshot = self.status.snapshot();
            self.evaluate(&snapshot, crate::time::ambient_now())
        })
    }

    fn group(&self) -> IndicatorGroup {
        IndicatorGroup::HealthOnly
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn indicator(
        status: &Arc<ReplicationStatus>,
        started_at: DateTime<Utc>,
    ) -> ReplicationHealthIndicator {
        ReplicationHealthIndicator::new(
            Arc::clone(status),
            HealthThresholds {
                lag_alert_after: Duration::from_secs(30),
                startup_grace: Duration::from_secs(60),
            },
            started_at,
        )
    }

    fn at(secs: i64) -> DateTime<Utc> {
        DateTime::from_timestamp(1_700_000_000 + secs, 0).expect("timestamp")
    }

    #[test]
    fn a_freshly_started_replicator_is_up_during_the_startup_grace() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        let health = indicator(&status, at(0));
        let output = health.evaluate(&status.snapshot(), at(10));
        assert_eq!(output.status, HealthStatus::Up);
        assert_eq!(
            output.details.get("destination").and_then(|v| v.as_str()),
            Some("file:///replicas")
        );
    }

    #[test]
    fn a_replicator_that_never_ships_goes_down_after_the_grace() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        let health = indicator(&status, at(0));
        assert_eq!(
            health.evaluate(&status.snapshot(), at(200)).status,
            HealthStatus::Down
        );
    }

    #[test]
    fn lag_past_the_threshold_is_down_and_recovers_on_the_next_success() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        let health = indicator(&status, at(0));
        status.record_tick_ok(0, at(10));

        assert_eq!(
            health.evaluate(&status.snapshot(), at(20)).status,
            HealthStatus::Up
        );
        let down = health.evaluate(&status.snapshot(), at(100));
        assert_eq!(down.status, HealthStatus::Down);
        assert_eq!(
            down.details
                .get("lag_seconds")
                .and_then(serde_json::Value::as_u64),
            Some(90)
        );

        status.record_tick_ok(0, at(100));
        assert_eq!(
            health.evaluate(&status.snapshot(), at(105)).status,
            HealthStatus::Up
        );
    }

    #[test]
    fn a_failing_tick_does_not_advance_the_replication_point() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        status.record_tick_ok(0, at(10));
        status.record_tick_error("endpoint unreachable", at(20));
        status.record_tick_error("endpoint unreachable", at(25));

        let snapshot = status.snapshot();
        assert_eq!(snapshot.last_success_at, Some(at(10)));
        assert_eq!(snapshot.consecutive_failures, 2);
        assert_eq!(snapshot.lag(at(40)), Some(Duration::from_secs(30)));

        let health = indicator(&status, at(0));
        let output = health.evaluate(&snapshot, at(60));
        assert_eq!(output.status, HealthStatus::Down);
        assert_eq!(
            output.details.get("error").and_then(|v| v.as_str()),
            Some("endpoint unreachable")
        );
    }

    #[test]
    fn a_failed_verification_is_down_even_when_shipping_is_current() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        let health = indicator(&status, at(0));
        status.record_tick_ok(0, at(100));
        status.record_verification(Err("integrity check failed".to_owned()), at(100));

        let output = health.evaluate(&status.snapshot(), at(101));
        assert_eq!(
            output.status,
            HealthStatus::Down,
            "uploaded is not the same as restorable"
        );
        assert_eq!(
            output
                .details
                .get("verification_error")
                .and_then(|v| v.as_str()),
            Some("integrity check failed")
        );

        status.record_tick_ok(0, at(200));
        status.record_verification(Ok(()), at(200));
        let recovered = health.evaluate(&status.snapshot(), at(201));
        assert_eq!(recovered.status, HealthStatus::Up);
        assert!(recovered.details.contains_key("last_verified_at"));
    }

    #[test]
    fn counters_accumulate_across_ticks() {
        let status = Arc::new(ReplicationStatus::new("s3://bucket"));
        status.record_generation("0000000001000-0000000000000001", at(1));
        status.record_segment(4_120, at(2));
        status.record_segment(8_240, at(3));
        let snapshot = status.snapshot();
        assert_eq!(snapshot.snapshots_taken, 1);
        assert_eq!(snapshot.segments_shipped, 2);
        assert_eq!(snapshot.bytes_shipped, 12_360);
        assert_eq!(snapshot.last_shipped_at, Some(at(3)));
        assert_eq!(
            snapshot.generation.as_deref(),
            Some("0000000001000-0000000000000001")
        );
    }

    #[test]
    fn the_indicator_is_health_only_so_it_never_pulls_a_replica_out_of_service() {
        let status = Arc::new(ReplicationStatus::new("file:///replicas"));
        assert_eq!(
            indicator(&status, at(0)).group(),
            IndicatorGroup::HealthOnly
        );
    }
}