# The cronstable Grand Tour — "Meridian"
**Every cronstable feature, at once, in one coherent deployment.**
Meridian is a fictional global commerce platform. Its entire scheduled
back-office runs on a **nine-node, mutual-TLS, leader-electing cronstable cluster**
(`distribution: spread`) that shares **one durable state store**. On top of that
fleet it runs, all together:
- a realistic **scheduled job set** (ingest → transform → load → report, plus
monitoring and housekeeping),
- **durable-state** jobs (ETL cursors, exactly-once billing, a fleet mutex and a
fleet semaphore, artifacts, run-scoped secrets, durable counters),
- five **orchestration DAGs**, one per pattern (dynamic fan-out ETL, linear
pipeline, static diamond, and two human-gated releases),
- **second-level SLA probes**, and
- **all four failure reporters** wired to live sinks (Mailpit for mail, an echo
server for webhooks, a statsd exporter for metrics, stdout for shell pages),
plus **success** reports; Sentry is one env var away.
Each example is deliberately **small**: every job shows exactly one feature, so
you can read it top to bottom and know what it proves.
If you only run one example, run this one. For a gentler start see
[`example/demo`](../demo) (single node), [`example/dag`](../dag) (DAGs alone),
[`example/job-state`](../job-state) (state primitives alone), or the
[`acme-platform`](../acme-platform) cluster showcase this one extends.
## Run it
```console
docker compose -f example/grand-tour/docker-compose.yml up --build
```
The nine nodes build the image from this repo, so the example works before the
state/DAG features ship in a published release. Then open:
| What | Where |
| --- | --- |
| Node dashboards | (meridian-a), …8081…8088 (b–i) |
| Prometheus metrics (per node) | |
| On-call inbox (Mailpit) | |
| Job metrics (statsd → Prometheus) | |
| Webhook sink | `docker compose -f example/grand-tour/docker-compose.yml logs -f webhook-sink` |
Nine full nodes plus the sinks (and the second-level probes ticking every 2s on
each node) is a real load on a laptop. Trim nodes or probes if it runs hot.
Stop and wipe everything (including the throwaway certs and the state store):
```console
docker compose -f example/grand-tour/docker-compose.yml down -v
```
## How it all coordinates
There is no coordination service beyond the shared state volume:
- **Leader election** defaults to the `gossip` backend: each node serves a
**mutual-TLS** `/peer` endpoint, attests that every peer holds the same
[job-set id](../../README.md#job-set-id), and the quorum elects a leader.
`distribution: spread` gives each `Leader`/`PreferLeader` job (and DAG) its
own **owner** node via rendezvous hashing — watch the dashboard **Owner**
column fan the pipeline across the fleet. (Or swap in the **filesystem**
backend, which elects over a lease file on the shared mount with no certs and
no `/peer` endpoint — see *Things to try*.)
- **Durable state** is a **shared** volume (`topology: shared`). Cursors,
locks, idempotency keys, artifacts and **every `dag_run`** live on it and
coordinate **fleet-wide**, so a task launches exactly once even though all
nine nodes run the identical config. Each node's `cluster:` section is
generated at start by `node-entrypoint.sh`; the job set (`platform.yaml`) is
mounted identically into all nine.
## Feature map
Every `[feature: …]` tag in [`platform.yaml`](platform.yaml) points at the job
or task that demonstrates it. The big ones:
| Area | Feature | Where |
| --- | --- | --- |
| **Scheduling** | cron strings, object form (named fields), `@reboot`, `@daily`, `utc:false` | throughout |
| | **second-level** (object `second:` and 7-field string) | `pulse-liveness`, `pulse-latency` |
| | the trailing **year** field | `fiscal-year-open` |
| | **business-day forms**: `LW`, `L-3`, `15W`, nth weekday `5#3` | `monthly-close-prep`, `payroll-file-transmit`, `invoice-day-run`, `board-pack-render` |
| | timezones (New York / London / Tokyo) + DST advisory | `finance-eod-close`, `eu-/apac-open-report` |
| **Execution** | string vs **argv-list** command, `environment`, **`env_file`** | `orders-ingest`, `pulse-*`, `backup-warehouse` |
| | `streamPrefix` **templating** (`{job_name}`/`{stream_name}`) + empty prefix | `orders-ingest`, `sla-rollup` |
| | `captureStdout/Stderr` off, **`maxLineLength`** | `silent-cleanup`, `log-rotate` |
| | `executionTimeout` + `killTimeout` (job is killed) | `slow-report-generator` |
| | `user`/`group` privilege drop (config only) | `drop-privileges` comment |
| | `enabled: false`, `saveLimit` | `legacy-nightly-sync`, `audit-log-ship` |
| **Failure** | `failsWhen` on **stderr / stdout / always** | `config-lint` / `format-check` / `alert-selftest` |
| | `retry` w/ backoff (**finite** → `onPermanentFailure`) | `webhook-dispatch` |
| | `retry` **forever** (`maximumRetries: -1`, self-heals) | `outbox-flush` |
| | **mail** / **webhook** / **shell** / **sentry** reporters | `cert-expiry-check` / `db-health-orders` / `slow-report-generator` / `fraud-model-refresh` |
| | reporter on **success** (`onSuccess`) | `ops-heartbeat-report` |
| | mail **SMTP auth + HTML**; webhook **method/headers/contentType** | `cert-expiry-check`; `db-health-orders` |
| **Concurrency** | `Forbid` (node) vs `Replace` vs **`concurrencyScope: cluster`** | `warehouse-sync` / `search-reindex` / `inventory-sync` |
| | fleet **mutex** vs fleet **semaphore** (`lock --permits N`) | `compact-warehouse` / `render-thumbnails` |
| **Clustering** | mTLS attestation, quorum election, `distribution: spread` | whole cluster; `node-entrypoint.sh` + `gen-certs.sh` |
| | **filesystem** backend (shared-mount lease, no service) | `BACKEND=filesystem` |
| | `clusterPolicy` Leader / PreferLeader / EveryNode | throughout |
| **Durable state** | **cursor** watermark, default + explicit **`--scope`** | `incremental-orders-export`, `dedup-orders` |
| | **idempotent** exactly-once | `charge-subscriptions` |
| | **artifact** put/**get**/**list** + **secret** + **KV** (string + `--json`) | `build-daily-report` |
| | durable **counter** | `platform-pulse-counter` |
| | catch-up **run-once** vs **run-all** + `startingDeadlineSeconds` + `catchupJitterSeconds` | `daily-reconcile` / `hourly-invoice-emit` |
| | `onlyIfLastSucceeded`, `archiveOutput` + `redactArchivedSecrets` | `daily-reconcile`, `build-daily-report` |
| | `deploymentId` namespacing, `onStoreUnavailable`, `gcGraceSeconds` | `state:` block |
| **Orchestration** | DAG **dynamic fan-out (`expand`)** + XCom + retries + fan-in + catch-up | `orders-etl` |
| | DAG **linear pipeline** + timezone | `nightly-close` |
| | DAG **static diamond** (explicit parallel) + `triggerRule: all_success` | `data-quality-gate` |
| | DAG **approval gate** (`onReject: skip`) + **sensor** + manual trigger | `release-train` |
| | DAG approval **`onReject: fail`** (failure cascade) | `hotfix-release` |
| **Metrics/API** | **statsd** push, native **Prometheus** `/metrics` (map form: public + custom buckets), `web.headers` | `etl-build-facts`, `queue-depth-probe`; every node |
| | opt-in bearer **`authToken`** | `web.authToken` (see *Things to try*) |
| | **iCal feed** (`/calendar.ics`, per-job `.ics`) + the dashboard **week calendar** | any node (see *Things to try*) |
| **Config** | **`include:`**, custom **`logging:`**, a **classic crontab** | `_defaults.yaml`, `legacy.crontab` |
## Self-driving dashboard demos (UTC, no clicks)
The failures are deterministic, driven by the wall-clock minute, so they fire on
their own:
| Time (UTC) | What happens | Feature |
| --- | --- | --- |
| **:05 & :35** | `slow-report-generator` is killed by its `executionTimeout` | timeout handling; shell report |
| **:15–:19** | the four `db-health-*` checks fail together (`exit 69`) | incident correlation; `db-health-orders` posts a **webhook** and **shell**-pages |
| **:25–:29** | `config-lint` "fails" only because it wrote to stderr | `failsWhen: producesStderr` |
| **:35–:39** | `format-check` "fails" only because it wrote to stdout | `failsWhen: producesStdout` |
| **:45–:47** | `cert-expiry-check` fails alone and **mails** on-call (SMTP auth + HTML) | mail report (see Mailpit) |
| **:20 & :40** | `alert-selftest` force-fails and posts a webhook | `failsWhen: always`; alert self-test |
| **:50** | `fraud-model-refresh` fails | Sentry (no-op until `SENTRY_DSN` set) + mail |
| every 5th min | `webhook-dispatch` fails, retries, then mails on permanent failure | finite retry + `onPermanentFailure` |
| every 5 min | `ops-heartbeat-report` succeeds and posts a **success** webhook + mail | `onSuccess` reporter |
| every 5 min | `outbox-flush` fails a few times, then self-heals (never permanent) | `retry: maximumRetries: -1` |
| every **:00** | four `hourly-*` reports collide | Schedule tab thundering-herd warning |
| continuous | `warehouse-sync` (node `Forbid`), `inventory-sync` (**cluster** `Forbid`), `search-reindex` (`Replace` → Cancelled), `render-thumbnails` (2-permit **semaphore**) | concurrency policies |
| every 3 min | the `orders-etl` DAG runs end to end | XCom + fan-out + fan-in |
| every 15 min | the `data-quality-gate` diamond certifies (even UTC hours) or skips `certify` (odd hours, a check fails) | `triggerRule: all_success` cascade |
| every few s | the `pulse-*` probes tick the next-run countdown **in seconds** | second-level scheduling |
## Things to try
1. **Watch the spread.** Open two dashboards side by side and compare each job's
**Owner**. Stop a node
(`docker compose -f example/grand-tour/docker-compose.yml stop meridian-a`)
and watch its owned jobs and DAGs re-home to the survivors.
2. **Lose quorum.** Stop 5 of 9 nodes: `Leader` jobs stand down (dashboard shows
*no quorum*), while `PreferLeader` jobs keep running.
3. **Compare single-leader vs spread.**
`DISTRIBUTION=single-leader docker compose -f example/grand-tour/docker-compose.yml up -d`
and watch every owned job collapse onto one node.
4. **Swap the leadership backend.** Bring the fleet up with the shared-mount
lease instead of the gossip mesh — no certs, no `/peer` endpoint, election is
one file on the state volume (single-leader only):
```console
docker compose -f example/grand-tour/docker-compose.yml down
BACKEND=filesystem docker compose -f example/grand-tour/docker-compose.yml up
```
Stop the leader and watch a follower adopt the lease file within a TTL.
5. **Turn on API auth.** Uncomment `web.authToken` in
[`platform.yaml`](platform.yaml), then:
```console
WEB_TOKEN=s3cret docker compose -f example/grand-tour/docker-compose.yml up -d
curl -i http://localhost:8080/status # 401 Unauthorized
curl -H 'Authorization: Bearer s3cret' http://localhost:8080/status # 200
curl -i http://localhost:8080/metrics # still public (200)
```
The `pulse-*` probes and the release DAG's canary already present `$WEB_TOKEN`,
so they keep working.
6. **Drive the release DAG.** Trigger it, then approve the gate from **any**
node (the decision is a compare-and-set on the shared run document):
```console
curl -X POST http://localhost:8080/dags/release-train/trigger
# -> {"dag":"release-train","runKey":"manual-…"}; then, with that runKey:
curl -X POST \
http://localhost:8080/dags/release-train/runs//tasks/approve/decision \
-H 'Content-Type: application/json' -d '{"decision":"approve","by":"me"}'
```
The `canary` **sensor** then polls the platform's own `/status` until it is
healthy, and `publish` ships the tagged build. Trigger `hotfix-release` the
same way and **reject** its gate to watch the opposite policy — `onReject:
fail` cascades the failure to `deploy` instead of skipping it.
7. **Backfill a DAG.** Deliberately replay `orders-etl` over a past window; each
scheduled instant in the range gets a run (idempotent — re-running the same
window is a no-op):
```console
curl -X POST http://localhost:8080/dags/orders-etl/backfill \
-H 'Content-Type: application/json' \
-d '{"from":"2026-07-05T09:00:00Z","to":"2026-07-05T09:30:00Z"}'
```
8. **Crash-resume a DAG.** While an `orders-etl` run is mid-flight, stop the node
advancing it; another node adopts the run within a lease TTL and finishes it
from durable state — no task double-launches.
9. **Prove durability.** Note `platform-pulse-counter`'s count, then
`restart` the fleet — the counter continues from where it left off (it lives
in the shared store, not memory).
10. **Inspect / back up the store** with the offline state-admin CLI (point `-c`
at the config dir the entrypoint assembled inside any node):
```console
dc="docker compose -f example/grand-tour/docker-compose.yml"
$dc exec meridian-a cronstable -c /tmp/cronstable.d state check # inventory: record counts per prefix
$dc exec meridian-a cronstable -c /tmp/cronstable.d state gc --dry-run # preview reclaimable state
$dc exec meridian-a cronstable -c /tmp/cronstable.d state backup -o /tmp/meridian-state.tar.gz
$dc exec meridian-a cronstable -c /tmp/cronstable.d -v # --validate-config: "Configuration is valid."
```
`check`, `gc --dry-run` and `backup` are safe while the daemon runs;
`restore ` and `migrate --dest ` expect a stopped fleet.
11. **Poke the control API.** `curl http://localhost:8080/status`,
`/jobs`, `/cluster`, `/job-set-id`, `/metrics`, and
`curl -X POST http://localhost:8080/jobs/run-schema-migration/start`.
12. **Put the fleet on your calendar.** Every node serves the schedule as an
iCal feed; subscribe from any calendar app and the overnight maintenance
jobs land on the on-call engineer's week:
```console
curl http://localhost:8080/calendar.ics # the fleet, next 14 days
curl "http://localhost:8080/calendar.ics?days=30&per_job=20" # wider window
curl http://localhost:8080/jobs/monthly-close-prep/calendar.ics # one job
```
With `web.authToken` on, calendar apps cannot send a bearer header, so the
`.ics` routes (only) also accept the token in the URL, the same
secret-address model calendar services use:
`curl "http://localhost:8080/calendar.ics?token=s3cret"`. The dashboard's
**◫ week** button draws the same data as a 7-day calendar: the
business-day jobs (`LW`, `L-3`, `15W`, `5#3`) land where the engine
says, and the per-minute hum is summarized below the grid instead of
flooding it.
## Alternate leadership backends (config only)
Beyond `gossip` and `filesystem`, cronstable can elect over **etcd** or a
**Kubernetes** Lease. They need an external service / control plane, so they are
not wired into this compose file, but the `cluster:` config is a one-liner swap.
etcd (HTTP-only, no extra Python deps):
```yaml
cluster:
backend: etcd
nodeName: meridian-a
etcd:
endpoints: [http://etcd:2379]
electionName: cronstable/leader
ttl: 15
```
Kubernetes (a `coordination.k8s.io` Lease; run one Deployment, no per-node peer
list):
```yaml
cluster:
backend: kubernetes
nodeName: meridian-a # usually the pod name (downward API)
kubernetes:
leaseName: cronstable-leader
leaseNamespace: meridian
clientLibrary: http # hand-rolled HTTP client, no `kubernetes` dep
```
See [`example/kubernetes`](../kubernetes) and
[`example/etcd`](../etcd) for full deployments.
## Files
| File | Purpose |
| --- | --- |
| [`docker-compose.yml`](docker-compose.yml) | the nine nodes plus the Mailpit / statsd / webhook sinks; its header comments list the things to try |
| [`platform.yaml`](platform.yaml) | the annotated job set + DAGs + `state:` + `web:` (mounted identically into all nine nodes) |
| [`_defaults.yaml`](_defaults.yaml) | shared `defaults:` + custom `logging:`, pulled in via `include:` |
| [`legacy.crontab`](legacy.crontab) | a classic Vixie crontab, loaded as-is from the same config dir |
| [`platform.env`](platform.env) | `env_file` for `backup-warehouse` |
| [`secrets/signing.key`](secrets/signing.key) | demo file for a `fromFile` run-scoped secret |
| [`gen-certs.sh`](gen-certs.sh) | mints the throwaway cluster CA + per-node leaf certs (gossip backend) |
| [`node-entrypoint.sh`](node-entrypoint.sh) | generates each node's `cluster:` section (gossip **or** filesystem) and assembles the config dir |
> **Note.** These nodes mount a writable state volume, so they do not run with a
> read-only root filesystem here. The published image still supports the fully
> hardened, non-root, read-only-rootfs deployment (only the state mount needs to
> be writable); see [Production container deployment](../../README.md#production-container-deployment).