ObjectStackObjectStack

Scheduled jobs — cron automation metadata

Run a sandboxed body, a connector pull, or a TypeScript function on a cron, interval, or one-off schedule — and decide when a job is the right tool instead of a schedule-triggered flow.

A job runs work on a schedule. You declare the schedule as metadata; the platform's job service owns the timing, the retries, the per-attempt time limit, and the run history. The work is a sandboxed body that travels with the metadata — the preferred form for code — a pull of a mapping's connector source, which is no code at all, or, deprecated, the name of a function in your bundle (handler). A job runs as the organization it declares.

import { defineJob } from '@objectstack/spec';

export const HealthSweepJob = defineJob({
  name: 'nightly_health_sweep',
  label: 'Nightly Project Health Sweep',
  description: 'Recomputes project health from budget burn and task progress.',
  schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' },
  handler: 'sweepProjectHealth',
  retryPolicy: { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 },
  timeoutMs: 300000,
});

Job, or a schedule-type flow?

Both run on a timer, so pick deliberately. The decision is not about timing and not about cluster behaviour — those are the same for both, because a schedule-type flow does not own a timer at all. The automation engine registers each schedule-triggered flow as a job named flow-schedule:<flowName> and hands it to the same IJobService. Same schedule forms, same adapter, same leader election.

What actually differs is what runs, who may change it, and what identity its writes carry:

jobschedule-type flow
What runsa sandboxed body, a mapping pull, or (deprecated) one TypeScript function from defineStack({ functions })a node graph — record operations, notify, http, approvals, subflows
Changeable after deployNo. job is allowRuntimeCreate: false and allowOrgOverride: false — there is no "create job" in Studio and no per-tenant forkYes — a new flow can be authored through Studio / PUT /meta (allowRuntimeCreate: true)
Identity of its data writessystem, in the job's declared organizationdeclared by runAs — and a user run that resolves no trigger user has its data operations refused, so a scheduled flow normally declares runAs: 'system'
Retry / time limitretryPolicy + timeoutMs on the job, honoured by the job adapterthe flow's own error handling
Run historysys_job + sys_job_runsys_automation_run

Rule of thumb: if the work is a function you ship and version with your code, declare a job. If the work is a sequence of record operations that an administrator may reasonably need to re-sequence without a deploy, build a schedule-triggered flow.

The "no runtime create" restriction is a consequence, not a policy preference: handler names a key in the compiled bundle's function table, so a job created through the runtime API could only ever name a function that the writer's process does not have. Both doors were closed rather than left to fail silently at boot.

Where a job lives

Two authoring doors, both first-class:

  • a *.job.ts (or *.job.yml / *.job.json) file anywhere in the package, or
  • an entry in the jobs collection of defineStack().

The handler is wired separately, by name, through functions:

export default defineStack({
  // …
  functions: {
    // the key here is what `handler` names
    sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' },
  },
  jobs: [HealthSweepJob],
});

name is snake_case and is the job's identity everywhere — the scheduling key, the sys_job row key, and the jobId stamped on each execution. There is no separate id key: it was removed in @objectstack/spec 17.0.0 because nothing read it, and two jobs differing only in id were one job declared twice.

Does this deployment run packaged jobs at all?

A job declared by a package — every defineJob reaching the runtime through defineStack({ jobs }) or a package bundle — is scheduled only on a deployment that has switched package-authored scheduled work on:

OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true

It is off by default in every tenancy posture and every kernel, and it is the same switch that gates time-triggered flows — one deployment decision for all package-authored scheduled work, because the resource risk is the same and a second switch would be a special case. Whether a clock-driven workload is affordable is a fact about the deployment, not about the job, so it is a deployment variable rather than metadata.

While it is off, the boot says so once per app, at info, with the count of jobs it did not schedule; os doctor prints the effective value.

⛔ Platform-internal scheduled work is not gated by this switch and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is authored by a package, not runs on the job service — the platform's own maintenance is part of the runtime a deployment asked for.

Schedule forms

schedule is a discriminated union on type. Three forms, and the schema accepts exactly these:

