Add a scheduled job

Prompt:

Clear out the expired sign-up links every night, and show me how to tell whether it ran.

Also works:

  • "Send the daily summary every morning at six."
  • "Check every hour that the app still responds, and keep a record I can read."

This page adds a scheduled job to an application you already have. For a new service, the turnzero-cloud command's scheduled-job template writes one with its handler and tests. Run scheduled work in Author the manifest is the reference for the declaration and its runs.

What your tool does

  • Reads the platform's schedule skill with read_context, id skill:schedule.
  • Reads the plan's shortest interval and schedule count with read_plan_quotas, and chooses a timetable within both.
  • Adds a schedule entry to the manifest's services list, and submits the whole manifest with submit_manifest.
  • Writes the job's handler as a POST route at the declared path, safe to repeat for one due instant.
  • Keeps what the job needs between runs in a file in a storage area or in the database, where the job needs anything.
  • Deploys with deploy. Where the application has a development environment, it promotes with promote when you ask.
  • Reads the schedule with read_schedules, fires one run now with run_schedule, and reads the run's lines with read_logs.

What you need

  • What the job does, and how often or at what time of day it runs. Times are in UTC, and the finest step is one minute. Your plan may set a longer one, which your tool reads.
  • Whether the job must remember anything from one run to the next, such as the last record it handled.

Before your AI starts

This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.

  • A connected, signed-in tool (Connect your tool).
  • An application that deploys, its identifier from list_applications, and its manifest (Author the manifest).
  • The application's environments, which read_status names. On one environment, the deploy starts the schedule in production. On two, the deploy starts it in development and the promote in production.
  • Work that fits one run. A run is one HTTP request that ends inside the execution window, 230 seconds by default. Longer work, a queue, or work a set time after a request does not suit a schedule.

Steps

1. Declare the schedule

Your tool first reads the plan's limits with read_plan_quotas: the schedule-minimum-interval row, in minutes, and the schedule-count-limit row. It chooses a timetable within both. The interval applies in development and production alike.

It then adds a schedule entry to the manifest's services list, or a schedule to the entry already there. The entry's schedules list gives each schedule three members:

  • name, unique and matching ^[a-z][a-z0-9_]*$, so nightly_cleanup passes and nightly-cleanup is refused manifest_invalid;
  • cron, a five-field expression in UTC, from minute to day of week, so 0 3 * * * means 03:00 UTC every day;
  • path, the absolute path of the handler on the application's own server.

The path is never the health path, and never under /__account/ or /__router/. One entry contains up to 25 schedules. Run scheduled work shows a whole manifest with the entry, and the forms a cron accepts.

Your tool submits the whole manifest with submit_manifest. Submitting starts no run. Each schedule row of the response names in starts_with the deploy or promote it waits for, with next_due null until then. Its window_seconds is the number of seconds a run has.

2. Write the handler

The platform delivers each run as one POST with an empty body to the declared path, through the serving router. The handler is an ordinary route at that path.

Each run includes four headers the router alone sets: x-turnzero-cloud-invocation with the value schedule, and x-turnzero-cloud-schedule, x-turnzero-cloud-run, and x-turnzero-cloud-schedule-due. They contain the schedule's name, the run identifier, and the due instant in UTC.

The smallest handler is a heartbeat. It checks x-turnzero-cloud-invocation, writes one console line naming the due instant, and responds 200. A handler without the package on the Schedule page shows it on plain node:http. The Schedule package's router is the other way: it reads the headers, routes each run to its handler, and gives the handler a key for the due instant.

The Node.js Runtime harness responds 404 scheduled_handler_only to any other caller of a declared path, so the path is not a public endpoint. A handler written without the package's router still checks the invocation header and responds 404 otherwise. That check matters where a framework routes more than the exact path to the handler, such as a prefix mount.

Other headers whose names begin x-turnzero-cloud- are the platform's own. The handler never logs or echoes one, and never logs a request's headers whole.

One due instant can reach the handler more than once, for example after the platform lost a run's outcome. So the handler makes its effect once for each schedule and due instant. A heartbeat is safe to repeat as it is. A job whose effect must not repeat records the due instant before acting, and skips an instant it already recorded (step 5).

The response sets the run's outcome. 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. The platform retries no run.

The handler finishes inside window_seconds. A due run wakes a stopped environment, such as a Free application after its idle stop, and the wake counts against the window. When the request's abort signal fires, the handler stops its work. It starts no timer, polling loop, or child process that outlives the response.

3. Deploy

A schedule runs only in an environment that has a deploy or promote made at or after its declaration. So your tool deploys after every change to the entry, as Deploy an application describes:

  • On an application with one environment, the deploy reaches production and starts the schedule there. Nothing is promoted.
  • On an application with two, the deploy starts the schedule in development. Production starts it at the next promote, which your tool makes only when you ask (Turn on a development environment and promote).

After the deploy, read_schedules shows the schedule's row with deployed: true and its next due time in next_due. With no environment named, it returns production's rows. Before the deploy, the row shows deployed: false and next_due null. Its reason is no_deploy where the environment has no deploy or promote yet, or awaiting_deploy where the declaration is newer than the last one.

