feat(events): schedule, recurrence and the calendar (Phase 4)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user