1use crate::shim::clock::ChainEpoch;
8use chrono::{DateTime, Utc};
9use std::sync::Arc;
10use std::sync::LazyLock;
11use std::sync::atomic::{self, AtomicI64};
12use tokio_util::sync::CancellationToken;
13
14#[derive(
16 Debug,
17 Clone,
18 Copy,
19 PartialEq,
20 Eq,
21 serde::Serialize,
22 serde::Deserialize,
23 schemars::JsonSchema,
24 strum::Display,
25)]
26pub enum ChainExportKind {
27 Snapshot,
29 DiffSnapshot,
31 SnapshotGc,
33 IndexBackfill,
36}
37
38#[derive(
41 Debug,
42 Clone,
43 Copy,
44 PartialEq,
45 Eq,
46 serde::Serialize,
47 serde::Deserialize,
48 schemars::JsonSchema,
49 strum::Display,
50)]
51pub enum ChainExportState {
52 Idle,
53 Running,
54 Succeeded,
55 Cancelled,
56 Failed,
57}
58
59#[derive(Default)]
62struct StatusInner {
63 outcome: Option<ChainExportState>,
65 kind: Option<ChainExportKind>,
66 start_time: Option<DateTime<Utc>>,
67 error: Option<String>,
68 cancellation_token: Option<CancellationToken>,
69 counters: Arc<ProgressCounters>,
71}
72
73impl StatusInner {
74 fn is_running(&self) -> bool {
76 self.cancellation_token.is_some()
77 }
78}
79
80#[derive(Default)]
81struct ProgressCounters {
82 epoch: AtomicI64,
83 initial_epoch: AtomicI64,
84}
85
86#[derive(Default)]
87pub struct ExportStatus {
88 inner: parking_lot::Mutex<StatusInner>,
89}
90
91pub struct StatusSnapshot {
93 pub state: ChainExportState,
94 pub kind: Option<ChainExportKind>,
95 pub error: Option<String>,
96 pub start_time: Option<DateTime<Utc>>,
97 pub epoch: ChainEpoch,
98 pub initial_epoch: ChainEpoch,
99}
100
101pub fn kind_label(kind: Option<ChainExportKind>) -> String {
102 kind.map(|k| k.to_string())
103 .unwrap_or_else(|| "unknown".into())
104}
105
106pub fn format_start_time(start_time: Option<DateTime<Utc>>) -> String {
107 start_time
108 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
109 .unwrap_or_else(|| "unknown".into())
110}
111
112impl ExportStatus {
113 pub fn snapshot(&self) -> StatusSnapshot {
114 let inner = self.inner.lock();
115 StatusSnapshot {
116 state: if inner.is_running() {
117 ChainExportState::Running
118 } else {
119 inner.outcome.unwrap_or(ChainExportState::Idle)
120 },
121 kind: inner.kind,
122 error: inner.error.clone(),
123 start_time: inner.start_time,
124 epoch: inner.counters.epoch.load(atomic::Ordering::Relaxed),
125 initial_epoch: inner.counters.initial_epoch.load(atomic::Ordering::Relaxed),
126 }
127 }
128
129 pub fn cancel_running(&self) -> bool {
132 if let Some(token) = &self.inner.lock().cancellation_token {
133 token.cancel();
134 true
135 } else {
136 false
137 }
138 }
139
140 fn try_begin(
142 &self,
143 kind: ChainExportKind,
144 cancellation_token: CancellationToken,
145 ) -> anyhow::Result<()> {
146 let mut inner = self.inner.lock();
147 anyhow::ensure!(
148 !inner.is_running(),
149 "an active {} export has been running since {}; check `forest-cli snapshot export-status`",
150 kind_label(inner.kind),
151 format_start_time(inner.start_time),
152 );
153 *inner = StatusInner {
154 outcome: None,
155 kind: Some(kind),
156 start_time: Some(Utc::now()),
157 error: None,
158 cancellation_token: Some(cancellation_token),
159 counters: Arc::new(ProgressCounters::default()),
160 };
161 Ok(())
162 }
163
164 fn record_outcome(&self, outcome: ChainExportState, error: Option<String>) {
166 let mut inner = self.inner.lock();
167 if inner.outcome.is_none() {
168 inner.outcome = Some(outcome);
169 inner.error = error;
170 }
171 }
172
173 fn end(&self) {
174 let mut inner = self.inner.lock();
175 if inner.outcome.is_none() {
177 inner.outcome = Some(ChainExportState::Failed);
178 if !std::thread::panicking() {
179 tracing::warn!("chain export guard dropped without a recorded outcome");
180 }
181 }
182 inner.cancellation_token = None;
183 }
184
185 pub(super) fn progress_reporter(&self) -> ProgressReporter {
186 ProgressReporter(self.inner.lock().counters.clone())
187 }
188}
189
190#[derive(Clone)]
194pub struct ProgressReporter(Arc<ProgressCounters>);
195
196impl ProgressReporter {
197 pub fn update_epoch(&self, epoch: ChainEpoch) {
198 self.0.epoch.store(epoch, atomic::Ordering::Relaxed);
199 _ = self.0.initial_epoch.compare_exchange(
200 0,
201 epoch,
202 atomic::Ordering::Relaxed,
203 atomic::Ordering::Relaxed,
204 );
205 }
206}
207
208pub static CHAIN_EXPORT_STATUS: LazyLock<ExportStatus> = LazyLock::new(ExportStatus::default);
209
210#[derive(Debug)]
211pub struct ChainExportGuard {
212 cancellation_token: CancellationToken,
213}
214
215impl ChainExportGuard {
216 pub fn try_start_export(kind: ChainExportKind) -> anyhow::Result<Self> {
217 let cancellation_token = CancellationToken::new();
218 CHAIN_EXPORT_STATUS.try_begin(kind, cancellation_token.clone())?;
219 Ok(Self { cancellation_token })
220 }
221
222 pub async fn run_cancellable<F: Future>(&self, fut: F) -> Option<F::Output> {
226 let output = self.cancellation_token.run_until_cancelled(fut).await;
227 if output.is_none() {
228 CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Cancelled, None);
229 }
230 output
231 }
232
233 pub fn record_outcome(&self, outcome: ChainExportState, error: Option<String>) {
236 CHAIN_EXPORT_STATUS.record_outcome(outcome, error);
237 }
238
239 pub fn finish<T>(self, result: anyhow::Result<T>) -> anyhow::Result<T> {
242 match &result {
243 Ok(_) => CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Succeeded, None),
244 Err(e) => {
245 CHAIN_EXPORT_STATUS.record_outcome(ChainExportState::Failed, Some(format!("{e:#}")))
246 }
247 }
248 result
249 }
250}
251
252impl Drop for ChainExportGuard {
253 fn drop(&mut self) {
254 self.cancellation_token.cancel();
256 CHAIN_EXPORT_STATUS.end();
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[tokio::test]
266 #[serial_test::serial(chain_export)]
267 async fn chain_export_cancel_stops_guarded_export() {
268 let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
269
270 let fut = g.run_cancellable(std::future::pending::<()>());
271 assert!(CHAIN_EXPORT_STATUS.cancel_running());
273 assert!(
274 fut.await.is_none(),
275 "cancellation must interrupt the export"
276 );
277
278 assert_eq!(
279 CHAIN_EXPORT_STATUS.snapshot().state,
280 ChainExportState::Running
281 );
282 drop(g);
283 assert_eq!(
284 CHAIN_EXPORT_STATUS.snapshot().state,
285 ChainExportState::Cancelled
286 );
287 }
288
289 #[test]
290 #[serial_test::serial(chain_export)]
291 fn chain_export_status_reports_kind() {
292 let g = ChainExportGuard::try_start_export(ChainExportKind::SnapshotGc).unwrap();
293 assert_eq!(
294 CHAIN_EXPORT_STATUS.snapshot().kind,
295 Some(ChainExportKind::SnapshotGc)
296 );
297
298 let err = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap_err();
300 assert!(
301 err.to_string().contains("SnapshotGc"),
302 "unexpected error: {err}"
303 );
304
305 drop(g);
307 assert_eq!(
308 CHAIN_EXPORT_STATUS.snapshot().kind,
309 Some(ChainExportKind::SnapshotGc)
310 );
311 let _g = ChainExportGuard::try_start_export(ChainExportKind::DiffSnapshot).unwrap();
312 assert_eq!(
313 CHAIN_EXPORT_STATUS.snapshot().kind,
314 Some(ChainExportKind::DiffSnapshot)
315 );
316 }
317
318 #[test]
319 fn chain_export_state_starts_idle() {
320 assert_eq!(
321 ExportStatus::default().snapshot().state,
322 ChainExportState::Idle
323 );
324 }
325
326 #[tokio::test]
328 #[serial_test::serial(chain_export)]
329 async fn chain_export_state_machine() {
330 let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
331 assert_eq!(
332 CHAIN_EXPORT_STATUS.snapshot().state,
333 ChainExportState::Running
334 );
335 g.finish(anyhow::Ok(())).unwrap();
336 assert_eq!(
337 CHAIN_EXPORT_STATUS.snapshot().state,
338 ChainExportState::Succeeded
339 );
340
341 let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
343 g.finish(anyhow::Result::<()>::Err(anyhow::anyhow!(
344 "missing state root"
345 )))
346 .unwrap_err();
347 assert_eq!(
348 CHAIN_EXPORT_STATUS.snapshot().state,
349 ChainExportState::Failed
350 );
351 assert_eq!(
352 CHAIN_EXPORT_STATUS.snapshot().error.as_deref(),
353 Some("missing state root")
354 );
355
356 let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
359 drop(g);
360 assert_eq!(
361 CHAIN_EXPORT_STATUS.snapshot().state,
362 ChainExportState::Failed
363 );
364 assert_eq!(CHAIN_EXPORT_STATUS.snapshot().error, None);
365
366 let g = ChainExportGuard::try_start_export(ChainExportKind::SnapshotGc).unwrap();
368 assert!(CHAIN_EXPORT_STATUS.cancel_running());
369 assert!(
370 g.run_cancellable(std::future::pending::<()>())
371 .await
372 .is_none()
373 );
374 g.finish(anyhow::Result::<()>::Err(anyhow::anyhow!(
375 "snapshot GC export was cancelled"
376 )))
377 .unwrap_err();
378 assert_eq!(
379 CHAIN_EXPORT_STATUS.snapshot().state,
380 ChainExportState::Cancelled
381 );
382 assert_eq!(CHAIN_EXPORT_STATUS.snapshot().error, None);
383
384 let g = ChainExportGuard::try_start_export(ChainExportKind::Snapshot).unwrap();
386 g.finish(anyhow::Ok(())).unwrap();
387 assert!(!CHAIN_EXPORT_STATUS.cancel_running());
388 assert_eq!(
389 CHAIN_EXPORT_STATUS.snapshot().state,
390 ChainExportState::Succeeded
391 );
392 }
393}