use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::Instrument as _;
use crate::backend::DurableBackendEnum;
use crate::error::DurableError;
use crate::ids::{ExecutionId, StepId};
use crate::journal::{EntryKind, Journal as _, JournalEntry};
pub(crate) const DEFAULT_SEGMENT_STEPS: u32 = 100;
#[derive(Debug)]
pub(crate) enum StepReplay {
Result(JournalEntry),
IntentOnly(JournalEntry),
Fresh,
}
#[derive(Debug, Default)]
struct LoadedStep {
result: Option<JournalEntry>,
intent: Option<JournalEntry>,
}
#[derive(Debug)]
struct CursorState {
loaded: BTreeMap<u32, LoadedStep>,
next_step_to_load: u32,
exhausted: bool,
checkpoints_preloaded: bool,
}
#[derive(Debug)]
pub(crate) struct ReplayCursor {
backend: Arc<DurableBackendEnum>,
execution_id: ExecutionId,
segment_steps: u32,
state: Mutex<CursorState>,
}
impl ReplayCursor {
pub(crate) fn new(
backend: Arc<DurableBackendEnum>,
execution_id: ExecutionId,
segment_steps: u32,
) -> Self {
let _span = tracing::info_span!(
"durable.replay.cursor.build",
execution_id = %execution_id.as_uuid(),
)
.entered();
Self {
backend,
execution_id,
segment_steps: segment_steps.max(1),
state: Mutex::new(CursorState {
loaded: BTreeMap::new(),
next_step_to_load: 0,
exhausted: false,
checkpoints_preloaded: false,
}),
}
}
fn segment_rows(&self) -> usize {
usize::try_from(self.segment_steps)
.unwrap_or(usize::MAX / 2)
.saturating_mul(2)
}
pub(crate) async fn lookup(&self, step_id: StepId) -> Result<StepReplay, DurableError> {
let step = step_id.value();
self.ensure_loaded_through(step).await?;
let entry = self.state.lock().await.loaded.remove(&step);
Ok(match entry {
Some(LoadedStep {
result: Some(result),
..
}) => StepReplay::Result(result),
Some(LoadedStep {
result: None,
intent: Some(intent),
}) => StepReplay::IntentOnly(intent),
Some(LoadedStep {
result: None,
intent: None,
})
| None => StepReplay::Fresh,
})
}
async fn ensure_loaded_through(&self, step: u32) -> Result<(), DurableError> {
let needs_checkpoint = !self.state.lock().await.checkpoints_preloaded;
if needs_checkpoint {
let entries = self
.backend
.read_checkpoints(self.execution_id)
.instrument(tracing::info_span!(
"durable.replay.cursor.preload",
execution_id = %self.execution_id.as_uuid(),
))
.await?;
let mut state = self.state.lock().await;
if !state.checkpoints_preloaded {
state.checkpoints_preloaded = true;
for entry in entries {
insert_entry(&mut state, entry);
}
}
}
loop {
let (exhausted, next_step_to_load) = {
let state = self.state.lock().await;
(state.exhausted, state.next_step_to_load)
};
if exhausted || next_step_to_load > step {
break;
}
self.load_segment_from(next_step_to_load).await?;
}
Ok(())
}
async fn load_segment_from(&self, from: u32) -> Result<(), DurableError> {
let limit = self.segment_rows();
let rows = async {
let rows = self
.backend
.read_execution_range(self.execution_id, from, limit)
.await?;
tracing::Span::current().record("count", rows.len());
Ok::<_, DurableError>(rows)
}
.instrument(tracing::info_span!(
"durable.replay.cursor.read_segment",
from_step_id = from,
count = tracing::field::Empty,
))
.await?;
let mut state = self.state.lock().await;
if state.next_step_to_load != from {
return Ok(());
}
if rows.len() < limit {
let mut max_step = from;
for entry in rows {
max_step = max_step.max(entry.step_id.value());
insert_entry(&mut state, entry);
}
state.exhausted = true;
state.next_step_to_load = max_step.saturating_add(1);
return Ok(());
}
let min_step = rows.iter().map(|e| e.step_id.value()).min().unwrap_or(from);
let max_step = rows.iter().map(|e| e.step_id.value()).max().unwrap_or(from);
if min_step == max_step {
for entry in rows {
insert_entry(&mut state, entry);
}
state.next_step_to_load = max_step.saturating_add(1);
} else {
for entry in rows {
if entry.step_id.value() == max_step {
continue;
}
insert_entry(&mut state, entry);
}
state.next_step_to_load = max_step;
}
Ok(())
}
}
fn insert_entry(state: &mut CursorState, entry: JournalEntry) {
let step = entry.step_id.value();
match entry.entry {
EntryKind::StepResult { .. } => state.loaded.entry(step).or_default().result = Some(entry),
EntryKind::EffectIntent { .. } => {
state.loaded.entry(step).or_default().intent = Some(entry);
}
EntryKind::PromiseCreated { .. }
| EntryKind::PromiseResolved { .. }
| EntryKind::TimerArmed { .. }
| EntryKind::TimerFired { .. }
| EntryKind::Checkpoint { .. } => {}
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use std::assert_matches;
use super::*;
use crate::backend::local::LocalBackend;
use crate::effect::EffectClass;
use crate::ids::{ExecutionKind, IdempotencyKey};
use bytes::Bytes;
async fn backend_with(steps: &[(u32, bool)]) -> (Arc<DurableBackendEnum>, ExecutionId) {
let local = LocalBackend::open(":memory:", 1_048_576).await.unwrap();
local.init().await.unwrap();
let exec = ExecutionId::new();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
for &(step, guarded) in steps {
let step_id = StepId::new(step);
let idem = IdempotencyKey::derive(exec, step_id, b"op");
if guarded {
local
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: idem,
effect: EffectClass::ExactlyOnceGuarded,
hmac: None,
},
created_at_ms: 0,
})
.await
.unwrap();
}
local
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: idem,
payload: Bytes::from_static(b"v"),
effect: if guarded {
EffectClass::ExactlyOnceGuarded
} else {
EffectClass::Idempotent
},
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
}
(Arc::new(DurableBackendEnum::Local(Arc::new(local))), exec)
}
async fn intent_only(step: u32) -> (Arc<DurableBackendEnum>, ExecutionId) {
let local = LocalBackend::open(":memory:", 1_048_576).await.unwrap();
local.init().await.unwrap();
let exec = ExecutionId::new();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(step);
local
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"op"),
effect: EffectClass::ExactlyOnceGuarded,
hmac: None,
},
created_at_ms: 0,
})
.await
.unwrap();
(Arc::new(DurableBackendEnum::Local(Arc::new(local))), exec)
}
#[tokio::test]
async fn lookup_classifies_result_intent_and_fresh() {
let (backend, exec) = backend_with(&[(0, false), (1, true)]).await;
let cursor = ReplayCursor::new(backend, exec, DEFAULT_SEGMENT_STEPS);
assert_matches!(
cursor.lookup(StepId::new(0)).await.unwrap(),
StepReplay::Result(_)
);
assert_matches!(
cursor.lookup(StepId::new(1)).await.unwrap(),
StepReplay::Result(_)
);
assert_matches!(
cursor.lookup(StepId::new(2)).await.unwrap(),
StepReplay::Fresh
);
}
#[tokio::test]
async fn lookup_reports_ambiguous_window_intent_only() {
let (backend, exec) = intent_only(0).await;
let cursor = ReplayCursor::new(backend, exec, DEFAULT_SEGMENT_STEPS);
assert_matches!(
cursor.lookup(StepId::new(0)).await.unwrap(),
StepReplay::IntentOnly(_)
);
}
#[tokio::test]
async fn segmented_reads_cover_a_long_journal() {
let steps: Vec<(u32, bool)> = (0..25).map(|s| (s, false)).collect();
let (backend, exec) = backend_with(&steps).await;
let cursor = ReplayCursor::new(backend, exec, 2);
for step in 0..25 {
assert!(
matches!(
cursor.lookup(StepId::new(step)).await.unwrap(),
StepReplay::Result(_)
),
"step {step} should replay from the journal"
);
}
assert_matches!(
cursor.lookup(StepId::new(25)).await.unwrap(),
StepReplay::Fresh
);
}
#[tokio::test]
async fn out_of_order_lookups_within_a_segment_resolve() {
let steps: Vec<(u32, bool)> = (0..8).map(|s| (s, false)).collect();
let (backend, exec) = backend_with(&steps).await;
let cursor = ReplayCursor::new(backend, exec, DEFAULT_SEGMENT_STEPS);
assert_matches!(
cursor.lookup(StepId::new(5)).await.unwrap(),
StepReplay::Result(_)
);
assert_matches!(
cursor.lookup(StepId::new(2)).await.unwrap(),
StepReplay::Result(_)
);
}
}