use gwk_domain::blob::{BLOB_CHUNK_BYTES, BlobAddress};
use gwk_domain::checkpoint::{CHECKPOINT_SCHEMA_VERSION, Checkpoint};
use gwk_domain::fsm::{AttemptState, StateMachine};
use gwk_domain::ids::{ByteCount, Seq};
use gwk_domain::port::BlobStore;
use gwk_domain::protocol::ProjectionRecord;
use sqlx::{PgConnection, PgPool, Row};
use crate::blob::store::PgBlobStore;
use crate::checkpoint::{RECORDS_MEDIA_TYPE, checkpoints, derived_records, projection_hash};
use crate::epoch::{GENESIS_EVENT_TYPE, KERNEL_AGGREGATE};
use crate::numeric::from_numeric_text;
use crate::project::{Refusal, apply_event, wire_str};
use crate::store::{PgEventStore, read_page};
const REPLAY_PAGE: usize = 1_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Verified { anchor: Seq },
Replayed { events: u64 },
Unverified { reason: String },
Diverged { expected: String, found: String },
}
#[derive(Debug, Clone)]
pub struct RecoveryReport {
pub watermark: Option<Seq>,
pub live_hash: String,
pub verdict: Verdict,
pub rejected: Vec<(Seq, String)>,
pub uncertain: Vec<String>,
}
impl RecoveryReport {
pub fn ready(&self) -> bool {
!matches!(self.verdict, Verdict::Diverged { .. })
}
}
#[derive(Debug, Clone)]
pub struct RebuildReport {
pub through_sequence: Option<Seq>,
pub live_hash: String,
pub rebuilt_hash: String,
pub agrees: bool,
}
struct Replayed {
events: u64,
watermark: Option<Seq>,
live_hash: String,
rebuilt_hash: String,
}
impl PgEventStore {
pub async fn recover(&self) -> Result<RecoveryReport, Refusal> {
let mut read = self
.pool()
.begin()
.await
.map_err(|e| Refusal::storage(format!("begin recovery read: {e}")))?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
.execute(&mut *read)
.await
.map_err(|e| Refusal::storage(format!("pin the recovery snapshot: {e}")))?;
let watermark = watermark_of(&mut read).await?;
let live = derived_records(&mut read).await?;
let live_hash = projection_hash(&live);
let (anchor, rejected) = newest_valid(&mut read, self.blobs(), watermark).await?;
let cold = live.is_empty();
read.rollback()
.await
.map_err(|e| Refusal::storage(format!("close the recovery read: {e}")))?;
let verdict = match (watermark, cold) {
(None, true) => Verdict::Unverified {
reason: "the log is empty".to_owned(),
},
(None, false) => Verdict::Diverged {
expected: projection_hash(&[]),
found: live_hash.clone(),
},
(Some(_), true) => {
let built = self.replay_into_live().await?;
match anchor
.as_ref()
.filter(|cp| Some(cp.through_sequence) == built.watermark)
{
Some(cp) if cp.projection_hash != built.rebuilt_hash => Verdict::Diverged {
expected: cp.projection_hash.clone(),
found: built.rebuilt_hash,
},
_ => Verdict::Replayed {
events: built.events,
},
}
}
(Some(mark), false) => match &anchor {
Some(cp) if cp.through_sequence == mark => {
if cp.projection_hash == live_hash {
Verdict::Verified {
anchor: cp.through_sequence,
}
} else {
Verdict::Diverged {
expected: cp.projection_hash.clone(),
found: live_hash.clone(),
}
}
}
Some(cp) => Verdict::Unverified {
reason: format!(
"the newest valid checkpoint is at {}, and the log runs to {} — \
the projections cannot be re-derived in place to compare",
cp.through_sequence.value(),
mark.value()
),
},
None if self.blobs().is_none() => Verdict::Unverified {
reason: "no blob store is attached, so no checkpoint can be read".to_owned(),
},
None => Verdict::Unverified {
reason: "no valid checkpoint".to_owned(),
},
},
};
let mut conn = self
.pool()
.acquire()
.await
.map_err(|e| Refusal::storage(format!("acquire: {e}")))?;
let uncertain = uncertain_attempts(&mut conn).await?;
Ok(RecoveryReport {
watermark,
live_hash,
verdict,
rejected,
uncertain,
})
}
pub async fn rebuild_into(&self, scratch: &PgPool) -> Result<RebuildReport, Refusal> {
let mut tx = scratch
.begin()
.await
.map_err(|e| Refusal::storage(format!("begin scratch rebuild: {e}")))?;
if !derived_records(&mut tx).await?.is_empty() {
return Err(Refusal::validation(
"the scratch database already holds projections — a rebuild needs an empty one",
));
}
let built = replay(self.pool(), &mut tx).await?;
tx.commit()
.await
.map_err(|e| Refusal::storage(format!("commit scratch rebuild: {e}")))?;
Ok(RebuildReport {
through_sequence: built.watermark,
live_hash: built.live_hash.clone(),
agrees: built.live_hash == built.rebuilt_hash,
rebuilt_hash: built.rebuilt_hash,
})
}
async fn replay_into_live(&self) -> Result<Replayed, Refusal> {
let mut tx = self
.pool()
.begin()
.await
.map_err(|e| Refusal::storage(format!("begin cold replay: {e}")))?;
let built = replay(self.pool(), &mut tx).await?;
tx.commit()
.await
.map_err(|e| Refusal::storage(format!("commit cold replay: {e}")))?;
Ok(built)
}
}
async fn replay(source: &PgPool, target: &mut PgConnection) -> Result<Replayed, Refusal> {
let mut src = source
.begin()
.await
.map_err(|e| Refusal::storage(format!("begin replay source: {e}")))?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
.execute(&mut *src)
.await
.map_err(|e| Refusal::storage(format!("pin the replay snapshot: {e}")))?;
let watermark = watermark_of(&mut src).await?;
let live_hash = projection_hash(&derived_records(&mut src).await?);
let mut cursor = None;
let mut events = 0u64;
loop {
let page = read_page(&mut *src, cursor, REPLAY_PAGE)
.await
.map_err(|e| Refusal::storage(format!("read the log: {e}")))?;
if page.is_empty() {
break;
}
for event in &page {
if event.aggregate_type == KERNEL_AGGREGATE && event.event_type == GENESIS_EVENT_TYPE {
continue;
}
apply_event(target, event).await?;
events += 1;
}
cursor = page.last().map(|e| e.global_sequence);
if page.len() < REPLAY_PAGE {
break;
}
}
let rebuilt_hash = projection_hash(&derived_records(target).await?);
src.rollback()
.await
.map_err(|e| Refusal::storage(format!("close the replay source: {e}")))?;
Ok(Replayed {
events,
watermark,
live_hash,
rebuilt_hash,
})
}
async fn newest_valid(
conn: &mut PgConnection,
blobs: Option<&PgBlobStore>,
watermark: Option<Seq>,
) -> Result<(Option<Checkpoint>, Vec<(Seq, String)>), Refusal> {
let Some(blobs) = blobs else {
return Ok((None, Vec::new()));
};
let mut rejected = Vec::new();
for checkpoint in checkpoints(conn).await? {
match validate(blobs, &checkpoint, watermark).await {
Ok(()) => return Ok((Some(checkpoint), rejected)),
Err(reason) => rejected.push((checkpoint.through_sequence, reason)),
}
}
Ok((None, rejected))
}
async fn validate(
blobs: &PgBlobStore,
checkpoint: &Checkpoint,
watermark: Option<Seq>,
) -> Result<(), String> {
if checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION {
return Err(format!(
"checkpoint schema {} is not {CHECKPOINT_SCHEMA_VERSION}",
checkpoint.schema_version
));
}
match watermark {
Some(mark) if checkpoint.through_sequence <= mark => {}
Some(mark) => {
return Err(format!(
"checkpoint runs through {} but the log ends at {}",
checkpoint.through_sequence.value(),
mark.value()
));
}
None => return Err("checkpoint exists but the log is empty".to_owned()),
}
if checkpoint.records_ref.media_type != RECORDS_MEDIA_TYPE {
return Err(format!(
"records are {:?}, not {RECORDS_MEDIA_TYPE}",
checkpoint.records_ref.media_type
));
}
let address = BlobAddress::parse(&checkpoint.records_ref.digest)
.map_err(|e| format!("records_ref: {e}"))?;
if address.digest_hex() != checkpoint.projection_hash {
return Err(format!(
"records address {} is not the projection hash {}",
address.digest_hex(),
checkpoint.projection_hash
));
}
let records = read_blob(blobs, &address, checkpoint.records_ref.byte_size.value())
.await
.map_err(|e| format!("read records: {e}"))?;
if projection_hash(&records) != checkpoint.projection_hash {
return Err("records do not hash to the recorded projection hash".to_owned());
}
for (line, raw) in records.split(|b| *b == b'\n').enumerate() {
if raw.is_empty() {
continue;
}
serde_json::from_slice::<ProjectionRecord>(raw)
.map_err(|e| format!("records line {}: {e}", line + 1))?;
}
Ok(())
}
async fn read_blob(
blobs: &PgBlobStore,
address: &BlobAddress,
size: u64,
) -> Result<Vec<u8>, gwk_domain::port::BlobError> {
let mut out = Vec::new();
while (out.len() as u64) < size {
let want = (size - out.len() as u64).min(BLOB_CHUNK_BYTES as u64);
let chunk = blobs
.read(
address,
ByteCount::new(out.len() as u64),
ByteCount::new(want),
)
.await?;
if chunk.is_empty() {
return Err(gwk_domain::port::BlobError::Integrity(format!(
"records end at {} of a declared {size} bytes",
out.len()
)));
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
async fn watermark_of(conn: &mut PgConnection) -> Result<Option<Seq>, Refusal> {
let text: Option<String> = sqlx::query_scalar("SELECT max(seq)::text FROM gwk.event")
.fetch_one(conn)
.await
.map_err(|e| Refusal::storage(format!("watermark: {e}")))?;
text.map(|t| from_numeric_text(&t))
.transpose()
.map(|opt| opt.map(Seq::new))
.map_err(|e| Refusal::storage(format!("watermark: {e}")))
}
async fn uncertain_attempts(conn: &mut PgConnection) -> Result<Vec<String>, Refusal> {
let states = AttemptState::STATES
.iter()
.filter(|state| AttemptState::can_transition(**state, AttemptState::Unknown))
.map(wire_str)
.collect::<Result<Vec<_>, _>>()?;
let rows = sqlx::query("SELECT id FROM gwk.attempt WHERE state = ANY($1) ORDER BY id")
.bind(&states)
.fetch_all(conn)
.await
.map_err(|e| Refusal::storage(format!("read attempts: {e}")))?;
rows.iter()
.map(|row| {
row.try_get::<String, _>(0)
.map_err(|e| Refusal::storage(format!("attempt id: {e}")))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uncertainty_is_read_off_the_fsm_and_excludes_what_never_started() {
let uncertain: Vec<&str> = AttemptState::STATES
.iter()
.filter(|s| AttemptState::can_transition(**s, AttemptState::Unknown))
.map(|s| wire_str(s).expect("wire name"))
.collect::<Vec<_>>()
.leak()
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(
uncertain,
["starting", "running", "blocked", "canceling"],
"the uncertain set is whatever the FSM says can end in `unknown`"
);
assert!(!AttemptState::can_transition(
AttemptState::Queued,
AttemptState::Unknown
));
assert!(!AttemptState::can_transition(
AttemptState::Leased,
AttemptState::Unknown
));
}
#[test]
fn only_a_proven_divergence_blocks_readiness() {
let report = |verdict| RecoveryReport {
watermark: None,
live_hash: projection_hash(&[]),
verdict,
rejected: Vec::new(),
uncertain: Vec::new(),
};
assert!(
report(Verdict::Verified {
anchor: Seq::new(1)
})
.ready()
);
assert!(report(Verdict::Replayed { events: 3 }).ready());
assert!(
report(Verdict::Unverified {
reason: "no valid checkpoint".to_owned()
})
.ready()
);
assert!(
!report(Verdict::Diverged {
expected: "a".repeat(64),
found: "b".repeat(64),
})
.ready()
);
}
}