-
Notifications
You must be signed in to change notification settings - Fork 0
Clustering and Leader Election
By default yacron2 holds its schedule in-process and keeps no shared state, so two instances started from the same configuration each run every job independently. That is the safe single-instance model, but it means you cannot simply scale to two replicas for availability without double-running every job.
The optional cluster section closes that gap. It lets a static set of
instances attest, over mutual TLS, that they are running the same job set, and,
when you opt in, turns that attestation into a quorum-gated leader
election so that several replicas deployed from one config run with only the
elected leader firing scheduled jobs. It builds directly on the
job-set id and is implemented in
yacron2/cluster.py (the ClusterManager, ClusterView, and the pure
elect_leader/quorum_size functions).
The default
gossipbackend is best-effort coordination, not fenced exactly-once. It keeps no shared state, so it is simple to operate and cannot wedge on a missing consensus store. The trade-off is that there are narrow windows where a firing may be skipped or (under some policies) double-run. If you need a hard exactly-once guarantee and already run a coordination store, setcluster.backend: kubernetesoretcd(below) to elect through aLease/ a lease-bound key instead. See Choosing a backend and Guarantees and trade-offs.
Terms used on this page. A job-set id is an order-independent
fingerprint of the jobs a node runs (two nodes match iff they hold the same job
set). A quorum is a strict majority of the cluster, ⌊N / 2⌋ + 1 nodes. A
node is quorate when it currently sees a quorum of agreeing members.
Fenced means a shared store guarantees a single holder (the lease backends).
A lease is a short-lived, auto-expiring claim on that store that the holder
keeps renewing. A bridge is a set of members that two mutually-unreachable
nodes can both still reach (the sides see the bridge, not each other); a
thin bridge is one of fewer than quorum - 1 shared members, too thin for
the two sides to confirm each other through it (see
Guarantees and trade-offs).
On this page: Quickstart · From one node to a cluster · Choosing a backend · At a glance · The job-set id foundation · Cluster peer attestation · Leader election · Per-job policy · Distribution · Observing the cluster · Guarantees and trade-offs · Certificate rotation · Operating the lease backends · Trying it locally · Cluster sizing math
The fastest way to see a leader-electing cluster is the bundled three-node demo, which mints a throwaway CA and per-node certs for you:
docker compose -f docker-compose-cluster.yml up --build
# then open http://localhost:8080/ , :8081 , :8082 and watch the cluster panelSee Trying it locally for the fuller walkthrough (failing the leader, losing quorum, drift, per-policy behaviour).
To build one by hand, each node gets the same job set plus a per-node cluster
block. This is a complete leader-electing gossip node (yacron-a of three):
cluster:
listen: "0.0.0.0:8443" # this node's mTLS /peer listener
tls:
ca: /etc/yacron2/cluster-ca.pem # shared cluster CA (trust anchor)
cert: /etc/yacron2/yacron-a.pem # this node's cert (SAN = yacron-a)
key: /etc/yacron2/yacron-a.key
peers: # every OTHER member -> size = 3
- host: yacron-b.internal:8443
- host: yacron-c.internal:8443
nodeName: yacron-a # unique, stable per node
electLeader: true # only the elected leader runs jobs(The certificate paths are yours to choose; the bundled compose demo, for
example, mounts its generated certs at /certs/ca.pem / /certs/yacron-a.pem.)
The quorum rule in one paragraph. List every other node in peers (never
this node itself), so the cluster size is len(peers) + 1 and every node
computes the same N. A node acts as leader only while it sees a quorum, a
strict majority (⌊N / 2⌋ + 1), of agreeing members. So a 3-node cluster
tolerates one node down; give each node a distinct nodeName and a matching
peer list.
Most clusters want the default single leader: enable
distribution: spread only when
one leader cannot carry all the scheduled work.
On Kubernetes, skip the certs and peer list entirely and set
cluster.backend: kubernetes so a Lease fences leadership instead (see
Choosing a backend).
To grow a single instance into a cluster without a double-run flag day, add attestation first, verify it is healthy, then turn on election:
-
Pick a unique, stable
nodeNameper replica (the orchestrator's stable hostname, a StatefulSet ordinal, or an explicit value). Reusing one across nodes silently double-runs; see Unique node names. -
Provision the coordination material. For
gossip, issue per-node certs from a dedicated cluster CA (see Cluster peer attestation); for a lease backend, set up theLeaseRBAC or etcd credentials (Operating the lease backends). -
Add the
clusterblock withelectLeader: falsefirst (attestation only). Every replica still runs every job, so nothing changes operationally, and you can confirm the peers reachagreedonGET /clusterbefore trusting the topology. -
Run
yacron2 --validate-configto catch a bad peer list, TLS paths, or lease ordering at rest; see the Command-Line Reference. -
Roll the replicas one at a time, letting each converge to
agreedbefore the next (change membership incrementally so majorities always overlap; see Consistent cluster size). -
Set
electLeader: true. Enabling election only once attestation is already healthy means the switch itself is a clean transition, not a flag day.
To collapse back to one instance, scale to replicas: 1, or set
electLeader: false (keep attestation) or remove the cluster block entirely.
The gossip backend keeps no shared state, so there is nothing to clean up; a
lease backend releases its lease on a graceful stop, so a survivor (if any) takes
over at once.
cluster.backend selects how leadership is decided. The decision rule: stay
on the default gossip when you want zero-dependency replicas and can tolerate
an occasional skip or double-run in narrow windows; pick kubernetes (already on
Kubernetes) or etcd (already run etcd) when you need a fenced, exactly-once
guarantee and already run that store. All three present the same per-job seam
(clusterPolicy) to the scheduler, so switching backends does not change how
jobs are written; only the coordination underneath, and therefore how the
cluster is observed, differs.
gossip (default)
|
kubernetes |
etcd |
|
|---|---|---|---|
| Coordination | embedded mTLS gossip, no shared state | a coordination.k8s.io/v1 Lease
|
a lease-bound etcd key |
| Guarantee | best-effort (may skip or double-run in narrow windows) | fenced, exactly-once while the apiserver is reachable | fenced, exactly-once while etcd is reachable |
| Extra dependency | none | none (optional yacron2[kubernetes]) |
none |
| Needs | per-node mTLS certs + a static peer list | in-cluster (or kubeconfig) apiserver access + a Lease RBAC | reachable etcd endpoint(s) |
| Best when | zero-dependency replicas, occasional skip/dup tolerable | already on Kubernetes and want a hard guarantee | already run etcd |
The per-backend config keys (cluster.kubernetes.*, cluster.etcd.*) are in the
Configuration Reference; deployment, RBAC,
auth/TLS, failure modes, and monitoring are in
Operating the lease backends.
Runnable samples are in
example/kubernetes/
and example/etcd/.
The rest of this page documents the gossip backend (the default) in
depth; its trust model and quorum math are specific to it. The clusterPolicy
semantics in Per-job policy, however, apply to every backend.
| Single instance (default) |
cluster only |
cluster + electLeader
|
|
|---|---|---|---|
| Replicas | 1 | many (each runs everything) | many (leader runs scheduled jobs) |
| Coordination | none | observe-only attestation | quorum-gated election |
| mTLS identity required | no | yes | yes |
| Endpoint | none |
GET /cluster, GET /peer
|
GET /cluster, GET /peer
|
| Double-running | n/a | yes (by design) | no for Leader jobs in a converged, fully-connected quorum (best-effort: a thin bridge, a same-N membership change, or the ~one-interval window after a partition can still let two nodes both lead; see Guarantees and trade-offs) |
A job-set id is an order-independent fingerprint of the set of jobs an
instance is running: two instances produce the same id if and only if they
hold the same set of jobs. It is taken over the effective (post-merge)
configuration of every job, normalises equivalent schedule spellings,
fingerprints user/group as configured (not as a resolved uid/gid), and
embeds no secret material. The scheme is versioned with a v1: prefix so ids
are only ever compared within one scheme.
The id is what the cluster compares: agreement means "we are running the same
jobs". It is available on the standalone GET /job-set-id endpoint,
in the dashboard header, and is logged at startup and whenever a reload changes
it. clusterPolicy (below) is part of the id, so two replicas that disagree on
a job's policy show up as drift rather than silently coordinating differently.
The full treatment of the job-set id lives in the
project README.
With a cluster section but without electLeader, the cluster is
observe-only: every instance still runs every job, and attestation just tells
you whether the peers agree. Each node serves a tiny GET /peer endpoint on a
dedicated mTLS listener and periodically polls every configured peer, comparing
job-set ids.
cluster:
listen: "0.0.0.0:8443" # the mTLS listener for this node
tls:
ca: /etc/yacron2/cluster-ca.pem # trust anchor for peer certificates
cert: /etc/yacron2/this-node.pem # this node's certificate
key: /etc/yacron2/this-node.key
peers:
- host: yacron-b.internal:8443
- host: yacron-c.internal:8443
nodeName: yacron-a # optional; defaults to the system hostname
interval: 30 # optional; seconds per round (default 30)
driftAfter: 3 # optional; rounds before "drifted" (default 3)
connectTimeout: 10 # optional; seconds per peer request (default 10)
electLeader: false # optional; run jobs on the leader only (see below)Run yacron2 --validate-config before deploying to catch a bad cluster
section (peer list, TLS paths, lease ordering) at rest rather than at startup;
see the Command-Line Reference.
The trust model is deliberately small and keeps no shared state:
-
mTLS is the membership boundary. A peer's certificate must chain to the
configured
ca, and (client side) match the host it was reached at, so only nodes the CA vouches for are ever attested. Standard TLS hostname verification provides that SAN pinning: the cert presented byyacron-b.internal:8443must carryyacron-b.internalas a Subject Alternative Name. The CA is the whole authentication boundary (yacron2 trusts any cert it signs to assert its identity and gossip state), so it must be a dedicated, closed CA issued only to yacron2 nodes, not a shared service-mesh or organisation-wide CA (any cert that CA admits can otherwise fabricate the/peerpayload below: fake agreement, trip the conflict gate, or suppress an@rebootjob). Provision the certificates from your own dedicated PKI (a private cert-manager issuer, an internal CA); yacron2 only consumes them. The same per-node cert/key is used both to serve/peerand to authenticate as a client when polling peers. An in-place renewal of these files (same paths, new bytes) is detected and applied automatically, with no restart (see Certificate rotation). - Each node keeps its own view. No node is authoritative: two healthy nodes converge to the same picture, and any disagreement is itself the signal.
-
Drift is debounced. A reachable peer whose id differs is only reported as
driftedafterdriftAfterconsecutive rounds, so a rolling deploy (a brief, legitimate mismatch) does not raise a false alarm.
Every peer in the GET /cluster view and the
dashboard panel carries one of these statuses (the constants live in
yacron2/cluster.py):
| Status | Meaning |
|---|---|
agreed |
Reachable over mTLS and reporting the same job-set id. |
syncing |
Reachable, but its id differs and the mismatch has not yet persisted for driftAfter rounds (a transient/rolling-deploy mismatch). |
drifted |
Reachable, but its id has differed for driftAfter consecutive rounds (an actual disagreement). Also used immediately (no debounce) when the peer reports a different fingerprint scheme (v1: vs another), since such ids are not comparable. |
unreachable |
Connect/timeout/OSError: the peer could not be contacted this round. |
untrusted |
TLS/certificate verification failed: the peer is not (or not provably) a cluster member. |
self |
The peer reported this node's own nodeName and its own instance id (an operator listed this node's own address in its peer list). It never counts toward agreement. |
conflict |
The peer reported this node's nodeName but a different instance id: a duplicate nodeName (two nodes sharing a name). It never counts toward agreement, and while any conflict is visible Leader jobs fail closed. See Unique node names. |
unknown |
Not yet contacted (the initial state before the first poll). |
A failed round (unreachable or untrusted) neither advances nor resets the
drift streak: the streak counts reachable-but-mismatched rounds, and only a
confirmed agreement (an agreed round, or the benign self case) clears it,
so a genuinely drifting peer cannot postpone its drifted label by flapping in
and out of reach.
The /peer endpoint is served only on the separate mTLS listen address,
never on the public web API. It returns a small JSON document with
everything a polling peer needs: the reporter's node_name and job_set_id
(the agreement key), a per-process instance_id (so a duplicate nodeName is
distinguishable from a self-listing), the declared cluster_size and the
distribution / elect_leader descriptors (the conflict gates), plus its own
members view, mutual_agreeing / quorate_vouched sets (bridge discovery and
spread owner deferral), and ran_reboot_jobs (deferred-@reboot
de-duplication).
The full annotated payload, the per-field safety role, and the trust-model notes (any CA-admitted peer can read and could fabricate the whole member and agreement graph, so the cluster CA must be a dedicated, closed boundary, and the listener caps request size but not concurrent connections) are in Architecture and Internals.
Setting electLeader: true turns the same attestation into a quorum-gated
leader election, so you can run more than one replica from the same config
without double-running jobs:
cluster:
listen: "0.0.0.0:8443"
tls:
ca: /etc/yacron2/cluster-ca.pem
cert: /etc/yacron2/this-node.pem
key: /etc/yacron2/this-node.key
peers:
- host: yacron-b.internal:8443
- host: yacron-c.internal:8443
electLeader: trueEach node independently elects, as leader, the lowest nodeName among the
members it currently sees agreeing on the job-set id, but only if that set
is a quorum (a strict majority) of the cluster. Only the leader runs
scheduled jobs. Manual runs via the API (POST /jobs/{name}/start) are
deliberately not gated, so you can still trigger a job on any node. Automatic
retries re-check the gate before every relaunch: a transient fail-closed
denial (lost quorum, a detected conflict, a rebuilt manager's still-converging
view) merely defers the retry and re-checks it, while a positively observed
ownership move abandons the pending retry (a WARNING plus a cancelled
run-history record) so it cannot double-run against the new owner. The full
defer-vs-abandon lifecycle is documented in
Failure Detection and Retries.
-
List every other member in
peers, not this node itself. The cluster size is thereforelen(peers) + 1, and the quorum is⌊size / 2⌋ + 1. The peer lists must be consistent across nodes for every node to compute the same size and quorum. This is enforced at runtime, not merely assumed. - If you accidentally list a node's own address in its own peer list, an entry
the config load can prove is local (a loopback address on this node's port
under a matching listen) is rejected or warned about up front; anything else
(e.g. the node's own routable IP) is recognised at runtime as
selfonce its self-poll succeeds, never counts toward agreement, and is excluded from the cluster size. In a genuinely 3+-node cluster that is benign (logged once at INFO): it neither changes the effectiveN/quorum nor (sinceNstays equal to what other nodes declare) trips the size-consistency check below. It is not harmless at the boundary: a self-padded "3-node" config that is really 2 nodes sails past theelectLeader2-node refusal and runs as the degenerate quorum-2-of-2 cluster (both nodes must be up; any single failure stops allLeaderjobs cluster-wide), which yacron2 flags at runtime with a prominent WARNING. Remove the self entry rather than relying on the exclusion. - The computed size, quorum, elected leader, and whether this node is the leader
are all shown at
GET /clusterand in the dashboard panel.
The quorum gate is what makes this safe with no shared state. Two strict
majorities of N cannot be disjoint, so under a clean network partition at most
one side is quorate, and therefore (within about one poll interval) at
most one leader exists. (That qualifier matters: a leader just cut off from
the majority keeps acting on its last, now-stale view until its own next poll,
so for up to one interval a clean partition can briefly double-run a
Leader firing rather than only skip one; the single-leader property reasserts
once the cut-off node re-polls and stands down. See
Guarantees and trade-offs.) The price is
liveness: a node that cannot see a majority deliberately stands down (runs
nothing) rather than risk a second leader. A Leader job therefore runs on a
given firing only while a majority of the cluster is up and mutually reachable.
The safety argument above assumes every node has a distinct nodeName. If
two nodes shared one, each would compute itself as the lowest name in its live
set and both would elect themselves: a silent double-run, exactly what
election is meant to prevent. So nodeName uniqueness is a correctness
requirement, not just a nicety.
yacron2 enforces it at runtime. Each process mints a random instance id at
startup and reports it on /peer alongside its nodeName. That lets a node
distinguish two cases that otherwise look identical:
- a benign self-listing (an operator put this node's own address in its
own peer list), where the peer reports this node's name and its own
instance id (status
self); from - a duplicate
nodeName(a different process announcing this node's name), where the instance id differs (statusconflict). A third node can likewise spot two distinct instances claiming one name.
This detection is best-effort. It relies on some node being able to see both copies, directly, or transitively by unioning peers' reported member lists (one hop). Two copies of a duplicated
nodeNamethat sit in disjoint partitions (no single node can observe both, even transitively) cannot be reconciled, so each side stays quorate and both lead, the same residual class as a same-Nmembership swap (see Consistent cluster size). So treat uniquenodeNames as something to enforce (distinct cert SANs, the orchestrator's stable hostnames), not merely to detect at runtime.
While any conflict is visible, this node's Leader jobs fail closed
(stand down) instead of risking a double-run, and the conflict is surfaced as a
conflict flag on GET /cluster, a banner in the
dashboard cluster panel, and an ERROR log line. It clears automatically once
the duplicate is renamed: the gate is self-healing. PreferLeader is not
gated on conflicts: it already accepts double-runs as the price of never
skipping. The default nodeName (the system hostname) is already unique per
host; set an explicit, unique nodeName when several nodes might share a
hostname (e.g. identical container images without distinct hostnames).
The safety argument also assumes every node uses the same cluster size N.
"Two strict majorities of N cannot be disjoint" is only true for a single
N: two majorities of different sizes can be disjoint. But N is each
node's own len(peers) + 1, and the job-set fingerprint
deliberately covers job definitions only, not the peer list. So two nodes
with divergent peer lists still see each other agreed, each reaches a quorum
under its own N, and both elect themselves: a silent double-run. An
ordinary cluster resize (say rolling 3 → 5 nodes) triggers exactly this:
mid-roll, the old nodes still carry N = 3 (quorum 2) while the new ones carry
N = 5 (quorum 3), so the old {a, b} and new {c, d, e} groups are each
quorate and each run the Leader jobs.
yacron2 closes this the same way it closes a duplicate nodeName. Each node
reports its declared N on /peer, and a peer that agrees on the job set but
declares a different N is treated as a first-class conflict: this node's
Leader jobs fail closed until the cluster reconverges on one N. Because a
resize leaves the job set unchanged, the divergent nodes are mutually agreed
and therefore each observe the mismatch: both sides stand down, so no firing
double-runs while the roll-out is in flight. The conflict is surfaced as the
size_conflict / conflicting_sizes fields on
GET /cluster, a banner in the dashboard cluster
panel, and an ERROR log line, and clears automatically once every node's
peers agree on the member set. As with a nodeName conflict, PreferLeader
is not gated: it already accepts double-runs as the price of never skipping.
Note: the check compares the declared size
N, which catches every resize (the documented failure above). It does not detect a same-Nbut different-membership divergence (e.g. swapping one peer for another while keeping the count). To stay safe, change membership one node at a time so the old and new majorities always overlap, and let each change converge (the dashboard showsagreedon every node) before the next.
A Leader job fires successfully only while a quorum is up and mutually
reachable, so pick an odd size: each odd size adds one failure of headroom
(3 tolerates 1, 5 tolerates 2, 7 tolerates 3), while an even size needs the same
quorum as the odd size below it yet has an extra node that can fail, so yacron2
warns on even sizes (for N > 2). A 2-node cluster is strictly worse than a
single replica (both must be up, with no failover upside), so yacron2 refuses
to start with electLeader and 2 nodes (a ConfigError); a 2-node cluster is
fine for attestation-only.
Framed as expected skipped firings for an hourly Leader job (8760
firings/year), which is often the more intuitive view:
| N | p=0.99 | p=0.999 |
|---|---|---|
| 1 | ≈88 skips/yr | ≈8.8 skips/yr |
| 3 | ≈2.6 skips/yr | ≈0.03 skips/yr |
| 5 | ≈0.09 skips/yr | negligible |
The binomial derivation and full availability tables are in the Appendix: cluster sizing math at the bottom of this page.
If electLeader is configured but the cluster listener fails to start (bad cert
files, a bad listen address, a port already in use), the node logs the error and
keeps running, and each policy honours its own contract: Leader jobs fail
closed and stay idle rather than falling back to running everything on every
replica, while PreferLeader jobs are never-skip and run anyway (a node
with no manager is exactly the "store/quorum unreachable" outage PreferLeader
accepts a double-run to survive; skipping would drop the job to zero runs on a
fleet-wide start failure). The operational consequence: a listener broken on
every replica means every replica runs every PreferLeader firing, which is
why PreferLeader is reserved for idempotent jobs. (EveryNode jobs are
unaffected; see below.) Leadership transitions are logged each time the node
acquires or loses scheduled-job leadership.
The cluster-wide electLeader switch sets the default behaviour, but each job
can override it with clusterPolicy to pick its own point on the
liveness-vs-duplication trade-off. No option is true exactly-once: each
gives up one side. Leader may skip, PreferLeader may double-run.
clusterPolicy |
Healthy (quorate) | Partitioned / sub-quorum | Use for |
|---|---|---|---|
Leader (default)
|
leader runs once | nobody runs (skips) | non-idempotent jobs where a duplicate is harmful and an occasional skip is OK (billing, outbound email) |
PreferLeader |
lowest node runs once | each side's lowest node runs (may double-run) | important and idempotent jobs that should never skip |
EveryNode |
every node runs | every reachable node runs | genuinely per-node work (local log rotation), or fully idempotent jobs |
jobs:
- name: charge-cards # must not double-charge; skip-tolerant
command: ./charge.sh
schedule: "0 * * * *"
clusterPolicy: Leader # the default; can be omitted
- name: refresh-cache # idempotent, but must not be skipped
command: ./refresh.sh
schedule: "*/5 * * * *"
clusterPolicy: PreferLeader
- name: rotate-local-logs # inherently per-node
command: ./rotate.sh
schedule: "@daily"
clusterPolicy: EveryNodeNotes:
-
clusterPolicyis inert unlesscluster.electLeaderis on. Without election, every job runs on every instance regardless of its policy. - When election is configured but no manager is running (e.g. the listener
failed to start),
Leaderjobs fail closed (skip), whilePreferLeaderjobs are never-skip and run anyway -- on this and every other manager-less replica, the documented double-run cost, preferred to dropping the job to zero runs.EveryNodejobs are independent of cluster health, so they keep firing regardless. - The active policy for each job (when election is on) is shown in the
dashboard's job drawer and included in the
GET /jobspayload. To keep the per-poll payload lean for the common single-instance case,clusterPolicyis omitted fromGET /jobswhen election is not configured. -
clusterPolicyis part of the job-set id, so replicas that disagree on a job's policy surface as drift.
The decision for one node, one firing, is exactly:
election off -> run (every node runs everything)
EveryNode -> run (always, even if the manager failed to start)
no manager -> Leader skips (fail closed), PreferLeader runs (never-skip; the leadership listener failed to start, which is the very outage PreferLeader accepts a double-run to survive)
conflict -> skip (fail closed; a duplicate nodeName, a cluster-size disagreement, OR a coordination-policy mismatch is visible)
PreferLeader -> run only if this node is the lowest reachable agreeing node
Leader -> run only if this node is the quorum-gated elected leader
(The conflict row applies to Leader only; PreferLeader and EveryNode are
gated on none of a duplicate nodeName, a cluster-size disagreement, or a
coordination-policy mismatch. A coordination-policy conflict is a quorate peer
advertising a different distribution or elect_leader setting, surfaced as
policy_conflict: true with the differing descriptors in conflicting_policies;
it is the third trigger of the umbrella conflict flag alongside conflict_names
and size_conflict. Under distribution: spread, described next, the last two
lines become "the per-job owner among the reachable agreeing nodes" and "the
quorum-gated per-job owner" respectively.)
One transient exception to PreferLeader's never-skip (gossip backend only): a
node whose manager was just (re)built -- a cold boot, or a reload that changed
the cluster section -- holds its never-skip gates closed while its view is
still converging: until every configured peer has been polled once, and
(bounded by about two poll intervals) until the current-build agreeing peers
re-attest this incarnation. Without the hold, a fresh manager's blank view
would elect itself on every node at once and double-run each due
PreferLeader firing on a healthy cluster. The cost is the mirror image: a
PreferLeader firing due inside that window is skipped -- on every node when
the held node is the rightful owner, since its peers still defer to it --
transient and self-healing, the same fail-closed trade the quorum gate makes.
A pending retry treats the hold as a transient denial (deferred, never
abandoned).
@reboot fires once at startup, which is the one instant the cluster has not
yet converged: no peer has been polled, so there is no quorum and no elected
owner. Running a leader-gated @reboot job immediately would misfire: a
Leader job would see no quorum and skip forever (@reboot never re-fires),
and a PreferLeader job would see only itself on every node and run everywhere.
So under electLeader an @reboot job with Leader or PreferLeader policy is
deferred: held until the cluster converges, then run once on the owner
that policy resolves to. The deferral exists only to get past that boot-time
"every node sees only itself" window; which owner runs it, and whether it runs
at all without a quorum, follows the job's policy exactly as for a scheduled
firing:
-
Leaderruns on the quorum-gated elected owner. If no quorum ever forms, the deferred job does not run (the at-most-once trade: a skip is preferred to a double-run), and it also stands down while anodeName/size conflict is visible. -
PreferLeaderruns on the quorum-free availability owner (the lowest reachable agreeing node), so it always resolves to some node and runs even with no quorum (an isolated or minority node runs it itself), exactly mirroringPreferLeader's never-skip contract for scheduled jobs. The price, as ever forPreferLeader, is a possible double-run across a partition.
For @reboot work that must run on every node at boot (warming a local
cache, announcing the node), use clusterPolicy: EveryNode, which is not
deferred.
A deferred @reboot one-shot is never silently lost across a reload.
Deferral only happens at the boot instant, so a job whose name momentarily
disappears from the loaded config before the cluster converges (a templating
glitch, or a remove-then-re-add seen mid-reload) is kept pending, not
dropped, and runs once the name comes back. The launch is always gated on the
name being present and still a Leader/PreferLeader @reboot, so:
- a job you deliberately remove from the config (and leave removed) never runs, since its name stays absent;
- a name reused for a different
@rebootjob runs the current definition, never the one captured at boot; and if the reused job is no longer a deferrable@reboot(it becameEveryNode, or a real schedule), the original one-shot is considered gone and the new job is left to its own scheduling.
On a lease backend the same deferral applies, translated to lease vocabulary:
a Leader @reboot runs on the lease holder (and skips while the store is
unreachable), a PreferLeader @reboot runs on this node when the store is
unreachable (a possible boot-time double-run), and EveryNode is not deferred.
The cross-node "already ran" record that gossip advertises peer-to-peer is
persisted in the lease store instead (a Kubernetes Lease annotation, an etcd
sibling key), scoped to the current job-set id, so
a failover holder does not re-run the one-shot. Because the store outlives the
processes, this shifts the semantics: a Leader @reboot runs once per job
configuration, not once per boot. Restarting the whole fleet with an unchanged
config does not re-fire it -- every node reads the record back and retires the
one-shot without running it. It fires again only when the job set changes (a new
job-set id invalidates the stored record), so a warm-up or migration step that
must run on every deploy cannot rely on a leader-gated @reboot here unless the
deploy also changes the job set.
By default (distribution: single-leader) the single elected leader runs
every Leader job, so the other replicas are pure standby for scheduled
work. That is the simplest model, but on a busy cluster it makes the leader a
hotspot while the rest idle.
Setting distribution: spread keeps the same quorum gate but replaces the one
leader with per-job ownership: each leader-gated job is assigned to a single
node by rendezvous (highest-random-weight) hashing of the job name against the
agreeing members. Different jobs hash to different nodes, so the scheduled
workload fans out roughly evenly across the cluster.
cluster:
listen: "0.0.0.0:8443"
tls: { ca: /etc/yacron2/cluster-ca.pem, cert: /etc/yacron2/this-node.pem, key: /etc/yacron2/this-node.key }
peers:
- host: yacron-b.internal:8443
- host: yacron-c.internal:8443
electLeader: true
distribution: spread # default is single-leaderWhat to know:
-
Same safety, not more. Spread keeps the quorum gate and is at-most-once
for
Leaderjobs, no weaker than single-leader. Under a clean partition every quorate node sees the same member set and computes the same owner, so at most one node runs each job. The subtle case is a thin bridge (a quorate pair that share too few witnesses to confirm each other): because the rendezvous winner is per-job, it can be exactly such an unconfirmable node, so the raw hash would let a peer that cannot see it self-own the job and double-run it, even though single-leader (whose winner is always the one global-lowest node, which everyone can see) would not. Spread closes this by folding the unconfirmed peers a quorate neighbour vouches for into each job's rendezvous and deferring to any that outrank it (two strict majorities of oneNalways overlap, so a co-owner you cannot see is always gossiped to you). The price is the same fail-closed trade single-leader makes: a job whose owner no quorate peer can currently confirm stands down until the view converges. This is a load optimization; it does not change the headline best-effort guarantee.Leaderjobs still skip without quorum;PreferLeaderstill ignores quorum (its owner is computed over the reachable set, so an isolated node owns and runs all of its jobs, and it keeps its never-skip contract unchanged). -
Rendezvous hashing, not modulo. When a node leaves or joins, only its
share of jobs is reassigned (to the next-highest-weight node); the rest stay
put. A membership change is therefore minimally disruptive, unlike
hash % N, which would reshuffle everything. - Best with many or heavy jobs. Hashing is only roughly even, so with a handful of jobs the split is lumpy (several can land on one node). It pays off when a single node cannot comfortably carry all the scheduled work; for light workloads the default single leader is simpler and equally correct.
-
Keep it consistent. Every node must agree on
distribution(just like the peer list andelectLeader). A quorate peer that agrees on the job set but advertises a differentdistribution(orelectLeader) is treated as a first-class coordination-policy conflict: it surfaces aspolicy_conflict: true(with the differing descriptors inconflicting_policies) and, as the third trigger of the umbrellaconflictflag, stands this node'sLeaderjobs down (fail closed) until every node reconverges on one policy.distributionis not part of the job-set id (it is cluster config, not a job property), so a mismatch does not show up as drift; treat it likeelectLeaderand roll it out uniformly. It is inert withoutelectLeader(and yacron2 warns if you set it anyway).
The bundled three-node demo names its two scheduled
leader-gated jobs tick-leader-only (Leader) and tick-prefer-leader
(PreferLeader). With all three nodes healthy:
| Job |
single-leader (default) |
spread |
|---|---|---|
tick-leader-only |
runs on yacron-a (the leader) |
runs on yacron-c
|
tick-prefer-leader |
runs on yacron-a (the leader) |
runs on yacron-b
|
So flipping to spread moves the two jobs onto two different nodes instead of
piling both onto the leader. The owner is a deterministic function of the job
name and the live member set, so it stays put until membership changes (then
only the affected jobs move). You can confirm it live:
curl -s http://localhost:8080/jobs | python -m json.tool | grep -A1 clusterOwner
# under distribution: spread, each leader-gated job carries a "clusterOwner"
# naming the node that runs it; single-leader mode emits no clusterOwner (the
# leader from GET /cluster owns every Leader job)GET /cluster on the web/HTTP interface returns the current view as
JSON. When no cluster section is configured it returns
{"enabled": false, "peers": []}; otherwise it returns the node's view:
In spread mode there is no single leader, so leader is null and
is_leader is false; use quorate to tell whether this node is running its
owned jobs. The per-job owners appear as a clusterOwner field on each
leader-gated job in GET /jobs.
The JSON above is the gossip shape. A lease backend returns a lease-shaped
view instead: backend names the backend, peers is [],
cluster_size/quorum are 1, conflict/size_conflict/policy_conflict
are always false,
and an extra lease block carries the holder and expiry: for kubernetes
{name, namespace, identity, holder, expiry}, for etcd
{electionName, identity, holder, leaseId, expiry}. There quorate means the
node has a fresh read of the lease store (see
Operating the lease backends
and the GET /cluster reference).
The same view is rendered as a cluster panel in the Web Dashboard: a status dot per peer, the agreement tally, and (when election is on) the quorum count and this node's role (leader, follower, "no quorum", or, in spread mode, "spread (per-job owner)").
Beyond agreement and roles, the gossip exchange also carries observability
payload: each node piggybacks a compact per-job run summary (running /
enabled / next fire / last finished run) on its /peer response, capped so it
can never push the response past the gossip byte limit. Every node therefore
holds a fleet-wide picture of what ran where that is at most one interval
stale per peer, served as GET /fleet and rendered as
the dashboard's fleet view,
the single pane of glass for spread mode, where each job's runs land on a
different node. The summaries are display-only: election, quorum, and every
run/skip decision ignore them, and a malformed summary from a peer degrades to
"no data for that node". The lease backends exchange nothing node-to-node, so
/fleet reports enabled: false there.
quorate is the field to alert on for every backend (on gossip it means "this
node sees a majority"; on a lease backend it means "this node has a fresh read of
the store"). This section covers the gossip signals; the lease equivalents are in
Monitoring the lease backends.
Every signal you would alert on is exported natively at GET /metrics (see
Metrics with Prometheus) as yacron2_cluster_*
series -- yacron2_cluster_quorate, yacron2_cluster_is_leader,
yacron2_cluster_conflict{kind}, yacron2_cluster_peers{status},
yacron2_cluster_size / yacron2_cluster_quorum, plus the
yacron2_cluster_leader_transitions_total and
yacron2_cluster_quorum_transitions_total counters -- each mirroring a
pre-derived field on GET /cluster (the
statsd integration is still per-job). Useful alerts:
| Alert when | Field(s) | Metric(s) | Means |
|---|---|---|---|
quorate is false for more than a few intervals |
quorate |
yacron2_cluster_quorate |
this node cannot see a majority, so its Leader jobs are standing down |
conflict is true
|
conflict, conflict_names, size_conflict, conflicting_sizes, policy_conflict, conflicting_policies
|
yacron2_cluster_conflict{kind} (kind="nodename" / "size" / "policy") |
a duplicate nodeName, a cluster-size disagreement, or a coordination-policy (distribution/elect_leader) mismatch is pausing Leader jobs (page on this) |
agreed peers fall below quorum
|
count of peers[].status == "agreed" vs quorum
|
yacron2_cluster_peers{status="agreed"} vs yacron2_cluster_quorum
|
the cluster is one failure from losing quorum (this node counts itself toward quorum, so quorum − 1 agreed peers is the last quorate state; any fewer duplicates the quorate alert) |
any peers[].status is untrusted
|
peers[].status, peers[].last_error
|
yacron2_cluster_peers{status="untrusted"} |
a peer's certificate failed verification (often a botched cert rotation; see Certificate rotation) |
The example alerts on the Prometheus
page include the quorum rule and the split-brain check
(sum(yacron2_cluster_is_leader) > 1). A blackbox / JSON-exporter probe
(Prometheus json_exporter, a Nagios check, etc.) scraping GET /cluster on
every replica remains a valid alternative, and is the only source for the
detail fields the metrics do not carry (per-peer last_seen and last_error,
conflict_names, conflicting_sizes, conflicting_policies; the leader's
name surfaces only as the yacron2_cluster_leader_info{leader} label). The same
transitions are also logged (leadership and quorum changes, conflict
onset at ERROR (clear at INFO), and per-peer untrusted/unreachable/drift
at WARNING), so a log-based alert is a viable second source.
The best-effort guarantee admits narrow windows
where two nodes each run the same Leader firing (a thin bridge, a >1-hop
gossip gap, or mid-convergence). This is not caught by the conflict flag:
that flag is only for a duplicate nodeName or a size disagreement, and by
construction the two transient leaders cannot see each other, so neither one's
GET /cluster shows anything wrong (each reports is_leader: true). There is
no single-node signal for it.
To detect it, scrape every replica's GET /cluster and alert when more than
one calls itself the leader:
# across all replicas, count how many believe they are the leader
for url in yacron-a:8080 yacron-b:8080 yacron-c:8080; do
curl -s "http://$url/cluster" \
| python -c 'import sys,json; print(str(json.load(sys.stdin).get("is_leader")).lower())'
done | grep -c '^true$' # > 1 means a transient double-leaderUnder distribution: spread there is no single leader, so instead compare the
clusterOwner each replica reports per job (GET /jobs) and alert if any job
has more than one distinct owner across the fleet. A non-idempotent job that
must never double-run belongs on a fenced backend (kubernetes / etcd) or a
single replica, not the gossip backend.
The delivery guarantee each clusterPolicy gives depends on the backend. The
matrix below is the one-word summary (fuller wording follows); "fenced" is the
hard, single-holder guarantee, "best-effort" admits the narrow gossip windows,
and "may skip" / "may dup" name the side each policy gives up:
clusterPolicy |
gossip |
kubernetes |
etcd |
|---|---|---|---|
Leader |
best-effort (may skip; rare dup) | fenced (may skip) | fenced (may skip) |
PreferLeader |
never-skip (may dup) | never-skip (may dup) | never-skip (may dup) |
EveryNode |
every-node | every-node | every-node |
On the lease backends "fenced" holds while the store is reachable; a store
outage is the one window a Leader job skips and a PreferLeader job may
double-run (see Failure modes). EveryNode is never gated on
any backend.
This gossip design intentionally keeps no shared state, which is what makes
it easy to run, but it means the guarantee is best-effort, not fenced
exactly-once. Because each node acts on a view only as fresh as its last poll
(interval), there are narrow windows where behaviour degrades:
-
Just after a leader dies, a
Leaderfiring may be skipped until the survivors notice (up to oneinterval) and re-elect. -
A leader partitioned away while still alive keeps electing itself on its
last (now-stale) view until its own next poll fails (up to one
interval), overlapping the majority's re-election, so a clean partition can briefly double-run aLeaderfiring, not only skip one. It self-heals once the cut-off node re-polls and stands down. -
Asymmetric or partial reachability. Two nodes that never agree with each
other can each stay quorate through shared members that bridge them. The
election turns that bridge from cause into cure: each side discovers the other
through the shared members' gossip and, once it can confirm the other is
itself quorate, the lower
nodeNamewins on both sides, so a bridge of at leastquorum - 1shared members collapses two would-be leaders back to one. A node only ever elects a leader it can confirm is itself quorate, so in a converged view a healthy majority is not silently stood down (it elects a node that actually runs). Two deliberate trades come with that liveness: two quorate nodes whose bridge is thinner thanquorum - 1shared members, are more than one gossip hop apart, or are still converging may each elect themselves and double-run aLeaderjob; and symmetrically (because bridge confirmation is only as fresh as the witnesses' last gossip) a confirmed candidate that has since become isolated can briefly draw the majority into deferring to it, a transient skip until the stale gossip ages out (~1–2intervals).spreadbehaves the same per job. (Choosing instead to fail closed on the double-run (skip rather than double-run) would require a lease/consensus store; see below.) -
While a resize is rolling out, nodes briefly disagree on the cluster size
N;Leaderjobs across the whole cluster stand down (fail closed) until every node'speersagree again, the at-most-once-preserving trade-off (see Consistent cluster size). - A
PreferLeaderjob may double-run across a partition (that is the point of the policy: it never skips).
If you need a hard exactly-once guarantee, you need a lease/consensus store
(etcd, a Kubernetes Lease), which this design deliberately avoids. If a job
must never be skipped or doubled, run a single replica (replicas: 1) or use
an external coordinator. Tuning the interval shorter narrows the degraded
windows at the cost of more polling traffic.
Rotation is automatic on the gossip backend: yacron2 reloads its mTLS
contexts when the mounted CA/cert/key change in place. On each config-reload pass
(every minute) it compares the on-disk CA, cert, and key against what it loaded
at startup and, if any changed, restarts the cluster manager to rebuild the TLS
contexts with the new material, so an in-place renewal (the cert-manager, Vault,
or Kubernetes mounted-secret pattern of same paths, new bytes) needs no manual
restart. A detected change is dry-run loaded first, so a half-written cert
observed mid-rotation is retried rather than tearing the cluster down. This is
gossip-only; the lease backends use no per-node mTLS certs.
For the operational runbooks (leaf-cert rotation, rolling the cluster CA with
trust overlap, and recovering from an untrusted cascade), see
Cluster certificate operations.
The kubernetes and etcd backends replace the gossip protocol with a real
coordination store, giving a fenced, exactly-once election while the store
is reachable. They share one code path (yacron2.leadership.LeaseBackend) and
differ only in which store they talk to. This section covers how they elect, how
to deploy each, their failure modes, and how to monitor them; the config keys
are in the Configuration Reference.
How the lease backends talk to their store: over plain HTTP using the core
aiohttp dependency, namely the Kubernetes apiserver's REST API and etcd's v3
gRPC-gateway JSON API. So the core install gains no new dependency, and by
avoiding grpc/protobuf wheels both backends run on the full set of architectures
yacron2 ships for. The Kubernetes backend can optionally use the official
kubernetes client when it is installed
(pip install yacron2[kubernetes]): cluster.kubernetes.clientLibrary: auto
(the default) prefers it when importable and otherwise falls back to the
hand-rolled REST transport, so the choice is automatic per architecture
(library requires the client, http forces the hand-rolled path). etcd always
uses its own v3 JSON gateway, so it has no optional client.
-
No peer list, no mTLS, no quorum math. The store is the single source of
truth, so the gossip-only keys
listen,tls,peers,interval, anddriftAfterare ignored (each logs a one-line startup advisory). A lease backend always elects, soelectLeaderis implied andelectLeader: falseis likewise ignored with an advisory (configuring a lease backend is opting into leadership). The cluster is logically a single holder (cluster_size/quorumreport1), andGET /clusterreturns a lease-shaped view; its full field list is under Observing the cluster. -
The lease is the fence, not a name. Leadership is decided by the
lease, so a duplicate node identity cannot make two nodes both lead the way
it can on a naive lease holder: etcd fences on the bound lease id (only
the node whose own lease backs the election key leads), and Kubernetes writes
a per-process
holderIdentitytoken so two nodes sharing anodeNamestill write distinct holders. You should still give each node a stable, unique name for clear observability (see Node identity). - Local-expiry safety. A holder only calls itself leader until a locally-computed lease deadline (renew time + duration, minus a small clock-skew margin), so a node whose renew loop stalls self-demotes without a network round-trip, and never two holders act at once.
-
PreferLeaderkeeps never-skip semantics. A node that currently cannot reach the coordination store runs aPreferLeaderjob anyway (it may double-run); a healthy follower that can see the holder defers.Leaderstays fail-closed: it skips while the store is unreachable. This is the deliberate, documented trade: aPreferLeaderjob never skips, at the cost of a possible double-run during a store outage. -
distribution: spread(an opt-in, gossip-only mode that fans jobs across nodes instead of one leader; see Distribution) is rejected at config load on a lease backend (a hardConfigError, not a silent fallback). A single lease holder cannot also be a per-job owner; use the gossip backend if you need per-job spread.
Both reduce leadership to "hold a short-lived lease on a shared object and keep renewing it":
-
Kubernetes drives a single
coordination.k8s.io/v1Lease. The holder writes its identity intospec.holderIdentityand refreshesspec.renewTimeeveryretryPeriodSeconds; if it stops, another node observes the lease go stale and takes it over, the standard client-go leader-election algorithm. The takeover deadline is anchored to the challenger's own clock from the moment it first saw the record (client-go'sobservedTime), so it is immune to clock skew between holder and challenger: a fast clock cannot steal a freshly-renewed lease. -
etcd creates a single key (
electionName) with a create-if-absent transaction (compareCREATErevision== 0), bound to a short-TTL lease it keeps alive. At most one node's transaction wins; if the holder dies the lease expires, etcd deletes the key, and another node's transaction wins. etcd's server enforces the TTL.
Both gate is_leader() on a locally-computed lease deadline (renew/keepalive
time + duration − a 1 s clock-skew margin), so a node whose renew loop stalls
self-demotes with no network call: that local expiry, not the store, is what
guarantees two holders never act at once. Separately, is_quorate() reflects
whether the node has a fresh successful read of the store (within one lease
duration / TTL); when it goes stale, Leader jobs fail closed and the never-skip
PreferLeader default runs the job anyway.
Precision note. The clock-skew margin applies to
is_leader's lease deadline, not to theis_quoratefreshness window (the full duration / TTL, no margin). So a follower's view of who leads can briefly lag a dead holder by up to one freshness window, while the would-be leader has already self-demoted: bounded and self-healing, andPreferLeadernever skips during it.
Leadership is fenced on the lease, but each node still carries an identity:
-
etcd: the value written at the election key is
cluster.nodeName(there is no separateetcd.identitykey). Leadership is decided on the bound lease id, not this string, so even two nodes sharing anodeNamecannot both lead (only the node whose lease backs the key is leader); a shared name only makes the displayed holder ambiguous. -
Kubernetes:
cluster.kubernetes.identity(defaulting tonodeName) is the human-readable holder; yacron2 appends a per-process token to theholderIdentityit actually writes (<identity>#<token>), so two nodes sharing an identity still write distinct holders and cannot both renew. The dashboard andGET /clusterstrip the token back to the readable name (sokubectl get lease … -o jsonpath='{.spec.holderIdentity}'shows the suffixed form, while the dashboard shows the clean name).
A duplicate identity therefore no longer silently breaks the fence, but give
each node a stable, unique name anyway so the holder shown in the dashboard,
kubectl get lease, or etcdctl get unambiguously names one node. In Kubernetes
both a Deployment and a StatefulSet give each pod a unique hostname; a
StatefulSet's ordinals just make the holder name predictable across restarts.
No mTLS, no peer list, no odd-replica rule: the apiserver is the authority, not
a quorum, so a plain Deployment with any replica count works:
cluster:
backend: kubernetes
kubernetes:
leaseName: yacron2-leader # the Lease object the replicas contend for
# leaseNamespace: null # default: the pod's own namespace
leaseDurationSeconds: 15 # failover happens within ~this long
renewDeadlineSeconds: 10 # must be < leaseDurationSeconds
retryPeriodSeconds: 2 # renew/observe cadence; must be < renewDeadlineSeconds
# clientLibrary: auto # auto | http | library (see below)The three timings are cross-checked at config load (a ConfigError
otherwise): renewDeadlineSeconds > 0,
leaseDurationSeconds > renewDeadlineSeconds, 0 < retryPeriodSeconds < renewDeadlineSeconds, and renewDeadlineSeconds + retryPeriodSeconds < leaseDurationSeconds (so a renew that just misses its deadline still leaves a
retry inside the lease window). Run yacron2 --validate-config to check them
before deploying; see the Command-Line Reference.
-
RBAC (required). The backend needs
get(observe) andupdate(renew / take over / release) on the one namedLease, pluscreateonleasesto first acquire it.createcannot be scoped byresourceNames, so it is a second, unscoped rule:apiVersion: rbac.authorization.k8s.io/v1 kind: Role rules: - apiGroups: ["coordination.k8s.io"] resources: ["leases"] resourceNames: ["yacron2-leader"] # keep in sync with cluster.kubernetes.leaseName verbs: ["get", "update"] - apiGroups: ["coordination.k8s.io"] resources: ["leases"] verbs: ["create"] # create cannot be scoped by resourceNames
A ready-to-apply
ServiceAccount+Role+RoleBinding+ 3-replicaDeploymentis inexample/kubernetes/deployment.yaml. Runyacron2 --validate-configon the config before applying the manifests; see the Command-Line Reference. -
Credentials. In-cluster, the pod's service-account token, CA, and namespace file are used automatically (
leaseNamespacedefaults to the pod's own namespace). For out-of-cluster / local testing setcluster.kubernetes.kubeconfig(and optionallyapiServerto override the server URL). -
Transport (
clientLibrary).auto(default) uses the officialkubernetesclient when it is importable (pip install yacron2[kubernetes]) and otherwise falls back to a hand-rolled apiserver REST transport over the coreaiohttpdependency, so on an architecture without the client it still works.httpforces the REST transport;libraryrequires the native client and fails the backend start (the node'sLeaderjobs then fail closed) if it is not importable. Both transports drive the same Lease, so the choice is purely about which client code runs. -
Failover timing. A holder that dies is replaced within ~
leaseDurationSeconds. On a graceful shutdown the holder clearsholderIdentityso a survivor takes over immediately. Shorter durations fail over faster at the cost of more apiserver traffic. All three timings are ordered and enforced at config load:leaseDurationSeconds > renewDeadlineSeconds,retryPeriodSeconds < renewDeadlineSeconds, andrenewDeadlineSeconds + retryPeriodSeconds < leaseDurationSeconds(so a renew that just misses its deadline still leaves a retry inside the window).
Point the backend at one or more etcd endpoints (tried in order for failover):
cluster:
backend: etcd
etcd:
endpoints: [http://etcd-0:2379, http://etcd-1:2379]
electionName: yacron2/leader # the key; its value is the holder's nodeName
ttl: 15 # lease TTL, seconds (>= 3; keepalive every ~ttl/3)
# username: root # for an auth-enabled cluster …
# password: { fromEnvVar: ETCD_PASSWORD }
# tls: { ca: /etc/etcd/ca.pem, cert: /etc/etcd/client.pem, key: /etc/etcd/client.key }-
Transport. Speaks etcd's v3 gRPC-gateway JSON/HTTP API directly over
aiohttp: noetcd3/grpc client, so no extra dependency and full architecture coverage. There is no optional native-client extra for etcd. -
Authentication. For an auth-enabled cluster set
usernameand resolve thepasswordfrom exactly one ofvalue/fromFile/fromEnvVar(likeweb.authToken); a configured-but-empty source fails closed at load. The auth token is obtained at startup and re-fetched automatically when it expires (yacron2 re-authenticates on an etcd401), so a token TTL does not wedge the backend. Always pair ausernamewith a resolvablepassword. -
TLS. For
https://endpoints settls.ca(andtls.cert/tls.keyfor client-cert auth).http://andhttps://endpoints are detected per-URL. -
Failover timing. A dead holder is replaced within ~
ttl. On a graceful shutdown the holder revokes its lease, deleting the key at once for immediate failover. The value atelectionNameis the holder'snodeName, soetcdctl get yacron2/leadershows who leads.ttlmust be >= 3 (a smaller value is rejected at config load). etcd may grant a smaller TTL than requested (its--min-lease-ttlsetting, or server load), which the backend honours: a smaller server-granted TTL narrows the effective leader window accordingly.
-
Store unreachable (apiserver / etcd down, or partitioned from this node).
Within one duration / TTL the node's
is_quorate()goes stale, so itsLeaderjobs fail closed (skip) and itsPreferLeaderjobs run anyway (the never-skip rule: they may double-run across the outage). When the store returns, leadership re-establishes within ~one duration. This is the lease backends' one double-run exposure, and it is the documentedPreferLeadertrade, not a fence break. -
A fenced backend never shows two simultaneous leaders. Unlike gossip there
is no thin-bridge / convergence double-run window for
Leaderjobs, so scraping every replica foris_leader: true(the gossip double-run check) will never find two while the store is healthy. -
Kubernetes optimistic concurrency. Writes carry the observed
resourceVersion; a node that loses the race gets an HTTP409, stands down for that round, and retries. The graceful release is best-effort: if it races a concurrent write the handover may instead wait outleaseDurationSeconds. - etcd lease loss. If a keepalive reports the lease gone (TTL ≤ 0) the holder re-grants a fresh lease and re-campaigns, becoming leader again only if it re-wins the key.
-
etcd TTL below the floor. If the server-honoured TTL ever drops below the
usable floor (3 s), the fence collapses to ~zero and this node's
Leaderjobs fail closed (safe, but it can no longer lead). yacron2 logs a one-time warning on that transition (and a recovery once the granted TTL returns above the floor); check etcd's--min-lease-ttland load if you see it.
quorate is the field to alert on for every backend; on a lease backend it
means this node has a fresh read of the store, not that it sees a majority. The
gossip alerts on the Monitoring table (per-peer
status, agreed-peers-vs-quorum, untrusted certs, the multi-leader scrape) do
not apply: a lease view has peers: [], quorum: 1, and conflict is always
false, and a fenced backend never reports two leaders. So the signal that
matters is quorate:
| Alert when | Field(s) | Means |
|---|---|---|
quorate is false on a replica for more than a few rounds |
quorate |
that replica cannot reach the lease store (not "no majority"): its Leader jobs are standing down and its PreferLeader jobs may double-run |
| the holder is unexpected or flapping |
lease.holder, lease.expiry
|
leadership is moving more than it should (renew loop starved, duration too short) |
Probe GET /cluster on each replica, or watch the store directly
(kubectl get lease <name> -o jsonpath='{.spec.holderIdentity}',
etcdctl get <electionName>). The same leadership transitions are logged.
The lease-backend @reboot behaviour is covered inline in the main
@reboot section.
For running multiple replicas on Kubernetes (both backend: kubernetes and
backend: gossip on a StatefulSet), see
Production and Container Deployment.
The repository ships a ready-to-run three-node cluster in
docker-compose-cluster.yml.
It generates a throwaway cluster CA and per-node certificates, brings up three
mutually-attesting nodes with electLeader: true and one job of each
clusterPolicy, and publishes each node's dashboard on a separate port
(8080/8081/8082) so you can watch leadership move when you stop the leader:
docker compose -f docker-compose-cluster.yml up --build
# then open http://localhost:8080/ , :8081 , :8082 and watch the cluster panel
docker compose -f docker-compose-cluster.yml stop yacron-a # watch leadership move to yacron-bThe compose file's header comments document the full set of things to try (losing quorum, drift, the per-policy job behaviour).
A full showcase. For the fullest end-to-end demo (distribution: spread,
all three clusterPolicy values, and mTLS together), the repository ships
docker-compose-acme.yml;
its walkthrough is in
example/acme-platform/README.md.
To watch distribution: spread
fan real load across the cluster, the repository also ships
docker-compose-cluster-large.yml:
ten nodes (dashboards on ports 8080–8089) running a larger job set with
several CPU-heavy jobs, defaulting to spread. Each node's config is generated
from environment variables by a small entrypoint, so there are no per-node files
to maintain.
docker compose -f docker-compose-cluster-large.yml up --build
docker stats # watch CPU spread across the nodes (a few cores busy on several nodes)
# contrast: pin everything to one leader and watch a single node light up
DISTRIBUTION=single-leader docker compose -f docker-compose-cluster-large.yml up -d(Ten is an even size, so the nodes log the even-size warning; that is expected and called out in the file. Quorum is 6, so it tolerates four failures.) Its header comments list how to inspect per-job owners and fail nodes.
To try a lease backend instead of gossip,
example/etcd/
ships a docker-compose.yml with a single etcd plus two yacron2 instances
(backend: etcd) and one job of each clusterPolicy:
docker compose -f example/etcd/docker-compose.yml up --build
# exactly one instance leads; fail it over and watch the other take over within ~ttl:
docker compose -f example/etcd/docker-compose.yml stop yacron2-a
docker compose -f example/etcd/docker-compose.yml exec etcd etcdctl get yacron2/leaderFor the Kubernetes Lease backend,
example/kubernetes/
has the RBAC + Deployment to apply against any cluster (k3d/kind for local).
-
Configuration Reference: the
clustersection andclusterPolicyoption schema. -
HTTP Control API: the
GET /clusterendpoint. - Web Dashboard: the cluster panel and per-job policy display.
- Production and Container Deployment: running multiple replicas under Kubernetes.
-
Architecture and Internals: where
cluster.pyfits in the daemon.
This expands the practical rule in Sizing the cluster with
the underlying probability. A Leader job fires successfully only while a quorum
is up and mutually reachable. If each node is independently up with probability
p, and the quorum is q = ⌊N/2⌋ + 1, then the chance a given firing runs is
the probability that at least q of N nodes are up, a binomial tail:
P(runs) = Σ (from k=q to N) C(N, k) · p^k · (1 − p)^(N − k)
The table evaluates that for a few realistic per-node availabilities, as a
fraction and as "nines" (−log₁₀(1 − P)). "Tol." is how many simultaneous node
failures the size survives (N − q).
| N | Quorum | Tol. | P(runs), p=0.9 | p=0.99 | p=0.999 |
|---|---|---|---|---|---|
| 1 | 1 | 0 | 0.9000 (1.0 nines) | 0.9900 (2.0) | 0.99900 (3.0) |
| 2 | 2 | 0 | 0.8100 (0.7) | 0.9801 (1.7) | 0.99800 (2.7) |
| 3 | 2 | 1 | 0.9720 (1.6) | 0.99970 (3.5) | 0.9999970 (5.5) |
| 4 | 3 | 1 | 0.9477 (1.3) | 0.99941 (3.2) | 0.9999940 (5.2) |
| 5 | 3 | 2 | 0.9914 (2.1) | 0.999990 (5.0) | ≈1 (8.0) |
| 7 | 4 | 3 | 0.9973 (2.6) | ≈1 (6.5) | ≈1 (10.5) |
How to read it:
- Odd sizes are the sweet spot. Each odd size adds one failure of headroom over the previous odd size: 3 tolerates 1, 5 tolerates 2, 7 tolerates 3.
- Even sizes are equal-or-worse, never better. N=4 still needs a quorum of 3, so it tolerates the same single failure as N=3, but it has an extra node that can fail, so its P(runs) is actually slightly lower (0.99941 vs 0.99970 at p=0.99). yacron2 warns on even sizes for exactly this reason.
-
2 is worse than 1. A 2-node quorum is 2, so both must be up: P = p²
(0.9801 at p=0.99), below a single node's
p(0.99), with no failover upside. yacron2 refuses to start withelectLeaderand a 2-node cluster, raising aConfigError("...strictly worse than a single replica..."). The same 2-node cluster is fine for attestation-only (withoutelectLeader).
Caveats on the math:
- It assumes independent failures. Correlated failures (a bad config push, a
shared host, zone, or power domain) break that assumption, and then more nodes
can even hurt. Spread the nodes across independent failure domains;
pshould be realistic uptime including deploys and restarts, not raw hardware MTBF. - It only models "is a quorum up". It does not capture the narrow
membership-change windows in Guarantees and trade-offs
(a firing may still slip through them), nor
PreferLeaderduplication, which is about partitions rather than node-up probability.
This wiki documents yacron2. See the README and the changelog.
yacron2 is a fork of gjcarneiro/yacron.
- Getting Started
- Configuration
- Job Behavior
- Integrations
- Reference and Development
{ "enabled": true, "backend": "gossip", // the active cluster.backend "node_name": "yacron-a", "job_set_id": "v1:…", "cluster_size": 3, "quorum": 2, "elect_leader": true, "distribution": "single-leader", // or "spread" "conflict": false, // umbrella: true if any conflict pauses Leader jobs "conflict_names": [], // the duplicated nodeName(s), if any "size_conflict": false, // true if an agreeing peer declares a different N "conflicting_sizes": [], // those divergent cluster sizes, if any "policy_conflict": false, // true if an agreeing peer declares a different distribution/elect_leader "conflicting_policies": [], // those differing coordination-policy descriptors, if any "quorate": true, // whether this node sees a quorum "leader": "yacron-a", // null when not quorate, or always in spread mode "is_leader": true, // always false in spread mode (no single leader) "peers": [ {"host": "yacron-b.internal:8443", "status": "agreed", "node_name": "yacron-b", "job_set_id": "v1:…", "last_seen": "2026-06-23T19:00:00+00:00", "last_error": null, "mismatch_streak": 0}, {"host": "yacron-c.internal:8443", "status": "unreachable", "node_name": null, "job_set_id": null, "last_seen": null, "last_error": "Cannot connect to host …", "mismatch_streak": 0} ] }