Aller au contenu
Bastide Connexion Créer un compte
Press / to search

Scheduled jobs

FrançaisMarkdownv0.3.0

A scheduled job runs work at a set time, with no visitor: purging sessions, sending a digest, recomputing a ranking. Bastide offers two kinds: application jobs, declared in glowsoft.json, and database jobs, written in SQL in the pg_cron style. This page covers both, their syntax, their time zone, their limits by tier and how to read their runs.

Application jobs

They are declared in the glowsoft.json at the root of your repository, and take effect on the next deployment. They run for production only.

Calling a path

The form is that of vercel.json: a project imported from Vercel keeps its jobs.

{
  "crons": [
    { "path": "/api/cron/cleanup", "schedule": "0 3 * * *" },
    { "path": "/api/cron/report?format=short", "schedule": "30 7 * * MON", "timezone": "Europe/Paris", "overlap": "skip", "timeoutSeconds": 120, "retries": 1 }
  ]
}

At each slot, the platform calls GET <path> under the production host name, through the edge like a visitor: a sleeping application wakes up for its job. The call carries:

  • User-Agent: bastide-cron/1;
  • X-Bastide-Execution: the attempt's unique identifier (exp_…);
  • X-Bastide-Signature: an Ed25519 signature, t=<Unix seconds>,kid=<fingerprint>,v1=<base64url signature>.

A 2xx response makes the attempt succeed; any other makes it fail, and it is retried. An application that cannot be reached (no connection, or a 502 from the platform) gets the slot marked missed after its last attempt.

Checking that the call comes from the platform

User-Agent and X-Bastide-Execution prove nothing: check the signature. The signed bytes are six lines joined by \n, with no trailing newline: bastide-cron/1, GET, the received Host header in lower case, the received path (query string included), the value of X-Bastide-Execution, and t.

  1. Read the public key once: GET https://api.bastide.app/v1/scheduled-runs/signing-key returns { algorithm, keyId, publicKey }, the key in PEM. No token is required.
  2. Reject a kid that is not the keyId you read: the key has been replaced, read it again.
  3. Reject a t more than 300 seconds away from your clock.
  4. Verify the signature over what you received. To reject a replay, remember X-Bastide-Execution: it never comes back.
import { createPublicKey, verify } from "node:crypto";

const key = await (await fetch("https://api.bastide.app/v1/scheduled-runs/signing-key")).json();
const publicKey = createPublicKey(key.publicKey);

export function comesFromPlatform(req) {
  const m = /^t=(\d+),kid=([0-9a-f]{16}),v1=([A-Za-z0-9_-]{86})$/.exec(
    req.headers["x-bastide-signature"] ?? "",
  );
  if (!m || m[2] !== key.keyId) return false;
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const signed = [
    "bastide-cron/1",
    "GET",
    String(req.headers.host).toLowerCase(),
    req.url,
    req.headers["x-bastide-execution"],
    m[1],
  ].join("\n");
  return verify(null, Buffer.from(signed), publicKey, Buffer.from(m[3], "base64url"));
}

Running a container: the cron service

A job can also run in its own container, without going through your web service:

{
  "services": [
    { "name": "web", "type": "web" },
    { "name": "cleanup", "type": "cron", "start": "cleanup", "schedule": "0 3 * * *" }
  ]
}
  • services always holds exactly one web.
  • start is the name of a script in your package.json, never a command. The container is built like a worker and starts with that script.
  • No call goes out and nothing is signed: the exit code concludes the attempt, zero succeeds, anything else fails and is retried. Past its timeout, the container is stopped and the attempt fails.

Job fields

FieldWhat it saysDefault
pathThe path called, query string included; starts with /, at most 255 characters.required (path job)
scheduleThe expression, at most 128 characters.required
timezoneAn IANA time zone name.Europe/Paris
overlapIf the previous run is still going: skip drops the slot, queue waits for it, kill stops the previous one.skip
timeoutSecondsA run's timeout, from 10 to 3,600 seconds.300
retriesRetries of a failed run, from 0 to 5, with a 30-second back-off doubled each time.2

At least once

