Releases: ptweezy/cronstable
Release list
1.2.12
The package finishes becoming cronstable. Everything that was still
named yacron2 -- the import package, the python -m entry point, the
console-script command, and the PyPI distribution -- is now cronstable, so
the name is consistent all the way from pip install through import to the
CLI. The fork has shipped as cronstable in its repo and docs for a while;
this release moves the code's own identifiers to match, so nothing user-facing
still answers to the old name.
-
The
yacron2package is renamed tocronstable. The source tree moves
fromyacron2/tocronstable/and every intra-package import follows, so
import yacron2...becomesimport cronstable...andpython -m yacron2
becomespython -m cronstable. The web assets, backends, and cluster
modules move with it; no module contents change, only their home. -
The CLI command and PyPI distribution are renamed. The console script the
wheel installs is nowcronstable(it wasyacron2), and the project
publishes to PyPI ascronstable--pip install cronstable. The repo,
Docker image, and container registry areptweezy/cronstable. Update any
scripts, service/unit files, orpip/import references that still say
yacron2; there is no compatibility shim, so the old names stop resolving. -
Release-pipeline hardening. The release workflow now pushes the version
tag with a scopedRELEASE_TOKENrather than the defaultGITHUB_TOKEN--
GitHub refuses to let the Actions app push a tag whose commit touches
.github/workflows/without theworkflowsscope it cannot be granted -- and
the PyPI publish runs withskip-existing, so a release interrupted after the
upload (before the tag and GitHub Release) can be retried without burning the
version.
Full Changelog: 1.2.11...1.2.12
1.2.10
yacron2 now parses cron expressions itself. This release retires the
third-party crontab (parse-crontab) library in favor of a small,
stdlib-only engine that lives in the tree -- so the scheduler owns the one
piece of syntax every job depends on, the dialect is documented and tested
where it is implemented, and the install carries one dependency fewer.
Compatibility is not aspirational: it is pinned by golden vectors recorded
from the old library across 180+ expressions and fixed instants (DST
transitions, leap days, month/year boundaries, the year cap, ambiguous
fall-back folds). Nothing changes for existing configs -- the dialect, the
timezone model, and the strictly-future fire semantics are all preserved
vector-by-vector.
-
yacron2/cronexpr.py: the built-in cron engine. A newCronTabclass
parses the crontab dialect yacron2 has always accepted (5/6/7 fields,
ranges, steps including bare-start5/15, lists, case-insensitive
jan/monnames,0-7weekdays with6-0wrap,L-last-day and
L5-last-Friday forms,@-nicknames) and answers the scheduler's two
questions:next()(strictly future, DST-correct across UTC-offset
changes, capped at the 2099 horizon so dead schedules drop from the index)
andtest(). A stdlib-only leaf module --calendaranddatetime, no
third-party imports. -
The
crontabdependency is dropped. Removed frompyproject.tomland
every import site (config.py,cron.py,crontabs.py,dagrun.py,
prometheus.py); classic crontab-file loading and YAMLschedulestrings
now share the same in-house engine, so both formats still accept identical
expressions. -
Golden-vector compatibility harness.
tests/gen_cron_golden.py
recordsnext()/test()answers from the original parse-crontab library
intotests/data/cron_golden.json;tests/test_cronexpr.pyreplays them
against the new engine so any behavioral drift fails the suite. This is
the proof that "behavior-compatible" is a fact, not a hope. -
mergedictsreimplemented. The config defaults-merge helper is
rewritten with the identical semantics -- dicts merge recursively, an empty
YAML section (None) never wipes a populated default,environmentand
secretslists merge by key/name, sentryfingerprintreplaces rather
than appends, and all other lists concatenate -- and now returns adict
directly, retiring thedict(mergedicts(...))wrappers at every call site. -
Dashboard accessibility pass. The web UI gains an interface font
toggle (the terminal monospace, or a proportional sans "reader mode" for
easier reading, with logs and cron strings kept monospace either way),
app-level color-vision palettes (deutan / tritan status-color remaps,
status glyphs always distinct too), whole-UI zoom, and a
reduce-motion switch, each reachable from Settings and the command
palette and persisted across sessions. -
Docs. The "Schedules and Timezones" wiki page now documents the full
dialect the engine owns -- the day-of-month-AND-day-of-week rule (Friday
the 13th, deliberately kept over Vixie's OR), theLforms, the
1970-2099 year horizon -- and Installation, Migration-from-yacron,
Classic-Crontabs, Architecture-and-Internals, and the README are updated to
point at the built-in engine instead of the removed library.
Full Changelog: 1.2.9...1.2.10
1.2.9
Full Changelog: 1.2.8...1.2.9
Resource monitoring grows a time axis. 1.2.8 answered "what did that run
use?" with two numbers per run; this release records how the run used it
-- a per-run CPU/memory chart series with a user-tunable sampling cadence --
and puts proper charts in the dashboard: a live view of the running
instance, the recorded profile of any recent run, per-run trend strips, and
a whole-node history chart behind the header meter. Everything rides the
existing HTTP+JSON surface and the durable run ledger; nothing changes for
existing configs (monitorResources: true still means what it meant, with
the same 1s cadence).
-
monitorResourcesmap form. Alongside the bool, the option now takes
a map:enabled(default true -- writing the map at all opts in),
interval(seconds between process-tree samples, default1.0, floor
0.1), andhistory(chart points kept per run, default240,0for
summary-only, ceiling2000). Validated at load time with the other
numeric ranges, merged normally underdefaults:, and accepted by DAG
tasks. Not fingerprinted, like the bool before it. -
Per-run chart series. Each monitored run now records a
[t, cpu%, rss]point per sample, downsampled in place once it exceeds
thehistorycap: adjacent buckets merge with mean CPU but peak RSS,
so the memory spikes people monitor for survive downsampling, and a run
of any length stays a few KB with uniform bucket widths. The series is
embedded in the durable run record'sresources.series-- charts survive
restarts and are bounded by the existingstate.maxRunsPerJobpruning --
and is deliberately excluded from the polled/jobsand
/jobs/{name}/runspayloads, which keep their summary-only shape. -
GET /jobs/{name}/resources. A lazy, chart-grade endpoint: the
run-so-far series of every currently-running monitored instance (plus its
live instantaneous readings) and the recorded series of recent finished
monitored runs, capped by arunsquery parameter.monitored: false
with empty lists distinguishes "never opted in" from "no data yet". -
GET /node/history+web.nodeHistory. A background sampler records
the node's CPU/memory (the same cgroup-aware percentagesGET /node
reports) into an in-memory ring -- every 5s, keeping the last hour, by
default. It follows the web app's lifecycle, is on whenever the web API
is on, and is tuned or disabled viaweb.nodeHistory
(interval/points, orfalse). A gap wider than the cadence in the
returned points means the daemon was down, not idle. -
Dashboard: the Resources tab and the node card. The job drawer gains
a Resources tab: CPU and memory drawn as separate small-multiple
charts (one honest 0-based axis each, never dual-axis) with a synced
crosshair and tooltip, chips to flip between the live instance and recent
runs, a refresh-cadence selector for the live view (1s-10s, persisted),
and clickable per-run CPU-time / peak-memory trend strips. Clicking the
header node meter opens a node resources card charting the retained
node history. Chart inks are the theme-aware blue/amber pair
(colorblind-safe and contrast-checked against every theme surface, light
and dark), gaps in sampling break the line rather than lying across it,
and unmonitored jobs get a pointer at the config instead of an empty
chart. -
Examples. The grand tour (
_defaults.yaml+platform.yaml) and the
large-cluster demo now use the map forms -- a 0.5s sampling cadence with
the default 240-point series, and a 2s x 1800-point node history ring --
so their resource-heavy jobs light the new charts up out of the box.
1.2.8
This release answers "what is this actually using?" Opt-in per-job
resource monitoring records every run's CPU time and peak memory and
carries the numbers everywhere a run already reports -- the dashboard, the
HTTP API, Prometheus, statsd, and failure reports -- while a new GET /node
endpoint and a cluster.observability block put live whole-node load beside
the jobs, on every node in the fleet. One footprint note: psutil joins the
core dependencies, the fork's first addition to the core install (it ships
wheels for the mainstream targets and builds from source elsewhere).
Behavior is unchanged without the new config: monitorResources is off by
default and the observability overlay is opt-in.
-
Per-job resource monitoring (
monitorResources: true). A
psutil-backed sampler polls the run's whole process tree and records its
total CPU time (user and system) and its sampled peak resident memory.
Accounting is best-effort by design: a process that exits mid-sample, a
platform that denies the read, or psutil failing outright simply yields
whatever was captured so far -- monitoring never crashes a job, never
delays it, and never changes its success/failure verdict. Peak RSS is a
sampled high-water mark and per-member CPU is banked as the tree shrinks,
so the long, heavy runs that matter are measured well; only a child that
spawns and exits within a single sampling gap escapes entirely. -
The numbers surface everywhere a run does. The dashboard overview
shows live CPU/memory chips on a running job, and the history tab adds
per-run CPU and peak-memory columns and stats. The HTTP API carries
resourceson each run in the history, liverunning_resourceson a
running job, and windowed CPU/RSS aggregates in the job stats. Prometheus
growsyacron2_job_cpu_seconds_total{job_name, mode}(user/system),
yacron2_job_peak_rss_bytes, and last-run CPU/RSS gauges -- emitted only
once a job has a monitored run, and persisted across restarts by the
durable metrics snapshot. A monitored run's statsd stop datagram gains a
cputimer and amax_rssgauge (an unmonitored job's datagram is
unchanged), and failure reports getcpu_seconds/max_rss_bytes(and
friends) plusYACRON2_CPU_SECONDS/YACRON2_MAX_RSS_BYTEStemplate
variables. -
GET /node: the node's own live load. A new endpoint samples the
serving host's CPU and memory fresh per request -- whole-host utilisation
plus the daemon's own footprint -- and drives a node meter in the
dashboard header. It is container-aware: under a cgroup v2 limit
(Docker/Kubernetes limits, systemd slices) the numbers describe the
daemon's slice -- the effective memory limit with reclaimable page cache
excluded (the same accountingdocker statsshows) and utilisation of the
CPU quota -- with memory and CPU switching over independently. Unlimited
cgroups, cgroup v1 hosts, and non-Linux platforms report whole-host
numbers, and the response shape never changes. -
cluster.observability: gossip as a secondary data plane. Opt in and
every node shares its whole-node CPU/memory across the cluster. Under
backend: gossipthe reading rides the election mesh as a small
X-Yacron2-Node-Statsresponse header on full and304responses alike,
so a sharing cluster's steady-state round still costs headers only. The
lease backends (kubernetes/etcd/filesystem), which have no
node-to-node channel of their own, can stand up a second,
election-inert gossip mesh purely for observability data -- which also
brings the fleet view to lease-backed clusters. The dashboard's cluster
panel gains per-peer load meters and the fleet view puts each node's live
load in its column header. Like the run summaries, node stats are
best-effort display data: a malformed peer payload degrades to "no data",
never poisoning the view or any decision. -
Dashboard themes. A new
carolina-lighttheme joins the palette, and
carolinareplacesamberas the default. -
Docs and examples. The README is rebuilt around a sixty-second quick
start, four tutorials (alerting and retries, durable restarts, a first
DAG, two-replica leader election), and a screenshot tour of the
dashboard; the screenshots themselves are now reproducible via a scripted
pipeline underdocs/screenshots/that captures a live grand-tour fleet.
The grand tour gains resource-monitored CPU- and memory-heavy demo jobs
and the observability overlay, and the new features are documented on the
wiki's Configuration-Reference, HTTP-API, Clustering, Metrics, and
Reporting pages.
Full Changelog: 1.2.7...1.2.8
1.2.7
Full Changelog: 1.2.6...1.2.7
This release makes yacron2 stateful. An opt-in durable state store lets
the scheduler remember across restarts -- retries that survive a daemon
restart, @reboot that really means once per boot, Prometheus counters that
do not reset -- and turns a shared directory into fleet-wide coordination:
cluster-scoped concurrency, cross-node retry takeover, and leader election
with nothing but a mount both nodes can reach. On top of the store sit a
state API handed to every job (key-value, cursors, fleet locks, idempotency
claims, artifacts, run-scoped secrets) and durable DAG orchestration
(dependencies, XCom, fan-out, sensors, approval gates, backfills) with
crash-resume. All of it is opt-in: without a state: block (and a dags:
block for pipelines) nothing changes -- no new behavior, no new files on
disk, and the zero-new-dependency, architecture-portable core install is
untouched.
-
A durable state store behind a single
state:block.state.path
names a directory -- a local disk or a shared NFS/EFS-style mount -- and
the daemon keeps everything under<path>/<deploymentId>: append-only
JSON record streams, mutable documents, content-addressed blobs, and
flock-guarded TTL leases with monotonic fence counters. The write
discipline is crash-safe on POSIX and Windows alike (atomic
temp-plus-rename with directory fsyncs; a record that cannot be parsed is
quarantined, never trusted and never fatal), files are owner-only
(0o700/0o600-- archived job output is exactly where secrets live),
and archived output additionally passes through a conservative
best-effort secret redactor before it is written. A store outage degrades
the stateful features, never scheduling: durable writes are
fire-and-forget, reads on scheduling paths are bounded and fall back,
store calls run on abandonable worker threads so a hung hard mount cannot
wedge the daemon or its shutdown, andstate.maxOpsPerSecondthrottles
everything except lease renewals, which must never queue behind bulk
work. The full model is documented on the wiki's Durable-State page. -
Restart-surviving scheduling. With a store configured, a pending
retry re-arms after a daemon restart instead of vanishing;@reboot
distinguishes a real boot from a mere restart (and, under election, runs
once per fleet); Prometheus counters persist across restarts; the run
ledger, optionally archived output (archiveOutput), and catch-up
checkpoints are durable; and in-flight run records let a restarted daemon
settle the runs that died with it, so failure handlers and retries fire
for work a crash orphaned. -
Garbage collection that can prove absence. Every node periodically
writes a manifest of the jobs, scopes, and dags it carries; GC deletes a
stream only when no recent manifest references it and its newest record
is older thanstate.gcGraceSeconds(default seven days), and it defers
wholesale until the retained manifest history spans a full grace window
-- "nobody has manifested yet" never reads as "nobody wants this".
Artifact streams and payload blobs age out with their scope, run
documents of removed dags are collected by the daemon that owned them,
and of the lease files only the per-run DAG advance class is ever
reclaimed: every other lease carries fences that persist in durable
records, so it is never deleted at any age. A node that is merely down
loses nothing. -
Fleet HA through a shared directory. A new
cluster.backend: filesystemruns leader election over the same flock-and-lease machinery
-- no gossip ports, no Kubernetes, no etcd -- and composes with
everything clustering shipped in 1.2.1.concurrencyScope: clustermakes
concurrencyPolicy: Forbid/Replacehold fleet-wide through per-job
slot leases (aReplacefired anywhere cancels the run wherever it
lives; a crashed holder's slot frees by TTL), a pending retry left by a
dead node can be claimed and resumed by a survivor -- serialized on a
claim lease and re-checked under it; the contract is at-least-once,
honestly -- and@rebootunder election survives leader failover in the
safe direction: a takeover can delay a one-shot, never double-run it. -
Every job gets a state API. With
state.jobApi(on by default once a
store is configured) the daemon serves a loopback-only HTTP endpoint and
injects its address and a per-run bearer token into each job's
environment; theyacron2binary doubles as the client.yacron2 state get/set/delete/keysis durable KV;yacron2 cursorkeeps resumable
positions;yacron2 lockgives fleet-wide mutexes and semaphores backed
by the same TTL leases the cluster uses, with fencing tokens and a
lock run --wrapper;yacron2 idempotentmakes run-once guards honest
(exit0fresh,5duplicate,1transport or store error);yacron2 artifactstores content-addressed payloads under configurable size caps.
A job'ssecrets:block stages secrets over the endpoint for exactly one
run -- resolved fresh, served only to that run, never in the environment
and never in the durable store -- read back withyacron2 secret get.
Scopes default to the job's own name;stateAllowedScopesopens shared
ones. -
DAG orchestration. A new
dags:section defines multi-step pipelines
on the job grammar: tasks withdependsOnand per-task retries, XCom
hand-off between tasks (yacron2 xcom push/pull), mapped fan-out over a
pushed list (capped, launched in bounded batches), sensors that poke on
an interval, and approval gates a human resolves from the dashboard or
API. Dags run on cron schedules (the job schedule grammar, minus
@reboot), manual triggers, and date-range backfills. A per-run advance
lease makes exactly one node drive each run; when a driver dies, the
lease lapses and a peer adopts the run mid-flight, reconciling exactly
what was and was not still running. Run history is retained per-dag and
collected under the same grace rules. See the wiki's
Orchestration-and-DAGs page. -
Dashboard and HTTP API. The dashboard gains DAG cards with a run
drawer and task graph (trigger, backfill, and approve/reject inline),
cluster/HA chips, per-job durable run history, and a metadata-only state
inspector -- streams, documents, leases, and blob inventory by name and
size, never values. The HTTP API adds the matching/dags/...routes
(runs, XCom, live task logs) and/stateinventory routes, and the
Prometheus endpoint grows state-store and DAG metric families. All routes
are documented on the wiki's HTTP-API page. -
Store administration.
yacron2 state backupwrites an owner-only
.tar.gzof the whole store, safe against a live daemon;state restore
merges it back atomically (fence-aware, refuses a non-empty store without
--force, and is not safe while a daemon uses the store);state migratecopies a store across paths or mounts without a reader ever
seeing a torn record;state gc [--dry-run]runs or previews a
collection pass;state checkverifies the store is usable and prints an
inventory;state migrate-schemarewrites records of older known
schemes. -
Packaging and examples. orjson joins uvloop in the
speedupsextra,
accelerating the durable-state and cluster-gossip JSON paths through
yacron2._json, whose stdlib fallback is behavior-identical -- the core
install stays zero-new-dependency and architecture-portable, and the
prebuilt binaries bundle orjson wherever a real wheel or verified source
build exists, with a verify-or-strip step mirroring uvloop's. New
examples:example/job-state(the CLI primitives),example/dagand
example/dag-cluster(pipelines, single-node and fleet), and
example/grand-tour(a docker-compose fleet exercising the whole
feature set end to end).
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