Merge pull request 'fix(events): give a lease's ledger row a reconcile path (Phase 11b)' (#193) from feature/events-p11b-leases-participation into edge
Reviewed-on: #193
This commit is contained in:
@@ -422,6 +422,58 @@ const ACTIONS = [
|
||||
await require('../events/ledger').markRunDirty(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Which of this run's leases the game side still has a record of (Phase 11b).
|
||||
*
|
||||
* **A lease row had no reconcile path at all until this existed**, and nothing
|
||||
* failed to say so. `cleanup.js` resolves a resource to the action of the step
|
||||
* that made it, and for a lease that action is `core.lease` — a CORE action, on
|
||||
* a path a module cannot register anything on. So every `override` row came
|
||||
* back `unanswered` for the life of the run, and a lease the shard had quietly
|
||||
* dropped (a restart reverts every config lease, by design) stayed in the
|
||||
* ledger as live until teardown went looking for a baseline nobody was holding.
|
||||
*
|
||||
* The question asked is deliberately NOT "is the value still what we applied".
|
||||
* That is drift, and drift is teardown's verdict to deliver through `restore`
|
||||
* so the row lands as `drifted` with the current value beside it. A reconcile
|
||||
* that inferred absence from a changed value would orphan the row first and
|
||||
* throw that away — the operator would be told the lease vanished rather than
|
||||
* that somebody moved it.
|
||||
*
|
||||
* A lease with no `inForce()` is reported in force, which is core's posture
|
||||
* everywhere else in this file: "I could not ask" must never be recorded as
|
||||
* "it is gone".
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const inForce = []
|
||||
|
||||
for (const row of resources || []) {
|
||||
if (row.kind !== 'override') continue
|
||||
|
||||
const lease = registries.eventLease(row.ref)
|
||||
|
||||
if (!lease || typeof lease.inForce !== 'function') {
|
||||
inForce.push(row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await lease.inForce({ ref: row.ref, payload: row.payload || null })
|
||||
} catch (err) {
|
||||
answer = null
|
||||
}
|
||||
|
||||
// Only an explicit `held: false` takes a row out. A module that threw, timed
|
||||
// out, or answered something unrecognisable has not said the lease is gone.
|
||||
if (answer && answer.ok === true && answer.held === false) continue
|
||||
|
||||
inForce.push(row.ref)
|
||||
}
|
||||
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -483,7 +483,7 @@ const isEventBudget = (id) => eventBudgets.has(id)
|
||||
* one by id.
|
||||
*/
|
||||
const allEventLeases = () =>
|
||||
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
|
||||
[...eventLeases.values()].map(({ read, apply: applyValue, restore, inForce, ...rest }) => rest)
|
||||
|
||||
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
|
||||
const eventLease = (id) => eventLeases.get(id) || null
|
||||
@@ -1192,6 +1192,26 @@ function checkEventLeaseShape(entry) {
|
||||
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
|
||||
}
|
||||
|
||||
// **`inForce()` is optional, and it is the fourth question a lease can answer**
|
||||
// (Phase 11b). `read` is "what is it now", `apply` is "hold it here", `restore`
|
||||
// is "put it back and tell me if somebody moved it" — and none of the three
|
||||
// answers "does the game side still have any record of this hold?", which is
|
||||
// what a reconcile after an outage needs.
|
||||
//
|
||||
// It is deliberately not `read()` with a comparison. A value that differs from
|
||||
// what the event applied is DRIFT, and drift is a verdict teardown has to
|
||||
// deliver through `restore` so the resource lands as `drifted`; a reconcile
|
||||
// that inferred absence from a changed value would orphan the row first and
|
||||
// destroy the one signal an operator needs. The two questions have different
|
||||
// answers on purpose.
|
||||
//
|
||||
// Optional because the fallback is the posture core takes everywhere else: a
|
||||
// lease that cannot say leaves its ledger row alone, which is exactly the
|
||||
// behaviour before this phase.
|
||||
if (l.inForce !== undefined && typeof l.inForce !== 'function') {
|
||||
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: l.id,
|
||||
label: l.label,
|
||||
@@ -1203,6 +1223,7 @@ function checkEventLeaseShape(entry) {
|
||||
read: l.read,
|
||||
apply: l.apply,
|
||||
restore: l.restore,
|
||||
inForce: l.inForce || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +485,76 @@ test('placeholders are not asked about, because there is nothing to ask yet', as
|
||||
assert.equal([...store.rows.values()][0].status, 'pending')
|
||||
})
|
||||
|
||||
// ── Reconciling a LEASE (Phase 11b) ────────────────────────────────────────
|
||||
|
||||
/// A lease row's action is `core.lease`, so these need core's own registrations.
|
||||
function registerCoreAnd(leaseOver = {}) {
|
||||
registries.registerCore()
|
||||
registerLease(leaseOver)
|
||||
}
|
||||
|
||||
test('a lease row had no reconcile path at all until core.lease grew one', async () => {
|
||||
// The hole this phase closed, asserted from the outside. `reconcileModule`
|
||||
// resolves a resource to the action of the step that made it, and for a lease
|
||||
// that action is CORE's — a path no module can register anything on. So every
|
||||
// `override` row came back `unanswered` for the life of the run, and a lease the
|
||||
// shard had quietly dropped stayed in the ledger as live until teardown went
|
||||
// looking for a baseline nobody was holding.
|
||||
registerCoreAnd({ inForce: async () => ({ ok: true, held: false }) })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'orphaned')
|
||||
})
|
||||
|
||||
test('a lease the shard still has a record of stays put', async () => {
|
||||
registerCoreAnd({ inForce: async () => ({ ok: true, held: true }) })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
test('a lease that cannot say is left alone, and DRIFT is not what this asks about', async () => {
|
||||
// Two properties in one walk. Every unanswerable shape leaves the row exactly as
|
||||
// it was, which is core's posture everywhere else — and a lease with no
|
||||
// `inForce()` at all is one of those shapes rather than a boot failure.
|
||||
//
|
||||
// The second is the reason `inForce` exists instead of a comparison against
|
||||
// `read()`: a value that differs from what the event applied is drift, and drift
|
||||
// is teardown's verdict to deliver through `restore` so the row lands `drifted`
|
||||
// with the current value beside it. A reconcile that inferred absence from a
|
||||
// changed value would orphan the row first and throw that away — telling the
|
||||
// operator the lease vanished rather than that somebody moved it.
|
||||
const shrugs = [
|
||||
undefined, // no inForce() declared at all
|
||||
async () => null,
|
||||
async () => ({ ok: false, held: false }), // could not ask; not an answer
|
||||
async () => ({ ok: true }), // answered without saying
|
||||
async () => { throw new Error('sidecar gone') },
|
||||
// The drift shape. `read()` would report 4.5 against an applied 3, and this
|
||||
// must NOT be read as "the lease is gone".
|
||||
async () => ({ ok: true, held: true, current: 4.5 }),
|
||||
]
|
||||
|
||||
for (const inForce of shrugs) {
|
||||
store.rows.clear()
|
||||
store.log.length = 0
|
||||
registries._reset()
|
||||
registerCoreAnd(inForce === undefined ? {} : { inForce })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.equal(summary.orphaned, 0, String(inForce))
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed', String(inForce))
|
||||
}
|
||||
})
|
||||
|
||||
test('reconcileAll asks every module that owns a live row', async () => {
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
|
||||
@@ -181,6 +181,7 @@ test('the catalog never carries a callable, whichever registration it came from'
|
||||
assert.equal(l.read, undefined)
|
||||
assert.equal(l.apply, undefined)
|
||||
assert.equal(l.restore, undefined)
|
||||
assert.equal(l.inForce, undefined)
|
||||
}
|
||||
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
|
||||
})
|
||||
@@ -661,6 +662,48 @@ test('core registers the lease VERB and a module registers the lease', async ()
|
||||
assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
|
||||
})
|
||||
|
||||
test('inForce() is optional, and a fourth question rather than a fourth spelling of read()', () => {
|
||||
// Phase 11b. `read` is "what is it now", `apply` is "hold it here", `restore` is
|
||||
// "put it back and tell me if somebody moved it" -- and none of them answers
|
||||
// "does the game side still have any record of this hold?", which is what a
|
||||
// reconcile after an outage needs. A config lease is reverted by a shard restart
|
||||
// by design, so the answer changes without the value ever being written by us.
|
||||
//
|
||||
// Optional, because the fallback is core's posture everywhere: a lease that
|
||||
// cannot say leaves its ledger row alone.
|
||||
//
|
||||
// Three module IDs rather than three reloads of one: `loadModule` rewrites
|
||||
// `index.js` in place and clears the LOADER from the require cache, but not the
|
||||
// module file it goes on to require. A second load of the same id silently
|
||||
// re-registers the first source.
|
||||
const lease = (id, extra) => `module.exports = (ctx, api) => {
|
||||
api.registerEventLeases([{
|
||||
id: '${id}.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
|
||||
maxDurationMs: 3600000,
|
||||
async read() { return { ok: true, value: 1 } },
|
||||
async apply() { return { ok: true } },
|
||||
async restore() { return { ok: true } },
|
||||
${extra}
|
||||
}])
|
||||
}`
|
||||
|
||||
const without = loadModule('leasea', lease('leasea', ''))
|
||||
assertRegistered(without)
|
||||
assert.equal(registries.eventLease('leasea.rate.gain').inForce, null)
|
||||
|
||||
const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },'))
|
||||
assertRegistered(withIt)
|
||||
assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function')
|
||||
|
||||
// And a declaration that is present but not callable fails the module rather
|
||||
// than being ignored -- the same posture the other three take. A module that
|
||||
// meant to answer and cannot is a module whose leases would silently never be
|
||||
// reconciled.
|
||||
const broken = loadModule('leasec', lease('leasec', "inForce: 'yes',"))
|
||||
assert.equal(broken.state, 'startup_failed')
|
||||
assert.match(broken.reason, /inForce must be a function/)
|
||||
})
|
||||
|
||||
test('core refuses a lease held longer than the module allows', async () => {
|
||||
// The bound is the MODULE's number and the enforcement is CORE's, which is the
|
||||
// §F split stated as one assertion. `retry: false` because a duration that is
|
||||
|
||||
Reference in New Issue
Block a user