Releases: ptweezy/yacron2
Release list
1.2.7
Full Changelog: 1.2.6...1.2.7
1.2.6
A toolchain and packaging release. There are no behavior, API, or
configuration changes and no change to the core dependency footprint: the
published wheel/sdist and the release binaries are built from the same sources
as before and install exactly the same runtime dependencies. The work adopts
uv across the paths where it pays off -- CI, the
build/release pipeline, and the local dev loop -- refreshes the container base
images, and pins the CI action versions.
-
uv on the runner-native CI, build, and dev paths. The dist build
(uv build), the runner-native PyInstaller binaries (macOS, Windows, and the
all-wheels Linux arches, each installed into a throwawayuv venvand frozen
withuv run), the version probe (uv run --no-project --with setuptools-scm), andtwine check(uvx twine) all run through uv now:
parallel downloads and a shared global wheel cache make them markedly faster,
and the results are behavior-identical.UV_PYTHON_DOWNLOADS=neverkeeps uv on
the exact interpretersetup-pythonpinned rather than fetching a managed one,
andUV_HTTP_TIMEOUTcarries the same transient-network hardening the pip
paths had. -
The emulated foreign-arch binary legs stay on pip, on purpose. The
musl and glibc-extra binary jobs (armv7/armv6, ppc64le, s390x, riscv64, i686)
build insidedocker runcontainers under QEMU, where uv is not a fit: its
official image is amd64/arm64 only and it has no musl ppc64le/s390x wheels, so
pip remains the arch-portable choice there and keepsPIP_RETRIES/PIP_TIMEOUT
hardening. The uvloop bundling and per-arch--versionsmoke test are
unchanged on every leg. -
uv in the local dev loop.
tox.ininow declaresrequires = tox-uv, so a
plaintoxauto-provisions its environments and installs dependencies with uv
(much faster, behavior-identical);tox-uvis added to thedevextra and
requirements_dev.txt.CONTRIBUTING.mddocuments the uv quickstart
(uv venv,uv pip install -e ".[dev]") alongside the unchanged stock
venv+pippath, and notes thetox --runner virtualenvescape hatch for
anyone who wants the legacy runner. -
Refreshed container base images. The Docker variant matrix moves to
current bases:ubuntu24.04 -> 26.04 (Python 3.12 -> 3.14),rhelUBI9 ->
UBI10,fedora41 -> 44 (3.13 -> 3.14),opensuseLeap 15.6 -> 16.0 (3.11 ->
3.13), anddistrolessto Python 3.13. The Debian/Alpine images and every
image tag stay as they were. -
Internal: every CI-consumed action is pinned to an exact version
(checkout@v7.0.0,setup-python@v6.3.0,setup-uv@v8.2.0, the docker/*
actions, etc.), and adependabot.ymlis added to keep those pins and the
Python dev dependencies current.
Full Changelog: 1.2.5...1.2.6
1.2.5
A performance and footprint release. There are no behavior or configuration
changes: every schedule fires exactly as before, the metrics endpoint renders
byte-for-byte identically, and the core install stays zero-new-dependency. The
work trims CPU on the daemon's hottest repeating paths -- the once-a-minute
config reload, every Prometheus scrape, and each cluster poll / gossip round /
lease renew -- lowers steady-state memory, and adds an optional faster event
loop.
-
Optional uvloop event loop (
speedupsextra).pip install yacron2[speedups]swaps asyncio's selector loop for uvloop's faster
libuv-based one, speeding every I/O path yacron2 drives: cluster gossip and
lease HTTP, the web dashboard, and the Prometheus scrape. It is entirely
opt-in and best-effort --__main__selects uvloop lazily on POSIX and falls
back to stock asyncio, behavior unchanged, whenever it is absent or
unimportable -- so it stays off the core install to keep the baseline
architecture-portable. Windows always uses its Proactor loop (there is no
uvloop build there, and the Proactor loop is required for subprocess support
anyway). The prebuilt POSIX binaries now bundle uvloop wherever it builds: a
wheel where one exists, an otherwise verified source build, with a start-up
self-test (verify_uvloop.py) that uninstalls a miscompiled build (a real
risk under QEMU emulation) before freezing so the binary cleanly runs on
asyncio instead. An arch where uvloop cannot build ships the asyncio binary
exactly as before. -
The once-a-minute reload no longer reparses an unchanged config. The
scheduler rereads and reparses the config every minute so an on-disk edit is
picked up promptly, but strictyaml is a slow pure-Python parser and reparsing
an unchanged file was pure wasted work (in a worker thread, but still real CPU
plus thread-pool churn).reload_confignow compares a cheapos.stat
fingerprint --(path, mtime_ns, size)per file, plus the config directory's
own mtime -- of exactly the files the last parse read (the top-level config,
every transitivelyincluded file, and each job'senv_file) and skips the
reparse entirely when nothing has changed, returning the already-loaded
config. A genuine edit, a vanished file, or a new entry dropped into a config
directory still reparses on the next pass. -
Cheaper Prometheus scrapes. The job-set fingerprint (
job_set_id, queried
on every scrape and every cluster poll / gossip round / lease renew) is a pure
function of the loaded jobs, so it is now computed once per reload and
memoized rather than re-deriving its per-job deepcopy / JSON / SHA-256 each
time. Thejob_next_run_timestampgauge reads the scheduler's authoritative
next-fire index instead of re-walking every crontab and building two aware
datetimes per job per scrape (falling back to a direct computation only in the
brief start-up window before the index is seeded). The histogramlelabel
strings are precomputed once from the bucket bounds rather than re-rendered for
every bucket of every job on every scrape. -
Lower steady-state memory and faster attribute access.
JobConfig-- one
instance per configured job for the life of the process -- now declares
__slots__, trimming its per-instance__dict__and speeding the attribute
reads on the scheduling hot path. Fingerprint redaction is now copy-on-write
instead ofdeepcopy, so the long immutable report templates (the sentry body,
the webhook body) are shared by reference rather than duplicated on each
fingerprint. The PyInstaller binaries are now built withoptimize=2, which
strips docstrings and (side-effect-free, internal-invariant) asserts from the
frozen bytecode -- yacron2's modules are deliberately docstring-dense, so this
shrinks the binary and lowers resident memory for the life of the daemon. -
The idle scheduler no longer polls once a second. The job reaper waited on
its "any job running?" event with a one-second timeout, waking every second
even when nothing was running. It now blocks on the event outright -- the wait
condition can only change when a job launches or shutdown is signalled, both of
which set the event (shutdown now does so explicitly, so the reaper exits
promptly) -- so a fully idle daemon does no per-second work. A related fix reads
the running-jobs map with.get()so a concurrency check can no longer leave a
phantom empty entry that would spin the reaper hot at shutdown. -
Internal: the reload skip cache (change detection, the failed-parse and
worker-thread paths), the memoized fingerprint, and the scrape reading the
seeded next-fire index are covered by new tests; the uvloop bundling is gated
behind a build-time verification step and the per-arch--versionsmoke test.
Full Changelog: 1.2.4...1.2.5
1.2.4
This release re-implements the scheduler core added in 1.2.3 without changing
what it does: every schedule fires exactly when it did before, and there are no
configuration changes. The daemon no longer wakes on a fixed cadence and tests
every job against the clock; it keeps each job's next fire time in an index and
sleeps until the soonest one is due, servicing only the jobs whose moment
has arrived.
-
Per-wake cost scales with jobs due, not jobs configured. The previous
loop matched every enabled job against the clock on every tick -- and with a
second-level job present that tick was once a second, so the whole job set was
scanned every second. The scheduler now maintains a next-fire index (each
job's next-fire instant, mirrored in a min-heap), sleeps until the earliest
entry, and touches only the jobs actually due. An idle wake over a large fleet
is an O(1) heap peek that runs zero cron matching; a wake with a cohort due
matches only that cohort. A deployment running thousands of sparsely-scheduled
jobs pays dramatically less per wake, and adding a second-level job no longer
imposes a per-second scan of everything else. -
Robust across wall-clock and NTP steps. The sleep is realized against the
event loop's monotonic clock, and firing compares the wall clock against
fixed, forward-only next-fire instants. A clock step backward (an NTP
correction or a manual set) now defers the pending fire instead of re-running
a slot that already fired; a large step forward, a resume from suspend, or
an RTC-less boot corrected far ahead resumes at the current slot in O(1)
instead of enumerating and replaying every occurrence in the skipped span. The
bounded catch-up for a genuinely overrun pass -- a slow config reload, a burst
of simultaneous launches -- is retained, still capped at the ten-second
CATCHUP_LIMIT, and now covers minute- and second-level jobs by the same
path. -
De-duplication is now structural. A fired slot cannot fire twice because
advancing the index moves a job's next fire strictly past the slot it just
fired; the old per-slot "did this already run?" gate is gone (_last_run_slot
is kept only for status/introspection). All the surrounding guarantees are
preserved: a job fires exactly once per matching slot, a mid-period restart
skips the period already under way (the index is seeded strictly-future at
start-up),@rebootjobs run once at boot, a config reload landing on a job's
own boundary minute does not skip that fire, and housekeeping (config reload,
cluster and web upkeep, logging) still runs at most once a wall-clock minute. -
Internal: the next-fire index, monotonic sleep, clock-step handling, reload
reconciliation, and a fleet-scale performance demonstration are covered by a
new batch of scheduler tests; the wiki's "How the scheduler ticks" section is
rewritten to describe the index.
Full Changelog: 1.2.3...1.2.4
1.2.3
This release brings second-level (sub-minute) scheduling: a job can now
fire at second granularity, either through a new second field on the schedule
object or a full seven-field crontab string. The scheduler keeps its historical
once-a-minute cadence -- and its zero overhead -- until some enabled job
actually asks for seconds, at which point it ticks once a second, firing
second-level jobs on time while every minute-level job still fires exactly once
in its minute. The release also starts honoring the schedule object's year
key (previously accepted but silently dropped, a behavior change for the few
configs that set it), surfaces a malformed schedule as a named ConfigError at
reload instead of an anonymous traceback, teaches the web dashboard to parse,
describe, and preview five-, six-, and seven-field expressions, and ships two
runnable examples (pulse-monitor and its clustered sibling pulse-cluster)
built around second-level probing. Sub-minute scheduling is entirely opt-in;
see the upgrade notes below for the one behavior change that can affect an
existing deployment.
Second-level (sub-minute) scheduling
-
New
secondfield and seven-field crontab strings. parse-crontab reads
extra columns from the ends of a crontab line, so the field count selects
the dialect: a five-field line has an implicit second of0and any year, a
six-field line adds a trailing year column, and a seven-field line adds a
leading second column too (second minute hour dayOfMonth month dayOfWeek year). So the objectsecond: "*/15"and the seven-field string
"*/15 * * * * * *"both fire every 15 seconds, while a six-field string pins
a year and stays minute-granular. Thesecondfield takes the same syntax as
any other (*,*/5,0,30,10-20);second: "*"fires every second.
Second-level scheduling is a YAML feature: classic crontab files stay
five-field and minute-granular. -
Adaptive cadence, zero cost when unused. The scheduler ticks once a second
only while some enabled job pins a second (Cron._needs_subminute());
otherwise it keeps the historical once-a-minute cadence, aligned to the top of
each UTC minute, byte-for-byte as before. A disabled second-level job never
forces the per-second cadence. The cadence is re-evaluated every tick, so a
reload that adds or removes a second-level job switches modes on that same
tick. -
Exactly once per slot; mixed cadences. Each pass reads the clock once and
tests every job against a single scheduling "slot" truncated to that job's own
resolution -- the whole second for a second-level job, the top of the minute
otherwise. Launches are de-duplicated per slot (_last_run_slot), so a
minute-level job now tested up to 60 times in its due minute still fires
exactly once, and a second-level job fires once per matching second even if two
ticks land in the same second. A leader-gated job is evaluated once per slot
regardless of which node runs it. Sub-minute and per-minute jobs mix freely in
one config;concurrencyPolicystill governs overlap as before. -
Catch-up for overrun seconds, bounded. In sub-minute mode, if a pass runs
long -- many simultaneous launches, or the once-a-minute config reload -- and
the clock crosses one or more whole seconds before the next pass, the skipped
seconds are serviced after the fact, so a second-level job due in the gap still
fires (once) rather than being silently dropped. The replay is bounded by a
ten-secondCATCHUP_LIMIT: a larger gap is treated as a stall, suspend, or
clock jump and resumed past with a warning, never replayed as a burst of
backdated launches (matching cron's no-catch-up-after-an-outage behavior).
Minute-level jobs need no catch-up: their minute-truncated slot already
absorbs any sub-minute overrun. -
No spurious run at a mid-period restart. On startup the de-dup map is
seeded with the in-progress slot for every scheduled job, so a job whose
minute (or second) is already under way does not fire immediately on the first
tick; it first fires at the next matching boundary, exactly as in minute-only
mode. Without this, merely having any second-level job present would have made
every minute-level job fire about a second after a mid-minute restart.
@rebootjobs are unaffected and still fire once at startup. -
Concurrent launches within a slot. When several jobs are due in the same
slot,spawn_jobsnow launches them concurrently instead of one at a time.
With N jobs sharing a slot the old serial form cost N times a subprocess spawn
-- the dominant source of same-second overrun -- which now collapses to about
a single spawn. The single-job case (the norm) still takes a direct await and
is byte-identical to before, and the de-dup and cluster-gate decisions are
still made sequentially, so only the per-job "Starting"/"spawned" log lines may
now interleave. -
Config reload moved off the event loop. The once-a-minute reload now runs
its disk read and full reparse in a worker thread (reload_config), so a slow
parse no longer freezes the event loop -- web API, cluster gossip, job-output
pumping -- for its whole duration. The parsed job set is still applied on the
loop thread and before jobs are serviced, so the cluster leader-gate is
always current for the tick. Housekeeping (config reload, cluster and web
(re)start, logging config) is gated to run at most once per wall-clock minute
even while the loop ticks per second; in pure minute-tick mode it runs every
iteration, exactly as before.
The year schedule key is now honored
-
yearrestricts the schedule to specific years. Earlier releases accepted
ayearkey on the schedule object but built only a five-field crontab string
from it, silently droppingyearso it had no effect -- a job with an
object-formyearran every year. It is now emitted as parse-crontab's
trailing year column and honored, soyear: "2017"really does pin the
schedule to 2017. (String schedules were always passed to parse-crontab
verbatim, so a six-field string already honored its year; only the object form
changes.) This is a behavior change -- see the upgrade notes below. -
Honoring
yearchanges that job's job-set fingerprint, so during a rolling
upgrade of a cluster the old and new binaries compute differentjob_set_ids
for the identical config and will not treat each other as agreed peers until
every node is upgraded -- the same transient, self-healing drift as any config
rollout, and leader election stays at-most-once throughout. Jobs that do not
use object-formyearare unaffected: their fingerprint is byte-for-byte
identical to before.
Schedule parsing, errors, and fingerprints
-
A malformed schedule now fails the reload with a named error.
parse-crontab'sValueErroron a bad field (an out-of-range value, the wrong
field count) is caught and re-raised asConfigError("invalid schedule '...': ..."), naming the offending expression, so a bad schedule fails config
load or reload cleanly with a message the reload loop can log, rather than
surfacing as an anonymous traceback. -
One object-to-crontab builder, shared everywhere. A single
schedule_object_to_crontabhelper now renders the object form to a crontab
line -- five fields normally, six or seven whenyear/secondare used -- and
is shared by parsing, the fingerprint, and the dashboard's schedule label, so
those three can never disagree on the mapping. The object form still collapses
to the exact five-field line as before when neithersecondnoryearis set,
so its fingerprint is unchanged. Whether a schedule counts as second-level is
derived from the actual rendered field count (seven), not mere key presence,
so a blanksecond:value that renders an empty column does not force the
whole scheduler onto the per-second cadence.
Web dashboard
-
Cron parsing, description, and preview understand five-, six-, and
seven-field expressions. The client-side cron engine normalizes any of the
three widths (implicit second0and any year for five fields, a trailing year
for six, a leading second for seven), computes next-fire times at second
resolution with year restriction (and parse-crontab's 2099 year ceiling), and
renders wall-clock times with a seconds component where the schedule has one.
Plain-English descriptions gain "Every second", "Every N seconds", "At
second(s) ...", and an "in {year}" clause -- and deliberately do not lead
with a per-second cadence phrase when a coarser field is restricted, so a
schedule like* 30 * * * * *is not described as firing every second. -
Cron sandbox covers the new widths. The palette's schedule sandbox
validates 5-, 6-, and 7-field expressions (its error copy and field-breakout
labels updated to match, labelling the leading second and trailing year
columns correctly), and its next-fire preview shows seconds. -
Clicking the wordmark spins the logo. The "yacron2" wordmark now triggers
the same mark-spin animation as clicking the mark glyph.
Examples and documentation
-
example/pulse-monitor-- a small, runnable real-time uptime / SLA monitor
built entirely on second-level scheduling: it probes a latency-critical service
every few seconds, heartbeats every ten, and rolls up a summary once a minute
(which still fires exactly once per minute alongside the per-second probes). It
watches yacron2's own/statusendpoint, sodocker compose -f docker-compose-pulse.yml upneeds nothing else running. -
example/pulse-cluster-- the clustered sibling: a three-node,
mutual-TLS, leader-electing cluster that splits ...
1.2.2
-
New webhook reporter: native Slack/Discord/Teams/ntfy notifications. A
fourth reporter joins sentry/mail/shell in everyreportblock:webhook
sends an HTTP request (POST by default) to a configured URL with a
jinja2-templated body. The default body is a{"text": ...}JSON payload
carrying the same subject-plus-body text as the default mail/sentry
templates, JSON-encoded with jinja2'stojsonfilter so quotes, newlines,
and non-ASCII job output always produce valid JSON -- pointurlat a
Slack, Mattermost, or Teams incoming webhook and it works with no further
configuration.method,contentType,headers,body, andtimeout
cover everything else (Discord's{"content": ...}shape, ntfy's
plain-text body and header-driven priority, or your own endpoint). The URL
resolves like the sentry DSN (value/fromFile/fromEnvVar) and is
treated as a secret throughout: it is never logged, and the job-set
fingerprint redacts the inline URL value and all header values (which
commonly carryAuthorizationtokens). No new dependency -- outbound
delivery rides the core aiohttp. Note: because every job's effective
config gains the new default block, job-set ids change on upgrade;
replicas must be on the same version to compare ids, as before. -
Unchanged peers now answer gossip polls with a bodyless
304. Every
/peerresponse carries a strongETag(a content hash of the payload),
and each polling node echoes the tag of the last full body a peer served
it back asIf-None-Match, so a peer whose state has not changed since
then skips re-sending the full O(members + jobs) JSON: a converged, idle
cluster's steady-state round costs headers rather than bodies. This is a
transport optimization, not a protocol change. A304is still a fresh,
mutually-authenticated round trip, and because the tag is content-derived,
a match proves the peer's payload is exactly the one the poller already
holds, so the poller replays its cached observation and every gate (mutual
agreement, conflict detection, thecluster.driftAfterdebounce) advances
exactly as if the identical body had been re-sent. The one live field, a
job's seconds-to-next-fire countdown, is hashed as the absolute next-fire
time instead, so the tag stays stable between fires and rolls exactly when
a schedule fires. Mixed fleets degrade safely during a rolling upgrade: an
older peer ignoresIf-None-Matchand keeps serving full bodies, a
tagless response stops the poller from sending the header at all, an
unsolicited304is recorded as a failed poll, and an over-long or
non-printable tag is never stored or echoed. -
Fleet-view countdowns are aged, not frozen. With
304rounds
refreshing a peer's liveness without re-shipping its job summaries, a
stored snapshot can now legitimately outlive many polling rounds, so
GET /fleetre-derives each peer job's advertisedscheduled_in
countdown from the snapshot's age (an elapsed duration measured on the
local clock alone, so peer clock offsets never leak in) instead of serving
the value the snapshot arrived with, clamping at zero. The fire itself
rolls the peer'sETag, so the next poll ships a full body carrying the
real successor value. -
/peerbodies that do go out are gzip-compressed once they reach
1 KiB (below that, the per-request CPU spend outweighs the few bytes
saved). The polling side already advertised gzip support, and the existing
response-size cap applies to the decompressed payload, so compression
does not weaken it. -
Dashboard: the version and job-set id header chips copy their value on
click. Header text is chrome and is no longer text-selectable; the two
values worth grabbing hand themselves out instead. Clicking the version
chip copies the version, and clicking the job-set chip copies the full
job-set id even though the header shows only a short prefix; both
tooltips say so. The command palette carries the same two copies, so both
values stay reachable from the keyboard. -
Dashboard: the quick "power-on sweep" flash between boots is gone. The
full POST boot screen still replays once its cooldown elapses, but the
visits in between now start the app directly instead of playing a
full-screen power-on animation first. -
Internal: the conditional exchange ships with a matching batch of cluster
tests (tag stability across countdown ticks and rollover on a fire, the
304replay path, unsolicited-304and unusable-tag rejection, countdown
aging, and an end-to-end mutual-TLS304-plus-gzip round), and the wiki's
Architecture and Internals page documents the exchange.
Full Changelog: 1.2.1...1.2.2
1.2.1
This is the largest yacron2 release since the fork. Its headline feature is
clustering: several replicas can now verify they hold the same job set, elect
a leader so each scheduled job runs on one node instead of every node, spread
jobs across the fleet, and fail over when a node dies, coordinating either
peer-to-peer over mutual TLS or through a Kubernetes or etcd lease. The
release also adds native Prometheus metrics, accepts classic crontab files as
configuration, and grows the web dashboard into a small operations console.
Clustering is entirely opt-in; see the upgrade notes below for the few
behavior changes that apply to existing deployments.
Clustering and leader election
- New optional top-level
cluster:section. Give every replica the same
static peer list and a dedicated mutual-TLS listener (cluster.listen,
cluster.tls.{ca,cert,key},cluster.peers), and each node polls every
peer once per round (cluster.interval, default 30 seconds), comparing
job-set ids (see 1.1.8) to attest that all replicas hold an identical job
set. Peers are reported asagreed,syncing,drifted(a mismatch that
persists forcluster.driftAfterconsecutive rounds, default 3),
unreachable,untrusted(TLS verification failed), orconflict. On its
own this is observe-only (every node still runs every job), which makes it
a safe first rollout step. - Leader election (
cluster.electLeader: true). The leader is the lowest
cluster.nodeName(default: the hostname) among the agreeing nodes this
node can see, and only when they form a strict majority of the declared
cluster size; below quorum a node stands down and runs nothing. Two
disjoint majorities cannot exist, so a clean partition produces at most one
leader within about one polling interval. Conflicts fail closed: a
duplicatenodeName, a cluster-size disagreement (say, a rolling resize
from 3 to 5 nodes), or a coordination-policy divergence parksLeaderjobs
until the fleet reconverges, and each is logged loudly and shown on the
dashboard. A freshly started or reconfigured node holds its jobs until it
has polled every configured peer at least once, so a blank-view node cannot
elect itself. Two-node election is refused at config load (it is strictly
worse than a single replica); even cluster sizes log a warning. - A per-job
clusterPolicy(also settable underdefaults:) decides
what election means for each job:Leader(the default: only the elected
leader runs it, and it is skipped when in doubt),PreferLeader(never
skip: the lowest reachable agreeing node runs it even without quorum,
accepting a rare double-run, so reserve it for idempotent jobs), and
EveryNode(all nodes run it, for node-local housekeeping). Manual runs
viaPOST /jobs/{name}/startare never gated. Automatic retries re-check
the gate before every relaunch and abandon a pending retry when ownership
has demonstrably moved to another node;@rebootjobs under election are
deferred until the cluster converges and then run once, on the owner. - Load-balanced jobs:
cluster.distribution: spread. Instead of one
leader running everyLeader/PreferLeaderjob, each such job is
assigned an owning node by rendezvous (highest-random-weight) hashing over
the quorate agreeing members, spreading work across the fleet. A
membership change moves only the departing or joining node's share of
jobs. The same quorum gating applies, andGET /jobsreports each job's
currentclusterOwner. - Mutual TLS is the entire trust boundary. A peer must present a
certificate chaining to the configured CA and matching the host it was
reached at, so the CA should be dedicated to yacron2 nodes and nothing
else. Certificates rotated in place are detected and reloaded without a
restart. The peer endpoint is served only on the cluster listener, never
on the web API listener. - The failure semantics are documented, not hand-waved. The built-in
backend is best-effort by design: there are narrow, self-healing windows
where aLeaderjob can be skipped or aPreferLeaderjob can
double-run, and the new wiki page enumerates them. When you need fenced
exactly-once execution, use one of the lease backends below. yacron2 --validate-configvalidates the entireclustersection (peer
list, TLS material, lease timing invariants) without starting anything.
Kubernetes and etcd lease backends
cluster.backend: gossip | kubernetes | etcdpicks the coordination
mechanism (defaultgossip, the peer-to-peer mode above). The lease
backends replace the peer quorum with a fenced, expiring lease in an
external store: exactly one node holds the lease at a time, soLeader
jobs are exactly-once while the store is reachable. With a lease backend,
election is always on, there is no peer list or cluster mTLS to manage,
anddistribution: spreadis rejected at config load (a single lease
cannot express per-job ownership).- Kubernetes (
cluster.kubernetes.*): replicas campaign for a
coordination.k8s.io/v1Lease object using the client-go leader-election
algorithm (leaseNamedefaults toyacron2-leader;
leaseDurationSeconds/renewDeadlineSeconds/retryPeriodSecondsdefault
to 15/10/2, with the client-go timing invariants enforced at config load).
In-cluster ServiceAccount token, CA, namespace, and API server are
detected automatically; akubeconfigand an explicitapiServer(https
only) are supported. The backend talks to the API server over the bundled
aiohttp by default; installing the new optional extra
(pip install yacron2[kubernetes]) switches to the official Kubernetes
client (clientLibrary: auto|http|library). The stored holder identity
embeds a per-process token, so two pods that accidentally share a name
cannot both hold the lease.example/kubernetes/ships a ready-to-apply
Deployment with the minimal ServiceAccount, Role, and RoleBinding. - etcd (
cluster.etcd.*): replicas campaign for a lease-bound key
(electionNamedefaults toyacron2/leader) through etcd's v3 JSON/HTTP
gRPC gateway, using a create-if-absent transaction fenced by the lease id
(ttldefaults to 15 seconds, minimum 3). Multipleendpointsfail over
in order; optionalusername/password(literal,fromFile, or
fromEnvVar) and client TLS are supported, and credentials are refused
unless every endpoint ishttps://.example/etcd/ships a compose demo
with an etcd and two yacron2 replicas. - Failover is fast and clock-safe on both. All fencing runs on the
monotonic clock with a one-second skew margin (no wall-clock or cross-node
clock-sync assumptions): a holder whose renewals stall demotes itself
locally before the store-side lease can expire, without a network call. A
graceful shutdown releases the lease explicitly (Kubernetes clears
holderIdentity, etcd revokes its lease) so a survivor takes over
immediately rather than waiting out the TTL. If the store becomes
unreachable,Leaderjobs fail closed andPreferLeaderjobs keep
running.@rebootbookkeeping is persisted in the store, scoped to the
job-set id, so aLeader@rebootjob runs once per job configuration
rather than once per fleet restart. - No new runtime dependencies. Both lease backends are plain HTTP over
the existing aiohttp core (no etcd or gRPC client; the Kubernetes client
is optional), so all packaged architectures keep working.
Prometheus metrics
- Native Prometheus metrics at
GET /metrics, served by the existing
web listener whenever the web API is enabled; there is no exporter
sidecar and no new dependency (the exposition is generated in-process).
Both the classic text format and OpenMetrics 1.0 are served, negotiated
via theAcceptheader. - Per-job series cover run outcomes (
yacron2_job_runs_totallabeled by
job_nameandstatus), retries, permanent failures, failures to start,
a duration histogram with configurable buckets
(web.metrics.durationBuckets), last success/failure/run timestamps and
exit code, and live state (enabled, running, next run). Daemon series
cover the version, start time, job-set id, job counts, and config-reload
health. Cluster series cover size, quorum, leadership, per-peer status
counts, conflicts, and leader/quorum transition counters; the wiki
suggests alerting onsum(yacron2_cluster_is_leader) > 1and on losing
yacron2_cluster_quorate. Metrics are recorded at the same point as the
run history, so/metricsand/jobs/{name}/runsnever disagree. web.authToken, when configured, protects/metricslike every other
endpoint;web.metrics.public: trueexempts just this endpoint for a
scraper, andweb.metrics: falseremoves it entirely.
Classic crontab files
- Classic (Vixie) crontabs are now accepted as configuration. A file
with a.crontabor.cronextension, or named exactlycrontab, is
parsed as a crontab wherever configuration is loaded: passed to-c,
dropped into a config directory alongside*.yamlfiles, or pulled in
viainclude:; a neutral-named file given to-corinclude:is
content-sniffed. Supported syntax: five-field entries (the same field
dialect as YAMLschedulestrings),@keywords(including@rebootand
@midnight), position-sensitiveVAR=valueenvironment lines, comments,
and the\%escape.SHELLandCRON_TZassignments are honored as the
job'sshellandtimezone. - Each entry becomes a normal yacron2 job named
<file>:<line>,
indistinguishable downstream: it shows up on the dashboard and HTTP API,
participates in the job-set id and clustering, and can be run or
cancelled on demand. ...
1.1.11
- Coverage is now published to Codecov.
Every CI matrix cell uploads its owncoverage.xmlunder an
<os>-py<version>flag, and Codecov merges them into one combined number, so
POSIX-only paths that Windows skips (privilege drop,user/group
resolution) still count toward the published total instead of dragging it down
to the lowest single row. tox now also writes the report it consumes
(pytest --cov-report=xml). The hard pass/fail gate stays with tox's
--cov-fail-under=82(see 1.1.10): Codecov's own project and patch status
checks are configured as informational only, so they annotate pull requests
without ever blocking them. The upload runs even on failed or cancelled jobs
and keepsfail_ci_if_error: false, so a Codecov outage never reds the build,
and flagcarryforwardkeeps the combined number stable when a matrix row is
skipped on a given run. The README gains a matching coverage badge.
Full Changelog: 1.1.10...1.1.11
1.1.10
-
Numeric
user/groupis read as a uid/gid, not a login name. In the
config schema theuser/grouptype was aStr() | Int()union, and
strictyaml matched the always-acceptingStr()first, souser: 1000
arrived as the string"1000"and was looked up as a login name
(getpwnam("1000")) rather than used as uid 1000. The union is now
Int() | Str(), so a bare number is treated as the uid/gid it looks like; a
non-numeric name (user: www-data) still falls through toStr(). (POSIX
only; per-jobuser/groupremains rejected with a configuration error on
Windows.) -
More resilient container builds. Every image build, across the default
Debian image and all seven distro variants (-alpine,-ubuntu,-rhel,
-fedora,-opensuse,-amazonlinux,-distroless), now wraps its
package-manager andpipnetwork steps in a retry-with-backoff helper,
alongside each manager's native knobs (apt'sAcquire::Retries,dnf's
--setopt=retries, an explicitzypper refreshretry, and a longer
pip --timeout), so a transient mirror or package-index hiccup retries
instead of failing the whole build. The build and test CI workflows get the
same hardening viaPIP_RETRIES/PIP_TIMEOUT, withbuild.ymlforwarding
them into its emulated cross-architecture binary builds viadocker run -e. -
The
-distrolessimage now builds foramd64/arm64only. The
gcr.io/distroless/python3-debian12base publishes noppc64leors390x
manifest, so requesting those arches aborted the distroless release with
"no match for platform in manifest". The RPM-based variants (-rhel,
-fedora,-opensuse) still cover the wider arch set. -
The README status badges also gain brand new colors
(and logos on the PyPI/Python badges). yay -
Internal: branch coverage is now measured and gated in CI (tox runs
pytest --cov-fail-under=82), backed by substantially expanded unit tests for
config and user/group validation, config reload and graceful shutdown, the
job runner, and the job-set-id fingerprint.
Full Changelog: 1.1.9...1.1.10
1.1.9
- More prebuilt container images. Alongside the default Debian-based image,
every release now also publishes the same build on seven more bases, each
tagged with a-<distro>suffix:-alpine,-ubuntu,-rhel(Red Hat
UBI 9),-fedora,-opensuse(Leap),-amazonlinux(2023) and
-distroless, plus an explicit-debianalias for the default. Pick the base
that matches your host userland or image-provenance policy; behaviour is
identical, since yacron2 is a pure-Python app (Python >= 3.10) and each image
uses its distro's native interpreter. The Debian image still owns the bare
latest/<version>tags and the widest architecture coverage. See
Distro variants.
Full Changelog: 1.1.8...1.1.9