Skip to content

Running Rocky Without an Orchestrator

You do not need Dagster, Airflow, or any orchestration platform to run Rocky on a schedule. The engine already owns the parts that are hard to get right, and a plain timer supplies the one part it does not: deciding when to start.

This guide shows how to drive rocky run from cron, a systemd timer, GitHub Actions, or a warehouse-native scheduler, how to read the exit codes so your alerting is honest, and how to get failure notifications and health checks without a control plane.

If you already run Dagster or Airflow, keep them. The dagster-rocky integration is first-class. This page is for the estates where a timer is enough.

A single rocky run is not a bare SQL script. Inside one invocation the engine:

  • Executes the model DAG in dependency order, with configurable concurrency across independent branches.
  • Retries and self-heals within the run. Failed statements are retried up to max_retries, and on the Databricks and Snowflake adapters a circuit breaker trips after a run of consecutive failures so one broken warehouse connection does not hammer the rest of the run. See the retry and circuit-breaker settings.
  • Reports partial success instead of losing good work. When some models materialize and others fail, the run does not discard what succeeded: it finishes with a partial-success exit code and enumerates the failures, so you keep the current data and know exactly what broke. See per-table error containment.
  • Records runs into embedded state (a local redb store), so rocky history and the audit trail work with no external database. Run-record persistence is best-effort and most complete for replication and transformation runs today.
  • Resumes a failed replication run with rocky run --resume-latest (or --resume <run_id>) — a flag on rocky run, not a separate command. It picks up from the latest recorded progress for the pipeline.
  • Deduplicates with idempotency keys. Pass rocky run --idempotency-key <key>; an in-flight or previously successful run under the same key is skipped rather than double-applied. By default a failed run leaves the key claimable so a retry can proceed; set dedup_on = "any" to also skip after a failure (which forgoes retry under that key). Idempotency keys and resume cannot be combined.
  • Fires lifecycle hooks on pipeline events, so notifications can live in the pipeline definition rather than in the scheduler. Hook coverage is still filling in — the failure hooks fire most reliably on the replication path today — so pair them with the exit-code routing below rather than relying on them alone.

What a timer adds is the trigger and, if you want it, retention of logs. Everything about how the run behaves is already in rocky.toml and the engine. That is the whole idea: the run’s behavior lives with the pipeline definition, not in a separate system you have to keep in sync.

Every recipe below keys off the process exit code. Rocky uses a distinct code per condition so a wrapper script or CI step can branch without parsing output:

Code Meaning Emitted by
0 Success every command
1 Generic hard failure (config error, unreadable state, or an error raised after some models already materialized — a budget breach, say) most commands
2 Partial success — some models materialized, some failed rocky run
3 A Critical health check rocky doctor
4 Compile and tests passed but advisory warnings were emitted rocky ci
130 Interrupted by SIGINT or SIGTERM rocky run

For a scheduled rocky run you will see 0, 1, 2, or 130. Codes 3 and 4 come from rocky doctor and rocky ci, which you may run as a pre-flight (below) or in CI.

Alert on any non-zero exit. Beyond that, one rule earns its keep:

Give exit 2 its own channel. A partial success means the run kept going and produced real, current data for the models that worked, while a subset failed. That is a different operational situation from a hard failure (exit 1). Note that exit 1 is generic: it often means nothing materialized, but it can also fire after some models landed (a budget breach, for one), so inspect the run’s --output json result or rocky history rather than assume the estate is empty. Routing exit 1 and exit 2 to the same place trains people to ignore the alert. A hard failure is a page; a partial success is a ticket for the on-call to look at the failed models before the next run.

130 (interrupted) usually means a deploy or a machine restart cut the run short; treat it as informational unless it repeats.

A small wrapper makes the routing explicit and is reusable across every scheduler:

#!/usr/bin/env bash
# rocky-run.sh — run a pipeline and route notifications by exit code.
set -uo pipefail
cd /srv/analytics
rocky run --output json >> /var/log/rocky/analytics.log 2>&1
code=$?
case "$code" in
0) ;; # success, stay quiet
2) notify "#data-partial" "Rocky partial success (exit 2): some models failed, run continued" ;;
130) ;; # interrupted, informational
*) notify "#data-oncall" "Rocky run FAILED (exit $code)" ;;
esac
exit "$code"

