1use crate::{
8 InternalError,
9 ops::{
10 ic::IcOps,
11 runtime::env::EnvOps,
12 storage::async_job_recovery::{AsyncJobOwner, AsyncJobRecoveryOps},
13 },
14 workflow::{placement::acknowledgement::PlacementAcknowledgementWorkflow, runtime},
15};
16use ic_timers::{
17 DeclarationLifetime, OnceContext, OnceRegistration, ScheduleError, TimerCadence,
18 TimerCompletion, TimerDirective, TimerError as ProviderError, TimerIdentity,
19 TimerIdentityError, TimerRegistrationStatus, TimerRunResult, TimerSchedule, TimerSnapshot,
20 WatchdogReconcileState, WatchdogRegistration, WatchdogRunResult, initialize_runtime,
21 reconcile_watchdog, register_once, timer_inventory,
22};
23use std::{
24 cell::{Cell, RefCell},
25 collections::BTreeSet,
26 future::Future,
27 thread::LocalKey,
28 time::Duration,
29};
30use thiserror::Error;
31
32pub const RECOVERY_WATCHDOG_CADENCE: Duration = Duration::from_secs(30);
34
35thread_local! {
36 static CORE_RECOVERY_WATCHDOG: RefCell<Option<WatchdogRegistration>> = const { RefCell::new(None) };
37 static NEXT_LIFECYCLE_ID: Cell<u64> = const { Cell::new(0) };
38 static TIMERS_SUSPENDED: Cell<bool> = const { Cell::new(false) };
39}
40
41#[non_exhaustive]
43#[derive(Debug, Error)]
44pub enum TimerError {
45 #[error("Canic timer claim custody is already borrowed")]
46 CustodyBusy,
47 #[error("Canic lifecycle timer identity allocation is exhausted")]
48 LifecycleIdentityExhausted,
49 #[error(transparent)]
50 Identity(#[from] TimerIdentityError),
51 #[error("Canic timer claim is missing")]
52 MissingClaim,
53 #[error(transparent)]
54 Provider(#[from] ProviderError),
55 #[error("timer registration rollback failed after {primary}: {cleanup}")]
56 RegistrationRollback {
57 primary: Box<Self>,
58 cleanup: Box<Self>,
59 },
60 #[error("Canic timer claim is running and cannot be suspended: {0}")]
61 RunningClaim(String),
62 #[error(transparent)]
63 Schedule(#[from] ScheduleError),
64 #[error("Canic timers are suspended for an authority snapshot")]
65 Suspended,
66 #[error("authority snapshots do not support a timer outside Canic custody: {0}")]
67 UnmanagedClaim(String),
68 #[error("Canic timer claim has the wrong scheduling policy")]
69 WrongPolicy,
70}
71
72impl From<TimerError> for InternalError {
73 fn from(_error: TimerError) -> Self {
74 Self::invariant()
75 }
76}
77
78pub struct TimerAuthorityWorkflow;
80
81impl TimerAuthorityWorkflow {
82 pub(crate) fn initialize_shared_runtime() -> Result<(), TimerError> {
84 initialize_runtime()?;
85 Ok(())
86 }
87
88 pub(crate) fn initialize_nonroot_runtime() -> Result<(), TimerError> {
90 Self::initialize_shared_runtime()
91 }
92
93 pub(crate) fn initialize_root_runtime() -> Result<(), TimerError> {
95 Self::initialize_shared_runtime()
96 }
97
98 pub(crate) fn restore_snapshot_suspension(sealed: bool) {
100 TIMERS_SUSPENDED.with(|suspended| suspended.set(sealed));
101 }
102
103 #[must_use]
105 pub(crate) fn is_suspended() -> bool {
106 TIMERS_SUSPENDED.with(Cell::get)
107 }
108
109 pub(crate) fn ensure_async_job_recovery_watchdog() -> Result<(), TimerError> {
111 require_active()?;
112 if EnvOps::is_root() {
113 return Ok(());
114 }
115 reconcile_core_recovery_watchdog(
116 WatchdogReconcileState::Scheduled,
117 Self::recover_expired_async_jobs,
118 )
119 }
120
121 pub(crate) fn ensure_async_job_recovery_watchdog_with_automatic_topup() -> Result<(), TimerError>
123 {
124 require_active()?;
125 reconcile_core_recovery_watchdog(
126 WatchdogReconcileState::Scheduled,
127 Self::recover_expired_async_jobs_with_automatic_topup,
128 )
129 }
130
131 pub(crate) fn require_root_resumable() -> Result<(), TimerError> {
133 require_no_active_async_job_attempts()?;
134 let mut identities = BTreeSet::from([
135 canister_pool_timer_identity()?,
136 recovery_watchdog_identity()?,
137 ]);
138 for identity in [
139 #[cfg(any(test, feature = "auth-root-delegation-state"))]
140 runtime::auth::RuntimeAuthWorkflow::claimed_root_issuer_renewal_timer_identity()?,
141 runtime::intent::IntentCleanupWorkflow::claimed_timer_identity()?,
142 runtime::log::LogRetentionWorkflow::claimed_timer_identity()?,
143 crate::workflow::metrics::publication::timer::PublicSamplingTimer::claimed_timer_identity()?,
144 runtime::cycles::CycleWorkflow::claimed_timer_identity()?,
145 PlacementAcknowledgementWorkflow::claimed_timer_identity()?,
146 claimed_core_recovery_watchdog_identity()?,
147 ]
148 .into_iter()
149 .flatten()
150 {
151 identities.insert(identity);
152 }
153 require_observed_claims_resumable(
154 &identities,
155 timer_inventory()?
156 .into_timers()
157 .into_iter()
158 .map(|snapshot| (snapshot.identity().clone(), snapshot.registration_status())),
159 )
160 }
161
162 pub(crate) fn require_coordinator_resumable() -> Result<(), TimerError> {
164 require_observed_claims_resumable(
165 &BTreeSet::new(),
166 timer_inventory()?
167 .into_timers()
168 .into_iter()
169 .map(|snapshot| (snapshot.identity().clone(), snapshot.registration_status())),
170 )
171 }
172
173 pub(crate) fn suspend_root() -> Result<(), TimerError> {
175 Self::require_root_resumable()?;
176 TIMERS_SUSPENDED.with(|suspended| suspended.set(true));
177
178 #[cfg(any(test, feature = "auth-root-delegation-state"))]
179 runtime::auth::RuntimeAuthWorkflow::cancel_root_issuer_renewal_timer()?;
180 runtime::intent::IntentCleanupWorkflow::cancel_timer()?;
181 runtime::log::LogRetentionWorkflow::cancel_timer()?;
182 crate::workflow::metrics::publication::timer::PublicSamplingTimer::cancel_timer()?;
183 runtime::cycles::CycleWorkflow::cancel_timer()?;
184 PlacementAcknowledgementWorkflow::cancel_timer()?;
185 cancel_core_recovery_watchdog()?;
186 Ok(())
187 }
188
189 pub(crate) fn suspend_coordinator() -> Result<(), TimerError> {
191 Self::require_coordinator_resumable()?;
192 TIMERS_SUSPENDED.with(|suspended| suspended.set(true));
193 Ok(())
194 }
195
196 pub(crate) fn resume_root() {
198 TIMERS_SUSPENDED.with(|suspended| suspended.set(false));
199 }
200
201 pub(crate) fn resume_coordinator() {
203 TIMERS_SUSPENDED.with(|suspended| suspended.set(false));
204 }
205
206 pub(crate) fn defer_lifecycle_once(
208 delay: Duration,
209 label: impl Into<String>,
210 task: impl Future<Output = ()> + 'static,
211 ) -> Result<(), TimerError> {
212 register_lifecycle_once(delay, label.into(), async move {
213 task.await;
214 TimerRunResult::new(TimerCompletion::success(1), TimerDirective::Stop)
215 })
216 }
217
218 pub(crate) fn defer_lifecycle_result_once(
220 delay: Duration,
221 label: impl Into<String>,
222 task: impl Future<Output = TimerRunResult> + 'static,
223 ) -> Result<(), TimerError> {
224 register_lifecycle_once(delay, label.into(), task)
225 }
226
227 pub(crate) fn recover_expired_async_jobs(now_ns: u64) -> u64 {
229 let mut recovered = 0u64;
230 #[cfg(any(test, feature = "auth-root-delegation-state"))]
231 if runtime::auth::RuntimeAuthWorkflow::recover_expired_root_issuer_renewal(now_ns) {
232 recovered = recovered.saturating_add(1);
233 }
234 if PlacementAcknowledgementWorkflow::recover_expired_timer(now_ns) {
235 recovered = recovered.saturating_add(1);
236 }
237 recovered
238 }
239
240 pub(crate) fn recover_expired_async_jobs_with_automatic_topup(now_ns: u64) -> u64 {
242 let recovered = Self::recover_expired_async_jobs(now_ns);
243 if runtime::cycles::CycleWorkflow::recover_expired_timer(now_ns) {
244 return recovered.saturating_add(1);
245 }
246 recovered
247 }
248
249 pub fn statuses() -> Result<Vec<TimerSnapshot>, TimerError> {
251 Ok(timer_inventory()?.into_timers())
252 }
253}
254
255fn require_no_active_async_job_attempts() -> Result<(), TimerError> {
256 let owners = [
257 #[cfg(any(test, feature = "auth-root-delegation-state"))]
258 (
259 AsyncJobOwner::AuthRenewal,
260 runtime::auth::RuntimeAuthWorkflow::root_issuer_renewal_timer_identity()?,
261 ),
262 (
263 AsyncJobOwner::PlacementReceiptAcknowledgement,
264 PlacementAcknowledgementWorkflow::timer_identity()?,
265 ),
266 (
267 AsyncJobOwner::CanisterPoolMaintenance,
268 canister_pool_timer_identity()?,
269 ),
270 (
271 AsyncJobOwner::CycleTopup,
272 runtime::cycles::CycleWorkflow::timer_identity()?,
273 ),
274 ];
275 for (owner, identity) in owners {
276 if AsyncJobRecoveryOps::active_lease_deadline(owner).is_some() {
277 return Err(TimerError::RunningClaim(format_identity(&identity)));
278 }
279 }
280 Ok(())
281}
282
283fn reconcile_core_recovery_watchdog(
284 desired: WatchdogReconcileState,
285 recover: fn(u64) -> u64,
286) -> Result<(), TimerError> {
287 let identity = recovery_watchdog_identity()?;
288 let cadence = TimerCadence::new(RECOVERY_WATCHDOG_CADENCE)?;
289 CORE_RECOVERY_WATCHDOG
290 .try_with(|registration| {
291 let mut registration = registration
292 .try_borrow_mut()
293 .map_err(|_| TimerError::CustodyBusy)?;
294 reconcile_watchdog(
295 &mut registration,
296 &identity,
297 cadence,
298 desired,
299 move |_context| run_core_recovery_watchdog(recover),
300 )
301 .map_err(TimerError::from)
302 })
303 .map_err(|_| TimerError::CustodyBusy)?
304}
305
306fn claimed_core_recovery_watchdog_identity() -> Result<Option<TimerIdentity>, TimerError> {
307 CORE_RECOVERY_WATCHDOG
308 .try_with(|registration| {
309 let registration = registration
310 .try_borrow()
311 .map_err(|_| TimerError::CustodyBusy)?;
312 Ok(registration
313 .as_ref()
314 .map(|registration| registration.identity().clone()))
315 })
316 .map_err(|_| TimerError::CustodyBusy)?
317}
318
319fn cancel_core_recovery_watchdog() -> Result<(), TimerError> {
320 CORE_RECOVERY_WATCHDOG
321 .try_with(|registration| {
322 let registration = registration
323 .try_borrow()
324 .map_err(|_| TimerError::CustodyBusy)?;
325 if let Some(registration) = registration.as_ref() {
326 registration.cancel()?;
327 }
328 Ok(())
329 })
330 .map_err(|_| TimerError::CustodyBusy)?
331}
332
333fn run_core_recovery_watchdog(recover: fn(u64) -> u64) -> WatchdogRunResult {
334 let recovered = recover(IcOps::now_nanos());
335 let completion = if recovered == 0 {
336 TimerCompletion::no_work()
337 } else {
338 TimerCompletion::success(recovered)
339 };
340 WatchdogRunResult::new(completion, ic_timers::WatchdogDecision::Continue)
341}
342
343pub fn recovery_watchdog_identity() -> Result<TimerIdentity, TimerError> {
344 TimerIdentity::try_new("canic", "async_job_recovery", "watchdog").map_err(Into::into)
345}
346
347fn canister_pool_timer_identity() -> Result<TimerIdentity, TimerError> {
348 TimerIdentity::try_new("canic", "canister_pool", "maintain").map_err(Into::into)
349}
350
351fn register_lifecycle_once(
352 delay: Duration,
353 label: String,
354 task: impl Future<Output = TimerRunResult> + 'static,
355) -> Result<(), TimerError> {
356 require_active()?;
357 let identity = next_lifecycle_identity(label)?;
358 let mut task = Some(task);
359 let registration = register_once(
360 identity,
361 DeclarationLifetime::RemoveWhenStopped,
362 move |_context: OnceContext| {
363 let task = task.take();
364 async move {
365 match task {
366 Some(task) => task.await,
367 None => TimerRunResult::new(
368 TimerCompletion::invariant_failure(0),
369 TimerDirective::Stop,
370 ),
371 }
372 }
373 },
374 )?;
375 if let Err(primary) = registration.ensure_scheduled(TimerSchedule::After(delay)) {
376 return match registration.unregister() {
377 Ok(()) => Err(primary.into()),
378 Err(cleanup) => Err(TimerError::RegistrationRollback {
379 primary: Box::new(primary.into()),
380 cleanup: Box::new(cleanup.into()),
381 }),
382 };
383 }
384 drop(registration);
385 Ok(())
386}
387
388fn next_lifecycle_identity(label: String) -> Result<TimerIdentity, TimerError> {
389 let id = NEXT_LIFECYCLE_ID.with(|next| {
390 let id = next
391 .get()
392 .checked_add(1)
393 .ok_or(TimerError::LifecycleIdentityExhausted)?;
394 next.set(id);
395 Ok::<_, TimerError>(id)
396 })?;
397 TimerIdentity::try_new("canic", format!("lifecycle-{id}"), label).map_err(Into::into)
398}
399
400pub fn require_active() -> Result<(), TimerError> {
401 if TIMERS_SUSPENDED.with(Cell::get) {
402 return Err(TimerError::Suspended);
403 }
404 Ok(())
405}
406
407pub fn with_owned_once<T>(
409 owner: &'static LocalKey<RefCell<Option<OnceRegistration>>>,
410 operation: impl FnOnce(&OnceRegistration) -> T,
411) -> Result<Option<T>, TimerError> {
412 owner
413 .try_with(|registration| {
414 let registration = registration
415 .try_borrow()
416 .map_err(|_| TimerError::CustodyBusy)?;
417 Ok::<_, TimerError>(registration.as_ref().map(operation))
418 })
419 .map_err(|_| TimerError::CustodyBusy)?
420}
421
422pub fn retain_owned_once(
424 owner: &'static LocalKey<RefCell<Option<OnceRegistration>>>,
425 registration: OnceRegistration,
426) -> Result<(), TimerError> {
427 retain_with_rollback(
428 registration,
429 |registration| {
430 owner.with(|current| {
431 let Ok(mut current) = current.try_borrow_mut() else {
432 return Err((TimerError::CustodyBusy, registration));
433 };
434 if current.is_some() {
435 return Err((TimerError::WrongPolicy, registration));
436 }
437 *current = Some(registration);
438 Ok(())
439 })
440 },
441 |registration| registration.unregister().map_err(TimerError::from),
442 )
443}
444
445fn retain_with_rollback<T>(
446 claim: T,
447 retain: impl FnOnce(T) -> Result<(), (TimerError, T)>,
448 cleanup: impl FnOnce(T) -> Result<(), TimerError>,
449) -> Result<(), TimerError> {
450 match retain(claim) {
451 Ok(()) => Ok(()),
452 Err((primary, claim)) => match cleanup(claim) {
453 Ok(()) => Err(primary),
454 Err(cleanup) => Err(TimerError::RegistrationRollback {
455 primary: Box::new(primary),
456 cleanup: Box::new(cleanup),
457 }),
458 },
459 }
460}
461
462fn format_identity(identity: &TimerIdentity) -> String {
463 format!(
464 "{}/{}/{}",
465 identity.owner(),
466 identity.subsystem(),
467 identity.name()
468 )
469}
470
471fn require_observed_claims_resumable(
472 claimed: &BTreeSet<TimerIdentity>,
473 observed: impl IntoIterator<Item = (TimerIdentity, TimerRegistrationStatus)>,
474) -> Result<(), TimerError> {
475 for (identity, registration) in observed {
476 if !claimed.contains(&identity) {
477 return Err(TimerError::UnmanagedClaim(format_identity(&identity)));
478 }
479 if registration == TimerRegistrationStatus::Running {
480 return Err(TimerError::RunningClaim(format_identity(&identity)));
481 }
482 }
483 Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::ops::storage::async_job_recovery::AsyncJobClaim;
490 use std::{cell::Cell, collections::BTreeSet};
491
492 #[test]
493 fn fixed_claim_identities_are_exact_and_unique() {
494 let identities = [
495 runtime::intent::IntentCleanupWorkflow::timer_identity()
496 .expect("intent cleanup identity"),
497 runtime::log::LogRetentionWorkflow::timer_identity().expect("log retention identity"),
498 runtime::auth::RuntimeAuthWorkflow::root_issuer_renewal_timer_identity()
499 .expect("auth renewal identity"),
500 runtime::cycles::CycleWorkflow::timer_identity().expect("cycle top-up identity"),
501 PlacementAcknowledgementWorkflow::timer_identity()
502 .expect("placement acknowledgement identity"),
503 recovery_watchdog_identity().expect("recovery watchdog identity"),
504 crate::workflow::fixture_provisioning::timer::FixtureImportTimer::timer_identity()
505 .expect("fixture import identity"),
506 canister_pool_timer_identity().expect("canister pool identity"),
507 crate::workflow::metrics::publication::timer::PublicSamplingTimer::timer_identity()
508 .expect("public sampling identity"),
509 ];
510 let unique = identities.iter().collect::<BTreeSet<_>>();
511 assert_eq!(unique.len(), identities.len());
512 assert!(
513 identities
514 .iter()
515 .all(|identity| identity.owner() == "canic")
516 );
517 }
518
519 #[test]
520 fn failed_custody_insertion_runs_registration_cleanup() {
521 let cleaned = Cell::new(false);
522 let error = retain_with_rollback(
523 17u8,
524 |claim| Err((TimerError::CustodyBusy, claim)),
525 |claim| {
526 assert_eq!(claim, 17);
527 cleaned.set(true);
528 Ok(())
529 },
530 )
531 .expect_err("custody rejection must propagate");
532
533 assert!(matches!(error, TimerError::CustodyBusy));
534 assert!(cleaned.get());
535 }
536
537 #[test]
538 fn authority_snapshot_rejects_a_claim_outside_canic_custody() {
539 let external = TimerIdentity::try_new("companion-framework", "snapshot", "unmanaged")
540 .expect("external identity");
541 assert!(matches!(
542 require_observed_claims_resumable(
543 &BTreeSet::new(),
544 [(external, TimerRegistrationStatus::Unregistered)]
545 ),
546 Err(TimerError::UnmanagedClaim(identity))
547 if identity == "companion-framework/snapshot/unmanaged"
548 ));
549 }
550
551 #[test]
552 fn authority_snapshot_rejects_a_running_canic_claim() {
553 let identity =
554 TimerIdentity::try_new("canic", "cycles", "topup").expect("Canic timer identity");
555 assert!(matches!(
556 require_observed_claims_resumable(
557 &BTreeSet::from([identity.clone()]),
558 [(identity, TimerRegistrationStatus::Running)]
559 ),
560 Err(TimerError::RunningClaim(identity)) if identity == "canic/cycles/topup"
561 ));
562 }
563
564 #[test]
565 fn authority_snapshot_rejects_a_watchdog_dispatched_async_job_attempt() {
566 let owner = AsyncJobOwner::CanisterPoolMaintenance;
567 AsyncJobRecoveryOps::abandon(owner);
568 assert!(matches!(
569 AsyncJobRecoveryOps::claim(owner, 10, 20),
570 Ok(AsyncJobClaim::Acquired(_))
571 ));
572
573 assert!(matches!(
574 require_no_active_async_job_attempts(),
575 Err(TimerError::RunningClaim(identity))
576 if identity == "canic/canister_pool/maintain"
577 ));
578 AsyncJobRecoveryOps::abandon(owner);
579 }
580}