use crate::shim::clock::ChainEpoch;
use chrono::{DateTime, Utc};
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::atomic::{self, AtomicI64};
use tokio_util::sync::CancellationToken;
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
strum::Display,
)]
pub enum ChainExportKind {
Snapshot,
DiffSnapshot,
SnapshotGc,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
strum::Display,
)]
pub enum ChainExportState {
Idle,
Running,
Succeeded,
Cancelled,
Failed,
}
#[derive(Default)]
struct StatusInner {
outcome: Option<ChainExportState>,
kind: Option<ChainExportKind>,
start_time: Option<DateTime<Utc>>,
error: Option<String>,
cancellation_token: Option<CancellationToken>,
counters: Arc<ProgressCounters>,
}
impl StatusInner {
fn is_running(&self) -> bool {
self.cancellation_token.is_some()
}
}
#[derive(Default)]
struct ProgressCounters {
epoch: AtomicI64,
initial_epoch: AtomicI64,
}
#[derive(Default)]
pub struct ExportStatus {
inner: parking_lot::Mutex<StatusInner>,
}
pub struct StatusSnapshot {
pub state: ChainExportState,
pub kind: Option<ChainExportKind>,
pub error: Option<String>,
pub start_time: Option<DateTime<Utc>>,
pub epoch: ChainEpoch,
pub initial_epoch: ChainEpoch,
}
pub fn kind_label(kind: Option<ChainExportKind>) -> String {
kind.map(|k| k.to_string())
.unwrap_or_else(|| "unknown".into())
}
pub fn format_start_time(start_time: Option<DateTime<Utc>>) -> String {
start_time
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| "unknown".into())
}
impl ExportStatus {
pub fn snapshot(&self) -> StatusSnapshot {
let inner = self.inner.lock();
StatusSnapshot {
state: if inner.is_running() {
ChainExportState::Running
} else {
inner.outcome.unwrap_or(ChainExportState::Idle)
},
kind: inner.kind,
error: inner.error.clone(),
start_time: inner.start_time,
epoch: inner.counters.epoch.load(atomic::Ordering::Relaxed),
initial_epoch: inner.counters.initial_epoch.load(atomic::Ordering::Relaxed),
}
}
pub fn cancel_running(&self) -> bool {
if let Some(token) = &self.inner.lock().cancellation_token {
token.cancel();
true
} else {
false
}
}
fn try_begin(
&self,
kind: ChainExportKind,
cancellation_token: CancellationToken,
) -> anyhow::Result<()> {
let mut inner = self.inner.lock();
anyhow::ensure!(
!inner.is_running(),
"an active {} export has been running since {}; check `forest-cli snapshot export-status`",
kind_label(inner.kind),
format_start_time(inner.start_time),
);
*inner = StatusInner {
outcome: None,
kind: Some(kind),
start_time: Some(Utc::now()),
error: None,
cancellation_token: Some(cancellation_token),
counters: Arc::new(ProgressCounters::default()),
};
Ok(())
}
fn record_outcome(&self, outcome: ChainExportState, error: Option<String>) {
let mut inner = self.inner.lock();
if inner.outcome.is_none() {
inner.outcome = Some(outcome);
inner.error = error;
}
}
fn end(&self) {
let mut inner = self.inner.lock();
if inner.outcome.is_none() {
inner.outcome = Some(ChainExportState::Failed);
if !std::thread::panicking() {
tracing::warn!("chain export guard dropped without a recorded outcome");
}
}
inner.cancellation_token = None;
}
pub(super) fn progress_reporter(&self) -> ProgressReporter {
ProgressReporter(self.inner.lock().counters.clone())
}
}
#[derive(Clone)]
pub struct ProgressReporter(Arc<ProgressCounters>);
impl ProgressReporter {
pub fn update_epoch(&self, epoch: ChainEpoch) {
self.0.epoch.store(epoch, atomic::Ordering::Relaxed);
_ = self.0.initial_epoch.compare_exchange(
0,
epoch,
atomic::Ordering::Relaxed,
atomic::Ordering::Relaxed,
);
}
}
pub static CHAIN_EXPORT_STATUS: LazyLock<ExportStatus> = LazyLock::new(ExportStatus::default);
#[derive(Debug)]
pub struct ChainExportGuard {
cancellation_token: CancellationToken,
}
impl ChainExportGuard {
pub fn try_start_export(kind: ChainExportKind) -> anyhow::Result<Self> {
let cancellation_token = CancellationToken::new();
CHAIN_EXPORT_STATUS.try_begin(kind, cancellation_token.clone())?;
Ok(Self { cancellation_token })
}
pub async fn run_cancellable<F: Future>(&self, fut: F) -> Option<F::Output> {
let output = self.cancellation_token.run_until_cancelled(fut).await;
if output.is_none() {
CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Cancelled, None);
}
output
}
pub fn finish<T>(self, result: anyhow::Result<T>) -> anyhow::Result<T> {
match &result {
Ok(_) => CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Succeeded, None),
Err(e) => {
CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Failed, Some(format!("{e:#}")))
}
}
result
}
}
impl Drop for ChainExportGuard {
fn drop(&mut self) {
self.cancellation_token.cancel();
CHAIN_EXPORT_STATUS.end();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[serial_test::serial(chain_export)]
async fn chain_export_cancel_stops_guarded_export() {
let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
let fut = g.run_cancellable(std::future::pending::<()>());
assert!(CHAIN_EXPORT_STATUS.cancel_running());
assert!(
fut.await.is_none(),
"cancellation must interrupt the export"
);
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Running
);
drop(g);
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Cancelled
);
}
#[test]
#[serial_test::serial(chain_export)]
fn chain_export_status_reports_kind() {
let g = ChainExportGuard::try_start_export(ChainExportKind::SnapshotGc).unwrap();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().kind,
Some(ChainExportKind::SnapshotGc)
);
let err = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap_err();
assert!(
err.to_string().contains("SnapshotGc"),
"unexpected error: {err}"
);
drop(g);
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().kind,
Some(ChainExportKind::SnapshotGc)
);
let _g = ChainExportGuard::try_start_export(ChainExportKind::DiffSnapshot).unwrap();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().kind,
Some(ChainExportKind::DiffSnapshot)
);
}
#[test]
fn chain_export_state_starts_idle() {
assert_eq!(
ExportStatus::default().snapshot().state,
ChainExportState::Idle
);
}
#[tokio::test]
#[serial_test::serial(chain_export)]
async fn chain_export_state_machine() {
let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Running
);
g.finish(anyhow::Ok(())).unwrap();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Succeeded
);
let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
g.finish(anyhow::Result::<()>::Err(anyhow::anyhow!(
"missing state root"
)))
.unwrap_err();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Failed
);
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().error.as_deref(),
Some("missing state root")
);
let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
drop(g);
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Failed
);
assert_eq!(CHAIN_EXPORT_STATUS.snapshot().error, None);
let g = ChainExportGuard::try_start_export(ChainExportKind::SnapshotGc).unwrap();
assert!(CHAIN_EXPORT_STATUS.cancel_running());
assert!(
g.run_cancellable(std::future::pending::<()>())
.await
.is_none()
);
g.finish(anyhow::Result::<()>::Err(anyhow::anyhow!(
"snapshot GC export was cancelled"
)))
.unwrap_err();
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Cancelled
);
assert_eq!(CHAIN_EXPORT_STATUS.snapshot().error, None);
let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
g.finish(anyhow::Ok(())).unwrap();
assert!(!CHAIN_EXPORT_STATUS.cancel_running());
assert_eq!(
CHAIN_EXPORT_STATUS.snapshot().state,
ChainExportState::Succeeded
);
}
}