use liminal::durability::settle::apply_routed_batch_settled;
use liminal::durability::{DurabilityError, DurableStore};
use super::checkpoint_batches::{
CheckpointPublishError, batch_bytes, publication_bound, split_history,
};
use super::log::{OperationLog, OperationLogError};
use super::operation_batch::{CheckpointStageError, OperationBatch};
use super::outbox_log::{OutboxLog, OutboxLogError};
use super::restore_checkpoint::{CheckpointError, CheckpointLiveState, RestoreCheckpoint};
use super::restore_checkpoint_history::{CheckpointHistoryRoot, ROOT_ENCODED_BYTES};
use super::restore_checkpoint_history_capture::PreparedCheckpointHistory;
use super::restore_checkpoint_locator::CheckpointLocator;
use super::restore_checkpoint_locator::checkpoint_conversation_route;
use super::restore_from_checkpoint::{
CheckpointDecision, CheckpointMetadata, CheckpointRefusal, PreparedCheckpointRestore,
RebuildRequired, TailReplayError, load_checkpoint, prepare_checkpoint_restore,
prepare_replayed_checkpoint,
};
use super::routed_stream::RoutedStreamRefusal;
use super::state::ConversationAuthority;
use super::state::checkpoint_owner::ConversationAuthorityRestoreContext;
pub(super) struct ReportedRebuild {
required: RebuildRequired,
}
#[derive(Debug, thiserror::Error)]
pub(super) enum CheckpointRebuildError {
#[error("rebuilt checkpoint conversation differs from its refused locator or operation log")]
ConversationMismatch,
#[error(transparent)]
Operation(#[from] OperationLogError),
#[error(transparent)]
Extension(#[from] OutboxLogError),
#[error(transparent)]
Checkpoint(#[from] CheckpointStageError),
#[error(transparent)]
Store(#[from] DurabilityError),
#[error(transparent)]
Fresh(#[from] super::restore_checkpoint::CheckpointError),
#[error(transparent)]
Refused(#[from] CheckpointPublishError),
#[error("history batch {index} refused: {source}")]
Batch {
index: usize,
#[source]
source: DurabilityError,
},
}
pub(super) struct RebuiltPublicationFailure<L, const N: usize> {
pub(super) error: CheckpointRebuildError,
pub(super) checkpoint: Option<Box<RestoreCheckpoint<L, N>>>,
}
impl<L, const N: usize> RebuiltPublicationFailure<L, N> {
fn after(
error: impl Into<CheckpointRebuildError>,
checkpoint: RestoreCheckpoint<L, N>,
) -> Self {
Self {
error: error.into(),
checkpoint: Some(Box::new(checkpoint)),
}
}
}
pub(super) fn report_rebuild(required: RebuildRequired) -> ReportedRebuild {
tracing::warn!(
conversation_id = required.conversation_id,
refusal = %required.refusal,
"CHECKPOINT_REBUILD: full authoritative replay is required before readiness"
);
ReportedRebuild { required }
}
pub(super) struct RebuiltCheckpointPublication<L, const N: usize> {
checkpoint: RestoreCheckpoint<L, N>,
history: PreparedCheckpointHistory,
prefix_digest: [u8; 32],
}
pub(super) async fn publish_rebuilt_checkpoint<L: CheckpointLiveState + Send, const N: usize>(
reported: ReportedRebuild,
store: &dyn DurableStore,
log: &OperationLog,
outbox_log: &OutboxLog,
publication: RebuiltCheckpointPublication<L, N>,
observed_locator: Option<&[u8]>,
) -> Result<RestoreCheckpoint<L, N>, RebuiltPublicationFailure<L, N>> {
let RebuiltCheckpointPublication {
checkpoint,
history,
prefix_digest,
} = publication;
let refused_conversation_id = reported.required.conversation_id;
let (positions, root, live) = checkpoint.into_parts();
let fresh = match reported.required.prepare_fresh(positions, root, live) {
Ok(fresh) => fresh,
Err(error) => {
return Err(RebuiltPublicationFailure {
error: error.into(),
checkpoint: None,
});
}
};
let checkpoint = fresh.checkpoint;
if positions.conversation_id != refused_conversation_id
|| positions.conversation_id != log.conversation_id()
{
return Err(RebuiltPublicationFailure {
error: CheckpointRebuildError::ConversationMismatch,
checkpoint: Some(Box::new(checkpoint)),
});
}
if history.root.encode_canonical().as_slice() != checkpoint.history_root().as_slice() {
return Err(RebuiltPublicationFailure {
error: CheckpointRebuildError::Fresh(
super::restore_checkpoint::CheckpointError::Association {
field: "rebuilt HISTORY root",
},
),
checkpoint: Some(Box::new(checkpoint)),
});
}
let route = checkpoint_conversation_route(positions.conversation_id);
let commit = RebuiltCommit {
history,
prefix_digest,
observed_locator,
route: &route,
};
let (commits, checkpoint) = commit_rebuilt(store, log, outbox_log, checkpoint, commit).await?;
super::restore_checkpoint_history::retire_v1_history(store, positions.conversation_id).await;
tracing::info!(
conversation_id = positions.conversation_id,
refusal = %fresh.refusal,
commits,
"CHECKPOINT_REBUILD: replayed heads and fresh checkpoint are durably published"
);
Ok(checkpoint)
}
struct RebuiltCommit<'a> {
history: PreparedCheckpointHistory,
prefix_digest: [u8; 32],
observed_locator: Option<&'a [u8]>,
route: &'a str,
}
async fn commit_rebuilt<L: CheckpointLiveState + Send, const N: usize>(
store: &dyn DurableStore,
log: &OperationLog,
outbox_log: &OutboxLog,
checkpoint: RestoreCheckpoint<L, N>,
commit: RebuiltCommit<'_>,
) -> Result<(usize, RestoreCheckpoint<L, N>), RebuiltPublicationFailure<L, N>> {
let RebuiltCommit {
history,
prefix_digest,
observed_locator,
route,
} = commit;
let positions = checkpoint.positions();
let bound = publication_bound(store);
let (_, mut nodes) = history.into_nodes();
let mut batch = OperationBatch::new(route);
if let Err(error) = log
.stage_bootstrap_head(&mut batch, positions.next_log_sequence)
.await
{
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
if let Err(error) = outbox_log
.stage_bootstrap_head(&mut batch, positions.extension_sequence)
.await
{
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
let staged =
batch.stage_rebuilt_checkpoint(store, &checkpoint, prefix_digest, observed_locator);
if let Err(error) = staged.await {
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
nodes.extend(
batch
.take_body_parts()
.into_iter()
.map(|part| (part.key, part.value)),
);
let mut batches = match split_history(nodes, bound.bound, bound.item_limit()) {
Ok(batches) => batches,
Err(error) => return Err(RebuiltPublicationFailure::after(error, checkpoint)),
};
let tail = batch.value_bytes();
if tail > bound.item_limit() {
let refusal = CheckpointPublishError::ItemExceedsStoreBound {
key: "checkpoint body".to_owned(),
bytes: tail,
bound: bound.item_limit(),
};
return Err(RebuiltPublicationFailure::after(refusal, checkpoint));
}
if batches
.last()
.is_some_and(|last| batch_bytes(last).saturating_add(tail) <= bound.bound)
{
if let Some(last) = batches.pop() {
for item in last {
if let Err(error) = batch.stage(item) {
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
}
}
}
let commits = batches.len().saturating_add(1);
for (index, items) in batches.into_iter().enumerate() {
if let Err(source) = apply_routed_batch_settled(store, route, items).await {
let error = CheckpointRebuildError::Batch {
index: index.saturating_add(1),
source,
};
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
}
if let Err(error) = batch
.commit(store, || log.issuance().mark_submitted())
.await
{
return Err(RebuiltPublicationFailure::after(error, checkpoint));
}
Ok((commits, checkpoint))
}
#[derive(Debug, thiserror::Error)]
pub(super) enum BootCheckpointError {
#[error(transparent)]
Store(#[from] DurabilityError),
#[error(transparent)]
Checkpoint(#[from] super::restore_checkpoint::CheckpointError),
#[error(transparent)]
Locator(#[from] super::restore_checkpoint_locator::CheckpointLocatorError),
#[error(transparent)]
Replay(#[from] super::outbox_replay::RestoreError),
#[error(transparent)]
Operation(#[from] OperationLogError),
#[error(transparent)]
Publish(#[from] CheckpointRebuildError),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum BootCheckpoint {
Restored,
Published,
Unpublished,
}
pub(super) async fn restore_at_boot(
store: &dyn DurableStore,
log: &OperationLog,
outbox_log: &OutboxLog,
config: &crate::config::types::ParticipantConfig,
limits: super::outbox::ConversationOutboxLimits,
) -> Result<super::state::ConversationAuthority, BootCheckpointError> {
Box::pin(restore_at_boot_reporting(
store, log, outbox_log, config, limits,
))
.await
.map(|(owner, _)| owner)
}
pub(super) async fn restore_at_boot_reporting(
store: &dyn DurableStore,
log: &OperationLog,
outbox_log: &OutboxLog,
config: &crate::config::types::ParticipantConfig,
limits: super::outbox::ConversationOutboxLimits,
) -> Result<(super::state::ConversationAuthority, BootCheckpoint), BootCheckpointError> {
let conversation_id = log.conversation_id();
let loaded = load_checkpoint::<ConversationAuthority, ROOT_ENCODED_BYTES>(
store,
conversation_id,
|positions, root| ConversationAuthorityRestoreContext::new(*positions, root, config),
|locator, root| {
CheckpointHistoryRoot::decode_canonical(root, locator.conversation_id())
.map(|_| ())
.map_err(|source| CheckpointError::LiveState {
source: Box::new(source),
})
},
)
.await?;
let prepared = match loaded.decision {
CheckpointDecision::Restore(checkpoint) => {
let preparation =
(|| {
let raw =
loaded
.observed_locator
.as_deref()
.ok_or(CheckpointError::Association {
field: "validated checkpoint locator",
})?;
let locator = CheckpointLocator::decode_canonical(raw, conversation_id)
.map_err(|error| CheckpointError::LiveState {
source: Box::new(error),
})?;
prepare_checkpoint_restore(checkpoint, &locator, config)
})();
let preparation = match preparation {
Ok(prepared) => {
match super::restore_checkpoint_history_capture::verify_history_root(
store,
prepared.root,
)
.await
{
Ok(()) => super::restore_checkpoint_history_capture::capture_history_check(
&prepared.owner,
prepared.root,
)
.map(|check| {
let mut prepared = prepared;
prepared.owner.checkpoint_history_check = Some(check);
prepared
})
.map_err(super::restore_checkpoint_history_capture::HistoryCaptureError::checkpoint),
Err(
super::restore_checkpoint_history_capture::HistoryCaptureError::Index(
super::restore_checkpoint_history_index::HistoryIndexError::Store(
error,
),
),
) => {
return Err(BootCheckpointError::Store(error));
}
Err(error) => Err(error.checkpoint()),
}
}
Err(error) => Err(error),
};
preparation.map_err(|error| RebuildRequired {
conversation_id,
refusal: CheckpointRefusal::Invalid(error),
})
}
CheckpointDecision::Rebuild(required) => Err(required),
};
match prepared {
Ok(prepared) => Box::pin(restore_validated_tail(log, outbox_log, config, prepared))
.await
.map(|owner| (owner, BootCheckpoint::Restored)),
Err(required) => {
Box::pin(rebuild_before_ready(
store,
log,
outbox_log,
config,
limits,
required,
loaded.observed_locator.as_deref(),
))
.await
}
}
}
async fn restore_validated_tail(
log: &OperationLog,
outbox_log: &OutboxLog,
config: &crate::config::types::ParticipantConfig,
prepared: PreparedCheckpointRestore,
) -> Result<ConversationAuthority, BootCheckpointError> {
let conversation_id = log.conversation_id();
let root = prepared.root;
let owner = prepared.owner;
let generation = log.generation().await?;
let native = OperationLog::at_generation(log.store(), conversation_id, generation);
let native_head = native.read_stream_next_seq().await?.unwrap_or(0);
let route = checkpoint_conversation_route(conversation_id);
let source = |sequence| {
let native = &native;
let route = &route;
async move {
if sequence < native_head {
native.read_at(sequence).await
} else {
match log.read_routed_at(route, sequence).await {
Err(OperationLogError::RoutedStream(RoutedStreamRefusal::MissingHead {
..
})) => Ok(None),
read => read,
}
}
}
};
let (mut owner, audit, schema, prefix) = owner
.replay_checkpoint_tail(
prepared.start,
(prepared.audit, prepared.prefix_digest),
source,
log,
outbox_log,
config,
)
.await
.map_err(|error| match error {
TailReplayError::Replay(error) => BootCheckpointError::Replay(error),
TailReplayError::Source(error) => BootCheckpointError::Operation(error),
})?;
let extension = owner
.outbox
.as_ref()
.ok_or(CheckpointError::Position {
field: "restored outbox",
})?
.checkpoint_boundaries()
.0;
let positions = owner.checkpoint_positions(schema, owner.next_log_sequence, extension)?;
owner.retain_checkpoint_metadata(root, &audit)?;
owner.advance_checkpoint_metadata(positions, root.encode_canonical(), prefix)?;
Ok(owner)
}
async fn rebuild_before_ready(
store: &dyn DurableStore,
log: &OperationLog,
outbox_log: &OutboxLog,
config: &crate::config::types::ParticipantConfig,
limits: super::outbox::ConversationOutboxLimits,
required: RebuildRequired,
observed_locator: Option<&[u8]>,
) -> Result<(ConversationAuthority, BootCheckpoint), BootCheckpointError> {
let conversation_id = log.conversation_id();
let (owner, audit, schema, prefix) =
ConversationAuthority::replay_for_rebuild(conversation_id, log, outbox_log, config, limits)
.await?;
if owner.next_log_sequence == 0 {
return Ok((owner, BootCheckpoint::Restored));
}
if let Err(refusal) = owner
.checkpoint_lifecycle_capturable()
.and_then(|()| owner.checkpoint_positions_framable())
{
tracing::warn!(
conversation_id,
refusal = %refusal,
checkpoint_refusal = %required.refusal,
"CHECKPOINT_REBUILD: the rebuilt owner cannot be captured yet; it serves from its replay and the next crossing publishes its checkpoint"
);
return serve_from_replay(owner, &audit, schema, prefix, conversation_id);
}
let reported = report_rebuild(required);
let history = super::restore_checkpoint_history_capture::capture_identity_history(
store,
&owner,
CheckpointHistoryRoot::empty(conversation_id),
)
.await
.map_err(|error| BootCheckpointError::Checkpoint(error.checkpoint()))?;
let root = history.root;
let checkpoint = prepare_replayed_checkpoint(owner, &audit, schema, root, prefix)?;
let checkpoint = match publish_rebuilt_checkpoint(
reported,
store,
log,
outbox_log,
RebuiltCheckpointPublication {
checkpoint,
history,
prefix_digest: prefix,
},
observed_locator,
)
.await
{
Ok(checkpoint) => checkpoint,
Err(RebuiltPublicationFailure {
error,
checkpoint: Some(checkpoint),
}) => {
tracing::warn!(
conversation_id,
refusal = %error,
"CHECKPOINT_REBUILD: the rebuilt checkpoint's publication was refused by the store; the owner serves from its replay and the next crossing publishes its checkpoint"
);
let (_, _, owner) = (*checkpoint).into_parts();
return serve_from_replay(owner, &audit, schema, prefix, conversation_id);
}
Err(RebuiltPublicationFailure { error, .. }) => return Err(error.into()),
};
let metadata = CheckpointMetadata::from_rebuilt(&checkpoint, prefix)?;
let (_, _, mut owner) = checkpoint.into_parts();
owner.install_checkpoint_metadata(metadata)?;
Ok((owner, BootCheckpoint::Published))
}
fn serve_from_replay(
mut owner: ConversationAuthority,
audit: &super::fenced_attach_terminal::ComposedTerminalValidation,
schema: super::restore_checkpoint::CheckpointSchema,
prefix: [u8; 32],
conversation_id: u64,
) -> Result<(ConversationAuthority, BootCheckpoint), BootCheckpointError> {
let root = CheckpointHistoryRoot::empty(conversation_id);
let extension_sequence = owner
.outbox
.as_ref()
.ok_or(super::restore_checkpoint::CheckpointError::Position {
field: "absent replayed outbox",
})?
.next_extension_sequence();
let positions =
owner.checkpoint_positions(schema, owner.next_log_sequence, extension_sequence)?;
owner.retain_checkpoint_metadata(root, audit)?;
let metadata = CheckpointMetadata::validated(positions, root.encode_canonical(), prefix)?;
owner.install_checkpoint_metadata(metadata)?;
Ok((owner, BootCheckpoint::Unpublished))
}