Replace notify with your curl to Slack, mail, or whatever you already use. --output json writes the full per-model result to the log so the on-call can see exactly which models failed without re-running anything.

The classic timer. The only thing cron does not give you for free is overlap protection: if a run takes longer than the interval, the next tick will start a second run on top of the first. Guard it with flock.

/etc/cron.d/rocky-analytics
# Run at 03:00 daily. flock -n makes a still-running previous run skip this tick
# rather than piling a second run on top of it.
0 3 * * * dataeng flock -n /var/lock/rocky-analytics.lock /srv/analytics/rocky-run.sh

flock -n (non-blocking) exits immediately if the lock is held. If you would rather queue the next run than skip it, drop -n and flock will wait for the lock instead. The wrapper script above owns the exit-code routing, so cron itself only needs to acquire the lock and start it.

If you do not want a wrapper, call the CLI directly and let cron mail you on any non-zero exit via MAILTO, but you lose the exit-2-specific channel:

MAILTO=data-oncall@example.com
0 3 * * * dataeng flock -n /var/lock/rocky-analytics.lock rocky -c /srv/analytics/rocky.toml run --output json

On a systemd host, a oneshot service plus a timer is more observable than cron: you get systemctl status, journalctl history, and OnFailure= handlers.

/etc/systemd/system/rocky-analytics.service
[Unit]
Description=Rocky analytics pipeline
After=network-online.target
Wants=network-online.target
# Fire a handler unit on any exit systemd considers a failure.
OnFailure=rocky-analytics-failed@%n.service
[Service]
Type=oneshot
User=dataeng
WorkingDirectory=/srv/analytics
# Pre-flight: fail fast (exit 3) if config or warehouse connectivity is broken.
ExecStartPre=/usr/local/bin/rocky doctor
ExecStart=/srv/analytics/rocky-run.sh
# The wrapper already routes partial success (exit 2) to its own channel, so tell
# systemd that 2 is not a unit failure — otherwise OnFailure fires for it too and
# you get the alert twice. Total failures (exit 1) still trip OnFailure as a backstop.
SuccessExitStatus=2
/etc/systemd/system/rocky-analytics.timer
[Unit]
Description=Run the Rocky analytics pipeline daily
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
# Persistent=true runs a missed occurrence once on the next boot (e.g. after the
# host was down at 03:00), which matches how a single rocky run behaves: it does
# the current work once, it does not replay a backlog of windows.
[Install]
WantedBy=timers.target

Enable it with systemctl enable --now rocky-analytics.timer. The ExecStartPre guard turns a broken deploy into a clean “service failed to start” instead of a half-run against a bad config. If you prefer the platform to do the routing, drop the wrapper, set ExecStart=/usr/local/bin/rocky run --output json, and handle partial-vs-total in the rocky-analytics-failed@ handler unit by reading $EXIT_STATUS.

These systemd and timer units are illustrative templates; adapt the paths, user, and OnCalendar to your host. The exit-code behavior they rely on is verified against the CLI (see the notes at the end of this page).

If your warehouse is reachable from GitHub’s runners, a scheduled workflow needs no infrastructure of your own. Key off the exit code so a partial success is visible but distinct from a hard failure.

.github/workflows/rocky-nightly.yml
name: Rocky nightly
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC daily
workflow_dispatch: {} # allow manual runs from the Actions tab
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rocky
run: curl -fsSL https://raw.githubusercontent.com/rocky-data/rocky/main/engine/install.sh | bash
- name: Pre-flight
run: rocky doctor
- name: Run pipeline
id: rocky
run: |
set +e
rocky run --output json | tee run.json
# ${PIPESTATUS[0]} is rocky's code, not tee's — do not use a bare $? here.
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Classify outcome
if: always()
run: |
case "${{ steps.rocky.outputs.exit_code }}" in
0) echo "Run succeeded." ;;
2) echo "::warning::Partial success — some models failed."; exit 0 ;;
130) echo "Interrupted."; exit 0 ;;
*) echo "::error::Run failed."; exit 1 ;;
esac