{ type: 'cron', expression: '0 0 * * *', timezone: 'America/New_York' }
{ type: 'interval', intervalMs: 900000 }
{ type: 'once', at: '2026-09-01T02:00:00.000Z' }
  • cron — a standard cron expression. timezone is an IANA name and defaults to UTC. You write the expression as a plain string; the build lowers it into the platform's expression envelope, and the cron adapter hands the source string to the cron engine.
  • interval — intervalMs is a positive integer in milliseconds. A fixed delay between fires, not an aligned wall-clock schedule.
  • once — at is an ISO 8601 datetime. A once job whose time has already passed when it is registered simply never fires.

Cron needs a cron-capable adapter. The default (adapter: 'auto') selects the durable database-backed adapter when an ObjectQL engine is available and routes cron schedules to the cron adapter. On a deployment pinned to the in-memory interval adapter, a cron schedule is registered but never executed — the adapter says so at warn level on registration, because that is the difference between "no cron engine here" and a job that silently never runs.

The job body

A job's body is the same sandboxed JavaScript body hooks and script actions carry — { language: 'js', source, capabilities } — so the work travels with the metadata instead of living in a runtime module only some boots import. When a job declares both, body wins; handler is deprecated beside it. A job must declare one of body, handler or pull, and pull is refused beside either of the other two.

import { defineJob } from '@objectstack/spec';

export const CloseStaleTasksJob = defineJob({
  name: 'close_stale_tasks',
  schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' },
  body: {
    language: 'js',
    source: `
      const stale = await ctx.api.object('task').find({ where: { status: 'open' }, limit: 200 });
      for (const t of stale) await ctx.api.object('task').update({ id: t.id, status: 'closed' });
      ctx.log.info('closed stale tasks', { count: stale.length });
    `,
    capabilities: ['api.read', 'api.write', 'log'],
  },
  handler: 'closeStaleTasks', // deprecated and optional — `body` wins when both are present
  timeoutMs: 120000,
});

Every door runs a job body. The boot (a config, or os start --artifact) and os package install (on install, and again on every restart) schedule a job's body through the same binder. A handler is code: it travels only in the artifact's runtime module, so it runs only on a boot that loads that module (a config, or os start --artifact). os package install therefore refuses a package whose enabled job has no body, or a body that does not bind (an expression body, or one carrying body.timeoutMs), with 422 VALIDATION_ERROR and the remedy: give the job a valid body, or boot it with os start --artifact. A pull job is data too: it installs when its pull binds, and is refused with the same 422 when it does not. Uninstalling a package stops its scheduled jobs at once, and a reinstall whose new version drops a job stops that job.

What running in the sandbox means for the code in source:

  • No module scope. source is the function body only: no import or require, and no helper, constant or variable from the surrounding file — the body is evaluated on its own, with the standard JavaScript globals and ctx.
  • Data only under declared capabilities. The body reaches records through ctx.api.object(…), and only with the tokens it declares: api.read, api.write, api.transaction. Logging is ctx.log, under log. An undeclared call throws at run time. Outbound HTTP is not available; use a Connector.
  • Not the handler's context. The JobHandlerContext a handler receives — ql, logger, bundle — does not exist inside the sandbox. A handler is not turned into a body by copying its source: rewrite its ql.find(…) reads as ctx.api.object(…).find(…) and its logger calls as ctx.log.
  • Only the JavaScript body. The expression (L1) body hooks accept is refused on a job: an expression performs no I/O, so its only effect would be a returned value, and a job runs for its effects.

objectstack build does not write a job's body for you: write it as data. The build cannot read the source of a function a handler names — defineStack parses functions into wrappers — and a handler written against JobHandlerContext would not run in the sandbox as it stands.

Time limit and long-running work

A body job has one time limit, the job's timeoutMs. One attempt is one sandbox run, and the runtime bounds that run by timeoutMs; the timeoutMs a hook or action body can carry inside body is refused on a job, so the limit is never written twice. Unlike that body-level key, which is capped at 30 seconds, the job-level timeoutMs has no cap. Unlike a handler attempt, a sandbox run over its limit is stopped, not merely abandoned.

So long-running work either declares a timeoutMs that covers it, or — usually better — splits into bounded runs: process a page of records per run (a limit on the read, as above) and let the schedule bring the next run, so each attempt finishes well inside its limit and a retry repeats one page rather than the whole sweep. Omit timeoutMs and a body run is still bounded by the sandbox's own default invocation limits, which are far shorter than a long sweep needs. A body also runs under the sandbox's per-run memory cap (body.memoryMb, at most 256), which is one more reason to page rather than load everything at once.

