Skip to content

Releases: ptweezy/cronstable

1.2.12

Choose a tag to compare

@github-actions github-actions released this 08 Jul 05:59
Immutable release. Only release title and notes can be modified.

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 yacron2 package is renamed to cronstable. The source tree moves
    from yacron2/ to cronstable/ and every intra-package import follows, so
    import yacron2... becomes import cronstable... and python -m yacron2
    becomes python -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 now cronstable (it was yacron2), and the project
    publishes to PyPI as cronstable -- pip install cronstable. The repo,
    Docker image, and container registry are ptweezy/cronstable. Update any
    scripts, service/unit files, or pip/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 scoped RELEASE_TOKEN rather than the default GITHUB_TOKEN --
    GitHub refuses to let the Actions app push a tag whose commit touches
    .github/workflows/ without the workflows scope it cannot be granted -- and
    the PyPI publish runs with skip-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

Choose a tag to compare

@github-actions github-actions released this 08 Jul 04:06
Immutable release. Only release title and notes can be modified.

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 new CronTab class
    parses the crontab dialect yacron2 has always accepted (5/6/7 fields,
    ranges, steps including bare-start 5/15, lists, case-insensitive
    jan/mon names, 0-7 weekdays with 6-0 wrap, 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)
    and test(). A stdlib-only leaf module -- calendar and datetime, no
    third-party imports.

  • The crontab dependency is dropped. Removed from pyproject.toml and
    every import site (config.py, cron.py, crontabs.py, dagrun.py,
    prometheus.py); classic crontab-file loading and YAML schedule strings
    now share the same in-house engine, so both formats still accept identical
    expressions.

  • Golden-vector compatibility harness. tests/gen_cron_golden.py
    records next()/test() answers from the original parse-crontab library
    into tests/data/cron_golden.json; tests/test_cronexpr.py replays 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.

  • mergedicts reimplemented. The config defaults-merge helper is
    rewritten with the identical semantics -- dicts merge recursively, an empty
    YAML section (None) never wipes a populated default, environment and
    secrets lists merge by key/name, sentry fingerprint replaces rather
    than appends, and all other lists concatenate -- and now returns a dict
    directly, retiring the dict(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), the L forms, 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

Choose a tag to compare

@github-actions github-actions released this 08 Jul 00:04
Immutable release. Only release title and notes can be modified.

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).

  • monitorResources map 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, default 1.0, floor
    0.1), and history (chart points kept per run, default 240, 0 for
    summary-only, ceiling 2000). Validated at load time with the other
    numeric ranges, merged normally under defaults:, 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
    the history cap: 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's resources.series -- charts survive
    restarts and are bounded by the existing state.maxRunsPerJob pruning --
    and is deliberately excluded from the polled /jobs and
    /jobs/{name}/runs payloads, 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 a runs query 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 percentages GET /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 via web.nodeHistory
    (interval/points, or false). 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

Choose a tag to compare

@github-actions github-actions released this 07 Jul 22:10
Immutable release. Only release title and notes can be modified.

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
    resources on each run in the history, live running_resources on a
    running job, and windowed CPU/RSS aggregates in the job stats. Prometheus
    grows yacron2_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
    cpu timer and a max_rss gauge (an unmonitored job's datagram is
    unchanged), and failure reports get cpu_seconds / max_rss_bytes (and
    friends) plus YACRON2_CPU_SECONDS / YACRON2_MAX_RSS_BYTES template
    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 accounting docker stats shows) 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: gossip the reading rides the election mesh as a small
    X-Yacron2-Node-Stats response header on full and 304 responses 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-light theme joins the palette, and
    carolina replaces amber as 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 under docs/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

Choose a tag to compare

