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}