| name | add-cron-job |
| description | Scaffold a new cron job with in-process guard, env-configurable schedule, SyncStatus DB locking, and job registry wiring. |
Add Cron Job
Scaffold a new cron job following the project's established patterns: in-process guard, configurable schedule, optional SyncStatus distributed locking, and registration in the job registry.
Arguments
$0 - Job name in kebab-case (e.g., cleanup-stale-votes, sync-delegations)
$1 - Default cron schedule (e.g., 0 */12 * * * for every 12 hours, */30 * * * * for every 30 min)
$2 - Short description (e.g., "Remove stale vote records older than 30 days")
Instructions
Step 1: Create the job file
Create src/jobs/{$0}.job.ts:
import cron from "node-cron";
import { prisma } from "../services";
let isRunning = false;
export const start{PascalCaseName}Job = () => {
const schedule = process.env.{ENV_VAR_NAME} || "{$1}";
const enabled = process.env.ENABLE_CRON_JOBS !== "false";
if (!enabled) {
console.log("[Cron] {Display name} job disabled via ENABLE_CRON_JOBS env variable");
return;
}
if (!cron.validate(schedule)) {
console.error(`[Cron] Invalid cron schedule for {$0}: ${schedule}`);
return;
}
cron.schedule(schedule, async () => {
if (isRunning) {
console.log(`[${new Date().toISOString()}] {Display name} job still running, skipping`);
return;
}
isRunning = true;
const timestamp = new Date().toISOString();
console.log(`\n[${timestamp}] Starting {display name} job...`);
try {
console.log(`[${timestamp}] {Display name} job completed successfully`);
} catch (error: any) {
console.error(`[${timestamp}] {Display name} job failed:`, error.message);
} finally {
isRunning = false;
}
});
console.log(`[Cron] {Display name} job scheduled: ${schedule}`);
};
Naming conventions:
- File:
{$0}.job.ts (kebab-case)
- Export:
start{PascalCase}Job (e.g., startCleanupStaleVotesJob)
- Env var:
{SCREAMING_SNAKE}_SCHEDULE (e.g., CLEANUP_STALE_VOTES_SCHEDULE)
Step 2: Register in job index
Add to src/jobs/index.ts:
import { start{PascalCaseName}Job } from "./{$0}.job";
export const startAllJobs = () => {
console.log("[Cron] Initializing all cron jobs...");
startProposalSyncJob();
startVoterPowerSyncJob();
start{PascalCaseName}Job();
console.log("[Cron] All cron jobs initialized");
};
Step 3: (Optional) Add SyncStatus distributed locking
For jobs running in GCP Cloud Run (multiple instances), add DB-level locking via the SyncStatus model to prevent concurrent execution:
import cron from "node-cron";
import { prisma } from "../services";
import { randomUUID } from "crypto";
let isRunning = false;
const JOB_NAME = "{$0}";
const LOCK_TIMEOUT_MINUTES = 30;
async function acquireLock(instanceId: string): Promise<boolean> {
try {
const result = await prisma.syncStatus.upsert({
where: { jobName: JOB_NAME },
create: {
jobName: JOB_NAME,
displayName: "{Display Name}",
isRunning: true,
startedAt: new Date(),
lockedBy: instanceId,
expiresAt: new Date(Date.now() + LOCK_TIMEOUT_MINUTES * 60 * 1000),
},
update: {
isRunning: true,
startedAt: new Date(),
lockedBy: instanceId,
expiresAt: new Date(Date.now() + LOCK_TIMEOUT_MINUTES * 60 * 1000),
},
});
return result.lockedBy === instanceId;
} catch {
return false;
}
}
async function releaseLock(
result: "success" | "failed",
itemsProcessed?: number,
errorMessage?: string
) {
await prisma.syncStatus.update({
where: { jobName: JOB_NAME },
data: {
isRunning: false,
completedAt: new Date(),
lastResult: result,
itemsProcessed,
errorMessage,
lockedBy: null,
expiresAt: null,
},
});
}
export const start{PascalCaseName}Job = () => {
const schedule = process.env.{ENV_VAR_NAME} || "{$1}";
if (process.env.ENABLE_CRON_JOBS === "false") return;
cron.schedule(schedule, async () => {
if (isRunning) return;
isRunning = true;
const instanceId = randomUUID();
try {
const locked = await acquireLock(instanceId);
if (!locked) {
console.log(`[${JOB_NAME}] Another instance holds the lock, skipping`);
return;
}
await releaseLock("success", 0);
} catch (error: any) {
console.error(`[${JOB_NAME}] Failed:`, error.message);
await releaseLock("failed", undefined, error.message).catch(() => {});
} finally {
isRunning = false;
}
});
console.log(`[Cron] ${JOB_NAME} scheduled: ${schedule}`);
};
Step 4: (Optional) Add manual trigger endpoint
If the job should be triggerable via API, create a controller:
Create src/controllers/data/trigger{PascalCase}.ts:
import { Request, Response } from "express";
export const postTrigger{PascalCase} = async (_req: Request, res: Response) => {
try {
res.json({ success: true, message: "{Display name} completed" });
} catch (error: any) {
console.error("Manual {$0} trigger failed:", error.message);
res.status(500).json({
error: "Trigger failed",
message: error.message,
});
}
};
Then add to src/routes/data.route.ts:
import { postTrigger{PascalCase} } from "../controllers/data/trigger{PascalCase}";
router.post("/trigger-{$0}", postTrigger{PascalCase});
Common Cron Schedules
| Schedule | Expression | Use Case |
|---|
| Every 5 minutes | */5 * * * * | High-frequency data sync |
| Every 30 minutes | */30 * * * * | Medium-frequency updates |
| Every hour | 0 * * * * | Hourly aggregation |
| Every 6 hours | 0 */6 * * * | Low-frequency sync |
| Every 12 hours | 0 */12 * * * | Twice daily |
| Daily at midnight | 0 0 * * * | Daily cleanup/reports |
| Offset from other jobs | 30 */6 * * * | Avoid overlapping with other jobs (minute 30) |
Checklist