All notes

Cron taught me to care about idempotency

Scheduling a Bash script was easy. Making repeated, overlapping and partially failed runs safe was the real automation problem.

about 20 minutes min read

The first cron job I wrote felt like a small promotion.

I had a script.

I had a schedule.

The computer would now perform the task without me.

0 2 * * * /home/miri/bin/generate-report

Every day at 02:00, the report would appear.

Automation achieved.

Except that scheduling a command and automating an operation are not quite the same thing.

Cron knew when to start the script. It knew nothing about:

whether yesterday's run was still active
whether today's report already existed
whether the previous run stopped halfway
whether running the command twice was safe
whether the environment matched my terminal
whether anybody would notice a failure

Cron had done exactly what I asked.

The problem was that I had not asked enough questions.

These examples assume Bash on Linux, traditional cron behaviour, GNU command-line tools and util-linux flock unless noted otherwise.

Cron starts attempts, not outcomes

A crontab entry broadly says:

run this command
at these matching times

For example:

15 3 * * * /home/miri/bin/cleanup-reports

The five scheduling fields represent:

minute
hour
day of month
month
day of week

followed by the command.

Conceptually:

SCHEDULE MATCHES
       │
       ▼
START COMMAND
       │
       ▼
CRON'S PART IS MOSTLY DONE

Cron does not understand the business meaning of cleanup reports. It does not know whether two copies may run safely, whether the result already exists or how to recover from partial work.

Those are responsibilities of the operation being scheduled.

Imagine this first attempt at a report script:

#!/usr/bin/env bash

mkdir /var/lib/reports

generate-summary >> /var/lib/reports/daily.txt

The first run may work.

The second fails to create the existing directory and appends another copy of the report:

orders: 42
orders: 42
orders: 42

Cron did not create those duplicates.

My operation did.

The schedule merely gave the mistake a reliable appointment.

Repetition changes the question

When I run a command manually, I often ask:

Does this work?

Once it is scheduled, I need to ask:

What happens the second time?

What happens if it runs twice at once?

What happens if it fails after changing half the state?

What happens if I rerun it manually?

Can it distinguish completed work from partial work?

Can it safely continue after interruption?

Cron creates repeated scheduled invocations. More attempts may come from manual reruns, monitoring systems, deployment scripts or external retry wrappers.

Cron itself does not normally retry a failed command. The wider operating environment creates several ways for the same logical work to be attempted again.

This is where I encountered the word idempotency.

Idempotency is about repeated effect

An idempotent operation can be applied repeatedly without repeatedly changing its intended result once that result has been reached.

A simple filesystem example is:

mkdir -p /var/lib/reports

The desired state is:

/var/lib/reports exists as a directory

Running the command again continues to satisfy the same condition.

Compare that with:

printf '%s\n' 'report complete' >> status.log

Every execution adds another line.

mkdir -p directory
        │
        └── converges on a state

append to file
        │
        └── creates another effect each time

Idempotency is always defined against an effect.

Suppose the desired outcome is:

There should be exactly one completed report for 23 May 2021.

A rerun may still write another log entry or increment a metric. It can be idempotent with respect to the published report without being idempotent with respect to every observable side effect.

The important promise is that a retry should not accidentally create:

two reports
two invoices
two payments
two emails

Idempotency is therefore tied to the operation’s semantics. It cannot be determined from shell syntax alone.

Identify one logical unit of work

A daily report may be attempted several times:

logical report:
    2021-05-23

attempts:
    02:00 scheduled invocation
    02:10 manual rerun
    02:15 recovery attempt

Those should usually be several attempts to achieve one logical outcome, not three separate reports.

The operation needs a stable identity for that work:

report date
order ID
source object ID
backup date
event ID
deployment version

Without a stable identity, it cannot reliably distinguish new work from another attempt at the same work.

The current clock is not automatically that identity.

A report started at 02:00 on 24 May may cover 23 May. A delayed retry after midnight could otherwise select a different date from the original attempt.

A script can accept the reporting period explicitly:

report_date=${1:?usage: generate-report YYYY-MM-DD}

Or it can define a visible default policy:

report_date=${1:-$(date -u --date='yesterday' +%F)}

The second form depends on GNU date, but it makes the contract clear:

scheduled invocation
    │
    └── process previous UTC day

manual recovery
    │
    └── pass the exact missed date

Timezone belongs to the operation’s input contract. The timezone used by cron and the timezone defining a business day are not automatically the same.

Recognise the desired state

Automation often becomes safer when it describes the state it requires instead of assuming every attempt begins from nothing.

This:

mkdir -p /var/lib/reports