Because the run step captures the code instead of failing the job directly, the classify step decides what a red build means. Here a partial success is a warning annotation that keeps the job green (it produced current data), while a total failure fails the job and triggers your normal Actions failure notifications. Flip that policy to taste. Store warehouse credentials as encrypted secrets and pass them as environment variables.

This is a scheduled production run. For running rocky ci on pull requests (compile and test with no warehouse), see the CI/CD guide.

Databricks Workflows and warehouse-native schedulers

Section titled “Databricks Workflows and warehouse-native schedulers”

If your warehouse already has a scheduler, you often do not need a separate host at all. Any scheduler that can run a shell command can run Rocky. On Databricks, a Workflow with a shell or Python task installs the binary and calls the CLI:

Terminal window
# A Databricks task command (or any warehouse-native scheduler's shell step).
set -uo pipefail
curl -fsSL https://raw.githubusercontent.com/rocky-data/rocky/main/engine/install.sh | bash
rocky doctor
rocky run --output json

The task’s exit code propagates to the Workflow run, so the platform’s own retry and alerting policies apply on top of Rocky’s in-run retries. If your scheduler distinguishes exit codes, wire exit 2 to a warning and the rest to a failure exactly as above; if it only sees success or failure, decide whether a partial success should mark the task failed. Snowflake Tasks, Airflow’s BashOperator, and cloud cron services (Cloud Scheduler, EventBridge Scheduler hitting a small runner) all follow the same shape: install, optional pre-flight, rocky run.

The Databricks and warehouse-native snippets are illustrative. They use the same rocky run and rocky doctor invocations verified below, but the surrounding task configuration depends on your platform.

You do not need an orchestrator’s alerting to hear about a failed run. Rocky has two mechanisms that live in the pipeline definition.

A webhook hook posts an HTTP request to Slack, Teams, PagerDuty, or Datadog when the pipeline errors. It sends the event context (run id, event, metadata) directly over HTTP with an optional HMAC signature. Nothing else in your stack has to be running.

rocky.toml
[hook.webhooks.on_pipeline_error]
url = "${SLACK_WEBHOOK_URL}"
preset = "slack"
secret = "${WEBHOOK_SECRET}"

The preset gives you a service-shaped body (Slack Block Kit here) for free; see Hooks for the full list of presets, custom body templates, and HMAC verification.

For an email or Slack message that carries more than “it failed” (recent runs, drift, freshness, quality, cost, and — when the scheduler is in use — holds, failure streaks, and incidents), use a command hook that renders rocky brief and delivers it. brief --output md produces a Slack- and email-ready Markdown document:

rocky.toml
[[hook.on_pipeline_error]]
command = "/srv/analytics/send-brief.sh"
on_failure = "warn"
#!/usr/bin/env bash
# send-brief.sh — post the estate digest when a run fails.
set -uo pipefail
cd /srv/analytics
# Pass --output md explicitly: when stdout is not a terminal (a hook, a pipe, a
# cron job), rocky defaults to JSON, so you must ask for markdown for the digest.
digest="$(rocky brief --since 24h --output md)"
curl -fsS -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg text "$digest" '{text: $text}')"

A webhook hook and a command hook are two different things: the [hook.webhooks.*] block posts the event context over HTTP by itself, while a [[hook.*]] command block runs a program you provide (which is what lets it shell out to rocky brief). Use the webhook for a fast “it failed” ping and the command hook when you want the full digest.

To confirm Rocky picked up either block, run rocky validate — it parses the whole config, both hook tables included. rocky hooks list prints the command hooks Rocky loaded (a [hook.webhooks.*] block will not appear there), so a command hook showing up confirms its event key is one Rocky recognizes. An unknown or misspelled on_<event> key is skipped with a warning rather than firing.

One detail bites timers specifically. Config values like ${SLACK_WEBHOOK_URL} are substituted from the environment, and a referenced variable that is not set makes config loading fail outright (rocky validate returns 1, and so does the run). cron and systemd start with a nearly empty environment, so export the secrets your config references in the timer’s own environment (Environment= or EnvironmentFile= for systemd, a sourced file for cron) or the run will not even start.

Two commands turn a blind timer into an observable one.

