Scheduler
The scheduler dispatches periodic jobs at schedule boundaries. It doesn’t run handlers itself. Instead, it enqueues into named queues that regular workers consume. This separation keeps scheduling lightweight and stops handler failures from affecting firing.
Registrations live in _honker_scheduler_tasks, which means every binding sees the same tasks. A Python process can register a schedule, and a Go worker can consume the enqueued jobs.
Register a task
Section titled “Register a task”import asynciofrom honker import Scheduler, crontab, every_s
scheduler = Scheduler(db)
scheduler.add( name="nightly-backup", queue="backups", schedule=crontab("0 3 * * *"), # every day at 3am local time payload={"target": "s3"}, expires=3600, # auto-drop if unclaimed after 1h)
scheduler.add( name="heartbeat", queue="health", schedule=every_s(1), # every second)
# Run forever. Multiple processes can call this — only one holds# the leader lock and actually fires.asyncio.run(scheduler.run())const { open } = require('@russellthehippo/honker-node');const db = open('app.db');const sched = db.scheduler();
sched.add({ name: 'nightly-backup', queue: 'backups', schedule: '0 3 * * *', payload: { target: 's3' }, expiresS: 3600,});
sched.add({ name: 'heartbeat', queue: 'health', schedule: '@every 1s',});
// Blocks; leader-elected. Cancel via AbortController.const ac = new AbortController();await sched.run('host-a', ac.signal);use honker::{Database, ScheduledTask};use std::sync::{atomic::AtomicBool, Arc};
let db = Database::open("app.db")?;let scheduler = db.scheduler();
scheduler.add(ScheduledTask { name: "nightly-backup".into(), queue: "backups".into(), schedule: "0 3 * * *".into(), payload: serde_json::json!({"target": "s3"}), priority: 0, expires_s: Some(3600),})?;
let stop = Arc::new(AtomicBool::new(false));scheduler.run(stop, "host-a")?; // blocks; leader-electedimport ( "context" honker "github.com/russellromney/honker-go")
db, _ := honker.Open("app.db", "./libhonker_ext.dylib")defer db.Close()
sched := db.Scheduler()sched.Add(honker.SchedulerTask{ Name: "nightly-backup", Queue: "backups", Schedule: "0 3 * * *", Payload: map[string]any{"target": "s3"},})sched.Run(context.Background(), "host-a") // blocksrequire "honker"
db = Honker::Database.new("app.db", extension_path: "./libhonker_ext.dylib")sched = Honker::Scheduler.new(db)
sched.add( name: "nightly-backup", queue: "backups", schedule: "0 3 * * *", payload: {target: "s3"}, expires_s: 3600,)
sched.runimport { open, Scheduler } from "@russellthehippo/honker-bun";
const db = open("app.db", "./libhonker_ext.dylib");const sched = new Scheduler(db);
sched.add({ name: "nightly-backup", queue: "backups", schedule: "0 3 * * *", payload: { target: "s3" }, expiresS: 3600,});
const ac = new AbortController();await sched.run({ owner: "host-a", signal: ac.signal });{:ok, db} = Honker.open("app.db", extension_path: "./libhonker_ext.dylib")
Honker.Scheduler.add(db, name: "nightly-backup", queue: "backups", schedule: "0 3 * * *", payload: %{target: "s3"}, expires_s: 3600)
Honker.Scheduler.run(db, "host-a", fn -> false end)#include "honker.hpp"
#include <atomic>
int main() { honker::Database db{"app.db", "./libhonker_ext.dylib"}; auto sched = db.scheduler();
sched.add( "nightly-backup", "backups", "0 3 * * *", R"({"target":"s3"})", 0, 3600 );
sched.add( "heartbeat", "health", "@every 1s", R"({"ok":true})" );
std::atomic<bool> stop{false}; sched.run(stop, "host-a");}.load ./libhonker_extSELECT honker_bootstrap();
-- Register a recurring task. `cron_expr` can be 5-field cron,-- 6-field cron, or `@every <n><unit>`.SELECT honker_scheduler_register( 'nightly-backup', 'backups', '0 3 * * *', '{"target":"s3"}', 0, 3600);
SELECT honker_scheduler_register( 'heartbeat', 'health', '@every 1s', '{"ok":true}', 0, NULL);
SELECT honker_scheduler_tick(unixepoch());Leader election
Section titled “Leader election”The scheduler acquires an advisory lock named honker-scheduler with a 60-second TTL. A periodic heartbeat extends the TTL during long idle waits. If the leader crashes, the TTL elapses and a standby takes over. Two schedulers running at the same time never double-fire because the lock is the sole gate, backed by BEGIN IMMEDIATE serialization.
This is binding-agnostic: a Python scheduler on one host and a Go scheduler on another host compete for the same lock through the database file. Whichever process holds the lock fires; the others stand by.
Missed-boundary catch-up
Section titled “Missed-boundary catch-up”If the scheduler was down across multiple boundaries, the next tick walks forward and fires each missed boundary. Good for low-frequency schedules like “fire once per hour for the last 6 hours.” For high-frequency schedules where catch-up is unwanted, set expires_s= so stale jobs drop out of the claim window.
Schedule syntax
Section titled “Schedule syntax”Honker accepts three schedule forms:
- 5-field cron:
minute hour day-of-month month day-of-week - 6-field cron:
second minute hour day-of-month month day-of-week @every <n><unit>such as@every 1s,@every 5m,@every 2h
Cron fields support:
*any valueNliteral valueN-Minclusive range*/Kevery K starting at the low endN-M/Krange with stepN,M,Plist
All calendar arithmetic runs in the system local timezone. Set TZ=UTC if you want UTC boundaries.
DST is handled correctly. Spring-forward skips nonexistent local times; fall-back fires once at the earlier (EDT in US/Eastern) instance. Pinned by tests in the Rust cron module.
Where it lives
Section titled “Where it lives”_honker_scheduler_taskshas one row per registered task:(name, queue, cron_expr, payload, priority, expires_s, next_fire_at).cron_exprstores the canonical schedule expression, even for@every ....- Scheduler state is on disk, not in any process’s memory. Every binding sees the same registrations.