Scheduled jobs#
Background jobs that run on a schedule are configured at Configuration → Scheduled Jobs
(/config/scheduled-jobs). This page is for the people operating them; the implementation notes
live in backend/CLAUDE.md.
The job list#
Each job is one row:
[>] deployment-cleanup Last run 18/09 17:07 — succeeded Next run 19/09 02:00 (Run) (History) Paused Dry run
Click the name to open the editor; the page remembers which jobs you left open. Run and show/hide history work straight from the row without opening anything, and the history table appears underneath it.
A warning icon on the row means the schedule will never run — open the job to see why. That icon is the only warning you get while the row is closed, so it is worth not ignoring.
What the fields mean#
| Field | Meaning |
|---|---|
| Repeat | How often the job runs: every day, every week, every month, or Advanced for a raw Quartz cron. |
| Times | One or more times of day. "Twice a day" is simply two entries, 02:00 and 14:00. |
| Time zone | The zone the times are read in, picked from a list. Daylight saving is handled for you. |
| Cron | The expression the builder produced, shown read-only so you can always see what will be stored. |
| Enabled | Off pauses the job entirely. Nothing fires until it is switched back on. |
| Skip runs | A countdown, not a switch. 2 skips the next two scheduled runs and then resumes on its own. |
| Dry run | The job reports what it would do and changes nothing. |
Every time you change something, the Next runs list under the form is recalculated by the scheduler itself — before you save. That list, not the expression, is the thing to check.
Times must share their minutes#
A single cron cannot express "02:00 and 14:30". Minute and hour are independent lists, so
0 0,30 2,14 * * ? would fire four times a day, not twice. The editor refuses rather than
generate that, and says so. Use Advanced if you genuinely need a different minute per hour.
Always read the "Next runs" list#
Quartz accepts cron expressions that are out of range without complaining, and then ignores the value you typed and treats that field as "every". It does not shift the schedule, it multiplies it:
0 0 99 * * ?looks like a daily job. It runs every hour.99 0 2 * * ?looks like a daily job. It runs every second for a minute — 60 times.
There is no way to make it strict, so the next-runs list under the form is the real check. If those three times are not what you intended, the expression is wrong regardless of the form accepting it.
An expression that parses but can never fire — 0 0 0 30 2 ?, the 30th of February — is rejected
when you save, with "never fires".
Changes take about ten seconds#
The service runs on up to ten instances in Azure. Saving applies immediately on the instance that served your request; the others notice within about ten seconds. If the next-fire time looks stale straight after saving, wait a moment and refresh rather than saving again.
Exactly one instance performs each scheduled run — the others record LeaseLost in the history and
do nothing. Seeing LeaseLost rows is normal and is the mechanism working, not an error.
Run history#
Each fire records a row:
| Outcome | Meaning |
|---|---|
Succeeded |
Ran to completion. In dry run this means it completed without changing anything. |
Failed |
Ran, but at least one item threw. The error column says which. |
Skipped |
A skip credit was consumed instead of doing work. |
LeaseLost |
Another instance took this run. Expected and harmless. |
Last run on the card comes from this history, not from the job's internal lease timestamp, so it reflects a run that actually happened.
Run now#
The Run button fires the job immediately on whichever instance serves the request. It:
- works even when the job is disabled, so you can test before enabling;
- does not consume a skip credit, since those count scheduled runs;
- still honours Dry run.
Deployment cleanup#
The first job, deployment-cleanup, drops the superseded database left behind by a completed
deployment once its retention date has passed. It verifies against sys.databases after each drop
and only marks a deploy-log row as cleaned when the database is genuinely gone; anything that
survives the drop is left for the next run and reported as a failure.
Because it drops databases, it ships disabled and in dry run. Recommended sequence when enabling it for the first time in an environment:
- Leave
Dry runon, press Run, and read the history —Itemstells you how many databases it would have dropped. - Confirm that list is what you expect.
- Turn
Dry runoff, then enable the job.
The database account the service runs as needs permission to drop databases; without it every
attempt is recorded as Failed with the SQL error.