feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

@@ -12,14 +12,22 @@
// 3. **advance** — claim each due run and move it through its phases
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
//
// **What "materialise" means in this phase.** §E's tick materialises due
// occurrences from a recurrence; the spec validator accepts `kind: 'manual'`
// alone until Phase 4, so there is no recurrence to expand and the only
// occurrences that exist are the ones an admin created. What that leaves for this
// leg is the half that is already real and already needed: the grace window. A
// run whose instant passed while the process was down does not start late and
// silently — it becomes `missed`, which is terminal and which a human can see
// (§L). Phase 4 adds the expansion above it.
// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
// The first half EXPANDS: every `ready` definition's recurrence is computed in
// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
// real `scheduled` row (`INSERT IGNORE` against the occurrence key, so the tick
// that already made one makes nothing). The second half SWEEPS: a run whose
// instant passed while the process was down does not start late and silently —
// it becomes `missed`, which is terminal and which a human can see (§L).
//
// **The two halves need each other, and the horizon is why.** Expansion looks
// forward from `now - grace` only, so an occurrence nobody ever materialised is
// never invented retroactively — waking up after three days down must not
// manufacture three days of history that no operator could have seen or
// cancelled. It does not have to: because rows exist a fortnight ahead of their
// instant, an outage that spans an occurrence finds the row already there, and
// the sweep marks it `missed` honestly. The horizon is what makes the missed
// sweep mean anything for a recurring event.
//
// **Two properties this file must not lose**, both already paid for once on this
// codebase:
@@ -45,6 +53,9 @@ const runsDb = require('../model/events/eventRuns.db')
const stepsDb = require('../model/events/eventRunSteps.db')
const logDb = require('../model/events/eventRunLog.db')
const versionsDb = require('../model/events/eventVersions.db')
const definitionsDb = require('../model/events/eventDefinitions.db')
const runsModel = require('../model/events/eventRuns.model')
const recurrence = require('../events/recurrence')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const log = require('./logger')('event-runner')
@@ -59,6 +70,19 @@ const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50
const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25
// How far ahead a recurrence is turned into real rows (org lead, 2026-09-02).
// Fourteen days is a fortnight of occurrences an operator can see, cancel and
// reschedule ONE AT A TIME, which a projection is not — and it is short enough
// that a definition edited today affects almost everything still ahead of it.
// Beyond it the calendar projects rather than materialises, so a monthly event
// is still visible three weeks out without a row nobody will honour.
const HORIZON_DAYS = Number(process.env.EVENT_MATERIALISE_AHEAD_DAYS) || 14
// A bound on one definition's expansion in one tick, not a target. A daily
// schedule over a fortnight is fourteen; this is what stops a hand-written spec
// turning one tick into a thousand inserts.
const MAX_OCCURRENCES_PER_DEFINITION = 100
// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip |
// pause | abort_run` and names no `n`; it lives here, as one number an operator
// can change, rather than in a column no authoring surface would ever show.
@@ -397,6 +421,98 @@ async function processRun(run, now = new Date()) {
}
/** Occurrences that passed their own grace window while nothing was running (§L). */
/**
* Turn every `ready` definition's recurrence into rows inside the horizon.
*
* The first half of the materialise leg (§E). Answers how many occurrences were
* newly created, which is zero on almost every tick — the horizon moves fifteen
* seconds at a time, so a weekly event creates one row a week and answers
* `created: false` for the same fourteen occurrences in between.
*
* **The window starts at `now - grace`, not at `now`.** An occurrence whose
* instant just passed is still startable inside the definition's own grace
* window, and that is exactly the case of a definition published four minutes
* before its first occurrence. An occurrence older than that is not materialised
* at all rather than materialised-then-swept: a row nobody could ever have seen
* is not history, and writing one would put a `missed` event on the calendar for
* a date on which this deployment had no such event.
*
* **Expansion is per definition and one failure does not stop the sweep.** A
* spec written directly into the database with a shape the validator would have
* refused is a bad row, not a bad tick.
*/
async function expandSchedules(now) {
const definitions = await definitionsDb.findSchedulable()
let created = 0
for (const definition of definitions) {
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
const from = now.getTime() - Number(definition.grace_seconds || 0) * 1000
const to = now.getTime() + HORIZON_DAYS * recurrence.DAY_MS
let occurrences
try {
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', from, to, {
limit: MAX_OCCURRENCES_PER_DEFINITION,
})
} catch (err) {
log.error('could not expand a schedule', {
definition: definition.id,
timezone: definition.timezone,
message: err.message,
})
continue
}
for (const occurrence of occurrences) {
try {
// `runsModel.create` rather than a second insert path: it re-checks that
// the definition is still `ready` and the version still has phases, and
// it materialises the first phase's steps with their idempotency keys.
// Nobody is watching a scheduled occurrence, so it needs those checks
// more than a hand-started one does.
const result = await runsModel.create(
definition.id,
// Scope is empty, deliberately (org lead, 2026-09-02). A fan-out across
// named scopes needs a registry of what a scope IS, which no phase owns
// yet; inventing one here would be a contract the modules were never
// asked about. An admin's own start route still takes any scope.
{ scope: '', scheduledFor: occurrence.at, source: 'schedule' },
null,
)
if (!result.ok || !result.created) continue
created += 1
if (occurrence.adjusted) {
// Why the clock reads oddly, recorded where an operator will look for
// it rather than left to be rediscovered at 3am on the last Sunday in
// October.
await logDb.write({
runId: result.run.id,
kind: 'run.created',
detail: {
dstAdjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
timezone: definition.timezone,
scheduledFor: occurrence.at,
},
})
}
} catch (err) {
log.error('could not materialise an occurrence', {
definition: definition.id,
at: occurrence.at,
message: err.message,
})
}
}
}
if (created) log.info('occurrences materialised', { created, horizonDays: HORIZON_DAYS })
return created
}
async function sweepMissed(now) {
const missed = await runsDb.findMissed(now)
let n = 0
@@ -433,6 +549,12 @@ async function tick(now = new Date()) {
log.error('failed to reclaim stale claims', { message: err.message })
}
try {
await expandSchedules(now)
} catch (err) {
log.error('schedule expansion failed', { message: err.message })
}
try {
await sweepMissed(now)
} catch (err) {
@@ -507,11 +629,13 @@ module.exports = {
advanceRun,
drainStep,
sweepMissed,
expandSchedules,
prune,
OWNER,
POLL_MS,
MAX_ATTEMPTS,
RETRY_MS,
HORIZON_DAYS,
RUN_LEASE_MS,
LOG_RETENTION_DAYS,
RUN_TERMINAL,