Schedule

Schedule lets an application run its own HTTP handlers on a timetable, without a scheduler of its own. The application declares each schedule in its manifest. Turn Zero Cloud calls the declared handler once per due time through the serving router, records each run's outcome, and lets you read the runs.

The package provides your application's side of that service. It reads the declarations from the manifest, reads each run the platform delivers, and passes the run to its handler. It writes the handler's response and gives the handler a key that makes it safe to repeat. A handler needs no package: A handler without the package shows one on plain node:http.

Use it when

Use Schedule when an application needs work done on a timetable rather than on a request: a reminder sent every fifteen minutes, a report built nightly, or stale rows removed daily. A schedule is declared in the manifest as a name, a five-field cron expression read in UTC, and the absolute path of a handler on the application's own server. Author the manifest explains the declaration and the plan's limits on it.

Do not use it for work that must run a set time after a request, for a queue, or for a task that runs longer than the schedule execution window. A scheduled run is one HTTP request that must finish inside that window. The window is 230 seconds by default, and read_schedules reports its length as window_seconds.

What it provides

  • Reading a scheduled run. The platform delivers a run as one POST with an empty body to the declared path, with four headers that the serving router alone sets. readScheduledRun(headers) reads them into one run value: the schedule name, the run identifier, the due instant, and a key for that schedule's due instant. It returns no run unless all four headers are present exactly once. The application never sets the four headers itself.
  • Reading the declarations. scheduleDeclarations(manifest) reads the schedules list of the manifest's schedule services entry. It rejects a shape the manifest schema does not allow, so your handlers are checked against the same text you submit. It evaluates no cron expression: the platform does that when the manifest is submitted and computes each due time itself.
  • A router. createScheduleRouter({ schedules, handlers }) takes the declarations and one handler per schedule name. At construction it throws on a handler for a schedule the manifest does not declare, a declared schedule with no handler, or two declarations at one path.
  • Mounting. Put router.handle(request, response) ahead of the application's own routing. It handles requests on declared paths and leaves every other path to the application. A request on a declared path that is not a scheduled run receives 404 scheduled_handler_only, the same refusal the runtime harness gives. A scheduled run reaches its handler with the run value, the request, the response, and the request's abort signal.
  • The handler's response. When a handler returns nothing, the router responds 200 with a JSON body that identifies the run. A handler may return a status and a body, or end the response itself. A 2xx status records the run as succeeded. Any other status records it as failed and keeps the first bytes of the body, so put the reason in the body. When the handler throws an error, the router responds 500 handler_failed.
  • One record per run. By default the router writes a JSON line to standard output giving the schedule, the run, the due instant, the path, the status, and the duration. The platform keeps its own run row and platform event beside it.
  • The idempotency key. One due instant can reach a handler more than once: a run whose outcome the platform lost, followed by the next due time's run. A manual run through run_schedule carries the instant of the call as its due instant, so its key is its own. The platform retries nothing. run.key is the schedule name and the due instant. A handler whose effect must not repeat records the key in your database or storage before acting, and skips the action when the key is already recorded. A value kept in memory does not count: the process keeps nothing past its end.
Header on a scheduled run What it contains
x-turnzero-cloud-invocation: schedule Marks the request as a scheduled run.
x-turnzero-cloud-schedule The schedule's name.
x-turnzero-cloud-run The run identifier.
x-turnzero-cloud-schedule-due The due instant, in UTC.

The platform's check covers the Node.js HTTP server, and it accepts a loopback request without the router's mark; a separate HTTPS or HTTP/2 server is not covered (Node.js Runtime).

The module is published as the npm package @turnzero/schedule. Use the library shows how to copy it into app/lib/schedule/, declare it as file:lib/schedule, and install it with npm install. The module imports nothing, reads no environment variable, starts no timer, and keeps no state across requests.

