LiquidScheduler Walkthrough: Using Scheduled and Recurring Tasks
EOSIO has deprecated deferred transactions. Learn how to schedule tasks in your dApp.
One crucial capability of many web applications is scheduled and recurring tasks. Modern apps need to be able to run maintenance tasks, data updates, and expirations and deadlines of all kinds.
For a decentralized application, it’s important to be able to execute these scheduled tasks in a trustless way.
Without a method for doing so, apps must rely on a centralized scheduling service, which is a vulnerable point. Or, they must rely on workarounds that may not apply well to each situation. One such workaround is, whenever a user of the dApp performs a certain action, checking for whether the deadline for a scheduled task has passed and performing that scheduled task if it has.
Since its launch, EOSIO had a “deferred transaction” feature which could schedule transactions in the future. This seemed useful, but was unreliable, as future transactions were never guaranteed. Actions which relied on deferred transactions had to have manual triggers put in place that could be activated if the deferred transaction failed.
Recent versions of EOSIO have deprecated deferred transactions. Developers must now look for other solutions.
LiquidScheduler is a fully-featured solution for scheduled and recurring tasks, with the same powerful characteristics of other DAPP Network services, such as customizable trustlessness.
LiquidScheduler Unboxed
The easiest way to get started, as usual, is by unboxing and investigating Zeus SDK’s sample box for the service: cron-dapp-service. Let’s check out the cronconsumer.cpp sample contract it gives us:
$ npm i -g @liquidapps/zeus-cmd //if you don’t have Zeus SDK installed$ zeus unbox cron-dapp-service$ cd cron-dapp-service/contracts/eos/cronconsumer$ nano cronconsumer.cpp //or with your favorite editor
Preparing a Contract for LiquidScheduler
As usual, we must include some preliminaries to activate DAPP Network services, LiquidScheduler in particular, for this contract. Some of these will be specific to LiquidScheduler, and differ from preprocessor code for other services:
#include "../dappservices/cron.hpp"#define DAPPSERVICES_ACTIONS() \
XSIGNAL_DAPPSERVICE_ACTION \
CRON_DAPPSERVICE_ACTIONS \#define DAPPSERVICE_ACTIONS_COMMANDS() \
CRON_SVC_COMMANDS()
The internal name for LiquidScheduler — the name you’ll often see in the code — is CRON
.
Again, as usual, we replace the usual class
or CONTRACT
line and the constructor that starts our contract class, as well as the usual ABI dispatcher at the end, with some macros:
#define CONTRACT_NAME() cronconsumerCONTRACT_START() . . .CONTRACT_END((testschedule))
…where testschedule
represents all of the actions in the class. Other actions you may add will be listed here. ((testschedule)(rstschedule))
, for example.
An Uncomplicated Schedule
The cronconsumer.cpp file is a simple one-file contract. Let’s take a look at its only action, testschedule
:
ACTION testschedule() { std::vector<char> payload;
/* SCHEDULE TIMER WITH 2 BEING SECONDS BETWEEN EACH CRON */
schedule_timer(_self, payload, 2);}
(I prefer the ACTION
macro, but the unboxed contract may have [[eosio::action]] void
instead.)
This schedule_timer
function we’ve called will trigger every 2 seconds, and will pass the payload through to a private function named timer_callback
.
Passing In Some Data
The testschedule
function as it stands doesn’t take any arguments, but we could easily require some data be submitted to the action. The second parameter of the schedule_timer
function is a vector<char>
type.
ACTION testschedule(std::vector<char> cronPayload) { schedule_timer(_self, cronPayload, 2);}
We can make this a little more versatile by packing a whole struct into our payload vector:
ACTION testschedule(cron_struct cronPayload) {
std::vector<char> rawPayload = eosio::pack<cron_struct>(cronPayload);
schedule_timer(_self, rawPayload, 2);}
In this case, of course, we’d have to define our cron_struct
, probably in our header file. In this way we could conveniently send in a bunch of data, and then put it back into struct form with something like:
auto payload = eosio::unpack<cron_struct>(rawPayload);
These are good things to know, but let’s return to our original cronconsumer.cpp contract, which doesn’t pass any payload through. After all, some scheduled tasks will not need any special data.
The Callback Function
So all we have for testschedule
is:
ACTION testschedule() { std::vector<char> payload;
/* SCHEDULE TIMER WITH 2 BEING SECONDS BETWEEN EACH CRON */
schedule_timer(_self, payload, 2);}
As it stands, our timer_callback
function should be triggered here every 2 seconds by our DAPP Service Providers. Here’s what a timer_callback
might look like. This is much simpler than the default in cronconsumer.cpp, but it’s the bare minimum we need.
bool timer_callback(name timer, std::vector<char> payload, uint32_t seconds){ // do whatever we need to do here // reschedule the LiquidScheduler job
return true;}
Including this in our contract will result in it — and whatever code we add inside — being executed after the delay in seconds we passed in to schedule_timer
.
Rescheduling
Note that schedule_timer
only schedules one action. Whether or not more actions are scheduled after that depends on what our timer_callback
function returns. Returning false will not reschedule the LiquidScheduler job. Returning true should continue rescheduling it indefinitely.
What if we only want it to run a certain number of times? Perhaps we are continually retrying a task, or the user only gets so many game powerup actions scheduled until they have to take some manual action to reset the maximum.
Let’s say we want to run this thing ten times. Well, we’re going to have to set up a singleton
in our contract to keep track, and that’s exactly what cronconsumer.cpp does:
/* SETUP COUNTER TABLE FOR HOW MANY TIME TO REPEAT CRON */TABLE stat {
uint64_t counter = 0;
};typedef eosio::singleton<"stat"_n, stat> stats_def;
Normal EOSIO stuff here. A singleton
is set up much like a multi_index
table, but is a simpler option if you’re not dealing with multiple rows of data. If you’re unfamiliar with what we’re doing here, you might be in the wrong place. Get the basics from the EOSIO developer docs.
With our singleton
ready to go, we can add some logic to our budding young timer_callback
function:
TABLE stat {
uint64_t counter = 0;
};typedef eosio::singleton<"stat"_n, stat> stats_def;
bool timer_callback(name timer, std::vector<char> payload, uint32_t seconds){ // as I mentioned above, we might want to eosio::unpack our raw payload here to use the data // do whatever we need to do here. Add some game energy? Send out a new recurring invoice? Etc. // Then, we create a singleton, or update our existing one, incrementing the counter value
stats_def statstable(_self, _self.value);
stat newstats;
if(!statstable.exists()){
statstable.set(newstats, _self);
}
else{
newstats = statstable.get();
}
newstats.counter++; statstable.set(newstats, _self); // reschedule only if we’ve run fewer than 10 times
return (newstats.counter < 10);}
There you have it! Now, whatever task we’re executing will run 10 times. We can test this with $ zeus test -c
.
Of course, some other action in our contract may be able to update our TABLE stat
in _self
scope as well — for instance, if the user logs in to our game and so resets his maximum automatic energy boosts — so that could change the actual number of times.
Deploying Our Contract
The process of staking to the LiquidScheduler service offered by some DAPP Service Providers and deploying the contract is the same as our VRAM Kylin/mainnet deployment tutorial.
If you’re running on an EOSIO chain using LiquidX, such as WAX or Telos, some extra deployment steps are involved to link accounts across EOSIO chains. Those steps are described here.
For additional exploration of an app using LiquidScheduler — along with vRAM, LiquidOracles, and LiquidAccounts — check out the code for the sample LiquidPortfolio dApp. You can also view the LiquidScheduler getting started page.