Skip to main content

gwk_kernel/
recover.rs

1//! Recovery: what a restart is allowed to CLAIM about the projections.
2//!
3//! The projections are written by [`apply_event`] inside the very transaction
4//! that appends the events, so a committed log and its projections cannot
5//! disagree by construction. Recovery exists for the cases where something
6//! outside that transaction went wrong — a restore that brought back a log
7//! without its projections, storage corruption, a hand-edited row — and its job
8//! is to say which of those it can rule out, honestly, rather than to assert
9//! more than it checked.
10//!
11//! # A checkpoint cannot be restored, and that is the schema's decision
12//!
13//! The contract schema forbids it, twice, with `ENABLE ALWAYS` triggers that no
14//! privilege and no `session_replication_role` can step around:
15//!
16//! * `<table>_born_initial` — a row must be born in its INITIAL state at
17//!   version 1. A checkpoint's rows are at whatever state they reached, so
18//!   loading them raises.
19//! * `<table>_no_truncate` — the statement-level partner of the delete guard,
20//!   there precisely to stop a TRUNCATE-then-reinsert walk-around.
21//!
22//! So a checkpoint is a VERIFICATION artifact, not a recovery shortcut: it
23//! proves what the projections hashed to at one sequence, and the only way to
24//! rebuild them is to replay the log from the beginning through the same
25//! `apply_event` that wrote them. This is a narrower job than the plan first
26//! assumed, and the narrowing is load-bearing — the guards are what make a
27//! projection row unforgeable by anything but the log, and buying a faster
28//! restart with a hole in them would be a bad trade.
29//!
30//! # What each verdict is worth
31//!
32//! [`Verdict::Verified`] is the strong one and needs a checkpoint AT the
33//! watermark, which a clean shutdown produces by taking one last snapshot.
34//! After a crash the newest checkpoint is behind the watermark and there is no
35//! way to reconstruct the expected hash without replaying, so recovery returns
36//! [`Verdict::Unverified`] and says so instead of implying a check it did not
37//! run. That is not a degraded kernel — the tail is still transactionally
38//! coupled to its events — it is an honest statement about what was proved.
39
40use gwk_domain::blob::{BLOB_CHUNK_BYTES, BlobAddress};
41use gwk_domain::checkpoint::{CHECKPOINT_SCHEMA_VERSION, Checkpoint};
42use gwk_domain::fsm::{AttemptState, StateMachine};
43use gwk_domain::ids::{ByteCount, Seq};
44use gwk_domain::port::BlobStore;
45use gwk_domain::protocol::ProjectionRecord;
46use sqlx::{PgConnection, PgPool, Row};
47
48use crate::blob::store::PgBlobStore;
49use crate::checkpoint::{RECORDS_MEDIA_TYPE, checkpoints, derived_records, projection_hash};
50use crate::epoch::{GENESIS_EVENT_TYPE, KERNEL_AGGREGATE};
51use crate::numeric::from_numeric_text;
52use crate::project::{Refusal, apply_event, wire_str};
53use crate::store::{PgEventStore, read_page};
54
55/// Events per replay page.
56///
57/// Small enough that a 100,000-event replay never holds more than a page of
58/// envelopes at once, large enough that the round trip is not the cost.
59const REPLAY_PAGE: usize = 1_000;
60
61/// What recovery was able to prove.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Verdict {
64    /// A valid checkpoint sat exactly at the watermark and the live
65    /// projections hashed to its recorded hash. The strongest statement
66    /// available, and what a clean shutdown sets up.
67    Verified { anchor: Seq },
68    /// The projections were empty and the log was replayed into them. The
69    /// state was BUILT here, so there is nothing to compare it against beyond
70    /// the checkpoint chain, which is checked when one lands at the watermark.
71    Replayed { events: u64 },
72    /// Nothing was proved, and the reason is named. The projections may be
73    /// perfectly correct — they usually are — but this run did not establish
74    /// it.
75    Unverified { reason: String },
76    /// The live projections did NOT hash to what the log says they should.
77    /// Readiness must be refused: serving is the one thing that must not
78    /// happen next.
79    Diverged { expected: String, found: String },
80}
81
82/// What a restart found, and what it is entitled to claim.
83#[derive(Debug, Clone)]
84pub struct RecoveryReport {
85    pub watermark: Option<Seq>,
86    /// The live projections' hash, always computed — it is the cheap half of
87    /// every verdict and the thing an operator will want in a bug report.
88    pub live_hash: String,
89    pub verdict: Verdict,
90    /// Checkpoints the ladder walked PAST, each with why it was rejected.
91    /// Surfaced rather than swallowed: a checkpoint failing validation is a
92    /// storage problem that will keep happening, and silence lets it.
93    pub rejected: Vec<(Seq, String)>,
94    /// Attempts a restart left in a state the FSM says can end in `unknown`.
95    ///
96    /// Reported, never acted on. See [`PgEventStore::recover`].
97    pub uncertain: Vec<String>,
98}
99
100impl RecoveryReport {
101    /// Whether the kernel may serve. Only a proven divergence blocks it —
102    /// "unverified" is a statement about this run, not an accusation.
103    pub fn ready(&self) -> bool {
104        !matches!(self.verdict, Verdict::Diverged { .. })
105    }
106}
107
108/// The result of an operator-directed rebuild into a scratch database.
109///
110/// It never swaps anything. Replacing the live projections with a rebuilt set
111/// is an operator act with its own downtime and its own blast radius, and a
112/// function that did it as a side effect of a comparison would be a trap.
113#[derive(Debug, Clone)]
114pub struct RebuildReport {
115    pub through_sequence: Option<Seq>,
116    pub live_hash: String,
117    pub rebuilt_hash: String,
118    pub agrees: bool,
119}
120
121/// One replay's outputs, all read under a single snapshot of the source log.
122struct Replayed {
123    events: u64,
124    watermark: Option<Seq>,
125    live_hash: String,
126    rebuilt_hash: String,
127}
128
129impl PgEventStore {
130    /// Establish what a restart can prove about the projections.
131    ///
132    /// Writes nothing except on the cold path, where the projections are empty
133    /// and the log is replayed into them through `apply_event` — the same
134    /// writer that would have filled them originally.
135    ///
136    /// It never appends an event. An attempt that was running when the kernel
137    /// died has an outcome the kernel did not observe, and `unknown` is a
138    /// CLAIM about that outcome; minting one here would put a fact into the log
139    /// that nothing witnessed, and would bury an attempt whose engine is in
140    /// fact still alive. They are reported instead, and the transition stays an
141    /// ordinary command with an actor behind it.
142    pub async fn recover(&self) -> Result<RecoveryReport, Refusal> {
143        let mut read = self
144            .pool()
145            .begin()
146            .await
147            .map_err(|e| Refusal::storage(format!("begin recovery read: {e}")))?;
148        // One snapshot for the watermark, the live hash and the checkpoint
149        // chain: a verdict assembled from three different moments would be a
150        // verdict about a database that never existed.
151        sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
152            .execute(&mut *read)
153            .await
154            .map_err(|e| Refusal::storage(format!("pin the recovery snapshot: {e}")))?;
155
156        let watermark = watermark_of(&mut read).await?;
157        let live = derived_records(&mut read).await?;
158        let live_hash = projection_hash(&live);
159        let (anchor, rejected) = newest_valid(&mut read, self.blobs(), watermark).await?;
160        let cold = live.is_empty();
161        read.rollback()
162            .await
163            .map_err(|e| Refusal::storage(format!("close the recovery read: {e}")))?;
164
165        let verdict = match (watermark, cold) {
166            // Nothing has ever been appended. Projections that are also empty
167            // agree with that trivially; projections that are NOT are rows with
168            // no log behind them, which is exactly the forgery the guards exist
169            // to prevent and must not be served.
170            (None, true) => Verdict::Unverified {
171                reason: "the log is empty".to_owned(),
172            },
173            (None, false) => Verdict::Diverged {
174                expected: projection_hash(&[]),
175                found: live_hash.clone(),
176            },
177            // Cold: a log with no projections. Replay is the ONLY way to build
178            // them, checkpoint or not.
179            (Some(_), true) => {
180                let built = self.replay_into_live().await?;
181                match anchor
182                    .as_ref()
183                    .filter(|cp| Some(cp.through_sequence) == built.watermark)
184                {
185                    Some(cp) if cp.projection_hash != built.rebuilt_hash => Verdict::Diverged {
186                        expected: cp.projection_hash.clone(),
187                        found: built.rebuilt_hash,
188                    },
189                    _ => Verdict::Replayed {
190                        events: built.events,
191                    },
192                }
193            }
194            (Some(mark), false) => match &anchor {
195                Some(cp) if cp.through_sequence == mark => {
196                    if cp.projection_hash == live_hash {
197                        Verdict::Verified {
198                            anchor: cp.through_sequence,
199                        }
200                    } else {
201                        Verdict::Diverged {
202                            expected: cp.projection_hash.clone(),
203                            found: live_hash.clone(),
204                        }
205                    }
206                }
207                Some(cp) => Verdict::Unverified {
208                    reason: format!(
209                        "the newest valid checkpoint is at {}, and the log runs to {} — \
210                         the projections cannot be re-derived in place to compare",
211                        cp.through_sequence.value(),
212                        mark.value()
213                    ),
214                },
215                None if self.blobs().is_none() => Verdict::Unverified {
216                    reason: "no blob store is attached, so no checkpoint can be read".to_owned(),
217                },
218                None => Verdict::Unverified {
219                    reason: "no valid checkpoint".to_owned(),
220                },
221            },
222        };
223
224        // Read last: on the cold path the replay above is what put the attempts
225        // there at all.
226        let mut conn = self
227            .pool()
228            .acquire()
229            .await
230            .map_err(|e| Refusal::storage(format!("acquire: {e}")))?;
231        let uncertain = uncertain_attempts(&mut conn).await?;
232
233        Ok(RecoveryReport {
234            watermark,
235            live_hash,
236            verdict,
237            rejected,
238            uncertain,
239        })
240    }
241
242    /// Replay the whole log into an EMPTY scratch database and report whether
243    /// it agrees with the live projections.
244    ///
245    /// This is the only full verification available once the log has moved past
246    /// its newest checkpoint, and the only place a rebuild can happen at all,
247    /// since the live tables refuse to be reset. It reads the live log, writes
248    /// only to `scratch`, and leaves the decision to replace anything with the
249    /// operator.
250    pub async fn rebuild_into(&self, scratch: &PgPool) -> Result<RebuildReport, Refusal> {
251        let mut tx = scratch
252            .begin()
253            .await
254            .map_err(|e| Refusal::storage(format!("begin scratch rebuild: {e}")))?;
255        // A rebuild INSERTs; against a scratch that already holds rows it would
256        // either collide or, worse, quietly build a mixture and compare that.
257        if !derived_records(&mut tx).await?.is_empty() {
258            return Err(Refusal::validation(
259                "the scratch database already holds projections — a rebuild needs an empty one",
260            ));
261        }
262        let built = replay(self.pool(), &mut tx).await?;
263        // Committed so the operator can inspect the rebuilt rows, diff them
264        // against live, and decide. Nothing here acts on the answer.
265        tx.commit()
266            .await
267            .map_err(|e| Refusal::storage(format!("commit scratch rebuild: {e}")))?;
268        Ok(RebuildReport {
269            through_sequence: built.watermark,
270            live_hash: built.live_hash.clone(),
271            agrees: built.live_hash == built.rebuilt_hash,
272            rebuilt_hash: built.rebuilt_hash,
273        })
274    }
275
276    /// Replay the log into this store's own empty projection tables.
277    async fn replay_into_live(&self) -> Result<Replayed, Refusal> {
278        let mut tx = self
279            .pool()
280            .begin()
281            .await
282            .map_err(|e| Refusal::storage(format!("begin cold replay: {e}")))?;
283        let built = replay(self.pool(), &mut tx).await?;
284        tx.commit()
285            .await
286            .map_err(|e| Refusal::storage(format!("commit cold replay: {e}")))?;
287        Ok(built)
288    }
289}
290
291/// Replay every event in `source`'s log into `target`'s projection tables.
292///
293/// The log is streamed from a repeatable-read snapshot held for the whole
294/// replay, so the watermark, the live hash and every page describe one fixed
295/// database. Without that a concurrent append would land in the rebuilt set but
296/// not the live hash it is about to be compared against, and report a
297/// divergence that is really just a race.
298async fn replay(source: &PgPool, target: &mut PgConnection) -> Result<Replayed, Refusal> {
299    let mut src = source
300        .begin()
301        .await
302        .map_err(|e| Refusal::storage(format!("begin replay source: {e}")))?;
303    sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
304        .execute(&mut *src)
305        .await
306        .map_err(|e| Refusal::storage(format!("pin the replay snapshot: {e}")))?;
307
308    let watermark = watermark_of(&mut src).await?;
309    let live_hash = projection_hash(&derived_records(&mut src).await?);
310
311    let mut cursor = None;
312    let mut events = 0u64;
313    loop {
314        let page = read_page(&mut *src, cursor, REPLAY_PAGE)
315            .await
316            .map_err(|e| Refusal::storage(format!("read the log: {e}")))?;
317        if page.is_empty() {
318            break;
319        }
320        for event in &page {
321            // Genesis is the one event with no command behind it — "the epoch
322            // boundary is the log itself", as `epoch` puts it, and nothing
323            // issued it. It has no projection to write and its payload is not a
324            // `KernelCommand`, so handing it to `apply_event` would fail every
325            // replay on the first event of every log.
326            if event.aggregate_type == KERNEL_AGGREGATE && event.event_type == GENESIS_EVENT_TYPE {
327                continue;
328            }
329            apply_event(target, event).await?;
330            events += 1;
331        }
332        cursor = page.last().map(|e| e.global_sequence);
333        if page.len() < REPLAY_PAGE {
334            break;
335        }
336    }
337
338    let rebuilt_hash = projection_hash(&derived_records(target).await?);
339    src.rollback()
340        .await
341        .map_err(|e| Refusal::storage(format!("close the replay source: {e}")))?;
342
343    Ok(Replayed {
344        events,
345        watermark,
346        live_hash,
347        rebuilt_hash,
348    })
349}
350
351/// The newest checkpoint that survives validation, plus every one walked past.
352///
353/// The ladder is the whole point: one unreadable checkpoint must not cost the
354/// kernel every older one, so a rejection is recorded and the walk continues.
355async fn newest_valid(
356    conn: &mut PgConnection,
357    blobs: Option<&PgBlobStore>,
358    watermark: Option<Seq>,
359) -> Result<(Option<Checkpoint>, Vec<(Seq, String)>), Refusal> {
360    let Some(blobs) = blobs else {
361        return Ok((None, Vec::new()));
362    };
363    let mut rejected = Vec::new();
364    for checkpoint in checkpoints(conn).await? {
365        match validate(blobs, &checkpoint, watermark).await {
366            Ok(()) => return Ok((Some(checkpoint), rejected)),
367            Err(reason) => rejected.push((checkpoint.through_sequence, reason)),
368        }
369    }
370    Ok((None, rejected))
371}
372
373/// Whether a checkpoint still describes something.
374///
375/// Every check here has a failure that was actually seen or is actually
376/// possible: a records blob swept or shredded out from under the row, a
377/// truncated container, a checkpoint from a log that was later restored to an
378/// earlier point. `Err` carries the reason so the caller can report it rather
379/// than log a bare "invalid".
380async fn validate(
381    blobs: &PgBlobStore,
382    checkpoint: &Checkpoint,
383    watermark: Option<Seq>,
384) -> Result<(), String> {
385    if checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION {
386        return Err(format!(
387            "checkpoint schema {} is not {CHECKPOINT_SCHEMA_VERSION}",
388            checkpoint.schema_version
389        ));
390    }
391    // A checkpoint past the watermark describes a log this database does not
392    // have — a restore to an earlier point leaves exactly this.
393    match watermark {
394        Some(mark) if checkpoint.through_sequence <= mark => {}
395        Some(mark) => {
396            return Err(format!(
397                "checkpoint runs through {} but the log ends at {}",
398                checkpoint.through_sequence.value(),
399                mark.value()
400            ));
401        }
402        None => return Err("checkpoint exists but the log is empty".to_owned()),
403    }
404    if checkpoint.records_ref.media_type != RECORDS_MEDIA_TYPE {
405        return Err(format!(
406            "records are {:?}, not {RECORDS_MEDIA_TYPE}",
407            checkpoint.records_ref.media_type
408        ));
409    }
410
411    let address = BlobAddress::parse(&checkpoint.records_ref.digest)
412        .map_err(|e| format!("records_ref: {e}"))?;
413    // The invariant snapshot writes down: the records blob's plaintext IS the
414    // bytes that were hashed, so its content address and the projection hash
415    // are one digest. Checked before the read, because if they disagree the
416    // read would be fetching some other snapshot's records.
417    if address.digest_hex() != checkpoint.projection_hash {
418        return Err(format!(
419            "records address {} is not the projection hash {}",
420            address.digest_hex(),
421            checkpoint.projection_hash
422        ));
423    }
424
425    let records = read_blob(blobs, &address, checkpoint.records_ref.byte_size.value())
426        .await
427        .map_err(|e| format!("read records: {e}"))?;
428    if projection_hash(&records) != checkpoint.projection_hash {
429        return Err("records do not hash to the recorded projection hash".to_owned());
430    }
431    // Bytes that hash correctly can still be from a format this build cannot
432    // read. Parsing every line is what makes "valid" mean usable.
433    for (line, raw) in records.split(|b| *b == b'\n').enumerate() {
434        if raw.is_empty() {
435            continue;
436        }
437        serde_json::from_slice::<ProjectionRecord>(raw)
438            .map_err(|e| format!("records line {}: {e}", line + 1))?;
439    }
440    Ok(())
441}
442
443/// Read a whole blob, one chunk at a time.
444///
445/// Bounded by the size the checkpoint row declares rather than by reading until
446/// the store stops yielding: a container that hands back more than its row says
447/// is not a longer snapshot, it is a disagreement, and the caller's hash check
448/// is entitled to see the declared bytes.
449async fn read_blob(
450    blobs: &PgBlobStore,
451    address: &BlobAddress,
452    size: u64,
453) -> Result<Vec<u8>, gwk_domain::port::BlobError> {
454    let mut out = Vec::new();
455    while (out.len() as u64) < size {
456        let want = (size - out.len() as u64).min(BLOB_CHUNK_BYTES as u64);
457        let chunk = blobs
458            .read(
459                address,
460                ByteCount::new(out.len() as u64),
461                ByteCount::new(want),
462            )
463            .await?;
464        if chunk.is_empty() {
465            return Err(gwk_domain::port::BlobError::Integrity(format!(
466                "records end at {} of a declared {size} bytes",
467                out.len()
468            )));
469        }
470        out.extend_from_slice(&chunk);
471    }
472    Ok(out)
473}
474
475/// The log's highest assigned sequence, read through the caller's connection.
476async fn watermark_of(conn: &mut PgConnection) -> Result<Option<Seq>, Refusal> {
477    let text: Option<String> = sqlx::query_scalar("SELECT max(seq)::text FROM gwk.event")
478        .fetch_one(conn)
479        .await
480        .map_err(|e| Refusal::storage(format!("watermark: {e}")))?;
481    text.map(|t| from_numeric_text(&t))
482        .transpose()
483        .map(|opt| opt.map(Seq::new))
484        .map_err(|e| Refusal::storage(format!("watermark: {e}")))
485}
486
487/// Attempts whose outcome a restart cannot know.
488///
489/// The FSM already names them and is not asked twice: a state is uncertain
490/// exactly when it has a legal edge to `unknown`. `queued` and `leased` do not
491/// — an attempt that never started has no outcome to be uncertain about, it
492/// simply has not run — and hard-coding a list here would be a second copy of
493/// the FSM, free to drift from the one the transitions are checked against.
494async fn uncertain_attempts(conn: &mut PgConnection) -> Result<Vec<String>, Refusal> {
495    let states = AttemptState::STATES
496        .iter()
497        .filter(|state| AttemptState::can_transition(**state, AttemptState::Unknown))
498        .map(wire_str)
499        .collect::<Result<Vec<_>, _>>()?;
500    let rows = sqlx::query("SELECT id FROM gwk.attempt WHERE state = ANY($1) ORDER BY id")
501        .bind(&states)
502        .fetch_all(conn)
503        .await
504        .map_err(|e| Refusal::storage(format!("read attempts: {e}")))?;
505    rows.iter()
506        .map(|row| {
507            row.try_get::<String, _>(0)
508                .map_err(|e| Refusal::storage(format!("attempt id: {e}")))
509        })
510        .collect()
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn uncertainty_is_read_off_the_fsm_and_excludes_what_never_started() {
519        let uncertain: Vec<&str> = AttemptState::STATES
520            .iter()
521            .filter(|s| AttemptState::can_transition(**s, AttemptState::Unknown))
522            .map(|s| wire_str(s).expect("wire name"))
523            .collect::<Vec<_>>()
524            .leak()
525            .iter()
526            .map(|s| s.as_str())
527            .collect();
528        assert_eq!(
529            uncertain,
530            ["starting", "running", "blocked", "canceling"],
531            "the uncertain set is whatever the FSM says can end in `unknown`"
532        );
533        // The two that matter for the honest-reporting argument: an attempt
534        // that never started has no outcome to be uncertain about.
535        assert!(!AttemptState::can_transition(
536            AttemptState::Queued,
537            AttemptState::Unknown
538        ));
539        assert!(!AttemptState::can_transition(
540            AttemptState::Leased,
541            AttemptState::Unknown
542        ));
543    }
544
545    #[test]
546    fn only_a_proven_divergence_blocks_readiness() {
547        let report = |verdict| RecoveryReport {
548            watermark: None,
549            live_hash: projection_hash(&[]),
550            verdict,
551            rejected: Vec::new(),
552            uncertain: Vec::new(),
553        };
554        assert!(
555            report(Verdict::Verified {
556                anchor: Seq::new(1)
557            })
558            .ready()
559        );
560        assert!(report(Verdict::Replayed { events: 3 }).ready());
561        // Unverified is a statement about this run, not an accusation: a crash
562        // leaves the newest checkpoint behind the watermark on a kernel whose
563        // projections are perfectly correct.
564        assert!(
565            report(Verdict::Unverified {
566                reason: "no valid checkpoint".to_owned()
567            })
568            .ready()
569        );
570        assert!(
571            !report(Verdict::Diverged {
572                expected: "a".repeat(64),
573                found: "b".repeat(64),
574            })
575            .ready()
576        );
577    }
578}