paint-brush
How to Execute Background Tasks on Particular Weekdays with IC-Cron and Chronoby@seniorjoinu
204 reads

How to Execute Background Tasks on Particular Weekdays with IC-Cron and Chrono

by Alexander VtyurinFebruary 15th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This tutorial attempts to give all the information in a beginner-friendly manner. It is intended for advanced blockchain-developers, who already understand all the basics about Rust canister development for the Internet Computer. This tutorial is dedicated to [Internet Computer (Dfinity) platform. After completing it: You will know some advanced Internet Computer canister (smart-contract) development techniques on Rust. You will use [ic-cron] library to execute background canisters tasks. You also use [chrono] and [now] to manage background task scheduling time.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - How to Execute Background Tasks on Particular Weekdays with IC-Cron and Chrono
Alexander Vtyurin HackerNoon profile picture

This tutorial is dedicated to Internet Computer (Dfinity) platform.


After completing it:


  • You will know some advanced Internet Computer canister (smart-contract) development techniques on Rust.
  • You will use IC-Cron library to execute background canisters tasks.
  • You will use Chrono and now Rust libraries to manage background task scheduling time.


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.

Motivation

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:


  • recurrent payments, which one needs to process, for example, each first day of each month at 12PM;
  • database backup creation, which should be executed every each day at 9PM;
  • email distribution, that should occur each week at some particular day and particular time;


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

Project initialization

To complete this tutorial we would need these tools:


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
}

Time management

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:


  • first, the scheduler will wait for at least 10 seconds to pass;
  • then, the scheduler will execute the task for the first time;
  • then, the scheduler will wait for at least a minute and execute it once again;
  • the previous entry will repeat 9 times until the total count of executions reaches 10;
  • after that, the scheduler will deschedule the task and remove it from its memory forever.


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.

Sanity check

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:


  • take a bunch of known weekdays by their dates;
  • make sure, that it is always an exact amount of nanoseconds between these weekdays;
  • make sure, that it is always an exact amount of nanoseconds between Monday and other days.


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;

Using ic-cron

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:

https://github.com/seniorjoinu/ic-cron-time-alignment