canic_core/ops/runtime/
cycles.rs

1pub use crate::ops::storage::cycles::CycleTrackerView;
2
3use crate::{
4    cdk::{futures::spawn, utils::time::now_secs},
5    dto::page::{Page, PageRequest},
6    log,
7    log::Topic,
8    ops::{
9        OPS_CYCLE_TRACK_INTERVAL, OPS_INIT_DELAY,
10        config::ConfigOps,
11        ic::{
12            canister_cycle_balance,
13            timer::{TimerId, TimerOps},
14        },
15        storage::{cycles::CycleTrackerStorageOps, env::EnvOps},
16    },
17    types::Cycles,
18};
19use std::{cell::RefCell, time::Duration};
20
21//
22// TIMER
23//
24
25thread_local! {
26    static TIMER: RefCell<Option<TimerId>> = const { RefCell::new(None) };
27
28    static TOPUP_IN_FLIGHT: RefCell<bool> = const { RefCell::new(false) };
29}
30
31///
32/// Constants
33///
34
35const TRACKER_INTERVAL: Duration = OPS_CYCLE_TRACK_INTERVAL;
36
37///
38/// CycleTrackerOps
39///
40
41pub struct CycleTrackerOps;
42
43impl CycleTrackerOps {
44    /// Start recurring tracking every X seconds
45    /// Safe to call multiple times: only one loop will run.
46    pub fn start() {
47        TIMER.with_borrow_mut(|slot| {
48            if slot.is_some() {
49                return;
50            }
51
52            let init = TimerOps::set(OPS_INIT_DELAY, "cycles:init", async {
53                Self::track();
54
55                let interval =
56                    TimerOps::set_interval(TRACKER_INTERVAL, "cycles:interval", || async {
57                        Self::track();
58                        let _ = Self::purge();
59                    });
60
61                TIMER.with_borrow_mut(|slot| *slot = Some(interval));
62            });
63
64            *slot = Some(init);
65        });
66    }
67
68    /// Stop recurring tracking.
69    pub fn stop() {
70        TIMER.with_borrow_mut(|slot| {
71            if let Some(id) = slot.take() {
72                TimerOps::clear(id);
73            }
74        });
75    }
76
77    pub fn track() {
78        let ts = now_secs();
79        let cycles = canister_cycle_balance().to_u128();
80
81        if !EnvOps::is_root() {
82            Self::evaluate_policies(cycles);
83        }
84
85        CycleTrackerStorageOps::record(ts, cycles);
86    }
87
88    fn evaluate_policies(cycles: u128) {
89        Self::check_auto_topup(cycles);
90    }
91
92    fn check_auto_topup(cycles: u128) {
93        use crate::ops::rpc::cycles_request;
94
95        // Read per-canister configuration.
96        // If no config or no topup policy is defined, nothing to do.
97        let Ok(canister_cfg) = ConfigOps::current_canister() else {
98            return;
99        };
100        let Some(topup) = canister_cfg.topup else {
101            return;
102        };
103
104        // If current balance is above the configured threshold, do not request cycles.
105        if cycles >= topup.threshold.to_u128() {
106            return;
107        }
108
109        // Prevent concurrent or overlapping top-up requests.
110        // This avoids spamming root if multiple ticks fire while a request is in flight.
111        let should_request = TOPUP_IN_FLIGHT.with_borrow_mut(|in_flight| {
112            if *in_flight {
113                false
114            } else {
115                *in_flight = true;
116                true
117            }
118        });
119
120        if !should_request {
121            return;
122        }
123
124        // Perform the top-up asynchronously.
125        // The in-flight flag is cleared regardless of success or failure.
126        spawn(async move {
127            let result = cycles_request(topup.amount.to_u128()).await;
128
129            TOPUP_IN_FLIGHT.with_borrow_mut(|in_flight| {
130                *in_flight = false;
131            });
132
133            match result {
134                Ok(res) => log!(
135                    Topic::Cycles,
136                    Ok,
137                    "requested {}, topped up by {}, now {}",
138                    topup.amount,
139                    Cycles::from(res.cycles_transferred),
140                    canister_cycle_balance()
141                ),
142                Err(e) => log!(Topic::Cycles, Error, "failed to request cycles: {e}"),
143            }
144        });
145    }
146
147    /// Purge old entries based on the retention window.
148    #[must_use]
149    pub fn purge() -> bool {
150        let now = now_secs();
151        let purged = CycleTrackerStorageOps::purge(now);
152
153        if purged > 0 {
154            log!(
155                Topic::Cycles,
156                Info,
157                "cycle_tracker: purged {purged} old entries"
158            );
159        }
160
161        purged > 0
162    }
163
164    #[must_use]
165    pub fn page(request: PageRequest) -> Page<(u64, Cycles)> {
166        let entries = CycleTrackerStorageOps::entries(request);
167        let total = CycleTrackerStorageOps::len();
168
169        Page { entries, total }
170    }
171}