expresses:

Ensure this directory hierarchy exists.

But existence alone may not be enough. The path could have the wrong type, ownership or permissions.

A script might check:

report_dir=/var/lib/reports

mkdir -p -- "$report_dir" ||
    exit 1

if [[ ! -d $report_dir || ! -w $report_dir ]]; then
    printf 'report directory is not writable: %s\n' \
        "$report_dir" >&2
    exit 1
fi

These checks improve diagnostics, but they are snapshots. A successful -w test does not reserve the directory or guarantee that a later write succeeds.

The real operation still needs error handling.

The final result also needs a completion test:

final_report="/var/lib/reports/orders-$report_date.tsv"

if [[ -s $final_report ]]; then
    printf 'report already exists: %s\n' "$final_report" >&2
    exit 0
fi

A successful previous attempt becomes a successful no-op.

Whether -s is enough depends on what “complete” means. A stronger test might verify:

expected header
checksum
record count
completion marker
remote acknowledgement

Idempotency requires a reliable way to recognise the desired state.

Build first, publish second

Appending a complete report is dangerous:

generate-summary >> daily-report.txt

Each run adds another result.

Replacing the output is closer to the intended state:

generate-summary > daily-report.txt

But the shell truncates the destination before generate-summary runs. If generation fails halfway, the previous good report may already be gone.

A safer pattern is:

generate temporary result
          │
          ▼
validate result
          │
          ▼
publish completed result

For example:

report_dir=/var/lib/reports
final_report=$report_dir/daily-report.txt

temporary_report=$(mktemp "$report_dir/.daily-report.XXXXXX") ||
    exit 1

cleanup()
{
    rm -f -- "$temporary_report"
}

trap cleanup EXIT

if ! generate-summary > "$temporary_report"; then
    printf 'report generation failed\n' >&2
    exit 1
fi

if [[ ! -s $temporary_report ]]; then
    printf 'generated report is empty\n' >&2
    exit 1
fi

mv -- "$temporary_report" "$final_report" ||
    exit 1

temporary_report=''

mktemp creates the file instead of asking the script to invent a predictable name such as /tmp/report.$$.

Placing the temporary file in the final directory keeps it on the same filesystem. Publication can then use a rename, so readers normally see the old object or the completed new one rather than a half-written final file.

That guarantee has limits.

A rename does not by itself guarantee durability after a machine or storage crash. The temporary file’s default permissions may also need to be changed before publication. An EXIT trap cannot clean up after SIGKILL or a system crash, so abandoned temporary files may need a retention policy.

The publication contract should also be explicit:

replace the existing result

is different from:

publish only when no result exists

The example above chooses replacement.

Remote effects need remote idempotency

Local files can often be recognised by path and content.

Remote effects are harder:

send email
create order
charge card
publish message
upload object

Suppose a job uploads a report and then records completion locally:

remote upload succeeds
        │
        ▼
process crashes before local marker
        │
        ▼
rerun cannot tell whether upload happened

The worst state is often:

I do not know whether the effect happened.

That is exactly when an operator wants to rerun the job.

A local marker written after a remote request cannot always resolve that uncertainty. The safest design is often for the remote system to understand a stable idempotency key:

report identity:
    orders-2021-05-23

remote operation:
    create or replace that object

Or:

payment identity:
    invoice-4821

remote operation:
    process this key once

Some operations are naturally repeatable.

Others need an explicit idempotency key or transactional design.

A scheduler cannot invent those semantics.

Concurrency needs its own policy

Idempotency describes repeated effect.

Concurrency asks what happens when attempts overlap.

Imagine two copies starting together:

PROCESS A                    PROCESS B

check report absent
                             check report absent

start generation
                             start generation

publish report
                             publish report

Both observed the same initial state.

This is a check-then-act race.

Cron does not automatically prevent overlap. A job scheduled every five minutes can overlap if one invocation takes eight minutes:

12:00 run ────────────────► 12:08

12:05 run ────────────────► 12:13

On Linux, flock provides a convenient advisory lock:

state_dir=/var/lib/report-generator

mkdir -p -- "$state_dir" ||
    exit 1

exec 9> "$state_dir/report.lock" ||
    exit 1

if ! flock -n 9; then
    printf 'another report run is already active\n' >&2
    exit 0
fi

File descriptor 9 remains open while the script runs, keeping the lock associated with that attempt.

flock is advisory. It coordinates programs that follow the same locking convention; it does not stop unrelated processes from modifying the files.

Lock contention also needs a policy:

exit 0
    another attempt is already pursuing the same outcome