rocky doctor runs config, state, adapter, and auth checks and exits 3 if any check is Critical. Putting it before rocky run (the ExecStartPre and pre-flight steps above) turns a broken config or an unreachable warehouse into a clean, early failure instead of a half-completed run — config problems and an unreachable warehouse are Critical. A degraded or unreadable local state store surfaces as a Warning (exit 0), so it will not by itself fail the pre-flight. Run the full battery, or scope to a single check with --check:

Terminal window
rocky doctor # all checks, exits 3 on any Critical
rocky doctor --check config # config only, offline and fast
rocky doctor --check auth # ping the warehouse to catch a rotated credential
rocky doctor --check scheduler # is the reconciler alive and unwedged?

When any pipeline declares a [schedule], rocky doctor also runs a scheduler check (silent otherwise). It reads only the filesystem and the state store — never the warehouse — so it works offline and for both rocky tick and rocky serve --scheduler. It reports Critical when .rocky/tick.lock is held but its heartbeat has gone stale (a wedged reconciler that no restart-free takeover can dislodge; the fix is to restart the holding process), and Warning when no tick has evaluated any schedule in more than twice the shortest cron interval (the timer looks dead). It is a good --check scheduler cron of its own on the host that runs the reconciler.

Runs are recorded in the embedded state store — most completely for replication and transformation runs today. rocky history reads it back with no warehouse round-trip, so it works from the same host your timer runs on:

Terminal window
rocky history # recent runs: id, start, status, model count, trigger
rocky history --model fct_revenue # one model's execution history
rocky history --since 2026-03-01 # runs on or after a date
rocky history --audit # include the governance audit trail

The status column tells partial from total after the fact (Success, PartialFailure, Failure), and trigger records how the run was started — a direct rocky run shows "trigger": "Manual", and a run launched by rocky tick (below) shows "trigger": "Schedule", joined to the tick that started it by a shared submission_id. Combined with rocky run --resume-latest for replication pipelines, this is enough to see what a scheduled run did overnight and pick up a failed one where it left off.

Native scheduling with rocky tick (experimental)

Section titled “Native scheduling with rocky tick (experimental)”

Everything above drives rocky run from a timer and lets the timer decide which pipeline runs when. rocky tick moves that decision into rocky.toml: each pipeline declares its own demand — a cron schedule, an after dependency, or a freshness budget — and a single rocky tick evaluates all of it at once and runs what is due.

There is still no daemon. The tick itself comes from the same cron or systemd timer you already have; you just point the timer at rocky tick on a short interval instead of at one specific rocky run. A one-minute timer turns the declarations below into SLO, cron, and dependency scheduling with nothing resident.

This is experimental while the reconciler soaks. External orchestrators stay first-class — if you run Dagster or Airflow today, keep them.

Scheduling is supported on replication, transformation, quality, and snapshot pipelines. load pipelines cannot participate yet — a load re-ingests every discovered file on each run rather than incrementally, so scheduling one would duplicate data, and it records no run the scheduler can observe. rocky validate rejects a scheduled load and rejects an after that references a load. Native load scheduling is a planned follow-up.

rocky.toml
[pipeline.raw]
type = "replication"
# ...adapter, source, target...
[pipeline.raw.schedule]
cron = "0 3 * * *" # run at 03:00
timezone = "Europe/Lisbon" # IANA name; default is the project [schedule].timezone, else UTC
[pipeline.staging]
type = "transformation"
models = "models/**"
[pipeline.staging.schedule]
after = ["raw"] # run once raw has a newer success than staging's last

cron, after, and freshness can combine on one pipeline — any source being due makes it due. See the [pipeline.*.schedule] reference for every key, the catch-up policy, and the freshness semantics.

/etc/cron.d/rocky-tick
# Evaluate all standing demand every minute. flock makes a still-running tick
# skip the next one rather than stacking a second reconciler on top.
* * * * * analytics cd /srv/analytics && flock -n /var/lock/rocky-tick.lock rocky tick --output json >> /var/log/rocky/tick.log 2>&1

Or a systemd timer:

/etc/systemd/system/rocky-tick.timer
[Unit]
Description=Evaluate Rocky schedule demand every minute
[Timer]
OnCalendar=*:0/1
# Do not stack ticks if one runs long.
AccuracySec=1s
[Install]
WantedBy=timers.target
/etc/systemd/system/rocky-tick.service
[Unit]
Description=Rocky demand reconciler tick
[Service]
Type=oneshot
WorkingDirectory=/srv/analytics
ExecStart=/usr/local/bin/rocky tick --output json
# Exit 2 (a due run failed or was partial) is not a unit failure — the wrapper
# below routes it. Total failures (exit 1) still trip OnFailure.
SuccessExitStatus=2

