How to check if a cron job ran

Read the record the job leaves, not the scheduler's own log. Count runs and successes per job in the run table: a job that fired and threw shows runs 8, ok 0, and the scheduler still reports it as having run. Windows Task Scheduler states the same fact as Last Result: 3.

Why check this

A scheduled job runs when nobody is watching. The tester signing off a nightly export sees the next morning's data, by which time a failed run is eight hours old.

Run it on staging sign-off, after a deploy that changes a job's dependencies, and after a schedule change. It prevents this: a price sync fires on time, throws ECONNREFUSED 127.0.0.1:5432 on its first line, and counts as a run on every dashboard that counts runs.

A real system leaves a run row, a last-success timestamp, an exit code and an output file. This page reads all four, and keeps two facts apart: the scheduler fired, and the work completed.

Prerequisites

import { DatabaseSync } from 'node:sqlite';
import { createServer } from 'node:http';
import { writeFileSync, rmSync, mkdirSync } from 'node:fs';

const INTERVAL_MS = 5000;
rmSync('jobs.db', { force: true });
rmSync('out', { recursive: true, force: true });
mkdirSync('out');

const db = new DatabaseSync('jobs.db');
db.exec(`CREATE TABLE job_run (
  id INTEGER PRIMARY KEY, job TEXT NOT NULL, started_at TEXT NOT NULL,
  finished_at TEXT, status TEXT NOT NULL, detail TEXT)`);
const start = db.prepare("INSERT INTO job_run (job, started_at, status) VALUES (?, ?, 'running')");
const finish = db.prepare('UPDATE job_run SET finished_at = ?, status = ?, detail = ? WHERE id = ?');
const now = () => new Date().toISOString().replace('T', ' ').slice(0, 19);

const jobs = {
  'export-csv': () => writeFileSync('out/export.csv', `id,amount\n1,${Date.now()}\n`),
  'sync-prices': () => { throw new Error('ECONNREFUSED 127.0.0.1:5432'); },
  'archive-logs': () => { const pending = 0; if (pending === 0) return; writeFileSync('out/archive.tar', ''); },
};

function runAll() {
  for (const [name, fn] of Object.entries(jobs)) {
    const id = start.run(name, now()).lastInsertRowid;
    try {
      fn();
      finish.run(now(), 'ok', null, id);
    } catch (e) {
      finish.run(now(), 'error', e.message, id);
    }
  }
}
setInterval(runAll, INTERVAL_MS);
runAll();

// The heartbeat a monitor reads: last successful finish per job, and its age.
createServer((req, res) => {
  const rows = db.prepare(`SELECT job, MAX(finished_at) AS last_success FROM job_run
    WHERE status = 'ok' GROUP BY job`).all();
  const body = Object.keys(jobs).map((job) => {
    const r = rows.find((x) => x.job === job);
    const age = r ? Math.round((Date.now() - Date.parse(r.last_success + 'Z')) / 1000) : null;
    return { job, last_success: r ? r.last_success : null, age_seconds: age, interval_seconds: INTERVAL_MS / 1000 };
  });
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify(body) + '\n');
}).listen(8931, '127.0.0.1', () => console.log(`scheduler on 8931, every ${INTERVAL_MS / 1000} s, pid ${process.pid}`));
schtasks /create /tn h2check-sync /tr "cmd /c exit 3" /sc once /st 23:59 /f