Pulling a mapping

A job whose work is "copy the records an external system holds into a local object" needs no code. Declare the sync on its target — a mapping with a connectorSource naming the rest or openapi connector it reads from — and give the job a pull naming that mapping. The mapping says where the rows come from, how fields map, and how a pulled row matches a stored one; the job says when.

import { defineJob } from '@objectstack/spec';

export const OrdersPullJob = defineJob({
  name: 'orders_pull_hourly',
  schedule: { type: 'cron', expression: '0 * * * *', timezone: 'UTC' },
  pull: { mapping: 'orders_pull' },
  retryPolicy: { maxRetries: 2, backoffMs: 60000 },
});
  • A run form of its own. pull is refused beside body or handler: the platform binds the pull itself, so code beside it would never run. A body cannot call a pull; a pull on a schedule is this declaration.
  • Checked when you build. defineStack — and so os validate — refuses a pull that names a mapping the stack does not declare, or one with no connectorSource. The binder checks the same reference against the artifact before it schedules the job, on every door, and os package install refuses a package whose enabled job's pull fails that check, with 422 VALIDATION_ERROR naming the job and the reason. A package an earlier version installed still loads after a restart; such a job of it is not scheduled, and a warning names it.
  • Each run is one pull. It calls the connector's read action once, reads one response, and writes the records through the import runner with the mapping's mode and upsertKey — see Data sync is defined on the target for the watermark and the one-response limit.
  • How a run is recorded. A pull the platform refuses — the mapping is gone, the connector is degraded, the upstream answered ok: false — rejects, so the run is failed and the retryPolicy applies. A pull whose rows the import runner refused completes as degraded, with the counts as its reason; retrying would refuse the same rows. Otherwise the run is success, including a pull that found nothing new.

The organization a job runs as

A scheduled run has no session to inherit an organization from. A job declares the one it runs in:

import { defineJob } from '@objectstack/spec';

export const PlantSweepJob = defineJob({
  name: 'plant_a_nightly_sweep',
  schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' },
  organization: 'org_plant_a',
  body: {
    language: 'js',
    source: "await ctx.api.object('task').find({ where: { status: 'open' }, limit: 100 });",
    capabilities: ['api.read'],
  },
});

Every run form runs as it: a body's ctx.api, a pull's reads and writes, and the executionContext a handler is handed are all system access carrying that organization, so a tenant-scoped row the job writes is stamped with it. Whether the key is required is the same deployment-posture rule time-triggered flows use, read when the job is scheduled:

Tenancy posture (with packaged scheduled work switched on)organizationA job that declares none
singlenot requiredruns with none; the install's one organization is resolved beneath each write
groupoptionalis scheduled, and a tenant-scoped row it writes is refused — the boot names such jobs once, at warn
isolatedrequiredis not scheduled, logged at error with the remedy

No organization is ever chosen for a job that declares none. Work wanted in several organizations is one job per organization.

The handler, and the ways it can fail to be one

handler must match a key of defineStack({ functions }). At kernel:ready the app plugin resolves each job's handler through the bundle's function table and calls IJobService.schedule(...) with it. Three outcomes at that moment, and they are deliberately not the same severity:

SituationWhat happensLog level
enabled: falsenot scheduleddebug
handler names nothing in the function tablenot scheduled — the job never runswarn
schedule() throwsnot scheduled — a silent outage; the app boots green while the work never runserror, plus a failure counter

The middle case is the one that has actually bitten this repo: a job declared for a long time with no function of that name anywhere in the app was skipped at every boot, and the sweep never ran. If a job appears to do nothing, read the boot log for its name before reading its schedule.

What the handler is given

At run time the handler is invoked with a JobHandlerContext (@objectstack/runtime):

MemberWhat it is
jobIdthe job's name — its identity everywhere
datathe payload of a manual trigger(name, data) run; absent on a scheduled run
bundlethe application's metadata bundle — declarations, not a data handle
qlthe live ObjectQL engine — the same handle defineStack({ onEnable }) receives
loggerthe platform logger, so a job's diagnostics are not console output
executionContextthe context the job runs as — { isSystem: true, tenantId } for a job that declares an organization, else { isSystem: true }. ql is the raw engine, so pass it as each call's context to write as that organization
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
  const stale = await ql.find('project', { where: { status: 'active' } });
  for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
  logger.info('sweep complete', { job: jobId, scanned: stale.length });
}

