Skip to content

Recurring jobs

This page explains how recurring tasks work internally. If you're looking for how to use them, see the Recurring tasks guide.

The recurring job model

A recurring job is a document that stores:

  • Template (templateId) — the task template to copy for each occurrence.
  • Interval — frequency, every-N, days of week, time of day, and timezone.
  • StatenextRunAt, lastRunAt, spawnCount.
  • Skip rulesskipDates[] (specific calendar days to skip) and skipUntil (skip everything before this date).
  • End conditionnever, on_date, or after_n occurrences.
  • Spawn status — optional override for the spawned task's status.

Schedule calculation

The scheduler uses a pure function (computeNextRunAt) that takes the interval definition, a reference date, and an optional end condition, and returns the next valid run date — or null if the series is exhausted.

Per-frequency logic

  • Day: steps forward by N days until the candidate is past the after date.
  • Week: scans up to 84 days, checking configured days of week and every-N-week alignment from the reference Monday.
  • Month: scans up to 60 months, checks that the day exists in the target month (31st in February → handled), and verifies N-month alignment.
  • Year: scans up to 8 years forward with the same day/month validation.

All calculations use the job's timezone (IANA, e.g. Europe/Moscow). Wall-clock time ↔ UTC conversion is done for each step — DST transitions don't cause missed or double-spawned tasks.

Spawning

A cron job runs every minute and calls tickDue().

Claiming (optimistic lock)

  1. Find a job where nextRunAt ≤ now and active = true.
  2. Check shouldSkip — if today is in skipDates[] or before skipUntil, advance to the next non-skip date without spawning.
  3. Atomically update nextRunAt using a findOneAndUpdate with the original nextRunAt value as a filter (CAS — compare-and-swap). If another scheduler instance got there first, this findOneAndUpdate returns null and we move to the next job.

Task creation

When a job is claimed:

  1. The task template is copied into the target list using the task-copy pipeline.
  2. The new task gets:
    • seriesId = the recurring job's ID.
    • occurrenceDate = UTC midnight of the scheduled day in the job's timezone.
    • Status = spawnStatusId (if set) or the list's first open status.
  3. nextRunAt is computed from the interval.
  4. spawnCount is incremented.
  5. If endCondition = after_n and spawnCount ≥ count, the job is deactivated.

Catch-up

If multiple occurrences were missed (e.g. the server was down for a day), each missed occurrence is spawned in sequence, one per tick. This prevents a thundering-herd of dozens of simultaneous task creations.

Skip logic

skipDates[] — array of calendar dates (local timezone) that should be skipped. When the scheduler encounters a job whose next candidate is in skipDates, it advances past it without spawning.

skipUntil — a single date boundary. All candidates before this date are skipped. Used for "pause until" scenarios.

findNextNonSkipRun wraps computeNextRunAt with a loop (max 200 iterations) that keeps advancing through skipped dates.

Edit modes

When a user edits an instance of a recurring task, three modes are available:

This occurrence only

The instance is updated directly. The occurrence's date is added to the job's skipDates[] so the normal schedule isn't disrupted. The job definition itself is not changed.

This and following occurrences (split)

  1. The current job's endDate is set to occurrenceDate - 1ms — it won't spawn past this point.
  2. The task template is cloned (so past instances keep their references).
  3. A new recurring job is created starting from today, pointing at the cloned template.
  4. The current instance is updated with the user's changes.

This means the series is physically split into two independent jobs. Past instances remain untouched.

All occurrences

  1. The task template is updated with the new title, description, priority, and tags.
  2. All past instances with the same seriesId and spawnedFromTemplateId are bulk-updated with the same fields (using updateMany).
  3. Future spawns will use the updated template.

Dates, statuses, and assignees of past instances are preserved — only the template-copied fields are overwritten.

Migration notes

If you have recurring jobs created before timezone support was added, a one-time migration (pnpm migrate:timezone) backfills a default timezone. The system handles missing timezones gracefully (defaults to UTC), but per-job timezone is required for correct daylight saving behaviour.