Everything for Linux Cron in one place — pick a section below. 12 reviewed items across 4 content types.
Quick Answer
The cron daemon, crond, wakes up once every minute, reads all configured crontabs, and for each entry checks whether the current minute, hour, day, month, and weekday match that entry's schedule fields; if it matches, it forks a new process to run the command, completely independent of whether a previous invocation of that same job is still running. Because cron has no concept of 'is the last run still in progress,' a job that occasionally takes longer than its scheduling interval gets a second, then third, then more overlapping instance launched on top of it, and without explicit locking those overlapping instances can compete for the same resources and cascade into an outage.
Detailed Answer
Picture a train station where a train departs exactly on the printed schedule every 10 minutes, no matter what, even if the previous train hasn't actually finished unloading passengers on the platform yet. The schedule board doesn't check whether the last train finished its business; it only checks whether 10 minutes have passed since the last scheduled departure time. Normally this is fine because trains reliably finish in under 10 minutes. But the one day a train breaks down and takes 25 minutes to clear the platform, the next scheduled train arrives anyway, and now there are two trains trying to use the same platform at once, a collision the schedule itself never anticipated or prevented in any way.
Cron was built in the earliest days of Unix specifically as a simple, minute-resolution time-based trigger, a design intentionally kept dumb and stateless, checking only wall-clock time against a schedule, with zero awareness of whether a previously-launched job is still executing. This simplicity is exactly why cron has remained essentially unchanged and universally reliable for decades: there's very little to go wrong in the scheduling logic itself. But that same simplicity means cron offers no built-in protection against the previous-run-is-still-going scenario, unlike more modern schedulers that track job state explicitly as part of their design.
Internally, crond wakes up every 60 seconds, reads the parsed crontab entries, from per-user crontabs typically stored under /var/spool/cron/, the system-wide /etc/crontab, and drop-in files under /etc/cron.d/, and for each entry evaluates whether the current minute, hour, day-of-month, month, and day-of-week fields match. Any entry that matches gets forked as a brand-new child process running under the environment and permissions of whichever user owns that crontab. Critically, cron doesn't check a PID file, doesn't ask whether a previous instance of this command is already running, and doesn't wait; it just forks and moves on to evaluate the next matching entry in sequence.
In production, this becomes dangerous for jobs whose runtime is close to or sometimes exceeds their schedule interval. For example, a billing-reconciler job scheduled every 5 minutes that normally finishes in 2 minutes but occasionally takes 8 minutes under heavy load. During that one slow run, cron launches a second instance at the next 5-minute mark anyway, and now two instances are both reading and writing the same reconciliation state, which can produce duplicate charges, double-counted records, or lock contention against a shared database table that cascades into broader slowness for other unrelated jobs and queries hitting the same tables.
The gotcha most engineers miss until they've been burned by it: adding a simple pgrep-based check inside the job script feels like a fix but has a subtle race condition. Between the pgrep check and actually starting the real work, a nearly-simultaneous second cron-triggered invocation can pass the same check before the first instance's process name shows up in the process table, so both proceed anyway. The robust fix is flock, a proper kernel-level file lock, wrapping the job's execution, which atomically ensures only one instance can hold the lock at a time, with the second invocation either exiting immediately or waiting, rather than trusting a process-name check that has an inherent timing gap built into it.
Code Example
# per-user crontab: billing-reconciler runs every 5 minutes, no overlap protection by default */5 * * * * /opt/billing-reconciler/reconcile.sh # fixed version: flock guarantees only one instance can run at a time */5 * * * * /usr/bin/flock -n /var/lock/billing-reconciler.lock /opt/billing-reconciler/reconcile.sh # -n = non-blocking: if lock is held, this invocation exits immediately instead of queueing # system-wide drop-in for a different job, /etc/cron.d/order-fulfillment SHELL=/bin/bash 0 2 * * * appuser flock -n /var/lock/order-fulfillment.lock /opt/order-fulfillment/nightly-sync.sh
Interview Tip
A junior engineer typically says 'cron runs jobs on a schedule,' without mentioning overlap risk at all. For a senior/architect role, the interviewer is actually looking for you to explain that cron has zero built-in awareness of whether a previous run is still executing, describe a concrete failure scenario, a job whose runtime occasionally exceeds its interval, and know that the correct fix is flock-based locking rather than a naive pgrep or PID-file check, which has a race condition that can still let two instances through under the right timing. This distinguishes real production experience from textbook knowledge of crontab syntax alone.
◈ Architecture Diagram
min 0 min 5 min 10 min 15
│ │ │ │
↓ ↓ ↓ ↓
[job A]--8min-------[job A] ← overlap! both running at min 10-13
(still running when min 10 fires)
with flock: [job A]--8min---[skip]--[job A]💬 Comments
Quick Answer
Check /var/log/cron, or /var/log/syslog, or journalctl -u cron depending on distro, first — cron itself logs every job it actually attempted to launch, so if there's no entry at all for the expected time, the crontab entry itself is missing, disabled, or has a schedule or timezone mismatch. If there IS a launch entry but the job failed, the actual error output went wherever cron's MAILTO was configured to send it, often an unmonitored local mailbox nobody reads, or was silently discarded entirely if the script's own output wasn't captured anywhere. The root cause is almost always one of: the crontab entry was accidentally removed during an unrelated edit, the script depends on environment variables or a PATH that only exists in an interactive shell and fails silently under cron's minimal environment, or the error output genuinely was generated but sent to an address nobody monitors.
Detailed Answer
Imagine a night-shift security guard whose job is to make rounds and note in a logbook started round at 2am every time they begin, but who was never told to write anything if something goes wrong mid-round. They just quietly stop and go back to the guard station without further explanation if they hit a problem. If you show up the next morning wondering why a door wasn't checked, the logbook will at least confirm whether the guard started that round at all. If they did start it, you then have to go find the guard directly to learn what actually went wrong, because the logbook itself won't tell you anything more than that they began.
This is fundamentally how cron's failure reporting was designed: cron's own log, via syslog, typically /var/log/cron or accessible through journalctl, faithfully records every time it launches a job, this is the started-round-at-2am entry, but cron was never designed to capture or interpret the job's actual success or failure. That's considered the job script's own responsibility, and cron's only built-in mechanism for surfacing errors is emailing whatever the script wrote to stdout and stderr to the address configured in MAILTO, or the crontab owner's local mail by default, which many modern systems don't even have a functioning mail transport configured for, meaning that email often goes absolutely nowhere at all.
The diagnostic sequence: first check cron's own log for whether the job was even launched at the expected time. A missing entry means the crontab entry itself has a problem, accidentally deleted during an unrelated crontab -e edit by someone else, a schedule field typo, or, commonly overlooked, a system timezone change or DST transition shifting when 2am actually falls, especially on systems using local time in crontabs rather than UTC. If the log shows the job WAS launched, the next step is checking whether output was actually captured anywhere — if the crontab entry didn't explicitly redirect stdout and stderr to a log file, the only record of what went wrong is whatever MAILTO mail delivery target exists, which needs to actually be checked, or better, forwarded to a monitored address or Slack webhook instead of a mailbox nobody opens.
In production, mature teams don't rely on manually checking /var/log/cron after the fact at all — they instrument each critical cron job to explicitly report its own success or failure to an external monitoring system, discussed further in dead-man's-switch style heartbeat monitoring, specifically because relying on humans to notice a missing overnight job the next morning is exactly the failure mode that just happened here. The immediate fix for this specific incident is redirecting the job's output to a captured log file and wiring MAILTO, or better, the script's own exit-code handling, to an actively monitored alert channel, not just a mailbox that sits unread for weeks.
The non-obvious gotcha: even after fixing logging and alerting, a very common recurring cause of the-job-silently-failed is that cron jobs run with a drastically minimal environment, often just PATH=/usr/bin:/bin and none of the environment variables or shell customizations present in an interactive login session. A script that works perfectly when an engineer runs it manually from their own shell can fail immediately under cron because it depends on a tool only found via a PATH entry set in .bashrc, or an environment variable normally exported by a login profile. Any cron job should be tested by explicitly running it with an empty, minimal environment, env -i /path/to/script.sh, rather than trusting that it works when I run it manually means anything about how it'll behave under cron's stripped-down execution context.
Code Example
# step 1: did the job even launch? check cron's own log first journalctl -u cron --since '24 hours ago' | grep -i billing-reconciler grep billing-reconciler /var/log/cron # on distros without journald # step 2: crontab entry - capture output explicitly, don't rely on MAILTO alone [email protected] 0 2 * * * /usr/bin/python3 /opt/billing-reconciler/reconcile.py >> /var/log/billing-reconciler.log 2>&1 # step 3: reproduce under cron's actual minimal environment, not your interactive shell env -i PATH=/usr/bin:/bin /usr/bin/python3 /opt/billing-reconciler/reconcile.py
Interview Tip
A junior engineer typically says 'check the cron logs,' stopping there without explaining what those logs do and don't tell you. For a senior/architect role, the interviewer is actually looking for you to distinguish job-never-launched, a crontab, schedule, or timezone problem visible in cron's own log, from job-launched-but-failed, visible only in captured script output or a MAILTO email that's often unmonitored, and specifically to know that cron's minimal environment, missing PATH entries and shell customizations present interactively, is one of the most common causes of a script that works when I run it manually but silently fails under cron.
◈ Architecture Diagram
cron log entry?
✗ no → crontab missing/disabled/timezone-shifted
✓ yes → job launched, check captured output
✗ no output captured → check MAILTO (often unread)
✓ output captured → read the actual error💬 Comments
Quick Answer
Wrap each cron job so it actively checks in with an external monitoring endpoint both when it starts and when it completes successfully, a dead man's switch or heartbeat pattern, using a service like Healthchecks.io, Cronitor, or a self-hosted equivalent, and have that external service alert if the expected check-in doesn't arrive within a grace period past the scheduled time. This inverts the monitoring responsibility from cron telling you it failed, which it can't do reliably, to an outside system noticing you went silent, which catches every failure mode including the job not running at all, hanging forever, or crashing before it could report its own error.
Detailed Answer
Think about how many home security systems handle a man-down scenario for someone living alone. Rather than waiting for the person to actively press a panic button when something goes wrong, which assumes they're conscious and able to act, the system requires the person to check in periodically, pressing a button once a day to confirm I'm fine, and if that routine check-in doesn't happen within an expected window, the system assumes something's wrong and alerts someone, without needing the person in trouble to do anything at all themselves.
This dead man's switch pattern, named after a railway or machinery safety mechanism that only stays in the safe state while someone actively holds it down, is exactly the right model for cron monitoring, because it solves the fundamental problem: cron cannot tell you a job failed if the failure mode is severe enough to prevent the job from even reaching its own error-reporting code, a hung process, a segfault, the entire host being down, the crontab entry silently deleted. Any monitoring approach that depends on the job itself successfully reporting failure has a blind spot exactly matching its own most catastrophic failure modes.
The implementation: each critical cron job is wrapped, or modified, to send an HTTP request to a unique monitoring URL at the start of execution and another at successful completion, commonly implemented by prefixing the actual command with a curl call. The monitoring service, self-hosted or SaaS, is configured with the job's expected schedule and a grace period, expect a successful ping every 24 hours, allow up to 2 hours of lateness before alerting, and it fires an alert specifically when an expected ping does NOT arrive in time. This means the alert triggers on absence of a signal, not presence of an error signal, which is what makes it robust against every failure mode, including the host being completely down and unable to send anything at all.
In production, teams typically also capture the job's actual exit code and include it in the completion ping, many of these services accept a status code, treating a non-zero exit as a distinct job-ran-but-failed signal versus job-never-even-started, giving two useful alert categories: job-didn't-run-or-complete-at-all, an infrastructure-level problem, versus job-ran-but-exited-with-an-error, an application-level problem, which route to different responders and severity levels. Dashboards typically show every monitored job's last successful check-in time and current status, giving an at-a-glance view across dozens of scheduled jobs that individual crontab inspection could never provide on its own.
The non-obvious gotcha: teams sometimes implement only a completion ping and skip the start ping, which seems like it saves an HTTP call but actually loses the ability to distinguish the job hung and never finished from the job never started at all — both look identical, no completion ping arrives, without a start ping to prove the job at least began. The start ping is also what lets you separately alert on job-has-been-running-suspiciously-long, started but no completion ping well past the job's normal runtime, a distinct and often earlier warning sign than waiting for the full scheduled-interval grace period to elapse before assuming failure.
Code Example
# cron entry: ping start and completion, capture exit code distinctly 0 2 * * * curl -fsS -m 10 --retry 3 https://hc-ping.com/abc123/start \ && /opt/order-fulfillment/nightly-sync.sh; \ curl -fsS -m 10 --retry 3 https://hc-ping.com/abc123/$? # $? after the job = exit code: 0 pings success, nonzero pings failure explicitly # monitoring service config (conceptual): expect a ping every 24h, page if 2h late # schedule: "0 2 * * *" grace_period: "2h" channel: pagerduty-platform-oncall
Interview Tip
A junior engineer typically says 'have the script send an alert if it fails,' which doesn't handle the job hanging, crashing before it can alert, or the whole host being down entirely. For a senior/architect role, the interviewer is actually looking for the dead man's switch pattern explained clearly, an external service alerting on the ABSENCE of an expected check-in, not the presence of a failure signal, since that's the only approach robust to catastrophic failure modes. Mentioning both start and completion pings, and the distinct running-too-long signal that a start-only ping enables, shows real operational depth beyond basic error alerting.
◈ Architecture Diagram
┌────────┐ start ping ┌─────────┐ complete ping ┌────────┐
│ cron │ ───────────→ │ job runs│ ────────────→ │monitor │
└────────┘ └─────────┘ └────────┘
│
no ping in grace period?
✗ ALERT (absence = signal)💬 Comments
Quick Answer
Migrate incrementally, one job at a time, by converting each crontab entry into a paired systemd .service, defining the actual command and its execution environment and user, and .timer, defining the OnCalendar schedule, unit, verifying behavior in parallel with the old cron entry disabled but not deleted until the timer has proven itself over a full cycle. The concrete advantages are real and specific: systemd timers have built-in overlap prevention, structured logging via journald queryable by job name, explicit dependency ordering, and per-job resource limits via cgroups, none of which cron offers natively.
Detailed Answer
Think about upgrading a factory from a wall clock that rings a bell at fixed times, with each department expected to independently know what to do when they hear their bell, to a proper shift-management system. The new system knows which shifts depend on which other shifts finishing first, refuses to start a new shift on a machine that's still actively running the previous one, keeps a detailed log of exactly when each shift started, finished, and whether it hit any problems, and can cap how many resources any single shift is allowed to consume. The bell-based system technically worked for decades, but every one of those improvements requires the department itself to build custom logic; the shift-management system provides it all as a built-in platform feature instead.
Systemd timers were introduced specifically to bring init-system-level features to job scheduling, addressing exactly the gaps that make raw cron risky at scale: no native overlap protection, no structured queryable logs, cron output goes to whatever ad-hoc redirection or MAILTO you configured, if anything at all, no dependency awareness, cron can't express don't-run-this-backup-job-until-the-database-mount-is-confirmed-ready, and no resource governance, a runaway cron job can consume unbounded CPU and memory with nothing else in the system aware of or limiting it. Because systemd already manages every other service on a modern Linux host, extending it to also manage scheduled jobs means scheduled jobs get the same operational tooling, journald logging, systemctl status, cgroup resource limits, that every other service already benefits from.
A safe migration converts one job at a time. For a job like order-fulfillment-sync, you create /etc/systemd/system/order-fulfillment-sync.service, defining ExecStart, the User= to run as, and any Environment= variables previously relied upon implicitly from a login shell, and a paired .timer unit with OnCalendar= expressing the schedule, which supports cron-like patterns but also richer expressions. Then systemctl enable --now order-fulfillment-sync.timer while leaving the old crontab entry commented out, not deleted, as a documented rollback path. You let both the new timer and the disabled-but-present old crontab entry sit for at least one full schedule cycle, confirming via journalctl -u order-fulfillment-sync.service that it ran successfully and on time, before removing the old crontab entry entirely.
In production, once migrated, Type=oneshot combined with default systemd behavior means a new timer-triggered start is simply skipped, not queued, not force-started, if the service unit is still marked active from a previous run, solving the classic cron overlap problem without any custom flock wrapper needed. Dependency ordering, After= and Requires=, lets you correctly sequence something like a reconciliation job that must not start until a database backup unit has completed, which cron has no native way to express at all. Resource limits, MemoryMax= and CPUQuota= in the service unit, mean a single runaway scheduled job can be capped from starving the rest of the host, which raw cron-launched processes never had unless manually wrapped in cgroup tooling separately by hand.
The non-obvious gotcha: OnCalendar scheduling in systemd timers is NOT minute-by-minute polling like cron. Systemd calculates the next trigger time analytically and can, by default, add a small randomized delay, RandomizedDelaySec, or align timers to reduce simultaneous wake-ups across many services, AccuracySec, default 1 minute of slack, meaning a timer's actual fire time can be a little later than the exact OnCalendar value unless you explicitly set AccuracySec=1s for jobs where precise timing genuinely matters. Teams migrating from cron's exact-minute behavior are frequently surprised by this when a job that always ran at exactly 2:00:00 under cron starts firing at 2:00:47 under a naively-configured timer instead.
Code Example
# /etc/systemd/system/order-fulfillment-sync.service [Unit] Description=Order fulfillment nightly sync After=network.target postgresql.service # dependency ordering cron cannot express [Service] Type=oneshot User=appuser ExecStart=/opt/order-fulfillment/sync.sh MemoryMax=512M # resource cap cron never enforced # /etc/systemd/system/order-fulfillment-sync.timer [Unit] Description=Run order-fulfillment-sync nightly at 2am [Timer] OnCalendar=*-*-* 02:00:00 AccuracySec=1s # disable systemd's default slack for exact timing Persistent=true # catch up if the host was down at trigger time [Install] WantedBy=timers.target # enable and verify systemctl enable --now order-fulfillment-sync.timer journalctl -u order-fulfillment-sync.service --since '24 hours ago'
Interview Tip
A junior engineer typically says 'systemd timers are just a modern cron,' without naming concrete differentiators. For a senior/architect role, the interviewer is actually looking for specifics: built-in overlap prevention via service unit active-state tracking, no flock needed, dependency ordering via After and Requires, structured journald logging, and cgroup-based resource limits, none native to cron. Bonus signal for knowing that OnCalendar scheduling isn't exact-time by default, AccuracySec and RandomizedDelaySec introduce intentional slack, which surprises engineers expecting cron's exact-minute firing behavior when they first migrate a timing-sensitive job.
◈ Architecture Diagram
cron: bell rings → job forks, no state tracking
systemd: .timer → .service (active?) → skip if still running
│
After=db-backup.service (dependency ordering)
MemoryMax=512M (resource cap)
journald (structured, queryable logs)💬 Comments
Context
A legacy VM estate whose scheduled work lived in root crontabs across 40 hosts: no central inventory, output emailed to aliases nobody read, jobs silently skipped when hosts were down at trigger time, and one job's failure discoverable only by its downstream effects.
Problem
Cron's minimalism was the problem: no dependency expression (jobs 'ordered' by guessed start times), no missed-run semantics (a reboot at 02:00 meant the 02:00 backup simply didn't happen), no journal integration, and per-host crontabs invisible to config management drift detection.
Solution
Converted to systemd timers via config management templates: each job became a .service (with User=, resource limits, After=/Requires= dependencies on mounts and networks it actually needs) plus a .timer (OnCalendar= translated from cron expressions, Persistent=true so missed runs fire on boot, RandomizedDelaySec to de-thunder herds). Output flows to the journal with per-job identifiers; failures trigger OnFailure= units that page. The templates live in Git, making the fleet's schedule diffable and reviewable for the first time.
Commands
backup.timer: [Timer] OnCalendar=02:00 Persistent=true RandomizedDelaySec=300
backup.service: [Unit] Requires=data.mount After=network-online.target [Service] User=backup ExecStart=/opt/jobs/backup.sh
OnFailure=alert@%n.service; journalctl -u backup.service --since yesterday
Outcome
The silent-skip class ended (Persistent=true replayed a missed backup on the very first post-migration reboot); job failures page within minutes instead of surfacing as downstream mysteries; the schedule inventory is a Git query; and RandomizedDelaySec dissolved the 02:00 thundering herd that had been spiking the SAN nightly.
Lessons Learned
The migration audit itself found 30 jobs that were dead (targets long gone) and two jobs running on the wrong hosts entirely — inventory value before any technical value. Translate cron expressions carefully: OnCalendar= semantics differ subtly (systemd-analyze calendar verifies).
💬 Comments
Context
A company that had suffered the canonical cron failure twice: a nightly job stopped running (once a commented-out line from a debugging session, once a deactivated owner account) and nobody knew for weeks — because failure alerting only fires when a job runs and fails, not when it doesn't run.
Problem
Cron has no concept of 'should have run': absence is silent by design. Failure-path alerting (exit-code checks, error emails) covers half the risk; the other half — the job never starting — was invisible until stale data or missed billing surfaced downstream.
Solution
Instituted heartbeat monitoring as a mandatory pattern: every scheduled job's wrapper curls a per-job heartbeat URL (healthchecks.io-style service, self-hosted) on successful completion; the service knows each job's expected cadence plus grace period and pages when a ping doesn't arrive — converting absence into a signal. Job registration is automated (config management generates both the schedule and the heartbeat check from one definition), so a job cannot be scheduled without its dead-man switch. Start pings plus success pings distinguish 'never started' from 'started and died' for faster triage.
Commands
wrapper: /opt/jobs/run-with-heartbeat billing-reconciler -- /opt/jobs/reconcile.sh
run-with-heartbeat: curl -fsS $HC_URL/start; "$@" && curl -fsS $HC_URL || curl -fsS $HC_URL/fail
definition: {name: billing-reconciler, schedule: '0 2 * * *', grace: 30m} -> renders cron line + checkOutcome
The next silent stoppage (a job whose host was decommissioned with the job forgotten) paged within 90 minutes of its missed window instead of weeks later; triage speed improved from the start/success ping split; and the one-definition registration means the schedule and its monitoring cannot drift apart.
Lessons Learned
Monitoring absence requires something external that knows the schedule — the job cannot report its own failure to start. Coupling registration (schedule + check from one source) is what makes the pattern durable; bolt-on heartbeats decay as jobs churn.
💬 Comments
Symptom
A scheduled script runs successfully when a human executes it interactively, but consistently fails (or does nothing observable) when triggered by cron, with no error visible anywhere obvious.
Error Message
/bin/sh: aws: command not found (only visible in mail spool or job-specific log, not in any dashboard)
Root Cause
Cron executes jobs with a minimal environment — typically just HOME, LOGNAME, PATH (often just /usr/bin:/bin), and SHELL — and does not source .bashrc/.profile/.zshrc the way an interactive login shell does. Scripts that rely on tools installed outside that minimal PATH (via nvm, pyenv, a custom /usr/local install) or on environment variables normally set by a shell profile silently fail to find them, and because most systems no longer have local mail delivery configured, cron's own failure output goes nowhere anyone checks.
Diagnosis Steps
Solution
Set an explicit PATH (and any other required environment variables) directly in the crontab or at the top of the script rather than relying on the invoking user's interactive shell environment, and redirect the job's stdout/stderr to a log file (or a monitoring endpoint) so failures are visible instead of silently discarded.
Commands
crontab -l
env -i /bin/sh -c '<the exact cron command>'
tail -f /var/log/cron /var/log/syslog
Prevention
Never write a cron-invoked script that assumes an interactive shell's environment; use absolute paths for every binary and file referenced. Add a lightweight heartbeat/dead-man's-switch check for any cron job whose silent failure would matter, since cron itself will not alert on failure by default.
💬 Comments
Symptom
A cron job scheduled every few minutes occasionally produces corrupted or partially-written output files, or two instances appear to write conflicting data, especially after the job's runtime grows to approach or exceed its own schedule interval.
Error Message
none single error — observed as corrupted/truncated output files or duplicate processing of the same input
Root Cause
Cron has no built-in concept of 'don't start a new run if the previous one is still running' — if a job's execution time grows (due to data volume, a slow dependency, or resource contention) to exceed its schedule interval, cron happily starts a second overlapping instance, and if both instances write to the same file or resource without coordination, the result is corrupted or inconsistent output.
Diagnosis Steps
Solution
Wrap the job command with a locking mechanism (flock against a lock file, or a PID-file check) so a new invocation exits immediately if a previous instance is still running, rather than proceeding to write concurrently; backfill/reprocess any output known to have been corrupted during the overlap window.
Commands
ps -ef | grep <job_script>
flock -n /var/lock/<job>.lock -c '<command>'
grep CRON /var/log/syslog | grep <job_name>
Prevention
Add flock -n /var/lock/<job>.lock <command> (or equivalent) to every cron job whose runtime could plausibly approach its schedule interval, as a standing pattern rather than only after an incident. Monitor job duration over time so a job trending toward its own interval is caught before it starts overlapping.
💬 Comments
Symptom
In March: the nightly billing-export job simply doesn't run — 02:30 never existed that night in the host's local timezone, and downstream reconciliation alarms fire at 09:00. In November (after the March 'fix' was deemed unnecessary): the job runs twice — 01:30 occurred twice — double-exporting the day's transactions to a partner who ingests them as duplicates.
Error Message
No error either time. March: no log entry at all (cron never fired — the wall-clock minute was skipped). November: two complete, successful runs 60 minutes apart, each proudly logging success.
Root Cause
The host ran in local time (America/New_York) and the job was scheduled at 02:30 — inside the DST transition window. Spring-forward skips 02:00-02:59 (job never fires); fall-back repeats 01:00-01:59 (jobs in that window fire twice, cron-implementation-dependent). The job itself was also non-idempotent on the double-run path: the export appended rather than replacing, so the November duplicate was a data incident, not just a scheduling quirk.
Diagnosis Steps
Solution
March's miss was backfilled manually. After November: all scheduled hosts moved to UTC (the systemic fix — UTC has no transitions), the export job made idempotent (deterministic output keyed on the business date, overwriting), and the partner ingestion gained a duplicate-detection check as defense in depth. A sweep found nine more jobs scheduled inside 01:00-03:00 local; all moved or hardened.
Commands
timedatectl set-timezone UTC # scheduled hosts
audit: grep -E '^[^#]*\s([01]):|^[^#]*\s2:' /etc/crontab /var/spool/cron/* # transition-window jobs
systemd alternative: OnCalendar=*-*-* 02:30 UTC
Prevention
Schedule in UTC everywhere schedules run (hosts, containers, CI) — local-time scheduling embeds two guaranteed anomalies a year. Independently: scheduled jobs must be idempotent per logical period (same business date -> same end state), because double-fire has other causes too (retries, failovers, the heartbeat backfill). The 01:00-03:00 local window is the audit target.
💬 Comments