ql is why a job does not follow the pure-function rule a flow script node follows. A script node returns a value and the flow graph does the I/O around it — a get_record before, a create_record after — so its context deliberately carries no engine. A job has no graph: no node before it, none after. Writing records on a timer is the thing scheduled work exists for, so the handler is handed the engine directly. A job's writes are still not counted by any caller, so declare its functions entry effect: 'writes' — that makes the run report "cannot say" rather than silently claiming it wrote nothing.

Do not reach the engine by having onEnable assign a module-scope global the handler reads later. That binding does not survive a built artifact: objectstack build emits your functions into a sibling runtime module, the artifact JSON carries no onEnable, and only functions is merged back on load — so on an artifact-served boot the global is never assigned and the job runs against nothing, silently. Take ql from the context.

What the handler returns decides how the run is recorded:

The handler…Recorded asRetried?
throws / rejectsfailed (or timeout)yes, per retryPolicy
resolves undefined or { outcome: 'completed' }success—
resolves { outcome: 'degraded', reason? }degradedno

degraded means "ran to completion, and its work did not happen" — a store was unavailable, zero rows matched a precondition. It is not a failure: it never retries and it does not bump the job's failure_count. A handler that wants the run retried must throw.

Retry and time limit

{ maxRetries: 3, backoffMs: 5000, backoffMultiplier: 2, maxRetryDelayMs: 30000, jitter: true }

Delay before retry n is min(backoffMs * backoffMultiplier^(n-1), maxRetryDelayMs), optionally jittered. maxRetries counts retries after the initial attempt and is capped at 10.

Two defaults worth knowing before you rely on the block:

  • maxRetries defaults to 0 — declaring retryPolicy without stating a count still means no retry. State a count to opt in.
  • backoffMultiplier defaults to 1 — a flat delay, not exponential.

Those defaults are why the block is closed: a key it does not declare — maxRetry for maxRetries, a maxDelayMs borrowed from another retry vocabulary — is refused at objectstack validate with the declared key it was near, rather than dropped and replaced by a default that never retries.

timeoutMs is a per-attempt limit in milliseconds. An over-limit run is recorded with status timeout and, being a failure, is retried like any other. JavaScript cannot forcibly cancel a running function, so a handler attempt is abandoned, not killed — a handler that ignores its own cancellation can still be executing after the platform has moved on. Omit timeoutMs for no per-attempt limit. For a job with a body, timeoutMs is also the limit of the sandbox run — see Time limit and long-running work.

Running on more than one node

A scheduled fire is leader-elected per job: the node whose scheduler fires first takes a per-job cluster lock, and peers that fire the same tick skip the run. One nightly job stays one nightly run no matter how many nodes are up, and on a single node with no cluster driver the lock is always granted, so nothing changes. See Cluster & Distributed Runtime for the primitive this is built on.

Two boundaries on that guarantee:

  • It covers scheduled fires. A manual trigger(name) deliberately bypasses the lock and runs on the node that received the call.
  • The lock makes a fire single-node, not single-flight-forever: it is a leased lock, so a run that outlives its lease can overlap a later fire.

Observing runs

With the durable adapter (the default when an ObjectQL engine is present), every execution lands in two platform objects you can query, build views on, and report from like any other:

  • sys_job_run — one row per attempt: job_name, status, started_at, completed_at, duration_ms, attempt (1 for the first run, higher for retries and replays), trigger (schedule | manual | replay), and error.
  • sys_job — the per-job summary an operator reads first: last_run_at, last_status, last_error, run_count, failure_count.

status and last_status are enforced select vocabularies — running, success, failed, timeout, degraded.

⚠️ Read the status before reading the error column. A degraded run puts its reason in the same error / last_error column a failure uses, and leaves failure_count flat. A column labelled "Error" can therefore hold a non-error operator note; gate on status === 'degraded' before treating it as a failure.

Per-attempt rows can be switched off in the adapter's options, in which case sys_job_run stays empty while the sys_job summary counters keep updating.

On this page