SwitchTenderSwitchTender Docs

Reliability

SwitchTender treats a run as durable, attributable work. It runs many at once under a fixed bound, coordinates failure instead of ignoring it, recovers when a worker dies, and keeps its store consistent while several workers write to it at the same time. This page states exactly how, and it is straight about the edges, because a claim you can check is worth more than one you cannot.

Workers and concurrency

A worker is any process running the executor against the shared store. The serve process is one, and each worker process adds another. Every process claims pending runs from the store and runs up to a fixed number at once, four by default and set with --workers. The bound is per process, so a fleet adds capacity by adding workers, each keeping its own limit rather than a single global pool.

A run can target a named queue, and only a worker serving that queue claims it, which places work across a mixed fleet. A GPU job waits for a GPU worker, and a default job runs anywhere.

Claiming is exact on PostgreSQL. A worker takes the oldest pending run with FOR UPDATE SKIP LOCKED, so two workers never claim the same run even when they poll at the same instant. SQLite serializes every write through a single connection while reads run on a separate read-only pool under WAL, so a long listing or log read never blocks a claim or a finalize. That is why SQLite fits one process and PostgreSQL backs a fleet. Run multiple workers against PostgreSQL.

Every process that finishes a run commits that run's outcome to the audit chain, so a run's evidence does not depend on which worker happened to claim it. That includes the runs nobody is left to finish: when the sweep interrupts a run whose worker died, it records that outcome too, because a change that was executing when its process disappeared is exactly the incident somebody asks about afterward. PostgreSQL serializes those appends with a transaction-level advisory lock, which is what lets a fleet write one chain without forking it.

Recovery when a worker dies

A running run carries a lease that its holder renews every few seconds. If a worker crashes or loses the network, the lease goes stale, and a janitor sweeps it after thirty seconds. Work that was still pending is returned to the queue for another worker. Work that was mid-flight is marked interrupted so it is not silently lost, and announced like any other end of a run: its outcome reaches the chain, and its channels and targets hear that it stopped, a pager included. An interrupted run does not resume from a checkpoint. It is a clean failure a person or a schedule can run again, not a partial state left holding a lease forever. A workflow whose only remaining work is waiting for a person at an approval step is the one exception: the sweep parks it instead, as the section on workflows below describes, so a decision on its step is not lost with the process.

A worker that only stalled, a paused virtual machine or a long hang on the network, cannot start a run the janitor took back from it. The move to running is fenced on the claim itself, the worker's name and the capability minted when it claimed the run, so a requeued run, or one another worker has claimed since, refuses the stale start, and the worker walks away without starting its tool.

A worker killed outright stops its tool as well. Every tool runs under a small supervisor, the same binary started again, that leads the tool's process group and watches a pipe only the worker holds open. The operating system closes that pipe the moment the worker dies, however it dies, and on Linux the kernel's parent-death signal says so too. The supervisor then stops the group the way a cancel does, a terminate signal first and a kill ten seconds later, and for a container run it also stops and removes the container by name, since the container runs under its daemon rather than under the worker. The play does not carry on changing hosts after the run reads as interrupted. The limits:

The keys and tokens a dead worker had staged on its host are removed by the run-files sweep every server and worker on that host runs, and by systemd when the worker ran under the shipped unit. A worker that is only partitioned keeps its files, because its lock still says it is alive. Run files has the rule.

Every server and worker on a host shares one project cache. Each run executes from a private copy of its project that its process locks for as long as the run lasts, and a process starting up removes only the copies whose process is gone, so restarting one replica or worker never deletes the checkout a live run on another is executing from. Updates to a project's shared checkout take a lock file in the cache, so two processes never clone or reset the same checkout at once.

What is stopping work

The overview's Needs attention panel answers, for every run that is not moving, what is stopping it. It sorts the waiting work into four counts, each a list a click away:

A connected worker is one that has reported within the last half minute. Every server and database worker reports on its own timer, so a worker with every slot busy is never mistaken for a missing one, and the control node reports each relay worker it hears from, claims and lease renewals alike.

Work in several conditions at once shows the most urgent as its main blocker, in the order above, the rest as badges, and one line on how they interact. Every timer measures the time in the current blocker: a run approved after a long hold has waited for a worker only since the approval. A retry, a dependency, or a run holding a slot is given as the reason on an item rather than counted, and a schedule fire skipped because its inventory matched no hosts stays in the schedule's history rather than here. The thresholds are organization defaults with per-queue and per-template overrides, set with --attention-file.