@github-actions github-actions released this 07 Jul 04:03
Immutable release. Only release title and notes can be modified.

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, and state.maxOpsPerSecond throttles
    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 than state.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: filesystem runs 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: cluster makes
    concurrencyPolicy: Forbid/Replace hold fleet-wide through per-job
    slot leases (a Replace fired 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 @reboot under 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; the yacron2 binary doubles as the client. yacron2 state get/set/delete/keys is durable KV; yacron2 cursor keeps resumable
    positions; yacron2 lock gives fleet-wide mutexes and semaphores backed
    by the same TTL leases the cluster uses, with fencing tokens and a
    lock run -- wrapper; yacron2 idempotent makes run-once guards honest
    (exit 0 fresh, 5 duplicate, 1 transport or store error); yacron2 artifact stores content-addressed payloads under configurable size caps.
    A job's secrets: 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 with yacron2 secret get.
    Scopes default to the job's own name; stateAllowedScopes opens shared
    ones.

  • DAG orchestration. A new dags: section defines multi-step pipelines
    on the job grammar: tasks with dependsOn and 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 /state inventory 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 backup writes an owner-only
    .tar.gz of 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 migrate copies 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 check verifies the store is usable and prints an
    inventory; state migrate-schema rewrites records of older known
    schemes.

  • Packaging and examples. orjson joins uvloop in the speedups extra,
    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/dag and
    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

Choose a tag to compare

@github-actions github-actions released this 03 Jul 21:18
Immutable release. Only release title and notes can be modified.

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 throwaway uv venv and frozen
    with uv run), the version probe (uv run --no-project --with setuptools-scm), and twine 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=never keeps uv on
    the exact interpreter setup-python pinned rather than fetching a managed one,
    and UV_HTTP_TIMEOUT carries 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 inside docker run containers 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 keeps PIP_RETRIES/PIP_TIMEOUT
    hardening. The uvloop bundling and per-arch --version smoke test are
    unchanged on every leg.

  • uv in the local dev loop. tox.ini now declares requires = tox-uv, so a
    plain tox auto-provisions its environments and installs dependencies with uv
    (much faster, behavior-identical); tox-uv is added to the dev extra and
    requirements_dev.txt. CONTRIBUTING.md documents the uv quickstart
    (uv venv, uv pip install -e ".[dev]") alongside the unchanged stock
    venv+pip path, and notes the tox --runner virtualenv escape hatch for
    anyone who wants the legacy runner.

  • Refreshed container base images. The Docker variant matrix moves to
    current bases: ubuntu 24.04 -> 26.04 (Python 3.12 -> 3.14), rhel UBI9 ->
    UBI10, fedora 41 -> 44 (3.13 -> 3.14), opensuse Leap 15.6 -> 16.0 (3.11 ->
    3.13), and distroless to 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 a dependabot.yml is added to keep those pins and the
    Python dev dependencies current.

Full Changelog: 1.2.5...1.2.6

1.2.5

Choose a tag to compare

@github-actions github-actions released this 03 Jul 19:19
Immutable release. Only release title and notes can be modified.

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 (speedups extra). 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_config now compares a cheap os.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 transitively included file, and each job's env_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. The job_next_run_timestamp gauge 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 histogram le label
    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 of deepcopy, 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 with optimize=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 --version smoke test.

Full Changelog: 1.2.4...1.2.5

1.2.4

Choose a tag to compare

@github-actions github-actions released this 03 Jul 04:29
Immutable release. Only release title and notes can be modified.

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), @reboot jobs 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

Choose a tag to compare

@github-actions github-actions released this 03 Jul 02:54
Immutable release. Only release title and notes can be modified.

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 second field 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 of 0 and 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 object second: "*/15" and the seven-field string
    "*/15 * * * * * *" both fire every 15 seconds, while a six-field string pins
    a year and stays minute-granular. The second field 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; concurrencyPolicy still 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-second CATCHUP_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.
    @reboot jobs are unaffected and still fire once at startup.

  • Concurrent launches within a slot. When several jobs are due in the same
    slot, spawn_jobs now 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

  • year restricts the schedule to specific years. Earlier releases accepted
    a year key on the schedule object but built only a five-field crontab string
    from it, silently dropping year so it had no effect -- a job with an
    object-form year ran every year. It is now emitted as parse-crontab's
    trailing year column and honored, so year: "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 year changes that job's job-set fingerprint, so during a rolling
    upgrade of a cluster the old and new binaries compute different job_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-form year are 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's ValueError on a bad field (an out-of-range value, the wrong
    field count) is caught and re-raised as ConfigError("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_crontab helper now renders the object form to a crontab
    line -- five fields normally, six or seven when year/second are 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 neither second nor year is 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 blank second: 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 second 0 and 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 /status endpoint, so docker compose -f docker-compose-pulse.yml up needs nothing else running.

  • example/pulse-cluster -- the clustered sibling: a three-node,
    mutual-TLS, leader-electing cluster that splits ...

Read more

1.2.2

Choose a tag to compare

@github-actions github-actions released this 02 Jul 22:47
Immutable release. Only release title and notes can be modified.
  • New webhook reporter: native Slack/Discord/Teams/ntfy notifications. A
    fourth reporter joins sentry/mail/shell in every report block: 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's tojson filter so quotes, newlines,
    and non-ASCII job output always produce valid JSON -- point url at a
    Slack, Mattermost, or Teams incoming webhook and it works with no further
    configuration. method, contentType, headers, body, and timeout
    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 carry Authorization tokens). 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
    /peer response carries a strong ETag (a content hash of the payload),
    and each polling node echoes the tag of the last full body a peer served
    it back as If-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. A 304 is 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, the cluster.driftAfter debounce) 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 ignores If-None-Match and keeps serving full bodies, a
    tagless response stops the poller from sending the header at all, an
    unsolicited 304 is 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 304 rounds
    refreshing a peer's liveness without re-shipping its job summaries, a
    stored snapshot can now legitimately outlive many polling rounds, so
    GET /fleet re-derives each peer job's advertised scheduled_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's ETag, so the next poll ships a full body carrying the
    real successor value.

  • /peer bodies 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
    304 replay path, unsolicited-304 and unusable-tag rejection, countdown
    aging, and an end-to-end mutual-TLS 304-plus-gzip round), and the wiki's
    Architecture and Internals page documents the exchange.

Full Changelog: 1.2.1...1.2.2