diff --git a/lib/batch-mutation-gate.js b/lib/batch-mutation-gate.js new file mode 100644 index 0000000..78b3b3b --- /dev/null +++ b/lib/batch-mutation-gate.js @@ -0,0 +1,55 @@ +function createBatchMutationGate() { + let activeLeaseCount = 0; + let sealed = false; + let activeAtSeal = false; + let drainPromise = null; + let resolveDrain = null; + + function acquire() { + if (sealed) return null; + + activeLeaseCount += 1; + let open = true; + + return Object.freeze({ + finish() { + if (!open) return false; + + open = false; + activeLeaseCount -= 1; + + if (sealed && activeLeaseCount === 0 && resolveDrain) { + const resolve = resolveDrain; + resolveDrain = null; + resolve(activeAtSeal); + } + + return true; + }, + isOpen() { + return open; + } + }); + } + + function sealAndDrain() { + if (drainPromise) return drainPromise; + + sealed = true; + activeAtSeal = activeLeaseCount > 0; + + if (!activeAtSeal) { + drainPromise = Promise.resolve(false); + return drainPromise; + } + + drainPromise = new Promise((resolve) => { + resolveDrain = resolve; + }); + return drainPromise; + } + + return Object.freeze({ acquire, sealAndDrain }); +} + +module.exports = { createBatchMutationGate }; diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 6432b60..a8be8f1 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -20,6 +20,7 @@ const sourceFiles = [ 'lib/account-auth.js', 'lib/account-rotation.js', 'lib/backup-crypto.js', + 'lib/batch-mutation-gate.js', 'lib/clouddrop-upload.js', 'lib/coalesced-set.js', 'lib/config-store.js', @@ -97,6 +98,7 @@ const sourceFiles = [ 'tests/account-status.test.js', 'tests/auto-resume.test.js', 'tests/backup-crypto.test.js', + 'tests/batch-mutation-gate.test.js', 'tests/byse-reject-recovery.test.js', 'tests/coalesced-set.test.js', 'tests/config-store.test.js', diff --git a/tests/batch-mutation-gate.test.js b/tests/batch-mutation-gate.test.js new file mode 100644 index 0000000..33f9240 --- /dev/null +++ b/tests/batch-mutation-gate.test.js @@ -0,0 +1,98 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { createBatchMutationGate } = require('../lib/batch-mutation-gate'); + +describe('batch mutation gate', () => { + it('keeps a seal pending until every lease active at seal has finished', async () => { + const gate = createBatchMutationGate(); + const first = gate.acquire(); + const second = gate.acquire(); + + const drain = gate.sealAndDrain(); + let drained = false; + drain.then(() => { + drained = true; + }); + + assert.equal(gate.acquire(), null); + assert.equal(first.isOpen(), true); + assert.equal(second.isOpen(), true); + + first.finish(); + await Promise.resolve(); + assert.equal(drained, false); + + second.finish(); + assert.equal(await drain, true); + assert.equal(drained, true); + }); + + it('reports no active mutation when sealing an idle gate', async () => { + const gate = createBatchMutationGate(); + + assert.equal(await gate.sealAndDrain(), false); + assert.equal(gate.acquire(), null); + }); + + it('returns the same drain promise and preserves the first seal snapshot', async () => { + const gate = createBatchMutationGate(); + const lease = gate.acquire(); + + const firstDrain = gate.sealAndDrain(); + lease.finish(); + const secondDrain = gate.sealAndDrain(); + + assert.equal(secondDrain, firstDrain); + assert.equal(await firstDrain, true); + assert.equal(await gate.sealAndDrain(), true); + }); + + it('makes lease completion idempotent without affecting other leases', async () => { + const gate = createBatchMutationGate(); + const first = gate.acquire(); + const second = gate.acquire(); + const drain = gate.sealAndDrain(); + + assert.equal(first.finish(), true); + assert.equal(first.finish(), false); + assert.equal(first.isOpen(), false); + assert.equal(second.isOpen(), true); + + let drained = false; + drain.then(() => { + drained = true; + }); + await Promise.resolve(); + assert.equal(drained, false); + + assert.equal(second.finish(), true); + assert.equal(await drain, true); + }); + + it('does not let a rejected caller block drain when the lease finishes in finally', async () => { + const gate = createBatchMutationGate(); + const lease = gate.acquire(); + const caller = (async () => { + try { + await Promise.reject(new Error('audit failed')); + } finally { + lease.finish(); + } + })(); + const drain = gate.sealAndDrain(); + + await assert.rejects(caller, /audit failed/); + assert.equal(await drain, true); + assert.equal(lease.isOpen(), false); + }); + + it('does not count a lease finished before sealing as active at seal', async () => { + const gate = createBatchMutationGate(); + const lease = gate.acquire(); + + lease.finish(); + + assert.equal(await gate.sealAndDrain(), false); + }); +});