A test double ships at the package's testing subpath, @turnzero/schedule/testing. createScheduleDouble fires a declared schedule through the router at once, setting the four headers as the platform does. Test your application locally shows it in use.

run_schedule fires a run due now and takes no chosen instant. To replay one due instant before any deploy, fire it twice through the double with double.fire('<schedule>', { due }). The second fire has the same run.key, so the test shows whether the handler skips an effect it already made. The integration guide in the entry shows this under Testing the composition, in its Test setup section.

Availability

Version 0.2.9 is the package's current version, and list_library reports the version the platform currently publishes. The entry includes the testing subpath from version 0.2.0. The entry contains the package's product statement, its detailed contract, its integration guide, and the compiled module.

Turn Zero Cloud runs the schedule service. It reads the declarations when the manifest is submitted, fires each due time once, and retries no run. read_schedules returns each run with its trigger, due instant, start, end, outcome, status, duration, and detail. A run still in progress has the outcome running. A finished run's outcome is one of succeeded, failed, window_ended, unreachable, skipped, missed, or abandoned.

Run scheduled work says when a declaration starts running, what starts_with and cron_preview show, the name rules and the plan's limits, the handler's headers, the wake, and the halt.

Node.js Runtime closes a declared path to everything but the platform's runs, and enforces the window. Database and Storage are where a handler keeps its idempotency record. Logging can receive the router's record of each run beside the application's other entries.

What an application must do

Declare every scheduled handler in the manifest, and pass the router a handler for every declaration. Do not pass the router a handler with no declaration: createScheduleRouter throws at startup, so the mismatch fails before any run.

Finish each run inside the schedule execution window, and stop the run's work when the request's abort signal fires. The serving router and the runtime harness end a run at the window, and a handler that keeps running past the grace interval ends the process. Start no timer, polling loop, or child process that outlives the response. Node.js Runtime describes the window, the signal, and the check on a declared path.

Make each handler safe to repeat for the same schedule and due instant, using the key the run includes and the application's own stored state.

Submit the manifest, then deploy, as Run scheduled work describes for one environment and for two. Read each run through read_schedules and its platform event through read_logs. Fire one run now through run_schedule to try a handler.

read_schedules confirms a schedule's registration with no run. The schedule's row shows its cron and path, deployed: true once the environment has a deploy made at or after the declaration, and its next due time in next_due.

A schedule already firing before a deploy keeps its next due time across it. Only a schedule that awaited the deploy starts from the deploy's instant, at its first due time after it. The exception is a deploy that ends a development halt: it starts every past-due schedule of that environment again from the deploy's instant, and nothing is recorded missed.

A row with deployed: false has next_due null, and its reason says why: no_deploy where the environment has no deploy or promote yet, or awaiting_deploy where the declaration is newer than the last one. in_flight names the run in progress. runs lists the most recent runs by due time, 5 by default and up to 100 with limit. window_seconds is the number of seconds a run has before the platform ends it.

The read_schedules response's page is the address of this page, and its detail names this section.

With no environment named, read_schedules returns production's schedules. Its halted member is the environment's current halt, or null. run_schedule starts one run now, after a deploy. It needs a request identifier: reuse it when retrying the same request, and use a new one for a new run. Identifiers are shared across the application's schedules, so do not reuse one for a different schedule. A second run is refused run_in_flight while one is still in progress.

run_schedule takes wait_seconds too, a whole number from 1 to 45. Its response then waits until the run it started ends, checking every two seconds, and a retry with the same request_id waits on that same run. Its settled is true where the run had ended at the time of the response, and run then contains the outcome. Where settled is false, the run is still running, not failed, and the response's next is the read_schedules call that waits for it. Without wait_seconds, run_schedule returns at once with the run running.

Where the run has ended, the response's detail gives the read_logs call that returns the handler's own console output: source container, the run's environment, and since the run's started_at. That read returns the newest lines at once wherever it can read the application's console directly. Where the read's own detail says lines "may still be in the ingestion", read it again about a minute later, since a line can take that long to arrive.

