Scheduled Jobs
The job system gives every Radish app a data model for background tasks. Three builtin entities — ScheduledJob, JobExecution, and JobSnapshot — are always generated with full CRUD services and API routes, the same way idempotency, rate limiting, and observability are. There is no features.jobs flag to turn on.
Radish generates the data layer (entities, services, routes, admin pages). The scheduler and executor — the code that polls for due jobs, invokes handlers, and records outcomes — is app code you write yourself. This separation is intentional: scheduling strategies vary (polling loop, BullMQ, cloud scheduler, cron + HTTP trigger), but the data model is the same regardless.
What Radish Generates
- ScheduledJob entity — defines what runs and when: handler identifier, schedule type and expressions, timezone, timeout, and enabled/disabled state. Full CRUD service and API routes.
- JobExecution entity — records each run with its outcome: start/completion times, duration, status, output or error. Full CRUD service and API routes.
- JobSnapshot entity — captures declared metrics from a run, optionally. Full CRUD service and API routes. See Job Snapshots for the full system.
- SnapshotBuffer utility — validates and buffers metric points for flush-on-success semantics. See Job Snapshots.
- Admin panel pages — job list with enable/disable toggle, detail pages with execution history, create/edit forms.
What You Build
The scheduler/executor is your app code. A recommended implementation pattern follows, but the specifics (polling interval, concurrency, queue backend) are up to you.
Recommended: Handler Registry Pattern
A handler registry maps handler identifiers to async functions. This is the pattern used by Radish production apps and documented throughout this guide:
// app/jobs/registry.ts — YOUR APP CODE, not generated
type JobHandler = {
fn: (context: JobContext) => Promise<any>;
config: { name: string; snapshot?: SnapshotDeclaration };
};
const handlers = new Map<string, JobHandler>();
export function registerJobHandler(
handler: string,
fn: (context: JobContext) => Promise<any>,
config: { name: string; snapshot?: SnapshotDeclaration }
) {
handlers.set(handler, { fn, config });
}
export function getJobHandler(handler: string) {
return handlers.get(handler);
}
// app/jobs/cleanup.ts — YOUR APP CODE
import { registerJobHandler } from './registry';
registerJobHandler('cleanup:expired-tokens', async (context) => {
const deleted = await tokenRepo.deleteExpired();
return { deleted };
}, {
name: 'Clean Expired Tokens',
});
Recommended: Polling Scheduler
A minimal scheduler polls ScheduledJob for due work and invokes the matching handler:
// app/jobs/scheduler.ts — YOUR APP CODE
import { ScheduledJobService, JobExecutionService, systemAuth } from '@generated/datalayer/services';
import { getJobHandler } from './registry';
async function runDueJobs() {
const svc = new ScheduledJobService(systemAuth());
const execSvc = new JobExecutionService(systemAuth());
const due = await svc.list({ enabled: true, nextRunAt: { $lte: new Date().toISOString() } });
for (const job of due.items) {
const handler = getJobHandler(job.handler);
if (!handler) continue;
const execution = await execSvc.create({
jobId: job.id, jobName: job.name, handler: job.handler,
status: 'running', startedAt: new Date().toISOString(), triggeredBy: 'scheduler',
});
try {
const output = await handler.fn({ jobId: job.id, jobName: job.name, triggeredBy: 'scheduler' });
await execSvc.update(execution.id, {
status: 'completed', completedAt: new Date().toISOString(),
output: JSON.stringify(output),
});
await svc.update(job.id, { lastRunAt: new Date().toISOString(), failureCount: 0 });
} catch (err) {
await execSvc.update(execution.id, {
status: 'failed', completedAt: new Date().toISOString(),
error: err.message,
});
await svc.update(job.id, { failureCount: job.failureCount + 1, lastError: err.message });
}
}
}
// Poll every 30 seconds
setInterval(runDueJobs, 30_000);
This is a starting point. Production schedulers typically add timeout enforcement (via AbortController), concurrency guards, and SnapshotBuffer integration (see Job Snapshots).
Recommended: Handler Context
A well-designed context object gives handlers everything they need:
| Property | Type | Description |
|---|---|---|
jobId / jobName | string | Identity of the ScheduledJob that triggered this run |
log(message) | function | Structured log entry linked to this execution |
signal | AbortSignal | Fires on timeout or cancellation — pass to fetch()/long loops to cooperatively abort |
snapshot(dimensions, metrics) | function | Records a metric point; only present when the handler declares a snapshot block (see Job Snapshots) |
triggeredBy | 'scheduler' | 'manual' | How this run was started — lets a handler behave differently for an unattended run vs. one a person is watching |
// Example handler using the recommended context shape — YOUR APP CODE
registerJobHandler('reports:daily-digest', async (context) => {
context.log('Starting digest generation');
const data = await fetchDigestData({ signal: context.signal });
if (context.triggeredBy === 'manual') {
context.log('Manual trigger — skipping recipient throttle');
}
return { sent: data.length };
}, {
name: 'Daily Digest',
});
Recommended: Execution Lifecycle
The recommended scheduler follows this lifecycle when executing a job:
- Check for jobs where
enabledis true andnextRunAt <= now. - Create a
JobExecutionwithstatus: 'running'andstartedAt: now. - Invoke the handler with the context object.
- On success — status becomes
completed;durationandoutputare recorded;ScheduledJob.lastRunAt/nextRunAtare updated;failureCountresets to0. - On failure — status becomes
failed;erroranddurationare recorded;ScheduledJob.failureCountincrements;lastErroris updated. - On timeout — status becomes
cancelled; a timeout error is recorded as if the handler had thrown.
If a handler declares a snapshot block, buffered snapshot points are flushed only on the success path (step 4) and discarded on failure or timeout (steps 5-6) — see Job Snapshots: Buffering and Partial Runs.
Schedule Types
ScheduledJob.scheduleType is either cron or interval, and schedules holds one or more expressions of that type.
Cron — standard cron expressions:
'0 6 * * *' — daily at 6 AM
'*/15 * * * *' — every 15 minutes
'0 0 * * 1' — Monday midnight
Interval — fixed duration between runs:
'5m' — every 5 minutes
'1h' — every hour
'30s' — every 30 seconds
Multiple entries in schedules are supported for jobs that need to run at more than one distinct time (e.g., separate cron expressions for weekdays vs. weekends).
Timezone
Jobs default to UTC. Set timezone to align cron expressions with a business schedule rather than converting the expression by hand. The timezone is also what job snapshots use to derive their bucket string — see Job Snapshots: bucket.
Timeout
timeout defaults to 300,000ms (5 minutes). Override for handlers that legitimately run long. Your scheduler implementation is responsible for enforcing the timeout (e.g., via AbortController with setTimeout).
Monitoring
Via API
Both entities are standard generated resources with full CRUD and the usual query parameters (filters, pagination, sort):
GET /api/v1/scheduledJobs — list all jobs
GET /api/v1/scheduledJobs/:id — job detail
GET /api/v1/jobExecutions?jobId=<id> — execution history for a job
GET /api/v1/jobExecutions?status=failed — recent failures across all jobs
ScheduledJob filters on name, handler, enabled, scheduleType. JobExecution filters on jobId, status, triggeredBy.
Via Admin Panel
The admin panel generates a dedicated Jobs section:
- List of all scheduled jobs with an enable/disable toggle
- Run Now — manually trigger a job outside its schedule
- Create form (name, handler, schedule type, cron/interval expression, timezone, timeout)
- Detail page per job, showing:
- Job configuration
- Recent executions with status, duration, output, and errors
- Snapshot data, if the handler declares a
snapshotblock
- Edit and delete actions
Manually triggering a run from the admin panel sets JobExecution.triggeredBy to 'manual' and records triggeredByUserId from the acting admin — separate from scheduled runs, which record 'scheduler'.
Failure Tracking
failureCount on ScheduledJob tracks consecutive failures — it resets to 0 on the next success, so it reflects the current failing streak rather than a lifetime total. Combine it with lastStatus and lastError for alerting:
failureCount > 0— the job has failed at least once since its last successlastStatus— the outcome of the most recent runlastError— the error message from that run, if it failed
Because ScheduledJob, JobExecution, and (when declared) JobSnapshot are generated services like any other entity, they support the same four-layer access control, filtering, and pagination as the rest of the data layer — nothing job-specific to learn on the query side.
Entity Reference
ScheduledJob Fields
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Job name (required, searchable) |
description | string | — | Optional human-readable description |
handler | string | — | Handler identifier passed to registerJobHandler() (required) |
scheduleType | enum | — | cron or interval (required) |
schedules | string[] | — | Cron expressions or interval values (required) |
timezone | string | UTC | Timezone used for cron evaluation and snapshot bucketing |
enabled | boolean | true | Whether the scheduler will run this job |
timeout | int | 300000 | Max run time in ms before the execution is cancelled |
lastRunAt | isoDate | — | Timestamp of the most recent run |
nextRunAt | isoDate | — | Timestamp of the next scheduled run |
lastStatus | enum | — | Outcome of the most recent run: running, completed, failed, cancelled |
failureCount | int | 0 | Consecutive failures since the last success |
lastError | string | — | Error message from the most recent failure |
metadata | any | — | Free-form data for handler-specific configuration |
Indexes: (enabled, nextRunAt) — the scheduler's polling query; (handler). Ownership: system.
JobExecution Fields
| Field | Type | Default | Description |
|---|---|---|---|
jobId | objectId → ScheduledJob | — | The job that produced this run (required) |
jobName | string | — | Job name at the time of execution, denormalized (required) |
handler | string | — | Handler identifier, denormalized (required) |
status | enum | running | running, completed, failed, cancelled |
startedAt | isoDate | — | When the run began (required) |
completedAt | isoDate | — | When the run ended |
duration | int | — | Run length in ms |
output | string | — | Handler return value, stringified |
error | string | — | Error message, if failed or cancelled |
triggeredBy | enum | scheduler | scheduler or manual |
triggeredByUserId | objectId → User | — | Admin who triggered a manual run |
Indexes: (jobId, startedAt), (status, startedAt). Ownership: system.
jobName and handler are denormalized onto JobExecution deliberately — execution history stays readable and queryable even if the parent ScheduledJob is renamed or deleted.
Related Documentation
- Job Snapshots — Declared metrics from scheduled jobs
- Builtin Entities — Overview of all system entities
- Telemetry — Observability infrastructure
- Services — Service layer architecture