Architecture

CronLord is roughly 3 000 lines of Crystal split across a scheduler, a set of runners, an HTTP server, and a UI. Everything lives in one process and one binary.

Process layout

+--------------------------------------------------------+
|  cronlord server                                       |
|                                                        |
|  +-------------+   +--------------+   +--------------+ |
|  |  Scheduler  |-->|   Runners    |-->|  LogBuffer   | |
|  |  (tickless) |   | shell / http |   | per-run file | |
|  +-------------+   | claude       |   +--------------+ |
|         |          +--------------+          ^         |
|         v                                    |         |
|  +-------------+   +--------------+           |        |
|  |   SQLite    |<--|  Kemal HTTP  |-----------+        |
|  |  (WAL + FK) |   |   UI + API   |   SSE streams      |
|  +-------------+   +--------------+                    |
|                           |                            |
|                           v                            |
|                    +--------------+                    |
|                    |   Notifier   |  -> webhook POST   |
|                    |  (spawn/fire)|                    |
|                    +--------------+                    |
+--------------------------------------------------------+

One OS process. Crystal fibers multiplex the scheduler loop, the HTTP server, and every concurrent job runner.

Scheduler

src/cronlord/scheduler.cr. Tickless:

  1. Compute the next_after(now) time for every enabled job.
  2. Sleep until the nearest one fires, or until the wakeup channel gets a signal (new job, edited schedule, kick).
  3. Dispatch the due job to the right runner, mark the run running, attach stdout/stderr pumps to the log buffer.
  4. Goto 1.

Two Channels drive the loop:

The scheduler never busy-loops. Idle CPU is literally zero.

Retries

schedule_retry runs inside a separate fiber and uses exponential backoff:

delay = min(base * 2^(attempt - 2), 1800)

Retries get a distinct trigger = "retry-N" so they don’t loop through should_retry? again.

Concurrency caps

Each job has max_concurrent. The scheduler checks the number of running rows for that job before firing; if the cap is hit the run is skipped (not queued) and logged at the scheduler level. v0.2 will add proper queueing.

Executor split

Each job has an executor field:

Workers heartbeat every lease_sec / 2 seconds. If a worker crashes or partitions, the Reaper fiber re-queues runs whose lease_expires_at has passed (runs every 30 s). This keeps jobs progressing even when a worker silently dies.

Runners

Each runner exposes a single module-level run(job, run, buffer) : Int32 and is responsible for:

The three runners share almost no code:

Adding a new kind is ~100 lines: implement run, wire it in scheduler.cr, add a select option in the job editor, document it.

Log buffer

src/cronlord/log_buffer.cr. A thin wrapper around a per-run file in logs/<run_id>.log:

Storage

src/cronlord/db.cr opens SQLite with journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000, and foreign_keys=true. The only external dependency is crystal-lang/crystal-sqlite3.

Migrations are numbered SQL files under db/migrations/. A schema_migrations table records which ones have been applied. The runner strips line comments so trailing -- comments inside statements don’t split them, and splits on top-level semicolons.

Schema at a glance

HTTP server

src/cronlord/server.cr. Kemal for routing, ECR for views. The server class holds the Config and the Scheduler so routes can call scheduler.kick or scheduler.trigger_now.

Views live under src/cronlord/views/ and are baked into the binary via ECR.render. No build step.

Views

CSS is two files: public/css/tokens.css (design tokens) and public/css/base.css (component rules). No build. No framework.

Notifier

src/cronlord/notifier.cr. After mark_finished, the scheduler calls Notifier.deliver(job, run). If the job has args["webhook_url"], the notifier spawns a fiber that POSTs JSON with 3 retries at 2-second intervals and a 5-second per-request timeout. Failures log to stderr and never raise back into the scheduler.

Security surfaces

Crystal trade-offs

Pros:

Cons (honest):

What’s deliberately simple

What’s deliberately missing today