A response that is not the platform's own, such as a gateway's error page or a closed connection, says nothing about whether the run started. Read read_schedules first, and repeat the call only with the same request_id.

To read any run's outcome without sleeping, add wait_seconds, a whole number from 1 to 45, to read_schedules. The read then waits to respond until no run of the environment is in flight, checking every two seconds for at most 45 seconds. Its settled member is true where no run was in flight at the time of the response, and waited_ms says how long it waited. settled: false means a run is still in flight, not that it failed, so read again with wait_seconds.

Keep the state a handler needs between runs in a JSON file in a declared storage area, or in the database where the manifest declares one. The process keeps nothing past its end. Write the file with a conditional put, so one run never silently overwrites another: If-Match with the version you read, or If-None-Match: * for the first write. A counter is a write-only tally that read_counters totals, never a place to keep state.

A handler reads the platform's origin and its credential from the settings a deployed copy receives: TURNZERO_CLOUD_GATEWAY_URL, or TURNZERO_CLOUD_API where the platform sets no separate gateway origin, and TURNZERO_CLOUD_TOKEN. Deploy an application lists every setting.

The sample below is a handler for the package's router that keeps its state in the file state.json of an area named report-state. It skips a due instant it already handled and counts its reports. No test checks this sample.

const origin = process.env.TURNZERO_CLOUD_GATEWAY_URL ?? process.env.TURNZERO_CLOUD_API;
const stateFile = `${origin}/storage/v0/areas/report-state/files/state.json`;
const bearer = { authorization: `Bearer ${process.env.TURNZERO_CLOUD_TOKEN}` };

export async function nightlyReport(run) {
  const read = await fetch(stateFile, { headers: bearer });
  if (read.status !== 200 && read.status !== 404) throw new Error(`state read answered ${read.status}`);
  const state = read.status === 200 ? await read.json() : { lastKey: null, reports: 0 };
  if (state.lastKey === run.key) return; // this due instant already ran

  // ... build and send the report ...

  const write = await fetch(stateFile, {
    method: 'PUT',
    headers: {
      ...bearer,
      'content-type': 'application/json',
      'x-acting-identity': 'nightly-report',
      ...(read.status === 200 ? { 'if-match': read.headers.get('etag') } : { 'if-none-match': '*' }),
    },
    body: JSON.stringify({ lastKey: run.key, reports: state.reports + 1 }),
  });
  if (write.status === 412) return; // another run wrote first; the next run reads its state
  if (!write.ok) throw new Error(`state write answered ${write.status}`);
}

Under the platform credential the calls need no environment header, because the credential fixes the environment. Each environment keeps its own copy of the file.

To start a new service with a scheduled handler already in place, run the turnzero-cloud command's scaffold line with --template scheduled-job. It writes an hourly heartbeat like the one below, but through this package's router, with its test double and tests.

A handler without the package

The smallest handler is a heartbeat on a cron schedule, and it needs no package. It is an ordinary POST route at the declared path that responds 200 at once and keeps no state. Written without the package's router, it checks for x-turnzero-cloud-invocation: schedule itself and responds 404 otherwise. A second run for the same x-turnzero-cloud-schedule-due instant does the same again, so it is safe to repeat. The runtime harness responds to any other caller with 404 scheduled_handler_only.

import { createServer } from 'node:http';

// The manifest declares a schedule whose handler path is /jobs/heartbeat.
createServer((request, response) => {
  if (request.url === '/jobs/heartbeat') {
    if (request.method !== 'POST' || request.headers['x-turnzero-cloud-invocation'] !== 'schedule') {
      response.writeHead(404).end();
      return;
    }
    console.log(`heartbeat due ${request.headers['x-turnzero-cloud-schedule-due']}`);
    response.writeHead(200).end();
    return;
  }
  // ... the application's own routes, the health path among them ...
}).listen(Number(process.env.PORT));