Periodic tasks and timers
Overview
Unlike other blockchains, ICP canisters can automatically execute tasks after a specified delay or interval.
There are two ways to schedule an automatic canister execution on ICP:
- Timers: Single-expiration or periodic canister calls with specified minimum timeout or interval. A canister can implement multiple timers. Each timer will receive a unique ID to identify it within the context of the canister.
- Heartbeats: Legacy periodic canister invocations with intervals close to the blockchain finalization rate (1s). Heartbeats are supported by ICP for backward compatibility and some very special use cases. Newly developed canisters should prefer using timers over the heartbeats.
Timers
Timers are implemented on two layers:
The protocol level implementation: The Internet Computer protocol supports one minimalistic on-shot global timer per canister via
ic0.global_timer_set()
system API call andcanister_global_timer
handler (see the Internet Computer interface specification).The CDK timers library level: The library wraps the minimalistic protocol implementation, adding multiple and periodic timers on top. Canister developers can enjoy the familiar timers functionality using the CDK timers library for:
Internally the CDK timers libraries do the following:
- The library keeps a global list of all periodic tasks inside the canister.
- Calls the
ic0.global_timer_set()
system API to schedule the next task from the list. - Implements the
canister_global_timer
method with the following logic:- For each expired task, the handler initiates a self canister call to isolate the tasks from each other and from the library code. Note, the normal inter-canister call costs costs apply.
- Reschedules periodic tasks at the end of their execution.
- Calls the
ic0.global_timer_set()
system API to schedule the next task.
The library does not handle the canister upgrades. It is up to the canister developer to serialize the timers in the canister_pre_upgrade
hook and reactivate the timers in the canister_post_upgrade
method if necessary.
For the code composability reasons, i.e. to be able to use different libraries with timers in a single project, canister developers are encouraged to use the CDK timers libraries over the protocol level API or the heartbeats.
Single timer example
- Motoko
- Rust
- TypeScript
- Python
system func timer(setGlobalTimer : Nat64 -> ()) : async () {
let next = Nat64.fromIntWrap(Time.now()) + 20_000_000_000;
setGlobalTimer(next); // absolute time in nanoseconds
print("Tick!");
}
use std::time::Duration;
const N: Duration = Duration::from_secs(5);
fn ring() {
ic_cdk::println!("Rust Timer Ring!");
}
#[ic_cdk::init]
fn init() {
let _timer_id = ic_cdk_timers::set_timer_interval(N, ring);
}
#[ic_cdk::post_upgrade]
fn post_upgrade() {
init();
}
import { IDL, setTimer, update } from 'azle';
export default class {
@update([], IDL.Nat64)
createTimer(): bigint {
const timerId = setTimer(1_000n, () =>
console.log('timer callback called')
);
return timerId;
}
}
from kybra import (
Duration,
ic,
query,
Record,
TimerId,
update,
void,
)
class StatusReport(Record):
single: bool
capture: str
class TimerIds(Record):
single: TimerId
capture: TimerId
status: StatusReport = {
"single": False,
"capture": "",
}
@update
def clear_timer(timer_id: TimerId) -> void:
ic.clear_timer(timer_id)
ic.print(f"timer {timer_id} cancelled")
@update
def set_timers(delay: Duration, interval: Duration) -> TimerIds:
captured_value = "🚩"
single_id = ic.set_timer(delay, one_time_timer_callback)
capture_id = ic.set_timer(
delay,
lambda: update_capture_status(captured_value)
or ic.print(f"Timer captured value: {captured_value}"),
)
return {
"single": single_id,
"capture": capture_id,
}
@query
def status_report() -> StatusReport:
return status
def one_time_timer_callback():
status["single"] = True
ic.print("one_time_timer_callback called")
def update_capture_status(value: str):
status["capture"] = value
Kybra canisters must be deployed from a Python virtual environment. Learn more in the Kybra docs.
Multiple timers example
To use multiple timers in a canister, simply create multiple timer definitions:
- Motoko
- Rust
- TypeScript
- Python
In Motoko, call the recurringTimer
function multiple times, setting different durations and callback functions for each timer:
import { print } = "mo:base/Debug";
import { recurringTimer } = "mo:base/Timer";
actor Alarm {
let N1 = 5;
let N2 = 10;
private func ring1() : async () {
print("Motoko Timer 1 Ring!");
};
private func ring2() : async () {
print("Motoko Timer 2 Ring!");
};
ignore recurringTimer<system>(#seconds N1, ring1);
ignore recurringTimer<system>(#seconds N2, ring2);
};
In Rust, call the set_timer_interval
function multiple times, setting different durations and callback functions for each timer:
use std::time::Duration;
const N: Duration = Duration::from_secs(5);
const Y: Duration = Duration::from_secs(20);
fn ring() {
ic_cdk::println!("Rust Timer Ring!");
}
#[ic_cdk::init]
fn init() {
let _timer_id1 = ic_cdk_timers::set_timer_interval(N, ring);
let _timer_id2 = ic_cdk_timers::set_timer_interval(Y, ring);
}
#[ic_cdk::post_upgrade]
fn post_upgrade() {
init();
}
import {
call,
clearTimer,
IDL,
query,
setTimer,
setTimerInterval,
update
} from 'azle';
const StatusReport = IDL.Record({
single: IDL.Bool,
inline: IDL.Int8,
capture: IDL.Text,
repeat: IDL.Int8,
singleCrossCanister: IDL.Vec(IDL.Nat8),
repeatCrossCanister: IDL.Vec(IDL.Nat8)
});
type StatusReport = {
single: boolean;
inline: number;
capture: string;
repeat: number;
singleCrossCanister: Uint8Array;
repeatCrossCanister: Uint8Array;
};
const TimerIds = IDL.Record({
single: IDL.Nat64,
inline: IDL.Nat64,
capture: IDL.Nat64,
repeat: IDL.Nat64,
singleCrossCanister: IDL.Nat64,
repeatCrossCanister: IDL.Nat64
});
type TimerIds = {
single: bigint;
inline: bigint;
capture: bigint;
repeat: bigint;
singleCrossCanister: bigint;
repeatCrossCanister: bigint;
};
let statusReport: StatusReport = {
single: false,
inline: 0,
capture: '',
repeat: 0,
singleCrossCanister: Uint8Array.from([]),
repeatCrossCanister: Uint8Array.from([])
};
export default class {
@update([IDL.Nat64])
clearTimer(timerId: bigint): void {
clearTimer(timerId);
console.info(`timer ${timerId} cancelled`);
}
@update([IDL.Nat64, IDL.Nat64], TimerIds)
setTimers(delay: bigint, interval: bigint): TimerIds {
const capturedValue = '🚩';
const singleId = setTimer(delay, oneTimeTimerCallback);
const inlineId = setTimer(delay, () => {
statusReport.inline = 1;
console.info('Inline timer called');
});
const captureId = setTimer(delay, () => {
statusReport.capture = capturedValue;
console.info(`Timer captured value ${capturedValue}`);
});
const repeatId = setTimerInterval(interval, () => {
statusReport.repeat++;
console.info(`Repeating timer. Call ${statusReport.repeat}`);
});
const singleCrossCanisterId = setTimer(
delay,
singleCrossCanisterTimerCallback
);
const repeatCrossCanisterId = setTimerInterval(
interval,
repeatCrossCanisterTimerCallback
);
return {
single: singleId,
inline: inlineId,
capture: captureId,
repeat: repeatId,
singleCrossCanister: singleCrossCanisterId,
repeatCrossCanister: repeatCrossCanisterId
};
}
@query([], StatusReport)
statusReport(): StatusReport {
return statusReport;
}
}
function oneTimeTimerCallback(): void {
statusReport.single = true;
console.info('oneTimeTimerCallback called');
}
async function singleCrossCanisterTimerCallback(): Promise<void> {
console.info('singleCrossCanisterTimerCallback');
statusReport.singleCrossCanister = await getRandomness();
}
async function repeatCrossCanisterTimerCallback(): Promise<void> {
console.info('repeatCrossCanisterTimerCallback');
statusReport.repeatCrossCanister = Uint8Array.from([
...statusReport.repeatCrossCanister,
...(await getRandomness())
]);
}
async function getRandomness(): Promise<Uint8Array> {
return await call('aaaaa-aa', 'raw_rand', {
returnIdlType: IDL.Vec(IDL.Nat8)
});
}
from kybra import (
Async,
Duration,
ic,
nat8,
query,
Record,
TimerId,
update,
void,
)
from kybra.canisters.management import management_canister
class StatusReport(Record):
single: bool
capture: str
repeat: nat8
class TimerIds(Record):
single: TimerId
capture: TimerId
repeat: TimerId
status: StatusReport = {
"single": False,
"capture": "",
"repeat": 0,
}
@update
def clear_timer(timer_id: TimerId) -> void:
ic.clear_timer(timer_id)
ic.print(f"timer {timer_id} cancelled")
@update
def set_timers(delay: Duration, interval: Duration) -> TimerIds:
captured_value = "🚩"
single_id = ic.set_timer(delay, one_time_timer_callback)
capture_id = ic.set_timer(
delay,
lambda: update_capture_status(captured_value)
or ic.print(f"Timer captured value: {captured_value}"),
)
repeat_id = ic.set_timer_interval(interval, repeat_timer_callback)
return {
"single": single_id,
"capture": capture_id,
"repeat": repeat_id,
}
@query
def status_report() -> StatusReport:
return status
def one_time_timer_callback():
status["single"] = True
ic.print("one_time_timer_callback called")
def repeat_timer_callback():
status["repeat"] += 1
ic.print(f"Repeating timer. Call {status['repeat']}")
def update_capture_status(value: str):
status["capture"] = value
Kybra canisters must be deployed from a Python virtual environment. Learn more in the Kybra docs.
Timers library limitations
Despite its superiority over the heartbeats, the CDK timers library has a few known shortcomings:
- Canister upgrades: the library keeps a global list of multiple and periodic tasks inside the canister heap. During the canister upgrade, a fresh WebAssembly state is created, all the timers are deactivated and the list of timers is cleared. It is up to the canister developer to serialize the timers in the
canister_pre_upgrade
and reactivate them in thecanister_post_upgrade
method if needed. - Self canister calls: to isolate the tasks from each other and from the scheduling logic, CDK timers library initiates a self canister call to execute each task. There are a few implications:
- Normal inter-canister call costs apply to execute each task. Note, timers are still more cost effective than the heartbeats.
- The tasks to execute are added at the end of the canister input queue. Depending on the canister and subnet load, the actual execution might be delayed.
- As the canister output queue is limited in size (500 messages at the moment), this implicitly limits the number of tasks which might be scheduled in one round.
- Advanced scheduling: the CDK timers library uses relative time to schedule tasks. To use an absolute time, canister developers should calculate the duration between now and the point in time, or use a third party library.
Heartbeats
Once an Internet Computer canister exports the canister_heartbeat
function, it will be called every subnet heartbeat interval (see the Internet Computer interface specification).
The only way to disable the heartbeats is to upgrade the canister to a version which does not export the canister_heartbeat
method. Also, the heartbeat interval is implementation-defined, and there is no way to adjust it.
Because of those limitations, in most cases CDK timers library for Rust or Motoko is a better option to schedule periodic tasks.
Frequently asked questions
Do timers support deterministic time slicing (DTS)?
Yes, as the CDK timers library initiates a self canister call to execute each task, normal update message instruction limits apply with DTS enabled.
What happens if a timer handler awaits for a call?
Normal await point rules apply: any new execution can start at the await point: a new message, another timer handler or a heartbeat. Once that new execution is finished or reached its await point, the execution of the current timer handler might be resumed.
What happens if a 1s periodic timer is executing for 2s?
If there are no await points in the timer handler, the periodic timer will be rescheduled at the end of the execution. If there are await points, it's implementation-defined when the periodic timer is rescheduled.
Tutorials and examples
Tutorials and examples using Motoko
- Motoko developer guide: Timers.
- Motoko developer guide: Heartbeats.
Tutorials and examples using Rust
- Backend tutorial: Using timers.
- Example: Periodic tasks and timers (compares the costs of timers and heartbeats).