Every row also shows cron_preview, up to three times the cron yields, in UTC. Your tool reads it to check the cron before the first run.

4. Confirm it runs

Your tool fires one run now with run_schedule, naming the schedule, the environment, and a new request_id. The run reaches the handler as a due run does: the same path, the same four headers, and the same window. Its trigger is manual, and its due instant is the time of the call.

With wait_seconds, a whole number from 1 to 45, the response waits until the run ends, and run then contains its outcome. console then holds the console lines from the run, the handler's among them. Where console.more is present, that read_logs call reads further lines. Where settled is false, the run is still running, not failed. The response's next is then the read_schedules call that waits for it.

A retry of the same request reuses its request_id, and a new run takes a new one. A response that is not the platform's own, such as a gateway's error page, says nothing about whether the run started. Your tool reads read_schedules first, and repeats the call only with the same request_id.

To read the runs, your tool calls read_schedules, with wait_seconds where a run is in flight. Then read_logs with source: "platform" returns one event for each run's outcome. With source: "container", it returns the handler's own console lines.

A console line can take about a minute to arrive. Where the read's detail says so, your tool reads again later. Read logs and counters describes the sources.

5. Keep state between runs

The process keeps nothing past its end, so a value kept in memory is not there at the next run. A heartbeat needs nothing from one run to the next and keeps no state: read_schedules records each run, and read_logs returns its lines.

A job that needs something from the last run, such as the due instants it already handled, keeps it in one of two places:

  • a JSON file in a declared storage area, written with a conditional put: If-Match with the version read, or If-None-Match: * for the first write, so one run never silently overwrites another;
  • the database, where the manifest declares one (Add a database).

Counters are write-only tallies that read_counters totals, never a place to keep state. What an application must do on the Schedule page shows a handler that keeps its state in a file with plain fetch.

Expected result

After the first due run, read_schedules for the environment shows the schedule's row with:

  • deployed: true, reason: null, and next_due set to the next due time;
  • in_flight null once the run has ended;
  • last_run with trigger: "schedule", the due instant, started_at, ended_at, outcome: "succeeded", the handler's status, and duration_ms;
  • the same run among runs, the most recent runs by due time.

read_logs with source: "platform" returns one event for the run, naming the schedule, the run, and the due instant. With source: "container", it returns the line the handler wrote. A run that run_schedule started shows the same, with trigger: "manual", and with wait_seconds the response's console holds the handler's line.

Refusals

A refusal gives its cause in detail. A submission refused before the manifest is recorded changes nothing.

The table covers the schedule entry, the plan's limits on it, read_schedules, and run_schedule. The deploy's own refusals are in Deploy an application, and the manifest's others in Author the manifest. The refusals page lists every refusal.

Refusal Status Cause Remedy
manifest_invalid 400 The schedule entry does not match the schema, such as a name with a hyphen; violations lists the failing paths. Correct each path and submit the whole manifest again.
invalid_request 400 read_schedules or run_schedule named an environment other than development or production, or a wait_seconds outside 1 to 45; detail names the member. Correct the named member and call again.
no_such_schedule 404 The application has no schedule of that name in the environment the call named. Name a declared schedule, and name the environment when it is not production.
schedule_interval_below_plan 409 The cron's due times fall closer together than the plan's schedule-minimum-interval. path names the schedule, quantity the minimum in minutes, and gap the shortest gap the cron gives. Choose a cron with longer gaps, or move to a plan with a shorter minimum.
schedule_count_over_plan 409 The manifest declares more schedules than the plan's schedule-count-limit. path names the first schedule past it, and quantity the limit. Remove schedules, or move to a larger plan with set_plan.
plan_schedule_conflict 409 set_plan chose a plan whose limits the declared schedules exceed. schedules lists each one with its value and the limit. Submit a manifest within the new plan's limits first, or choose another plan.
plan_quantity_unset 409 The plan has no value set for a schedule limit; plan and measure name it. Nothing is recorded. Choose a plan whose limits are set, or wait until platform staff set it.
schedule_not_deployed 409 run_schedule named an environment with no deploy or promote since the schedule was declared. Deploy to that environment, or promote to production on an application with two environments, then fire the run again.
environment_not_created 409 run_schedule named development on an application with one environment. Fire the run in production, or turn development on with create_environment and deploy there first.
run_in_flight 409 A run of the schedule has not ended yet. A due time that falls during it is skipped, not queued. Wait until read_schedules shows the run ended, then fire again.
target_environment_halted 409 run_schedule named a halted environment. Resume it with resume_environment, then fire again.
usage_over_quota 429 The application is over its plan's monthly limit for backend actions or data transfer. The serving router recorded the run skipped before the handler ran. Wait for the next UTC month, or move to a larger plan or raise the quota.
  • Run scheduled work in Author the manifest is the reference for the declaration: the cron's forms, starts_with, the skipped runs, and the halt.
  • Schedule describes the package's router, what a run delivers to the handler, and a handler that keeps its state in a file.
  • Add a database adds the database a job can keep its state in.
  • The schedule skill, which your tool reads with read_context and the id skill:schedule, gives these steps as the rules your tool follows.
  • Read logs and counters reads the platform's events and the handler's lines.
  • Store files covers the storage areas and the conditional put.