rocky tick takes its own non-blocking lock (.rocky/tick.lock, next to your config), so two ticks never reconcile at once even if a run outlives its interval — the outer flock above is just a cheap early skip.

The resident scheduler: rocky serve --scheduler (experimental)

Section titled “The resident scheduler: rocky serve --scheduler (experimental)”

The timer approach keeps nothing resident: cron or systemd wakes rocky tick, it reconciles once, and it exits. If you already run rocky serve for the HTTP API, you can instead let the server drive the reconciler in-process, on a poll interval, with the --scheduler flag:

Terminal window
# The API plus a resident reconciler that ticks every 15 seconds (the default).
rocky serve --scheduler
# Tune the cadence and the shutdown drain window.
rocky serve --scheduler --poll-interval-seconds 30 --drain-timeout-seconds 120

It is the same reconciler as rocky tick — the same [schedule] declarations, the same cron/after/freshness evaluation, the same .rocky/tick.lock and schedule_state — hosted inside a long-lived process instead of behind an external timer. A few things the resident form gives you:

  • Runs show up as jobs. Each scheduled run is recorded through the same jobs model as POST /api/v1/jobs/run, so GET /api/v1/jobs/{submission_id} reports it and a restarted server reports honest status for what it launched.
  • It coordinates with API mutations. A scheduler tick and an API run/apply never collide on the state store: whichever is second gets a clean 409 mutation_in_progress (or, for the scheduler, simply skips the tick and re-evaluates next time) rather than racing the writer lock.
  • Config is re-read every tick. Edit rocky.toml under a running server and the next tick picks it up. A parse error on one tick is logged and skipped; the loop never dies on a bad edit and never runs a stale schedule.
  • Shutdown drains. On SIGTERM or Ctrl-C the server stops starting new work, gives a run already in flight up to --drain-timeout-seconds (but never beyond that run’s own timeout_minutes) to finish on its own, and then terminates it — draining in-flight HTTP requests in the same window before the process exits. A run cut short by the drain is recorded as failed and is not retried until its next occurrence. The scheduler also holds its first tick until the server has finished starting up (its job sweep and listener bind), so a scheduled run never precedes — or outlives a failed — server startup.

You can read the scheduler’s state over HTTP:

GET /api/v1/schedule

It reports every scheduled pipeline with its cron/after/freshness configuration, when it last evaluated and fired, its next expected fire, any active backoff, and the claims currently in flight — plus the tick-lock state. Two things to read correctly:

  • tick_lock.state: free is the normal steady state. The lock is held only for the brief duration of a tick, so a healthy scheduler reports free on almost every request. free does not mean no scheduler is running — for that, look at last_evaluated_at against the cadence. held means a tick is in progress right now, and wedged means the lock’s heartbeat has gone stale (a reconciler that needs restarting).
  • A next_fire_at in the past means overdue. The projection is anchored on the last occurrence that actually fired, not on the clock, so a stalled timer reports the slot it missed rather than a healthy-looking future one. Pipelines whose schedule cannot be resolved carry a config_error and never fire — the endpoint surfaces the reason rather than omitting the pipeline. The endpoint reads stored state only; it does not evaluate demand (that is rocky tick --dry-run), so it is a cheap, side-effect-free health read.

The resident reconciler is a single loop, so a scheduled run that hangs holds the loop until the run finishes or is terminated: the server keeps serving HTTP, but no further schedules are evaluated. Set timeout_minutes on a [schedule] so a stuck run is terminated and the loop moves on. Without one, a hung run stalls scheduling until the process is restarted — rocky doctor --check scheduler reports this as a dead timer. Automatic recovery of a wedged reconciler is a planned follow-up; until it lands, bound long runs with timeout_minutes and watch the doctor check.

A minimal systemd unit for the resident form:

