This tutorial is dedicated to Internet Computer (Dfinity) platform.
After completing it:
This tutorial attempts to give all the information in a beginner-friendly manner, however it is intended for advanced blockchain-developers, who already understand all the basics about Rust canister development for the Internet Computer.
Before we begin, it is recommended to go through these basics again. Here are some good starting points: official website, dedicated to canister development on the IC; developer forum, where you can find an answer to almost any question.
It is a frequent software development task - to automatically execute some kind of task in a particular time of year, month, week or day.
Some examples are:
This tutorial tries to help developers, who want to implement the same kind of functionality on the IC. We are going to build a simple Rust canister, that should greet its users each week at any day of week the user defines.
Complete tutorial code of this project can be found in this repository:
https://github.com/seniorjoinu/ic-cron-time-alignment
To complete this tutorial we would need these tools:
wasm32-unknown-unknown
toolchain
You can use any file system layout you want, but here’s mine:
- src
- actor.rs // файл, опысывающий логику нашего канистера
- common.rs // вспомогательные функции
- build.sh // скрипт билда канистера
- can.did // candid интерфейс нашего канистера
- cargo.toml
- dfx.json
First of all, let’s set up the build procedure for our project. Inside cargo.toml
we need to declare all of the dependencies we would use:
// cargo.toml
[package]
name = "ic-cron-time-alignment"
version = "0.1.0"
edition = "2018"
[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"
[dependencies]
ic-cdk = "0.3"
ic-cdk-macros = "0.3"
serde = "1.0"
ic-cron = "0.5.1"
chrono = "0.4"
now = "0.1"
num-traits = "0.2"
Then inside build.sh
file we have to put this script - it will build and optimize a wasm-module for us:
# build.sh
#!/usr/bin/env bash
cargo build --target wasm32-unknown-unknown --release --package ic-cron-time-alignment && \
ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ic_cron_time_alignment.wasm -o ./target/wasm32-unknown-unknown/release/ic-cron-time-alignment-opt.wasm
And also we need dfx.json
file, in which we would declare our canisters (we only have one) and what are they made of:
{
"canisters": {
"ic-cron-time-alignment": {
"build": "./build.sh",
"candid": "./can.did",
"wasm": "./target/wasm32-unknown-unknown/release/ic-cron-time-alignment-opt.wasm",
"type": "custom"
}
},
"dfx": "0.9.0",
"networks": {
"local": {
"bind": "127.0.0.1:8000",
"type": "ephemeral"
}
},
"version": 1
}
The cron_enqueue()
function from ic-cron, which we’re going to use today for task scheduling has the following signature:
pub fn cron_enqueue<Payload: CandidType>(
payload: Payload,
scheduling_interval: SchedulingInterval,
) -> TaskId;
Let’s dive deep into it to better understand how exactly ic-cron will process our scheduled tasks. This function accepts these two arguments:
payload
, of any type that implements CandidType
(it is a trait, that lets us efficiently serialize data in IC-compatible manner) - this argument allows a developer to attach any kind of data they would need at the moment of execution;scheduling_interval
, of type SchedulingInterval
- this argument is used to set the correct task scheduling settings.
The first argument is not so interesting for us in this tutorial (because it is pretty simple - you just attach any data you want and that’s it), but the second one is very interesting. Let’s take a closer look at it.
The type SchedulingInterval
is defined this way:
#[derive(Clone, Copy, CandidType, Deserialize)]
pub enum Iterations {
Infinite,
Exact(u64),
}
#[derive(Clone, Copy, CandidType, Deserialize)]
pub struct SchedulingInterval {
pub delay_nano: u64,
pub interval_nano: u64,
pub iterations: Iterations,
}
Notice how both of these types are annotated with CandidType
and Deserialize
macros - this enables us to easily transmit them between canisters or even use them as a payload
for task scheduling with ic-cron.
This type has three following fields:
delay_nano
- an amount of nanoseconds the scheduler will wait until executing this task for a first time (if you pass 0
here, the task will be executed at the next consensus round);
interval_nano
- an amount of nanoseconds the scheduler will wait after each execution of the task to execute it again (if you pass 0
here, the task will be executed at every consensus round);
iterations
- enum type that has two values:
Infinite
- this task should be executed infinite number of times, until it is manually descheduled (using cron_dequeue()
function)Exact(u64)
- this task should be executed the exact amount of times.
It is important to remember that time flows differently on any blockchain platform - non-linearly and in spurts. During a single transaction execution time is not moving at all (if you execute time()
function two times in a row they both return the same result). This happens because subnet nodes only synchronize their state (and time) with the consensus protocol which runs in rounds.
Therefore any time-related operations on IC should be performed with that in mind and never rely on time intervals lesser than a single consensus round duration. In IC the consensus is pretty fast (relative to other blockchain platforms) - a single consensus round continues for about two seconds. You should always take it into account.
For example, if you pass 10 seconds inside delay_nano
while scheduling a task, this task won’t necessarily be executed exactly after 10 seconds. More likely it will be executed in an interval between 10 and 12 seconds, despite the fact that ic-cron will do its best to compensate for that error.
So, if for example, you would create an object like:
let nanos_in_sec = 1_000_000_000u64;
let scheduling_interval = SchedulingInterval {
delay_nano: 10 * nanos_in_sec,
interval_nano: 60 * nanos_in_sec,
iterations: Iterations::Exact(10),
};
The task you would use this interval with will be processed like this:
So it looks like, in order to execute a task on some particular day of the week, a developer only needs to carefully calculate delay_nano
and interval_nano
parameters. delay_nano
should equal to an amount of nanoseconds from the moment of task creation to the defined weekday. interval_nano
should equal to constant - the amount of nanoseconds in a week.
First of all, let’s define an enum that would hold weekdays for us:
// common.rs
#[derive(CandidType, Deserialize)]
pub enum DayOfWeek {
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
Sun,
}
impl DayOfWeek {
pub fn to_weekday_num(&self) -> u32 {
match self {
DayOfWeek::Mon => 1,
DayOfWeek::Tue => 2,
DayOfWeek::Wed => 3,
DayOfWeek::Thu => 4,
DayOfWeek::Fri => 5,
DayOfWeek::Sat => 6,
DayOfWeek::Sun => 7,
}
}
}
Yes, we could use the same exact type from chrono
library called Weekday
, but then we would have to implement CandidType
and Deserialize
traits for them by hand, which is not something we want to spend time for.
This enum has nothing interesting at all. Only a little helper-function that returns the number of the weekday in that week.
It would be very nice if we could work with timestamps (which are basically just u64
) in some semantically pleasant way, so let’s define a trait that we’ll use later to work with u64
:
// common.rs
pub trait TimeNanos {
fn to_datetime(&self) -> DateTime<Utc>;
fn nanos_till_next(&self, weekday: DayOfWeek) -> u64;
}
This trait contains only two methods:
to_datetime()
- transforms a u64
timestamp into DateTime
type from chrono
library, that has rich functionality in terms of time manipulations;nanos_till_next(DayOfWeek)
- calculates an amount of nanoseconds between a u64
timestamp and the next day of week provided by user as an argument.
Let’s implement this trait for u64
now, starting with to_datetime()
function:
// common.rs
impl TimeNanos for u64 {
fn to_datetime(&self) -> DateTime<Utc> {
let system_time = UNIX_EPOCH + std::time::Duration::from_nanos(*self);
DateTime::<Utc>::from(system_time)
}
...
}
There is nothing interesting happens here. We just use Rust’s standard library to get UTC time from nanoseconds timestamp we have and then wrap this system time object into DateTime
type.
Now let’s move to the most interesting function here - nanos_till_next()
:
// common.rs
impl TimeNanos for u64 {
fn nanos_till_next(&self, weekday: DayOfWeek) -> u64 {
let current_datetime = self.to_datetime();
let current_weekday_num = current_datetime
.weekday()
.number_from_monday()
.to_i64()
.unwrap();
let target_weekday_num = weekday.to_weekday_num().to_i64().unwrap();
let same_weekday_correction = if target_weekday_num == current_weekday_num {
7
} else {
0
};
let days_till_target =
(same_weekday_correction + target_weekday_num - current_weekday_num).abs();
let duration_since_beginning_of_the_day =
current_datetime - current_datetime.beginning_of_day();
let duration_till_target =
Duration::days(days_till_target) - duration_since_beginning_of_the_day;
duration_till_target
.num_nanoseconds()
.unwrap()
.to_u64()
.unwrap()
}
...
}
First of all, we get current DateTime
from the function we just defined ourselves - to_datetime()
.
Then we get a weekday number of the current DateTime
and also a weekday number of the day of week passed by the user as an argument. Then we calculate the amount of days between current weekday and the target weekday (taking into account the fact that they can be the same).
After that we just wrap that amount of days into a Duration
object and subtract an amount of time that is already passed today since the start of the day (this will make our final result to match exactly 00:00
time of our target weekday). The result we return as nanoseconds.
As you might notice, our functions don’t use any of Internet Computer system APIs. This is great, because it allows us to use standard Rust’s test engine in order to check if we did everything right:
// common.rs
#[cfg(test)]
mod tests {
use crate::common::NANOS_IN_DAY;
use crate::{DayOfWeek, TimeNanos, NANOS_IN_WEEK};
use chrono::{DateTime, NaiveDate, Utc};
use num_traits::ToPrimitive;
fn str_to_datetime(str: &str) -> DateTime<Utc> {
DateTime::<Utc>::from_utc(
NaiveDate::parse_from_str(str, "%d-%m-%Y")
.unwrap()
.and_hms(0, 0, 0),
Utc,
)
}
#[test]
fn works_fine() {
let mon = str_to_datetime("31-01-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let tue = str_to_datetime("08-02-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let wed = str_to_datetime("16-02-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let thu = str_to_datetime("24-02-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let fri = str_to_datetime("04-03-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let sat = str_to_datetime("12-03-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
let sun = str_to_datetime("20-03-2022")
.timestamp_nanos()
.to_u64()
.unwrap();
// checking days consistency
assert_eq!(
mon.nanos_till_next(DayOfWeek::Mon),
NANOS_IN_WEEK,
"Invalid time dif mon"
);
assert_eq!(
tue.nanos_till_next(DayOfWeek::Tue),
NANOS_IN_WEEK,
"Invalid time dif tue"
);
assert_eq!(
wed.nanos_till_next(DayOfWeek::Wed),
NANOS_IN_WEEK,
"Invalid time dif wed"
);
assert_eq!(
thu.nanos_till_next(DayOfWeek::Thu),
NANOS_IN_WEEK,
"Invalid time dif thu"
);
assert_eq!(
fri.nanos_till_next(DayOfWeek::Fri),
NANOS_IN_WEEK,
"Invalid time dif fri"
);
assert_eq!(
sat.nanos_till_next(DayOfWeek::Sat),
NANOS_IN_WEEK,
"Invalid time dif sat"
);
assert_eq!(
sun.nanos_till_next(DayOfWeek::Sun),
NANOS_IN_WEEK,
"Invalid time dif sun"
);
// checking we can reach any day within the next week
assert_eq!(
mon.nanos_till_next(DayOfWeek::Tue),
NANOS_IN_DAY,
"Invalid time dif mon-tue"
);
assert_eq!(
mon.nanos_till_next(DayOfWeek::Wed),
NANOS_IN_DAY * 2,
"Invalid time dif mon-wed"
);
assert_eq!(
mon.nanos_till_next(DayOfWeek::Thu),
NANOS_IN_DAY * 3,
"Invalid time dif mon-thu"
);
assert_eq!(
mon.nanos_till_next(DayOfWeek::Fri),
NANOS_IN_DAY * 4,
"Invalid time dif mon-fri"
);
assert_eq!(
mon.nanos_till_next(DayOfWeek::Sat),
NANOS_IN_DAY * 5,
"Invalid time dif mon-sat"
);
assert_eq!(
mon.nanos_till_next(DayOfWeek::Sun),
NANOS_IN_DAY * 6,
"Invalid time dif mon-sun"
);
}
}
The test is very simple:
By the way, time constants are defined like this:
// common.rs
pub const NANOS_IN_DAY: u64 = 1_000_000_000 * 60 * 60 * 24;
pub const NANOS_IN_WEEK: u64 = NANOS_IN_DAY * 7;
So now we need to define a function that a user will use in order to ask a canister to greet them every defined weekday:
// actor.rs
implement_cron!();
#[update]
pub fn greet_each(weekday: DayOfWeek, name: String) -> TaskId {
cron_enqueue(
name,
SchedulingInterval {
delay_nano: time().nanos_till_next(weekday),
interval_nano: NANOS_IN_WEEK,
iterations: Iterations::Infinite,
},
)
.expect("Unable to enqueue a new cron task")
}
Notice that we use implement_cron!()
macro here. This macro initializes the state of ic-cron for our canister and also extends it with functions we use (like cron_enqueue()
).
Because we already did a good job understanding how cron_enqueue()
works internally, we won’t stop at it again right now. We pass a users name as a payload
here, in order to use it later while greeting.
We almost did it. All we have left is to process all the scheduled tasks inside special system function that is annotated with #[heartbeat]
macro. This function gets executed each consensus round automatically.
// actor.rs
#[heartbeat]
pub fn tick() {
for task in cron_ready_tasks() {
let name: String = task.get_payload().expect("Unable to deserialize a name");
print(format!("Hello, {}", name).as_str());
}
}
Inside it, we just get the name of the user and print “Hello, <username>” message to the console.
This tutorial keeps it simple, but instead of printing to the console you can do any kind of stuff here: from writing something into the state to interacting with remote canisters.
This is it! I hope the tutorial was helpful. Complete source code (with a couple of additional functions) is located in this repo: