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())

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_s= 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.