/etc/systemd/system/rocky-serve.service
[Unit]
Description=Rocky API + resident scheduler
After=network-online.target
[Service]
WorkingDirectory=/opt/rocky/analytics
ExecStart=/usr/local/bin/rocky serve --scheduler
Restart=on-failure
# Give an in-flight scheduled run time to drain before SIGKILL.
TimeoutStopSec=180
[Install]
WantedBy=multi-user.target

Pick one form or the other, not both against the same project: a rocky tick timer and a rocky serve --scheduler on the same state file are two reconcilers (see below).

Event-driven triggers: webhook ingress (experimental)

Section titled “Event-driven triggers: webhook ingress (experimental)”

The resident scheduler can also accept an HTTP webhook that queues a run demand for a named pipeline, so an external event (a Fivetran sync completing, an upstream job finishing, a manual “run now” button) fires a pipeline without waiting for the next cron slot:

POST /api/v1/hooks/trigger/{pipeline}

The route is live only under --scheduler (nothing else would consume the demand) and is authenticated by its own HMAC, not the --token Bearer token used by the rest of the API. Set a shared secret and sign the raw request body with HMAC-SHA256, hex-encoded, in the X-Rocky-Signature header:

Terminal window
export ROCKY_WEBHOOK_SECRET='a-long-random-secret'
rocky serve --scheduler # in another shell
# Sign an (empty) body and trigger the `orders` pipeline.
BODY=''
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ROCKY_WEBHOOK_SECRET" | awk '{print $2}')
curl -sS -X POST http://127.0.0.1:8080/api/v1/hooks/trigger/orders \
-H "X-Rocky-Signature: $SIG" \
-H 'X-Rocky-Delivery: evt-2026-05-02-0001' \
--data-binary "$BODY"
# → 202 {"demand":"accepted","demand_uid":"…"}

X-Rocky-Delivery is an optional idempotency key, and it changes the guarantee you get. Every accepted demand is at-most-once regardless. But whether the same event runs at most once depends on the header:

  • With X-Rocky-Delivery: a redelivery of the same id is deduplicated (idempotently 202 {"demand":"duplicate"}) for 24 hours after the demand is consumed, so a sender that retries the same event does not double-fire the pipeline. This is the mode to use if your sender retries.
  • Without it (body-hash fallback): the demand deduplicates on the body hash only while it is still queued; an identical body delivered again after consumption is a new demand and fires again. So for a delivery-id-less sender the guarantee is at-least-once across retries — pair it with a freshness schedule (below) so a re-fire is at worst a redundant refresh, not a correctness problem.

Fail-closed. With no ROCKY_WEBHOOK_SECRET set, the route answers 404 unless the server is bound to loopback (the local-dev convenience, where it accepts without a signature). Running serve without --scheduler also answers 404. An over-rate flood is shed with 429 and a Retry-After header before anything is written. An unsigned or wrongly-signed request is 401, and a request for a pipeline not in your config is 404 — but only after the signature verifies, so an unauthenticated caller cannot use the endpoint to enumerate pipeline names.

At-most-once delivery — read this before you depend on it. An accepted webhook is written to a durable, fsync’d spool file before the 202, so a crash between the 202 and the next tick never loses it. The reconciler then consumes each spooled demand at most once: it is attempted exactly one time and never retried. The one loss window is narrow but real — if the reconciler crashes after it has claimed the demand and the child run also dies before recording its outcome, that demand is finalized as a failure and not re-run. (A child that outlives a dead reconciler still records its run and is honored; a sender’s own retries cover everything before the 202.) Because a webhook is not retried on the delivery side, a webhook-only pipeline should also carry a freshness schedule as a backstop, so that if a delivery is ever dropped the freshness trigger still brings the pipeline current within its budget:

[pipelines.orders.schedule]
freshness = true # backstop: re-runs if it goes stale, even if a webhook is lost

A webhook-triggered run records trigger: "webhook" in its history (rocky history), distinct from a cron/after/freshness schedule run, and shows up under GET /api/v1/schedule’s in-flight claims while it runs. A demand whose pipeline was removed from config since it was accepted is finalized (never run) and logged loudly rather than left pending forever.

