diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js index fe886b6..97951b6 100644 --- a/server/src/events/cleanup.js +++ b/server/src/events/cleanup.js @@ -320,6 +320,16 @@ async function sweep() { // cleanup, whose counters were spent deliberately. const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS) let swept = 0 + + // **A finished run with nothing left to give back is complete** (MODULE_API + // 1.11.0). The scan above joins on an unresolved row, and until `expired` no + // run could end `pending` without one. Now a zone can expire while its run is + // still going — `expireResource` leaves a live run's status to its terminal + // path — and when that run ends, nothing in the scan would ever select it: + // `pending` for ever. Found by the step-2 walk, run 46. + for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) { + if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1 + } for (const candidate of candidates) { if (!runsDb.TERMINAL.includes(candidate.status)) continue try { diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js index 4e68195..354f51e 100644 --- a/server/src/model/events/eventRunResources.db.js +++ b/server/src/model/events/eventRunResources.db.js @@ -402,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) { ) } +/** + * Finished runs still marked `pending` that have no unresolved row at all — a + * run whose last resource the game expired while it was still going (MODULE_API + * 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see + * them because it joins on an unresolved row. + */ +async function finishedRunsWithNothingLeft(limit = 25) { + const rows = await query( + `SELECT r.id FROM event_runs r + WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed') + AND r.cleanup_status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM event_run_resources res + WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?)) + ORDER BY r.id + LIMIT ?`, + [...UNRESOLVED, Number(limit)], + ) + return rows.map((row) => Number(row.id)) +} + module.exports = { STEP_KIND, + finishedRunsWithNothingLeft, HELD, UNRESOLVED, reserve, diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index 27202c7..abf6eed 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -101,6 +101,7 @@ beforeEach(() => { ...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)), ] resourcesDb.runsNeedingCleanup = async () => store.candidates || [] + resourcesDb.finishedRunsWithNothingLeft = async () => store.nothingLeft || [] runsDb.setCleanupStatus = async (id, to, from = null) => { if (from && !from.includes(store.cleanupStatus)) return false @@ -728,3 +729,20 @@ test('a run still in flight keeps its cleanup status, and so does one with rows await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' }) assert.equal(store.cleanupStatus, 'incomplete') }) + +test('a run whose last resource expired while it ran is settled complete when it ends', async () => { + // The step-2 walk's run 46: the zone expired while the run was still going, so + // expireResource left its status to the terminal path — and the sweep's scan, + // which joins on an unresolved row, would never have selected it. `pending` + // for ever, on a ledger with nothing left in it. + store.cleanupStatus = 'pending' + store.candidates = [] + store.nothingLeft = [RUN.id] + assert.equal(await cleanup.sweep(), 1) + assert.equal(store.cleanupStatus, 'complete') + + // And never over an incomplete one — that is a human's to clear. + store.cleanupStatus = 'incomplete' + await cleanup.sweep() + assert.equal(store.cleanupStatus, 'incomplete') +})