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', payload: null,});
// 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());Lifecycle: pause, resume, list, update
Section titled “Lifecycle: pause, resume, list, update”Each schedule is one addressable row in _honker_scheduler_tasks. You can mutate it from any process or binding without touching the table directly.
sched = honker.Scheduler(db)
sched.add(name="recap", queue="emails", schedule=crontab("0 9 * * 1"), payload={"team": "premier-league"})
# Stop firing without losing the row.sched.pause("recap")
# What's scheduled? Each row has cron_expr, payload, priority, enabled, next_fire_at.for row in sched.list(): print(row["name"], row["enabled"], row["next_fire_at"])
# Mutate fields in place. Cron change recomputes next_fire_at from now.sched.update("recap", schedule=crontab("0 9 * * 2")) # change to Tuesdaysched.update("recap", payload={"team": "championship"})
sched.resume("recap")
# Or remove entirely.sched.remove("recap")const sched = new honker.Scheduler(db);
sched.add({ name: "recap", queue: "emails", cron: "0 9 * * 1", payload: { team: "premier-league" } });
sched.pause("recap");sched.list(); // every schedule with statesched.update("recap", { cron: "0 9 * * 2" });sched.resume("recap");sched.remove("recap");pause / resume are idempotent — calling pause on an already-paused schedule returns false. update with no field args is a no-op (returns false). All methods are available in every binding except Java/Kotlin (Python, Node, Bun, Rust crate, Go, Ruby, Elixir, .NET, C++); the JVM bindings don’t expose these lifecycle methods yet — call the honker_scheduler_* SQL functions there instead.
Bounded recurrence (“run N times then stop”)
Section titled “Bounded recurrence (“run N times then stop”)”Honker doesn’t ship a max_runs column — the question of “what counts as N” (fires, claims, successful completions) is application logic. The simple pattern: keep your own counter and unschedule when it hits the bound.
@db.queue("snapshots").task()def snapshot(): take_snapshot() count = increment_counter() # your own table if count >= 10: honker.Scheduler(db).remove("snapshot")This matches what pg-boss does on the Postgres side.
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= 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.