There is deliberately no button that puts a run back in the queue by hand. A worker that stopped reporting may only be cut off from the store rather than gone, and a run requeued out from under it could execute a second time on the same hosts. The lease sweep's reclaim is the safe version of that move, because it waits for the lease to expire first, and the panel shows exactly when it will.

High availability

High availability is a Team feature. On PostgreSQL the control plane runs active-active. Start two or more serve processes against the same database, put any load balancer in front, and every replica serves the full API and UI while all of them execute work. There is no leader to elect and no coordinator to stand up, because every cross-process decision already happens in the store:

When a replica dies, its leases go stale and any survivor's janitor requeues the work, which the integration suite proves with two replicas on one PostgreSQL: shared claiming with no double-claim, a single fire for a schedule two replicas race for, and a dead replica's run finished by the survivor. Kill a replica mid-run and the run fails clean: it is marked interrupted, a terminal state ready for an explicit rerun, never silently re-executed. Only work still leased and unstarted requeues automatically.

To run it, point every replica at the same PostgreSQL with SWITCHTENDER_DB, or --db, which puts the password on the command line. Give them all the same SWITCHTENDER_ENCRYPTION_KEY and SWITCHTENDER_ENCRYPTION_SALT, so sealed credentials decrypt everywhere, and the same SWITCHTENDER_AUDIT_KEY, since replicas sharing a database sign as one install and none of them mints the key. Health-check /readyz at the balancer: it reads the store and fails when the database is out of reach, while /healthz answers as long as the process is up and would keep a replica that cannot reach the database in rotation. SQLite has no server to share, so it stays a single-node deployment by design, and PostgreSQL is the HA backend.

Splits balance real work

A split shards one inventory across parallel slices of the same playbook, each limited to its hosts, with the parent rolling the slices into one host matrix. Hosts are packed by their measured average duration over recent runs, heaviest first into the least-loaded shard, so each shard carries a similar amount of wall-clock work. The packing is deterministic, and a host with no history is given the average weight so a new host is neither favored nor stranded.

Balancing by measured duration is the point. Round-robin slicing hands each shard the same count of hosts, so a handful of slow hosts make one shard the long pole and the run waits on it. Packing by time evens the finish line.

Splitting applies to Ansible, and only when there are at least two shards and at least two hosts to divide. Below that it runs as one. The parent holds a lease while it waits for every shard to reach a terminal state, then rolls up the outcome: every shard succeeds and the split succeeds, any shard fails and the split fails, any cancel and it cancels. Retrying a split re-runs only the shards that failed, under a new parent that records what it retried, so a hundred-host run that failed on three hosts costs three hosts to fix, not a hundred.

Pipelines coordinate failure

A pipeline runs steps in order, or as a dependency graph when steps declare what they depend on. Each step is itself a run with a full matrix, events, and history.

In order, a failed step stops the pipeline, unless that step is marked to continue on failure, in which case the next step still runs. In a graph, a step runs once all its dependencies finish and each of them either succeeded or is allowed to continue on failure. A step blocked by a failed dependency is skipped, and the skip carries down to everything that depended on it. A skipped step starts no run, so the history shows what ran and what never got the chance.

A workflow that reaches an approval step and has nothing else to do parks: it releases its lease and waits in the store, so a restart while it waits loses nothing. Whichever replica records the decision resumes it from the stored step records with one compare-and-set, so the decision is acted on exactly once, and every replica's janitor times out an expired step and resumes a decided workflow a crash left parked. A process can also die after it lists the step and before it parks, which leaves the workflow running under a lease nobody renews while the step can still be decided. When that lease expires the sweep parks the workflow instead of interrupting it, as its coordinator would have, and a decision made in the meantime resumes it through the same compare-and-set. It does so only when nothing else is left to do but wait for a person: a workflow with another step still executing, or ready to start, is interrupted like any run whose worker died. Only a step's own decision moves a parked workflow: an approve or reject posted to the workflow itself is refused with 409, naming the step and the call that decides it. A parked workflow is stored under a status of its own, so an earlier release running against the same database, during a rolling update or after a rollback, neither approves it as a run held before it started nor sweeps it, and it waits for this release. The concepts page describes approval steps in full.

A step can retry a set number of times. Each attempt is a fresh child run, so every try keeps its own log and matrix and none overwrite each other. Retries run back to back with no built-in delay, so a step that should pause between attempts sets that in its own logic. Values a step publishes with set_stats flow to the steps that depend on it, merged in dependency order, so a later step reads what an earlier one produced.