exit non-zero
    this invocation failed to perform required work

wait for lock
    every invocation should eventually run

Skipping may be acceptable for one daily report. But a hung process could make later invocations skip successfully, so independent monitoring should verify that the expected report appears.

A lock is not idempotency.

LOCK
    controls overlap

IDEMPOTENCY
    controls repeated effect

Reliable automation may need both.

Environment is input too

A script that works in my terminal may fail under cron.

My interactive environment can provide:

a large PATH
shell configuration
aliases
environment variables
current working directory
authentication agents
terminal input

A cron job runs in a more limited, non-interactive context.

A crontab can make important assumptions explicit:

SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin

17 2 * * * /usr/local/sbin/generate-daily-report

If the script begins with:

#!/usr/bin/env bash

its shebang selects Bash when the script is invoked directly. The shell configured by cron still interprets the crontab command line.

Crontab command fields have their own parsing rules, and some cron formats give % special meaning. Keeping the entry simple avoids embedding a second program inside the scheduler configuration.

A scheduled script should also use explicit paths and a deliberate working directory:

readonly report_dir=/var/lib/reports

cd -- "$report_dir" || {
    printf 'cannot enter report directory: %s\n' \
        "$report_dir" >&2
    exit 1
}

The environment is part of the program interface.

If the operation requires something, it should say so.

Output and status are operational interfaces

A scheduled command has no human sitting in front of its terminal.

Traditional cron installations may mail unredirected stdout and stderr to the job owner. A chatty successful job can therefore create a great deal of mail.

Silencing everything is not necessarily better:

0 2 * * * /path/to/job >/dev/null 2>&1

Now genuine diagnostics disappear too.

A deliberate policy might be:

stdout
    meaningful result, usually quiet on success

stderr
    diagnostics requiring attention

exit status
    whether this attempt achieved its contract

external check
    whether the expected business result appeared

Cron can successfully launch a command while the operation later fails.

crontab parsed
      │
      ▼
scheduler launched command
      │
      ▼
script validated environment
      │
      ▼
operation completed
      │
      ▼
result verified

“Cron ran” is not the same statement as:

The scheduled business outcome was achieved.

A deliberate daily report script

The following is an illustrative local-file workflow, not a universal production template.

It checks important failures explicitly rather than relying on set -e, whose behaviour depends on command context.

#!/usr/bin/env bash

set -u
set -o pipefail

umask 027

