Skip to content

Jobs and retention

Two declarations describe work that happens on a clock, without anybody pressing anything.

  • job belongs in the app block. It names a script and the schedule that calls it.
  • retention belongs in a table block. It says how long that table keeps a record.

Both run in the application’s own time zone, which app { timezone: } declares. There is no per-job zone and no per-rule zone. Read Set the time zone.

app city_news "Sampletown News" {
timezone: Europe/Berlin
job ip_hashes every day at 03:00 -> logic/ip-hashes.ts "Clear IP hashes"
job daily_digest every day at 07:30 -> logic/digest.ts "Daily digest"
job weekly_review every week on monday at 06:15 -> logic/weekly.ts "Weekly review"
job billing every month on 1 at 04:05 -> logic/billing.ts "Monthly billing"
job feed_epoch every 15min -> logic/feed-epoch.ts
}

The shape is a transition’s: a name, what triggers it, ->, the script, and an optional label.

Status: the declaration compiles, the engine gives it a schedule record, and the schedule arithmetic decides each occurrence. No script runs. The runtime has no executor for an app-declared job, so every occurrence of one is recorded as a failure whose message names the missing half. Do not ship a job as work that happens. See Project status.

The name keys the schedule record, the lease and every audit event the job writes. Moving the line changes nothing. Renaming the job creates a new job with a fresh clock.

Two refusals protect that:

  • two jobs with one name in one application.
  • a name that starts with mtd_. The engine names its own scheduled work in that prefix.

A job name is lower case, for the same reason: it is a key rather than a caption.

The script must exist, must end in .ts, and must stay inside the application directory.

Shape Example When it runs
every <period> every 15min on the wall-clock grid the period divides: :00, :15, :30, :45
every day at HH:MM every day at 03:00 once a day, at that wall clock
every week on <weekday> at HH:MM every week on monday at 06:15 once a week
every month on <1..28> at HH:MM every month on 1 at 04:05 once a month

A cron expression is not accepted. It carries no repair message, and its own rule is not readable from the syntax: with the day of the month and the day of the week both restricted, Vixie cron runs the job when either matches.

A weekday is written in English, like every other keyword in this language. The data may be in any language. montag is a compile error that names monday.

A period divides an hour, or is a whole number of hours that divides a day, and is at least five minutes. That rule admits exactly fourteen spellings:

5min 6min 10min 12min 15min 20min 30min
1h 2h 3h 4h 6h 8h 12h

Anything else is a compile error that prints the list. Three cases get their own repair:

  • 24h, 2d and longer. Write every day at HH:MM, which says the period and says when.
  • every 1min. The floor is five minutes. A run costs an organization open plus a sandbox. At one job a minute, a node with 3,000 organizations spends 1.8 CPU seconds per minute on the opens alone.
  • The same period in another unit, such as 60min or 120min. The message names the exact replacement, 1h or 2h.

every month on 29, 30 and 31 are refused with 1..28 in the message. A monthly job on the 31st either skips February or runs on 1 March. Which of the two happens depends on the library, and neither is visible in the declaration.

A summer-time change is resolved, never refused

Section titled “A summer-time change is resolved, never refused”

A wall-clock time a person types is refused when it names no instant or two, because the author is there to say which they meant. A job has no author at 02:30, so the engine decides:

  • an occurrence in the gap runs at the first instant that exists after it. For a 02:30 job in Europe/Berlin on 2026-03-29 that is 03:00 +02:00.
  • an occurrence in the repeated hour runs once, at the earlier of the two instants: 02:30 +02:00 on 2026-10-25.

The advance is computed in wall-clock terms. every day at 03:00 in Europe/Berlin is therefore 23 hours after the spring day and 25 hours after the autumn one.

A period follows the same two rules, one wall-clock point at a time. For every 15min in Europe/Berlin:

  • on 2026-03-29, 02:00, 02:15, 02:30 and 02:45 do not exist. They are one occurrence at 03:00 +02:00, and the next one is 03:15.
  • on 2026-10-25, the job runs at 02:45 +02:00 and next at 03:00 +01:00. The second pass through 02:00–02:59 has no runs, so the gap between those two runs is 75 minutes.
table kommentare "Comments" {
text long text required "Comment"
ip_hash text "IP hash"
retention 7d on ip_hash after created_at // empty that one column
retention 2y after created_at // delete the record
}

A retention says how long the table keeps a record, counted from a date, and what goes when the window is up. With on <column> the column is emptied and the record stays. Without it the record is deleted.

A retention is a declaration and never a script. The engine runs one statement it wrote itself, from the rule in the compiled model. A legal obligation must not depend on a script that can throw at 03:00 where nobody is looking.

Status: declared rules run. The engine enforces every rule of every table once a day, at 03:00 in the application’s time zone, in batches of 1,000 records. A delete takes the record’s images with it. Read How expired data is deleted.

A window is a whole number followed by d, m or y: 7d, 30d, 6m, 2y.

A month is 30 days and a year is 365 days. Both numbers belong to this language rather than to a calendar, and the two are not equally close to one:

  • a year is never longer than a calendar year, which is the direction a legal window has to round.
  • a month is 30 days, which a calendar month is only sometimes. retention 1m on a record written on 1 February empties it on 3 March.

A calendar month would make the window depend on each record’s own date. Two records written a day apart would then have windows of different lengths.

15min and 5h are refused. The rule compares dates, so there is no hour in it to compare.

after names the date the window is counted from. It is a date or a datetime column of the table, including created_at, or a path through ref columns to a date on another record:

retention 3y after course.term.ends_on

A path is at most three hops. Two rules are compile errors:

  • a column that is not a date. The message lists the date columns of the table.
  • a hop that is not required. A record whose reference is empty would have no date to count from, so it would never be deleted and nothing would report it.

A record whose date is empty is kept. The comparison is false rather than true. The opposite reading, “no date means infinitely old”, deletes records nobody can get back.

The comparison is by date and not by instant. A record written at 23:59 therefore goes at the start of the seventh day rather than at the end of it. That keeps personal data for slightly less time than the window asks, which is the safe direction.

on names the column that is emptied. Four columns are refused, each with the repair in the message:

Refused Why
a column the table does not have a typo. The closest name is offered
a column the engine owns, such as created_at, a slug or a lifecycle state the engine writes it and reads it back
a required column it is NOT NULL in the database, so emptying it would fail every night with nothing on any page to show it
a column with no single stored value: a multi, a list, an aggregate or a calculated column there is nothing to empty

The repair line offers the other half of the choice too: delete the record instead of emptying the column.

Two rules about the same thing are refused

Section titled “Two rules about the same thing are refused”

Two rules that delete the record, or two rules on the same column, are a compile error. They can only disagree, and the shorter one would decide.

Declaring or shortening a retention needs an acknowledgement

Section titled “Declaring or shortening a retention needs an acknowledgement”

A new rule, and a rule whose window got shorter, both delete stored records on a deploy that reads as one line added. Both need an acknowledgement in the app block:

app city_news "Sampletown News" {
retention comments !destructive "Statutory deletion period for comments."
retention comments.ip_hash !destructive "GDPR: IP hashes after seven days."
}

Without it the deploy is refused and the message names the line to write.

Three changes need no acknowledgement, because none of them deletes anything the deployed application would have kept:

  • lengthening a window.
  • removing a rule.
  • declaring a rule on a table that the same migration creates. That table has no records.

The acknowledgement may stay in the source afterwards. Once it matches nothing, the deploy plan reports it as unused and continues.