Skip to main content

forest/ipld/
util.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use crate::blocks::Tipset;
5use crate::cid_collections::{CidHashSet, CidHashSetLike};
6use crate::ipld::Ipld;
7use crate::prelude::*;
8use crate::shim::clock::ChainEpoch;
9use crate::shim::executor::Receipt;
10use crate::utils::db::car_stream::CarBlock;
11use crate::utils::encoding::extract_cids;
12use crate::utils::multihash::prelude::*;
13use arc_swap::ArcSwapOption;
14use bytes::Bytes;
15use chrono::{DateTime, Utc};
16use futures::Stream;
17use pin_project_lite::pin_project;
18use std::borrow::Borrow;
19use std::collections::VecDeque;
20use std::pin::Pin;
21use std::sync::atomic::{AtomicBool, AtomicI64};
22use std::sync::{LazyLock, atomic};
23use std::task::{Context, Poll};
24use tokio_util::sync::CancellationToken;
25
26#[derive(Default)]
27pub struct ExportStatus {
28    pub epoch: AtomicI64,
29    pub initial_epoch: AtomicI64,
30    pub exporting: AtomicBool,
31    pub cancelled: AtomicBool,
32    pub succeeded: AtomicBool,
33    pub start_time: ArcSwapOption<DateTime<Utc>>,
34    pub cancellation_token: ArcSwapOption<CancellationToken>,
35}
36
37impl ExportStatus {
38    pub fn epoch(&self) -> i64 {
39        self.epoch.load(atomic::Ordering::Relaxed)
40    }
41
42    pub fn initial_epoch(&self) -> i64 {
43        self.initial_epoch.load(atomic::Ordering::Relaxed)
44    }
45
46    pub fn exporting(&self) -> bool {
47        self.exporting.load(atomic::Ordering::Relaxed)
48    }
49
50    pub fn cancelled(&self) -> bool {
51        self.cancelled.load(atomic::Ordering::Relaxed)
52    }
53
54    pub fn succeeded(&self) -> bool {
55        self.succeeded.load(atomic::Ordering::Relaxed)
56    }
57
58    pub fn start_time(&self) -> Option<DateTime<Utc>> {
59        self.start_time.load().clone().map(Arc::unwrap_or_clone)
60    }
61
62    pub fn cancellation_token(&self) -> Option<CancellationToken> {
63        self.cancellation_token
64            .load()
65            .clone()
66            .map(Arc::unwrap_or_clone)
67    }
68}
69
70pub static CHAIN_EXPORT_STATUS: LazyLock<ExportStatus> = LazyLock::new(ExportStatus::default);
71
72#[derive(Debug)]
73pub struct ChainExportGuard {
74    cancellation_token: CancellationToken,
75}
76
77impl ChainExportGuard {
78    pub fn try_start_export() -> anyhow::Result<Self> {
79        let cancellation_token = CancellationToken::new();
80        start_export(cancellation_token.clone())?;
81        Ok(Self { cancellation_token })
82    }
83
84    pub fn cancel_export(&self) {
85        cancel_export()
86    }
87
88    pub fn mark_as_succeeded(&self) {
89        export_succeeded()
90    }
91
92    /// Every export path that holds a [`ChainExportGuard`] must await its work through
93    /// this method — an export that does not race against the cancellation token cannot
94    /// be cancelled and appears stuck until process restart. On cancellation, marks
95    /// the export status as cancelled and returns `None`.
96    pub async fn run_cancellable<F: Future>(&self, fut: F) -> Option<F::Output> {
97        let output = self.cancellation_token.run_until_cancelled(fut).await;
98        if output.is_none() {
99            self.cancel_export();
100        }
101        output
102    }
103}
104
105impl Drop for ChainExportGuard {
106    fn drop(&mut self) {
107        // In case some tasks are waiting on this token
108        self.cancellation_token.cancel();
109        end_export()
110    }
111}
112
113fn update_epoch(new_value: i64) {
114    let status = &*CHAIN_EXPORT_STATUS;
115    status.epoch.store(new_value, atomic::Ordering::Relaxed);
116    _ = status.initial_epoch.compare_exchange(
117        0,
118        new_value,
119        atomic::Ordering::Relaxed,
120        atomic::Ordering::Relaxed,
121    );
122}
123
124fn start_export(cancellation_token: CancellationToken) -> anyhow::Result<()> {
125    let status = &*CHAIN_EXPORT_STATUS;
126    let export_in_progress = status.exporting.swap(true, atomic::Ordering::Relaxed);
127    anyhow::ensure!(
128        !export_in_progress,
129        "An active chain export job has started at {}, start epoch: {}, current epoch: {}",
130        status.start_time().unwrap_or_default(),
131        status.initial_epoch(),
132        status.epoch(),
133    );
134    status.epoch.store(0, atomic::Ordering::Relaxed);
135    status.initial_epoch.store(0, atomic::Ordering::Relaxed);
136    status.cancelled.store(false, atomic::Ordering::Relaxed);
137    status.succeeded.store(false, atomic::Ordering::Relaxed);
138    status.start_time.store(Some(Utc::now().into()));
139    status
140        .cancellation_token
141        .store(Some(cancellation_token.into()));
142    Ok(())
143}
144
145fn export_succeeded() {
146    CHAIN_EXPORT_STATUS
147        .succeeded
148        .store(true, atomic::Ordering::Relaxed);
149}
150
151fn end_export() {
152    CHAIN_EXPORT_STATUS
153        .exporting
154        .store(false, atomic::Ordering::Relaxed);
155    CHAIN_EXPORT_STATUS.cancellation_token.store(None);
156}
157
158fn cancel_export() {
159    let status = &*CHAIN_EXPORT_STATUS;
160    status.cancelled.store(true, atomic::Ordering::Relaxed);
161}
162
163fn should_save_block_to_snapshot(cid: Cid) -> bool {
164    // Don't include identity CIDs.
165    // We only include raw and dagcbor, for now.
166    // Raw for "code" CIDs.
167    if cid.hash().code() == u64::from(MultihashCode::Identity) {
168        false
169    } else {
170        matches!(
171            cid.codec(),
172            crate::shim::crypto::IPLD_RAW | fvm_ipld_encoding::DAG_CBOR
173        )
174    }
175}
176
177/// Depth-first-search iterator for `ipld` leaf nodes.
178///
179/// This iterator consumes the given `ipld` structure and returns leaf nodes (i.e.,
180/// no list or map) in depth-first order. The iterator can be extended at any
181/// point by the caller.
182///
183/// Consider walking this `ipld` graph:
184/// ```text
185/// List
186///  ├ Integer(5)
187///  ├ Link(Y)
188///  └ String("string")
189///
190/// Link(Y):
191/// Map
192///  ├ "key1" => Bool(true)
193///  └ "key2" => Float(3.14)
194/// ```
195///
196/// If we walk the above `ipld` graph (replacing `Link(Y)` when it is encountered), the leaf nodes will be seen in this order:
197/// 1. `Integer(5)`
198/// 2. `Bool(true)`
199/// 3. `Float(3.14)`
200/// 4. `String("string")`
201pub struct DfsIter {
202    dfs: Vec<Ipld>,
203}
204
205impl DfsIter {
206    pub fn new(root: Ipld) -> Self {
207        DfsIter { dfs: vec![root] }
208    }
209}
210
211impl From<Cid> for DfsIter {
212    fn from(cid: Cid) -> Self {
213        DfsIter::new(Ipld::Link(cid))
214    }
215}
216
217impl Iterator for DfsIter {
218    type Item = Ipld;
219
220    fn next(&mut self) -> Option<Self::Item> {
221        while let Some(ipld) = self.dfs.pop() {
222            match ipld {
223                Ipld::List(list) => self.dfs.extend(list.into_iter().rev()),
224                Ipld::Map(map) => self.dfs.extend(map.into_values().rev()),
225                other => return Some(other),
226            }
227        }
228        None
229    }
230}
231
232enum IterateType {
233    Message(Cid),
234    MessageReceipts(Cid),
235    StateRoot(Cid),
236    EventsRoot(Cid),
237}
238
239enum Task {
240    // Yield the block, don't visit it.
241    Emit(Cid, Option<Bytes>),
242    // Visit all the elements, recursively.
243    Iterate(ChainEpoch, Cid, IterateType, Vec<Cid>),
244}
245
246pin_project! {
247    pub struct ChainStream<DB, T, S = CidHashSet> {
248        tipset_iter: T,
249        db: DB,
250        dfs: VecDeque<Task>, // Depth-first work queue.
251        seen: S,
252        stateroot_limit_exclusive: ChainEpoch,
253        fail_on_dead_links: bool,
254        message_receipts: bool,
255        events: bool,
256        tipset_keys:bool,
257        track_progress: bool,
258        n_polled: usize,
259    }
260}
261
262impl<DB, T, S> ChainStream<DB, T, S> {
263    pub fn fail_on_dead_links(mut self, fail_on_dead_links: bool) -> Self {
264        self.fail_on_dead_links = fail_on_dead_links;
265        self
266    }
267
268    pub fn track_progress(mut self, track_progress: bool) -> Self {
269        self.track_progress = track_progress;
270        self
271    }
272
273    /// Whether to enable traversal of message receipt roots during chain export.
274    pub fn with_message_receipts(mut self, message_receipts: bool) -> Self {
275        self.message_receipts = message_receipts;
276        self
277    }
278
279    /// Whether to enable traversal of events roots during chain export.
280    /// Requires message receipts to be enabled as well.
281    pub fn with_events(mut self, events: bool) -> Self {
282        self.events = events;
283        self
284    }
285
286    /// Whether to export tipset keys.
287    pub fn with_tipset_keys(mut self, tipset_keys: bool) -> Self {
288        self.tipset_keys = tipset_keys;
289        self
290    }
291
292    pub fn into_seen(self) -> S {
293        self.seen
294    }
295}
296
297/// Stream all blocks that are reachable before the `stateroot_limit` epoch in a depth-first
298/// fashion.
299/// After this limit, only block headers are streamed. Any dead links are reported as errors.
300///
301/// # Arguments
302///
303/// * `db` - A database that implements [`Blockstore`] interface.
304/// * `tipset_iter` - An iterator of [`Tipset`], descending order `$child -> $parent`.
305/// * `stateroot_limit` - An epoch that signifies how far back (exclusive) we need to inspect tipsets,
306///   in-depth. This has to be pre-calculated using this formula: `$cur_epoch - $depth`, where `$depth`
307///   is the number of `[`Tipset`]` that needs inspection.
308pub fn stream_chain<
309    DB: Blockstore,
310    T: Borrow<Tipset>,
311    ITER: Iterator<Item = T> + Unpin,
312    S: CidHashSetLike,
313>(
314    db: DB,
315    tipset_iter: ITER,
316    stateroot_limit_exclusive: ChainEpoch,
317    seen: S,
318) -> ChainStream<DB, ITER, S> {
319    ChainStream {
320        tipset_iter,
321        db,
322        dfs: VecDeque::new(),
323        seen,
324        stateroot_limit_exclusive,
325        fail_on_dead_links: true,
326        message_receipts: false,
327        events: false,
328        tipset_keys: false,
329        track_progress: false,
330        n_polled: 0,
331    }
332}
333
334// Stream available graph in a depth-first search. All reachable nodes are touched and dead-links
335// are ignored.
336pub fn stream_graph<
337    DB: Blockstore,
338    T: Borrow<Tipset>,
339    ITER: Iterator<Item = T> + Unpin,
340    S: CidHashSetLike,
341>(
342    db: DB,
343    tipset_iter: ITER,
344    stateroot_limit_exclusive: ChainEpoch,
345    seen: S,
346) -> ChainStream<DB, ITER, S> {
347    stream_chain(db, tipset_iter, stateroot_limit_exclusive, seen).fail_on_dead_links(false)
348}
349
350impl<DB: Blockstore, T: Borrow<Tipset>, ITER: Iterator<Item = T> + Unpin, S: CidHashSetLike> Stream
351    for ChainStream<DB, ITER, S>
352{
353    type Item = anyhow::Result<CarBlock>;
354
355    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
356        use Task::*;
357
358        let export_tipset_keys = self.tipset_keys;
359        let fail_on_dead_links = self.fail_on_dead_links;
360        let stateroot_limit_exclusive = self.stateroot_limit_exclusive;
361        let this = self.project();
362
363        // Yield to the runtime every 128 polls to allow cancellation
364        {
365            *this.n_polled += 1;
366            if this.n_polled.is_multiple_of(128) {
367                cx.waker().wake_by_ref();
368                return Poll::Pending;
369            }
370        }
371
372        loop {
373            while let Some(task) = this.dfs.front_mut() {
374                match task {
375                    Emit(_, _) => {
376                        if let Some(Emit(cid, data)) = this.dfs.pop_front() {
377                            if let Some(data) = data {
378                                return Poll::Ready(Some(Ok(CarBlock { cid, data })));
379                            } else if let Some(data) = this.db.get(&cid)? {
380                                return Poll::Ready(Some(Ok(CarBlock {
381                                    cid,
382                                    data: data.into(),
383                                })));
384                            } else if fail_on_dead_links {
385                                return Poll::Ready(Some(Err(anyhow::anyhow!(
386                                    "[Emit] missing key: {cid}"
387                                ))));
388                            };
389                        }
390                    }
391                    Iterate(epoch, block_cid, _type, cid_vec) => {
392                        if *this.track_progress {
393                            update_epoch(*epoch);
394                        }
395                        while let Some(cid) = cid_vec.pop() {
396                            // The link traversal implementation assumes there are three types of encoding:
397                            // 1. DAG_CBOR: needs to be reachable, so we add it to the queue and load.
398                            // 2. IPLD_RAW: WASM blocks, for example. Need to be loaded, but not traversed.
399                            // 3. _: ignore all other links
400                            // Don't revisit what's already been visited.
401                            if should_save_block_to_snapshot(cid) && this.seen.insert(cid)? {
402                                if let Some(data) = this.db.get(&cid)? {
403                                    if cid.codec() == fvm_ipld_encoding::DAG_CBOR {
404                                        let new_values = extract_cids(&data)?;
405                                        cid_vec.extend(new_values.into_iter().rev());
406                                    }
407                                    return Poll::Ready(Some(Ok(CarBlock {
408                                        cid,
409                                        data: data.into(),
410                                    })));
411                                } else if fail_on_dead_links {
412                                    let type_display = match _type {
413                                        IterateType::Message(c) => {
414                                            format!("message {c}")
415                                        }
416                                        IterateType::StateRoot(c) => {
417                                            format!("state root {c}")
418                                        }
419                                        IterateType::MessageReceipts(c) => {
420                                            // Forgive message receipts
421                                            tracing::trace!(
422                                                "[Iterate] missing key: {cid} from message receipts {c} in block {block_cid} at epoch {epoch}"
423                                            );
424                                            continue;
425                                        }
426                                        IterateType::EventsRoot(c) => {
427                                            // Forgive events
428                                            tracing::trace!(
429                                                "[Iterate] missing key: {cid} from events root {c} in block {block_cid} at epoch {epoch}"
430                                            );
431                                            continue;
432                                        }
433                                    };
434                                    return Poll::Ready(Some(Err(anyhow::anyhow!(
435                                        "[Iterate] missing key: {cid} from {type_display} in block {block_cid} at epoch {epoch}"
436                                    ))));
437                                }
438                            }
439                        }
440                        this.dfs.pop_front();
441                    }
442                }
443            }
444
445            // This consumes a [`Tipset`] from the iterator one at a time. The next iteration of the
446            // enclosing loop is processing the queue. Once the desired depth has been reached -
447            // yield the block without walking the graph it represents.
448            if let Some(tipset) = this.tipset_iter.next() {
449                // Tipset key cid can be convert from and to eth hash, which is useful for Eth APIs
450                if export_tipset_keys
451                    && let Ok(CarBlock { cid, data }) = tipset.borrow().key().car_block()
452                {
453                    this.dfs.push_back(Emit(cid, Some(data)));
454                }
455
456                for block in tipset.borrow().block_headers() {
457                    let (cid, data) = block.car_block()?;
458                    if this.seen.insert(cid)? {
459                        if *this.track_progress {
460                            update_epoch(block.epoch);
461                        }
462                        // Make sure we always yield a block otherwise.
463                        this.dfs.push_back(Emit(cid, Some(data.into())));
464
465                        if block.epoch == 0 {
466                            // The genesis block has some kind of dummy parent that needs to be emitted.
467                            for p in &block.parents {
468                                this.dfs.push_back(Emit(p, None));
469                            }
470                        }
471
472                        // Process block messages.
473                        if block.epoch > stateroot_limit_exclusive {
474                            this.dfs.push_back(Iterate(
475                                block.epoch,
476                                *block.cid(),
477                                IterateType::Message(block.messages),
478                                DfsIter::from(block.messages)
479                                    .filter_map(ipld_to_cid)
480                                    .collect(),
481                            ));
482                            if *this.message_receipts {
483                                this.dfs.push_back(Iterate(
484                                    block.epoch,
485                                    *block.cid(),
486                                    IterateType::MessageReceipts(block.message_receipts),
487                                    DfsIter::from(block.message_receipts)
488                                        .filter_map(ipld_to_cid)
489                                        .collect(),
490                                ));
491                            }
492                            // ignore failure as receipts are not required by a lite snapshot
493                            if *this.events
494                                && let Ok(receipts) =
495                                    Receipt::get_receipts(this.db, block.message_receipts)
496                            {
497                                for receipt in receipts {
498                                    if let Some(events_root) = receipt.events_root() {
499                                        this.dfs.push_back(Iterate(
500                                            block.epoch,
501                                            *block.cid(),
502                                            IterateType::EventsRoot(events_root),
503                                            DfsIter::from(events_root)
504                                                .filter_map(ipld_to_cid)
505                                                .collect(),
506                                        ));
507                                    }
508                                }
509                            }
510                        }
511
512                        // Visit the block if it's within required depth. And a special case for `0`
513                        // epoch to match Lotus' implementation.
514                        if block.epoch == 0 || block.epoch > stateroot_limit_exclusive {
515                            // NOTE: In the original `walk_snapshot` implementation we walk the dag
516                            // immediately. Which is what we do here as well, but using a queue.
517                            this.dfs.push_back(Iterate(
518                                block.epoch,
519                                *block.cid(),
520                                IterateType::StateRoot(block.state_root),
521                                DfsIter::from(block.state_root)
522                                    .filter_map(ipld_to_cid)
523                                    .collect(),
524                            ));
525                        }
526                    }
527                }
528            } else {
529                // That's it, nothing else to do. End of stream.
530                return Poll::Ready(None);
531            }
532        }
533    }
534}
535
536pin_project! {
537    pub struct IpldStream<DB, S> {
538        db: DB,
539        cid_vec: Vec<Cid>,
540        seen: S,
541    }
542}
543
544impl<DB, S> IpldStream<DB, S> {
545    pub fn new(db: DB, roots: Vec<Cid>, seen: S) -> Self {
546        Self {
547            db,
548            cid_vec: roots,
549            seen,
550        }
551    }
552}
553
554impl<DB: Blockstore, S: CidHashSetLike> Stream for IpldStream<DB, S> {
555    type Item = anyhow::Result<CarBlock>;
556
557    fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Option<Self::Item>> {
558        let this = self.project();
559        while let Some(cid) = this.cid_vec.pop() {
560            if should_save_block_to_snapshot(cid) && this.seen.insert(cid)? {
561                if let Some(data) = this.db.get(&cid)? {
562                    if cid.codec() == fvm_ipld_encoding::DAG_CBOR {
563                        let new_cids = extract_cids(&data)?;
564                        this.cid_vec.extend(new_cids);
565                    }
566                    return Poll::Ready(Some(Ok(CarBlock {
567                        cid,
568                        data: data.into(),
569                    })));
570                } else {
571                    return Poll::Ready(Some(Err(anyhow::anyhow!("missing key: {cid}"))));
572                }
573            }
574        }
575        // That's it, nothing else to do. End of stream.
576        Poll::Ready(None)
577    }
578}
579
580fn ipld_to_cid(ipld: Ipld) -> Option<Cid> {
581    if let Ipld::Link(cid) = ipld {
582        Some(cid)
583    } else {
584        None
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::blocks::{Chain4U, HeaderBuilder, chain4u};
592    use crate::db::MemoryDB;
593    use crate::utils::db::CborStoreExt as _;
594    use fil_actors_shared::fvm_ipld_amt::Amtv0;
595    use futures::TryStreamExt as _;
596    use fvm_ipld_encoding::RawBytes;
597    use ipld_core::ipld::Ipld;
598    use std::sync::Arc;
599
600    #[tokio::test]
601    async fn return_data_links_are_not_followed_by_the_walk() -> anyhow::Result<()> {
602        let db = Arc::new(MemoryDB::default());
603
604        // A fetchable dag-cbor block, reached only if the walk follows links inside `Return`.
605        let embedded = db.put_cbor_default(&Ipld::String("embedded".into()))?;
606
607        // A link in the receipt (positive control): the walk does reach tag-42 links
608        // structurally embedded in the receipts AMT.
609        let events_root = db.put_cbor_default(&Ipld::String("events-root".into()))?;
610
611        // Build the receipt: `Return` is itself valid dag-cbor encoding a tag-42 link to the
612        // `embedded` block.
613        let receipt = fvm_shared4::receipt::Receipt {
614            exit_code: fvm_shared4::error::ExitCode::OK,
615            return_data: RawBytes::new(serde_ipld_dagcbor::to_vec(&Ipld::Link(embedded))?),
616            gas_used: 0,
617            events_root: Some(events_root),
618        };
619
620        let receipts_root = Amtv0::new_from_iter(&db, std::iter::once(receipt))?;
621
622        // One-block tipset whose `message_receipts` points at the AMT. Epoch 1 (> the stateroot
623        // limit below) so the receipts branch is reached.
624        let c4u = Chain4U::with_blockstore(db.clone());
625        chain4u! {
626            in c4u;
627            [_genesis]
628            -> head @ [_header = HeaderBuilder::new().with_message_receipts(receipts_root)]
629        };
630
631        let mut stream = stream_chain(&db, std::iter::once(head), 0, CidHashSet::default())
632            .with_message_receipts(true)
633            // The embedded target is present; other roots (e.g. default state roots) are not, and a
634            // missing root must not stall the walk.
635            .fail_on_dead_links(false);
636
637        let mut seen = Vec::new();
638        while let Some(block) = stream.try_next().await? {
639            seen.push(block.cid);
640        }
641
642        // Receipts walking is active and reaches the AMT root...
643        assert!(
644            seen.contains(&receipts_root),
645            "receipts AMT root must be reachable with receipts enabled"
646        );
647        // ...and follows the EventsRoot link inside it.
648        assert!(
649            seen.contains(&events_root),
650            "EventsRoot link inside the receipt must be followed"
651        );
652        // But the link inside the opaque `Return` byte string is never followed.
653        assert!(
654            !seen.contains(&embedded),
655            "link embedded in Return must not be followed by the walk"
656        );
657
658        Ok(())
659    }
660
661    /// Pins the invariant documented on [`ChainExportGuard::run_cancellable`].
662    #[tokio::test]
663    #[serial_test::serial(chain_export)]
664    async fn chain_export_cancel_stops_guarded_export() {
665        let g = ChainExportGuard::try_start_export().unwrap();
666
667        // Cancel via the status-global token, as the `Forest.ChainExportCancel` handler does.
668        let token = CHAIN_EXPORT_STATUS.cancellation_token().unwrap();
669
670        let fut = g.run_cancellable(std::future::pending::<()>());
671        token.cancel();
672        assert!(
673            fut.await.is_none(),
674            "cancellation must interrupt the export"
675        );
676
677        assert!(CHAIN_EXPORT_STATUS.cancelled());
678        assert!(CHAIN_EXPORT_STATUS.exporting());
679
680        drop(g);
681        assert!(!CHAIN_EXPORT_STATUS.exporting());
682        assert!(CHAIN_EXPORT_STATUS.cancelled());
683    }
684
685    #[test]
686    #[serial_test::serial(chain_export)]
687    fn test_chain_export_guard() {
688        // First export (Cancel)
689        let g = ChainExportGuard::try_start_export().unwrap();
690        assert!(CHAIN_EXPORT_STATUS.exporting());
691        assert!(!CHAIN_EXPORT_STATUS.succeeded());
692        assert!(!CHAIN_EXPORT_STATUS.cancelled());
693
694        // Another attempt should fail
695        ChainExportGuard::try_start_export().unwrap_err();
696
697        // Cancel
698        g.cancel_export();
699        assert!(CHAIN_EXPORT_STATUS.cancelled());
700
701        // Drop
702        drop(g);
703        assert!(!CHAIN_EXPORT_STATUS.exporting());
704        assert!(!CHAIN_EXPORT_STATUS.succeeded());
705        assert!(CHAIN_EXPORT_STATUS.cancelled());
706
707        // Second export (Success)
708        let g = ChainExportGuard::try_start_export().unwrap();
709        assert!(CHAIN_EXPORT_STATUS.exporting());
710        assert!(!CHAIN_EXPORT_STATUS.succeeded());
711        assert!(!CHAIN_EXPORT_STATUS.cancelled());
712
713        // Another attempt should fail
714        ChainExportGuard::try_start_export().unwrap_err();
715
716        // On success
717        g.mark_as_succeeded();
718        assert!(CHAIN_EXPORT_STATUS.succeeded());
719
720        // Drop
721        drop(g);
722        assert!(!CHAIN_EXPORT_STATUS.exporting());
723        assert!(CHAIN_EXPORT_STATUS.succeeded());
724        assert!(!CHAIN_EXPORT_STATUS.cancelled());
725
726        // Third export (failure)
727        let g = ChainExportGuard::try_start_export().unwrap();
728        assert!(CHAIN_EXPORT_STATUS.exporting());
729        assert!(!CHAIN_EXPORT_STATUS.succeeded());
730        assert!(!CHAIN_EXPORT_STATUS.cancelled());
731
732        // Another attempt should fail
733        ChainExportGuard::try_start_export().unwrap_err();
734
735        // Drop
736        drop(g);
737        assert!(!CHAIN_EXPORT_STATUS.exporting());
738        assert!(!CHAIN_EXPORT_STATUS.succeeded());
739        assert!(!CHAIN_EXPORT_STATUS.cancelled());
740    }
741}