Managing jobs from code

sdk.crons creates, reads and triggers scheduled jobs from a handler, and can never give one power.

sdk.crons is the handler-facing door to scheduled jobs. Reach for it when a schedule belongs to your data rather than to your configuration: giving each new customer their own digest job, or reporting which jobs are failing.

Two grants

CapabilityMethodsDefault
sdk:crons_readlist, get, listRuns, getRun, listRoles, previewgranted
sdk:crons_writecreate, update, pause, resume, remove, runNow (implies sdk:crons_read)deny, high risk

sdk:crons_write is high risk, not medium, and the reason is easy to miss. Creating a job is harmless: a new job holds no roles and can do nothing. Changing one is not. An existing job may already be armed with roles a person deliberately bound, and its handler decides what code runs under them. A handler that could repoint someone else's job would run arbitrary code with roles its own author was never given. runNow is here for the same reason: it fires a job as that job's principal, not as the caller.

Binding roles is deliberately not in the SDK. Arming a job stays in the console and the governed API (crons:assign_cron_role), so handler code can create a schedule but can never grant it power. That is what keeps a handler from minting an unattended principal more privileged than itself.

What a caller sees

Inside the app, a caller lists and manages only its own organization's jobs and runs. The app's owner, reaching the app from outside (the console, the owner's agents), sees every organization's, and each job and run names its organization (orgUid, orgName; null for the owner's own). A job created inside the app belongs to the caller's organization.

Create a job

export default async function onboardCustomer(data, ctx) {
  const { sdk } = ctx;

  // Check the schedule first: real fire times, in the customer's timezone.
  const nextRuns = await sdk.crons.preview({
    scheduleKind: "cron",
    cronExpression: "0 7 * * mon-fri",
    timezone: data.timezone,
    count: 3,
  });

  // Created PAUSED, holding no roles: it can do nothing until someone arms it.
  const job = await sdk.crons.create({
    name: `Morning digest: ${data.customerName}`,
    targetHandlerUid: data.digestHandlerUid, // the handler's uid; it runs the published version
    payload: { customerUid: data.customerUid },
    scheduleKind: "cron",
    cronExpression: "0 7 * * mon-fri",
    timezone: data.timezone,
    timeoutSeconds: 120,
  });

  return { jobUid: job.uid, status: job.status, nextRuns };
}

A schedule is either { scheduleKind: "interval", intervalSeconds } (at least 60) or { scheduleKind: "cron", cronExpression }, with a timezone. The same validator serves the console, the API and the SDK, so a schedule cannot be accepted by one and refused by another. See Schedules.

Read jobs and runs

// Which jobs failed recently, and what they were allowed to do when they did.
const failures = await sdk.crons.listRuns({ status: "failed", limit: 20 });

return failures.map((run) => ({
  job: run.cronName,
  when: run.startedAt,
  why: run.errorText,
  couldDo: run.principalRoles, // the roles held AT THE TIME it ran
}));
  • list({ status, limit, offset }) returns jobs, newest first. A job's firing is its unfinished occurrence, if any (running, retrying or stopped, with its turns and counts).
  • listRuns({ cronUid, status, limit, offset }) returns runs, newest first; omit cronUid for every job you can see. getRun(runUid) adds the result and the failed rows.
  • listRoles(cronUid) returns the roles bound to the job's principal: exactly what it may do.

Change, pause and trigger

MethodDoes
update(cronUid, patch)Changes name, description, handler, payload, timeout or schedule. The next run time is recomputed only when the schedule changes or the job is activated, so a rename never pushes a job's next fire.
resume(cronUid)Activates the job: it starts firing on its schedule.
pause(cronUid)Holds the job, keeping its schedule and history.
remove(cronUid)Deletes the job and retires its principal, removing its role bindings. Run history is kept.
runNow(cronUid)Fires the job now, for real, as its own principal. Carries on an unfinished firing instead of starting over.

Two meanings of 'resume'

sdk.crons.resume activates a paused job. The console's Resume button is something else: it carries on an unfinished firing, and in code that is sdk.crons.runNow.

runNow answers { status, resumed, continues, run }. resumed is true when it carried on an unfinished firing; continues is "background" when work remains on an active job, "resume" when a paused job stopped with work left, and null when the firing finished. It is refused with CRON_BUSY while a turn of the job is running.

Errors

CodeWhen
SDK_CAP_NOT_GRANTED (403)At save: the code uses a write method and you lack sdk:crons_write.
SDK_CAP_NOT_IN_MANIFEST (403)At run time: the published version was not frozen with the capability it needs.
CRON_BUSY (409)runNow while a turn of that job is running.
404An unknown or malformed job or run uid, or one in another organization.

Signatures and every field of CronJob and CronRun are in the Handler SDK reference.

Next

On this page