readonly program_name=${0##*/}
readonly state_dir=/var/lib/report-generator
readonly report_dir=/var/lib/reports
readonly exporter=/usr/local/bin/export-orders

temporary_report=''

log()
{
    printf '%s: %s\n' "$program_name" "$*" >&2
}

die()
{
    log "$*"
    exit 1
}

cleanup()
{
    if [[ -n $temporary_report ]]; then
        rm -f -- "$temporary_report"
    fi
}

trap cleanup EXIT

main()
{
    local report_date
    local final_report

    report_date=${1:-$(date -u --date='yesterday' +%F)} ||
        die 'could not determine report date'

    [[ $report_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] ||
        die "invalid report date: $report_date"

    mkdir -p -- "$state_dir" "$report_dir" ||
        die 'could not create required directories'

    [[ -d $state_dir && -w $state_dir ]] ||
        die "state directory is not writable: $state_dir"

    [[ -d $report_dir && -w $report_dir ]] ||
        die "report directory is not writable: $report_dir"

    [[ -x $exporter ]] ||
        die "exporter is not executable: $exporter"

    exec 9> "$state_dir/daily-report.lock" ||
        die 'could not open lock file'

    if ! flock -n 9; then
        log 'another report run is already active'
        return 0
    fi

    final_report=$report_dir/orders-$report_date.tsv

    if [[ -s $final_report ]]; then
        log "report already complete: $final_report"
        return 0
    fi

    temporary_report=$(
        mktemp "$report_dir/.orders-$report_date.XXXXXX"
    ) || die 'could not create temporary report'

    log "generating report for $report_date"

    if ! "$exporter" \
        --date "$report_date" \
        > "$temporary_report"
    then
        die "report generation failed for $report_date"
    fi

    if [[ ! -s $temporary_report ]]; then
        die "generated report is empty for $report_date"
    fi

    chmod 0640 -- "$temporary_report" ||
        die 'could not set report permissions'

    mv -- "$temporary_report" "$final_report" ||
        die "could not publish report: $final_report"

    temporary_report=''

    log "report published: $final_report"
}

main "$@"

The crontab entry remains simple:

PATH=/usr/local/bin:/usr/bin:/bin

17 2 * * * /usr/local/sbin/generate-daily-report

A recovery attempt can pass the work identity explicitly:

/usr/local/sbin/generate-daily-report 2021-05-23

The script now has visible policies for:

logical work identity
dependencies and directories
overlapping execution
already-completed work
temporary output
validation and publication
file permissions
diagnostics and cleanup

Cron still decides when to start another attempt.

The script decides what that attempt means.

The limits are part of the design

Even this example assumes:

one non-empty final file means the report is complete
the exporter is read-only
the previous UTC day is the normal reporting period
skipping an overlap is acceptable
cooperating writers all use the same lock
replacement is the publication policy

A production implementation might require checksums, record counts, ownership checks, durable storage guarantees, monitoring, retention and cleanup of abandoned temporary files.

Not every scheduled run should collapse into one result either.

A metric collected every minute represents a new time interval. Hourly snapshots may intentionally produce:

snapshot-12
snapshot-13
snapshot-14

The idempotency boundary might be:

Produce no more than one snapshot for each hour.

The design question remains:

What is the identity of one intended unit of work?

Once that identity is known, retries, duplicates and overlap can have explicit meanings.

The scheduler is not the workflow

Cron supplies time-based triggering.

It does not automatically provide:

dependency management
distributed locking
retry policy
backoff
durable workflow state
transaction coordination
alerting
execution history
idempotency keys

For small local jobs, Bash, files and locks may be sufficient.

As the workflow grows, a service manager, timer system, queue or workflow engine may become more appropriate.

Changing schedulers does not remove the central requirement.

Anything that can be retried, redelivered or restarted still needs a repeated-execution model.

Idempotency is not a cron feature.

It is an operation-design feature.

The mental model I’m keeping

My original model was:

SCRIPT
   │
   ▼
CRON
   │
   ▼
AUTOMATION

The new model is:

                        CRON
                          │
                          │ schedule matches
                          ▼
                    START ATTEMPT
                          │
                          ▼
                     SCRIPT LOGIC
                          │
          ┌───────────────┼────────────────┐
          │               │                │
          ▼               ▼                ▼
   identify work     acquire lock     validate state
          │               │                │
          └───────────────┼────────────────┘
                          ▼
                 desired result exists?
                     │           │
                    yes          no
                     │           │
                     ▼           ▼
                  succeed     prepare result
                                  │
                                  ▼
                              verify result
                                  │
                                  ▼
                             publish result

Cron answers:

When should another attempt begin?

The operation must answer:

What is this attempt trying to achieve?

How do I recognise that it has already succeeded?

Can another copy run at the same time?

What state might a failed attempt leave behind?

Can I safely try again?

That is the difference between a scheduled command and reliable automation.

Cron did not make me care about idempotency because cron was unreliable.

It made me care because cron was extremely reliable at repeating whatever mistake I had scheduled.

A tiny line in a crontab can therefore be a useful architecture review.

It asks, every few minutes:

Are you quite sure this is safe to do again?

References and further reading

Cron and crontab

POSIX.1-2017: crontab Defines crontab as the standard interface for scheduling periodic background work, including the default execution environment and handling of unredirected output.

Linux crontab(5) manual page Documents the traditional crontab file format, scheduling fields, environment assignments, command execution and output-mail behaviour.

Linux cron(8) manual page Describes the cron daemon’s role in examining installed schedules and starting commands whose time expressions match.

Preventing overlapping runs

Linux flock(1) manual page Documents the util-linux flock command used to manage advisory locks from shell scripts or wrap a lock around another command.

Linux flock(2) manual page Documents the underlying Linux advisory file-locking system call and the lifecycle of locks associated with open files.

Repeated filesystem operations

GNU Coreutils: mkdir Documents directory creation and the -p option, which does not report an error when an existing destination is already a directory.

Temporary result creation

GNU Coreutils: mktemp Explains safe creation of temporary files and directories and the problems with predictable temporary filenames assembled from process IDs.

Publishing generated files

GNU Coreutils: mv Documents moving and renaming files, including the distinction between an ordinary rename and cross-filesystem behaviour.

Linux rename(2) manual page Documents filesystem rename behaviour and the atomic replacement of an existing destination name.

Bash execution and failure reporting

GNU Bash Reference Manual: Exit Status Documents Bash’s zero-success and non-zero-failure convention and the $? parameter used to inspect a command’s result.

GNU Bash Reference Manual: Redirections Explains how Bash directs standard output and standard error to files or other destinations before executing a command.

GNU Bash Reference Manual: Bourne Shell Builtins Includes the reference for trap, which the example uses to clean up temporary state when the script exits.