An attempt can run more than once: a retry follows a failure even when your code had finished its work, and a cron service container whose node is replaced restarts on the next one. Write jobs that can be replayed safely. A slot discovered more than five minutes late is not launched: it is recorded as missed. A new deployment does not catch up past slots.

Expression syntax

Five fields, to the minute: minute, hour, day of month, month, day of week.

ElementExampleMeaning
Value30 7 * * *Every day at 7:30.
List0 8,12,18 * * *At 8:00, 12:00 and 18:00.
Range0 9-17 * * MON-FRIEvery hour from 9:00 to 17:00, Monday to Friday.
Step*/15 * * * *Every fifteen minutes.
Names0 0 1 JAN *Months JAN…DEC and days SUN…SAT, case-insensitive.
Shortcuts@daily@hourly, @daily, @midnight, @weekly, @monthly, @yearly, @annually.
  • Sunday is both 0 and 7.
  • When both day of month AND day of week are restricted, the job runs if either matches: 0 0 1 * MON means "the 1st and every Monday", as with cron and Vercel.
  • Daylight saving changes follow the cronie rule. In spring, a fixed-time job that falls in the hour that does not exist runs once, at the first minute after the jump. In autumn, it runs at the first occurrence only. A job whose hour field is * or */n follows real time.
  • The granularity is one minute: a per-second expression is refused.

A wrong expression or time zone drops the job without failing the deployment: the deployment log gives the job's position, the field and the rule, never the value written.

Database jobs

For purely SQL work, write the job in your database, with the functions you would use on Supabase:

select cron.schedule('purge-sessions', '*/10 * * * *', $$ delete from sessions where expire_le < now() $$);
select cron.unschedule('purge-sessions');
select * from cron.job_run_details order by start_time desc limit 20;

cron.alter_job(job_id, active, fenetre_secondes) suspends or resumes a job and sets its window. This is not pg_cron: the platform reads your jobs and runs them itself, which changes a few things.

  • The time zone is UTC, and the granularity one minute.
  • The command runs inside a transaction, under your project's role, through the pooler: no VACUUM and no COMMIT. It is stopped after 10 minutes.
  • At most 50 jobs per database.
  • A window (60 to 3,600 seconds, 300 by default): a slot that has not started within it is recorded as missed, with its cause, instead of vanishing. A slot whose previous run is still going is recorded the same way.
  • A new job is picked up at most five minutes after it is created.
  • The history (cron.job_run_details, states running, succeeded, failed, missed) is purged beyond 30 days.
  • An alert: each failure or missed slot emits the database.cron.failed event to the project's endpoints, grouped by job (see Webhooks).

Limits by tier

TierApplication jobs per project
Discovery5
Standard20
Pro100
Sovereign500

Path jobs (crons) and cron services count together. Every declared job counts, even one that will be dropped for a wrong expression. Beyond the cap, the deployment is refused with a message naming the tier and the cap. Database jobs have their own cap of 50 per database, whatever the tier.

Reading runs

Every attempt of an application job (its slot, its state, its duration, its code, and the start of the response or of what the container wrote) is kept 90 days.

  • Console: the Journaux (Logs) screen, « Tâches planifiées » (scheduled jobs) view.
  • Agent: logs_read with scheduledRuns: true; at most fifty attempts per read, most recent first, the rest counted.
  • Command line: bastide logs read --project-id <project> --scheduled-runs.
{
  "projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "environmentId": "env_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "scheduledRuns": true
}

Database job runs are read in cron.job_run_details, through the SQL editor or database_sql_read.

Common errors

  • « N tâche(s) planifiée(s) écartée(s), le déploiement continue » (N jobs dropped, the deployment carries on): read the position and the field; fix the expression or the time zone.
  • Deployment refused, « au-delà des N que le palier permet » (beyond the N the tier allows): remove jobs or change tier.
  • Missed slot: the application did not answer (stopped, 502); read its logs.
  • The job runs twice: it is a retry; make it replayable with X-Bastide-Execution.
  • A database job does not start: check that it is active and that its expression has five fields; a refused expression never runs, and the refusal is reported only once.

See also

Report a mistake on this page