DEV Community

Cover image for How to stop a cron job from running twice
Kashif Manzer
Kashif Manzer

Posted on

How to stop a cron job from running twice

How to stop a cron job from running twice

You have a cron job that runs every minute. It takes 90 seconds to finish.

That is the whole bug. Read it again. Cron does not check whether your last run finished. It fires on the schedule, blindly, every time. Minute one starts the job. Minute two starts a second copy while the first is still working. Minute three starts a third. They step on each other: double writes, duplicate emails, two workers chewing the same queue.

Everyone learns this the hard way. The fix is one idea: the job must take a lock before it works, and walk away politely if the lock is already taken.

Watch it happen

Here is a tiny Python job. It grabs an exclusive lock, then works for 8 seconds. I ran three copies of it:

import fcntl, time, sys

start = time.time()
with open("/tmp/demo.lock", "w") as f:
    try:
        fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        print(f"[{time.time()-start:.3f}s] another copy is running, exiting")
        sys.exit(1)
    print(f"[{time.time()-start:.3f}s] lock acquired, doing the work...")
    time.sleep(8)
    print(f"[{time.time()-start:.3f}s] work done")
Enter fullscreen mode Exit fullscreen mode

The real terminal output:

--- instance 1 starts (foreground, holds lock 8s) ---
--- instance 2 starts while instance 1 holds the lock ---
[0.000s] another copy is running, exiting without doing the work
instance 2 exit code: 1
[0.000s] lock acquired, doing the real work...
[8.000s] work done
--- instance 3 starts after instance 1 finished ---
[0.000s] lock acquired, doing the real work...
[8.000s] work done
Enter fullscreen mode Exit fullscreen mode

Instance 2 noticed the lock in under a millisecond and left. Instance 3, starting after instance 1 finished, went straight to work. No queueing, no piling up, no double work.

Why the lock cannot get stuck

This is the part that makes it trustworthy. The lock is not a flag in your program. It lives in the kernel. flock asks the Linux kernel to mark the file "locked by this process", and the kernel releases it the moment the process dies, for any reason.

Kill the job with Ctrl-C? Lock released. The job crashes? Lock released. Someone runs kill -9 and the process never gets to clean up? Lock released. The kernel always holds the other end of the rope, so there is no such thing as a stale lock file to clean up afterwards.

One honest caveat: this holds on a normal local disk. On a network file system, advisory locks can behave strangely depending on the mount and the protocol version. Keep your lock file on local disk and you sidestep the entire class of problems.

The same trick in Go

package main

import (
    "fmt"
    "os"

    "golang.org/x/sys/unix"
)

func main() {
    f, err := os.OpenFile("/tmp/report.lock", os.O_CREATE|os.O_RDWR, 0600)
    if err != nil {
        fmt.Fprintln(os.Stderr, "cannot open lock file:", err)
        os.Exit(1)
    }
    if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
        fmt.Fprintln(os.Stderr, "another copy is already running")
        os.Exit(1)
    }
    runTheReport()
}
Enter fullscreen mode Exit fullscreen mode

LOCK_EX means exclusive, LOCK_NB means "do not wait, fail immediately". That second flag is the whole decision: waiting would just recreate the pileup, one job deep.

The laziest version

If the job is a shell script, you do not need code at all. The flock command wraps any program:

* * * * * /usr/bin/flock -n /tmp/report.lock /opt/jobs/report.sh
Enter fullscreen mode Exit fullscreen mode

-n means non-blocking: if the lock is taken, flock exits immediately without running the script. One flag, overlaps gone.

Which pattern fits your job?

  • Skip the overlap (reports, syncs, backups): non-blocking lock, second copy exits. This is the common case.
  • Never skip, never overlap (queue drains): blocking lock, second copy waits its turn. Use LOCK_EX without LOCK_NB, but set a timeout so a genuinely stuck job cannot jam the line forever.
  • Overlap is fine (stateless, idempotent workers): no lock at all. Just be sure the work really is safe to run twice, because one day it will.

Cron fires blindly. That is not a bug in cron; it is the contract. The fix is eight lines and a file the kernel babysits for you.

What is the most surprising thing a double-running cron job ever did to you?

Top comments (0)