dagron/ docs
Operate

Scaling & HA

dagron scales by running more identical scheduler replicas against one Postgres — there is no separate control plane to size or fail over. This page covers the mechanism, the knobs, and the limits.

How dagron scales

Every dagron scheduler replica runs the same binary with the same configuration. Replicas do not know about each other and hold no coordination state: they meet only in the database. Three properties of the claim protocol do all the work:

Replicas wake on Postgres LISTEN/NOTIFY rather than polling, and the reconcile loop is idempotent and database-driven — there is no in-memory state that matters, which is what makes replicas interchangeable. Adding a replica therefore adds throughput and removes a single point of failure in the same move. This is the same multiple-active-scheduler model Apache Airflow 2.x uses for its scheduler.

Because a dead replica's tasks are reclaimed and re-run after lease expiry, execution is at-least-once. Tasks must be idempotent — that is the contract that buys leaderless HA.

Deployment configurations

dagron is the same binary everywhere; a "setup" is which backend and components you wire behind it. There are two anchors — Basic and HA — with a scale-out step between them and a sovereign variant beyond.

SetupBackend & topologyAvailabilityWho runs it
Basic SQLite, one dagron binary (the default build). dagron dev is this. Just the binary and a disk. None (single process); 500 ms poll. A laptop, a single container, a small team.
Scale-out Postgres (--features postgres), N identical schedulers, LISTEN/NOTIFY. One Postgres on fast IO; PgBouncer as N grows. Data plane is active-active (SKIP LOCKED + lease recovery); cron/GC are leader-gated singletons. Postgres is still a single point of failure. A platform team.
HA Scale-out plus Postgres synchronous replication and automated failover: primary + standby (+ witness), PgBouncer, backups to an object store. Optional read replica. Survives a scheduler death and a database-node death; the cron/GC singleton rides the failover pair via leader election. A team that pages on downtime.
Sovereign / air-gapped HA plus a consensus-replicated store to remove the database as the last single point of failure. No single store SPOF; on-prem / air-gap. Regulated buyers who fund it.

The consensus-replicated store behind the Sovereign row is a design direction in the project's HA plan, not something you helm install today. Basic through HA are the shipped configurations.

For air-gapped installs of the shipped configurations, the Helm chart's global.imageRegistry value relocates every image in the chart to a private mirror with a single value.

Scaling out the engine

The Helm chart (on Artifact Hub) exposes the scale-relevant values directly. The engine image must be built with the postgres, ops, and kubernetes cargo features — the default is a SQLite-only build, which cannot do multi-replica claiming.

VALUES.YAML — A THREE-REPLICA FLEET
engine:
  replicas: 3             # identical schedulers, active-active
  workerCount: 8          # per-replica task concurrency (WORKER_COUNT)
  executor: k8s           # each task runs as a one-shot Pod
  maxInflightRuns: 2000   # admission cap: POST /runs sheds load with 429
                          # + Retry-After above it; 0 disables

postgres:
  enabled: false          # the bundled Postgres is for throwaway testing;
                          # bring a managed database instead
externalDatabaseSecret:
  name: dagron-db         # pre-existing Secret holding the connection string,
  key: DATABASE_URL       # so the password never lands in helm history
INSTALL / RESIZE
$ kubectl -n dagron create secret generic dagron-db \
    --from-literal=DATABASE_URL='postgres://dagron:REAL_PW@db.internal:5432/dagron?sslmode=require'
$ helm upgrade --install dagron oci://registry-1.docker.io/mancube/dagron \
    -n dagron --create-namespace -f values.yaml

Chart defaults are deliberately small: engine.replicas: 1, engine.workerCount: 4, engine.maxInflightRuns: 64. Scaling the fleet is a values change and a helm upgrade — because replicas coordinate purely through the database, adding one requires no rebalancing, no peer discovery, and no config on the existing replicas. Environment knobs the chart does not model can be appended verbatim via engine.extraEnv.

Three things to keep in view as replicas grows:

Scaling ingestion

Submissions scale independently of task dispatch. The open streaming source (SOURCE=stream) follows an NDJSON file or FIFO; pointing STREAM_PATH at a directory switches to sharded multi-consumer mode — each *.ndjson file is a partition, split across engines via per-partition leases, each shard with its own exactly-once cursor. Cap how many shards one engine holds with STREAM_MAX_PARTITIONS so capacity spreads across consumers instead of one engine hoarding every shard. Whatever the source, MAX_INFLIGHT_RUNS is the admission valve: overflow stays buffered at the source, never in the scheduler.

Scaling the database

The engine is not the component that grows. Postgres is the shared source of truth, the throughput ceiling, and — until you replicate it — the single point of failure. The HA topology is standard Postgres practice, not anything dagron-specific:

Use synchronous replication (synchronous_commit = on with a sync standby) for RPO 0. A committed task-success record must survive failover — otherwise a task can silently re-run after being recorded done. Asynchronous replication trades a small re-run window for latency and is acceptable only for idempotent workloads. Default guidance is sync.

What happens during a failover

When the primary fails and a standby is promoted, no operator action is needed on the dagron side:

  1. Scheduler connections drop; in-flight claim and mark transactions error.
  2. The reconcile loop is idempotent and database-driven, so each replica simply retries its tick against the new primary. No in-memory state is lost because there is none that matters.
  3. Any task whose completion did not commit before failover keeps its running lease; after the lease expires it is reclaimed and re-run (at-least-once).
  4. The singleton leader's session is gone, so a standby replica acquires leadership on the new primary.

PgBouncer and the LISTEN session

