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:
job | schedule-type flow | |
|---|---|---|
| What runs | a sandboxed body, a mapping pull, or (deprecated) one TypeScript function from defineStack({ functions }) | a node graph — record operations, notify, http, approvals, subflows |
| Changeable after deploy | No. job is allowRuntimeCreate: false and allowOrgOverride: false — there is no "create job" in Studio and no per-tenant fork | Yes — a new flow can be authored through Studio / PUT /meta (allowRuntimeCreate: true) |
| Identity of its data writes | system, in the job's declared organization | declared 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 limit | retryPolicy + timeoutMs on the job, honoured by the job adapter | the flow's own error handling |
| Run history | sys_job + sys_job_run | sys_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
jobscollection ofdefineStack().
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=trueIt 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.timezoneis an IANA name and defaults toUTC. 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—intervalMsis a positive integer in milliseconds. A fixed delay between fires, not an aligned wall-clock schedule.once—atis an ISO 8601 datetime. Aoncejob 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.
sourceis the function body only: noimportorrequire, and no helper, constant or variable from the surrounding file — the body is evaluated on its own, with the standard JavaScript globals andctx. - 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 isctx.log, underlog. An undeclared call throws at run time. Outbound HTTP is not available; use a Connector. - Not the handler's context. The
JobHandlerContextahandlerreceives —ql,logger,bundle— does not exist inside the sandbox. A handler is not turned into a body by copying its source: rewrite itsql.find(…)reads asctx.api.object(…).find(…)and itsloggercalls asctx.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.
pullis refused besidebodyorhandler: 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 soos validate— refuses apullthat names a mapping the stack does not declare, or one with noconnectorSource. The binder checks the same reference against the artifact before it schedules the job, on every door, andos package installrefuses a package whose enabled job'spullfails that check, with422 VALIDATION_ERRORnaming 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
modeandupsertKey— 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 isfailedand theretryPolicyapplies. A pull whose rows the import runner refused completes asdegraded, with the counts as its reason; retrying would refuse the same rows. Otherwise the run issuccess, 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) | organization | A job that declares none |
|---|---|---|
single | not required | runs with none; the install's one organization is resolved beneath each write |
group | optional | is scheduled, and a tenant-scoped row it writes is refused — the boot names such jobs once, at warn |
isolated | required | is 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:
| Situation | What happens | Log level |
|---|---|---|
enabled: false | not scheduled | debug |
handler names nothing in the function table | not scheduled — the job never runs | warn |
schedule() throws | not scheduled — a silent outage; the app boots green while the work never runs | error, 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):
| Member | What it is |
|---|---|
jobId | the job's name — its identity everywhere |
data | the payload of a manual trigger(name, data) run; absent on a scheduled run |
bundle | the application's metadata bundle — declarations, not a data handle |
ql | the live ObjectQL engine — the same handle defineStack({ onEnable }) receives |
logger | the platform logger, so a job's diagnostics are not console output |
executionContext | the 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 as | Retried? |
|---|---|---|
| throws / rejects | failed (or timeout) | yes, per retryPolicy |
resolves undefined or { outcome: 'completed' } | success | — |
resolves { outcome: 'degraded', reason? } | degraded | no |
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:
maxRetriesdefaults to0— declaringretryPolicywithout stating a count still means no retry. State a count to opt in.backoffMultiplierdefaults to1— 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(1for the first run, higher for retries and replays),trigger(schedule|manual|replay), anderror.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.
Related
- Schema reference: Job — every field, generated from the spec
- Cluster semantics: Cluster & Distributed Runtime
- The alternative: Flows for schedule-triggered node graphs
- Sending mail from a job:
services.emailand Email Templates