feat(events): the resource ledger, leases and cleanup (Phase 8)
Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section F puts the duration bound and the two-events-one-target conflict check on core's side of the seam, and a lease verb per module would be both re-implemented once per module, advisory everywhere. - Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A spawn's ref does not exist until the module answers, so what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - The unique key was proved against a REAL MariaDB, because nothing else can prove it: whether multiple NULLs collide in a unique index, whether a STORED generated column is recomputed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 1. A lease was never given back at all. `core.lease` reserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease kept `cleanup_status = 'not_required'` and the cleanup leg -- which selected on `pending` -- never looked at it. 2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing sweep moved the run to `incomplete`, which took it out of the leg's own scan for ever. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (2) made the console lie. Spending every row's `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and the run page then rendered "3 attempts" beside resources nothing had ever tried. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,18 @@
|
||||
// that died between entering a phase and opening its gate opens no second
|
||||
// one on the next tick.
|
||||
//
|
||||
// **Phase 8 added the resource ledger**, and its unique key is the single most
|
||||
// server-dependent thing in this feature. "Two events cannot hold a lease on one
|
||||
// target" has to hold among LIVE rows only — last week's finished event must not
|
||||
// keep this week's from leasing the same rate — and MariaDB has no partial index,
|
||||
// so the encoding is a STORED generated column that goes NULL once the row is no
|
||||
// longer ours. Whether multiple NULLs collide in a unique index is a property of
|
||||
// the server and of nothing else, and TEAMS.md §2.5 already had to be corrected
|
||||
// once on this exact shape: MariaDB refuses ON DELETE SET NULL on a foreign key
|
||||
// whose column is a base column of a stored generated column (error 1901), which
|
||||
// is why the expression reads `status` alone and `step_id` stays a SET NULL FK.
|
||||
// Both halves of that are proved below rather than believed.
|
||||
//
|
||||
// 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
|
||||
@@ -208,6 +220,29 @@ CREATE TABLE event_run_budget (
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_run_resources (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
step_id BIGINT NULL,
|
||||
owner_module VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
ref VARCHAR(190) NOT NULL,
|
||||
payload JSON NULL,
|
||||
lease_until DATETIME NULL,
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
revert_attempts INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
member_key VARCHAR(190) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
|
||||
INDEX idx_evres_run (run_id, status),
|
||||
INDEX idx_evres_live (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
@@ -438,6 +473,7 @@ 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_resources')
|
||||
await pool.query('DELETE FROM event_run_steps')
|
||||
await pool.query('DELETE FROM event_runs')
|
||||
await pool.query('DELETE FROM event_definitions')
|
||||
@@ -1139,7 +1175,21 @@ test('a gate goes with its run', async (t) => {
|
||||
// live server, so the pool holds open connections and the process never exits —
|
||||
// 49 green tests and a file that hangs until the harness kills it. Every other
|
||||
// event test file already closes it in an `after`; this one now has a reason to.
|
||||
//
|
||||
// **And that second pool has to be aimed at the throwaway database**, which it
|
||||
// was not before Phase 8 noticed. `utils/db` builds its pool at REQUIRE time from
|
||||
// `DB_NAME`, and its own `dotenv.config()` reads `server/.env` — so a model test
|
||||
// run on a developer's machine was reaching that developer's real schema while
|
||||
// the fixtures it was asserting against were being written next door. It passed
|
||||
// only because the two tables happened to exist in both. `dotenv` does not
|
||||
// overwrite a variable that already exists, so setting it here, before the
|
||||
// require below, is what beats the file. This file drops its database in `after`,
|
||||
// so aiming the model pool at it is also what keeps the whole run disposable.
|
||||
process.env.DB_NAME = DB
|
||||
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const appDb = require('../src/utils/db')
|
||||
after(() => appDb.close())
|
||||
|
||||
@@ -1303,3 +1353,324 @@ test('a non-positive spend never reaches the database', async (t) => {
|
||||
assert.equal(await budgetDb.spend(runId, 'uo.creatures', 0), true)
|
||||
assert.equal(await consumedOf(runId), 0)
|
||||
})
|
||||
|
||||
// ── The resource ledger's unique key (Phase 8) ─────────────────────────────
|
||||
//
|
||||
// Every test here is about a property of the SERVER. A stub can enforce whatever
|
||||
// rule its author had in mind; only MariaDB can say whether this encoding of
|
||||
// "unique among live rows" actually is one.
|
||||
|
||||
const insertResource = async (runId, over = {}) =>
|
||||
(
|
||||
await pool.query(
|
||||
`INSERT INTO event_run_resources (run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
runId,
|
||||
over.stepId ?? null,
|
||||
over.owner ?? 'demo',
|
||||
over.kind ?? 'creature',
|
||||
over.ref ?? '0xA',
|
||||
over.payload ?? null,
|
||||
over.leaseUntil ?? null,
|
||||
over.memberKey ?? null,
|
||||
over.status ?? 'pending',
|
||||
],
|
||||
)
|
||||
).insertId
|
||||
|
||||
const dup = async (fn) => {
|
||||
try {
|
||||
await fn()
|
||||
return null
|
||||
} catch (err) {
|
||||
return err.code || String(err.errno)
|
||||
}
|
||||
}
|
||||
|
||||
test('two runs cannot hold one target, and the refusal comes from the server', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// §D: "the unique index is what stops two events leasing one target." Not a
|
||||
// read-then-insert — two runs entering the same tick would both pass the check
|
||||
// — so the whole conflict story is this one constraint answering.
|
||||
const a = await seedRun()
|
||||
const b = await seedRun()
|
||||
await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
|
||||
const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
|
||||
assert.equal(code, 'ER_DUP_ENTRY')
|
||||
})
|
||||
|
||||
test('the key is released by the three statuses that mean it is no longer ours', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Amended 2026-09-03. §D says "among non-reverted rows", which was written
|
||||
// before the six statuses had their meanings; taken literally it makes
|
||||
// `drifted` and `orphaned` hold a target for ever, so one bad night would
|
||||
// disable a lease permanently with no control able to clear it. `drifted` means
|
||||
// somebody else has hold of the value and this run has let go; `orphaned` means
|
||||
// it vanished. Neither is a claim on the target.
|
||||
for (const status of ['reverted', 'drifted', 'orphaned']) {
|
||||
await pool.query('DELETE FROM event_run_resources')
|
||||
const a = await seedRun()
|
||||
const b = await seedRun()
|
||||
await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status })
|
||||
const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
|
||||
assert.equal(code, null, `a ${status} row must not hold the target`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the key is HELD by the three that mean core still believes it is ours', async (t) => {
|
||||
if (needDb(t)) return
|
||||
for (const status of ['pending', 'confirmed', 'reverting']) {
|
||||
await pool.query('DELETE FROM event_run_resources')
|
||||
const a = await seedRun()
|
||||
const b = await seedRun()
|
||||
await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status })
|
||||
const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
|
||||
assert.equal(code, 'ER_DUP_ENTRY', `a ${status} row must hold the target`)
|
||||
}
|
||||
})
|
||||
|
||||
test('many released rows on one target coexist, which is the whole encoding', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The property the NULL depends on: multiple NULLs do not collide in a unique
|
||||
// index. A weekly event that leases the same rate every Saturday accumulates one
|
||||
// released row per week, and the fiftieth must not fail to insert.
|
||||
const a = await seedRun()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'reverted' })
|
||||
}
|
||||
const held = await pool.query(
|
||||
'SELECT COUNT(*) AS n FROM event_run_resources WHERE ref = ? AND live_marker IS NULL',
|
||||
['demo.rate'],
|
||||
)
|
||||
assert.equal(Number(held[0].n), 5)
|
||||
// And a live one still goes on top of them.
|
||||
assert.equal(await dup(() => insertResource(a.runId, { kind: 'override', ref: 'demo.rate' })), null)
|
||||
})
|
||||
|
||||
test('an UPDATE that releases a row frees the target at once', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The generated column is STORED, so this is really asking whether MariaDB
|
||||
// recomputes it on UPDATE and re-indexes. Cleanup depends on it entirely: the
|
||||
// moment a lease is restored, the next event may take it.
|
||||
const a = await seedRun()
|
||||
const b = await seedRun()
|
||||
const id = await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
|
||||
assert.equal(await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' })), 'ER_DUP_ENTRY')
|
||||
await pool.query("UPDATE event_run_resources SET status = 'reverted' WHERE id = ?", [id])
|
||||
assert.equal(await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' })), null)
|
||||
})
|
||||
|
||||
test('the key is per owner and per kind, not per ref', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `kind` and `ref` are module-opaque strings core stores verbatim, so two
|
||||
// modules using the same word for different things must not collide — and one
|
||||
// module's `creature:0xA` and `item:0xA` are two objects.
|
||||
const a = await seedRun()
|
||||
await insertResource(a.runId, { owner: 'demo', kind: 'creature', ref: '0xA', status: 'confirmed' })
|
||||
assert.equal(await dup(() => insertResource(a.runId, { owner: 'other', kind: 'creature', ref: '0xA' })), null)
|
||||
assert.equal(await dup(() => insertResource(a.runId, { owner: 'demo', kind: 'item', ref: '0xA' })), null)
|
||||
assert.equal(await dup(() => insertResource(a.runId, { owner: 'demo', kind: 'creature', ref: '0xB' })), null)
|
||||
})
|
||||
|
||||
test('a deleted step leaves its resources behind, and a deleted run does not', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// TEAMS.md §2.5's correction, held as a test: `step_id` is SET NULL and it only
|
||||
// works because the generated column reads `status` alone. If `step_id` were in
|
||||
// that expression MariaDB would refuse the constraint outright (error 1901), and
|
||||
// the migration would fail on a fresh install rather than here.
|
||||
//
|
||||
// The two directions are different on purpose. A record of what was changed in
|
||||
// the WORLD must outlive the row that scheduled it — `engagement_sends`'
|
||||
// argument — while a deleted RUN takes its ledger with it, because the ledger
|
||||
// exists to answer questions about a run.
|
||||
const a = await seedRun()
|
||||
const stepId = await seedStep(a.runId)
|
||||
const id = await insertResource(a.runId, { stepId, status: 'confirmed' })
|
||||
|
||||
await pool.query('DELETE FROM event_run_steps WHERE id = ?', [stepId])
|
||||
const orphaned = (await pool.query('SELECT step_id, status FROM event_run_resources WHERE id = ?', [id]))[0]
|
||||
assert.equal(orphaned.step_id, null)
|
||||
assert.equal(orphaned.status, 'confirmed')
|
||||
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [a.runId])
|
||||
const gone = await pool.query('SELECT id FROM event_run_resources WHERE id = ?', [id])
|
||||
assert.equal(gone.length, 0)
|
||||
})
|
||||
|
||||
test('the ledger model reserves, confirms, reverts and refuses for real', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Through the shipping module rather than a copy of its statements, like the
|
||||
// budget tests above: `reserve` reads ER_DUP_ENTRY as a refusal and looks the
|
||||
// holder up to name it, and both halves of that are the connector's behaviour
|
||||
// rather than this file's.
|
||||
const a = await seedRun()
|
||||
const b = await seedRun()
|
||||
|
||||
const first = await resourcesDb.reserve({ runId: a.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
|
||||
assert.equal(first.ok, true)
|
||||
await resourcesDb.confirm(first.id)
|
||||
|
||||
const second = await resourcesDb.reserve({ runId: b.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
|
||||
assert.equal(second.ok, false)
|
||||
assert.equal(second.code, 'held')
|
||||
assert.equal(Number(second.holder.run_id), Number(a.runId))
|
||||
assert.equal(second.holder.status, 'confirmed')
|
||||
|
||||
// And once it is given back the second run gets it.
|
||||
await resourcesDb.markReverted(first.id)
|
||||
const third = await resourcesDb.reserve({ runId: b.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
|
||||
assert.equal(third.ok, true)
|
||||
})
|
||||
|
||||
test('failRevert increments and NEVER resets, and only a human clears it', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Engagement Phase 14's rule at the statement level: `revert_attempts =
|
||||
// revert_attempts + 1` is written in one place, and the reset is a separate
|
||||
// statement with an actor behind it. A `SET revert_attempts = ?` anywhere in
|
||||
// the sweep would make the ceiling unreachable and the row cycle for ever.
|
||||
const a = await seedRun()
|
||||
const id = await resourcesDb
|
||||
.reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
|
||||
.then((r) => r.id)
|
||||
await resourcesDb.confirm(id)
|
||||
|
||||
await resourcesDb.failRevert(id, 'the shard did not answer')
|
||||
await resourcesDb.failRevert(id, 'still nothing')
|
||||
let row = (await pool.query('SELECT * FROM event_run_resources WHERE id = ?', [id]))[0]
|
||||
assert.equal(Number(row.revert_attempts), 2)
|
||||
assert.equal(row.status, 'confirmed')
|
||||
assert.equal(row.last_error, 'still nothing')
|
||||
|
||||
assert.equal(await resourcesDb.resetAttempts(a.runId), 1)
|
||||
row = (await pool.query('SELECT * FROM event_run_resources WHERE id = ?', [id]))[0]
|
||||
assert.equal(Number(row.revert_attempts), 0)
|
||||
})
|
||||
|
||||
test('claimRevert is a compare-and-set, and reverting is not re-claimable', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The cleanup leg and the manual cleanup route can both be working one run at
|
||||
// once. `reverting` is deliberately not claimable — a row another pass is
|
||||
// mid-revert on is left alone, exactly as a step with a live claim is.
|
||||
const a = await seedRun()
|
||||
const id = await resourcesDb
|
||||
.reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
|
||||
.then((r) => r.id)
|
||||
await resourcesDb.confirm(id)
|
||||
|
||||
assert.equal(await resourcesDb.claimRevert(id), true)
|
||||
assert.equal(await resourcesDb.claimRevert(id), false, 'a second pass must not take a row mid-revert')
|
||||
|
||||
await resourcesDb.markReverted(id)
|
||||
assert.equal(await resourcesDb.claimRevert(id), false, 'and a reverted row is finished')
|
||||
})
|
||||
|
||||
test('the placeholder is resolvable exactly once, and only if it is a placeholder', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The `kind = '@step'` guard in the statement, not in JavaScript. It is what
|
||||
// stops a bug elsewhere resolving a real resource — which would be core marking
|
||||
// a live creature as given back without asking anyone.
|
||||
const a = await seedRun()
|
||||
const placeholder = await resourcesDb
|
||||
.reserve({ runId: a.runId, owner: 'demo', kind: resourcesDb.STEP_KIND, ref: 'k'.repeat(40) })
|
||||
.then((r) => r.id)
|
||||
const real = await resourcesDb
|
||||
.reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
|
||||
.then((r) => r.id)
|
||||
|
||||
assert.equal(await resourcesDb.resolvePlaceholder(placeholder), true)
|
||||
assert.equal(await resourcesDb.resolvePlaceholder(placeholder), false)
|
||||
assert.equal(await resourcesDb.resolvePlaceholder(real), false)
|
||||
const stillThere = (await pool.query('SELECT status FROM event_run_resources WHERE id = ?', [real]))[0]
|
||||
assert.equal(stillThere.status, 'pending')
|
||||
})
|
||||
|
||||
test('the unresolved reads see the five statuses that still want something', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `cleanup_status` is derived from this count, so what it includes IS the
|
||||
// definition of "clean". A `drifted` row left out of it would let a run whose
|
||||
// lease somebody else took call itself complete.
|
||||
const a = await seedRun()
|
||||
for (const status of ['pending', 'confirmed', 'reverting', 'reverted', 'orphaned', 'drifted']) {
|
||||
await insertResource(a.runId, { ref: `ref-${status}`, status })
|
||||
}
|
||||
assert.equal(await resourcesDb.unresolvedCount(a.runId), 5)
|
||||
const rows = await resourcesDb.unresolvedForRun(a.runId)
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.status).sort(),
|
||||
['confirmed', 'drifted', 'orphaned', 'pending', 'reverting'],
|
||||
)
|
||||
const counts = await resourcesDb.unresolvedCounts([a.runId])
|
||||
assert.equal(counts.get(a.runId) ?? counts.get(String(a.runId)), 5)
|
||||
})
|
||||
|
||||
test('payload comes back as an object rather than as a string', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// A lease's baseline lives in here and the cleanup sweep reads it out to pass
|
||||
// to `restore`. The connector hands JSON back as text, so a missing hydration
|
||||
// is a `restore(undefined)` — a lease put back to nothing, silently.
|
||||
const a = await seedRun()
|
||||
const id = await resourcesDb
|
||||
.reserve({
|
||||
runId: a.runId,
|
||||
owner: 'demo',
|
||||
kind: 'override',
|
||||
ref: 'demo.rate',
|
||||
payload: { baseline: 1, applied: 3 },
|
||||
})
|
||||
.then((r) => r.id)
|
||||
void id
|
||||
const [row] = await resourcesDb.forRun(a.runId)
|
||||
assert.deepEqual(row.payload, { baseline: 1, applied: 3 })
|
||||
})
|
||||
|
||||
test('the cleanup scan finds a run that owes something, whatever its status column says', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await pool.query('ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS cleanup_status ' +
|
||||
"ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required'")
|
||||
|
||||
// **`not_required` is in the scan because of a live-walk defect**, not for
|
||||
// tidiness. A run whose only resource was a LEASE never went through the
|
||||
// ledger's `markRunDirty` — `core.lease` reserves its own row — so its column
|
||||
// stayed `not_required` and the lease was never given back at all. A terminal
|
||||
// run with an unresolved row has work to do whatever any summary column says.
|
||||
const lease = await seedRun({ status: 'completed' })
|
||||
await insertResource(lease.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
|
||||
const marked = await seedRun({ status: 'cancelled' })
|
||||
await insertResource(marked.runId, { ref: '0xB', status: 'pending' })
|
||||
await pool.query("UPDATE event_runs SET cleanup_status = 'pending' WHERE id = ?", [marked.runId])
|
||||
|
||||
// Three that must NOT be selected, one per reason.
|
||||
const running = await seedRun({ status: 'running' })
|
||||
await insertResource(running.runId, { ref: '0xC', status: 'confirmed' })
|
||||
const done = await seedRun({ status: 'completed' })
|
||||
await insertResource(done.runId, { ref: '0xD', status: 'reverted' })
|
||||
const givenUp = await seedRun({ status: 'completed' })
|
||||
await insertResource(givenUp.runId, { ref: '0xE', status: 'confirmed' })
|
||||
await pool.query("UPDATE event_runs SET cleanup_status = 'incomplete' WHERE id = ?", [givenUp.runId])
|
||||
|
||||
const found = (await resourcesDb.runsNeedingCleanup(10, 3)).map((r) => Number(r.id))
|
||||
assert.deepEqual(found.sort(), [Number(lease.runId), Number(marked.runId)].sort())
|
||||
})
|
||||
|
||||
test('the scan stops selecting a run whose attempts are spent', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Without the bound in the join, a run whose rows are all spent would be
|
||||
// selected, worked over and found to have nothing to do on every tick for the
|
||||
// rest of its life.
|
||||
const a = await seedRun({ status: 'completed' })
|
||||
const id = await insertResource(a.runId, { status: 'confirmed' })
|
||||
await pool.query("UPDATE event_runs SET cleanup_status = 'pending' WHERE id = ?", [a.runId])
|
||||
assert.equal((await resourcesDb.runsNeedingCleanup(10, 3)).length, 1)
|
||||
|
||||
await pool.query('UPDATE event_run_resources SET revert_attempts = 3 WHERE id = ?', [id])
|
||||
assert.equal((await resourcesDb.runsNeedingCleanup(10, 3)).length, 0)
|
||||
})
|
||||
|
||||
test('setCleanupStatus is guarded, which is what stops a late row re-opening a swept run', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const a = await seedRun({ status: 'completed' })
|
||||
assert.equal(await runsDb.setCleanupStatus(a.runId, 'complete'), true)
|
||||
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending', ['not_required']), false)
|
||||
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending'), true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user