PgBouncer's transaction pooling cannot serve a session-scoped LISTEN. dagron has a split-DSN seam for exactly this: point DATABASE_URL at PgBouncer for pooled transactions, and set DATABASE_LISTEN_URL to the direct Postgres endpoint for the reconcile loop's LISTEN session. The same pair exists on dagron-api for its SSE listener.

Backups and restore procedures for the state database are covered on the Admin & maintenance page.

Concurrency controls within the engine

Inside each replica, and across the fleet, these environment variables govern how much runs at once. The full reference lives on the CLI & configuration page; these are the scale-relevant ones.

VariableDefaultWhat it does
WORKER_COUNT16Worker-pool size per replica — the maximum concurrently running tasks on that replica.
MAX_INFLIGHT_RUNS64Admission valve: cap on simultaneously active runs. The API answers 429 + Retry-After above it; overflow stays buffered at the source. 0 disables the API-side cap.
POOLSunsetNamed concurrency pools, e.g. POOLS=etl:4,db:2. A task's pool: draws a slot; over-budget tasks wait in ready — no run is dropped. Keep the value identical across HA replicas.
RUNNER_CLASSESunsetRestrict this scheduler to claiming tasks whose runner_class is in the list — segment the fleet so, say, GPU work only lands on GPU-adjacent schedulers. Unset claims every class.
DB_MAX_CONNECTIONS8Postgres pool size per engine (minimum 2). Lower it for lean engines sharing a pooled cluster.
TASK_LEASE_HEARTBEATonWorkers renew a running task's lease every 10 s, so long tasks are never reclaimed mid-run.
DATABASE_LISTEN_URLunsetDirect (non-PgBouncer) endpoint for the LISTEN session when DATABASE_URL points at a transaction pooler.
READY_AGE_ALERT_SECS300Warn (and export scheduler_ready_oldest_age_seconds) when a runner class's oldest ready task has waited longer than this — catches a class no live scheduler serves.

Two per-workflow controls complement the fleet-wide caps: a task-level priority orders dispatch among simultaneously-ready tasks (a tiebreak that never overrides dependencies), and a DAG-level max_active_runs caps how many runs of one workflow may be running at once — further fires are held back and the API returns 429.

On Postgres, pooled claims serialize via a global advisory lock while the unpooled fast path stays lock-free. If you do not need pool caps, leaving POOLS unset keeps claiming at full parallelism.

Cluster singletons & the leader

A few jobs must run exactly once cluster-wide, not once per replica: firing cron schedules (CRON_CONFIG), firing database-backed UI schedules (DB_SCHEDULES), retention GC (GC_RETENTION_SECS), and the stale-ready alert. With N active schedulers these would otherwise fire N times — N duplicate cron runs, N racing deleters.

dagron gates them behind a single leader elected with a lightweight, session-scoped lock in Postgres (an advisory lock — no new infrastructure, since every replica already holds a Postgres connection). One replica wins and runs the singleton steps in its reconcile tick; the others skip those steps and keep doing data-plane work at full speed. The leadership lease length is LEADER_LEASE_SECS (default 30).

When the leader dies — process death, or a network partition from the database — its session ends, Postgres releases the lock, and a standby replica acquires leadership on its next attempt. There is no lease table to garbage-collect and nothing to page on: schedule firing pauses for at most the takeover window, then resumes on the new leader.

Limits

A workflow may not exceed 100,000 tasks. The expansion step carries a fixed budget (MAX_TASKS = 100_000); a spec that expands past it is refused at submission with expansion exceeded 100000 tasks. This is a designed admission limit with a typed error — the engine refuses rather than degrading or running out of memory.

Details that matter when you are near the ceiling:

Validating your sizing

The engine binary is not the thing you size. The engine container idles around 10 MB resident — the footprint of a deployment is dominated by Postgres, the executor, and any ingestion component, never by the engine. Capacity planning is therefore: size Postgres, size where tasks actually run, then add cheap scheduler replicas for HA and throughput.

The tiers below are the project's own rough guidance — starting points to validate with a load test, not promises. Real numbers depend on DAG width, task duration, executor, and history retention.

TierScale (approx)ReplicasWORKER_COUNTMAX_INFLIGHT_RUNSDatastoreExecutor
Dev / small< 10k runs/day18–1664SQLite or one Postgreslocal / docker
Medium~100k runs/day2–3 (multi-AZ)16–32128–256managed Postgres (+ replica)docker or kubernetes
Large~1M runs/day3–532–64256–512managed Postgres, primary + replicakubernetes
XL / huge> 10M runs/day or massive fan-out5–N across AZs64+512–Nmanaged Postgres across AZskubernetes + cluster autoscaler

On node sizing: a scheduler replica is a few MiB of RAM plus whatever its WORKER_COUNT local tasks need — and with EXECUTOR=kubernetes, tasks run as pods elsewhere, so the replica stays tiny. Size nodes for Postgres connections and headroom, not for the binary.

Postgres is the number to watch: run/task history and the per-task claim rate drive its load. Whatever tier you pick, confirm it under your own workload shape before committing — the repository ships a load-test harness with config-driven profiles (sustained, spike, soak, ramp), Grafana dashboards, and chaos scripts that kill a scheduler mid-run to verify lease recovery. Stepping engine.replicas during a run reads out your horizontal control-plane headroom directly.

Beyond one team Enterprise

Everything above is Apache-2.0 and complete on its own. For fleets shared across teams, dagron Enterprise adds a scaling layer on the same engine: managed broker sources (Redis, SQS, Kafka, NATS) for queue-driven ingestion Enterprise, gang scheduling for all-or-nothing co-scheduled ranks Enterprise, and a multi-tenant control plane with per-tenant isolation Enterprise. The open-vs-enterprise line is drawn in the project README.