-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture and Internals
Internal design reference for developers reading or extending yacron2. It maps the modules, describes the single-threaded asyncio event loop, the scheduler main loop and hot reload, the running-job lifecycle, the retry state machine, concurrency handling, and signal-driven shutdown. It references functions by name rather than repeating the option reference; see Configuration Reference for option semantics.
yacron2 is a POSIX-only asyncio Python daemon. The package imports grp and
pwd at module load (config.py), so it does not run on Windows.
| Module | Responsibility |
|---|---|
yacron2/__main__.py |
CLI entry point. Argument parsing, basic logging setup, event-loop creation, signal-handler registration, and process exit codes. |
yacron2/config.py |
strictyaml CONFIG_SCHEMA, DEFAULT_CONFIG, _REPORT_DEFAULTS; config loading from a file or directory; include handling and dict merging (mergedicts); JobConfig parsing/validation; Yacron2Config dataclass. |
yacron2/cron.py |
Cron class: scheduler main loop (Cron.run), hot reload (update_config), the aiohttp web app (start_stop_web_app and handlers), due-job spawning (spawn_jobs / job_should_run / launch_scheduled_job / maybe_launch_job), the job reaper (_wait_for_running_jobs), and retry orchestration (handle_job_failure / schedule_retry_job / cancel_job_retries). |
yacron2/job.py |
RunningJob lifecycle (subprocess launch, privilege drop, wait, stream capture), StreamReader, the Reporter implementations (SentryReporter, MailReporter, ShellReporter), and JobRetryState. |
yacron2/statsd.py |
StatsdJobMetricWriter and the UDP StatsdClientProtocol used to emit best-effort statsd metrics. |
yacron2/version.py |
Generated version string (version), served by the web /version endpoint and printed by --version. |
The dependency direction is __main__ -> cron -> (config, job) ->
(statsd, config). config.py has no dependency on cron.py or job.py.
main() in __main__.py creates a fresh event loop with
asyncio.new_event_loop() and runs main_loop(loop) inside a try/finally
that always calls loop.close(). There is no Windows event-loop branch by
design (the module is POSIX-only).
main_loop parses arguments (-c/--config, -l/--log-level,
-v/--validate-config, --version), calls logging.basicConfig at the chosen
level, and constructs Cron(args.config). Construction calls
Cron.update_config() once eagerly, so a ConfigError at startup is caught in
main_loop and turned into exit code 1. With --validate-config, a successful
construction logs "Configuration is valid." and exits 0 without starting the
loop. With --version, the version is printed and the process exits 0 before
any config is loaded.
The whole daemon is single-threaded: one event loop drives the scheduler loop,
the reaper task, all running-job subprocess waits, the reporters, and the
aiohttp web server. Concurrency is cooperative via await; there are no worker
threads and no locks (the code comments note that asyncio being
single-threaded is what makes certain flag-based guards safe without locking).
The two POSIX signals SIGINT and SIGTERM are registered with
loop.add_signal_handler(...) bound to cron.signal_shutdown, then removed in
the finally. loop.run_until_complete(cron.run()) blocks until the main loop
returns.
Cron.run first starts the reaper task:
self._wait_for_running_jobs_task = asyncio.create_task(self._wait_for_running_jobs())
It then enters while not self._stop_event.is_set():. Each iteration performs,
in order:
-
Hot reload.
config = self.update_config()re-reads the configuration from disk and rebuildsself.cron_jobs(anOrderedDictof name ->JobConfig). OnConfigError, the error is logged andself.cron_jobsis left unchanged, becauseupdate_configonly assignsself.cron_jobson a successful parse; the loop keeps running the previously loaded jobs.configis initialised toNoneat the top of each iteration so that a failed parse does not dereference an unbound config later in the body. Any other exception is logged as"please report this as a bug (1)". -
Web app start/stop.
await self.start_stop_web_app(config.web_config)reconciles the running aiohttp server against the (possibly changed) web config. (See "Web control app".) -
Logging config. If the reloaded config has a non-
Nonelogging_configthat differs fromapplied_logging_config,logging.config.dictConfig(...)is applied. It is recorded as applied only on success, so a logging section that was broken on a previous reload is re-tried and picked up once fixed, without a restart. A failure logs an error pointing at the Pythonlogging.configdictionary-schema docs and the offending config. -
Spawn due jobs.
await self.spawn_jobs(startup). -
Sleep to the next minute boundary.
next_sleep_interval()computes the seconds until the next minute boundary in UTC asnow.replace(second=0) + WAKEUP_INTERVAL(whereWAKEUP_INTERVALis one minute). Becausereplace(second=0)clears only the seconds field, the target retainsnow's sub-second component, so the wake-up lands at the same fractional offset past the next minute rather than exactly:00.000000. The loop then doesawait asyncio.wait_for(self._stop_event.wait(), sleep_interval), so a shutdown signal wakes it immediately; aTimeoutError(the normal case) means the minute elapsed.
startup is True only on the first iteration and is set to False
immediately after the first spawn_jobs. This drives the @reboot startup
pass (see below).
spawn_jobs(startup) iterates self.cron_jobs.values() and calls
launch_scheduled_job(job) for every job where job_should_run(startup, job)
is true. job_should_run:
- returns
Falsefor any disabled job (job.enabledisFalse); - when
startupisTrue, returnsTrueonly for jobs whose schedule is the string"@reboot", andFalsefor all others; - when
startupisFalse, evaluatesCronTabschedules withcrontab.test(get_now(job.timezone).replace(second=0))and returnsTruewhen the current minute matches. Non-CronTabschedules (i.e."@reboot") returnFalseon non-startup iterations, so a@rebootjob runs once at daemon start and never on the regular cadence.
As README.md puts it, @reboot "will only run the job when yacron2 is
initially executed."
When _stop_event is set the while loop exits and Cron.run logs
"Shutting down (after currently running jobs finish)...", then:
- Drains pending retries: while
self.retry_stateis non-empty, itcancel_job_retries(name)for every entry concurrently viaasyncio.gather. -
await self._wait_for_running_jobs_task— awaits the reaper, which only returns onceself.running_jobsis empty and the stop event is set. - If a web server is running, logs
"Stopping http server"andawait self.web_runner.cleanup().
signal_shutdown simply calls self._stop_event.set(). Note that
handle_job_failure early-returns when _stop_event.is_set(), so jobs that
finish during shutdown are not reported as failures and do not schedule new
retries.
update_config is the single point of reload. When config_arg is None
(the unit-test path) it returns an empty Yacron2Config. Otherwise it calls
parse_config(self.config_arg), which dispatches on whether the argument is a
directory (_parse_config_dir) or a single file (parse_config_file). On
success it overwrites self.cron_jobs with a fresh OrderedDict keyed by job
name and returns the full Yacron2Config (jobs, web config, job defaults,
logging config). Because reload happens once per minute at the top of the loop,
config edits take effect within a minute without a restart. Schedule parsing,
include merging, and defaults application all happen inside parse_config*; see
Includes, Defaults, and Multi-File Config.
start_stop_web_app(web_config) reconciles a single aiohttp AppRunner:
- If a runner exists and the new
web_configisNoneor differs from the currently appliedself.web_config, the old server is cleaned up andself.web_runneris reset toNone. - If a
web_configwith a non-emptylistenlist exists and no runner is running, a newweb.Applicationis built. WhenauthTokenis configured,_resolve_web_tokenresolves the bearer token from exactly one source (value,fromFile, orfromEnvVar) and raisesConfigErrorif it resolves to empty (fail-closed);_make_auth_middlewarethen enforces a case-insensitiveBearerscheme and a constant-timehmac.compare_digesttoken comparison. Routes areGET /version,GET /status, andPOST /jobs/{name}/start. EachlistenURL is turned into a site byweb_site_from_url(TCP forhttp://host:port, Unix socket forunix://path); a malformed URL or a bind error logs a warning and skips that address rather than aborting the reload.socketModeis applied tounix://sockets via_apply_socket_mode.
See HTTP Control API for the request/response contract.
A single long-lived task started by Cron.run owns all completion handling. It
maintains a local wait_tasks map from RunningJob to an asyncio.Task
wrapping job.wait(), and runs while self.running_jobs is non-empty or the
stop event is not yet set. Each cycle:
- For every
RunningJobinself.running_jobsthat does not yet have a wait task, it creates one withasyncio.create_task(job.wait()). - If there are no wait tasks, it waits up to 1 second on the
self._jobs_runningevent (set bymaybe_launch_jobwhenever a job is spawned) and continues, avoiding a busy loop. - Otherwise it clears
_jobs_runningandawait asyncio.wait(..., timeout=1.0, return_when=FIRST_COMPLETED). Completed jobs are removed fromwait_tasks;task.result()is read (an unexpected exception is logged as"please report this as a bug (2)"), then each finished job is passed to_handle_finished_job.
CancelledError is re-raised; any other unexpected exception in the loop is
logged as "please report this as a bug (3)" followed by a one-second sleep.
_handle_finished_job removes the job from self.running_jobs[name] (deleting
the key when the list becomes empty), then:
- If
job.replacedisTrue, the run was a deliberateconcurrencyPolicy: Replacetermination: it is logged as replaced and not reported and does not trigger retries. - Otherwise it inspects
job.fail_reason.None->handle_job_success; non-None->handle_job_failure.
maybe_launch_job constructs RunningJob(job, self.retry_state.get(job.name)),
calls await running_job.start(), appends it to self.running_jobs[job.name],
and sets self._jobs_running. The lifecycle inside RunningJob:
-
start()chooses the spawn function from the command form:create_subprocess_execfor a list command, or whenshellis set the command is run as[shell, "-c", command]viaexec; a string command with noshellusescreate_subprocess_shell. It assemblesenv(only when the job hasenvironmententries, layering them overos.environafterfixup_pyinstaller_env), setspreexec_fn=self._demotewhen a uid/gid is configured, requestsstdout/stderrPIPEs percaptureStdout/captureStderr, sets the stream bufferlimittomaxLineLength, and recordsexecution_deadline = time.perf_counter() + executionTimeoutwhen a timeout is set. Arguments are encoded to bytes before spawning. If the spawn raisesSubprocessError,UnicodeEncodeError, orFileNotFoundError, the error is logged,self.start_failed = Trueis set, andstart()returns without a process. On success_on_start()emits the statsdjob_startedmetric (best-effort;OSErroris caught) and aStreamReadertask is started for each captured stream. -
_demote()runs in the child (still privileged) and drops privileges in the security-critical order: supplementary groups first (os.initgroups(username, gid)when both are known, elseos.setgroups([])), thenos.setgid(gid), thenos.setuid(uid). Any failure raisesRuntimeError. (uid/gid resolution and the "must be root" check happen earlier inJobConfig._resolve_user_group.) -
wait()is what the reaper awaits. If there is no process butstart_failedis set, it synthesisesretcode = 127(conventional "command not found"), reads streams, and returns — so a failed launch is a normal job failure, not a reaper bug. With no deadline it awaitsproc.wait()and then_on_stop(). With a deadline it awaitsasyncio.wait_for(proc.wait(), timeout); onTimeoutErrorit logs theexecutionTimeout, setsretcode = -100, and callscancel(). Finally it reads the stream buffers. -
cancel()sendsSIGTERM(proc.terminate()), waits up tokillTimeoutfor graceful exit, andproc.kill()s (SIGKILL) on timeout; it then calls_on_stop()._on_stop()is idempotent (guarded byself._stopped) becausecancel()andwait()can both reach it for one run (e.g. underReplace); it emits the statsdjob_stoppedmetric once. -
Failure classification.
fail_reasonis a property evaluated againstfailsWhen:always, thennonzeroReturn(retcode != 0), thenproducesStdout/producesStderr(true when captured output is non-empty or lines were discarded).failedisfail_reason is not None. -
Reporting.
report_failure,report_permanent_failure, andreport_successeach delegate to_report_common, which runs all threeREPORTERS(SentryReporter,MailReporter,ShellReporter) concurrently withasyncio.gather(..., return_exceptions=True); an exception from any one reporter is logged and does not stop the others. Each reporter reads the relevant sub-key of the report config (onFailure["report"],onPermanentFailure["report"], oronSuccess["report"]) and self-disables when its required fields are unset (e.g. mail with noto/from, sentry with no resolvabledsn, shell with nocommand). Templates render againsttemplate_vars. See Reporting (Mail, Sentry, Shell).
Each captured stream gets a StreamReader whose _read task loops on
stream.readline(), decodes UTF-8 with errors="replace", and for live output
prefixes each line with streamPrefix (formatted with job_name/stream_name)
and writes it to the daemon's own sys.stdout/sys.stderr via _emit (bytes
write with an ASCII-replacement fallback). For retention it keeps the first
saveLimit // 2 lines in save_top and the last saveLimit - saveLimit // 2
lines in a bounded save_bottom deque, incrementing discarded_lines for every
evicted or never-saved line. A ValueError from an over-long line (exceeding
the buffer limit) logs a warning and is skipped. join() awaits the reader
task and returns the assembled text — top lines, an optional
"[.... N lines discarded ...]" marker, then bottom lines — plus the discard
count. See Output Capturing.
Retries are driven by JobRetryState (in job.py), the
Cron.retry_state map (name -> JobRetryState), and the
schedule_retry_job coroutine.
JobRetryState holds delay (current, initialised to initialDelay),
multiplier (backoffMultiplier), max_delay (maximumDelay), a count of
retries performed, an optional task (the pending schedule_retry_job task),
and a cancelled flag. next_delay() returns the current delay, then advances
it to min(delay * multiplier, max_delay) and increments count (exponential
backoff capped at maximumDelay).
Flow:
-
Arming.
launch_scheduled_jobfirstcancel_job_retries(name)(asserting the name is then absent fromretry_state), and ifmaximumRetriesis truthy creates aJobRetryState(initialDelay, backoffMultiplier, maximumDelay)and stores it under the job name beforemaybe_launch_job. TheRunningJobis given this state via theretry_stateconstructor argument. -
On failure (
handle_job_failure, only when not shutting down): the job's output is logged,report_failure()runs, then the retry state is examined. If there is no state or it iscancelled,report_permanent_failure()runs and the flow stops. Otherwise any prior pending retrytaskis awaited (if done) or cancelled, and the cap is checked: whenstate.count >= maximumRetries and maximumRetries != -1, retries are cancelled (cancel_job_retries) andreport_permanent_failure()runs. AmaximumRetriesof-1means retry forever (the documented sentinel, enforced byJobConfig._validate_numeric_ranges). Otherwise a new retry is scheduled:state.task = asyncio.create_task(schedule_retry_job(name, state.next_delay(), state.count)). -
schedule_retry_job(name, delay, retry_num)logs the planned retry,await asyncio.sleep(delay), then re-fetches the job fromself.cron_jobs. If the job has disappeared from the (possibly reloaded) config, it pops the stale retry state and returns; otherwise it callsmaybe_launch_job(job). -
On success (
handle_job_success):cancel_job_retries(name)clears any pending retry, thenreport_success()runs. -
cancel_job_retries(name)pops the state (no-op if absent), setscancelled = True, and awaits or cancels the pendingtask.
See Failure Detection and Retries for the operator-facing options.
self.running_jobs is a defaultdict(list) mapping job name to the list of
currently running RunningJob instances, so multiple concurrent runs of one
job are tracked. maybe_launch_job consults concurrencyPolicy when an
instance is already running:
-
Allow— launch another instance (the list grows). -
Forbid— return without launching. -
Replace— for each currently running instance, setrunning_job.replaced = Truebeforeawait running_job.cancel(), so the reaper recognises the forced termination as a replacement rather than a failure (_handle_finished_jobskips reporting/retries for replaced runs), then a fresh instance is launched.
The replaced flag is the single mechanism distinguishing an
operator/scheduler-initiated replacement from an actual job failure. See
Concurrency and Timeouts.
When a job has a statsd config, RunningJob builds a
StatsdJobMetricWriter. _on_start sends {prefix}.start:1|g; _on_stop
sends {prefix}.stop:1|g, {prefix}.success:{0|1}|g, and
{prefix}.duration:{ms}|ms|@0.1, where success is 0 if self.job.failed else 1
and duration is wall time between start and stop in milliseconds. Each
send_to_statsd call is one fire-and-forget UDP datagram, so there are two
datagrams per run: job_started sends the single .start line, and
job_stopped sends the three stop metrics concatenated (newline-delimited) into
one datagram. job_stopped also early-returns without sending if start_time
is None (i.e. job_started never ran). send_to_statsd opens a datagram
endpoint with StatsdClientProtocol, which sends in connection_made and is
immediately closed. Send failures are caught as OSError at the RunningJob
call sites and logged as warnings so telemetry never crashes the scheduler. See
Metrics with statsd.
- One process, one thread, one event loop.
- One long-lived scheduler coroutine (
Cron.run) and one long-lived reaper coroutine (_wait_for_running_jobs). - One short-lived
wait()task per running job, plus oneschedule_retry_jobtask per armed-and-pending retry. - Reporters run concurrently per job but failures are isolated.
- Shutdown is cooperative: a signal sets
_stop_event, the scheduler stops spawning, pending retries are cancelled, and the reaper drains in-flight jobs before the process exits.
For the broader operational picture see Production and Container Deployment; for the CLI surface see Command-Line Reference.
This wiki documents yacron2. See the README and the changelog.
yacron2 is a fork of gjcarneiro/yacron.
- Getting Started
- Configuration
- Job Behavior
- Integrations
- Reference and Development