Files
website/server/test/eventRunnerSql.test.js
wtclaude 9bc0bf5a3d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:20 -05:00

1100 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The runner's raw SQL, against a real MariaDB ───────────────────────────
//
// EVENTS_PLAN.md Phase 2. `eventRunner.test.js` stubs the three tables and
// exercises everything the runner DECIDES. It cannot prove the statements whose
// whole correctness is a server contract, and on this codebase that gap has
// already cost something once: engagement's cooldown claim was green against its
// stub and always allowed the send against a real server, because the connector
// defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0.
//
// So the five statements that decide who owns what run here, for real:
//
// • **`claimStart`** — the CAS `scheduled -> starting`. "Exactly one winner" is
// `affectedRows = 1` for one caller and 0 for every other, and that is a
// property of the SERVER's answer, not of the SQL's shape.
// • **`claimTick`** — the same, for a run already in flight, and with no
// owner-matches escape clause. A live lease must refuse its own holder, or
// one `setInterval` that overran advances one run twice.
// • **`transition`** — a guarded status move. The guard is the whole thing: a
// run cancelled between the read and the write must not be transitioned.
// • **`reclaimStale`** on steps — two statements in a fixed ORDER, give-up
// before hand-back. Reversing them makes `MAX_ATTEMPTS` unreachable and the
// row cycles forever (Engagement Phase 14's defect), and **neither statement
// may touch `attempts`**.
// • **`holdNext`** — `UPDATE ... ORDER BY seq LIMIT 1` with a guard, which is
// both a MariaDB-specific syntax and a correctness claim: it must move the
// next PENDING step and only ever push a due date later.
//
// **Phase 3 added four more**, and each of them is a control a staff member
// presses against a live game world:
//
// * **`confirmParked` / `skipByHuman`** - both keyed on `status = 'running'
// AND claim_expires_at IS NULL`. That pair, and only that pair, means "a cue
// waiting on a human". If the clause let a LEASED step through, confirm would
// race the process mid-dispatch on that row.
// * **`cancelOpen`** - pending steps and parked cues, never a leased one.
// * **`lastStartedSeq`** - a `MAX(seq) ... WHERE status <> 'pending'`, which is
// what decides whether retry is offered. The first draft asked for the LOWEST
// unsettled seq instead, which is a different step whenever a phase carried
// on past an `on_failure: skip` failure.
//
// **Phase 4 added two reads**, and a read earns a place here when a stub cannot
// tell it is wrong:
//
// * **`findSchedulable`** - the query the runner runs on EVERY tick to decide
// what has a recurrence to expand. It joins a definition to its published
// version and left-joins the series, and every stub of it in
// `eventSchedule.test.js` is a hand-written object rather than that join. A
// syntax error or a wrong join direction here is a runner that materialises
// nothing, silently, for ever.
// * **`repinScheduled`** - the UPDATE publish runs over already-materialised
// occurrences. Its guard is the whole statement, and the two rows it must
// NOT touch are a run that has started and a run that is already terminal.
// * **`listInWindow`** - the calendar's real half, with a correlated subquery
// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
// series.
//
// **Phase 5 added the gate statements**, and the increment is the one on this
// list with the closest precedent: engagement's cooldown claim was green against
// its stub and always allowed the send against a real server, because the
// connector defaults `foundRows: true`. A gate's tally is the same shape of
// claim — a conditional UPDATE whose answer decides something — so it is proved
// here rather than believed.
//
// * **`GATE_COUNT`** - `tally = tally + 1` with `CASE WHEN tally + 1 >=
// needed` inside the same statement, guarded on `satisfied_at IS NULL`. Two
// emits arriving together must each add one and exactly ONE of them must
// cross the threshold; a read-then-write would let both see 2 of 3. **This
// is the statement that failed here first**: MariaDB evaluates SET
// assignments left to right with the values already assigned, so an
// increment written before the CASE made the CASE read the new tally and
// close a two-firing gate on the first firing. The ORDER of the SET list is
// load-bearing, and only a real server says so.
// * **`GATE_SATISFY`** - the same guard for the two reasons that are not a
// firing: an `after` deadline passing, and a human forcing it. A force that
// races the tick must lose harmlessly.
// * **`GATE_OPEN_FOR_TRIGGER`** - the emit path's only query, and the one
// index in this feature on a hot path. Its JOIN is what stops a gate
// belonging to a cancelled run counting for ever.
// * **`uq_evgate_phase`** - what makes `open` an INSERT IGNORE, so a process
// that died between entering a phase and opening its gate opens no second
// one on the next tick.
//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
// a phase a no-op).
//
// **It SKIPS when there is no database**, deliberately: CI runs the suite with
// the pool pointed at a dead port, and a file that failed there would make every
// PR red for a reason unrelated to itself. Run it against this machine's
// container with:
//
// DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=... DB_PASSWORD=... \
// node --test test/eventRunnerSql.test.js
//
// It creates its tables in a throwaway database named after the process and
// drops it again, so it can never touch a real schema.
const { test, before, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const mariadb = require('mariadb')
// Trimmed to the columns these statements read or write. The ENUMs are verbatim,
// because "is `missed` a legal value" is one of the things being proved.
const SCHEMA = `
CREATE TABLE event_series (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(160) NOT NULL,
slug VARCHAR(160) NOT NULL,
ordering INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL DEFAULT 'x',
slug VARCHAR(200) NOT NULL DEFAULT 'x',
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
current_version_id INT NULL,
series_id INT NULL,
concurrency_key VARCHAR(190) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
grace_seconds INT NOT NULL DEFAULT 900
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version INT NOT NULL DEFAULT 1,
spec JSON NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version_id INT NOT NULL,
scope VARCHAR(190) NOT NULL DEFAULT '',
status ENUM('scheduled','starting','running','paused','ending',
'completed','cancelled','failed','missed')
NOT NULL DEFAULT 'scheduled',
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
current_phase VARCHAR(64) NULL,
scheduled_for DATETIME NOT NULL,
concurrency_key VARCHAR(190) NULL,
started_at DATETIME NULL,
ended_at DATETIME NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
INDEX idx_evrun_due (status, scheduled_for),
INDEX idx_evrun_concurrency (concurrency_key, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_run_steps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
phase VARCHAR(64) NOT NULL,
seq INT NOT NULL,
action_id VARCHAR(96) NOT NULL,
params JSON NULL,
action_version INT NOT NULL DEFAULT 1,
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
NOT NULL DEFAULT 'pending',
due_at DATETIME NULL,
attempts INT NOT NULL DEFAULT 0,
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
idempotency_key CHAR(40) NOT NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
last_error VARCHAR(500) NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_evstep_slot (run_id, phase, seq),
INDEX idx_evstep_due (status, due_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_run_phase_gates (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
phase VARCHAR(64) NOT NULL,
kind ENUM('after','on') NOT NULL,
after_seconds INT NULL,
trigger_id VARCHAR(96) NULL,
conditions JSON NULL,
needed INT NOT NULL DEFAULT 1,
tally INT NOT NULL DEFAULT 0,
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_at DATETIME NULL,
last_event JSON NULL,
last_event_at DATETIME NULL,
satisfied_at DATETIME NULL,
satisfied_by VARCHAR(16) NULL,
forced_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
UNIQUE KEY uq_evgate_phase (run_id, phase),
INDEX idx_evgate_open (trigger_id, satisfied_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`
// The statements under test, verbatim from `eventRuns.db.js` and
// `eventRunSteps.db.js`. Duplicated rather than required, because requiring the
// modules would drag in `utils/db`'s pool, which the harness has already pointed
// at a dead port. The pool below leaves `foundRows` at the connector's default,
// exactly as `utils/db.js` does — pinning it here would make this file agree with
// the code by construction and prove nothing about the pool the server runs.
const CLAIM_START = `
UPDATE event_runs
SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
started_at = COALESCE(started_at, NOW())
WHERE id = ? AND status = 'scheduled'`
const CLAIM_TICK = `
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 < ?)`
const TRANSITION = `
UPDATE event_runs SET status = ?, current_phase = ?
WHERE id = ? AND status IN (?)`
const CLAIM_STEP = `
UPDATE event_run_steps
SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
started_at = COALESCE(started_at, NOW())
WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`
const STEP_GIVE_UP = `
UPDATE event_run_steps
SET status = 'failed', last_error = 'gave up after repeated interruptions',
finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
WHERE status = 'running'
AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
AND attempts >= ?`
const STEP_RECLAIM = `
UPDATE event_run_steps
SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`
const HOLD_NEXT = `
UPDATE event_run_steps
SET due_at = ?
WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
AND (due_at IS NULL OR due_at < ?)
ORDER BY seq LIMIT 1`
// Phase 3's four, verbatim from `eventRunSteps.db.js`.
const CONFIRM_PARKED = `
UPDATE event_run_steps
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`
const SKIP_BY_HUMAN = `
UPDATE event_run_steps
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const REQUEUE = `
UPDATE event_run_steps
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
WHERE id = ? AND status = 'failed'`
const CANCEL_OPEN = `
UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
WHERE run_id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const LAST_STARTED_SEQ = `
SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
// Phase 5's four, verbatim from `eventPhaseGates.db.js`.
const GATE_OPEN = `
INSERT IGNORE INTO event_run_phase_gates
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
const GATE_COUNT = `
UPDATE event_run_phase_gates
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
last_event = ?,
last_event_at = ?,
tally = tally + 1
WHERE id = ? AND satisfied_at IS NULL`
const GATE_SATISFY = `
UPDATE event_run_phase_gates
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
WHERE id = ? AND satisfied_at IS NULL`
const GATE_OPEN_FOR_TRIGGER = `
SELECT g.* FROM event_run_phase_gates g
JOIN event_runs r ON r.id = g.run_id
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
AND r.status IN ('running','paused')
AND r.current_phase = g.phase
LIMIT 200`
const MATERIALISE_RUN = `
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
VALUES (?, ?, ?, ?, ?)`
const MATERIALISE_STEP = `
INSERT IGNORE INTO event_run_steps (run_id, phase, seq, action_id, idempotency_key)
VALUES (?, ?, ?, ?, ?)`
const FIND_MISSED = `
SELECT r.id 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 < ?`
// Verbatim `eventDefinitions.db#findSchedulable`.
const FIND_SCHEDULABLE = `
SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
d.current_version_id, d.series_id, v.spec AS version_spec,
s.name AS series_name, s.slug AS series_slug
FROM event_definitions d
JOIN event_versions v ON v.id = d.current_version_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE d.state = 'ready'
ORDER BY d.id`
// Verbatim `eventRuns.db#listInWindow`, with no optional filter applied.
const LIST_IN_WINDOW = `
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
v.version AS version_number,
(SELECT COUNT(*) FROM event_run_steps s
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
JOIN event_versions v ON v.id = r.version_id
LEFT JOIN event_series se ON se.id = d.series_id
WHERE r.scheduled_for >= ? AND r.scheduled_for < ?
ORDER BY r.scheduled_for, r.id
LIMIT 500`
// Verbatim `eventRuns.db#repinScheduled`.
const REPIN_SCHEDULED = `
UPDATE event_runs
SET version_id = ?
WHERE definition_id = ?
AND status = 'scheduled'
AND started_at IS NULL
AND version_id <> ?`
const DB = `rg_events_test_${process.pid}`
let pool = null
let available = false
const poolOpts = () => ({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
})
before(async () => {
const admin = mariadb.createPool({
...poolOpts(),
connectionLimit: 1,
connectTimeout: 2000,
initializationTimeout: 2000,
multipleStatements: true,
})
try {
await admin.query(`CREATE DATABASE ${DB}`)
available = true
} catch {
available = false
} finally {
await admin.end().catch(() => {})
}
if (!available) return
pool = mariadb.createPool({
...poolOpts(),
database: DB,
connectionLimit: 3,
multipleStatements: true,
bigIntAsNumber: true,
insertIdAsNumber: true,
})
await pool.query(SCHEMA)
})
after(async () => {
if (pool) {
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
await pool.end().catch(() => {})
}
})
// Checked INSIDE each test, never as a `{ skip }` option: the option is evaluated
// when the file is read, which is before `before()` has had a chance to find out
// whether there is a database. Every test skipped unconditionally is what that
// mistake looks like, and it looks exactly like a passing suite.
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
const needDb = (t) => {
if (available) return false
t.skip(SKIP)
return true
}
const T0 = new Date('2026-09-02T12:00:00Z')
const later = (ms) => new Date(T0.getTime() + ms)
const rows = (r) => Number(r.affectedRows)
beforeEach(async () => {
if (!available) return
await pool.query('DELETE FROM event_run_phase_gates')
await pool.query('DELETE FROM event_run_steps')
await pool.query('DELETE FROM event_runs')
await pool.query('DELETE FROM event_definitions')
await pool.query('DELETE FROM event_versions')
await pool.query('DELETE FROM event_series')
})
async function seedRun(over = {}) {
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (?)', [
over.graceSeconds ?? 900,
])
const r = await pool.query(
`INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, concurrency_key,
claimed_by, claim_expires_at)
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`,
[
def.insertId,
over.scope ?? '',
over.status ?? 'scheduled',
over.scheduledFor ?? T0,
over.concurrencyKey ?? null,
over.claimedBy ?? null,
over.claimExpiresAt ?? null,
],
)
return { runId: r.insertId, definitionId: def.insertId }
}
const seedStep = async (runId, over = {}) =>
(
await pool.query(
`INSERT INTO event_run_steps (run_id, phase, seq, action_id, status, due_at, attempts,
claimed_by, claim_expires_at, idempotency_key)
VALUES (?, ?, ?, 'test.noop', ?, ?, ?, ?, ?, ?)`,
[
runId,
over.phase ?? 'main',
over.seq ?? 0,
over.status ?? 'pending',
over.dueAt ?? null,
over.attempts ?? 0,
over.claimedBy ?? null,
over.claimExpiresAt ?? null,
over.key ?? 'k'.repeat(40),
],
)
).insertId
const stepById = async (id) => (await pool.query('SELECT * FROM event_run_steps WHERE id = ?', [id]))[0]
const runById = async (id) => (await pool.query('SELECT * FROM event_runs WHERE id = ?', [id]))[0]
// ── claimStart: exactly one winner ─────────────────────────────────────────
test('claimStart: the first caller wins and every other gets zero', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun()
const first = await pool.query(CLAIM_START, ['host:1', later(60_000), runId])
const second = await pool.query(CLAIM_START, ['host:2', later(60_000), runId])
assert.equal(rows(first), 1, 'the winner is told 1')
assert.equal(rows(second), 0, 'the loser is told 0, not 1 with foundRows')
assert.equal((await runById(runId)).claimed_by, 'host:1')
assert.equal((await runById(runId)).status, 'starting')
})
test('claimStart: started_at is stamped once and never moved', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun()
await pool.query(CLAIM_START, ['host:1', later(60_000), runId])
const first = (await runById(runId)).started_at
await pool.query("UPDATE event_runs SET status = 'scheduled' WHERE id = ?", [runId])
await pool.query(CLAIM_START, ['host:2', later(60_000), runId])
assert.deepEqual((await runById(runId)).started_at, first, 'COALESCE keeps the original instant')
})
// ── claimTick: a live lease refuses even its own holder ────────────────────
test('claimTick: a live lease is not re-enterable by the process that took it', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000) })
const again = await pool.query(CLAIM_TICK, ['host:1', later(120_000), runId, T0])
assert.equal(rows(again), 0, 'a tick that overran must not advance its own run twice')
})
test('claimTick: an expired lease is takeable, by anyone', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(-60_000) })
const taken = await pool.query(CLAIM_TICK, ['host:2', later(60_000), runId, T0])
assert.equal(rows(taken), 1)
assert.equal((await runById(runId)).claimed_by, 'host:2')
})
test('claimTick: a paused run is never claimable', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'paused' })
assert.equal(rows(await pool.query(CLAIM_TICK, ['host:1', later(60_000), runId, T0])), 0)
})
// ── transition: the guard is the whole point ───────────────────────────────
test('transition: a run cancelled underneath the tick is not transitioned', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
const moved = await pool.query(TRANSITION, ['completed', 'main', runId, 'running'])
assert.equal(rows(moved), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})
test('transition: running -> running is a guarded write, not a no-op', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
// This is how the runner advances `current_phase`, and `foundRows` is exactly
// what makes it report 1 despite `status` not changing — which is the answer
// the caller needs, because what it is checking is that the run is STILL
// running, not that the status moved.
const moved = await pool.query(TRANSITION, ['running', 'closing', runId, 'running'])
assert.equal(rows(moved), 1)
assert.equal((await runById(runId)).current_phase, 'closing')
})
// ── The step claim ─────────────────────────────────────────────────────────
test('the step claim: one winner, and attempts is incremented by the claim alone', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const stepId = await seedStep(runId)
assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 1)
assert.equal(rows(await pool.query(CLAIM_STEP, ['host:2', later(60_000), stepId, T0])), 0)
assert.equal((await stepById(stepId)).attempts, 1, 'one claim, one attempt')
})
test('the step claim: a step held behind a core.wait is not due', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const stepId = await seedStep(runId, { dueAt: later(300_000) })
assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 0)
assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(360_000), stepId, later(301_000)])), 1)
})
// ── The reclaim: order, and what it must not touch ─────────────────────────
test('the reclaim hands a stale step back WITHOUT resetting attempts', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const stepId = await seedStep(runId, { status: 'running', attempts: 2, claimExpiresAt: later(-1000) })
await pool.query(STEP_GIVE_UP, [T0, 3])
await pool.query(STEP_RECLAIM, [T0])
const step = await stepById(stepId)
assert.equal(step.status, 'pending')
assert.equal(step.attempts, 2, 'Engagement Phase 14: a reclaim that reset this made MAX_ATTEMPTS unreachable')
})
test('the reclaim gives up FIRST, so a spent step leaves running as failed', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const stepId = await seedStep(runId, { status: 'running', attempts: 3, claimExpiresAt: later(-1000) })
const gaveUp = await pool.query(STEP_GIVE_UP, [T0, 3])
const reclaimed = await pool.query(STEP_RECLAIM, [T0])
assert.equal(rows(gaveUp), 1)
assert.equal(rows(reclaimed), 0, 'reversing these two makes the row retry forever')
assert.equal((await stepById(stepId)).status, 'failed')
assert.equal((await stepById(stepId)).attempts, 3)
})
test('the reclaim leaves a PARKED step alone, however long it waits', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const stepId = await seedStep(runId, { status: 'running', attempts: 1, claimExpiresAt: null })
await pool.query(STEP_GIVE_UP, [later(365 * 24 * 3600 * 1000), 3])
await pool.query(STEP_RECLAIM, [later(365 * 24 * 3600 * 1000)])
const step = await stepById(stepId)
assert.equal(step.status, 'running', 'a NULL lease is a parked cue, not staleness')
assert.equal(step.attempts, 1)
})
// ── holdNext ───────────────────────────────────────────────────────────────
test('holdNext moves the next PENDING step of the phase, and only one', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'done' })
const second = await seedStep(runId, { seq: 1 })
const third = await seedStep(runId, { seq: 2 })
const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
assert.equal(rows(moved), 1)
assert.deepEqual((await stepById(second)).due_at, later(300_000))
assert.equal((await stepById(third)).due_at, null, 'a wait holds the next step, not the rest of the phase')
})
test('holdNext never pulls a due date earlier, so a re-dispatch cannot double the wait', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'done' })
const second = await seedStep(runId, { seq: 1, dueAt: later(600_000) })
const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
assert.equal(rows(moved), 0)
assert.deepEqual((await stepById(second)).due_at, later(600_000))
})
test('holdNext skips a step that is already running', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'done' })
await seedStep(runId, { seq: 1, status: 'running' })
const third = await seedStep(runId, { seq: 2 })
await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
assert.deepEqual((await stepById(third)).due_at, later(300_000))
})
// ── The two unique indexes that carry the weight ───────────────────────────
test('uq_evrun_occurrence, not the claim, is what stops two runs of one occurrence', async (t) => {
if (needDb(t)) return
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
const a = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
const b = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
assert.equal(rows(a), 1)
assert.equal(rows(b), 0, 'INSERT IGNORE answers honestly rather than raising a 1062')
const all = await pool.query('SELECT COUNT(*) AS n FROM event_runs')
assert.equal(Number(all[0].n), 1)
})
test("scope '' rather than NULL is what makes that index work at all", async (t) => {
if (needDb(t)) return
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
// The empty-string case collides, as it must. A NULL scope would NOT: multiple
// NULLs do not collide in MariaDB, which would silently permit two runs of one
// occurrence — the reason the column is NOT NULL DEFAULT ''.
await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])), 0)
// Two different scopes are two different occurrences, which is what lets a
// worldwide event fan out across servers without colliding with itself.
assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, 'atlantic', T0, null])), 1)
})
test('uq_evstep_slot makes re-materialising a phase a no-op', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'a'.repeat(40)])), 1)
assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'b'.repeat(40)])), 0)
const [step] = await pool.query('SELECT idempotency_key FROM event_run_steps WHERE run_id = ?', [runId])
assert.equal(step.idempotency_key, 'a'.repeat(40), 'a re-materialise cannot overwrite a key a dispatch already sent')
})
// ── The grace window is per definition ─────────────────────────────────────
test('findMissed compares against each definitions own grace window', async (t) => {
if (needDb(t)) return
const tight = await seedRun({ graceSeconds: 600 })
const generous = await seedRun({ graceSeconds: 3600, scope: 'b' })
const missed = (await pool.query(FIND_MISSED, [later(30 * 60 * 1000)])).map((r) => Number(r.id))
assert.deepEqual(missed, [tight.runId])
assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed')
})
// -- Phase 3: the controls a human presses ----------------------------------
test('confirm resolves a parked cue and cannot touch a step being dispatched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { seq: 0, status: 'running', claimedBy: 'host:1', claimExpiresAt: null })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000), key: 'x'.repeat(40) })
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['gate opened', parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['nope', busy])), 0, 'a live lease is a step somebody owns')
assert.equal((await stepById(parked)).status, 'done')
assert.equal((await stepById(parked)).last_error, 'gate opened')
assert.equal((await stepById(busy)).status, 'running')
})
test('a confirm of an already-confirmed cue reports 0, not 1', async (t) => {
if (needDb(t)) return
// The engagement Phase 4a shape: a connector that defaults `foundRows: true`
// reports 1 for an UPDATE that matched and changed nothing, and a control that
// read that as success would tell a second staff member their press worked.
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { status: 'running', claimExpiresAt: null })
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 0)
})
test('skip takes a pending step and a parked cue, and refuses a leased one', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const pending = await seedStep(runId, { seq: 0, status: 'pending' })
const parked = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: null, key: 'y'.repeat(40) })
const busy = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: later(60_000), key: 'z'.repeat(40) })
const failed = await seedStep(runId, { seq: 3, status: 'failed', key: 'w'.repeat(40) })
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, pending])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, parked])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, busy])), 0)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, failed])), 0, 'a failed step is terminal; resume carries the phase past it')
})
test('cancelOpen closes pending steps and parked cues, and leaves a leased one alone', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const done = await seedStep(runId, { seq: 0, status: 'done' })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: later(60_000), key: 'p'.repeat(40) })
const parked = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: null, key: 'q'.repeat(40) })
const pending = await seedStep(runId, { seq: 3, status: 'pending', key: 'r'.repeat(40) })
assert.equal(rows(await pool.query(CANCEL_OPEN, [runId])), 2)
assert.equal((await stepById(done)).status, 'done')
assert.equal((await stepById(busy)).status, 'running', 'nothing can recall a command already sent')
assert.equal((await stepById(parked)).status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
assert.equal((await stepById(pending)).status, 'cancelled')
})
test('requeue only takes a failed step, and puts attempts back to zero', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'paused' })
const failed = await seedStep(runId, { seq: 0, status: 'failed', attempts: 3 })
const pending = await seedStep(runId, { seq: 1, status: 'pending', key: 's'.repeat(40) })
assert.equal(rows(await pool.query(REQUEUE, [failed])), 1)
assert.equal(rows(await pool.query(REQUEUE, [pending])), 0)
const row = await stepById(failed)
assert.equal(row.status, 'pending')
assert.equal(Number(row.attempts), 0)
assert.equal(row.due_at, null, 'a re-queued step is due now, not at the retry backoff it was left on')
})
test('lastStartedSeq names the furthest step of the phase, not the earliest unsettled one', async (t) => {
if (needDb(t)) return
// The defect this replaced: a phase that carried on past a failed step (an
// `on_failure` of `skip`) and then paused at a later one. "The lowest seq that
// is not settled" answers with the FIRST failure - a step the runner has long
// since stepped over - and retry would re-queue a row behind its own cursor.
const { runId } = await seedRun({ status: 'paused' })
await seedStep(runId, { seq: 0, status: 'failed', key: 'a'.repeat(40) })
await seedStep(runId, { seq: 1, status: 'done', key: 'b'.repeat(40) })
await seedStep(runId, { seq: 2, status: 'failed', key: 'c'.repeat(40) })
await seedStep(runId, { seq: 3, status: 'pending', key: 'd'.repeat(40) })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(Number(row.seq), 2)
})
test('lastStartedSeq is NULL for a phase nothing has touched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'pending' })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(row.seq, null, 'a null must read as "nothing to retry", not as seq 0')
})
test('a guarded transition refuses a run that was cancelled underneath it', async (t) => {
if (needDb(t)) return
// What the admin cancel looks like from the runner's side, mid-tick: the
// guarded write returns 0 and the tick treats the run as taken rather than
// advancing a run somebody has just stopped.
const { runId } = await seedRun({ status: 'running' })
await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})
// ── Phase 4: the two reads ────────────────────────────────────────
/** A definition with a published version, and optionally a series. */
async function seedDefinition({ state = 'ready', spec = { schedule: { kind: 'manual' } }, series = null } = {}) {
let seriesId = null
if (series) {
const s = await pool.query('INSERT INTO event_series (name, slug) VALUES (?, ?)', [series, series])
seriesId = s.insertId
}
const d = await pool.query(
'INSERT INTO event_definitions (state, series_id, timezone) VALUES (?, ?, ?)',
[state, seriesId, 'Europe/Berlin'],
)
const v = await pool.query(
'INSERT INTO event_versions (definition_id, version, spec) VALUES (?, 1, ?)',
[d.insertId, JSON.stringify(spec)],
)
await pool.query('UPDATE event_definitions SET current_version_id = ? WHERE id = ?', [
v.insertId,
d.insertId,
])
return { definitionId: d.insertId, versionId: v.insertId, seriesId }
}
test('findSchedulable returns ready definitions with their PUBLISHED spec, series or not', async (t) => {
if (needDb(t)) return
const withSeries = await seedDefinition({
spec: { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' } },
series: 'royal-spy',
})
const withoutSeries = await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
const found = await pool.query(FIND_SCHEDULABLE)
const ids = found.map((r) => r.id).sort((a, b) => a - b)
assert.deepEqual(ids, [withSeries.definitionId, withoutSeries.definitionId].sort((a, b) => a - b))
// The LEFT JOIN must not drop the definition that belongs to no arc — an
// inner join here would make every event outside a series unschedulable, and
// most events are outside one.
const plain = found.find((r) => r.id === withoutSeries.definitionId)
assert.equal(plain.series_name, null)
const arced = found.find((r) => r.id === withSeries.definitionId)
assert.equal(arced.series_name, 'royal-spy')
assert.equal(arced.timezone, 'Europe/Berlin')
// The spec really came back, and really came back parseable.
const spec = typeof arced.version_spec === 'string' ? JSON.parse(arced.version_spec) : arced.version_spec
assert.equal(spec.schedule.kind, 'weekly')
})
test('findSchedulable skips a draft, an archived one, and one with no published version', async (t) => {
if (needDb(t)) return
await seedDefinition({ state: 'draft' })
await seedDefinition({ state: 'archived' })
// `ready` with a dangling version pointer: the JOIN is what must drop it, and
// a definition whose version row went missing must not become a runner crash.
const orphan = await seedDefinition({ state: 'ready' })
await pool.query('DELETE FROM event_versions WHERE id = ?', [orphan.versionId])
assert.equal((await pool.query(FIND_SCHEDULABLE)).length, 0)
})
test('listInWindow is half-open on the window, and counts only PARKED steps as waiting', async (t) => {
if (needDb(t)) return
const def = await seedDefinition()
const at = async (when) => {
const r = await pool.query(
'INSERT INTO event_runs (definition_id, version_id, scope, scheduled_for) VALUES (?, ?, ?, ?)',
[def.definitionId, def.versionId, '', when],
)
return r.insertId
}
const before = await at(new Date('2026-09-01T00:00:00Z'))
const onFrom = await at(new Date('2026-09-02T00:00:00Z'))
const inside = await at(new Date('2026-09-05T00:00:00Z'))
const onTo = await at(new Date('2026-09-09T00:00:00Z'))
// `>= from AND < to` — the instant ON the upper bound belongs to the NEXT
// window. A closed interval would draw the last day of one month and the first
// of the next as the same occurrence twice.
const found = await pool.query(LIST_IN_WINDOW, [
new Date('2026-09-02T00:00:00Z'),
new Date('2026-09-09T00:00:00Z'),
])
assert.deepEqual(found.map((r) => r.id), [onFrom, inside])
assert.ok(!found.some((r) => r.id === before || r.id === onTo))
// A parked step is `running` with a NULL lease; a leased one is the runner
// mid-dispatch and is not waiting on anybody.
await seedStep(inside, { status: 'running', claimExpiresAt: null, key: 'a'.repeat(40) })
await seedStep(inside, {
seq: 1,
status: 'running',
claimedBy: 'host',
claimExpiresAt: later(60_000),
key: 'b'.repeat(40),
})
const again = await pool.query(LIST_IN_WINDOW, [
new Date('2026-09-02T00:00:00Z'),
new Date('2026-09-09T00:00:00Z'),
])
assert.equal(Number(again.find((r) => r.id === inside).waiting_steps), 1)
})
test('repinScheduled moves the occurrences that have not begun, and only those', async (t) => {
if (needDb(t)) return
// The case: an editor fixes a typo on a weekly event on Wednesday. Two Fridays
// are already materialised on v3, last Friday's run is finished, and one is in
// flight right now.
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
const mk = async (status, versionId, startedAt, when) =>
(
await pool.query(
`INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[def.insertId, versionId, `s${when}`, status, T0, startedAt],
)
).insertId
const ahead1 = await mk('scheduled', 3, null, 1)
const ahead2 = await mk('scheduled', 3, null, 2)
const running = await mk('running', 3, T0, 3)
const done = await mk('completed', 3, T0, 4)
// Already on the new version: excluded by `version_id <> ?`, so a second
// publish of an unchanged definition is not a fleet of pointless writes.
const already = await mk('scheduled', 4, null, 5)
const moved = rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4]))
assert.equal(moved, 2)
const versionOf = async (id) =>
Number((await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [id]))[0].version_id)
assert.equal(await versionOf(ahead1), 4)
assert.equal(await versionOf(ahead2), 4)
// A run that has begun keeps the version it pinned, for ever: that pin is what
// makes it explicable afterwards.
assert.equal(await versionOf(running), 3)
assert.equal(await versionOf(done), 3)
assert.equal(await versionOf(already), 4)
})
test('a scheduled run whose started_at is somehow set is left alone', async (t) => {
if (needDb(t)) return
// Belt and braces on the guard: `status = 'scheduled'` and `started_at IS NULL`
// are two conditions rather than one because a row that has both is the only
// row that is provably untouched.
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
const r = await pool.query(
`INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
VALUES (?, 3, '', 'scheduled', ?, ?)`,
[def.insertId, T0, T0],
)
assert.equal(rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])), 0)
const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
assert.equal(Number(after.version_id), 3)
})
// ── Phase 5: the gate statements ───────────────────────────────────────────
/** A run in `running` on one phase, plus an open gate for it. */
async function seedGate({ kind = 'on', needed = 1, trigger = 'uo.champ.boss_up', status = 'running', phase = 'boss' } = {}) {
const { runId } = await seedRun({ status })
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', [phase, runId])
const g = await pool.query(GATE_OPEN, [
runId,
phase,
kind,
kind === 'after' ? 1800 : null,
kind === 'on' ? trigger : null,
kind === 'on' ? JSON.stringify({ variable: 'region', cmp: 'eq', value: 'Yew' }) : null,
needed,
T0,
kind === 'after' ? later(1800_000) : null,
])
return { runId, gateId: Number(g.insertId) }
}
const gateById = async (id) => (await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [id]))[0]
test('two firings arriving together each count, and exactly one crosses the threshold', async (t) => {
if (needDb(t)) return
const { gateId } = await seedGate({ needed: 2 })
// The whole reason the CASE is inside the UPDATE: a read-then-write would let
// both of these see "1 of 2" and neither satisfy, or both satisfy and advance
// one phase twice.
const [a, b] = await Promise.all([
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
])
assert.equal(rows(a) + rows(b), 2, 'both increments land')
const gate = await gateById(gateId)
assert.equal(Number(gate.tally), 2)
assert.ok(gate.satisfied_at, 'and the second one closed it')
assert.equal(gate.satisfied_by, 'condition')
})
test('the threshold is read BEFORE the increment, not after it', async (t) => {
if (needDb(t)) return
// The defect this file was written to catch, and did: MariaDB evaluates SET
// assignments left to right with the values already assigned, so an increment
// placed FIRST makes the CASE after it read `new + 1 >= needed` and close a
// two-firing gate on the first firing. Every stub of this agrees with the
// intent rather than with the server, which is exactly why it survived one.
const { gateId } = await seedGate({ needed: 2 })
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
const half = await gateById(gateId)
assert.equal(Number(half.tally), 1)
assert.equal(half.satisfied_at, null, 'one of two is not two')
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
assert.ok((await gateById(gateId)).satisfied_at, 'and the second one is')
})
test('an increment against a closed gate is refused, not applied', async (t) => {
if (needDb(t)) return
const { gateId } = await seedGate({ needed: 1 })
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 1)
// `WHERE satisfied_at IS NULL` — and 0 rather than 1, which is the whole
// reason this file exists: `foundRows: true` would answer 1 for a no-op.
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 0)
assert.equal(Number((await gateById(gateId)).tally), 1, 'the tally does not keep climbing after the phase moved on')
})
test('a force and a deadline race the same guard, and only one of them wins', async (t) => {
if (needDb(t)) return
const { gateId } = await seedGate({ kind: 'after' })
const [forced, elapsed] = await Promise.all([
pool.query(GATE_SATISFY, [T0, 'forced', 7, gateId]),
pool.query(GATE_SATISFY, [T0, 'elapsed', null, gateId]),
])
assert.equal(rows(forced) + rows(elapsed), 1, 'exactly one of the two writes')
const gate = await gateById(gateId)
assert.ok(['forced', 'elapsed'].includes(gate.satisfied_by))
// Whichever won is the one in the row, and the log records that one — never
// both.
assert.equal(gate.satisfied_by === 'forced' ? Number(gate.forced_by) : gate.forced_by, gate.satisfied_by === 'forced' ? 7 : null)
})
test('opening a gate twice writes one row', async (t) => {
if (needDb(t)) return
const { runId } = await seedGate()
// The INSERT IGNORE that makes recovery from a process which died between
// entering a phase and opening its gate uneventful.
const again = await pool.query(GATE_OPEN, [runId, 'boss', 'on', null, 'uo.champ.boss_up', null, 1, later(60_000), null])
assert.equal(rows(again), 0)
const all = await pool.query('SELECT * FROM event_run_phase_gates WHERE run_id = ?', [runId])
assert.equal(all.length, 1)
assert.equal(new Date(all[0].entered_at).getTime(), T0.getTime(), 'and the first entry time is the one that stands')
})
test('the emit path sees only gates whose run is live and on that phase', async (t) => {
if (needDb(t)) return
const live = await seedGate()
const paused = await seedGate({ status: 'paused' })
const cancelled = await seedGate({ status: 'scheduled' })
await pool.query('UPDATE event_runs SET status = ? WHERE id = ?', ['cancelled', cancelled.runId])
const moved = await seedGate()
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', ['cleanup', moved.runId])
const closed = await seedGate()
await pool.query(GATE_SATISFY, [T0, 'forced', null, closed.gateId])
const found = (await pool.query(GATE_OPEN_FOR_TRIGGER, ['uo.champ.boss_up'])).map((g) => Number(g.id))
assert.deepEqual(found.sort((a, b) => a - b), [live.gateId, paused.gateId].sort((a, b) => a - b))
// `paused` counts: the world does not stop because an operator paused the
// console, and discarding firings during a pause would make pause destructive.
assert.ok(found.includes(paused.gateId))
})
test('a gate goes with its run', async (t) => {
if (needDb(t)) return
// ON DELETE CASCADE, which is what keeps an orphaned gate from outliving the
// run whose phase it was about.
const { runId, gateId } = await seedGate()
await pool.query('DELETE FROM event_run_steps WHERE run_id = ?', [runId])
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0)
})