Skip to content

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.

import asyncio
from 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())

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 Tuesday
sched.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 state
sched.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.

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.

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.

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 value
  • N literal value
  • N-M inclusive range
  • */K every K starting at the low end
  • N-M/K range with step
  • N,M,P list

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.

  • _honker_scheduler_tasks has one row per registered task: (name, queue, cron_expr, payload, priority, expires_s, next_fire_at).
  • cron_expr stores the canonical schedule expression, even for @every ....
  • Scheduler state is on disk, not in any process’s memory. Every binding sees the same registrations.