Cancellation reaches the whole tree

Canceling a run signals its process group, not only the top process. On Unix the entire tree the tool spawned, the ssh connections, plugin processes, and child commands, receives the kill, with a grace window before the pipes are closed. On Windows the direct process is signaled. Prefer Unix workers where a clean stop of the whole tree matters.

Cancellation crosses processes. The request is written to the store, and whichever worker holds the run acts on it within a few seconds through the same watch loop that renews the lease. A pending run that no worker has claimed is canceled in place. Canceling a split cancels its shards, and canceling a pipeline stops the running steps and skips the rest.

Durability and consistency

Writes are atomic. A run is saved as a single upsert. A batch of events, and the per-host and per-task summaries, are each written inside one transaction, so a reader never sees half a set. When a run reaches a terminal state, the final save is retried a few times so a brief database contention does not lose the outcome.

The schema is applied idempotently on both stores, guarded by IF NOT EXISTS, so starting a newer binary against an existing database is safe to repeat.

The audit trail is a hash chain built on SHA-256. Every recorded mutation carries the previous entry's hash and its own hash over its content, so altering, reordering, or dropping an entry breaks the chain, which GET /v1/audit/verify detects. The chain is tamper-evident with no key configured. GET /v1/audit/bundle seals the chain into a signed LoomSeal bundle, and the open loomseal verifier confirms the trail offline, without trusting the server that produced it. A chain that does not verify is refused rather than sealed: the endpoint answers 409 naming what failed and where, so a broken chain is never signed over. switchtender audit report, a Team feature, renders the period's changes as a self-contained HTML evidence report a compliance or vendor-security reviewer reads without any tooling, and the bundle proves the chain behind it independently. The append is serialized by an in-process lock on SQLite and a transaction-level advisory lock on PostgreSQL, with a unique index on the sequence number as a cross-process backstop, so the chain stays linear even under concurrent writers.

Idempotency and delivery

On PostgreSQL each pending run is claimed exactly once across the fleet. The scheduler uses that same claim, so two servers running the same cron entry never double-fire.

A submission is deduplicated when it carries a key. POST /v1/runs and POST /v1/pipelines take an Idempotency-Key header, kept per organization, so a client that retries a submit it already sent is answered with the run the first one made rather than a second run. A key may be up to 255 bytes, and a longer one is refused with 400. Without the header each call creates a new run with a fresh identifier, so a client that cannot send one should treat a submit as create-once on its side. The server keys what it launches itself the same way: a rerun, a webhook delivery, and a provisioning callback are deduplicated on what they carry, and each scheduled fire carries a key derived from its schedule and occurrence, run.ScheduleKey, in the server's reserved st: namespace, which no caller's key can spell. A fire that takes up an occurrence an interrupted fire handed back therefore finds the run that one made instead of starting a second. A server that crashes before it can hand an occurrence back leaves it marked in flight, from the same write that claimed it, and any server's scheduler takes it up two minutes later: it records the run the key finds, or fires the occurrence again under the same key. An approval is a compare-and-set state transition, so two concurrent approvals release a held run exactly once and the loser gets a clear conflict.

Stored events are ordered and written once per batch. A batch replayed after a transient error appends rather than deduplicates, so a consumer keys on the event sequence number.

Notifications to named targets, the ones defined once and attached to templates, workflows, schedules, projects, or organizations, are queued in the database in the same write that records the run's event. Deliveries to each target are attempted in the run's event order, a failed attempt is tried four more times over about three minutes, and a delivery pending when a server stops is made by the next server to start. One that still fails is kept as failed on the run, on the target, and in the doctor. Delivery order has the details.

The eleven server-wide channels, from webhook and Slack to PagerDuty, Twilio SMS, and email, and a template's own notification list are best effort. Each is attempted at least once with one retry, then logged and dropped, and the run's extra vars are stripped from the payload so survey and template values never leave the system. Notifications do not block a run from finishing. On shutdown a server waits for the best-effort deliveries already in flight, and a named-target attempt it cuts short is made by the next server instead. Treat a notification as a signal, and the store as the source of truth.

How this compares

SwitchTender coordinates a fleet with database leasing, so the same one binary adds capacity without a separate mesh to stand up. It splits by measured duration rather than round-robin, so a run finishes on the slowest shard and not the unluckiest one. It retries only the shards that failed. It reads a run as a host-by-task matrix instead of a log stream. And it proves its own audit trail offline. The comparison lays the three tools side by side, ahead, even, and behind.