Scheduled Tasks
mooR can run a verb for you later, once or on a cadence, without a task sitting in suspend()
between firings. These native schedules live in the kernel's scheduler beside the suspended-task
queue, survive a server restart, and cost nothing while idle: a schedule that fires once an hour
consumes no task, no transaction and no memory beyond its own entry for the other 59 minutes and 59
seconds.
This chapter is for core authors replacing "heartbeat" loops — a wizard task that suspend(1)s
forever and calls everything registered on it — with something the server does for them.
Why not a loop?
The traditional MOO answer to "do this every N seconds" is a forked task:
fork (0)
while (1)
this:tick();
suspend(1);
endwhile
endfork
That works, but every suspended task carries its entire VM state in the tasks database, every wake
is a transaction whether or not there is anything to do, and one such loop calling a hundred
consumers puts all hundred in one transaction: one conflict retries everything, one traceback aborts
everything, and the tick budget is shared. When the loop dies — a wizard kill_tasks the wrong id,
or the retry limit is exhausted — nothing restarts it.
A native schedule inverts this. The kernel holds a deadline and a (object, verb, args) triple.
When the deadline passes, it starts a fresh background task that calls the verb, exactly as fork
would have. Each firing is its own task with its own transaction and its own tick budget; a fault in
one leaves the others alone. The schedule is data, not a task, so it is restored from the tasks
database after a restart with its id and its deadline intact.
The builtins
| Function | Purpose |
|---|---|
schedule_at(obj, verb, when [, args] [, options]) | Run obj:verb(@args) once at Unix time when. Returns an id. |
schedule_every(obj, verb, interval [, args] [, options]) | Run obj:verb(@args) every interval seconds. Returns an id. |
schedule_stop(id) | Cancel. Returns true if something was cancelled, else false. |
schedule_valid(id) | Whether the id refers to a schedule that will fire again. |
schedule_info(id) | A map describing the schedule and its run history. |
schedules([owner]) | Live schedule ids the caller may see. |
schedules_for(obj) | Live schedule ids whose target is obj. |
The full argument-by-argument reference is in Built-in Functions: Server.
A first schedule
// Ring the bell at the top of the next hour.
next_hour = (time() / 3600 + 1) * 3600;
this.bell_schedule = schedule_at(this, "ring", next_hour);
At next_hour, the scheduler starts a background task and calls this:ring(elapsed). That trailing
argument is the number of real seconds since the schedule was last fired (a float; 0.0 the first
time) — see pass_elapsed below. Inside ring, player is this (the target)
unless you said otherwise, caller and caller_perms() are whoever created the schedule, and the
verb runs with its owner's permissions like any other verb.
// Every minute, forever (or until stopped).
this.upkeep_schedule = schedule_every(this, "upkeep", 60);
Recurring deadlines are computed from the previous deadline, not from when the previous firing
finished, so a 60-second schedule fires at t, t+60, t+120, … regardless of how long each
upkeep took or whether it faulted. The next deadline is armed as soon as a firing starts; if it
arrives while that firing is still running, the overlap policy decides what happens.
Schedule ids and transactions
A schedule id is an integer from its own id space; it is never a task id, so kill_task on one is
E_INVARG and schedule_stop on a task id is a quiet false. Ids are never reused, across
restarts included: the tasks database records the highest id handed out, so an id kept in a property
after its schedule stopped or retired cannot come to name a different schedule.
Creation and cancellation are buffered until your task commits, with exactly the semantics of
task_send():
- A schedule created in a task that rolls back —
rollback(), an uncaught error, a conflict retry — never exists. The id you were handed simply never fires. schedule_stop()takes effect when your task commits.- Within the creating task,
schedule_valid(id)is alreadytrueandschedule_stop(id)already works, so code that creates-then-cancels in one transaction behaves sensibly.
Because the schedule is committed with your transaction, storing the id in a property in the same transaction is safe: either both land or neither does.
Stopping and stale ids
schedule_stop(id) never raises for an id that no longer exists. It returns false. A one-shot
that has already fired, a recurring schedule that retired itself, an id from before a database
reload — all false, none an error. This is deliberate: cancelling something that already happened
is an ordinary race, and the calling code should not need a try around it.
It does raise E_PERM if the schedule exists, is live, and you are neither its owner nor a wizard.
Stopping a retired schedule (see below) also returns false, but if you are its owner or a wizard
it discards the retained entry when your task commits, so schedule_info() on that id then raises
E_INVARG.
Retirement
A schedule that will not fire again is retired. It keeps its entry for 60 seconds, so
schedule_info() can tell you why it ended (retire_reason, last_fault, the run counters).
During that time schedule_valid() is false and the id no longer appears in
schedules()/schedules_for(). After 60 seconds, or sooner if you schedule_stop() it, the entry
is dropped: schedule_info() raises E_INVARG as for any unknown id, and the entry's args, state
and fault value stop counting as references to anonymous objects. Retired entries are not saved in
the tasks database, so a restart drops them as well. If you need the outcome for longer, record it
from the verb itself. The reasons, as reported in retire_reason:
retire_reason | Meaning |
|---|---|
"one_shot_done" | A schedule_at fired and did not re-arm. |
"returned_zero" | Under the adaptive protocol, the verb returned 0. |
"negative_return" | Under the adaptive protocol, the verb returned a negative number. |
"max_faults" | The verb faulted max_faults times in a row. |
"invalid_target" | The target was recycled or the verb no longer resolves when the deadline came. |
"deadline_out_of_range" | The next deadline would be later than the latest one a schedule can hold. |
schedule_stop() does not retire; it removes the entry outright.
Deadlines are stored as nanoseconds since the Unix epoch in 64 bits, so the latest deadline a
schedule can hold is 18446744073 (July 2554). schedule_at with a later when, or
schedule_every with an interval that reaches past it, is E_INVARG. A schedule whose next
deadline would pass it — an adaptive verb returning an enormous delay, or a recurring schedule whose
cadence runs off the end — retires with "deadline_out_of_range", counted as a fault.
Options
The optional fifth argument is a map. Keys may be strings or symbols; an unknown key is E_INVARG
at creation, so a typo cannot be silently ignored.
adaptive
Enables the return-value protocol: the fired verb's return value sets the next delay.
| Verb returns | Effect |
|---|---|
positive number n | Fire again in n seconds (int or float). |
0 | Retire ("returned_zero"). |
| negative number | Retire ("negative_return"). |
| anything else | One-shot: retire. Recurring: keep the cadence. |
Default: on for schedule_at, off for schedule_every. A one-shot that returns 0.5 is
therefore a self-rescheduling chain with no loop and no task between links:
// #219:lockup -- called by its own schedule.
if (this:customers_present())
return 30; // check again in half a minute
endif
this:lock_doors();
return 0; // done
With adaptive on for a recurring schedule, a positive return overrides the next interval only;
after that firing the cadence resumes.
catchup
What to do when deadlines passed with nothing running to account for them: the server was down, or the scheduler was stalled and fired a deadline late.
| Value | Behaviour |
|---|---|
"skip" | Advance to the next future deadline on cadence; count the misses in missed_count. Default. |
"once" | Fire once immediately, then resume the cadence. |
"all" | Fire once per missed interval. Use with care: a week of downtime is a lot of firings. |
A one-shot with a past deadline always fires once (there is no cadence to skip along). Deadlines
that pass while a firing is running are governed by overlap, not catchup.
overlap
What to do when a deadline arrives while the previous firing of the same schedule is still running.
| Value | Behaviour |
|---|---|
"skip" | Drop this firing and arm the next deadline; each dropped deadline adds one to overlap_count. Default. |
"queue" | Run it as soon as the current firing finishes. One firing is queued; further deadlines count as "skip". |
"concurrent" | Start it anyway; two tasks now run the verb at once. |
Under "skip" and "queue" the cadence stays in phase. A 1-second schedule whose firing runs from
t to t+2.5 sees the t+1 and t+2 deadlines arrive mid-firing. Under "skip" both are dropped
and the next firing is at t+3. Under "queue" one firing starts at t+2.5, the other deadline
adds one to overlap_count, and the firing after that is at t+3.
jitter
Seconds (int or float, ≥ 0). Each deadline is offset by a uniformly random amount in
[-jitter, +jitter], never earlier than one scheduler tick from now. Give every ambient schedule a
little jitter so a thousand "every 60 seconds" rooms do not all fire on the same tick.
max_faults
Consecutive faults (uncaught error, tick/time limit, or failure to start) before the schedule
retires with "max_faults". 0 means unlimited. Default 50. A successful firing resets the
consecutive count; fault_count in schedule_info() is the lifetime total.
pass_elapsed
Default on. Appends one float argument — real seconds since the schedule's previous firing, or
0.0 on the first — after args (and after state, if any). Verbs that integrate over time
(decay, regeneration, cooling) should use it rather than assuming the nominal interval, because
"skip" catchup and scheduler load both stretch the real gap.
A verb that takes no arguments and does not want one should pass ["pass_elapsed" -> 0]; an
unexpected argument is harmless to most verbs but confusing to read.
state
An opaque MOO value appended to args on every firing (before elapsed). Limited to 4 KB
serialised; larger is E_INVARG. It is constant for the life of the schedule — this is for a cursor
or a key, not a mutable accumulator. Mutable state belongs on the target object.
persist
Default on: the schedule is saved to the tasks database and restored after a restart. Pass
["persist" -> 0] for something that only makes sense within this server run (a debounce timer, a
one-off retry).
A firing that was suspended (in suspend(), read(), a worker call) when the server went down is
restored with the other suspended tasks and still counts as the running firing of its schedule. The
deadline it was serving is not fired again, overlap applies to it as before, and its result
settles the schedule as usual: a one-shot then retires. A recurring schedule is armed at load for
the cadence point after the one being served, with catchup applied to the points that passed while
the server was down, just as if the firing had started normally. If the schedule was stopped, or was
not persistent, the restored task still runs to completion, and its result is ignored.
player
The value of player inside the fired verb. Default: the target object. Set it when the verb
notifies player and the target is not a player — e.g. a room's ambient message that should reach a
particular character. Whichever object ends up as player, the creator must control it (see
Permissions).
Permissions
- The creating task's permissions (as seen by
task_perms()at the moment of the call) become the schedule's owner and its authority principal. The verb must be callable now under those permissions — found on the target or an ancestor, and executable (x) by them, the same check an ordinary method call makes — or creation isE_INVARG: better a loud failure at the call site than a quiet"invalid_target"retirement at three in the morning. - The fired verb runs as its own owner, as every verb does. The authority principal governs verb
lookup at firing time, and inside the verb it is both
callerandcaller_perms(): the call looks as if the creator had made it directly. Acaller == thisguard therefore refuses a schedule made by anyone other than the object itself, andset_task_perms(caller_perms())drops to the creator's permissions. playerin the fired verb — the target, or theplayeroption — must be an object the creator controls: a wizard may name anything; anyone else must be that object or own it, or creation isE_PERM. Verbs authorize onplayer, so letting a programmer choose it freely would let them act as any player.schedule_stop,schedule_info: owner or wizard.schedules(): a wizard sees every live schedule; anyone else sees their own. With anownerargument, wizard or self only.schedules_for(obj): anyone. Recycling an object and cancelling what targets it must work regardless of who created those schedules.
Inspecting schedules
schedule_info(id) returns a string-keyed map. Times are float Unix seconds (0.0 for "never");
durations are integer nanoseconds; absent optional values are 0.
id, target, verb, args, owner, authority, kind ("at"|"every"), interval,
created_at, next_run, last_run,
run_count, fault_count, consecutive_faults, last_fault, missed_count, overlap_count,
last_duration_ns, mean_duration_ns, p99_duration_ns,
running_task (0 if idle; the oldest under "concurrent"), interval_clamped,
retired (bool), retire_reason (string, "" while live),
adaptive, catchup, overlap, jitter, max_faults (0 = unlimited), pass_elapsed, persist,
player, state
A wizard audit that flags anything firing more often than it should is a few lines:
for id in (schedules())
info = schedule_info(id);
if (info["kind"] == "every" && info["interval"] < 1.0)
notify(player, tostr("Schedule ", id, " on ", info["target"], ":", info["verb"], " runs every ", info["interval"], "s"));
endif
endfor
What the kernel does and does not enforce
- Sub-tick intervals are clamped, not rejected.
schedule_every(x, "v", 0.001)fires once per scheduler tick (10 ms by default) andschedule_info()reportsinterval_clamped: true. If your core wants a stricter floor — and for anything a player-facing verb can trigger, it should — put it in the core's wrapper, not here. - Firings are ordinary background tasks. They get
bg_ticks/bg_seconds, they appear inqueued_tasks()while running, they can bekill_tasked (the schedule counts it as a fault), and their output goes to a background session forplayer. - A conflict retry is not a fault. If the firing's transaction conflicts and is retried, the
schedule sees one firing, not two, and
fault_countis untouched. - Recycling the target does not cancel schedules by itself: the next firing finds the target
gone and retires with
"invalid_target". A core'srecyclepath should callschedules_for(obj)andschedule_stopeach id so the retirement never has to happen. - Anonymous objects referenced by a schedule's target, args or state are kept alive by the schedule (it is a GC root).
Recipes
Debounce. Coalesce a burst of events into one handler run:
if (schedule_valid(this.flush_id))
schedule_stop(this.flush_id);
endif
this.flush_id = schedule_at(this, "flush", ftime() + 2.0, {}, ["persist" -> 0]);
Per-object upkeep with catch-up. Let the verb integrate over elapsed so nothing depends on the
schedule being punctual:
// this:regenerate(elapsed)
{elapsed} = args;
this.hp = min(this.max_hp, this.hp + this.regen_per_second * elapsed);
One driver per object, no double drive. Store the id, and check it before creating another:
if (!schedule_valid(this.drive_id))
this.drive_id = schedule_at(this, "drive", ftime());
endif
Replacing a heartbeat registry. Rather than one schedule per consumer, give each class of
consumer a batch verb on one recurring schedule, and have that verb iterate its live instances with
suspend_if_needed() between them. One schedule, one entry in schedules(), and a faulting
instance can be caught per-iteration without losing the batch.