DEV Community

Alexander
Alexander

Posted on

Introduction To ic-cron Library

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:

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
}
Enter fullscreen mode Exit fullscreen mode

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 a heartbeat 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:

  1. A task concept - background computation units separated from each other.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
} 
Enter fullscreen mode Exit fullscreen mode

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)