Run exactly one reconciler per project, meaning per state file — one rocky tick timer, or one rocky serve --scheduler, not both and not several. All of a reconciler’s mutual exclusion is local to one machine: the .rocky/tick.lock flock and the state store’s own writer lock both live on that host’s filesystem and cannot see a second host. Scheduler state (the cursor and claim tables) is deliberately local-only as well — a remote [state] backend (S3, GCS, Valkey, tiered) never uploads it and a download never overwrites it — so two hosts ticking the same project each keep an independent cursor and would both fire the same occurrence. Remote state is last-writer-wins today (there is no cross-host compare-and-swap yet), so there is no fence to lean on across machines. If you need timers on several hosts, give each host its own project and state file, or keep a single timer and let the other hosts invoke rocky run directly.

rocky tick --dry-run evaluates demand and reports exactly what would run, executing nothing and writing no state. Use it to confirm a new schedule does what you expect:

Terminal window
rocky tick --dry-run --now 2026-05-02T03:00:00Z --output json

--now pins the evaluation instant (RFC3339) so you can preview a future occurrence or a catch-up window deterministically; omit it and the tick uses the wall clock.

rocky tick reuses the exit-code contract above: 0 when nothing was due or every run it launched succeeded, 2 when at least one launched run failed or came back partial, 1 when the tick could not proceed at all (invalid config, unopenable state — it runs nothing and fails closed). The same rocky-run.sh routing works unchanged.

One honesty note worth internalizing: exit 0 does not mean the estate is healthy. After the tick that first observes a failure, a broken pipeline goes into failure_backoff — subsequent ticks correctly skip it (so they do not hammer it every minute) and therefore exit 0. The ongoing problem lives in the tick’s JSON, not its exit code: each suppressed demand appears in skipped[] with a reason (failure_backoff, partial_backoff) and a resume_at, and counts plus the consecutive_failures metric carry the running total. Alert on those, and on the scheduler metrics, not on exit codes alone.

A tick can also come back exit 0 having done nothing because another rocky process — a manual rocky run, or its own child from a still-running prior tick — held the state store when it tried to open it. That shows up as a single state_busy entry in skipped[]. One is normal contention and self-heals on the next tick; a state_busy on every tick for many minutes means a wedged writer holding the store, and is worth an alert.

When a scheduled run finalizes as a failure — a full failure or a partial one (both trip the scheduler’s backoff) — the reconciler writes one JSON file under .rocky/incidents/ with the structured facts of that incident, no narration: the pipeline, the demand source (cron, after, freshness, webhook), the outcome, the occurrence it was for, the submission id, the exit code, the attempt count for the demand cycle, the consecutive-failure count after this failure, and retrieval pointers (a rocky history command, plus the /api/v1/jobs/{id} and /api/v1/schedule endpoints under the resident scheduler). A fact the emitting path cannot know — the exit code of a run recovered from a crashed owner, say — is null, never a guessed zero. The recovery paths emit too: a failure finalized by the stuck-claim resolver or the orphan sweep gets its bundle from whichever seam observed it. The one deliberate exception is a child the spawner itself terminated for a shutdown drain — a graceful shutdown is not an incident (a run that failed on its own while a drain happened to be in progress still records one). The newest 50 are kept; the sweep deletes only files the writer itself named, and refuses to operate through a symlinked incidents directory.

The point of the format is that whoever picks up the page — a human or an agent — starts from citations instead of re-deriving what happened. rocky brief surfaces the count and the newest bundle’s path in its Scheduler section, so the digest a failure hook posts already points at the file to open.

  • Observability — export traces and metrics over OpenTelemetry so a scheduled estate is visible in Grafana, Tempo, or any OTLP backend, with no UI to host.
  • Hooks — the full lifecycle-event surface behind the notification recipes above.
  • Failure modes — how to read every failure Rocky can report, including partial success.
  • CI/CD integrationrocky ci for pull-request checks, the complement to the scheduled runs here.

On verification. The commands on this page were exercised against the playground pipeline with the current engine build: a clean rocky run returns 0, a run where one model fails while the others materialize returns 2, and rocky doctor returns 3 on a Critical check. rocky validate, rocky hooks list, and rocky brief --output md were run against the hook configuration shown above. Exit codes 1, 4, and 130 follow the CLI’s documented convention. The systemd, GitHub Actions, and Databricks configurations are illustrative templates around those verified commands: adapt them to your host and platform.