This tutorial is dedicated to Rust smart-contract (canister) development on Internet Computer (Dfinity) platform. Completing it, you’ll know how to use ic-cron library APIs in order to implement any background computation scenario within your IC dapp.
Before digging into this tutorial it is recommended to learn the basics of smart-contract development for the Internet Computer. Here are some good starting points: official website, dedicated to canister development on the IC; IC developer forum, where one could find an answer to almost any technical question.
This tutorial is intended for new developers who want to understand the basics of ic-cron. If you already finished it, it is recommended to check out my other tutorials about this library:
- Extending Sonic With Limit Orders Using ic-cron Library
- How to Execute Background Tasks on Particular Weekdays with IC-Cron and Chrono
- How To Build A Token With Recurrent Payments On The Internet Computer Using ic-cron Library
Motivation
Historically, smart-contracts were never able to run background tasks. On other blockchain platforms, where every network node machine stores every smart-contract there is just no way of implementing this functionality in a scalable way. But the Internet Computer (IC) works differently - the network is split into many small pieces called “subnetworks” each of which is responsible for only a small subset of smart-contracts (canisters) of the whole network. This system architecture really pushes the boundaries of what one can build on blockchain.
Developers often need to write programs, which perform some background computations. There are many examples of that: from notifications and reminders, to bots and email distribution software. Now it is possible to do any of them in smart-contracts, thanks to the IC.
Internet Computer provides canister developers with basic APIs for periodic arbitrary computations. ic-cron library lets developers to easily manage these background computations, extending Internet Computer basic APIs with scheduling functionality.
In this tutorial we’ll go through this functionality of ic-cron in more details, covering advantages of using this library comparing to standard API as well as some examples of how task scheduling with ic-cron works.
Heartbeat
The IC lets us describe any background computation by putting them into special function annotated with the heartbeat
macro. The name of this macro is actually a clue on how it works - it is executed by the subnet node machines automatically once each consensus round (each block). One consensus round, at the moment of writing this article, lasts for about two seconds. This means that the heartbeat
function of any canister is also executed once about each two seconds. This function is intended to be used somehow like this:
#[heartbeat]
fn tick() {
// whatever you want to execute each consensus round
// use `time()` system function, to check for current timestamp
}
An ability to do periodical work in smart-contract is a super-cool feature by itself, that opens a lot of possibilities which previously were completely out of reach for blockchain developers. But it’s pretty obvious that this solution won’t scale well in case of complex logic, when there are many different background computations running simultaneously. The code for such a logic will quickly turn into a hardly readable mess, each change to which will lead to countless hours of debugging. This is because there is no easy way for a developer to return an error from the heartbeat
function (since no one makes a call to it - it triggers automatically).
By the way, never use
caller()
function inside aheartbeat
annotated function! There is no caller, and you won’t see any error - you code will just fail silently.
To fix this scalability problem we only need two more things:
- A task concept - background computation units separated from each other.
- A task scheduler with simple APIs - some kind of service, which would let us manage the execution time of each task.
This is exactly what ic-cron library is.
ic-cron
In other words, ic-cron is just a task scheduler with some utilities. It queues all the tasks, a developer need to schedule, sorting by the soonest execution timestamp of these tasks. This queue is based on BinaryHeap
Rust’s collection, that makes it work really fast and saves cycles.
Despite ic-cron being a library it is not a “pure library”, because it saves its data into canister’s state and uses other system APIs of the IC. Canisters on the IC can’t inherit other canister’s functionality (since they’re just wasm-programs), but we still can “inject” a functionality of one canister into another, “forcefully” implementing the inheritance pattern. And we have to do that with ic-cron in order to get good development experience.
This can be done via Rust’s macro system: ic-cron provides a special macro implement_cron!()
that handles library state initialization and implements some utility functions to work with that state:
-
cron_enqueue()
- adds a new task to the scheduler, putting it in execution queue; -
cron_ready_tasks()
- returns all the tasks which should be executed right now; -
cron_dequeue()
- removes a task from the scheduler, declining its further execution; -
get_cron_state()
- returns a reference to the scheduler’s complete state.
Let’s discuss these functions in more details.
Scheduling a executing of a task
cron_enqueue
function is defined the following way:
pub fn cron_enqueue<Payload: CandidType>(
payload: Payload,
scheduling_interval: SchedulingInterval,
) -> candid::Result<TaskId>
It lets us schedule a new background task, by supplying any data our task will need at the moment of execution (any CandidType
will work just fine) and configuring appropriate execution time settings.
Scheduled tasks should be processed inside the heartbeat
function like that:
#[heartbeat]
fn tick() {
for task in cron_ready_tasks() {
// process the task however you want
}
}
I.e. each time subnet node machines are executing the heartbeat
function, we take all the tasks ready to be processed right at the moment and then process them.
In order to differentiate between task types one can use a Payload
- some data, attached to the task at the moment of scheduling. This data can be anything you want. For example, one could use an enum
:
enum CronTask {
MakeCoffee(CoffeeType),
PlayMusic(Song),
CallPhone(Person),
}
#[heartbeat]
fn tick() {
for task in cron_ready_tasks() {
let task_type: CronTask = task.get_payload().expect("Unable to get a task type");
match task_type {
CronTask::MakeCoffee(coffee_type) => make_coffee(coffee_type),
CronTask::PlayMusic(song) => play_music(song),
CronTask::CallPhone(person) => call_phone(person),
};
}
}
It is possible to schedule tasks for a single (delayed) execution as well as for periodic execution. This is what iterations
parameter of scheduling_interval
argument does:
cron_enqueue(
payload,
SchedulingInterval {
delay_nano: 100, // start after 100 nanoseconds
interval_nano: 0,
iterations: Iterations::Exact(1), // this task will be executed only once
},
).expect("Enqueue failed");
// or
cron_enqueue(
payload,
SchedulingInterval {
delay_nano: 0, // start immediately
interval_nano: 100, // waiting for 100 nanoseconds after each execution
iterations: Iterations::Infinite, // this task will be executed until descheduled manually
},
).expect("Enqueue failed");
delay_nano
and interval_nano
parameters let us define the execution time very flexibly. Any option is possible: execute each day at 12 AM; execute each week starting from next tuesday; execute each 10 seconds; execute each 29th of February - just calculate these parameters correctly and you’re good.
Descheduling a task
cron_enqueue()
function returns TaskId
- a u64
task identifier that the scheduler uses internally. One could use this identifier in order to cancel previously scheduled task via cron_dequeue()
function:
// schedules a task for background execution
let task_id = cron_enqueue(payload, scheduling_interval);
// deschedules the task, preventing its further background execution, until rescheduled again
cron_dequeue(task_id).expect("No such a task");
This function is intended to manually cancel a task that shouldn’t be executed anymore. For example, if you’re building an alarm-clock-canister, you’ll need an instrument to set the alarm off. cron_dequeue()
is the instrument you would use for that.
Using the scheduler’s state
Also ic-cron gives us an ability to read (or write, if you know what you’re doing) current scheduler’s state by using get_cron_state()
function. This function returns an object like:
#[derive(Default, CandidType, Deserialize, Clone)]
pub struct TaskScheduler {
pub tasks: HashMap<TaskId, ScheduledTask>, // tasks by ids
pub queue: TaskExecutionQueue, // task ids by timestamp of their next planned execution
}
You could use this object in different ways. For example you could get a list of all scheduled tasks in order of their execution time:
let tasks: Vec<ScheduledTask> = get_cron_state().get_tasks();
Another thing that could be done with the help of this function and set_cron_state()
function is that one could persist the scheduler’s state in stable memory between canister upgrades:
#[ic_cdk_macros::pre_upgrade]
fn pre_upgrade_hook() {
let cron_state = get_cron_state().clone();
stable_save((cron_state,)).expect("Unable to save the state to stable memory");
}
#[ic_cdk_macros::post_upgrade]
fn post_upgrade_hook() {
let (cron_state,): (TaskScheduler,) =
stable_restore().expect("Unable to restore the state from stable memory");
set_cron_state(cron_state);
}
Doing that way, even after an upgrade your canister will continue to execute scheduled tasks in the same order they should’ve been executed before the upgrade.
Afterword
Despite that ic-cron’s API is tiny, this library lets us define any background computation scenario we could imagine. It gives developers more than it takes back, by using efficient primitives under the hood, which adds only a small computational overhead, and by enabling some really complex functionality to be implemented without losing code readabilty.
And, by the way, it’s open source. Come take a look and give it a try!
https://github.com/seniorjoinu/ic-cron
Thanks for reading!
Top comments (0)