Steps

  1. Step 1.

    Start the scheduler and keep the pid it prints.

    node scheduler.mjs
    
    (node:39848) ExperimentalWarning: SQLite is an experimental feature and might change at any time
    (Use `node --trace-warnings ...` to show where the warning was created)
    scheduler on 8931, every 5 s, pid 39848

    The pid stops it in step 9. A 5 second interval makes the page reproducible in two minutes.

  2. Step 2.

    Ask the naive question first.

    sqlite3 -header -column jobs.db "SELECT job, COUNT(*) AS started_recently, MAX(started_at) AS last_start FROM job_run WHERE started_at > datetime('now', '-15 seconds') GROUP BY job;"
    
    job           started_recently  last_start
    ------------  ----------------  -------------------
    archive-logs  3                 2026-09-12 18:06:24
    export-csv    3                 2026-09-12 18:06:24
    sync-prices   3                 2026-09-12 18:06:24

    Three jobs, three starts each. Nothing here is wrong and nothing is useful: it is the answer a green dashboard rests on. Both datetime('now') and started_at are UTC.

  3. Step 3.

    Count successes, not runs.

    sqlite3 -header -column jobs.db "SELECT job, COUNT(*) AS runs, SUM(status = 'ok') AS ok, MAX(CASE WHEN status = 'ok' THEN finished_at END) AS last_success, MAX(CASE WHEN status = 'error' THEN detail END) AS last_error FROM job_run GROUP BY job;"
    
    job           runs  ok  last_success         last_error
    ------------  ----  --  -------------------  ---------------------------
    archive-logs  8     8   2026-09-12 18:06:29
    export-csv    8     8   2026-09-12 18:06:29
    sync-prices   8     0                        ECONNREFUSED 127.0.0.1:5432

    sync-prices ran eight times and succeeded zero times. runs beside ok is the smallest query that tells the two facts apart, and last_error names the dependency that is down.

  4. Step 4.

    Run the query a monitoring rule carries, and watch a job vanish.

    sqlite3 -header -column jobs.db "SELECT job, MAX(finished_at) AS last_success FROM job_run WHERE status = 'ok' GROUP BY job;"
    
    job           last_success
    ------------  -------------------
    archive-logs  2026-09-12 18:06:34
    export-csv    2026-09-12 18:06:34

    Two rows for three jobs. Filtering on success before grouping drops the job that never succeeded, so a staleness alert has nothing to fire against.

  5. Step 5.

    Read the heartbeat the way a monitor does, over HTTP.

    curl -s http://127.0.0.1:8931/jobs
    
    [{"job":"export-csv","last_success":"2026-09-12 18:06:34","age_seconds":5,"interval_seconds":5},{"job":"sync-prices","last_success":null,"age_seconds":null,"interval_seconds":5},{"job":"archive-logs","last_success":"2026-09-12 18:06:34","age_seconds":5,"interval_seconds":5}]

    This endpoint walks the list of jobs that should exist, so sync-prices reports null instead of vanishing. Compare age_seconds against interval_seconds: 5 against 5 is one interval behind, the freshest possible.

  6. Step 6.

    Check the artifact. An exit status is not an output file.

    ls -l --time-style=full-iso out/
    
    total 1
    -rw-r--r-- 1 khark 197121 26 2026-09-12 21:06:39.043345900 +0300 export.csv

    archive-logs reported ok eight times and there is no archive.tar. The file time carries +0300 while the run rows are UTC, so an unconverted comparison calls a fresh file three hours stale.

  7. Step 7.

    Read the same fact from Windows Task Scheduler.

    schtasks /query /tn h2check-sync /fo LIST /v
    
    …
    TaskName:                             \h2check-sync
    Next Run Time:                        12.09.2026 23:59:00
    Status:                               Ready
    …
    Last Run Time:                        12.09.2026 21:03:44
    Last Result:                          3
    …
    Task To Run:                          cmd /c exit 3
    …

    Status: Ready means the task is not running now, not that it worked. Last Run Time is the scheduler firing; Last Result is the exit code of what it fired.

  8. Step 8.

    List every task at once.

    Get-ScheduledTask -TaskName 'h2check-*' | Get-ScheduledTaskInfo | Format-Table TaskName, LastRunTime, LastTaskResult -AutoSize
    
    TaskName        LastRunTime         LastTaskResult
    --------        -----------         --------------
    h2check-missing 12.09.2026 21:04:04     2147942402
    h2check-archive 30.11.1999 0:00:00          267011
    h2check-export  12.09.2026 21:05:05              0
    h2check-sync    12.09.2026 21:03:03              3

    Four states in one table. h2check-missing points at a path that does not exist, so a run is recorded and nothing ran. The two readers also disagree on the clock: 21:03:03 here against 21:03:44 in step 7.

  9. Step 9.

    Stop the scheduler by the pid from step 1, then ask again.

    powershell -Command "Stop-Process -Id 39848 -Force"
    curl -s http://127.0.0.1:8931/jobs; echo "curl exit $?"
    
    curl exit 7

    Exit 7 is a refused connection. A monitor reading only the heartbeat body cannot tell a failing job from an absent scheduler. Stop by pid: a stop by image name takes every node on the machine.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | runs 8, ok 8 and a fresh last_success | The job fired and finished on every interval | Keep the pair in the check. One number cannot say this. | | runs 8, ok 0 with a last_error | The schedule works and the job does not | Fix the dependency in last_error, here a database on 127.0.0.1:5432. | | A job missing from the result entirely | The query filtered on success before grouping | Drive the query from the job list, as step 5 does. A missing row raises no alert. | | status ok and no output file | The job returned early and reported success | Assert on the artifact and its mtime as well as the status. | | Last Result: 267011 with a 1999 date | Windows has never started this task | Check the trigger, not the job. See Why is my cron job not running. | | Last Run Time recent, Last Result non-zero | The scheduler fired and the program failed or was not found | Decode the code. 2147942402 is 0x80070002, file not found. | | curl exit 7 on the heartbeat | Nothing is scheduling at all | Alert on the endpoint being absent, separately from what it reports. |

