DEV Community

anicca
anicca

Posted on

Debugging Duplicate Cron Executions on macOS

TL;DR

A cron job scheduled to run once daily was executing 3 times per day on a Mac mini. The root causes were duplicate crontab entries from non-idempotent setup scripts and coexistence of LaunchAgents with crontab. Here's how to detect and prevent this.

The Problem

I run an AI agent automation platform on a Mac mini. One of the scheduled jobs — a daily memory aggregation task — was supposed to fire once per day. On Saturday, I discovered it had been running 3 times daily. No errors, no crashes — just silent over-execution burning compute and creating duplicate data.

Root Cause Analysis

Cause 1: Crontab Entry Duplication

The setup script that registers the cron job was not idempotent. Each time it ran (during debugging, redeployments, or system restarts), it appended a new line to crontab without checking if one already existed.

# The bad pattern
echo "0 1 * * * /path/to/job.sh" | crontab -

# Check for duplicates
crontab -l | sort | uniq -d
Enter fullscreen mode Exit fullscreen mode

Cause 2: LaunchAgent + Crontab Coexistence

macOS has two scheduling systems: the traditional Unix crontab and Apple's launchd (via LaunchAgent plist files). If the same job is registered in both, it runs twice — once from each scheduler, potentially at different times if timezone handling differs.

# Find LaunchAgents that might overlap
ls ~/Library/LaunchAgents/
launchctl list | grep -i daily
Enter fullscreen mode Exit fullscreen mode

Cause 3: Timezone Mismatch

Crontab typically runs in the system's local timezone, but LaunchAgents use the timezone specified in the plist (or default to local). When a server was migrated from a UTC-based VPS to a local Mac mini without updating timezone assumptions, the same job could fire at unexpected hours.

The Fix

Idempotent Crontab Registration

#!/bin/bash
CRON_JOB="0 1 * * * /path/to/daily-memory.sh"
# Remove existing entry, then add fresh
(crontab -l 2>/dev/null | grep -v "daily-memory.sh"; echo "$CRON_JOB") | crontab -
Enter fullscreen mode Exit fullscreen mode

This pattern removes any existing matching line before adding the new one. Run it 100 times — you still get exactly one entry.

Lock File Guard

Even after fixing the root cause, add a lock file as a defense-in-depth measure:

#!/bin/bash
LOCKFILE="/tmp/daily-memory.lock"

if [ -f "$LOCKFILE" ]; then
  LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCKFILE") ))
  if [ $LOCK_AGE -lt 3600 ]; then
    echo "Already running (lock age: ${LOCK_AGE}s). Skipping."
    exit 0
  fi
fi

touch "$LOCKFILE"
trap "rm -f $LOCKFILE" EXIT

# Actual work here
Enter fullscreen mode Exit fullscreen mode

The 1-hour staleness check prevents permanent lockouts from crashed processes.

Consolidate Schedulers

On macOS, pick one scheduler and stick with it:

  • LaunchAgent if you need macOS-specific features (wake from sleep, load on login)
  • Crontab if you want Unix portability and simplicity

Audit and remove the other:

# Remove duplicate LaunchAgent
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.job.plist
rm ~/Library/LaunchAgents/com.example.job.plist
Enter fullscreen mode Exit fullscreen mode

Prevention Checklist

Practice Why
Idempotent crontab scripts Prevents entry multiplication
Lock files with staleness Catches duplicates at runtime
Single scheduler policy Eliminates cross-scheduler duplication
Weekly crontab -l audit Catches drift before it causes problems
Timezone in crontab header CRON_TZ=America/Los_Angeles makes intent explicit

Lessons Learned

  1. Silent failures are worse than loud ones — The job succeeded every time. There were no errors to alert on. Only manual inspection revealed the triplication.
  2. Migration changes assumptions — Moving from a VPS (UTC, crontab only) to a Mac mini (local TZ, launchd available) introduced new failure modes that weren't in the original design.
  3. Defense in depth for scheduled jobs — Fix the root cause AND add a lock file. Belt and suspenders.

The fix took 10 minutes. Finding the problem took 2 days. That's the nature of silent duplication bugs — they hide in plain sight.

Top comments (0)