feat(events): the runner (Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 5m29s

`utils/eventRunner.js`, the eighth poller, wired into server.js beside
engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their
grace window into `missed`, advances each due run through its phases, and drains
that phase's steps in `seq` order. The three core actions from Phase 1 get real
bodies, so a published event started from the existing run route now announces,
waits and completes on its own.

No routes are added: a runner has no surface, and the live controls stay Phase
3's.

Four things the org lead settled (2026-09-02): a parked step is `running` with a
NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members
rather than special cases keyed on an action id; a run whose concurrency key is
held stays `scheduled` and lets its grace window decide; and `n` in §L's
`retry(n)` is a runner constant.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 06:32:24 -05:00
parent d88906e43c
commit 2e964cfeee
10 changed files with 2527 additions and 41 deletions

View File

@@ -17,6 +17,12 @@ const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
// The statuses a run never leaves. A transition INTO one of these stamps
// `ended_at` and drops the claim, and only rows in one of them are eligible for
// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all
// only if every path a run can take reaches one of them.
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
const SELECT_LIST = `
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number
FROM event_runs r
@@ -102,4 +108,256 @@ const countActiveForDefinition = async (definitionId) => {
return Number(row?.n || 0)
}
module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition }
// ── Phase 2: the claim, the transitions and the reclaim ────────────────────
//
// Everything below is the runner's, and none of it existed in Phase 1 for a
// stated reason: a half-written claim is worse than no claim, because it reads
// as protection. It is written here now, in full.
//
// **The division of labour with the unique index has not changed.** The index one
// section up is what makes "one run per occurrence per scope" TRUE; the CAS below
// decides only WHO advances an occurrence that already exists. Neither substitutes
// for the other, and this deployment being single-instance (§N4) changes the test
// rather than the design — the same two protections are what keep a tick that
// overran into the next one from advancing a run twice.
/**
* Runs the runner should look at this tick: due, and not yet terminal.
*
* It selects rather than claims — `claimStart` and `claimTick` below are one row
* at a time — so two sweepers see the same candidates and then disagree,
* harmlessly, about which of them owns each. `idx_evrun_due (status,
* scheduled_for)` is this query.
*
* `paused` is absent from the status list on purpose. A paused run is waiting on
* a human and must not be advanced by a tick; the only thing that moves it is
* Phase 3's resume control.
*/
const findDue = async (now, limit = 50) => {
const n = Math.min(Math.max(Number(limit) || 50, 1), 500)
return (
await query(
`SELECT * FROM event_runs
WHERE status IN ('scheduled','starting','running','ending')
AND scheduled_for <= ?
ORDER BY scheduled_for, id
LIMIT ${n}`,
[now],
)
).map(hydrate)
}
/**
* Take ownership of a run that has not started: the CAS `scheduled -> starting`.
*
* Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE
* SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the
* row, every other sweeper gets 0 and moves on. No transaction to hold open and
* no MariaDB version floor.
*/
async function claimStart(id, owner, leaseUntil) {
const result = await query(
`UPDATE event_runs
SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
started_at = COALESCE(started_at, NOW())
WHERE id = ? AND status = 'scheduled'`,
[owner, leaseUntil, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Take a lease on a run already in flight, so one tick works on it at a time.
*
* Unlike `claimStart` this does not change `status` — the run is already
* `starting`, `running` or `ending`, and what is being claimed is the right to
* advance it.
*
* **A live lease is not re-enterable, not even by the process that took it**, and
* that is the whole point rather than an oversight. `setInterval` fires the next
* tick whether or not the last one has returned, so an owner-matches escape
* clause here would let one process advance one run twice at once — which is
* precisely the overrun the plan says the CAS is meant to protect against. A run
* this process still holds is a run this process is still working on; the tick
* skips it, and `releaseClaim` below is what ends that in the ordinary case.
*/
async function claimTick(id, owner, leaseUntil, now) {
const result = await query(
`UPDATE event_runs
SET claimed_by = ?, claim_expires_at = ?
WHERE id = ?
AND status IN ('starting','running','ending')
AND (claim_expires_at IS NULL OR claim_expires_at < ?)`,
[owner, leaseUntil, id, now],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Give a still-in-flight run back, so the next tick can pick it up at once.
*
* A run left parked on a GM cue, or waiting out a `core.wait`, is not finished
* and must not carry a lease: without this the run would be unadvanceable until
* the lease expired, which would turn every wait into `max(wait, leaseMs)`.
* Scoped to `claimed_by = ?` so a process can only release its own claim.
*/
async function releaseClaim(id, owner) {
const result = await query(
'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?',
[id, owner],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* A guarded status transition: `from -> to`, and only from `from`.
*
* Every move the runner makes goes through here rather than through a bare
* UPDATE, so "did this transition actually happen" is answerable at each call
* site. A `false` is not an error — it is another worker, or this run having been
* cancelled from the admin surface between the read and the write, which is a
* race Phase 3's live controls make ordinary.
*/
async function transition(id, from, to, { phase, error, clearClaim = false } = {}) {
const sets = ['status = ?']
const args = [to]
if (phase !== undefined) {
sets.push('current_phase = ?')
args.push(phase)
}
if (error !== undefined) {
sets.push('last_error = ?')
args.push(error === null ? null : String(error).slice(0, 500))
}
if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())')
if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL')
const froms = Array.isArray(from) ? from : [from]
const result = await query(
`UPDATE event_runs SET ${sets.join(', ')}
WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`,
[...args, id, ...froms],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Set health without touching status (§E).
*
* The two columns are separate because a run can be genuinely running and
* degraded at once — announcements landing, world writes parked — and one column
* cannot say both. Guarded on the current value so a tick that re-observes the
* same degradation does not restamp `updated_at`.
*/
async function setHealth(id, health) {
const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [
health,
id,
health,
])
return Number(result?.affectedRows || 0) === 1
}
/**
* Runs whose start instant passed more than their own grace window ago (§E, §L).
*
* The window is per definition, so the comparison is against `grace_seconds` on
* the joined row rather than against a constant here: an event whose announcement
* gives a fifteen-minute window and one that must start on the second are the
* same query with different data.
*
* Only `scheduled` runs qualify. A run that reached `starting` has begun, and
* "began and then stalled" is a different fact from "never began" — conflating
* them would let `missed` describe a run that had already announced itself.
*/
const findMissed = async (now, limit = 100) => {
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
return (
await query(
`SELECT r.* FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
WHERE r.status = 'scheduled'
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?
ORDER BY r.scheduled_for
LIMIT ${n}`,
[now],
)
).map(hydrate)
}
/**
* Is another run holding this concurrency key?
*
* The org lead's answer for a held key (2026-09-02) is to leave the run
* `scheduled` and let the grace window decide, so this is a READ rather than a
* claim: the caller holds off, logs which run holds the key, and tries again next
* tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a
* NULL key is skipped by that index — which is right, because a definition with
* no key never contends.
*/
const concurrencyHolder = async (key, exceptRunId) => {
if (!key) return null
const [row] = await query(
`SELECT id, status, definition_id FROM event_runs
WHERE concurrency_key = ?
AND id <> ?
AND status IN ('starting','running','paused','ending')
ORDER BY id LIMIT 1`,
[key, exceptRunId],
)
return row || null
}
/**
* Recover runs whose claim outlived the process that took it.
*
* **It does not change status and it touches no counter.** All it releases is the
* lease; the run stays exactly where it was and the next tick picks it up through
* `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep
* that returned a stale row to its start state made the attempt ceiling
* unreachable, so the row cycled forever, never terminal, and therefore never
* eligible for any retention sweep.
*/
const reclaimStale = async (now) => {
const result = await query(
`UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL
WHERE status IN ('starting','running','ending')
AND claim_expires_at IS NOT NULL
AND claim_expires_at < ?`,
[now],
)
return Number(result?.affectedRows || 0)
}
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
const terminalBefore = async (before, limit = 500) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
return (
await query(
`SELECT id FROM event_runs
WHERE status IN (${TERMINAL.map(() => '?').join(',')})
AND COALESCE(ended_at, updated_at) < ?
ORDER BY id LIMIT ${n}`,
[...TERMINAL, before],
)
).map((r) => Number(r.id))
}
module.exports = {
list,
getById,
materialise,
findOccurrence,
countActiveForDefinition,
findDue,
findMissed,
claimStart,
claimTick,
releaseClaim,
transition,
setHealth,
concurrencyHolder,
reclaimStale,
terminalBefore,
TERMINAL,
}