Common mistakes

Sign: The last-success query returns two rows, the alert stays green, and a third job has not worked all week.Cause: Three jobs were scheduled. WHERE status = 'ok' GROUP BY job returned archive-logs and export-csv, because sync-prices had eight runs and zero successes and so had nothing to group. A missing row is not a late row, and a threshold on age never fires on it. Build the result from the list of jobs that should exist and look each one up.
Sign: A job reports ok on every run and the file it is supposed to write is never there.Cause: archive-logs returned from a guard before writing anything, and the scheduler recorded ok eight times in a row. The output directory held one file, export.csv, written by a different job. An exit status reports that the function returned, not that it did the work. Check the artifact and its mtime as a second gate.
Sign: A check reads Last Run Time, finds a date in the field, and reports the task as having run.Cause: A Windows task created and never started reports Last Run Time 30.11.1999 0:00:00 and Last Result 267011, which is 0x41303, SCHED_S_TASK_HAS_NOT_RUN. The timestamp field is populated, so a null check passes on it and a date parser accepts it. Test the result code first and the timestamp second.
Sign: An assertion on the last result passes under schtasks and fails under PowerShell on the same task.Cause: Both read one value. schtasks /query /fo LIST /v printed Last Result: -2147024894 and Get-ScheduledTaskInfo printed LastTaskResult : 2147942402 for the task whose program was missing. Both are 0x80070002, file not found, one signed and one unsigned. Compare against 0, or convert to hex before comparing.

What to check next

FAQ

How do I test a cron job without waiting for the schedule?

Trigger the entry on demand. schtasks /run /tn h2check-sync started the task here and set Last Result to 3 within four seconds. For a timer, shorten the interval: this run used 5 seconds.

How do I check that a cron job ran successfully, not only that it started?

Compare two numbers from one row. runs 8, ok 0 is a job the scheduler fired eight times that finished none of them. A check counting only starts calls it healthy.

How do I test a scheduled task on Windows?

Create it with schtasks /create, start it with schtasks /run, then read Last Run Time and Last Result with schtasks /query /fo LIST /v. Get-ScheduledTaskInfo reports both fields for several tasks at once.

Where do cron job logs live?

That depends on the daemon and the distribution, and it answers a different question. A log path says what the scheduler did; the run table says what your code did.

Verified

Verified by Maks Vernynode 22.23.2node:sqlite 3.51.3sqlite3 3.50.6curl 8.21.0PowerShell 5.1.22621.6133GNU coreutils 8.32

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny