type AsyncTask = () => Promise<void>;
export interface GuardOptions {
name: string;
logger?: Pick<Console, "info" | "warn" | "error">;
}
export function withRunGuard(task: AsyncTask, opts: GuardOptions): AsyncTask {
const log = opts.logger ?? console;
let running = false;
return async function guardedRun(): Promise<void> {
if (running) {
log.warn(`[cron:${opts.name}] previous run still active, skipping tick`);
return;
}
running = true;
const startedAt = Date.now();
try {
await task();
log.info(`[cron:${opts.name}] finished in ${Date.now() - startedAt}ms`);
} catch (err) {
log.error(`[cron:${opts.name}] run failed`, err);
} finally {
running = false;
}
};
}
import type { Redis } from "ioredis";
import { randomUUID } from "crypto";
const RELEASE_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`;
export class RedisLock {
private token: string | null = null;
constructor(
private readonly redis: Redis,
private readonly key: string,
private readonly ttlMs: number
) {}
async acquire(): Promise<boolean> {
const token = randomUUID();
const res = await this.redis.set(this.key, token, "PX", this.ttlMs, "NX");
if (res === "OK") {
this.token = token;
return true;
}
return false;
}
async release(): Promise<void> {
if (!this.token) return;
await this.redis.eval(RELEASE_SCRIPT, 1, this.key, this.token);
this.token = null;
}
}
import cron, { ScheduledTask } from "node-cron";
import type { Redis } from "ioredis";
import { withRunGuard } from "./job-guard";
import { RedisLock } from "./distributed-lock";
import { runNightlyCleanup } from "./tasks/nightly-cleanup";
export function startNightlyCleanup(redis: Redis): ScheduledTask {
const expression = "0 3 * * *";
if (!cron.validate(expression)) {
throw new Error(`invalid cron expression: ${expression}`);
}
const guarded = withRunGuard(async () => {
const lock = new RedisLock(redis, "lock:nightly-cleanup", 10 * 60 * 1000);
if (!(await lock.acquire())) {
console.info("[cron:nightly-cleanup] lock held elsewhere, skipping");
return;
}
try {
await runNightlyCleanup();
} finally {
await lock.release();
}
}, { name: "nightly-cleanup" });
return cron.schedule(expression, guarded, {
scheduled: true,
timezone: "America/New_York",
});
}
This snippet shows a common production problem with node-cron: a scheduled task can fire again before the previous run has finished, causing overlapping executions that double-process data or hammer a database. The fix is a guard that skips a tick when a previous run is still in flight, plus an optional cross-process lock for horizontally scaled deployments.
In job-guard.ts, withRunGuard wraps a task function and tracks in-process state with a plain boolean running. If a new tick arrives while running is true, the guard logs a skip and returns instead of launching a concurrent run. This closure-based approach is deliberately simple: a single Node process only needs an in-memory flag to serialize a recurring task. The try/finally block is the crucial detail — running is reset in finally so a thrown error never leaves the guard permanently stuck in the locked state, which would silently kill the schedule.
Because an in-memory flag only protects one process, distributed-lock.ts adds RedisLock for when the same cron runs on several instances. acquire uses Redis SET with NX (set only if absent) and PX (millisecond TTL) in one atomic call, so exactly one process wins the tick. The TTL matters: if a worker crashes mid-run the key expires on its own, preventing a permanently held lock. release runs a small Lua script that compares the stored token before deleting, so a slow worker whose lock already expired cannot delete a lock a different worker now holds.
In scheduler.ts, cron.schedule registers the expression and wires both guards together. Passing timezone makes 0 3 * * * mean 3am in a real zone rather than server-local time, a frequent source of off-by-hours bugs. validate rejects a malformed expression at startup instead of failing silently. The handler first checks the in-process guard, then the Redis lock, and always releases the lock in finally.
The trade-off is that skipped ticks are dropped, not queued — appropriate for idempotent maintenance work like cache warming or cleanup, but a real queue is better when every tick must eventually run. This pattern is the right reach when a periodic job's runtime is unpredictable and occasionally exceeds its interval.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.