Skip to main content

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.

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',
});

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).

A well-designed context object gives handlers everything they need:

PropertyTypeDescription
jobId / jobNamestringIdentity of the ScheduledJob that triggered this run
log(message)functionStructured log entry linked to this execution
signalAbortSignalFires on timeout or cancellation — pass to fetch()/long loops to cooperatively abort
snapshot(dimensions, metrics)functionRecords 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',
});

The recommended scheduler follows this lifecycle when executing a job:

  1. Check for jobs where enabled is true and nextRunAt <= now.
  2. Create a JobExecution with status: 'running' and startedAt: now.
  3. Invoke the handler with the context object.
  4. On success — status becomes completed; duration and output are recorded; ScheduledJob.lastRunAt/nextRunAt are updated; failureCount resets to 0.
  5. On failure — status becomes failed; error and duration are recorded; ScheduledJob.failureCount increments; lastError is updated.
  6. 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 snapshot block
  • 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 success
  • lastStatus — the outcome of the most recent run
  • lastError — 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​

FieldTypeDefaultDescription
namestring—Job name (required, searchable)
descriptionstring—Optional human-readable description
handlerstring—Handler identifier passed to registerJobHandler() (required)
scheduleTypeenum—cron or interval (required)
schedulesstring[]—Cron expressions or interval values (required)
timezonestringUTCTimezone used for cron evaluation and snapshot bucketing
enabledbooleantrueWhether the scheduler will run this job
timeoutint300000Max run time in ms before the execution is cancelled
lastRunAtisoDate—Timestamp of the most recent run
nextRunAtisoDate—Timestamp of the next scheduled run
lastStatusenum—Outcome of the most recent run: running, completed, failed, cancelled
failureCountint0Consecutive failures since the last success
lastErrorstring—Error message from the most recent failure
metadataany—Free-form data for handler-specific configuration

Indexes: (enabled, nextRunAt) — the scheduler's polling query; (handler). Ownership: system.

JobExecution Fields​

FieldTypeDefaultDescription
jobIdobjectId → ScheduledJob—The job that produced this run (required)
jobNamestring—Job name at the time of execution, denormalized (required)
handlerstring—Handler identifier, denormalized (required)
statusenumrunningrunning, completed, failed, cancelled
startedAtisoDate—When the run began (required)
completedAtisoDate—When the run ended
durationint—Run length in ms
outputstring—Handler return value, stringified
errorstring—Error message, if failed or cancelled
triggeredByenumschedulerscheduler or manual
triggeredByUserIdobjectId → 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.