diff --git a/packages/SwingSet/misc-tools/extract-transcript-from-kerneldb.js b/packages/SwingSet/misc-tools/extract-transcript-from-kerneldb.js index 4f10d99c6bd..ad1987f23a4 100644 --- a/packages/SwingSet/misc-tools/extract-transcript-from-kerneldb.js +++ b/packages/SwingSet/misc-tools/extract-transcript-from-kerneldb.js @@ -18,7 +18,7 @@ if (!dirPath) { if (!isSwingStore(dirPath)) { throw Error(`${dirPath} does not appear to be a swingstore (no ./data.mdb)`); } -const { kvStore, streamStore } = openSwingStore(dirPath).kernelStorage; +const { kvStore, transcriptStore } = openSwingStore(dirPath).kernelStorage; function get(key) { return kvStore.get(key); } @@ -98,7 +98,7 @@ if (!vatName) { fs.writeSync(fd, JSON.stringify(first)); fs.writeSync(fd, '\n'); - // The streamStore holds concatenated transcripts from all upgraded + // The transcriptStore holds concatenated transcripts from all upgraded // versions. For each old version, it holds every delivery from // `startVat` through `stopVat`. For the current version, it holds // every delivery from `startVat` up through the last delivery @@ -123,9 +123,8 @@ if (!vatName) { console.log(`${transcriptLength} transcript entries`); let deliveryNum = 0; - const transcriptStream = `transcript-${vatID}`; - const stream = streamStore.readStream(transcriptStream, startPos, endPos); - for (const entry of stream) { + const transcript = transcriptStore.readSpan(vatID, startPos, endPos); + for (const entry of transcript) { // entry is JSON.stringify({ d, syscalls }), syscall is { d, response } const t = { transcriptNum, ...JSON.parse(entry) }; // console.log(`t.${deliveryNum} : ${t}`); diff --git a/packages/SwingSet/misc-tools/replay-transcript.js b/packages/SwingSet/misc-tools/replay-transcript.js index 441a2ba72f4..2a6cedfa7d7 100644 --- a/packages/SwingSet/misc-tools/replay-transcript.js +++ b/packages/SwingSet/misc-tools/replay-transcript.js @@ -142,7 +142,7 @@ async function replay(transcriptFile) { return loadRaw(snapFile); }, } - : makeSnapStore(process.cwd(), makeSnapStoreIO()); + : makeSnapStore(process.cwd(), () => {}, makeSnapStoreIO()); const testLog = undefined; const meterControl = makeDummyMeterControl(); const gcTools = harden({ diff --git a/packages/SwingSet/src/kernel/kernel.js b/packages/SwingSet/src/kernel/kernel.js index 1884af60894..248f2bfc617 100644 --- a/packages/SwingSet/src/kernel/kernel.js +++ b/packages/SwingSet/src/kernel/kernel.js @@ -257,8 +257,11 @@ export default function buildKernel( // check will report 'false'. That's fine, there's no state to // clean up. if (kernelKeeper.vatIsAlive(vatID)) { - const promisesToReject = kernelKeeper.cleanupAfterTerminatedVat(vatID); - for (const kpid of promisesToReject) { + // Reject all promises decided by the vat, making sure to capture the list + // of kpids before that data is deleted. + const deadPromises = [...kernelKeeper.enumeratePromisesByDecider(vatID)]; + kernelKeeper.cleanupAfterTerminatedVat(vatID); + for (const kpid of deadPromises) { resolveToError(kpid, makeError('vat terminated'), vatID); } } @@ -818,33 +821,37 @@ export default function buildKernel( upgradeMessage, incarnationNumber: vatKeeper.getIncarnationNumber(), }; + const disconnectionCapData = kser(disconnectObject); /** @type { import('../types-external.js').KernelDeliveryStopVat } */ - const kd1 = harden(['stopVat', kser(disconnectObject)]); - const vd1 = vatWarehouse.kernelDeliveryToVatDelivery(vatID, kd1); - const status1 = await deliverAndLogToVat(vatID, kd1, vd1); + const stopVatKD = harden(['stopVat', disconnectionCapData]); + const stopVatVD = vatWarehouse.kernelDeliveryToVatDelivery( + vatID, + stopVatKD, + ); + const stopVatStatus = await deliverAndLogToVat(vatID, stopVatKD, stopVatVD); + const stopVatResults = deliveryCrankResults(vatID, stopVatStatus, false); + + // We don't meter stopVat, since no user code is running, but we + // still report computrons to the runPolicy + let { computrons } = stopVatResults; // BigInt or undefined + if (computrons !== undefined) { + assert.typeof(computrons, 'bigint'); + } - // make arguments for vat-vat-admin.js vatUpgradeCallback() /** - * @param {SwingSetCapData} _errorCD + * Make a method-arguments structure representing failure + * for vat-vat-admin.js vatUpgradeCallback(). + * + * @param {SwingSetCapData} _errorCapData * @returns {RawMethargs} */ - function makeFailure(_errorCD) { - insistCapData(_errorCD); // kser(Error) + const makeFailureMethargs = _errorCapData => { + insistCapData(_errorCapData); // kser(Error) // const error = kunser(_errorCD) // actually we shouldn't reveal the details, so instead we do: const error = Error('vat-upgrade failure'); return ['vatUpgradeCallback', [upgradeID, false, error]]; - } - - // We use deliveryCrankResults to parse the stopVat status. - const results1 = deliveryCrankResults(vatID, status1, false); - - // We don't meter stopVat, since no user code is running, but we - // still report computrons to the runPolicy - let { computrons } = results1; // BigInt or undefined - if (computrons !== undefined) { - assert.typeof(computrons, 'bigint'); - } + }; // TODO: if/when we implement vat pause/suspend, and if // deliveryCrankResults changes to not use .terminate to indicate @@ -852,18 +859,20 @@ export default function buildKernel( // pause/suspend a vat for a delivery error, here we want to // unwind the upgrade. - if (results1.terminate) { + if (stopVatResults.terminate) { // get rid of the worker, so the next delivery to this vat will // re-create one from the previous state // eslint-disable-next-line @jessie.js/no-nested-await await vatWarehouse.stopWorker(vatID); // notify vat-admin of the failed upgrade - const vatAdminMethargs = makeFailure(results1.terminate.info); + const vatAdminMethargs = makeFailureMethargs( + stopVatResults.terminate.info, + ); // we still report computrons to the runPolicy const results = harden({ - ...results1, + ...stopVatResults, computrons, abort: true, // always unwind consumeMessage: true, // don't repeat the upgrade @@ -873,8 +882,25 @@ export default function buildKernel( return results; } - // stopVat succeeded, so now we stop the worker, delete the - // transcript and any snapshot + // stopVat succeeded. finish cleanup on behalf of the worker. + + // TODO: send BOYD to the vat, to give it one last chance to clean + // up, drop imports, and delete durable data. If we ever have a + // vat that is so broken it can't do BOYD, we can make that + // optional. #7001 + + // walk c-list for all decided promises, reject them all + for (const kpid of kernelKeeper.enumeratePromisesByDecider(vatID)) { + resolveToError(kpid, disconnectionCapData, vatID); + } + + // TODO: getNonDurableObjectExports, synthesize abandonVSO, + // execute it as if it were a syscall. (maybe distinguish between + // reachable/recognizable exports, abandon the reachable, retire + // the recognizable) #6696 + + // cleanup done, now we stop the worker, delete the transcript and + // any snapshot await vatWarehouse.resetWorker(vatID); const source = { bundleID }; @@ -888,23 +914,32 @@ export default function buildKernel( // deliver a startVat with the new vatParameters /** @type { import('../types-external.js').KernelDeliveryStartVat } */ - const kd2 = harden(['startVat', vatParameters]); - const vd2 = vatWarehouse.kernelDeliveryToVatDelivery(vatID, kd2); + const startVatKD = harden(['startVat', vatParameters]); + const startVatVD = vatWarehouse.kernelDeliveryToVatDelivery( + vatID, + startVatKD, + ); // decref vatParameters now that translation did incref for (const kref of vatParameters.slots) { kernelKeeper.decrementRefCount(kref, 'upgrade-vat-event'); } - const status2 = await deliverAndLogToVat(vatID, kd2, vd2); - const results2 = deliveryCrankResults(vatID, status2, false); - computrons = addComputrons(computrons, results2.computrons); + const startVatStatus = await deliverAndLogToVat( + vatID, + startVatKD, + startVatVD, + ); + const startVatResults = deliveryCrankResults(vatID, startVatStatus, false); + computrons = addComputrons(computrons, startVatResults.computrons); - if (results2.terminate) { + if (startVatResults.terminate) { // unwind just like above // eslint-disable-next-line @jessie.js/no-nested-await await vatWarehouse.stopWorker(vatID); - const vatAdminMethargs = makeFailure(results2.terminate.info); + const vatAdminMethargs = makeFailureMethargs( + startVatResults.terminate.info, + ); const results = harden({ - ...results2, + ...startVatResults, computrons, abort: true, // always unwind consumeMessage: true, // don't repeat the upgrade diff --git a/packages/SwingSet/src/kernel/kernelQueue.js b/packages/SwingSet/src/kernel/kernelQueue.js index 7f3fc29b808..3a8672b74d8 100644 --- a/packages/SwingSet/src/kernel/kernelQueue.js +++ b/packages/SwingSet/src/kernel/kernelQueue.js @@ -45,9 +45,7 @@ export function makeKernelQueueHandler(tools) { const p = kernelKeeper.getResolveablePromise(kpid, vatID); const { subscribers } = p; for (const subscriber of subscribers) { - if (subscriber !== vatID) { - notify(subscriber, kpid); - } + notify(subscriber, kpid); } kernelKeeper.resolveKernelPromise(kpid, rejected, data); const tag = rejected ? 'rejected' : 'fulfilled'; diff --git a/packages/SwingSet/src/kernel/state/kernelKeeper.js b/packages/SwingSet/src/kernel/state/kernelKeeper.js index 6de9927b716..e3afc63d10d 100644 --- a/packages/SwingSet/src/kernel/state/kernelKeeper.js +++ b/packages/SwingSet/src/kernel/state/kernelKeeper.js @@ -38,8 +38,7 @@ const enableKernelGC = true; * @typedef { import('../../types-external.js').KernelSlog } KernelSlog * @typedef { import('../../types-external.js').ManagerType } ManagerType * @typedef { import('../../types-external.js').SnapStore } SnapStore - * @typedef { import('../../types-external.js').StreamPosition } StreamPosition - * @typedef { import('../../types-external.js').StreamStore } StreamStore + * @typedef { import('../../types-external.js').TranscriptStore } TranscriptStore * @typedef { import('../../types-external.js').VatKeeper } VatKeeper * @typedef { import('../../types-external.js').VatManager } VatManager */ @@ -86,8 +85,6 @@ const enableKernelGC = true; // $vatSlot is one of: o+$NN/o-$NN/p+$NN/p-$NN/d+$NN/d-$NN // v$NN.c.$vatSlot = $kernelSlot = ko$NN/kp$NN/kd$NN // v$NN.nextDeliveryNum = $NN -// v$NN.t.startPosition = $NN // inclusive -// v$NN.t.endPosition = $NN // exclusive // v$NN.vs.$key = string // v$NN.meter = m$NN // XXX does this exist? // v$NN.reapInterval = $NN or 'never' @@ -174,7 +171,7 @@ const FIRST_METER_ID = 1n; * @param {KernelSlog|null} kernelSlog */ export default function makeKernelKeeper(kernelStorage, kernelSlog) { - const { kvStore, streamStore, snapStore } = kernelStorage; + const { kvStore, transcriptStore, snapStore } = kernelStorage; insistStorageAPI(kvStore); @@ -784,10 +781,8 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { const vatKeeper = provideVatKeeper(vatID); const exportPrefix = `${vatID}.c.o+`; const importPrefix = `${vatID}.c.o-`; - const promisePrefix = `${vatID}.c.p`; - const kernelPromisesToReject = []; - vatKeeper.deleteSnapshots(); + vatKeeper.deleteSnapshotsAndTranscript(); // Note: ASCII order is "+,-./", and we rely upon this to split the // keyspace into the various o+NN/o-NN/etc spaces. If we were using a @@ -825,23 +820,8 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { // that will also delete both db keys } - // now find all orphaned promises, which must be rejected - for (const k of enumeratePrefixedKeys(kvStore, promisePrefix)) { - // The vpid for a promise imported or exported by a vat (and thus - // potentially a promise for which the vat *might* be the decider) will - // always be of the form `p+NN` or `p-NN`. The corresponding vpid->kpid - // c-list entry will thus always begin with `vMM.c.p`. Decider-ship is - // independent of whether the promise was imported or exported, so we - // have to look up the corresponding kernel promise table entry to see - // whether the vat is the decider or not. If it is, we add the promise - // to the list of promises that must be rejected because the dead vat - // will never be able to act upon them. - const kpid = kvStore.get(k); - const p = getKernelPromise(kpid); - if (p.state === 'unresolved' && p.decider === vatID) { - kernelPromisesToReject.push(kpid); - } - } + // the caller used enumeratePromisesByDecider() before calling us, + // so they already know the orphaned promises to reject // now loop back through everything and delete it all for (const k of enumeratePrefixedKeys(kvStore, `${vatID}.`)) { @@ -873,8 +853,6 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { } decStat('vats'); } - - return kernelPromisesToReject; } function addMessageToPromiseQueue(kernelSlot, msg) { @@ -928,6 +906,27 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { kvStore.set(`${kpid}.decider`, ''); } + function* enumeratePromisesByDecider(vatID) { + insistVatID(vatID); + const promisePrefix = `${vatID}.c.p`; + for (const k of enumeratePrefixedKeys(kvStore, promisePrefix)) { + // The vpid for a promise imported or exported by a vat (and thus + // potentially a promise for which the vat *might* be the decider) will + // always be of the form `p+NN` or `p-NN`. The corresponding vpid->kpid + // c-list entry will thus always begin with `vMM.c.p`. Decider-ship is + // independent of whether the promise was imported or exported, so we + // have to look up the corresponding kernel promise table entry to see + // whether the vat is the decider or not. If it is, we add the promise + // to the list of promises that must be rejected because the dead vat + // will never be able to act upon them. + const kpid = kvStore.get(k); + const p = getKernelPromise(kpid); + if (p.state === 'unresolved' && p.decider === vatID) { + yield kpid; + } + } + } + function addSubscriberToPromise(kernelSlot, vatID) { insistKernelType('promise', kernelSlot); insistVatID(vatID); @@ -1297,11 +1296,11 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { return found; } if (!kvStore.has(`${vatID}.o.nextID`)) { - initializeVatState(kvStore, streamStore, vatID); + initializeVatState(kvStore, transcriptStore, vatID); } const vk = makeVatKeeper( kvStore, - streamStore, + transcriptStore, kernelSlog, vatID, addKernelObject, @@ -1328,20 +1327,6 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { } /** - * Cease writing to the vat's transcript. - * - * @param {string} vatID - */ - function closeVatTranscript(vatID) { - insistVatID(vatID); - const transcriptStream = `transcript-${vatID}`; - streamStore.closeStream(transcriptStream); - } - - /** - * NOTE: caller is responsible to closeVatTranscript() - * before evicting a VatKeeper. - * * @param {string} vatID */ function evictVatKeeper(vatID) { @@ -1448,7 +1433,6 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { if (vk) { // TODO: find some way to expose the liveSlots internal tables, the // kernel doesn't see them - closeVatTranscript(vatID); const vatTable = { vatID, state: { transcript: Array.from(vk.getTranscript()) }, @@ -1586,6 +1570,7 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { addSubscriberToPromise, setDecider, clearDecider, + enumeratePromisesByDecider, incrementRefCount, decrementRefCount, getObjectRefCount, @@ -1615,7 +1600,6 @@ export default function makeKernelKeeper(kernelStorage, kernelSlog) { provideVatKeeper, vatIsAlive, evictVatKeeper, - closeVatTranscript, cleanupAfterTerminatedVat, addDynamicVatID, getDynamicVats, diff --git a/packages/SwingSet/src/kernel/state/vatKeeper.js b/packages/SwingSet/src/kernel/state/vatKeeper.js index 71a7f561552..e95000f88fe 100644 --- a/packages/SwingSet/src/kernel/state/vatKeeper.js +++ b/packages/SwingSet/src/kernel/state/vatKeeper.js @@ -18,8 +18,7 @@ import { enumeratePrefixedKeys } from './storageHelper.js'; * @typedef { import('../../types-external.js').ManagerOptions } ManagerOptions * @typedef { import('../../types-external.js').SnapStore } SnapStore * @typedef { import('../../types-external.js').SourceOfBundle } SourceOfBundle - * @typedef { import('../../types-external.js').StreamPosition } StreamPosition - * @typedef { import('../../types-external.js').StreamStore } StreamStore + * @typedef { import('../../types-external.js').TranscriptStore } TranscriptStore * @typedef { import('../../types-external.js').VatManager } VatManager * @typedef { import('../../types-internal.js').RecordedVatOptions } RecordedVatOptions * @typedef { import('../../types-external.js').TranscriptEntry } TranscriptEntry @@ -36,25 +35,24 @@ const FIRST_DEVICE_ID = 70n; * Establish a vat's state. * * @param {*} kvStore The key-value store in which the persistent state will be kept - * @param {*} streamStore Accompanying stream store + * @param {*} transcriptStore Accompanying transcript store * @param {string} vatID The vat ID string of the vat in question * TODO: consider making this part of makeVatKeeper */ -export function initializeVatState(kvStore, streamStore, vatID) { +export function initializeVatState(kvStore, transcriptStore, vatID) { kvStore.set(`${vatID}.o.nextID`, `${FIRST_OBJECT_ID}`); kvStore.set(`${vatID}.p.nextID`, `${FIRST_PROMISE_ID}`); kvStore.set(`${vatID}.d.nextID`, `${FIRST_DEVICE_ID}`); kvStore.set(`${vatID}.nextDeliveryNum`, `0`); kvStore.set(`${vatID}.incarnationNumber`, `1`); - kvStore.set(`${vatID}.t.startPosition`, `${streamStore.STREAM_START}`); - kvStore.set(`${vatID}.t.endPosition`, `${streamStore.STREAM_START}`); + transcriptStore.initTranscript(vatID); } /** * Produce a vat keeper for a vat. * * @param {KVStore} kvStore The keyValue store in which the persistent state will be kept - * @param {StreamStore} streamStore Accompanying stream store, for the transcripts + * @param {TranscriptStore} transcriptStore Accompanying transcript store, for the transcripts * @param {*} kernelSlog * @param {string} vatID The vat ID string of the vat in question * @param {*} addKernelObject Kernel function to add a new object to the kernel's @@ -76,7 +74,7 @@ export function initializeVatState(kvStore, streamStore, vatID) { */ export function makeVatKeeper( kvStore, - streamStore, + transcriptStore, kernelSlog, vatID, addKernelObject, @@ -94,7 +92,6 @@ export function makeVatKeeper( snapStore = undefined, ) { insistVatID(vatID); - const transcriptStream = `transcript-${vatID}`; function getRequired(key) { const value = kvStore.get(key); @@ -475,20 +472,12 @@ export function makeVatKeeper( /** * Generator function to return the vat's transcript, one entry at a time. * - * @param {StreamPosition} [startPos] Optional position to begin reading from + * @param {number} [startPos] Optional position to begin reading from * * @yields { TranscriptEntry } a stream of transcript entries */ function* getTranscript(startPos) { - if (startPos === undefined) { - startPos = Number(getRequired(`${vatID}.t.startPosition`)); - } - const endPos = Number(getRequired(`${vatID}.t.endPosition`)); - for (const entry of streamStore.readStream( - transcriptStream, - /** @type { StreamPosition } */ (startPos), - endPos, - )) { + for (const entry of transcriptStore.readSpan(vatID, startPos)) { yield /** @type { TranscriptEntry } */ (JSON.parse(entry)); } } @@ -499,21 +488,13 @@ export function makeVatKeeper( * @param {object} entry The transcript entry to append. */ function addToTranscript(entry) { - const oldPos = Number(getRequired(`${vatID}.t.endPosition`)); - const newPos = streamStore.writeStreamItem( - transcriptStream, - JSON.stringify(entry), - oldPos, - ); - kvStore.set(`${vatID}.t.endPosition`, `${newPos}`); + transcriptStore.addItem(vatID, JSON.stringify(entry)); } - /** @returns {StreamPosition} */ + /** @returns {number} */ function getTranscriptEndPosition() { - const endPosition = - kvStore.get(`${vatID}.t.endPosition`) || - assert.fail('missing endPosition'); - return Number(endPosition); + const { endPos } = transcriptStore.getCurrentSpanBounds(vatID); + return endPos; } function getSnapshotInfo() { @@ -540,6 +521,7 @@ export function makeVatKeeper( const endPosition = getTranscriptEndPosition(); const info = await manager.makeSnapshot(endPosition, snapStore); + transcriptStore.rolloverSpan(vatID); const { hash, uncompressedSize, @@ -560,19 +542,18 @@ export function makeVatKeeper( return true; } - function deleteSnapshots() { + function deleteSnapshotsAndTranscript() { if (snapStore) { snapStore.deleteVatSnapshots(vatID); } + transcriptStore.deleteVatTranscripts(vatID); } - function removeSnapshotAndTranscript() { + function dropSnapshotAndResetTranscript() { if (snapStore) { - snapStore.deleteVatSnapshots(vatID); + snapStore.stopUsingLastSnapshot(vatID); } - - const endPos = getRequired(`${vatID}.t.endPosition`); - kvStore.set(`${vatID}.t.startPosition`, endPos); + transcriptStore.rolloverSpan(vatID); } function vatStats() { @@ -584,9 +565,8 @@ export function makeVatKeeper( const objectCount = getCount(`${vatID}.o.nextID`, FIRST_OBJECT_ID); const promiseCount = getCount(`${vatID}.p.nextID`, FIRST_PROMISE_ID); const deviceCount = getCount(`${vatID}.d.nextID`, FIRST_DEVICE_ID); - const startCount = Number(getRequired(`${vatID}.t.startPosition`)); - const endCount = Number(getRequired(`${vatID}.t.endPosition`)); - const transcriptCount = endCount - startCount; + const { startPos, endPos } = transcriptStore.getCurrentSpanBounds(vatID); + const transcriptCount = endPos - startPos; // TODO: Fix the downstream JSON.stringify to allow the counts to be BigInts return harden({ @@ -645,8 +625,8 @@ export function makeVatKeeper( vatStats, dumpState, saveSnapshot, - deleteSnapshots, getSnapshotInfo, - removeSnapshotAndTranscript, + deleteSnapshotsAndTranscript, + dropSnapshotAndResetTranscript, }); } diff --git a/packages/SwingSet/src/kernel/vat-warehouse.js b/packages/SwingSet/src/kernel/vat-warehouse.js index bde42866978..a51d135d94e 100644 --- a/packages/SwingSet/src/kernel/vat-warehouse.js +++ b/packages/SwingSet/src/kernel/vat-warehouse.js @@ -241,7 +241,6 @@ export function makeVatWarehouse(kernelKeeper, vatLoader, policyOptions) { } ephemeral.vats.delete(vatID); xlate.delete(vatID); - kernelKeeper.closeVatTranscript(vatID); kernelKeeper.evictVatKeeper(vatID); // console.log('evict: shutting down', vatID); @@ -383,7 +382,7 @@ export function makeVatWarehouse(kernelKeeper, vatLoader, policyOptions) { async function resetWorker(vatID) { await evict(vatID); const vatKeeper = kernelKeeper.provideVatKeeper(vatID); - vatKeeper.removeSnapshotAndTranscript(); + vatKeeper.dropSnapshotAndResetTranscript(); } /** diff --git a/packages/SwingSet/src/types-ambient.js b/packages/SwingSet/src/types-ambient.js index dbaf86f92c2..18bd2299a27 100644 --- a/packages/SwingSet/src/types-ambient.js +++ b/packages/SwingSet/src/types-ambient.js @@ -135,8 +135,7 @@ */ /** * @typedef { import('@agoric/swing-store').KVStore } KVStore - * @typedef { import('@agoric/swing-store').StreamStore } StreamStore - * @typedef { import('@agoric/swing-store').StreamPosition } StreamPosition + * @typedef { import('@agoric/swing-store').TranscriptStore } TranscriptStore * @typedef { import('@agoric/swing-store').SwingStore } SwingStore * @typedef { import('@agoric/swing-store').SwingStoreKernelStorage } SwingStoreKernelStorage * @typedef { import('@agoric/swing-store').SwingStoreHostStorage } SwingStoreHostStorage diff --git a/packages/SwingSet/src/types-external.js b/packages/SwingSet/src/types-external.js index cebac5c0eea..6949b7f4475 100644 --- a/packages/SwingSet/src/types-external.js +++ b/packages/SwingSet/src/types-external.js @@ -205,7 +205,7 @@ export {}; * vatSyscallHandler: unknown) => Promise, * } } VatManagerFactory * @typedef { { deliver: (delivery: VatDeliveryObject) => Promise, - * replayTranscript: (startPos: StreamPosition | undefined) => Promise, + * replayTranscript: (startPos: number | undefined) => Promise, * makeSnapshot?: (endPos: number, ss: SnapStore) => Promise, * shutdown: () => Promise, * } } VatManager @@ -273,8 +273,7 @@ export {}; * @typedef { import('@agoric/swing-store').KVStore } KVStore * @typedef { import('@agoric/swing-store').SnapStore } SnapStore * @typedef { import('@agoric/swing-store').SnapshotResult } SnapshotResult - * @typedef { import('@agoric/swing-store').StreamStore } StreamStore - * @typedef { import('@agoric/swing-store').StreamPosition } StreamPosition + * @typedef { import('@agoric/swing-store').TranscriptStore } TranscriptStore * @typedef { import('@agoric/swing-store').SwingStore } SwingStore * @typedef { import('@agoric/swing-store').SwingStoreKernelStorage } SwingStoreKernelStorage * @typedef { import('@agoric/swing-store').SwingStoreHostStorage } SwingStoreHostStorage diff --git a/packages/SwingSet/test/bootstrap-syscall-failure.js b/packages/SwingSet/test/bootstrap-syscall-failure.js index e22cc98d98d..123e6b54fbc 100644 --- a/packages/SwingSet/test/bootstrap-syscall-failure.js +++ b/packages/SwingSet/test/bootstrap-syscall-failure.js @@ -34,7 +34,7 @@ export function buildRootObject(vatPowers, vatParameters) { () => testLog('p2 resolve (bad!)'), e => testLog(`p2 reject ${e}`), ); - const p3 = E(badvat).begood(ourThing); + const p3 = E(badvat).begoodagain(ourThing); p3.then( () => testLog('p3 resolve (bad!)'), e => testLog(`p3 reject ${e}`), diff --git a/packages/SwingSet/test/device-plugin/test-device.js b/packages/SwingSet/test/device-plugin/test-device.js index 307587e3de6..7b38950c373 100644 --- a/packages/SwingSet/test/device-plugin/test-device.js +++ b/packages/SwingSet/test/device-plugin/test-device.js @@ -90,6 +90,12 @@ test.serial('plugin first time', async t => { ]); }); +// NOTE: the following test CANNOT be run standalone. It requires execution of +// the prior test to establish its necessary starting state. This is a bad +// practice and should be fixed. It's not bad enough to warrant fixing right +// now, but worth flagging with this comment as a help to anyone else who gets +// tripped up by it. + test.serial('plugin after restart', async t => { const { bridge, cycle, dump, plugin, queueThunkForKernel } = await setupVatController(t); diff --git a/packages/SwingSet/test/test-marshal.js b/packages/SwingSet/test/test-marshal.js index ccc6b6c7aad..16029f47390 100644 --- a/packages/SwingSet/test/test-marshal.js +++ b/packages/SwingSet/test/test-marshal.js @@ -144,7 +144,7 @@ test('unserialize promise', async t => { t.truthy(p instanceof Promise); }); -test('kernel serialzation of errors', async t => { +test('kernel serialization of errors', async t => { // The kernel synthesizes e.g. `Error('vat-upgrade failure')`, so we // need kmarshal to serialize those errors in a deterministic // way. This test checks that we don't get surprising things like diff --git a/packages/SwingSet/test/test-promises.js b/packages/SwingSet/test/test-promises.js index 272aeea929b..89cc8e12c23 100644 --- a/packages/SwingSet/test/test-promises.js +++ b/packages/SwingSet/test/test-promises.js @@ -6,7 +6,9 @@ import { loadBasedir, buildKernelBundles, } from '../src/index.js'; -import { kser, kslot } from '../src/lib/kmarshal.js'; +import { kser, kslot, kunser } from '../src/lib/kmarshal.js'; + +const bfile = name => new URL(name, import.meta.url).pathname; test.before(async t => { const kernelBundles = await buildKernelBundles(); @@ -212,3 +214,70 @@ test('refcount while queued', async t => { await c.run(); t.deepEqual(c.kpResolution(kpid4), kser([true, 3])); }); + +test('local promises are rejected by vat upgrade', async t => { + // TODO: Generalize packages/SwingSet/test/upgrade/test-upgrade.js + /** @type {SwingSetConfig} */ + const config = { + includeDevDependencies: true, // for vat-data + defaultManagerType: 'xs-worker', + bootstrap: 'bootstrap', + defaultReapInterval: 'never', + vats: { + bootstrap: { + sourceSpec: bfile('./bootstrap-relay.js'), + }, + }, + bundles: { + watcher: { sourceSpec: bfile('./vat-durable-promise-watcher.js') }, + }, + }; + const c = await buildVatController(config); + t.teardown(c.shutdown); + c.pinVatRoot('bootstrap'); + await c.run(); + + const run = async (method, args = []) => { + assert(Array.isArray(args)); + const kpid = c.queueToVatRoot('bootstrap', method, args); + await c.run(); + const status = c.kpStatus(kpid); + if (status === 'fulfilled') { + const result = c.kpResolution(kpid); + return kunser(result); + } + assert(status === 'rejected'); + const err = c.kpResolution(kpid); + throw kunser(err); + }; + const messageVat = (name, methodName, args) => + run('messageVat', [{ name, methodName, args }]); + // eslint-disable-next-line no-unused-vars + const messageObject = (presence, methodName, args) => + run('messageVatObject', [{ presence, methodName, args }]); + + const S = Symbol.for('passable'); + await run('createVat', [{ name: 'watcher', bundleCapName: 'watcher' }]); + await messageVat('watcher', 'watchLocalPromise', ['orphaned']); + await messageVat('watcher', 'watchLocalPromise', ['fulfilled', S]); + await messageVat('watcher', 'watchLocalPromise', ['rejected', undefined, S]); + const v1Settlements = await messageVat('watcher', 'getSettlements'); + t.deepEqual(v1Settlements, { + fulfilled: { status: 'fulfilled', value: S }, + rejected: { status: 'rejected', reason: S }, + }); + await run('upgradeVat', [{ name: 'watcher', bundleCapName: 'watcher' }]); + const v2Settlements = await messageVat('watcher', 'getSettlements'); + t.deepEqual(v2Settlements, { + fulfilled: { status: 'fulfilled', value: S }, + rejected: { status: 'rejected', reason: S }, + orphaned: { + status: 'rejected', + reason: { + name: 'vatUpgraded', + upgradeMessage: 'vat upgraded', + incarnationNumber: 1, + }, + }, + }); +}); diff --git a/packages/SwingSet/test/test-state.js b/packages/SwingSet/test/test-state.js index e5946c367e5..72932c659aa 100644 --- a/packages/SwingSet/test/test-state.js +++ b/packages/SwingSet/test/test-state.js @@ -145,13 +145,15 @@ function duplicateKeeper(serialize) { test('kernelStorage param guards', async t => { const { kvStore } = buildKeeperStorageInMemory(); - const exp = { message: /true must be a string/ }; - t.throws(() => kvStore.set('foo', true), exp); - t.throws(() => kvStore.set(true, 'foo'), exp); - t.throws(() => kvStore.has(true), exp); - t.throws(() => kvStore.getNextKey(true), exp); - t.throws(() => kvStore.get(true), exp); - t.throws(() => kvStore.delete(true), exp); + const expv = { message: /value must be a string/ }; + const expk = { message: /key must be a string/ }; + const exppk = { message: /previousKey must be a string/ }; + t.throws(() => kvStore.set('foo', true), expv); + t.throws(() => kvStore.set(true, 'foo'), expk); + t.throws(() => kvStore.has(true), expk); + t.throws(() => kvStore.getNextKey(true), exppk); + t.throws(() => kvStore.get(true), expk); + t.throws(() => kvStore.delete(true), expk); }); test('kernel state', async t => { diff --git a/packages/SwingSet/test/test-xsnap-metering.js b/packages/SwingSet/test/test-xsnap-metering.js index 36bbc38175c..5731ba0b0f6 100644 --- a/packages/SwingSet/test/test-xsnap-metering.js +++ b/packages/SwingSet/test/test-xsnap-metering.js @@ -45,7 +45,7 @@ function checkMetered(t, args, metered) { async function doTest(t, metered) { const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeSnapStoreIO()); const { p: p1, startXSnap: start1 } = make(store); const worker1 = await start1('vat', 'name', handleCommand, metered, false); diff --git a/packages/SwingSet/test/test-xsnap-store.js b/packages/SwingSet/test/test-xsnap-store.js index 4083d2b0dda..6adfbd547a8 100644 --- a/packages/SwingSet/test/test-xsnap-store.js +++ b/packages/SwingSet/test/test-xsnap-store.js @@ -60,7 +60,7 @@ test(`create XS Machine, snapshot (${snapSize.raw} Kb), compress to smaller`, as t.teardown(() => vat.close()); const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeMockSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeMockSnapStoreIO()); const { compressedSize } = await store.saveSnapshot( 'vat0', @@ -81,7 +81,7 @@ test('SES bootstrap, save, compress', async t => { t.teardown(() => vat.close()); const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeMockSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeMockSnapStoreIO()); await vat.evaluate('globalThis.x = harden({a: 1})'); @@ -101,7 +101,7 @@ test('SES bootstrap, save, compress', async t => { test('create SES worker, save, restore, resume', async t => { const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeMockSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeMockSnapStoreIO()); const vat0 = await bootSESWorker('ses-boot2', async m => m); t.teardown(() => vat0.close()); @@ -127,7 +127,7 @@ test('create SES worker, save, restore, resume', async t => { */ test('XS + SES snapshots are long-term deterministic', async t => { const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeMockSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeMockSnapStoreIO()); const vat = await bootWorker('xs1', async m => m, '1 + 1'); t.teardown(() => vat.close()); @@ -173,7 +173,7 @@ Then commit the changes in .../snapshots/ path. async function makeTestSnapshot() { const db = sqlite3(':memory:'); - const store = makeSnapStore(db, makeMockSnapStoreIO()); + const store = makeSnapStore(db, () => {}, makeMockSnapStoreIO()); const vat = await bootWorker('xs1', async m => m, '1 + 1'); const bootScript = await getBootScript(); await vat.evaluate(bootScript); diff --git a/packages/SwingSet/test/upgrade/test-upgrade.js b/packages/SwingSet/test/upgrade/test-upgrade.js index ebe690359e4..096dd9ef002 100644 --- a/packages/SwingSet/test/upgrade/test-upgrade.js +++ b/packages/SwingSet/test/upgrade/test-upgrade.js @@ -487,7 +487,7 @@ test('failed upgrade - explode', async t => { }, }; - const kernelStorage = initSwingStore().kernelStorage; + const { kernelStorage } = initSwingStore(); await initializeSwingset(config, [], kernelStorage); const c = await makeSwingsetController(kernelStorage); c.pinVatRoot('bootstrap'); diff --git a/packages/SwingSet/test/vat-admin/terminate/test-terminate.js b/packages/SwingSet/test/vat-admin/terminate/test-terminate.js index 2a98d2f5669..1bc8c73572c 100644 --- a/packages/SwingSet/test/vat-admin/terminate/test-terminate.js +++ b/packages/SwingSet/test/vat-admin/terminate/test-terminate.js @@ -443,7 +443,7 @@ test.serial('dead vat state removed', async t => { const configPath = new URL('swingset-die-cleanly.json', import.meta.url) .pathname; const config = await loadSwingsetConfigFile(configPath); - const kernelStorage = initSwingStore().kernelStorage; + const { kernelStorage, debug } = initSwingStore(); const controller = await buildVatController(config, [], { kernelStorage, @@ -456,16 +456,22 @@ test.serial('dead vat state removed', async t => { controller.kpResolution(controller.bootstrapResult), kser('bootstrap done'), ); - const kvStore = kernelStorage.kvStore; + const { kvStore } = kernelStorage; t.is(kvStore.get('vat.dynamicIDs'), '["v6"]'); t.is(kvStore.get('ko26.owner'), 'v6'); t.is(Array.from(enumeratePrefixedKeys(kvStore, 'v6.')).length > 30, true); + const beforeDump = debug.dump(true); + t.truthy(beforeDump.transcripts.v6); + t.truthy(beforeDump.snapshots.v6); controller.queueToVatRoot('bootstrap', 'phase2', []); await controller.run(); t.is(kvStore.get('vat.dynamicIDs'), '[]'); t.is(kvStore.get('ko26.owner'), undefined); t.is(Array.from(enumeratePrefixedKeys(kvStore, 'v6.')).length, 0); + const afterDump = debug.dump(true); + t.falsy(afterDump.transcripts.v6); + t.falsy(afterDump.snapshots.v6); }); test.serial('terminate with presence', async t => { diff --git a/packages/SwingSet/test/vat-durable-promise-watcher.js b/packages/SwingSet/test/vat-durable-promise-watcher.js new file mode 100644 index 00000000000..aad122941dc --- /dev/null +++ b/packages/SwingSet/test/vat-durable-promise-watcher.js @@ -0,0 +1,40 @@ +import { Far } from '@endo/marshal'; +import { getCopyMapEntries, M } from '@agoric/store'; +import { makePromiseKit } from '@endo/promise-kit'; +import { + prepareExo, + provideDurableMapStore, + watchPromise, +} from '@agoric/vat-data'; + +export function buildRootObject(_vatPowers, vatParameters, baggage) { + const settlements = provideDurableMapStore(baggage, 'settlements'); + const PromiseWatcherI = M.interface('PromiseWatcher', { + onFulfilled: M.call(M.any(), M.string()).returns(), + onRejected: M.call(M.any(), M.string()).returns(), + }); + const watcher = prepareExo(baggage, 'PromiseWatcher', PromiseWatcherI, { + onFulfilled(value, name) { + settlements.init(name, harden({ status: 'fulfilled', value })); + }, + onRejected(reason, name) { + settlements.init(name, harden({ status: 'rejected', reason })); + }, + }); + + return Far('root', { + watchLocalPromise: (name, fulfillment, rejection) => { + const { promise, resolve, reject } = makePromiseKit(); + if (fulfillment !== undefined) { + resolve(fulfillment); + } else if (rejection !== undefined) { + reject(rejection); + } + watchPromise(promise, watcher, name); + }, + getSettlements: () => { + const settlementsCopyMap = settlements.snapshot(); + return Object.fromEntries(getCopyMapEntries(settlementsCopyMap)); + }, + }); +} diff --git a/packages/SwingSet/test/vat-syscall-failure.js b/packages/SwingSet/test/vat-syscall-failure.js index 7442ac1d163..861c73e399e 100644 --- a/packages/SwingSet/test/vat-syscall-failure.js +++ b/packages/SwingSet/test/vat-syscall-failure.js @@ -8,7 +8,7 @@ export default function setup(syscall, _state, _helpers, vatPowers) { } const { method, args } = extractMessage(vatDeliverObject); vatPowers.testLog(`${method}`); - const thing = method === 'begood' ? args.slots[0] : 'o-3414159'; + const thing = method.startsWith('begood') ? args.slots[0] : 'o-3414159'; syscall.send(thing, kser(['pretendToBeAThing', [method]])); } return dispatch; diff --git a/packages/SwingSet/test/vat-warehouse/test-preload.js b/packages/SwingSet/test/vat-warehouse/test-preload.js index 18a647e093d..65b4f1fcde7 100644 --- a/packages/SwingSet/test/vat-warehouse/test-preload.js +++ b/packages/SwingSet/test/vat-warehouse/test-preload.js @@ -35,7 +35,7 @@ test('only preload maxVatsOnline vats', async t => { const argv = []; const db = sqlite3(':memory:'); - const snapStore = makeSnapStore(db, makeSnapStoreIO()); + const snapStore = makeSnapStore(db, () => {}, makeSnapStoreIO()); const kernelStorage = { ...initSwingStore().kernelStorage, snapStore }; await initializeSwingset(config, argv, kernelStorage, initOpts); diff --git a/packages/SwingSet/test/vat-warehouse/test-reload-snapshot.js b/packages/SwingSet/test/vat-warehouse/test-reload-snapshot.js index e822b2593c4..2dae19bbca3 100644 --- a/packages/SwingSet/test/vat-warehouse/test-reload-snapshot.js +++ b/packages/SwingSet/test/vat-warehouse/test-reload-snapshot.js @@ -24,7 +24,7 @@ test('vat reload from snapshot', async t => { }; const db = sqlite3(':memory:'); - const snapStore = makeSnapStore(db, makeSnapStoreIO()); + const snapStore = makeSnapStore(db, () => {}, makeSnapStoreIO()); const kernelStorage = { ...initSwingStore().kernelStorage, snapStore }; const argv = []; @@ -38,8 +38,8 @@ test('vat reload from snapshot', async t => { const snapshotInfo = snapStore.getSnapshotInfo(vatID); const start = snapshotInfo ? snapshotInfo.endPos : 0; - const endPosition = kernelStorage.kvStore.get(`${vatID}.t.endPosition`); - const end = Number(endPosition); + const bounds = kernelStorage.transcriptStore.getCurrentSpanBounds(vatID); + const end = bounds.endPos; return [start, end]; } diff --git a/packages/swing-store/docs/data-export.md b/packages/swing-store/docs/data-export.md new file mode 100644 index 00000000000..c538448fa21 --- /dev/null +++ b/packages/swing-store/docs/data-export.md @@ -0,0 +1,194 @@ +# SwingStore Data Import/Export + +The "SwingStore" package provides the database-backed storage component that each SwingSet kernel uses to hold all necessary state. This includes message queues, c-list tables, XS heap snapshots, and vat delivery transcripts. The host application is responsible for creating a swingstore instance and passing it to the new kernel, and for committing the store's database at the appropriate point in the execution cycle. + +Some applications may want to record their state changes in a way that can be cloned, to create new instances of the application. For example, a blockchain may consist of many "validators", each of which holds a replica of (hopefully) identical SwingSet kernel state, and we need a way to launch new validators and bring them quickly and cheaply up-to-date with the existing ones. We want the old validators to publish their SwingSet state, and for a prospective new validator node to be able to download this state as a starting point, rather than needing to replay the entire transaction/transcript history of the chain from the beginning. Some portion of this data may follow an untrusted path, so the new node must be able to validate the data it receives against some trust root. Typically there is a "block root hash" which they use as a starting point (which they either accept on faith from their operator, or which they somehow test against chain voting rules), then they can validate additional data against this root hash. + +Blockchain platforms like cosmos-sdk have tools to implement "state-sync", so the library will handle data formatting and distribution. But at the application layer, we must provide the SwingStore state to this library in a suitable format. The cosmos-sdk state-sync tools require that 1: every block includes a commitment to the entire state of the application, and 2: every once in a while (perhaps once per day) the application will be asked for a set of "export artifacts". The combination of the current block's commitment and the export artifacts should be sufficient for a new participant to receive a state vector that can be safely validated against the current chain state. + +Each SwingStore instance provides methods to facilitate this state export, and then to build a new SwingStore from the exported dataset. There is one set of methods to perform one-time full exports of the state. To facilitate consensus machines, a second set of methods are provided to perform incremental export of just the validation data, allowing the (large) remaining data to be exported only on rare occasions. + +## Two Stages: Export Data and Export Artifacts + +The SwingStore export protocol defines two stages (effectively two datasets). The contents of both are private to the SwingStore (the host application should make no assumptions about their contents or semantics). The first stage is called the "export data", and contains a set of key-value pairs (both strings). The second is a called the "export artifacts", each of which has a name (a string), and contains a blob of bytes. In general, the artifact blobs are much larger than the first-stage export data values, and take more time to generate. Host applications will typically not access the second-stage export artifacts until after the swingstore `commit()` is complete. + +![image 1](./images/data-export-1.jpg) + +Each time a SwingStore API is used to modify the state somehow (e.g. adding/changing/deleting a `kvStore` entry, or pushing a new item on to a transcript), the contents of both datasets may change. New first-stage entries can be created, existing ones may be modified or deleted. And the set of second-stage artifacts may change. + +These export data/artifact changes can happen when calling into the kernel (e.g. invoking the external API of a device, causing the device code to change its own state or push messages onto the run-queue), or by normal kernel operations as it runs (any time `controller.run()` is executing). When the kernel is idle (after `controller.run()` has completed), the kernel will not make any changes to the SwingStore, and both datasets will be stable. + +Among other things, the SwingStore records a transcript of deliveries for each vat. The collection of all deliveries to a particular vat since its last heap snapshot was written is called the "current span". For each vat, the first-stage export data will record a single record that remembers the extent and the hash of the current span. This record then refers to a second-stage export artifact that contains the actual transcript contents. + +![image 2a](./images/data-export-2a.jpg) + +When a delivery is made, a new entry is appended to the end of the current span. This updates (replaces) the record in the first-stage export data: the new record has a longer extent (the `endPos` value is higher), and the span contents have a new hash. The second-stage export artifact is replaced as well: the name remains the same, but contents are now different. + +![image 2b](./images/data-export-2b.jpg) + +To clone a SwingStore, the host application must extract both stages from the source copy, and somehow deliver them to a new instance of the application, which can feed both datasets into a new SwingStore. When complete, the destination SwingStore will have the same contents as the original, or at least enough to continue execution from the moment of copy (it may be lacking optional/historical data, like non-current vat transcripts from before the most recent heap snapshot). + +The host application is responsible for delivering both datasets, but it is only responsible for maintaining the *integrity* of the first stage export data. This table contains enough information to validate the contents of the export artifacts. The new clone is entirely reliant upon the contents of the first stage: if someone can manage to corrupt its contents, the new clone may be undetectably and arbitrarily corrupted. But as long as the first stage was delivered correctly, any changes to the second stage export artifacts will be discovered by the new SwingStore, and the import process will abort with an error. This split reduces the cost of supporting occasional state-sync export operations, as described below. + +## Full Export + +The simplest (albeit more expensive) way to use SwingStore data export is by creating an "exporter" and asking it to perform a one-time full export operation. + +The exporter is created by calling `makeSwingStoreExporter(dirpath)`, passing it the same directory pathname that was used to make your SwingStore instance. This API allows the exporter to use a separate SQLite database connection, so the original can continue executing deliveries and moving the application forward, while the exporter continues in the background. The exporter creates a new read-only SQLite transaction, which allows it to read from the old DB state even though new changes are being made on top of that checkpoint. In addition, the exporter can run in a thread or child process, so the export process can run in parallel with ongoing application work. This gives you as much time as you want to perform the export, without halting operations (however note that the child process must survive long enough to finish the export). + +After calling `hostStorage.commit()`, the host application can extract the first-stage export data, and then the second-stage export artifacts: + +```js + import { buffer } from 'node:stream/consumers'; + + const dirPath = '.../swing-store'; + const swingStore = openSwingStore(dirPath); + ... + await controller.run(); + hostStorage.commit(); + // spawn a child process + + // child process does: + const exporter = makeSwingStoreExporter(dirPath); + // exporter now has a txn, parent process is free to proceed forward + const exportData = new Map(); + for (const [key, value] of exporter.getExportData()) { + exportData.set(key, value); + } + const exportArtifacts = new Map(); + for (const name of exporter.getArtifactNames()) { + const reader = exporter.getArtifact(name); + // reader is an async iterable of Uint8Array, e.g. a stream + const data = await buffer(reader); + exportArtifacts.set(name, data); + } + // export is 'exportData' and 'exportArtifacts' +``` + +![image 3](./images/data-export-3.jpg) + +When doing a complete export, the `getExportData()` iterator will only announce each first-stage key once. + +Note that the new DB transaction is created during the execution of `makeSwingStoreExporter()`. If the exporter is run in a child process, the parent must ensure that it does not invoke the next `hostStorage.commit()` before the child reports that `makeSwingStoreExporter()` has completed. The export will capture the state of the SwingStore as of some particular commit, and we don't want to have a race between the parent finishing the next block, and the child establishing a transactional anchor on the state from the previous block. + +## Incremental Export + +The full export can be useful for backing up a "solo" swingset kernel, where consensus among multiple nodes is not required. However the more common (and complicated) use case is in a consensus machine, where multiple replicas are trying to maintain the same state. SwingStore offers an "incremental export" mode that is designed to work with the cosmos-sdk state-sync protocol. + +In this protocol, every block must contain enough information (hashes) to validate the entire state-sync dataset, even though most blocks are not used for for state-sync (and only a very few replicas will volunteer to create state-sync data). All validators vote on the block hashes, and these blocks are widely reported by block explorers and follower/archive nodes, so it is fairly easy to answer the question "is this the correct root hash?" for an arbitrary block height. + +When someone wants to launch a new validator, they ask around for an available state-sync snapshot. This will typically come from an archiving node, which produces a new snapshot each day. The archive node will report back the block height of their latest state-sync snapshot. The new validator operator must acquire a valid block header for that height, doing their own due diligence on the correctness of that header (checking its hash against public sources, etc). Then they can instruct their application to proceed with the state-sync download, which fetches the contents of the state-sync snapshot and compares them against the approved block header root hash. + +So, to include SwingStore data in this state-sync snapshot, we need a way to get the first-stage export data (including its validation hashes) into every block, as cheaply as possible. We defer the more expensive second-stage export until a state-sync producing node decides it is time to make a snapshot. + +To support this, SwingStore has an "incremental export" mode. This is activated when the host application supplies an "export callback" option to the SwingStore instance constructor. Instead of retrieving the entire first-stage export data at the end of the block, the host application will be continuously notified about changes to this data as the kernel executes. The host application can then incorporate those entries into an existing hashed Merkle tree (e.g. the cosmos-sdk IAVL tree), whose root hash is included in the consensus block hash. Every time the callback is given `(key, value)`, the host should add a new (or modify some existing) IAVL entry, using an IAVL key within some range dedicated to the SwingStore first-stage export data. When the callback receives `(key, undefined)` or `(key, null)`, it should delete the entry. In this way, the IAVL tree maintains a "shadow copy" of the first-stage export data at all times, making the contents both covered by the consensus hash, and automatically included in the cosmos-sdk IAVL tree where it will become available to the new validator as it begins to reconstruct the SwingStore. + +All validator nodes use this export callback, even if they never perform the rest of the export process, to ensure that the consensus state includes the entire first-stage dataset. (Note that the first stage data is generally smaller than the full dataset, making this relatively inexpensive). + +Then, on the few occasions when the application needs to build a full state-sync snapshot, it can ask the SwingStore (after block commit) for the full set of artifacts that match the most recent commit. + +![image 4](./images/data-export-4.jpg) + +```js + const dirPath = '.../swing-store'; + const iavl = ...; + function exportCallback(key, value) { + const iavlKey = `ssed.${key}`; // 'ssed' is short for SwingStoreExportData + if (value) { + iavl.set(iavlKey, value); + } else { + iavl.delete(iavlKey); // value===undefined means delete + } + } + const swingStore = openSwingStore(dirPath, { exportCallback }); + ... + await controller.run(); + hostStorage.commit(); + + // now, if the validator is configured to publish state-sync snapshots, + // and if this block height is one of the publishing points, + // do the following: + + // spawn a child process + + // child process does: + const exporter = makeSwingStoreExporter(dirPath); + // note: no exporter.getExportData(), the first-stage data is already in IAVL + const artifacts = new Map(); + for (const name of exporter.getArtifactNames()) { + artifacts.set(name, exporter.getArtifact(name)); + } + // instruct cosmos-sdk to include 'artifacts' in the state-sync snapshot +``` + +## Import + +On other end of the export process is an importer. This is a new host application, which wants to start from the contents of the export, rather than initializing a brand new (empty) kernel state. + +When starting a brand new instance, host applications would normally call `openSwingStore(dirPath)` to create a new (empty) SwingStore, then call SwingSet's `initializeSwingset(config, .., kernelStorage)` to let the kernel initialize the DB with a config-dependent starting state: + +```js +// this is done only the first time an instance is created: + +import { openSwingStore } from '@agoric/swing-store'; +import { initializeSwingset } from '@agoric/swingset-vat'; +const dirPath = './swing-store'; +const { hostStorage, kernelStorage } = openSwingStore(dirPath); +await initializeSwingset(config, argv, kernelStorage); +``` + +Once the initial state is created, each time the application is launched, it will build a controller around the existing state: + +```js +import { openSwingStore } from '@agoric/swing-store'; +import { makeSwingsetController } from '@agoric/swingset-vat'; +const dirPath = './swing-store'; +const { hostStorage, kernelStorage } = openSwingStore(dirPath); +const controller = await makeSwingsetController(kernelStorage); +// ... now do things like controller.run(), etc +``` + +When cloning an existing kernel, the initialization step is replaced with `importSwingStore`. The host application should feed the importer with the export data and artifacts, by passing an object that has the same API as the SwingStore's exporter: + +```js +import { importSwingStore } from '@agoric/swing-store'; +const dirPath = './swing-store'; +const exporter = { + getExportData() { // return iterator of [key,value] pairs }, + getArtifactNames() { // return iterator of names }, + getArtifact(name) { // return blob of artifact data }, +}; +const { hostStorage } = importSwingStore(exporter, dirPath); +hostStorage.commit(); +// now the swingstore is fully populated +``` + +Once the new SwingStore is fully populated with the previously-exported data, the host application can use `makeSwingsetController()` to build a kernel that will start from the exported state. + +## Optional / Historical Data + +Some of the data maintained by SwingStore is not strictly necessary for kernel execution, at least under normal circumstances. For example, once a vat worker performs a heap snapshot, we no longer need the transcript entries from before the snapshot was taken, since vat replay will start from the snapshot point. We split each vat's transcript into "spans", delimited by heap snapshot events, and the "current span" is the most recent one (still growing), whereas the "historical spans" are all closed and immutable. Likewise, we only really need the most recent heap snapshot for each vat: older snapshots might be interesting for experiments that replay old transcripts with different versions of the XS engine, but no normal kernel will ever need them. + +Most validators would prefer to prune this data, to reduce their storage needs. But we can imagine some [extreme upgrade scenarios](https://github.com/Agoric/agoric-sdk/issues/1691) that would require access to these historical transcript spans. Our compromise is to record *validation data* for these historical spans in the export data, but omit the spans themselves from the export artifacts. Validators can delete the old spans at will, and if we ever need them in the future, we can add code that will fetch copies from an archive service, validate them against the export data hashes, and re-insert the relevant entries into the SwingStore. + +Likewise, each time a heap snapshot is recorded, we cease to need any previous snapshot. And again, as a hedge against even more drastic recovery scenarios, we strike a compromise between minimizing retained data and the ability to validate old snapshots, by retaining only their hashes. + +As a result, for each active vat, the first-stage Export Data contains a record for every old transcript span, plus one for the current span. It also contains a record for every old heap snapshot, plus one for the most recent heap snapshot, plus a `.current` record that points to the most recent snapshot. However the exported artifacts may or may not include blobs for the old transcript spans, or for the old heap snapshots. + +The `openSwingStore()` function has an option named `keepTranscripts` (which defaults to `true`), which causes the transcriptStore to retain the old transcript items. A second option named `keepSnapshots` (which defaults to `false`) causes the snapStore to retain the old heap snapshots. Opening the swingStore with a `false` option does not necessarily delete the old items immediately, but they'll probably get deleted the next time the kernel triggers a heap snapshot or transcript-span rollover. Validators who care about minimizing their disk usage will want to set both to `false`. In the future, we will arrange the SwingStore SQLite tables to provide easy `sqlite3` CLI commands that will delete the old data, so validators can also periodically use the CLI command to prune it. + +The `getArtifactNames()` API includes an option named `includeHistorical`. If `true`, all available historical artifacts will be included in the export (limited by what the `openSwingStore` options have deleted). If `false`, none will be included. Note that the "export data" is necessarily unaffected: if we *ever* want to validate this optional data, the hashes are mandatory. But the `getArtifactNames()` list will be smaller if you set `includeHistorical = false`. Also, re-exporting from a pruned copy will lack the old data, even if the re-export uses `includeHistorical = true`, because the second SwingStore cannot magically reconstruct the missing data. + +Note that when a vat is terminated, we delete all information about it, including transcript items and snapshots, both current and old. This will remove all the Export Data records, and well as the matching artifacts from `getArtifactNames`. + +## Implementation Details + +SwingStore contains components to accomodate all the various kinds of state that the SwingSet kernel needs to store. This currently consists of three portions: + +* `kvStore`, a general-purpose string/string key-value table +* `transcriptStore`: append-only vat deliveries, broken into "spans", delimited by heap snapshot events +* `snapshotStore`: binary blobs containing JS engine heap state, to limit transcript replay depth + +Currently, the SwingStore treats transcript spans and heap snapshots as export artifacts, with hashes recorded in the export data for validation (and to remember exactly which artifacts are necessary). The `kvStore` is copied one-to-one into the export data (i.e. we keep a full shadow copy in IAVL), because that is the fastest way to ensure the `kvStore` data is fully available and validated. + +If some day we implement an IAVL-like Merkle tree inside SwingStore, and use it to automatically generate a root hash for the `kvStore` at the end of each block, we will replace this (large) shadow copy with a single `kvStoreRootHash` entry, and add a new export artifact to contain the full contents of the kvStore. This reduce the size of the IAVL tree, as well as the rate of IAVL updates during block execution, at the cost of increased CPU and complexity within SwingStore. diff --git a/packages/swing-store/docs/images/data-export-1.jpg b/packages/swing-store/docs/images/data-export-1.jpg new file mode 100644 index 00000000000..e8aa4aced8d Binary files /dev/null and b/packages/swing-store/docs/images/data-export-1.jpg differ diff --git a/packages/swing-store/docs/images/data-export-2a.jpg b/packages/swing-store/docs/images/data-export-2a.jpg new file mode 100644 index 00000000000..b4dd268543d Binary files /dev/null and b/packages/swing-store/docs/images/data-export-2a.jpg differ diff --git a/packages/swing-store/docs/images/data-export-2b.jpg b/packages/swing-store/docs/images/data-export-2b.jpg new file mode 100644 index 00000000000..3df65739556 Binary files /dev/null and b/packages/swing-store/docs/images/data-export-2b.jpg differ diff --git a/packages/swing-store/docs/images/data-export-3.jpg b/packages/swing-store/docs/images/data-export-3.jpg new file mode 100644 index 00000000000..4321a340570 Binary files /dev/null and b/packages/swing-store/docs/images/data-export-3.jpg differ diff --git a/packages/swing-store/docs/images/data-export-4.jpg b/packages/swing-store/docs/images/data-export-4.jpg new file mode 100644 index 00000000000..d9e269527eb Binary files /dev/null and b/packages/swing-store/docs/images/data-export-4.jpg differ diff --git a/packages/swing-store/package.json b/packages/swing-store/package.json index 4bfe5a8c943..b2ae9bad608 100644 --- a/packages/swing-store/package.json +++ b/packages/swing-store/package.json @@ -21,6 +21,7 @@ "@agoric/assert": "^0.5.1", "@agoric/internal": "^0.2.1", "better-sqlite3": "^7.5.0", + "readline-transform": "^1.0.0", "tmp": "^0.2.1" }, "devDependencies": { diff --git a/packages/swing-store/src/hasher.js b/packages/swing-store/src/hasher.js index 700154d2663..17153494b35 100644 --- a/packages/swing-store/src/hasher.js +++ b/packages/swing-store/src/hasher.js @@ -1,25 +1,30 @@ -import { assert } from '@agoric/assert'; +import { Fail } from '@agoric/assert'; import { createHash } from 'crypto'; /** - * @typedef { (initial?: string) => { - * add: (more: string) => void, - * finish: () => string, - * } - * } CreateSHA256 + * @typedef {{ + * add: (more: string | Buffer) => Hasher, + * finish: () => string, + * }} Hasher */ -/** @type { CreateSHA256 } */ +/** + * @param {string | Buffer} [initial] + * @returns {Hasher} + */ function createSHA256(initial = undefined) { const hash = createHash('sha256'); let done = false; + // eslint-disable-next-line no-use-before-define + const self = harden({ add, finish, sample }); function add(more) { - assert(!done); + !done || Fail`hash already finished`; hash.update(more); + return self; } function finish() { - assert(!done); + !done || Fail`hash already finished`; done = true; return hash.digest('hex'); } @@ -29,7 +34,7 @@ function createSHA256(initial = undefined) { if (initial) { add(initial); } - return harden({ add, finish, sample }); + return self; } harden(createSHA256); export { createSHA256 }; diff --git a/packages/swing-store/src/snapStore.js b/packages/swing-store/src/snapStore.js index 801cbd2c557..2f34cf8ad26 100644 --- a/packages/swing-store/src/snapStore.js +++ b/packages/swing-store/src/snapStore.js @@ -4,7 +4,7 @@ import { createHash } from 'crypto'; import { finished as finishedCallback, Readable } from 'stream'; import { promisify } from 'util'; import { createGzip, createGunzip } from 'zlib'; -import { assert, details as d } from '@agoric/assert'; +import { Fail, q } from '@agoric/assert'; import { aggregateTryFinally, PromiseAllOrErrors } from '@agoric/internal'; import { fsStreamReady } from '@agoric/internal/src/fs-stream.js'; @@ -26,18 +26,28 @@ import { fsStreamReady } from '@agoric/internal/src/fs-stream.js'; */ /** + * @typedef { import('./swingStore').SwingStoreExporter } SwingStoreExporter + * * @typedef {{ - * hasHash: (vatID: string, hash: string) => boolean, * loadSnapshot: (vatID: string, loadRaw: (filePath: string) => Promise) => Promise, * saveSnapshot: (vatID: string, endPos: number, saveRaw: (filePath: string) => Promise) => Promise, * deleteAllUnusedSnapshots: () => void, * deleteVatSnapshots: (vatID: string) => void, - * deleteSnapshotByHash: (vatID: string, hash: string) => void, + * stopUsingLastSnapshot: (vatID: string) => void, * getSnapshotInfo: (vatID: string) => SnapshotInfo, * }} SnapStore * * @typedef {{ - * dumpActiveSnapshots: () => {}, + * exportSnapshot: (name: string, includeHistorical: boolean) => AsyncIterable, + * importSnapshot: (artifactName: string, exporter: SwingStoreExporter, artifactMetadata: Map) => void, + * getExportRecords: (includeHistorical: boolean) => Iterable<[key: string, value: string]>, + * getArtifactNames: (includeHistorical: boolean) => AsyncIterable, + * }} SnapStoreInternal + * + * @typedef {{ + * hasHash: (vatID: string, hash: string) => boolean, + * dumpSnapshots: (includeHistorical?: boolean) => {}, + * deleteSnapshotByHash: (vatID: string, hash: string) => void, * }} SnapStoreDebug * */ @@ -65,6 +75,7 @@ const noPath = /** @type {import('fs').PathLike} */ ( /** * @param {*} db + * @param {() => void} ensureTxn * @param {{ * createReadStream: typeof import('fs').createReadStream, * createWriteStream: typeof import('fs').createWriteStream, @@ -75,12 +86,14 @@ const noPath = /** @type {import('fs').PathLike} */ ( * tmpName: typeof import('tmp').tmpName, * unlink: typeof import('fs').promises.unlink, * }} io + * @param {(key: string, value: string | undefined) => void} noteExport * @param {object} [options] * @param {boolean | undefined} [options.keepSnapshots] - * @returns {SnapStore & SnapStoreDebug} + * @returns {SnapStore & SnapStoreInternal & SnapStoreDebug} */ export function makeSnapStore( db, + ensureTxn, { createReadStream, createWriteStream, @@ -90,6 +103,7 @@ export function makeSnapStore( tmpName, unlink, }, + noteExport = () => {}, { keepSnapshots = false } = {}, ) { db.exec(` @@ -131,19 +145,68 @@ export function makeSnapStore( * use by some vat. */ function deleteAllUnusedSnapshots() { + ensureTxn(); sqlDeleteAllUnusedSnapshots.run(); } + function snapshotArtifactName(rec) { + return `snapshot.${rec.vatID}.${rec.endPos}`; + } + + function snapshotMetadataKey(rec) { + return `snapshot.${rec.vatID}.${rec.endPos}`; + } + + function currentSnapshotMetadataKey(rec) { + return `snapshot.${rec.vatID}.current`; + } + + /** + * @param {string} vatID + * @param {number} endPos + * @param {string} [hash] + * @param {number} [inUse] + */ + function snapshotRec(vatID, endPos, hash, inUse) { + return { vatID, endPos, hash, inUse }; + } + + const sqlGetPriorSnapshotInfo = db.prepare(` + SELECT endPos, hash + FROM snapshots + WHERE vatID = ? AND inUse = 1 + `); + + const sqlClearLastSnapshot = db.prepare(` + UPDATE snapshots + SET inUse = 0, compressedSnapshot = null + WHERE inUse = 1 AND vatID = ? + `); + const sqlStopUsingLastSnapshot = db.prepare(` UPDATE snapshots SET inUse = 0 WHERE inUse = 1 AND vatID = ? `); + function stopUsingLastSnapshot(vatID) { + ensureTxn(); + const oldInfo = sqlGetPriorSnapshotInfo.get(vatID); + if (oldInfo) { + const rec = snapshotRec(vatID, oldInfo.endPos, oldInfo.hash, 0); + noteExport(snapshotMetadataKey(rec), JSON.stringify(rec)); + if (keepSnapshots) { + sqlStopUsingLastSnapshot.run(vatID); + } else { + sqlClearLastSnapshot.run(vatID); + } + } + } + const sqlSaveSnapshot = db.prepare(` INSERT OR REPLACE INTO snapshots (vatID, endPos, inUse, hash, uncompressedSize, compressedSize, compressedSnapshot) - VALUES (?, ?, 1, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?) `); /** @@ -190,20 +253,25 @@ export function makeSnapStore( await finished(snapReader); const h = hashStream.digest('hex'); - sqlStopUsingLastSnapshot.run(vatID); - if (!keepSnapshots) { - deleteAllUnusedSnapshots(); - } + ensureTxn(); + stopUsingLastSnapshot(vatID); compressedSize = compressedSnapshot.length; sqlSaveSnapshot.run( vatID, endPos, + 1, h, uncompressedSize, compressedSize, compressedSnapshot, ); - + const rec = snapshotRec(vatID, endPos, h, 1); + const exportKey = snapshotMetadataKey(rec); + noteExport(exportKey, JSON.stringify(rec)); + noteExport( + currentSnapshotMetadataKey(rec), + snapshotArtifactName(rec), + ); return h; }); @@ -223,12 +291,50 @@ export function makeSnapStore( ); } + const sqlGetSnapshot = db.prepare(` + SELECT compressedSnapshot, inUse + FROM snapshots + WHERE vatID = ? AND endPos = ? + `); + + /** + * Read a snapshot and return it as a stream of data suitable for export to + * another store. + * + * Snapshot artifact names should be strings of the form: + * `snapshot.${vatID}.${startPos}` + * + * @param {string} name + * @param {boolean} includeHistorical + * @returns {AsyncIterable} + */ + function exportSnapshot(name, includeHistorical) { + typeof name === 'string' || Fail`artifact name must be a string`; + const parts = name.split('.'); + const [type, vatID, pos] = parts; + // prettier-ignore + (parts.length === 3 && type === 'snapshot') || + Fail`expected artifact name of the form 'snapshot.{vatID}.{endPos}', saw ${q(name)}`; + const endPos = Number(pos); + const snapshotInfo = sqlGetSnapshot.get(vatID, endPos); + snapshotInfo || Fail`snapshot ${q(name)} not available`; + const { inUse, compressedSnapshot } = snapshotInfo; + compressedSnapshot || Fail`artifact ${q(name)} is not available`; + inUse || includeHistorical || Fail`artifact ${q(name)} is not available`; + // weird construct here is because we need to be able to throw before the generator starts + async function* exporter() { + const gzReader = Readable.from(compressedSnapshot); + const unzipper = createGunzip(); + const snapshotReader = gzReader.pipe(unzipper); + yield* snapshotReader; + } + return exporter(); + } + const sqlLoadSnapshot = db.prepare(` SELECT hash, compressedSnapshot FROM snapshots - WHERE vatID = ? - ORDER BY endPos DESC - LIMIT 1 + WHERE vatID = ? AND inUse = 1 `); /** @@ -243,8 +349,9 @@ export function makeSnapStore( return aggregateTryFinally( async () => { const loadInfo = sqlLoadSnapshot.get(vatID); - assert(loadInfo, `no snapshot available for vat ${vatID}`); + loadInfo || Fail`no snapshot available for vat ${q(vatID)}`; const { hash, compressedSnapshot } = loadInfo; + compressedSnapshot || Fail`no snapshot available for vat ${q(vatID)}`; const gzReader = Readable.from(compressedSnapshot); cleanup.push(() => gzReader.destroy()); const snapReader = gzReader.pipe(createGunzip()); @@ -268,7 +375,7 @@ export function makeSnapStore( await Promise.all([finished(gzReader), finished(snapWriter)]); const h = hashStream.digest('hex'); - h === hash || assert.fail(d`actual hash ${h} !== expected ${hash}`); + h === hash || Fail`actual hash ${q(h)} !== expected ${q(hash)}`; const snapWriterClose = cleanup.pop(); snapWriterClose(); @@ -287,21 +394,34 @@ export function makeSnapStore( WHERE vatID = ? `); + const sqlGetSnapshotList = db.prepare(` + SELECT endPos + FROM snapshots + WHERE vatID = ? + ORDER BY endPos + `); + sqlGetSnapshotList.pluck(true); + /** * Delete all snapshots for a given vat (for use when, e.g., a vat is terminated) * * @param {string} vatID */ function deleteVatSnapshots(vatID) { + ensureTxn(); + const deletions = sqlGetSnapshotList.all(vatID); + for (const endPos of deletions) { + const exportRec = snapshotRec(vatID, endPos, undefined); + noteExport(snapshotMetadataKey(exportRec), undefined); + } + noteExport(currentSnapshotMetadataKey({ vatID }), undefined); sqlDeleteVatSnapshots.run(vatID); } const sqlGetSnapshotInfo = db.prepare(` SELECT endPos, hash, uncompressedSize, compressedSize FROM snapshots - WHERE vatID = ? - ORDER BY endPos DESC - LIMIT 1 + WHERE vatID = ? AND inUse = 1 `); /** @@ -353,24 +473,138 @@ export function makeSnapStore( * @param {string} hash */ function deleteSnapshotByHash(vatID, hash) { + ensureTxn(); sqlDeleteSnapshotByHash.run(vatID, hash); } - const sqlDumpActiveSnapshots = db.prepare(` - SELECT vatID, endPos, hash, compressedSnapshot + const sqlGetSnapshotMetadata = db.prepare(` + SELECT vatID, endPos, hash, uncompressedSize, compressedSize, inUse + FROM snapshots + WHERE inUse = ? + ORDER BY vatID, endPos + `); + + /** + * Obtain artifact metadata records for spanshots contained in this store. + * + * @param {boolean} includeHistorical If true, include all metadata that is + * present in the store regardless of its currency; if false, only include + * the metadata that is part of the swingset's active operational state. + * + * Note: in the currently anticipated operational mode, this flag should + * always be set to `true`, because *all* snapshot metadata is, for now, + * considered part of the consensus set. This metadata is being retained for + * diagnostic purposes and as a hedge against possible future need. While + * such a need seems highly unlikely, the future is uncertain and it will be + * easier to purge this data later than to recover it if it is lost. However, + * the flag itself is present in case future operational policy allows for + * pruning historical metadata, for example after further analysis and + * practical experience tells us that it will not be needed. + * + * @yields {[key: string, value: string]} + * @returns {Iterable<[key: string, value: string]>} + */ + function* getExportRecords(includeHistorical = true) { + for (const rec of sqlGetSnapshotMetadata.iterate(1)) { + const exportRec = snapshotRec(rec.vatID, rec.endPos, rec.hash, 1); + const exportKey = snapshotMetadataKey(rec); + yield [exportKey, JSON.stringify(exportRec)]; + yield [currentSnapshotMetadataKey(rec), snapshotArtifactName(rec)]; + } + if (includeHistorical) { + for (const rec of sqlGetSnapshotMetadata.iterate(0)) { + const exportRec = snapshotRec(rec.vatID, rec.endPos, rec.hash, 0); + yield [snapshotMetadataKey(rec), JSON.stringify(exportRec)]; + } + } + } + + async function* getArtifactNames(includeHistorical) { + for (const rec of sqlGetSnapshotMetadata.iterate(1)) { + yield snapshotArtifactName(rec); + } + if (includeHistorical) { + for (const rec of sqlGetSnapshotMetadata.iterate(0)) { + yield snapshotArtifactName(rec); + } + } + } + + /** + * @param {string} name Artifact name of the snapshot + * @param {SwingStoreExporter} exporter Whence to get the bits + * @param {object} info Metadata describing the artifact + * @returns {Promise} + */ + async function importSnapshot(name, exporter, info) { + const parts = name.split('.'); + const [type, vatID, rawEndPos] = parts; + // prettier-ignore + parts.length === 3 && type === 'snapshot' || + Fail`expected snapshot name of the form 'snapshot.{vatID}.{endPos}', saw '${q(name)}'`; + // prettier-ignore + info.vatID === vatID || + Fail`snapshot name says vatID ${q(vatID)}, metadata says ${q(info.vatID)}`; + const endPos = Number(rawEndPos); + // prettier-ignore + info.endPos === endPos || + Fail`snapshot name says endPos ${q(endPos)}, metadata says ${q(info.endPos)}`; + + const artifactChunks = exporter.getArtifact(name); + const inStream = Readable.from(artifactChunks); + let size = 0; + inStream.on('data', chunk => (size += chunk.length)); + const hashStream = createHash('sha256'); + const gzip = createGzip(); + inStream.pipe(hashStream); + inStream.pipe(gzip); + const compressedArtifact = await buffer(gzip); + await finished(inStream); + const hash = hashStream.digest('hex'); + // prettier-ignore + info.hash === hash || + Fail`snapshot ${q(name)} hash is ${q(hash)}, metadata says ${q(info.hash)}`; + ensureTxn(); + sqlSaveSnapshot.run( + vatID, + endPos, + info.inUse, + info.hash, + size, + compressedArtifact.length, + compressedArtifact, + ); + } + + const sqlDumpCurrentSnapshots = db.prepare(` + SELECT vatID, endPos, hash, compressedSnapshot, inUse FROM snapshots WHERE inUse = 1 ORDER BY vatID, endPos `); + const sqlDumpAllSnapshots = db.prepare(` + SELECT vatID, endPos, hash, compressedSnapshot, inUse + FROM snapshots + ORDER BY vatID, endPos + `); + /** * debug function to dump active snapshots + * + * @param {boolean} [includeHistorical] */ - function dumpActiveSnapshots() { + function dumpSnapshots(includeHistorical = true) { + const sql = includeHistorical + ? sqlDumpAllSnapshots + : sqlDumpCurrentSnapshots; const dump = {}; - for (const row of sqlDumpActiveSnapshots.iterate()) { - const { vatID, endPos, hash, compressedSnapshot } = row; - dump[vatID] = { endPos, hash, compressedSnapshot }; + for (const row of sql.iterate()) { + const { vatID, endPos, hash, compressedSnapshot, inUse } = row; + if (!dump[vatID]) { + dump[vatID] = []; + } + dump[vatID].push({ endPos, hash, compressedSnapshot, inUse }); } return dump; } @@ -380,11 +614,15 @@ export function makeSnapStore( loadSnapshot, deleteAllUnusedSnapshots, deleteVatSnapshots, + stopUsingLastSnapshot, getSnapshotInfo, + getExportRecords, + getArtifactNames, + exportSnapshot, + importSnapshot, hasHash, + dumpSnapshots, deleteSnapshotByHash, - - dumpActiveSnapshots, }); } diff --git a/packages/swing-store/src/streamStore.js b/packages/swing-store/src/streamStore.js deleted file mode 100644 index 1c978d4eb78..00000000000 --- a/packages/swing-store/src/streamStore.js +++ /dev/null @@ -1,162 +0,0 @@ -// @ts-check -import { assert, Fail, q } from '@agoric/assert'; - -const STREAM_START = 0; -/** - * @typedef { number } StreamPosition - * - * @typedef {{ - * writeStreamItem: (streamName: string, item: string, position: StreamPosition) => StreamPosition, - * readStream: (streamName: string, startPosition: StreamPosition, endPosition: StreamPosition) => IterableIterator, - * closeStream: (streamName: string) => void, - * STREAM_START: StreamPosition, - * }} StreamStore - * - * @typedef {{ - * dumpStreams: () => any, - * }} StreamStoreDebug - * - */ - -function* empty() { - // Yield nothing -} - -/** - * @param {unknown} streamName - * @returns {asserts streamName is string} - */ - -function insistStreamName(streamName) { - assert.typeof(streamName, 'string'); - streamName.match(/^[-\w]+$/) || Fail`invalid stream name ${q(streamName)}`; -} - -/** - * @param {unknown} position - * @returns {asserts position is StreamPosition} - */ - -function insistStreamPosition(position) { - assert.typeof(position, 'number'); - assert(position >= 0); -} - -/** - * @param {*} db - * @param {() => void} ensureTxn - * @returns { StreamStore & StreamStoreDebug } - */ -export function makeStreamStore(db, ensureTxn) { - db.exec(` - CREATE TABLE IF NOT EXISTS streamItem ( - streamName TEXT, - position INTEGER, - item TEXT, - PRIMARY KEY (streamName, position) - ) - `); - - const streamStatus = new Map(); - - const sqlDumpStreamsQuery = db.prepare(` - SELECT streamName, position, item - FROM streamItem - ORDER BY streamName, position - `); - - function dumpStreams() { - // debug function to return: dump[streamName][position] = item - const streams = {}; - for (const row of sqlDumpStreamsQuery.iterate()) { - const { streamName, position, item } = row; - if (!streams[streamName]) { - streams[streamName] = []; - } - streams[streamName][position] = item; - } - return streams; - } - - const sqlReadStreamQuery = db.prepare(` - SELECT item - FROM streamItem - WHERE streamName = ? AND position >= ? AND position < ? - ORDER BY position - `); - - /** - * @param {string} streamName - * @param {StreamPosition} startPosition - * @param {StreamPosition} endPosition - */ - function readStream(streamName, startPosition, endPosition) { - insistStreamName(streamName); - !streamStatus.get(streamName) || - Fail`can't read stream ${q(streamName)} because it's already in use`; - insistStreamPosition(startPosition); - insistStreamPosition(endPosition); - startPosition <= endPosition || - Fail`${q(startPosition)} <= ${q(endPosition)}}`; - - function* reader() { - ensureTxn(); - for (const { item } of sqlReadStreamQuery.iterate( - streamName, - startPosition, - endPosition, - )) { - streamStatus.get(streamName) === 'reading' || - Fail`can't read stream ${q(streamName)}, it's been closed`; - yield item; - } - streamStatus.delete(streamName); - } - !streamStatus.has(streamName) || - Fail`can't read stream ${q(streamName)} because it's already in use`; - - if (startPosition === endPosition) { - return empty(); - } - - streamStatus.set(streamName, 'reading'); - - return reader(); - } - - const sqlStreamWrite = db.prepare(` - INSERT INTO streamItem (streamName, item, position) - VALUES (?, ?, ?) - ON CONFLICT(streamName, position) DO UPDATE SET item = ? - `); - - /** - * @param {string} streamName - * @param {string} item - * @param {StreamPosition} position - */ - const writeStreamItem = (streamName, item, position) => { - insistStreamName(streamName); - insistStreamPosition(position); - !streamStatus.get(streamName) || - Fail`can't write stream ${q(streamName)} because it's already in use`; - - ensureTxn(); - sqlStreamWrite.run(streamName, item, position, item); - return position + 1; - }; - - /** @param {string} streamName */ - const closeStream = streamName => { - insistStreamName(streamName); - streamStatus.delete(streamName); - }; - - return harden({ - writeStreamItem, - readStream, - closeStream, - STREAM_START, - dumpStreams, - }); -} diff --git a/packages/swing-store/src/swingStore.js b/packages/swing-store/src/swingStore.js index 56ec40ce430..43e663ef280 100644 --- a/packages/swing-store/src/swingStore.js +++ b/packages/swing-store/src/swingStore.js @@ -7,10 +7,10 @@ import { file as tmpFile, tmpName } from 'tmp'; import sqlite3 from 'better-sqlite3'; -import { assert, Fail } from '@agoric/assert'; +import { assert, Fail, q } from '@agoric/assert'; import { makeMeasureSeconds } from '@agoric/internal'; -import { makeStreamStore } from './streamStore.js'; +import { makeTranscriptStore } from './transcriptStore.js'; import { makeSnapStore } from './snapStore.js'; import { createSHA256 } from './hasher.js'; @@ -29,6 +29,18 @@ export function makeSnapStoreIO() { }; } +/** + * @param {string} key + */ +function getKeyType(key) { + if (key.startsWith('local.')) { + return 'local'; + } else if (key.startsWith('host.')) { + return 'host'; + } + return 'consensus'; +} + /** * @typedef {{ * has: (key: string) => boolean, @@ -39,16 +51,17 @@ export function makeSnapStoreIO() { * }} KVStore * * @typedef { import('./snapStore').SnapStore } SnapStore + * @typedef { import('./snapStore').SnapStoreInternal } SnapStoreInternal * @typedef { import('./snapStore').SnapshotResult } SnapshotResult * - * @typedef { import('./streamStore').StreamPosition } StreamPosition - * @typedef { import('./streamStore').StreamStore } StreamStore - * @typedef { import('./streamStore').StreamStoreDebug } StreamStoreDebug + * @typedef { import('./transcriptStore').TranscriptStore } TranscriptStore + * @typedef { import('./transcriptStore').TranscriptStoreInternal } TranscriptStoreInternal + * @typedef { import('./transcriptStore').TranscriptStoreDebug } TranscriptStoreDebug * * @typedef {{ - * kvStore: KVStore, // a key-value StorageAPI object to load and store data on behalf of the kernel - * streamStore: StreamStore, // a stream-oriented API object to append and read streams of data - * snapStore?: SnapStore, + * kvStore: KVStore, // a key-value API object to load and store data on behalf of the kernel + * transcriptStore: TranscriptStore, // a stream-oriented API object to append and read transcript entries + * snapStore: SnapStore, * startCrank: () => void, * establishCrankSavepoint: (savepoint: string) => void, * rollbackCrank: (savepoint: string) => void, @@ -58,30 +71,190 @@ export function makeSnapStoreIO() { * }} SwingStoreKernelStorage * * @typedef {{ - * kvStore: KVStore, // a key-value StorageAPI object to load and store data on behalf of the host + * kvStore: KVStore, // a key-value API object to load and store data on behalf of the host * commit: () => Promise, // commit changes made since the last commit * close: () => Promise, // shutdown the store, abandoning any uncommitted changes * diskUsage?: () => number, // optional stats method + * setExportCallback: (cb: (updates: KVPair[]) => void) => void, // Set a callback invoked by swingStore when new serializable data is available for export * }} SwingStoreHostStorage - * + */ + +/** * @typedef {{ * kvEntries: {}, - * streams: {}, + * transcripts: {}, * snapshots: {}, * }} SwingStoreDebugDump * * @typedef {{ - * dump: () => SwingStoreDebugDump, + * dump: (includeHistorical?: boolean) => SwingStoreDebugDump, * serialize: () => Buffer, * }} SwingStoreDebugTools * * @typedef {{ + * transcriptStore: TranscriptStoreInternal, + * snapStore: SnapStoreInternal, + * }} SwingStoreInternal + * + * @typedef {{ * kernelStorage: SwingStoreKernelStorage, * hostStorage: SwingStoreHostStorage, * debug: SwingStoreDebugTools, + * internal: SwingStoreInternal, * }} SwingStore */ +/** + * @typedef {[ + * key: string, + * value: string|undefined, + * ]} KVPair + * + * @typedef {object} SwingStoreExporter + * + * Allows export of data from a swingStore as a fixed view onto the content as + * of the most recent commit point at the time the exporter was created. The + * exporter may be used while another SwingStore instance is active for the same + * DB, possibly in another thread or process. It guarantees that regardless of + * the concurrent activity of other swingStore instances, the data representing + * the commit point will stay consistent and available. + * + * @property {() => AsyncIterable} getExportData + * + * Get a full copy of the first-stage export data (key-value pairs) from the + * swingStore. This represents both the contents of the KVStore (excluding host + * and local prefixes), as well as any data needed to validate all artifacts, + * both current and historical. As such it represents the root of trust for the + * application. + * + * Content of validation data (with supporting entries for indexing): + * - kv.${key} = ${value} // ordinary kvStore data entry + * - snapshot.${vatID}.${endPos} = ${{ vatID, endPos, hash }); + * - snapshot.${vatID}.current = `snapshot.${vatID}.${endPos}` + * - transcript.${vatID}.${startPos} = ${{ vatID, startPos, endPos, hash }} + * - transcript.${vatID}.current = ${{ vatID, startPos, endPos, hash }} + * + * @property {() => AsyncIterable} getArtifactNames + * + * Get a list of name of artifacts available from the swingStore. A name returned + * by this method guarantees that a call to `getArtifact` on the same exporter + * instance will succeed. Options control the filtering of the artifact names + * yielded. + * + * Artifact names: + * - transcript.${vatID}.${startPos}.${endPos} + * - snapshot.${vatID}.${endPos} + * + * @property {(name: string) => AsyncIterable} getArtifact + * + * Retrieve an artifact by name. May throw if the artifact is not available, + * which can occur if the artifact is historical and wasn't been preserved. + * + * @property {() => Promise} close + * + * Dispose of all resources held by this exporter. Any further operation on this + * exporter or its outstanding iterators will fail. + */ + +/** + * @param {string} dirPath + * @param {string} exportMode + * @returns {SwingStoreExporter} + */ +export function makeSwingStoreExporter(dirPath, exportMode = 'current') { + typeof dirPath === 'string' || Fail`dirPath must be a string`; + exportMode === 'current' || + exportMode === 'archival' || + exportMode === 'debug' || + Fail`invalid exportMode ${q(exportMode)}`; + const exportHistoricalSnapshots = exportMode === 'debug'; + const exportHistoricalTranscripts = exportMode !== 'current'; + const filePath = path.join(dirPath, 'swingstore.sqlite'); + const db = sqlite3(filePath); + + // Execute the data export in a (read) transaction, to ensure that we are + // capturing the state of the database at a single point in time. + const sqlBeginTransaction = db.prepare('BEGIN TRANSACTION'); + sqlBeginTransaction.run(); + + // ensureTxn can be a dummy, we just started one + const ensureTxn = () => {}; + const snapStore = makeSnapStore(db, ensureTxn, makeSnapStoreIO()); + const transcriptStore = makeTranscriptStore(db, ensureTxn, () => {}); + + const sqlGetAllKVData = db.prepare(` + SELECT key, value + FROM kvStore + ORDER BY key + `); + + /** + * @returns {AsyncIterable} + * @yields {KVPair} + */ + async function* getExportData() { + const kvPairs = sqlGetAllKVData.iterate(); + for (const kv of kvPairs) { + if (getKeyType(kv.key) === 'consensus') { + yield [`kv.${kv.key}`, kv.value]; + } + } + yield* snapStore.getExportRecords(true); + yield* transcriptStore.getExportRecords(true); + } + + /** + * @returns {AsyncIterable} + * @yields {string} + */ + async function* getArtifactNames() { + yield* snapStore.getArtifactNames(exportHistoricalSnapshots); + yield* transcriptStore.getArtifactNames(exportHistoricalTranscripts); + } + + /** + * @param {string} name + * @returns {AsyncIterable} + */ + function getArtifact(name) { + typeof name === 'string' || Fail`artifact name must be a string`; + const [type] = name.split('.', 1); + + if (type === 'snapshot') { + return snapStore.exportSnapshot(name, exportHistoricalSnapshots); + } else if (type === 'transcript') { + return transcriptStore.exportSpan(name, exportHistoricalTranscripts); + } else { + assert.fail(`invalid artifact type ${q(type)}`); + } + } + + const sqlAbort = db.prepare('ROLLBACK'); + + async function close() { + // After all the data has been extracted, always abort the export + // transaction to ensure that the export was read-only (i.e., that no bugs + // inadvertantly modified the database). + sqlAbort.run(); + db.close(); + } + + return harden({ + getExportData, + getArtifactNames, + getArtifact, + close, + }); +} + +/** + * Function used to create a new swingStore from an object implementing the + * exporter API. The exporter API may be provided by a swingStore instance, or + * implemented by a host to restore data that was previously exported. + * + * @typedef {(exporter: SwingStoreExporter) => Promise} ImportSwingStore + */ + /** * A swing store holds the state of a swingset instance. This "store" is * actually several different stores of different types that travel as a flock @@ -95,7 +268,7 @@ export function makeSnapStoreIO() { * and values are both strings. Provides random access to a large number of * mostly small data items. Persistently stored in a sqlite table. * - * streamStore - a streaming store used to hold kernel transcripts. Transcripts + * transcriptStore - a streaming store used to hold kernel transcripts. Transcripts * are both written and read (if they are read at all) sequentially, according * to metadata kept in the kvStore. Persistently stored in a sqllite table. * @@ -141,8 +314,8 @@ export function makeSnapStoreIO() { function makeSwingStore(dirPath, forceReset, options = {}) { const { serialized } = options; if (serialized) { - assert(Buffer.isBuffer(serialized), `options.serialized must be Buffer`); - assert.equal(dirPath, null, `options.serialized makes :memory: DB`); + Buffer.isBuffer(serialized) || Fail`options.serialized must be Buffer`; + dirPath === null || Fail`options.serialized makes :memory: DB`; } let crankhasher; function resetCrankhash() { @@ -175,7 +348,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { filePath = ':memory:'; } - const { traceFile, keepSnapshots } = options; + const { traceFile, keepSnapshots, keepTranscripts } = options; let traceOutput = traceFile ? fs.createWriteStream(path.resolve(traceFile), { @@ -219,6 +392,22 @@ function makeSwingStore(dirPath, forceReset, options = {}) { ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS pendingExports ( + key TEXT, + value TEXT, + PRIMARY KEY (key) + ) + `); + let exportCallback; + function setExportCallback(cb) { + typeof cb === 'function' || Fail`callback must be a function`; + exportCallback = cb; + } + if (options.exportCallback) { + setExportCallback(options.exportCallback); + } + const sqlBeginTransaction = db.prepare('BEGIN IMMEDIATE TRANSACTION'); let inCrank = false; @@ -241,26 +430,14 @@ function makeSwingStore(dirPath, forceReset, options = {}) { // happens, it would wake up with inconsistent state. The only commit point // must be the hostStorage.commit(). function ensureTxn() { - assert(db); + db || Fail`db not initialized`; if (!db.inTransaction) { sqlBeginTransaction.run(); - assert(db.inTransaction); + db.inTransaction || Fail`must be in a transaction`; } return db; } - /** - * @param {string} key - */ - function getKeyType(key) { - if (key.startsWith('local.')) { - return 'local'; - } else if (key.startsWith('host.')) { - return 'host'; - } - return 'consensus'; - } - function diskUsage() { if (dirPath) { const dataFilePath = `${dirPath}/swingstore.sqlite`; @@ -289,7 +466,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * @throws if key is not a string. */ function get(key) { - assert.typeof(key, 'string'); + typeof key === 'string' || Fail`key must be a string`; return sqlKVGet.get(key); } @@ -329,7 +506,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { */ function getNextKey(previousKey) { - assert.typeof(previousKey, 'string'); + typeof previousKey === 'string' || Fail`previousKey must be a string`; return sqlKVGetNextKey.get(previousKey); } @@ -343,7 +520,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * @throws if key is not a string. */ function has(key) { - assert.typeof(key, 'string'); + typeof key === 'string' || Fail`key must be a string`; return get(key) !== undefined; } @@ -363,8 +540,8 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * @throws if either parameter is not a string. */ function set(key, value) { - assert.typeof(key, 'string'); - assert.typeof(value, 'string'); + typeof key === 'string' || Fail`key must be a string`; + typeof value === 'string' || Fail`value must be a string`; // synchronous read after write within a transaction is safe // The transaction's overall success will be awaited during commit ensureTxn(); @@ -386,7 +563,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * @throws if key is not a string. */ function del(key) { - assert.typeof(key, 'string'); + typeof key === 'string' || Fail`key must be a string`; ensureTxn(); sqlKVDel.run(key); trace('del', key); @@ -400,14 +577,27 @@ function makeSwingStore(dirPath, forceReset, options = {}) { delete: del, }; + const sqlAddPendingExport = db.prepare(` + INSERT INTO pendingExports (key, value) + VALUES (?, ?) + ON CONFLICT DO UPDATE SET value = excluded.value + `); + + function noteExport(key, value) { + if (exportCallback) { + sqlAddPendingExport.run(key, value); + } + } + const kernelKVStore = { ...kvStore, - set(key, value, bypassHash) { - assert.typeof(key, 'string'); + set(key, value) { + typeof key === 'string' || Fail`key must be a string`; const keyType = getKeyType(key); - assert(keyType !== 'host'); + keyType !== 'host' || Fail`kernelKVStore refuses host keys`; set(key, value); - if (keyType === 'consensus' && !bypassHash) { + if (keyType === 'consensus') { + noteExport(`kv.${key}`, value); crankhasher.add('add'); crankhasher.add('\n'); crankhasher.add(key); @@ -417,11 +607,12 @@ function makeSwingStore(dirPath, forceReset, options = {}) { } }, delete(key) { - assert.typeof(key, 'string'); + typeof key === 'string' || Fail`key must be a string`; const keyType = getKeyType(key); - assert(keyType !== 'host'); + keyType !== 'host' || Fail`kernelKVStore refuses host keys`; del(key); if (keyType === 'consensus') { + noteExport(`kv.${key}`, undefined); crankhasher.add('delete'); crankhasher.add('\n'); crankhasher.add(key); @@ -434,20 +625,29 @@ function makeSwingStore(dirPath, forceReset, options = {}) { ...kvStore, set(key, value) { const keyType = getKeyType(key); - assert(keyType === 'host'); + keyType === 'host' || Fail`hostKVStore requires host keys`; set(key, value); }, delete(key) { const keyType = getKeyType(key); - assert(keyType === 'host'); + keyType === 'host' || Fail`hostKVStore requires host keys`; del(key); }, }; - const { dumpStreams, ...streamStore } = makeStreamStore(db, ensureTxn); - const { dumpActiveSnapshots, ...snapStore } = makeSnapStore( + const { dumpTranscripts, ...transcriptStore } = makeTranscriptStore( + db, + ensureTxn, + noteExport, + { + keepTranscripts, + }, + ); + const { dumpSnapshots, ...snapStore } = makeSnapStore( db, + ensureTxn, makeSnapStoreIO(), + noteExport, { keepSnapshots, }, @@ -457,13 +657,13 @@ function makeSwingStore(dirPath, forceReset, options = {}) { const sqlReleaseSavepoints = db.prepare('RELEASE SAVEPOINT t0'); function startCrank() { - !inCrank || Fail`already in crank`; + !inCrank || Fail`startCrank while already in a crank`; inCrank = true; resetCrankhash(); } function establishCrankSavepoint(savepoint) { - inCrank || Fail`not in crank`; + inCrank || Fail`establishCrankSavepoint outside of crank`; const savepointOrdinal = savepoints.length; savepoints.push(savepoint); const sql = db.prepare(`SAVEPOINT t${savepointOrdinal}`); @@ -471,7 +671,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { } function rollbackCrank(savepoint) { - inCrank || Fail`not in crank`; + inCrank || Fail`rollbackCrank outside of crank`; for (const savepointOrdinal of savepoints.keys()) { if (savepoints[savepointOrdinal] === savepoint) { const sql = db.prepare(`ROLLBACK TO SAVEPOINT t${savepointOrdinal}`); @@ -480,7 +680,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { return; } } - assert.fail(`no such savepoint as "${savepoint}"`); + Fail`no such savepoint as "${q(savepoint)}"`; } function emitCrankHashes() { @@ -506,6 +706,10 @@ function makeSwingStore(dirPath, forceReset, options = {}) { // Store the new activityhash const activityhash = hasher.finish(); set('activityhash', activityhash); + // Need to explicitly call noteExport here because activityhash is written + // directly to the low-level store to avoid recursive hashing, which + // bypasses the normal notification mechanism + noteExport(`kv.activityhash`, activityhash); return { crankhash, activityhash }; } @@ -514,12 +718,35 @@ function makeSwingStore(dirPath, forceReset, options = {}) { return get('activityhash') || ''; } + const sqlExportsGet = db.prepare(` + SELECT * + FROM pendingExports + ORDER BY key + `); + sqlExportsGet.raw(true); + + const sqlExportsClear = db.prepare(` + DELETE + FROM pendingExports + `); + + function flushPendingExports() { + if (exportCallback) { + const exports = sqlExportsGet.all(); + if (exports.length > 0) { + sqlExportsClear.run(); + exportCallback(exports); + } + } + } + function endCrank() { - inCrank || Fail`not in crank`; + inCrank || Fail`endCrank outside of crank`; if (savepoints.length > 0) { sqlReleaseSavepoints.run(); savepoints.length = 0; } + flushPendingExports(); inCrank = false; } @@ -529,8 +756,9 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * Commit unsaved changes. */ async function commit() { - assert(db); + db || Fail`db not initialized`; if (db.inTransaction) { + flushPendingExports(); sqlCommit.run(); } } @@ -540,7 +768,7 @@ function makeSwingStore(dirPath, forceReset, options = {}) { * you want to save them, call commit() first). */ async function close() { - assert(db); + db || Fail`db not initialized`; commit(); db.close(); db = null; @@ -569,19 +797,37 @@ function makeSwingStore(dirPath, forceReset, options = {}) { return Object.fromEntries(s.all()); } - function dump() { + function dump(includeHistorical = true) { // return comparable JS object graph with entire DB state return harden({ kvEntries: dumpKVEntries(), - streams: dumpStreams(), - snapshots: dumpActiveSnapshots(), + transcripts: dumpTranscripts(includeHistorical), + snapshots: dumpSnapshots(includeHistorical), }); } + const transcriptStorePublic = { + initTranscript: transcriptStore.initTranscript, + rolloverSpan: transcriptStore.rolloverSpan, + getCurrentSpanBounds: transcriptStore.getCurrentSpanBounds, + addItem: transcriptStore.addItem, + readSpan: transcriptStore.readSpan, + deleteVatTranscripts: transcriptStore.deleteVatTranscripts, + }; + + const snapStorePublic = { + loadSnapshot: snapStore.loadSnapshot, + saveSnapshot: snapStore.saveSnapshot, + deleteAllUnusedSnapshots: snapStore.deleteAllUnusedSnapshots, + deleteVatSnapshots: snapStore.deleteVatSnapshots, + stopUsingLastSnapshot: snapStore.stopUsingLastSnapshot, + getSnapshotInfo: snapStore.getSnapshotInfo, + }; + const kernelStorage = { kvStore: kernelKVStore, - streamStore, - snapStore, + transcriptStore: transcriptStorePublic, + snapStore: snapStorePublic, startCrank, establishCrankSavepoint, rollbackCrank, @@ -594,16 +840,22 @@ function makeSwingStore(dirPath, forceReset, options = {}) { commit, close, diskUsage, + setExportCallback, }; const debug = { serialize, dump, }; + const internal = { + snapStore, + transcriptStore, + }; return harden({ kernelStorage, hostStorage, debug, + internal, }); } @@ -625,11 +877,179 @@ function makeSwingStore(dirPath, forceReset, options = {}) { */ export function initSwingStore(dirPath = null, options = {}) { if (dirPath) { - assert.typeof(dirPath, 'string'); + typeof dirPath === 'string' || Fail`dirPath must be a string`; } return makeSwingStore(dirPath, true, options); } +function parseVatArtifactExportKey(key) { + const parts = key.split('.'); + const [_type, vatID, rawPos] = parts; + // prettier-ignore + parts.length === 3 || + Fail`expected artifact name of the form '{type}.{vatID}.{pos}', saw ${q(key)}`; + const isCurrent = rawPos === 'current'; + let pos; + if (isCurrent) { + pos = -1; + } else { + pos = Number(rawPos); + } + + return { vatID, isCurrent, pos }; +} + +function artifactKey(type, vatID, pos) { + return `${type}.${vatID}.${pos}`; +} + +/** + * @param {SwingStoreExporter} exporter + * @param {string | null} [dirPath] + * @param {object} options + * @returns {Promise} + */ +export async function importSwingStore(exporter, dirPath = null, options = {}) { + if (dirPath) { + typeof dirPath === 'string' || Fail`dirPath must be a string`; + } + const { includeHistorical = false } = options; + const store = makeSwingStore(dirPath, true, options); + const { kernelStorage, internal } = store; + + // Artifact metadata, keyed as `${type}.${vatID}.${pos}` + // + // Note that this key is almost but not quite the artifact name, since the + // names of transcript span artifacts also include the endPos, but the endPos + // value is in flux until the span is complete. + const artifactMetadata = new Map(); + + // Each vat requires a transcript span and (usually) a snapshot. This table + // tracks which of these we've seen, keyed by vatID. + // vatID -> { snapshotKey: metadataKey, transcriptKey: metatdataKey } + const vatArtifacts = new Map(); + + for await (const [key, value] of exporter.getExportData()) { + const [tag] = key.split('.', 1); + const subKey = key.substring(tag.length + 1); + if (tag === 'kv') { + // 'kv' keys contain individual kvStore entries + if (value == null) { + // Note '==' rather than '===': any nullish value implies deletion + kernelStorage.kvStore.delete(subKey); + } else { + kernelStorage.kvStore.set(subKey, value); + } + } else if (tag === 'transcript' || tag === 'snapshot') { + // 'transcript' and 'snapshot' keys contain artifact description info. + assert(value); // make TypeScript shut up + const { vatID, isCurrent, pos } = parseVatArtifactExportKey(key); + if (isCurrent) { + const vatInfo = vatArtifacts.get(vatID) || {}; + if (tag === 'snapshot') { + // `export.snapshot.{vatID}.current` directly identifies the current snapshot artifact + vatInfo.snapshotKey = value; + } else if (tag === 'transcript') { + // `export.transcript.${vatID}.current` contains a metadata record for the current + // state of the current transcript span as of the time of export + const metadata = JSON.parse(value); + vatInfo.transcriptKey = artifactKey(tag, vatID, metadata.startPos); + artifactMetadata.set(vatInfo.transcriptKey, metadata); + } + vatArtifacts.set(vatID, vatInfo); + } else { + artifactMetadata.set(artifactKey(tag, vatID, pos), JSON.parse(value)); + } + } else { + Fail`unknown artifact type tag ${q(tag)} on import`; + } + } + + // At this point we should have acquired the entire KV store state, plus + // sufficient metadata to identify the complete set of artifacts we'll need to + // fetch along with the information required to validate each of them after + // fetching. + // + // Depending on how the export was parameterized, the metadata may also include + // information about historical artifacts that we might or might not actually + // fetch depending on how this import was parameterized + + // Fetch the set of current artifacts. + + // Keep track of fetched artifacts in this set so we don't fetch them a second + // time if we are trying for historical artifacts also. + const fetchedArtifacts = new Set(); + + for await (const [vatID, vatInfo] of vatArtifacts.entries()) { + // For each vat, we *must* have a transcript span. If this is not the very + // first transcript span in the history of that vat, then we also must have + // a snapshot for the state of the vat immediately prior to when the + // transcript span begins. + vatInfo.transcriptKey || + Fail`missing current transcript key for vat ${q(vatID)}`; + const transcriptInfo = artifactMetadata.get(vatInfo.transcriptKey); + transcriptInfo || Fail`missing transcript metadata for vat ${q(vatID)}`; + let snapshotInfo; + if (vatInfo.snapshotKey) { + snapshotInfo = artifactMetadata.get(vatInfo.snapshotKey); + snapshotInfo || Fail`missing snapshot metadata for vat ${q(vatID)}`; + } + if (!snapshotInfo) { + transcriptInfo.startPos === 0 || + Fail`missing current snapshot for vat ${q(vatID)}`; + } else { + snapshotInfo.endPos === transcriptInfo.startPos || + Fail`current transcript for vat ${q(vatID)} doesn't go with snapshot`; + fetchedArtifacts.add(vatInfo.snapshotKey); + } + await (!snapshotInfo || + internal.snapStore.importSnapshot( + vatInfo.snapshotKey, + exporter, + snapshotInfo, + )); + const transcriptArtifactName = `${vatInfo.transcriptKey}.${transcriptInfo.endPos}`; + await internal.transcriptStore.importSpan( + transcriptArtifactName, + exporter, + transcriptInfo, + ); + fetchedArtifacts.add(transcriptArtifactName); + } + if (!includeHistorical) { + return store; + } + + // If we're also importing historical artifacts, have the exporter enumerate + // the complete set of artifacts it has and fetch all of them except for the + // ones we've already fetched. + for await (const artifactName of exporter.getArtifactNames()) { + if (fetchedArtifacts.has(artifactName)) { + continue; + } + let fetchedP; + if (artifactName.startsWith('snapshot.')) { + fetchedP = internal.snapStore.importSnapshot( + artifactName, + exporter, + artifactMetadata.get(artifactName), + ); + } else if (artifactName.startsWith('transcript.')) { + // strip endPos off artifact name + const metadataKey = artifactName.split('.').slice(0, 3).join('.'); + fetchedP = internal.transcriptStore.importSpan( + artifactName, + exporter, + artifactMetadata.get(metadataKey), + ); + } else { + Fail`unknown artifact type: ${artifactName}`; + } + await fetchedP; + } + return store; +} + /** * Open a persistent swingset store. If there is no existing store at the given * `dirPath`, a new, empty store will be created. @@ -643,7 +1063,7 @@ export function initSwingStore(dirPath = null, options = {}) { * @returns {SwingStore} */ export function openSwingStore(dirPath, options = {}) { - assert.typeof(dirPath, 'string'); + typeof dirPath === 'string' || Fail`dirPath must be a string`; return makeSwingStore(dirPath, false, options); } @@ -658,7 +1078,7 @@ export function openSwingStore(dirPath, options = {}) { * or openSwingStore, returns true. Else returns false. */ export function isSwingStore(dirPath) { - assert.typeof(dirPath, 'string'); + typeof dirPath === 'string' || Fail`dirPath must be a string`; if (fs.existsSync(dirPath)) { const storeFile = path.resolve(dirPath, 'swingstore.sqlite'); if (fs.existsSync(storeFile)) { diff --git a/packages/swing-store/src/transcriptStore.js b/packages/swing-store/src/transcriptStore.js new file mode 100644 index 00000000000..2b4a9969740 --- /dev/null +++ b/packages/swing-store/src/transcriptStore.js @@ -0,0 +1,523 @@ +// @ts-check +import ReadlineTransform from 'readline-transform'; +import { Readable } from 'stream'; +import { Buffer } from 'buffer'; +import { Fail, q } from '@agoric/assert'; +import { createSHA256 } from './hasher.js'; + +/** + * @typedef { import('./swingStore').SwingStoreExporter } SwingStoreExporter + * + * @typedef {{ + * initTranscript: (vatID: string) => void, + * rolloverSpan: (vatID: string) => void, + * getCurrentSpanBounds: (vatID: string) => { startPos: number, endPos: number, hash: string }, + * deleteVatTranscripts: (vatID: string) => void, + * addItem: (vatID: string, item: string) => void, + * readSpan: (vatID: string, startPos?: number) => Iterable, + * }} TranscriptStore + * + * @typedef {{ + * exportSpan: (name: string, includeHistorical: boolean) => AsyncIterable + * importSpan: (artifactName: string, exporter: SwingStoreExporter, artifactMetadata: Map) => Promise, + * getExportRecords: (includeHistorical: boolean) => Iterable<[key: string, value: string]>, + * getArtifactNames: (includeHistorical: boolean) => AsyncIterable, + * }} TranscriptStoreInternal + * + * @typedef {{ + * dumpTranscripts: (includeHistorical?: boolean) => any, + * }} TranscriptStoreDebug + * + */ + +function* empty() { + // Yield nothing +} + +/** + * @param {number} position + * @returns {asserts position is number} + */ + +function insistTranscriptPosition(position) { + typeof position === 'number' || Fail`position must be a number`; + position >= 0 || Fail`position must not be negative`; +} + +/** + * @param {*} db + * @param {() => void} ensureTxn + * @param {(key: string, value: string | undefined ) => void} noteExport + * @param {object} [options] + * @param {boolean | undefined} [options.keepTranscripts] + * @returns { TranscriptStore & TranscriptStoreInternal & TranscriptStoreDebug } + */ +export function makeTranscriptStore( + db, + ensureTxn, + noteExport = () => {}, + { keepTranscripts = true } = {}, +) { + db.exec(` + CREATE TABLE IF NOT EXISTS transcriptItems ( + vatID TEXT, + position INTEGER, + item TEXT, + PRIMARY KEY (vatID, position) + ) + `); + + // Transcripts are broken up into "spans", delimited by heap snapshots. If we + // take heap snapshots after deliveries 100 and 200, and have not yet + // performed delivery 201, we'll have two non-current (i.e., isCurrent=null) + // spans (one with startPos=0, endPos=100, the second with startPos=100, + // endPos=200), and a single empty isCurrent==1 span with startPos=200 and + // endPos=200. After we perform delivery 201, the single isCurrent=1 span + // will will still have startPos=200 but will now have endPos=201. For every + // vatID, there will be exactly one isCurrent=1 span, and zero or more + // non-current (historical) spans. + // + // The transcriptItems associated with historical spans may or may not exist, + // depending on pruning. However, the items associated with the current span + // must always be present + + db.exec(` + CREATE TABLE IF NOT EXISTS transcriptSpans ( + vatID TEXT, + startPos INTEGER, -- inclusive + endPos INTEGER, -- exclusive + hash TEXT, -- cumulative hash of this item and previous cumulative hash + isCurrent INTEGER, + PRIMARY KEY (vatID, startPos), + UNIQUE (vatID, isCurrent) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS currentTranscriptIndex + ON transcriptSpans (vatID, isCurrent) + `); + + const sqlDumpItemsQuery = db.prepare(` + SELECT vatID, position, item + FROM transcriptItems + WHERE vatID = ? AND ? <= position AND position < ? + ORDER BY vatID, position + `); + + const sqlDumpSpansQuery = db.prepare(` + SELECT vatID, startPos, endPos, isCurrent + FROM transcriptSpans + ORDER BY vatID, startPos + `); + + function dumpTranscripts(includeHistorical = true) { + // debug function to return: dump[vatID][position] = item + const transcripts = {}; + for (const spanRow of sqlDumpSpansQuery.iterate()) { + if (includeHistorical || spanRow.isCurrent) { + for (const row of sqlDumpItemsQuery.iterate( + spanRow.vatID, + spanRow.startPos, + spanRow.endPos, + )) { + const { vatID, position, item } = row; + if (!transcripts[vatID]) { + transcripts[vatID] = {}; + } + transcripts[vatID][position] = item; + } + } + } + return transcripts; + } + + /** + * Compute a new cumulative hash for a span that includes a new transcript + * item. This is computed by hashing together the hash from the previous item + * in its span together with the new item's own text. + * + * @param {string} priorHash The previous item's hash + * @param {string} item The item itself + * + * @returns {string} The hash of the combined parameters. + */ + function updateSpanHash(priorHash, item) { + const itemHash = createSHA256(item).finish(); + return createSHA256(priorHash).add(itemHash).finish(); + } + + /** + * @type {string} Seed hash to use as the prior hash when computing the hash + * of the very first item in a span, since it has no prior item to draw upon. + */ + const initialHash = createSHA256('start of transcript span').finish(); + + const sqlWriteSpan = db.prepare(` + INSERT INTO transcriptSpans + (vatID, startPos, endPos, hash, isCurrent) + VALUES (?, ?, ?, ?, ?) + `); + + /** + * Start a new transcript for a given vat + * + * @param {string} vatID The vat whose transcript this shall be + */ + function initTranscript(vatID) { + ensureTxn(); + sqlWriteSpan.run(vatID, 0, 0, initialHash, 1); + } + + const sqlGetCurrentSpanBounds = db.prepare(` + SELECT startPos, endPos, hash + FROM transcriptSpans + WHERE vatID = ? AND isCurrent = 1 + `); + + /** + * Obtain the bounds and other metadata for a vat's current transcript span. + * + * @param {string} vatID The vat in question + * + * @returns {{startPos: number, endPos: number, hash: string}} + */ + function getCurrentSpanBounds(vatID) { + const bounds = sqlGetCurrentSpanBounds.get(vatID); + bounds || Fail`no current transcript for ${q(vatID)}`; + return bounds; + } + + function spanArtifactName(rec) { + return `transcript.${rec.vatID}.${rec.startPos}.${rec.endPos}`; + } + + function spanMetadataKey(rec) { + if (rec.isCurrent) { + return `transcript.${rec.vatID}.current`; + } else { + return `transcript.${rec.vatID}.${rec.startPos}`; + } + } + + function spanRec(vatID, startPos, endPos, hash, isCurrent) { + isCurrent = isCurrent ? 1 : 0; + return { vatID, startPos, endPos, hash, isCurrent }; + } + + const sqlEndCurrentSpan = db.prepare(` + UPDATE transcriptSpans + SET isCurrent = null + WHERE isCurrent = 1 AND vatID = ? + `); + + const sqlDeleteOldItems = db.prepare(` + DELETE FROM transcriptItems + WHERE vatID = ? AND position < ? + `); + + /** + * End the current transcript span for a vat and start a new one. + * + * @param {string} vatID The vat whose transcript is to rollover to a new + * span. + */ + function rolloverSpan(vatID) { + ensureTxn(); + const { hash, startPos, endPos } = getCurrentSpanBounds(vatID); + const rec = spanRec(vatID, startPos, endPos, hash, 0); + noteExport(spanMetadataKey(rec), JSON.stringify(rec)); + sqlEndCurrentSpan.run(vatID); + sqlWriteSpan.run(vatID, endPos, endPos, initialHash, 1); + const newRec = spanRec(vatID, endPos, endPos, initialHash, 1); + noteExport(spanMetadataKey(newRec), JSON.stringify(newRec)); + if (!keepTranscripts) { + sqlDeleteOldItems.run(vatID, endPos); + } + } + + const sqlDeleteVatSpans = db.prepare(` + DELETE FROM transcriptSpans + WHERE vatID = ? + `); + + const sqlDeleteVatItems = db.prepare(` + DELETE FROM transcriptItems + WHERE vatID = ? + `); + + const sqlGetVatSpans = db.prepare(` + SELECT vatID, startPos, isCurrent + FROM transcriptSpans + WHERE vatID = ? + ORDER BY startPos + `); + + /** + * Delete all transcript data for a given vat (for use when, e.g., a vat is terminated) + * + * @param {string} vatID + */ + function deleteVatTranscripts(vatID) { + ensureTxn(); + const deletions = sqlGetVatSpans.all(vatID); + for (const rec of deletions) { + noteExport(spanMetadataKey(rec), undefined); + } + sqlDeleteVatItems.run(vatID); + sqlDeleteVatSpans.run(vatID); + } + + const sqlGetAllSpanMetadata = db.prepare(` + SELECT vatID, startPos, endPos, hash, isCurrent + FROM transcriptSpans + ORDER BY vatID, startPos + `); + + const sqlGetCurrentSpanMetadata = db.prepare(` + SELECT vatID, startPos, endPos, hash, isCurrent + FROM transcriptSpans + WHERE isCurrent = 1 + ORDER BY vatID, startPos + `); + + /** + * Obtain artifact metadata records for spans contained in this store. + * + * @param {boolean} includeHistorical If true, include all metadata that is + * present in the store regardless of its currency; if false, only include + * the metadata that is part of the swingset's active operational state. + * + * Note: in the currently anticipated operational mode, this flag should + * always be set to `true`, because *all* transcript span metadata is, for + * now, considered part of the consensus set. This metadata is being retained + * as a hedge against possible future need, wherein we find it necessary to + * replay a vat's entire history from t0 and therefor need to be able to + * validate historical transcript artifacts that were recovered from external + * archives rather than retained directly. While such a need seems highly + * unlikely, it hypothetically could be forced by some necessary vat upgrade + * that implicates path-dependent ephemeral state despite our best efforts to + * avoid having any such state. However, the flag itself is present in case + * future operational policy allows for pruning historical transcript span + * metadata, for example because we've determined that such full-history + * replay will never be required or because such replay would be prohibitively + * expensive regardless of need and therefor other repair strategies employed. + * + * @yields {[key: string, value: string]} + * @returns {Iterable<[key: string, value: string]>} An iterator over pairs of + * [spanMetadataKey, rec], where `rec` is a JSON-encoded metadata record for the + * span named by `spanMetadataKey`. + */ + function* getExportRecords(includeHistorical = true) { + const sql = includeHistorical + ? sqlGetAllSpanMetadata + : sqlGetCurrentSpanMetadata; + for (const rec of sql.iterate()) { + const { vatID, startPos, endPos, hash, isCurrent } = rec; + const exportRec = spanRec(vatID, startPos, endPos, hash, isCurrent); + yield [spanMetadataKey(rec), JSON.stringify(exportRec)]; + } + } + + /** + * Obtain artifact names for spans contained in this store. + * + * @param {boolean} includeHistorical If true, include all spans that are + * present in the store regardless of their currency; if false, only include + * the current span for each vat. + * + * @yields {string} + * @returns {AsyncIterable} An iterator over the names of all the artifacts requested + */ + async function* getArtifactNames(includeHistorical) { + const sql = includeHistorical + ? sqlGetAllSpanMetadata + : sqlGetCurrentSpanMetadata; + for (const rec of sql.iterate()) { + yield spanArtifactName(rec); + } + } + + const sqlGetSpanEndPos = db.prepare(` + SELECT endPos + FROM transcriptSpans + WHERE vatID = ? AND startPos = ? + `); + sqlGetSpanEndPos.pluck(true); + + const sqlReadSpanItems = db.prepare(` + SELECT item + FROM transcriptItems + WHERE vatID = ? AND ? <= position AND position < ? + ORDER BY position + `); + + /** + * Read the items in a transcript span + * + * @param {string} vatID The vat whose transcript is being read + * @param {number} [startPos] A start position identifying the span to be + * read; defaults to the current span, whatever it is + * + * @returns {Iterable} An iterator over the items in the indicated span + */ + function readSpan(vatID, startPos) { + let endPos; + if (startPos === undefined) { + ({ startPos, endPos } = getCurrentSpanBounds(vatID)); + } else { + insistTranscriptPosition(startPos); + endPos = sqlGetSpanEndPos.get(vatID, startPos); + typeof endPos === 'number' || + Fail`no transcript span for ${q(vatID)} at ${q(startPos)}`; + } + insistTranscriptPosition(startPos); + startPos <= endPos || Fail`${q(startPos)} <= ${q(endPos)}}`; + + function* reader() { + for (const { item } of sqlReadSpanItems.iterate( + vatID, + startPos, + endPos, + )) { + yield item; + } + } + + if (startPos === endPos) { + return empty(); + } + + return reader(); + } + + const sqlGetSpanIsCurrent = db.prepare(` + SELECT isCurrent + FROM transcriptSpans + WHERE vatID = ? AND startPos = ? + `); + sqlGetSpanIsCurrent.pluck(true); + + /** + * Read a transcript span and return it as a stream of data suitable for + * export to another store. Transcript items are terminated by newlines. + * + * Transcript span artifact names should be strings of the form: + * `transcript.${vatID}.${startPos}.${endPos}` + * + * @param {string} name The name of the transcript artifact to be read + * @param {boolean} includeHistorical If true, allow non-current spans to be fetched + * + * @returns {AsyncIterable} + * @yields {Uint8Array} + */ + async function* exportSpan(name, includeHistorical) { + typeof name === 'string' || Fail`artifact name must be a string`; + const parts = name.split('.'); + const [type, vatID, pos] = parts; + // prettier-ignore + (parts.length === 4 && type === 'transcript') || + Fail`expected artifact name of the form 'transcript.{vatID}.{startPos}.{endPos}', saw ${q(name)}`; + const isCurrent = sqlGetSpanIsCurrent.get(vatID, pos); + isCurrent !== undefined || Fail`transcript span ${q(name)} not available`; + isCurrent || + includeHistorical || + Fail`transcript span ${q(name)} not available`; + const startPos = Number(pos); + for (const entry of readSpan(vatID, startPos)) { + yield Buffer.from(`${entry}\n`); + } + } + + const sqlAddItem = db.prepare(` + INSERT INTO transcriptItems (vatID, item, position) + VALUES (?, ?, ?) + `); + + const sqlUpdateSpan = db.prepare(` + UPDATE transcriptSpans + SET endPos = ?, hash = ? + WHERE vatID = ? AND isCurrent = 1 + `); + + /** + * Append an item to the current transcript span for a given vat + * + * @param {string} vatID The whose transcript is being added to + * @param {string} item The item to add + */ + const addItem = (vatID, item) => { + ensureTxn(); + const { startPos, endPos, hash } = getCurrentSpanBounds(vatID); + sqlAddItem.run(vatID, item, endPos); + const newEndPos = endPos + 1; + const newHash = updateSpanHash(hash, item); + sqlUpdateSpan.run(newEndPos, newHash, vatID); + const rec = spanRec(vatID, startPos, newEndPos, newHash, 1); + noteExport(spanMetadataKey(rec), JSON.stringify(rec)); + }; + + /** + * Import a transcript span from another store. + * + * @param {string} name Artifact Name of the transcript span + * @param {SwingStoreExporter} exporter Exporter from which to get the span data + * @param {object} info Metadata describing the span + * + * @returns {Promise} + */ + async function importSpan(name, exporter, info) { + const parts = name.split('.'); + const [type, vatID, rawStartPos, rawEndPos] = parts; + // prettier-ignore + parts.length === 4 && type === 'transcript' || + Fail`expected artifact name of the form 'transcript.{vatID}.{startPos}.{endPos}', saw '${q(name)}'`; + // prettier-ignore + info.vatID === vatID || + Fail`artifact name says vatID ${q(vatID)}, metadata says ${q(info.vatID)}`; + const startPos = Number(rawStartPos); + // prettier-ignore + info.startPos === startPos || + Fail`artifact name says startPos ${q(startPos)}, metadata says ${q(info.startPos)}`; + const endPos = Number(rawEndPos); + // prettier-ignore + info.endPos === endPos || + Fail`artifact name says endPos ${q(endPos)}, metadata says ${q(info.endPos)}`; + const artifactChunks = exporter.getArtifact(name); + const inStream = Readable.from(artifactChunks); + const lineTransform = new ReadlineTransform(); + const lineStream = inStream.pipe(lineTransform); + let hash = initialHash; + let pos = startPos; + for await (const item of lineStream) { + sqlAddItem.run(vatID, item, pos); + hash = updateSpanHash(hash, item); + pos += 1; + } + pos === endPos || Fail`artifact ${name} is not available`; + info.hash === hash || + Fail`artifact ${name} hash is ${q(hash)}, metadata says ${q(info.hash)}`; + sqlWriteSpan.run( + info.vatID, + info.startPos, + info.endPos, + info.hash, + info.isCurrent ? 1 : null, + ); + } + + return harden({ + initTranscript, + rolloverSpan, + getCurrentSpanBounds, + addItem, + readSpan, + deleteVatTranscripts, + + exportSpan, + importSpan, + getExportRecords, + getArtifactNames, + + dumpTranscripts, + }); +} diff --git a/packages/swing-store/test/test-deletion.js b/packages/swing-store/test/test-deletion.js new file mode 100644 index 00000000000..a01727a5c2e --- /dev/null +++ b/packages/swing-store/test/test-deletion.js @@ -0,0 +1,93 @@ +// @ts-check +import test from 'ava'; +import '@endo/init/debug.js'; +import fs from 'fs'; +import { initSwingStore } from '../src/swingStore.js'; + +async function dosnap(filePath) { + fs.writeFileSync(filePath, 'abc'); +} + +test('delete snapshots with export callback', async t => { + const exportLog = []; + const exportCallback = exports => { + for (const [key, value] of exports) { + exportLog.push([key, value]); + } + }; + const store = initSwingStore(null, { exportCallback }); + const { kernelStorage, hostStorage } = store; + const { snapStore } = kernelStorage; + const { commit } = hostStorage; + + await snapStore.saveSnapshot('v1', 10, dosnap); + await snapStore.saveSnapshot('v1', 11, dosnap); + await snapStore.saveSnapshot('v1', 12, dosnap); + // nothing is written to exportCallback until endCrank() or commit() + t.deepEqual(exportLog, []); + + await commit(); + + t.is(exportLog.length, 4); + t.is(exportLog[0][0], 'snapshot.v1.10'); + t.is(exportLog[1][0], 'snapshot.v1.11'); + t.is(exportLog[2][0], 'snapshot.v1.12'); + t.is(exportLog[3][0], 'snapshot.v1.current'); + exportLog.length = 0; + + // in a previous version, deleteVatSnapshots caused overlapping SQL + // queries, and failed + snapStore.deleteVatSnapshots('v1'); + await commit(); + + t.deepEqual(exportLog, [ + ['snapshot.v1.10', null], + ['snapshot.v1.11', null], + ['snapshot.v1.12', null], + ['snapshot.v1.current', null], + ]); + exportLog.length = 0; +}); + +test('delete transcripts with export callback', async t => { + const exportLog = []; + const exportCallback = exports => { + for (const [key, value] of exports) { + exportLog.push([key, value]); + } + }; + const store = initSwingStore(null, { exportCallback }); + const { kernelStorage, hostStorage } = store; + const { transcriptStore } = kernelStorage; + const { commit } = hostStorage; + + transcriptStore.initTranscript('v1'); + transcriptStore.addItem('v1', 'aaa'); + transcriptStore.addItem('v1', 'bbb'); + transcriptStore.addItem('v1', 'ccc'); + transcriptStore.rolloverSpan('v1'); + transcriptStore.addItem('v1', 'ddd'); + transcriptStore.addItem('v1', 'eee'); + transcriptStore.addItem('v1', 'fff'); + // nothing is written to exportCallback until endCrank() or commit() + t.deepEqual(exportLog, []); + + await commit(); + + t.is(exportLog.length, 2); + t.is(exportLog[0][0], 'transcript.v1.0'); + t.is(exportLog[1][0], 'transcript.v1.current'); + exportLog.length = 0; + + // in a previous version, deleteVatTranscripts caused overlapping SQL + // queries, and failed + transcriptStore.deleteVatTranscripts('v1'); + await commit(); + + t.deepEqual(exportLog, [ + ['transcript.v1.0', null], + ['transcript.v1.current', null], + ]); + + exportLog.length = 0; +}); diff --git a/packages/swing-store/test/test-exportImport.js b/packages/swing-store/test/test-exportImport.js new file mode 100644 index 00000000000..f6b38aeccfa --- /dev/null +++ b/packages/swing-store/test/test-exportImport.js @@ -0,0 +1,444 @@ +// @ts-check + +import '@endo/init/debug.js'; +import fs from 'fs'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tmp from 'tmp'; + +import { + initSwingStore, + makeSwingStoreExporter, + importSwingStore, +} from '../src/swingStore.js'; + +function makeExportLog() { + const exportLog = []; + const shadowStore = new Map(); + return { + callback(updates) { + exportLog.push(updates); + for (const [key, value] of updates) { + if (value == null) { + shadowStore.delete(key); + } else { + shadowStore.set(key, value); + } + } + }, + getLog() { + return exportLog; + }, + entries() { + return shadowStore.entries(); + }, + }; +} + +/** + * @param {string} [prefix] + * @returns {Promise<[string, () => void]>} + */ +const tmpDir = prefix => + new Promise((resolve, reject) => { + tmp.dir({ unsafeCleanup: true, prefix }, (err, name, removeCallback) => { + if (err) { + reject(err); + } else { + resolve([name, removeCallback]); + } + }); + }); + +function actLikeAVatRunningACrank(vat, ks, crank, doFail) { + const { kvStore, transcriptStore } = ks; + const { vatID } = vat; + ks.startCrank(); + if (doFail) { + ks.establishCrankSavepoint('a'); + } + kvStore.set('kval', `set in ${crank}`); + kvStore.set(`${vatID}.vval`, `stuff in ${crank}`); + kvStore.set(`${vatID}.monotonic.${crank}`, 'more and more'); + if (crank % 3 === 0) { + kvStore.delete('brigadoon'); + } else { + kvStore.set('brigadoon', `here during ${crank}`); + } + transcriptStore.addItem(vatID, `stuff done during crank #${crank}`); + if (doFail) { + ks.rollbackCrank('a'); + } else { + vat.endPos += 1; + } + ks.endCrank(); +} + +async function fakeAVatSnapshot(vat, ks) { + await ks.snapStore.saveSnapshot(vat.vatID, vat.endPos, async filePath => { + fs.writeFileSync( + filePath, + `snapshot of vat ${vat.vatID} as of ${vat.endPos}`, + ); + }); + ks.transcriptStore.rolloverSpan(vat.vatID); +} + +const compareElems = (a, b) => a[0].localeCompare(b[0]); + +test('crank abort leaves no debris in export log', async t => { + const exportLog = makeExportLog(); + const [dbDir, cleanup] = await tmpDir('testdb'); + t.teardown(cleanup); + + const ssOut = initSwingStore(dbDir, { + exportCallback: exportLog.callback, + }); + const { kernelStorage } = ssOut; + + const vat = { vatID: 'vatA', endPos: 0 }; + kernelStorage.transcriptStore.initTranscript(vat.vatID); + + // Run 4 "blocks", each consisting of 4 "cranks", accumulating stuff, + // aborting every third crank. + let crankNum = 0; + for (let block = 0; block < 4; block += 1) { + for (let crank = 0; crank < 4; crank += 1) { + crankNum += 1; + actLikeAVatRunningACrank( + vat, + kernelStorage, + crankNum, + crankNum % 3 === 0, + ); + } + // eslint-disable-next-line no-await-in-loop + await ssOut.hostStorage.commit(); + } + + const exporter = makeSwingStoreExporter(dbDir, 'current'); + + const exportData = []; + for await (const elem of exporter.getExportData()) { + exportData.push(elem); + } + exportData.sort(compareElems); + + const feedData = []; + for (const elem of exportLog.entries()) { + feedData.push(elem); + } + feedData.sort(compareElems); + + t.deepEqual(exportData, feedData); + // Commented data entries would have been produced by the aborted cranks + t.deepEqual(exportData, [ + ['kv.brigadoon', 'here during 16'], + ['kv.kval', 'set in 16'], + ['kv.vatA.monotonic.1', 'more and more'], + ['kv.vatA.monotonic.10', 'more and more'], + ['kv.vatA.monotonic.11', 'more and more'], + // ['kv.vatA.monotonic.12', 'more and more'], + ['kv.vatA.monotonic.13', 'more and more'], + ['kv.vatA.monotonic.14', 'more and more'], + // ['kv.vatA.monotonic.15', 'more and more'], + ['kv.vatA.monotonic.16', 'more and more'], + ['kv.vatA.monotonic.2', 'more and more'], + // ['kv.vatA.monotonic.3', 'more and more'], + ['kv.vatA.monotonic.4', 'more and more'], + ['kv.vatA.monotonic.5', 'more and more'], + // ['kv.vatA.monotonic.6', 'more and more'], + ['kv.vatA.monotonic.7', 'more and more'], + ['kv.vatA.monotonic.8', 'more and more'], + // ['kv.vatA.monotonic.9', 'more and more'], + ['kv.vatA.vval', 'stuff in 16'], + [ + 'transcript.vatA.current', + // '{"vatID":"vatA","startPos":0,"endPos":16,"hash":"83e7ed8d3ee339a8b0989512973396d3e9db4b4c3d76570862d99e3cdebaf8c6","isCurrent":1}', + '{"vatID":"vatA","startPos":0,"endPos":11,"hash":"ff988824e0fb02bfd0a5ecf466513fd4ef2ac6e488ab9070e640683faa8ddb11","isCurrent":1}', + ], + ]); +}); + +async function testExportImport( + t, + runMode, + exportMode, + importMode, + failureMode, + expectedArtifactNames, +) { + const exportLog = makeExportLog(); + const [dbDir, cleanup] = await tmpDir('testdb'); + t.teardown(cleanup); + + const keepTranscripts = runMode !== 'current'; + const keepSnapshots = runMode === 'debug'; + const ssOut = initSwingStore(dbDir, { + exportCallback: exportLog.callback, + keepSnapshots, + keepTranscripts, + }); + const { kernelStorage, debug } = ssOut; + + const vats = [ + { vatID: 'vatA', endPos: 0 }, + { vatID: 'vatB', endPos: 0 }, + ]; + for (const vat of vats) { + kernelStorage.transcriptStore.initTranscript(vat.vatID); + } + + // Run 4 "blocks", each consisting of 4 "cranks", across 2 "vats" + // Snapshot 'vatA' after the first and third blocks and 'vatB' after the second + // This will leave 2 current snapshots and 1 historical snapshot + let crankNum = 0; + for (let block = 0; block < 4; block += 1) { + for (let crank = 0; crank < 4; crank += 1) { + crankNum += 1; + const vat = vats[crankNum % vats.length]; + actLikeAVatRunningACrank(vat, kernelStorage, crankNum); + } + if (block < 3) { + // eslint-disable-next-line no-await-in-loop + await fakeAVatSnapshot(vats[block % 2], kernelStorage); + } + // eslint-disable-next-line no-await-in-loop + await ssOut.hostStorage.commit(); + } + + const exporter = makeSwingStoreExporter(dbDir, exportMode); + + const exportData = []; + for await (const elem of exporter.getExportData()) { + exportData.push(elem); + } + exportData.sort(compareElems); + + const feedData = []; + for (const elem of exportLog.entries()) { + feedData.push(elem); + } + feedData.sort(compareElems); + + t.deepEqual(exportData, feedData); + t.deepEqual(exportData, [ + ['kv.brigadoon', 'here during 16'], + ['kv.kval', 'set in 16'], + ['kv.vatA.monotonic.10', 'more and more'], + ['kv.vatA.monotonic.12', 'more and more'], + ['kv.vatA.monotonic.14', 'more and more'], + ['kv.vatA.monotonic.16', 'more and more'], + ['kv.vatA.monotonic.2', 'more and more'], + ['kv.vatA.monotonic.4', 'more and more'], + ['kv.vatA.monotonic.6', 'more and more'], + ['kv.vatA.monotonic.8', 'more and more'], + ['kv.vatA.vval', 'stuff in 16'], + ['kv.vatB.monotonic.1', 'more and more'], + ['kv.vatB.monotonic.11', 'more and more'], + ['kv.vatB.monotonic.13', 'more and more'], + ['kv.vatB.monotonic.15', 'more and more'], + ['kv.vatB.monotonic.3', 'more and more'], + ['kv.vatB.monotonic.5', 'more and more'], + ['kv.vatB.monotonic.7', 'more and more'], + ['kv.vatB.monotonic.9', 'more and more'], + ['kv.vatB.vval', 'stuff in 15'], + [ + 'snapshot.vatA.2', + '{"vatID":"vatA","endPos":2,"hash":"6c7e452ee3eaec849c93234d933af4300012e4ff161c328d3c088ec3deef76a6","inUse":0}', + ], + [ + 'snapshot.vatA.6', + '{"vatID":"vatA","endPos":6,"hash":"36afc9e2717c395759e308c4a877d491f967e9768d73520bde758ff4fac5d8b9","inUse":1}', + ], + ['snapshot.vatA.current', 'snapshot.vatA.6'], + [ + 'snapshot.vatB.4', + '{"vatID":"vatB","endPos":4,"hash":"afd477014db678fbc1aa58beab50f444deb653b8cc8e8583214a363fd12ed57a","inUse":1}', + ], + ['snapshot.vatB.current', 'snapshot.vatB.4'], + [ + 'transcript.vatA.0', + '{"vatID":"vatA","startPos":0,"endPos":2,"hash":"ea8ac1a751712ad66e4a9182adc65afe9bb0f4cd0ee0b828c895c63fbd2e3157","isCurrent":0}', + ], + [ + 'transcript.vatA.2', + '{"vatID":"vatA","startPos":2,"endPos":6,"hash":"88f299ca67b8acdf6023a83bb8e899af5adcf3271c7a1a2a495dcd6f1fbaac9f","isCurrent":0}', + ], + [ + 'transcript.vatA.current', + '{"vatID":"vatA","startPos":6,"endPos":8,"hash":"fe5d692b24a32d53bf617ba9ed3391b60c36a402c70a07a6aa984fc316e4efcc","isCurrent":1}', + ], + [ + 'transcript.vatB.0', + '{"vatID":"vatB","startPos":0,"endPos":4,"hash":"41dbf60cdec066106c7030517cb9f9f34a50fe2259705cf5fdbdd0b39ae12e46","isCurrent":0}', + ], + [ + 'transcript.vatB.current', + '{"vatID":"vatB","startPos":4,"endPos":8,"hash":"34fa09207bfb7af5fc3e65acb07f13b60834d0fbd2c6b9708f794c4397bd865d","isCurrent":1}', + ], + ]); + + const artifactNames = []; + for await (const name of exporter.getArtifactNames()) { + artifactNames.push(name); + } + t.deepEqual(artifactNames, expectedArtifactNames); + + const includeHistorical = importMode !== 'current'; + + const beforeDump = debug.dump(keepSnapshots); + let ssIn; + try { + ssIn = await importSwingStore(exporter, null, { + includeHistorical, + }); + } catch (e) { + if (failureMode === 'transcript') { + t.is(e.message, 'artifact "transcript.vatA.0.2" is not available'); + return; + } else if (failureMode === 'snapshot') { + t.is(e.message, 'artifact "snapshot.vatA.2" is not available'); + return; + } + throw e; + } + t.is(failureMode, 'none'); + await ssIn.hostStorage.commit(); + const dumpsShouldMatch = + runMode !== 'debug' || (exportMode === 'debug' && importMode !== 'current'); + if (dumpsShouldMatch) { + const afterDump = ssIn.debug.dump(keepSnapshots); + t.deepEqual(beforeDump, afterDump); + } + + exporter.close(); +} + +const expectedCurrentArtifacts = [ + 'snapshot.vatA.6', + 'snapshot.vatB.4', + 'transcript.vatA.6.8', + 'transcript.vatB.4.8', +]; + +const expectedArchivalArtifacts = [ + 'snapshot.vatA.6', + 'snapshot.vatB.4', + 'transcript.vatA.0.2', + 'transcript.vatA.2.6', + 'transcript.vatA.6.8', + 'transcript.vatB.0.4', + 'transcript.vatB.4.8', +]; + +const expectedDebugArtifacts = [ + 'snapshot.vatA.6', + 'snapshot.vatB.4', + 'snapshot.vatA.2', + 'transcript.vatA.0.2', + 'transcript.vatA.2.6', + 'transcript.vatA.6.8', + 'transcript.vatB.0.4', + 'transcript.vatB.4.8', +]; + +const C = 'current'; +const A = 'archival'; +const D = 'debug'; + +test('export and import data for state sync - current->current->current', async t => { + await testExportImport(t, C, C, C, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - current->current->archival', async t => { + await testExportImport(t, C, C, A, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - current->current->debug', async t => { + await testExportImport(t, C, C, D, 'none', expectedCurrentArtifacts); +}); + +test('export and import data for state sync - current->archival->current', async t => { + await testExportImport(t, C, A, C, 'none', expectedArchivalArtifacts); +}); +test('export and import data for state sync - current->archival->archival', async t => { + await testExportImport(t, C, A, A, 'transcript', expectedArchivalArtifacts); +}); +test('export and import data for state sync - current->archival->debug', async t => { + await testExportImport(t, C, A, D, 'transcript', expectedArchivalArtifacts); +}); + +test('export and import data for state sync - current->debug->current', async t => { + await testExportImport(t, C, D, C, 'none', expectedDebugArtifacts); +}); +test('export and import data for state sync - current->debug->archival', async t => { + await testExportImport(t, C, D, A, 'snapshot', expectedDebugArtifacts); +}); +test('export and import data for state sync - current->debug->debug', async t => { + await testExportImport(t, C, D, D, 'snapshot', expectedDebugArtifacts); +}); + +// ------------------------------------------------------------ + +test('export and import data for state sync - archival->current->current', async t => { + await testExportImport(t, A, C, C, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - archival->current->archival', async t => { + await testExportImport(t, A, C, A, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - archival->current->debug', async t => { + await testExportImport(t, A, C, D, 'none', expectedCurrentArtifacts); +}); + +test('export and import data for state sync - archival->archival->current', async t => { + await testExportImport(t, A, A, C, 'none', expectedArchivalArtifacts); +}); +test('export and import data for state sync - archival->archival->archival', async t => { + await testExportImport(t, A, A, A, 'none', expectedArchivalArtifacts); +}); +test('export and import data for state sync - archival->archival->debug', async t => { + await testExportImport(t, A, A, D, 'none', expectedArchivalArtifacts); +}); + +test('export and import data for state sync - archival->debug->current', async t => { + await testExportImport(t, A, D, C, 'none', expectedDebugArtifacts); +}); +test('export and import data for state sync - archival->debug->archival', async t => { + await testExportImport(t, A, D, A, 'snapshot', expectedDebugArtifacts); +}); +test('export and import data for state sync - archival->debug->debug', async t => { + await testExportImport(t, A, D, D, 'snapshot', expectedDebugArtifacts); +}); + +// ------------------------------------------------------------ + +test('export and import data for state sync - debug->current->current', async t => { + await testExportImport(t, D, C, C, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - debug->current->archival', async t => { + await testExportImport(t, D, C, A, 'none', expectedCurrentArtifacts); +}); +test('export and import data for state sync - debug->current->debug', async t => { + await testExportImport(t, D, C, D, 'none', expectedCurrentArtifacts); +}); + +test('export and import data for state sync - debug->archival->current', async t => { + await testExportImport(t, D, A, C, 'none', expectedArchivalArtifacts); +}); +test('export and import data for state sync - debug->archival->archival', async t => { + await testExportImport(t, D, A, A, 'none', expectedArchivalArtifacts); +}); +test('export and import data for state sync - debug->archival->debug', async t => { + await testExportImport(t, D, A, D, 'none', expectedArchivalArtifacts); +}); + +test('export and import data for state sync - debug->debug->current', async t => { + await testExportImport(t, D, D, C, 'none', expectedDebugArtifacts); +}); +test('export and import data for state sync - debug->debug->archival', async t => { + await testExportImport(t, D, D, A, 'none', expectedDebugArtifacts); +}); +test('export and import data for state sync - debug->debug->debug', async t => { + await testExportImport(t, D, D, D, 'none', expectedDebugArtifacts); +}); diff --git a/packages/swing-store/test/test-snapstore.js b/packages/swing-store/test/test-snapstore.js index 4dc0eae7c1b..2eb167c9cfa 100644 --- a/packages/swing-store/test/test-snapstore.js +++ b/packages/swing-store/test/test-snapstore.js @@ -13,16 +13,36 @@ import tmp from 'tmp'; import { makeMeasureSeconds } from '@agoric/internal'; import { makeSnapStore } from '../src/snapStore.js'; +function makeExportLog() { + const exportLog = []; + return { + noteExport(key, value) { + exportLog.push([key, value]); + }, + getLog() { + return exportLog; + }, + }; +} + +function ensureTxn() {} + test('build temp file; compress to cache file', async t => { const db = sqlite3(':memory:'); - const store = makeSnapStore(db, { - ...tmp, - tmpFile: tmp.file, - ...path, - ...fs, - ...fs.promises, - measureSeconds: makeMeasureSeconds(() => 0), - }); + const exportLog = makeExportLog(); + const store = makeSnapStore( + db, + ensureTxn, + { + ...tmp, + tmpFile: tmp.file, + ...path, + ...fs, + ...fs.promises, + measureSeconds: makeMeasureSeconds(() => 0), + }, + exportLog.noteExport, + ); let keepTmp = ''; const result = await store.saveSnapshot('fakeVatID', 47, async filePath => { t.falsy(fs.existsSync(filePath)); @@ -32,7 +52,7 @@ test('build temp file; compress to cache file', async t => { const { hash } = result; const expectedHash = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; - t.like(result, { + t.deepEqual(result, { hash: expectedHash, uncompressedSize: 3, compressedSize: 23, @@ -40,12 +60,18 @@ test('build temp file; compress to cache file', async t => { compressSeconds: 0, }); const snapshotInfo = store.getSnapshotInfo('fakeVatID'); - t.deepEqual(snapshotInfo, { + const dbInfo = { endPos: 47, hash: expectedHash, uncompressedSize: 3, compressedSize: 23, - }); + }; + const exportInfo = { + endPos: 47, + hash: expectedHash, + inUse: 1, + }; + t.deepEqual(snapshotInfo, dbInfo); t.is(store.hasHash('fakeVatID', hash), true); const zero = '0000000000000000000000000000000000000000000000000000000000000000'; @@ -66,6 +92,11 @@ test('build temp file; compress to cache file', async t => { t.truthy(snapshotGZ); const contents = zlib.gunzipSync(snapshotGZ); t.is(contents.toString(), 'abc', 'gunzip(contents) matches original'); + const logInfo = { vatID: 'fakeVatID', ...exportInfo }; + t.deepEqual(exportLog.getLog(), [ + ['snapshot.fakeVatID.47', JSON.stringify(logInfo)], + ['snapshot.fakeVatID.current', `snapshot.fakeVatID.47`], + ]); }); test('snapStore prepare / commit delete is robust', async t => { @@ -78,7 +109,9 @@ test('snapStore prepare / commit delete is robust', async t => { measureSeconds: makeMeasureSeconds(() => 0), }; const db = sqlite3(':memory:'); - const store = makeSnapStore(db, io, { keepSnapshots: true }); + const store = makeSnapStore(db, ensureTxn, io, () => {}, { + keepSnapshots: true, + }); const hashes = []; for (let i = 0; i < 5; i += 1) { diff --git a/packages/swing-store/test/test-state.js b/packages/swing-store/test/test-state.js index 7e2aef3e4bb..c2629428aff 100644 --- a/packages/swing-store/test/test-state.js +++ b/packages/swing-store/test/test-state.js @@ -41,13 +41,26 @@ function* iterate(kvStore, start, end) { } } +function makeExportLog() { + const exportLog = []; + return { + callback(updates) { + exportLog.push(updates); + }, + getLog() { + return exportLog; + }, + }; +} + function checkKVState(t, swingstore) { const kv = swingstore.debug.dump().kvEntries; t.deepEqual(kv, { foo: 'f', foo1: 'f1', foo3: 'f3' }); } -function testKVStore(t, storage) { - const { kvStore } = storage.kernelStorage; +function testKVStore(t, storage, exportLog) { + const { kvStore, startCrank, endCrank } = storage.kernelStorage; + startCrank(); t.falsy(kvStore.has('missing')); t.is(kvStore.get('missing'), undefined); @@ -69,18 +82,31 @@ function testKVStore(t, storage) { 'foo2', 'foo3', ]); + endCrank(); + startCrank(); kvStore.delete('foo2'); t.falsy(kvStore.has('foo2')); t.is(kvStore.get('foo2'), undefined); t.is(kvStore.getNextKey('foo1'), 'foo3'); t.is(kvStore.getNextKey('foo2'), 'foo3'); + endCrank(); checkKVState(t, storage); + t.deepEqual(exportLog.getLog(), [ + [ + ['kv.foo', 'f'], + ['kv.foo1', 'f1'], + ['kv.foo2', 'f2'], + ['kv.foo3', 'f3'], + ], + [['kv.foo2', null]], + ]); } test('in-memory kvStore read/write', t => { - const ss1 = initSwingStore(null); - testKVStore(t, ss1); + const exportLog = makeExportLog(); + const ss1 = initSwingStore(null, { exportCallback: exportLog.callback }); + testKVStore(t, ss1, exportLog); const serialized = ss1.debug.serialize(); const ss2 = initSwingStore(null, { serialized }); checkKVState(t, ss2); @@ -88,10 +114,11 @@ test('in-memory kvStore read/write', t => { test('persistent kvStore read/write/re-open', async t => { const [dbDir, cleanup] = await tmpDir('testdb'); + const exportLog = makeExportLog(); t.teardown(cleanup); t.is(isSwingStore(dbDir), false); - const ss1 = initSwingStore(dbDir); - testKVStore(t, ss1); + const ss1 = initSwingStore(dbDir, { exportCallback: exportLog.callback }); + testKVStore(t, ss1, exportLog); await ss1.hostStorage.commit(); await ss1.hostStorage.close(); t.is(isSwingStore(dbDir), true); @@ -120,114 +147,96 @@ test('persistent kvStore maxKeySize write', async t => { await hostStorage.close(); }); -async function testStreamStore(t, dbDir) { - const { kernelStorage, hostStorage } = initSwingStore(dbDir); - const { streamStore } = kernelStorage; +async function testTranscriptStore(t, dbDir) { + const exportLog = makeExportLog(); + const { kernelStorage, hostStorage } = initSwingStore(dbDir, { + exportCallback: exportLog.callback, + keepTranscripts: true, // XXX need to vary + }); + const { transcriptStore } = kernelStorage; const { commit, close } = hostStorage; - const start = streamStore.STREAM_START; - let s1pos = start; - s1pos = streamStore.writeStreamItem('st1', 'first', s1pos); - s1pos = streamStore.writeStreamItem('st1', 'second', s1pos); - const s1posAlt = s1pos; - s1pos = streamStore.writeStreamItem('st1', 'third', s1pos); - let s2pos = streamStore.STREAM_START; - s2pos = streamStore.writeStreamItem('st2', 'oneth', s2pos); - s1pos = streamStore.writeStreamItem('st1', 'fourth', s1pos); - s2pos = streamStore.writeStreamItem('st2', 'twoth', s2pos); - const s2posAlt = s2pos; - s2pos = streamStore.writeStreamItem('st2', 'threeth', s2pos); - s2pos = streamStore.writeStreamItem('st2', 'fourst', s2pos); - streamStore.closeStream('st1'); - streamStore.closeStream('st2'); - const reader1 = streamStore.readStream('st1', start, s1pos); - t.deepEqual(Array.from(reader1), ['first', 'second', 'third', 'fourth']); - s2pos = streamStore.writeStreamItem('st2', 're3', s2posAlt); - streamStore.closeStream('st2'); - const reader2 = streamStore.readStream('st2', start, s2pos); - t.deepEqual(Array.from(reader2), ['oneth', 'twoth', 're3']); - - const reader1alt = streamStore.readStream('st1', s1posAlt, s1pos); + transcriptStore.initTranscript('st1'); + transcriptStore.initTranscript('st2'); + transcriptStore.addItem('st1', 'first'); + transcriptStore.addItem('st1', 'second'); + transcriptStore.rolloverSpan('st1'); + transcriptStore.addItem('st1', 'third'); + transcriptStore.addItem('st2', 'oneth'); + transcriptStore.addItem('st1', 'fourth'); + transcriptStore.addItem('st2', 'twoth'); + transcriptStore.addItem('st2', 'threeth'); + transcriptStore.addItem('st2', 'fourst'); + const reader1 = transcriptStore.readSpan('st1', 0); + t.deepEqual(Array.from(reader1), ['first', 'second']); + const reader2 = transcriptStore.readSpan('st2', 0); + t.deepEqual(Array.from(reader2), ['oneth', 'twoth', 'threeth', 'fourst']); + + t.throws(() => transcriptStore.readSpan('st2', 3), { + message: 'no transcript span for "st2" at 3', + }); + + const reader1alt = transcriptStore.readSpan('st1'); t.deepEqual(Array.from(reader1alt), ['third', 'fourth']); + const reader1alt2 = transcriptStore.readSpan('st1', 2); + t.deepEqual(Array.from(reader1alt2), ['third', 'fourth']); - const emptyPos = streamStore.writeStreamItem('empty', 'filler', start); - streamStore.closeStream('empty'); - const readerEmpty = streamStore.readStream('empty', emptyPos, emptyPos); + transcriptStore.initTranscript('empty'); + const readerEmpty = transcriptStore.readSpan('empty'); t.deepEqual(Array.from(readerEmpty), []); - const readerEmpty2 = streamStore.readStream('empty', start, start); - t.deepEqual(Array.from(readerEmpty2), []); - - await commit(); - await close(); -} - -test('in-memory streamStore read/write', async t => { - await testStreamStore(t, null); -}); -test('persistent streamStore read/write', async t => { - const [dbDir, cleanup] = await tmpDir('testdb'); - t.teardown(cleanup); - t.is(isSwingStore(dbDir), false); - await testStreamStore(t, dbDir); -}); - -async function testStreamStoreModeInterlock(t, dbDir) { - const { kernelStorage, hostStorage } = initSwingStore(dbDir); - const { streamStore } = kernelStorage; - const { commit, close } = hostStorage; - const start = streamStore.STREAM_START; - - const s1pos = streamStore.writeStreamItem('st1', 'first', start); - streamStore.closeStream('st1'); - - const reader = streamStore.readStream('st1', start, s1pos); - t.throws(() => streamStore.readStream('st1', start, s1pos), { - message: `can't read stream "st1" because it's already in use`, - }); - t.throws(() => streamStore.writeStreamItem('st1', 'second', s1pos), { - message: `can't write stream "st1" because it's already in use`, + t.throws(() => transcriptStore.readSpan('nonexistent'), { + message: 'no current transcript for "nonexistent"', }); - streamStore.closeStream('st1'); - t.throws(() => reader.next(), { - message: `can't read stream "st1", it's been closed`, - }); - - streamStore.closeStream('nonexistent'); await commit(); + t.deepEqual(exportLog.getLog(), [ + [ + [ + 'transcript.st1.0', + '{"vatID":"st1","startPos":0,"endPos":2,"hash":"d385c43882cfb5611d255e362a9a98626ba4e55dfc308fc346c144c696ae734e","isCurrent":0}', + ], + [ + 'transcript.st1.current', + '{"vatID":"st1","startPos":2,"endPos":4,"hash":"789342fab468506c624c713c46953992f53a7eaae390d634790d791636b96cab","isCurrent":1}', + ], + [ + 'transcript.st2.current', + '{"vatID":"st2","startPos":0,"endPos":4,"hash":"45de7ae9d2be34148f9cf3000052e5d1374932d663442fe9f39a342d221cebf1","isCurrent":1}', + ], + ], + ]); await close(); } -test('in-memory streamStore mode interlock', async t => { - await testStreamStoreModeInterlock(t, null); +test('in-memory transcriptStore read/write', async t => { + await testTranscriptStore(t, null); }); -test('persistent streamStore mode interlock', async t => { +test('persistent transcriptStore read/write', async t => { const [dbDir, cleanup] = await tmpDir('testdb'); t.teardown(cleanup); t.is(isSwingStore(dbDir), false); - await testStreamStoreModeInterlock(t, dbDir); + await testTranscriptStore(t, dbDir); }); -test('streamStore abort', async t => { +test('transcriptStore abort', async t => { const [dbDir, cleanup] = await tmpDir('testdb'); t.teardown(cleanup); const { kernelStorage, hostStorage } = initSwingStore(dbDir); - const { streamStore } = kernelStorage; + const { transcriptStore } = kernelStorage; const { commit, close } = hostStorage; - const start = streamStore.STREAM_START; - const s1pos = streamStore.writeStreamItem('st1', 'first', start); - streamStore.closeStream('st1'); + transcriptStore.initTranscript('st1'); + transcriptStore.addItem('st1', 'first'); await commit(); // really write 'first' - streamStore.writeStreamItem('st2', 'second', s1pos); - streamStore.closeStream('st1'); + transcriptStore.initTranscript('st2'); + transcriptStore.addItem('st2', 'second'); // abort is close without commit await close(); - const { streamStore: ss2 } = openSwingStore(dbDir).kernelStorage; - const reader = ss2.readStream('st1', start, s1pos); + const { transcriptStore: ss2 } = openSwingStore(dbDir).kernelStorage; + const reader = ss2.readSpan('st1', 0); t.deepEqual(Array.from(reader), ['first']); // and not 'second' }); diff --git a/packages/swingset-liveslots/src/liveslots.js b/packages/swingset-liveslots/src/liveslots.js index 02e3711a249..2699c8dee1e 100644 --- a/packages/swingset-liveslots/src/liveslots.js +++ b/packages/swingset-liveslots/src/liveslots.js @@ -551,7 +551,14 @@ function build( const knownResolutions = new WeakMap(); - // this is called with all outbound argument vrefs + /** + * Determines if a vref from a watched promise or outbound argument + * identifies a promise that should be exported, and if so then + * adds it to exportedVPIDs and sets up handlers. + * + * @param {any} vref + * @returns {boolean} whether the vref was added to exportedVPIDs + */ function maybeExportPromise(vref) { // we only care about new vpids if ( @@ -570,7 +577,9 @@ function build( // if (!knownResolutions.has(p)) { // TODO really? // eslint-disable-next-line no-use-before-define followForKernel(vpid, p); + return true; } + return false; } function exportPassByPresence() { @@ -657,18 +666,16 @@ function build( assertAcceptableSyscallCapdataSize, ); - const watchedPromiseManager = makeWatchedPromiseManager( + const watchedPromiseManager = makeWatchedPromiseManager({ syscall, vrm, vom, collectionManager, // eslint-disable-next-line no-use-before-define convertValToSlot, - unmeteredConvertSlotToVal, - // eslint-disable-next-line no-use-before-define - meterControl.unmetered(revivePromise), - unmeteredUnserialize, - ); + convertSlotToVal: unmeteredConvertSlotToVal, + maybeExportPromise, + }); function convertValToSlot(val) { // lsdebug(`serializeToSlot`, val, Object.isFrozen(val)); @@ -816,6 +823,7 @@ function build( registerValue(slot, p); return p; } + const unmeteredRevivePromise = meterControl.unmetered(revivePromise); function resolutionCollector() { const resolutions = []; @@ -1183,7 +1191,6 @@ function build( // upgrade, or if we acquire decider authority for a // previously-imported promise if (pRec) { - // TODO: insist that we do not have decider authority for promiseID meterControl.assertNotMetered(); const val = m.unserialize(data); if (rejected) { @@ -1424,7 +1431,7 @@ function build( Fail`buildRootObject() for vat ${forVatID} returned ${rootObject} with no interface`; // Need to load watched promises *after* buildRootObject() so that handler kindIDs // have a chance to be reassociated with their handlers. - watchedPromiseManager.loadWatchedPromiseTable(); + watchedPromiseManager.loadWatchedPromiseTable(unmeteredRevivePromise); const rootSlot = makeVatSlot('object', true, BigInt(0)); valToSlot.set(rootObject, rootSlot); @@ -1516,26 +1523,11 @@ function build( assert(!didStopVat); didStopVat = true; - // all vpids are either "imported" (kernel knows about it and - // kernel decides), "exported" (kernel knows about it but we - // decide), or neither (local, we decide, kernel is unaware). TODO - // this could be cheaper if we tracked all three states (make a - // Set for "neither") instead of doing enumeration and set math. - try { - // mark "imported" plus "neither" for rejection at next startup - const importedVPIDsSet = new Set(importedVPIDs.keys()); - watchedPromiseManager.prepareShutdownRejections( - importedVPIDsSet, - disconnectObjectCapData, - ); - // reject all "exported" vpids now - const deciderVPIDs = Array.from(exportedVPIDs.keys()).sort(); // eslint-disable-next-line @jessie.js/no-nested-await await releaseOldState({ m, disconnectObjectCapData, - deciderVPIDs, syscall, exportedRemotables, addToPossiblyDeadSet, diff --git a/packages/swingset-liveslots/src/stop-vat.js b/packages/swingset-liveslots/src/stop-vat.js index 488fe2af920..4a5ab226ff5 100644 --- a/packages/swingset-liveslots/src/stop-vat.js +++ b/packages/swingset-liveslots/src/stop-vat.js @@ -33,25 +33,6 @@ import { enumerateKeysWithPrefix } from './vatstore-iterators.js'; const rootSlot = makeVatSlot('object', true, 0n); -function rejectAllPromises({ deciderVPIDs, syscall, disconnectObjectCapData }) { - // Pretend that userspace rejected all non-durable promises. We - // basically do the same thing that `thenReject(p, vpid)(rejection)` - // would have done, but we skip ahead to the syscall.resolve - // part. The real `thenReject` also does pRec.reject(), which would - // give control to userspace (who might have re-imported the promise - // and attached a .then to it), and stopVat() must not allow - // userspace to gain agency. - - const rejections = deciderVPIDs.map(vpid => [ - vpid, - true, - disconnectObjectCapData, - ]); - if (rejections.length) { - syscall.resolve(rejections); - } -} - function identifyExportedRemotables( vrefSet, { exportedRemotables, valToSlot }, @@ -298,12 +279,6 @@ function deleteCollectionsWithDecref({ syscall, vrm }) { // END: the preceding functions aren't ready for use yet export async function releaseOldState(tools) { - // First, pretend that userspace has rejected all non-durable - // promises, so we'll resolve them into the kernel (and retire their - // IDs). - - rejectAllPromises(tools); - // The next step is to pretend that the kernel has dropped all // non-durable exports: both the in-RAM Remotables and the on-disk // virtual objects (but not the root object). This will trigger diff --git a/packages/swingset-liveslots/src/vpid-tracking.md b/packages/swingset-liveslots/src/vpid-tracking.md index 7c32976a186..208acc7a6cf 100644 --- a/packages/swingset-liveslots/src/vpid-tracking.md +++ b/packages/swingset-liveslots/src/vpid-tracking.md @@ -2,85 +2,90 @@ Kernels and vats communicate about promises by referring to their VPIDs: vat(-centric) promise IDs. These are strings like `p+12` and `p-23`. Like VOIDs (object IDs), the plus/minus sign indicates which side of the boundary allocated the number (`p+12` and `o+12` are allocated by the vat, `p-13` and `o-13` are allocated by the kernel). But where the object ID sign also indicates which side "owns" the object (i.e. where the behavior lives), the promise ID sign is generally irrelevant. -Instead, we care about which side holds the resolution authority. This is not indicated by the VPID sign, and in fact is not necessarily static. Liveslots does not currently have any mechanism to allow one promise to be forwarded to another, but if it acquires this some day, then the decider of a promise could shift from kernel to vat to kernel again before it finally gets resolved. And there *is* a sequence that allows a vat to receive a reference to a promise in method arguments first, then receive a message whose result promise uses that VPID second (`p1 = p2~.foo(); x~.bar(p1)`, then someone resolves `p2` to `x`). In this case, the decider is initially the kernel, then the authority is transferred to the vat later. +Instead, we care about which side holds the resolution authority for a promise (referred to as being its **decider**). This is not indicated by the VPID sign, and in fact is not necessarily static. Liveslots does not currently have any mechanism to allow one promise to be forwarded to another, but if it acquires this some day, then the decider of a promise could shift from kernel to vat to kernel again before it finally gets resolved. And there *are* sequences that allow a vat to receive a reference to a promise in method arguments before receiving a message whose result promise uses that same VPID (e.g., `p1 = p2~.foo(); x~.bar(p1)`, then someone resolves `p2` to `x`. Consider also tildot-free code like `{ promise: p1, resolve: resolveP1 } = makePromiseKit(); p2 = E(p1).push('queued'); await E(observer).push(p2); resolveP1(observer);` with an observer whose `push` returns a prompt response). In such cases, the decider is initially the kernel but that authority is transferred to a vat later. -Each `Promise` starts out in the "unresolved" state, then later transitions irrevocably to the "resolved" state. Liveslots frequently (but not always) pays attention to this transition by calling `.then`, to attach a callback. To handle resolution cycles, liveslots remembers the resolution of old promises in a `WeakMap` for as long as the `Promise` exists. Consequently, for liveslots' purposes, every `Promise` is either resolved (the callback has fired and liveslots remembers the resolution), or unresolved (liveslots has not yet seen a resolution). +Each Promise starts out in the "unresolved" state, then later transitions irrevocably to the "resolved" state. Liveslots frequently (but not always) pays attention to this transition by calling `then` to attach fulfillment/rejection settlement callbacks. To handle resolution cycles, liveslots remembers the resolution of old promises in a `WeakMap` for as long as the Promise exists. Consequently, for liveslots' purposes, every Promise is either resolved (a callback has fired and liveslots remembers the settlement), or unresolved (liveslots has not yet seen a resolution that settles it). There are roughly four ways that liveslots might become aware of a promise: -* serialization: a `Promise` instance is serialized, either for the arguments of an outbound `syscall.send` or `syscall.resolve`, the argument of `watchPromise()`, or to be stored into virtualized data (e.g. `bigMapStore.set(key, promise)`, or assignment to a property of a virtual object) -* creation for outbound result: liveslots allocates a VPID for the `.result` of an outbound `syscall.send`, and creates a new `Promise` instance to give back to userspace -* deserialization: the arguments of an inbound `dispatch.deliver` or `dispatch.notify` are deserialized, and a new `Promise` instance is created -* inbound result: the kernel-allocated `.result` VPID of an inbound `dispatch.deliver` is associated with the `Promise` we get back from `HandledPromise.applyMethod` +* serialization: a Promise instance is serialized, either for the arguments of an outbound `syscall.send` or `syscall.resolve`, the argument of `watchPromise()`, or to be stored into virtualized data (e.g. `bigMapStore.set(key, promise)`, or assignment to a property of a virtual object) +* creation for outbound result: liveslots allocates a VPID for the `result` of an outbound `syscall.send`, and creates a new Promise instance to give back to userspace +* deserialization: the arguments of an inbound `dispatch.deliver` or `dispatch.notify` are deserialized, and a new Promise instance is created +* inbound result: the kernel-allocated `result` VPID of an inbound `dispatch.deliver` is associated with the Promise we get back from `HandledPromise.applyMethod` -A `Promise` may be associated with a VPID even though the kernel does not know about it (i.e. the VPID is not in the c-list): this can occur when a Promise is merely stored into virtual data without also being sent to (or received from) the kernel. The kernel's knowledge is temporary: once the promise is resolved (either `syscall.resolve` or `dispatch.notify`), the VPID is retired from the vat's c-list. So a VPID might start out stored only in vdata, then get sent to the kernel, then get resolved, leaving it solely in vdata once more. +A Promise may be associated with a VPID even though the kernel does not know about it (i.e. the VPID is not in the kernel's c-list for that vat). This can occur when a Promise is stored into virtual data without also being sent to (or received from) the kernel, although note that every Promise associated with a durable promise watcher _is_ sent to the kernel so it can be rejected during vat upgrade. A Promise can also be resolved but still referenced in vdata and forgotten by the kernel (the kernel's knowledge is temporary; it retires VPIDs from c-lists upon `syscall.resolve` or `dispatch.notify` as appropriate). So a VPID might start out stored only in vdata, then get sent to the kernel, then get resolved, leaving it solely in vdata once more. -Each unresolved VPID has a decider: either the vat or the kernel. The VPID is resolved by the first of the following events: +Each unresolved VPID has a decider: either the kernel or a vat. It can remain unresolved for arbitrarily long, but becomes resolved by the first of the following events: -* if/when userspace resolves the corresponding `Promise` and liveslots learns of the resolution, then liveslots will perform a `syscall.resolve()` -* if/when the vat is upgraded, liveslots will perform a `syscall.resolve()` of all remaining VPIDs during the delivery of `dispatch.stopVat()` (after which all `Promise`s evaporate along with the rest of the JS heap) -* if/when the vat is terminated, the kernel internally rejects all remaining vat-decided KPIDs, without involving the vat -* otherwise, the VPID remains unresolved until the heat death of the universe +* if liveslots learns about local resolution of the corresponding Promise by userspace, then liveslots will perform a `syscall.resolve()` (prompting 'notify' deliveries to other subscribed vats) +* if liveslots learns about resolution by inbound notify, then liveslots will unregister it as necessary and inform userspace of the resolution +* if the vat is terminated, the kernel internally rejects all remaining vat-decided KPIDs without involving the vat +* if the vat is upgraded, each of those terminate-associated rejections is followed by a 'notify' delivery to the new incarnation Liveslots tracks promises in the following data structures: -* `slotToVal` / `valToSlot` : these manage *registration*, the mapping from VPID to `Promise` and vice versa. These also register objects (Presences, Remotables, and Representatives) to/from VOIDs, and device nodes. +* `slotToVal` / `valToSlot` : these manage *registration*, the mapping from VPID to Promise and vice versa. These also register objects (Presences, Remotables, and Representatives) to/from VOIDs, and device nodes. * to support GC of objects, `slotToVal.get(vref)` is a WeakRef, and `valToSlot` is a WeakMap * liveslots uses independent strong references to maintain object/promise lifetimes * `exportedVPIDs`: a `Map`: all Promises currently known to the kernel and decided by the vat * `importedVPIDs`: a `Map`: all Promises currently known to the kernel and decided by the kernel * `remotableRefCounts`: a `Map`: all Promises (and Remotables) referenced by virtual data -The vat-kernel c-list contains all VPIDs in `exportedVPIDs` and `importedVPIDs`. The vat is the decider for `exportedVPIDs`, while the kernel is the decider for `importedVPIDs`. For every VPID in `exportedVPIDs`, we've used `.then` on the `Promise` instance to arrange for a `syscall.resolve` when the promise resolves or rejects. For every VPID key of the `importedVPIDs` Map, the corresponding value is a `[resolve, reject]` "`pRec`", so one of them can be called during `dispatch.notify`. Every VPID in `slotToVal` is either in `exportedVPIDs`, `importedVPIDs`, or neither. +The kernel's c-list for a vat contains all VPIDs in `exportedVPIDs` and `importedVPIDs`. The vat is the decider for `exportedVPIDs`, while the kernel is the decider for `importedVPIDs`. For every VPID in `exportedVPIDs`, we've used `then` on the Promise instance to arrange for a `syscall.resolve` when it settles (becomes fulfilled or rejected). For every VPID key of the `importedVPIDs` Map, the corresponding value is a `[resolve, reject]` "**pRec**", so one of the functions can be called during `dispatch.notify`. Every VPID in `slotToVal` is either in `exportedVPIDs` but not `importedVPIDs`, `importedVPIDs` but not `exportedVPIDs`, or neither. -If a VPID in `importedVPIDs` is resolved (by the kernel, via `dispatch.notify`), the VPID is removed from `importedVPIDs`. If a VPID in `exportedVPIDs` is resolved (by the vat, i.e. liveslots observes the previously-added `.then` callback be executed), liveslots invokes `syscall.resolve` and removes the VPID from `exportedVPIDs`. The c-list will not contain VPIDs for any resolved promise. +If a VPID in `importedVPIDs` is resolved (by the kernel, via `dispatch.notify`), the VPID is removed from `importedVPIDs`. If a VPID in `exportedVPIDs` is resolved (by the vat, i.e. liveslots observes invocation of a previously-added settlement callback), liveslots invokes `syscall.resolve` and removes the VPID from `exportedVPIDs`. The c-list for a vat will not contain a VPID for any resolved promise. -The `slotToVal`/`valToSlot` registration must remain until 1: the kernel is no longer aware of the VPID, 2: the Promise is not present in any virtual data, and 3: the promise is not being watched by a `promiseWatcher`. If the registration were to be lost while one of those three conditions were still true, a replacement `Promise` might be created while the original was still around, causing confusion. +The `slotToVal`/`valToSlot` registration must remain until all of the following are true: + +* the kernel is no longer aware of the VPID +* the Promise is not present in any virtual data +* the promise is not being watched by a `promiseWatcher`. + +If the registration were to be lost while any of the above conditions were still true, a replacement Promise might be created while the original was still around, causing confusion. ## Maintaining Strong References -Remember that the `slotToVal` registration uses a WeakRef, so being registered there does not keep the `Promise` object alive. +Remember that the `slotToVal` registration uses a WeakRef, so being registered there does not keep the Promise object alive. -`exportedVPIDs` and `importedVPIDs` keep their `Promise` alive in their value. vdata keeps it alive through the key of `remotableRefCounts`. `promiseWatcher` uses an internal `ScalarBigMapStore` to keep the `Promise` alive. +`exportedVPIDs` and `importedVPIDs` keep their Promise alive in their value. vdata keeps it alive through the key of `remotableRefCounts`. `promiseWatcher` uses an internal `ScalarBigMapStore` to keep the Promise alive. ## Promise/VPID Management Algorithm -* When a `Promise` is first serialized (it appears in `convertValToSlot`), a VPID is assigned and the VPID/`Promise` mapping is registered in `valToSlot`/`slotToVal` +* When a Promise is first serialized (it appears in `convertValToSlot`), a VPID is assigned and the VPID/Promise mapping is registered in `valToSlot`/`slotToVal` * at this point, there is not yet a strong reference to the Promise * When a VPID appears in the serialized arguments of `syscall.send` or `syscall.resolve`: * if the VPID already exists in `exportedVPIDs` or `importedVPIDs`: do nothing - * else: use `followForKernel` to add to `exportedVPIDs` and use `.then()` to attach a `handle` callback -* When `followForKernel`'s `handle` callback is executed: + * else: use `followForKernel` to add the VPID to `exportedVPIDs` and attach `.then(onFulfill, onReject)` callbacks that will map fulfillment/rejection to `syscall.resolve()` +* When a `followForKernel` settlement callback is executed: * do `syscall.resolve()` * remove from `exportedVPIDs` - * check `remotableRefCounts`. If 0: unregister from `valToSlot`/`slotToVal` + * if `remotableRefCounts` reports 0 references: unregister from `valToSlot`/`slotToVal` * When the kernel delivers a `dispatch.notify`: - * retrieve the `resolve`/`reject` "`pRec`" from `importedVPIDs` + * retrieve the `[resolve, reject]` pRec from `importedVPIDs` * invoke the appropriate function with the deserialized argument - * check `remotableRefCounts`. If 0: unregister from `valToSlot`/`slotToVal` + * if `remotableRefCounts` reports 0 references: unregister from `valToSlot`/`slotToVal` * When the vdata refcount for a VPID drops to zero: * if the VPID still exists in `exportedVPIDs` or `importedVPIDs`: do nothing * else: unregister from `valToSlot`/`slotToVal` * When a new VPID is deserialized (it appears in `convertSlotToVal`), this must be the arguments of a delivery (not vdata) * use `makePipelinablePromise` to create a HandledPromise for the VPID - * add the Promise and it's `resolve`/`reject` pair to `importedVPIDs` - * register the Promise in `valToSlot`/`slotToVal'` + * add the Promise and its `resolve`/`reject` pair to `importedVPIDs` + * register the Promise in `valToSlot`/`slotToVal` * use `syscall.subscribe` to request a `dispatch.notify` delivery when the kernel resolves this promise -* When a VPID appears as the `.result` of an outbound `syscall.send`: +* When a VPID appears as the `result` of an outbound `syscall.send`: (_note overlap with the preceding_) * use `allocateVPID` to allocate a new VPID * use `makePipelinablePromise` to create a HandledPromise for the VPID - * add the Promise and it's `resolve`/`reject` pair to `importedVPIDs` - * register the Promise in `valToSlot`/`slotToVal'` + * add the Promise and its `resolve`/`reject` pair to `importedVPIDs` + * register the Promise in `valToSlot`/`slotToVal` * use `syscall.subscribe` to request a `dispatch.notify` delivery when the kernel resolves this promise -* When a VPID appears as the `.result` of an inbound `dispatch.deliver`: - * check to see if the VPID is present in `importedVPIDs`: - * if yes: extract `pRec`, use `pRec.resolve(res)` to forward the invocation result promise to the previously-imported promise, then remove the VPID from `importedVPIDs` - * if no: register `res` the invocation result promise under the VPID - * in either case, next we add the VPID to `exportedVPIDs` - * then we call `followForKernel` to attach a callback to the `res` promise +* When a VPID appears as the `result` of an inbound `dispatch.deliver`, the vat is responsible for deciding it: + * construct a promise `res` to capture the userspace-provided result + * if the VPID is present in `importedVPIDs`: retrieve the `[resolve, reject]` pRec and use `resolve(res)` to forward eventual settlement of `res` to settlement of the previously-imported promise, then remove the VPID from `importedVPIDs` + * else: register marshaller association between the VPID and `res` + * in either case, use `followForKernel` to add the VPID to `exportedVPIDs` and attach `.then(onFulfill, onReject)` callbacks that will map fulfillment/rejection to `syscall.resolve()` -If the serialization is for storage in virtual data, the act of storing the `VPID` will add the `Promise` to `remotableRefCounts`, which maintains a strong reference for as long as the VPID is held. When it is removed from virtual data (or the object/collection is deleted), the refcount will be decremented. When the refcount drops to zero, we perform the `exportedVPIDs`/`importedVPIDs` check and then maybe unregister the promise. +If the serialization is for storage in virtual data, the act of storing the VPID will add the Promise to `remotableRefCounts`, which maintains a strong reference for as long as the VPID is held. When it is removed from virtual data (or the object/collection is deleted), the refcount will be decremented. When the refcount drops to zero, we perform the `exportedVPIDs`/`importedVPIDs` check and then maybe unregister the promise. If the serialization is for the arguments of an outbound `syscall.send` or `syscall.resolve` (or `syscall.callNow`, or `syscall.exit`), the VPID will be added to `exportedVPIDs`. diff --git a/packages/swingset-liveslots/src/watchedPromises.js b/packages/swingset-liveslots/src/watchedPromises.js index 8a7e6cabbc7..2f3ffc1851a 100644 --- a/packages/swingset-liveslots/src/watchedPromises.js +++ b/packages/swingset-liveslots/src/watchedPromises.js @@ -7,27 +7,25 @@ import { E } from '@endo/eventual-send'; import { parseVatSlot } from './parseVatSlots.js'; /** - * - * @param {*} syscall - * @param {*} vrm - * @param {import('./virtualObjectManager.js').VirtualObjectManager} vom - * @param {*} cm - * @param {*} convertValToSlot - * @param {*} convertSlotToVal - * @param {*} [revivePromise] - * @param {*} [unserialize] + * @param {object} options + * @param {*} options.syscall + * @param {*} options.vrm + * @param {import('./virtualObjectManager.js').VirtualObjectManager} options.vom + * @param {*} options.collectionManager + * @param {import('@endo/marshal').ConvertValToSlot} options.convertValToSlot + * @param {import('@endo/marshal').ConvertSlotToVal} options.convertSlotToVal + * @param {(vref: any) => boolean} options.maybeExportPromise */ -export function makeWatchedPromiseManager( +export function makeWatchedPromiseManager({ syscall, vrm, vom, - cm, + collectionManager, convertValToSlot, convertSlotToVal, - revivePromise, - unserialize, -) { - const { makeScalarBigMapStore } = cm; + maybeExportPromise, +}) { + const { makeScalarBigMapStore } = collectionManager; const { defineDurableKind } = vom; // virtual Store (not durable) mapping vpid to Promise objects, to @@ -104,37 +102,17 @@ export function makeWatchedPromiseManager( ); } - function loadWatchedPromiseTable() { - const deadPromisesRaw = syscall.vatstoreGet('deadPromises'); - if (!deadPromisesRaw) { - return; - } - const disconnectObjectCapData = JSON.parse( - syscall.vatstoreGet('deadPromiseDO'), - ); - const disconnectObject = unserialize(disconnectObjectCapData); - syscall.vatstoreDelete('deadPromises'); - syscall.vatstoreDelete('deadPromiseDO'); - const deadPromises = new Set(deadPromisesRaw.split(',')); - - for (const [vpid, watches] of watchedPromiseTable.entries()) { - if (deadPromises.has(vpid)) { - watchedPromiseTable.delete(vpid); - for (const watch of watches) { - const [watcher, ...args] = watch; - void Promise.resolve().then(() => { - if (watcher.onRejected) { - watcher.onRejected(disconnectObject, ...args); - } else { - throw disconnectObject; - } - }); - } - } else { - const p = revivePromise(vpid); - promiseRegistrations.init(vpid, p); - pseudoThen(p, vpid); - } + /** + * Revives watched promises. + * + * @param {(vref: any) => Promise} revivePromise + * @returns {void} + */ + function loadWatchedPromiseTable(revivePromise) { + for (const vpid of watchedPromiseTable.keys()) { + const p = revivePromise(vpid); + promiseRegistrations.init(vpid, p); + pseudoThen(p, vpid); } } @@ -175,7 +153,6 @@ export function makeWatchedPromiseManager( // TODO: add vpid->p virtual table mapping, to keep registration alive // TODO: remove mapping upon resolution - // TODO: track watched but non-exported promises, add during prepareShutdownRejections // maybe check importedVPIDs here and add to table if !has void Promise.resolve().then(() => { const watcherVref = convertValToSlot(watcher); @@ -202,35 +179,22 @@ export function makeWatchedPromiseManager( watchedPromiseTable.set(vpid, harden([...watches, [watcher, ...args]])); } else { watchedPromiseTable.init(vpid, harden([[watcher, ...args]])); + + // Ensure that this vat's promises are rejected at termination. + if (maybeExportPromise(vpid)) { + syscall.subscribe(vpid); + } + promiseRegistrations.init(vpid, p); pseudoThen(p, vpid); } }); } - function prepareShutdownRejections( - importedVPIDsSet, - disconnectObjectCapData, - ) { - const deadPromises = []; - for (const vpid of watchedPromiseTable.keys()) { - if (!importedVPIDsSet.has(vpid)) { - deadPromises.push(vpid); // "exported" plus "neither" vpids - } - } - deadPromises.sort(); // just in case - syscall.vatstoreSet('deadPromises', deadPromises.join(',')); - syscall.vatstoreSet( - 'deadPromiseDO', - JSON.stringify(disconnectObjectCapData), - ); - } - return harden({ preparePromiseWatcherTables, loadWatchedPromiseTable, providePromiseWatcher, watchPromise, - prepareShutdownRejections, }); } diff --git a/packages/swingset-liveslots/test/liveslots-helpers.js b/packages/swingset-liveslots/test/liveslots-helpers.js index 40904d9c83a..626f10d4200 100644 --- a/packages/swingset-liveslots/test/liveslots-helpers.js +++ b/packages/swingset-liveslots/test/liveslots-helpers.js @@ -15,11 +15,13 @@ import { import { kser } from './kmarshal.js'; /** - * @param {boolean} [skipLogging = false] + * @param {object} [options] + * @param {boolean} [options.skipLogging = false] + * @param {Map} [options.kvStore = new Map()] */ -export function buildSyscall(skipLogging) { +export function buildSyscall(options = {}) { + const { skipLogging = false, kvStore: fakestore = new Map() } = options; const log = []; - const fakestore = new Map(); let sortedKeys; let priorKeyReturned; let priorKeyIndex; @@ -152,23 +154,38 @@ export async function makeDispatch( return { dispatch, testHooks }; } -function makeRPMaker() { - let idx = 0; +function makeRPMaker(nextNumber = 1) { + let idx = nextNumber - 1; return () => { idx += 1; return `p-${idx}`; }; } +/** + * @param {import('ava').ExecutionContext} t + * @param {Function} buildRootObject + * @param {string} vatName + * @param {object} [options] + * @param {boolean} [options.forceGC] + * @param {Map} [options.kvStore = new Map()] + * @param {number} [options.nextPromiseImportNumber] + * @param {boolean} [options.skipLogging = false] + */ export async function setupTestLiveslots( t, buildRootObject, vatName, - forceGC, - skipLogging, + options = {}, ) { - const { log, syscall, fakestore } = buildSyscall(skipLogging); - const nextRP = makeRPMaker(); + const { + forceGC, + kvStore = new Map(), + nextPromiseImportNumber, + skipLogging = false, + } = options; + const { log, syscall, fakestore } = buildSyscall({ skipLogging, kvStore }); + const nextRP = makeRPMaker(nextPromiseImportNumber); const { dispatch, testHooks } = await makeDispatch( syscall, buildRootObject, @@ -215,7 +232,7 @@ export async function setupTestLiveslots( for (const [vpid, rejected, value] of l.resolutions) { if (vpid === rp) { if (rejected) { - throw Error(`vpid ${vpid} rejected with ${value}`); + throw Error(`vpid ${rp} rejected with ${value}`); } else { return value; // resolved successfully } @@ -249,6 +266,7 @@ export async function setupTestLiveslots( return { v, + dispatch, dispatchMessage, dispatchMessageSuccessfully, dispatchDropExports, diff --git a/packages/swingset-liveslots/test/storeGC/test-lifecycle.js b/packages/swingset-liveslots/test/storeGC/test-lifecycle.js index e7de7cef5da..72691cbf75d 100644 --- a/packages/swingset-liveslots/test/storeGC/test-lifecycle.js +++ b/packages/swingset-liveslots/test/storeGC/test-lifecycle.js @@ -71,7 +71,7 @@ test.serial('store lifecycle 1', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); // lerv -> Lerv Create store @@ -100,7 +100,7 @@ test.serial('store lifecycle 2', async t => { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -176,7 +176,7 @@ test.serial('store lifecycle 3', async t => { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -213,7 +213,7 @@ test.serial('store lifecycle 3', async t => { // test 4: lerv -> Lerv -> LERv -> LeRv -> lerv test.serial('store lifecycle 4', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -245,7 +245,7 @@ test.serial('store lifecycle 5', async t => { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -279,7 +279,7 @@ test.serial('store lifecycle 5', async t => { // test 6: lerv -> Lerv -> LERv -> LeRv -> LeRV -> LeRv -> LeRV -> leRV -> lerv test.serial('store lifecycle 6', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -324,7 +324,7 @@ test.serial('store lifecycle 6', async t => { // test 7: lerv -> Lerv -> LERv -> lERv -> LERv -> lERv -> lerv test.serial('store lifecycle 7', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); @@ -363,7 +363,7 @@ test.serial('store lifecycle 7', async t => { // test 8: lerv -> Lerv -> LERv -> LERV -> LERv -> LERV -> lERV -> lERv -> lerv test.serial('store lifecycle 8', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); // lerv -> Lerv Create store await dispatchMessageSuccessfully('makeAndHold'); diff --git a/packages/swingset-liveslots/test/storeGC/test-refcount-management.js b/packages/swingset-liveslots/test/storeGC/test-refcount-management.js index 6bf4950ac8b..c9a3772aef6 100644 --- a/packages/swingset-liveslots/test/storeGC/test-refcount-management.js +++ b/packages/swingset-liveslots/test/storeGC/test-refcount-management.js @@ -28,7 +28,7 @@ test.serial('store refcount management 1', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -100,7 +100,7 @@ test.serial('store refcount management 2', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -136,7 +136,7 @@ test.serial('store refcount management 3', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -176,7 +176,7 @@ test.serial('presence refcount management 1', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -224,7 +224,7 @@ test.serial('presence refcount management 2', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -280,7 +280,7 @@ test.serial('remotable refcount management 1', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -316,7 +316,7 @@ test.serial('remotable refcount management 2', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; diff --git a/packages/swingset-liveslots/test/storeGC/test-scalar-store-kind.js b/packages/swingset-liveslots/test/storeGC/test-scalar-store-kind.js index a34025913e3..e6b427e15db 100644 --- a/packages/swingset-liveslots/test/storeGC/test-scalar-store-kind.js +++ b/packages/swingset-liveslots/test/storeGC/test-scalar-store-kind.js @@ -18,7 +18,7 @@ test.serial('assert known scalarMapStore ID', async t => { // registered. Check it explicity here. If this test fails, consider // updating `mapRef()` to use the new value. - const { testHooks } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { testHooks } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const id = testHooks.obtainStoreKindID('scalarMapStore'); t.is(id, 2); t.is(mapRef('INDEX'), 'o+v2/INDEX'); diff --git a/packages/swingset-liveslots/test/storeGC/test-weak-key.js b/packages/swingset-liveslots/test/storeGC/test-weak-key.js index a816a897954..c53dcd22785 100644 --- a/packages/swingset-liveslots/test/storeGC/test-weak-key.js +++ b/packages/swingset-liveslots/test/storeGC/test-weak-key.js @@ -23,7 +23,7 @@ test.serial('verify store weak key GC', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -108,7 +108,7 @@ test.serial('verify weakly held value GC', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; @@ -147,7 +147,7 @@ test.serial('verify weakly held value GC', async t => { // prettier-ignore test.serial('verify presence weak key GC', async t => { const { v, dispatchMessage, dispatchRetireImports, testHooks } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; const presenceRef = 'o-5'; // Presence5 diff --git a/packages/swingset-liveslots/test/test-baggage.js b/packages/swingset-liveslots/test/test-baggage.js index 61f44028fc7..2f3647e0912 100644 --- a/packages/swingset-liveslots/test/test-baggage.js +++ b/packages/swingset-liveslots/test/test-baggage.js @@ -22,7 +22,7 @@ test.serial('exercise baggage', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const { fakestore } = v; diff --git a/packages/swingset-liveslots/test/test-handled-promises.js b/packages/swingset-liveslots/test/test-handled-promises.js new file mode 100644 index 00000000000..db1aafbaed8 --- /dev/null +++ b/packages/swingset-liveslots/test/test-handled-promises.js @@ -0,0 +1,363 @@ +/* eslint-disable no-await-in-loop, @jessie.js/no-nested-await, no-shadow */ +import test from 'ava'; +import '@endo/init/debug.js'; + +import { Far } from '@endo/marshal'; +import { Fail } from '@agoric/assert'; +import { M, provideLazy as provide } from '@agoric/store'; +import { makePromiseKit } from '@endo/promise-kit'; +// Disabled to avoid circular dependencies. +// import { makeStoreUtils } from '@agoric/vat-data/src/vat-data-bindings.js'; +// import { makeExoUtils } from '@agoric/vat-data/src/exo-utils.js'; +import { kslot, kser } from './kmarshal.js'; +import { setupTestLiveslots } from './liveslots-helpers.js'; +import { makeResolve, makeReject } from './util.js'; + +// eslint-disable-next-line no-unused-vars +const compareEntriesByKey = ([ka], [kb]) => (ka < kb ? -1 : 1); + +// Paritally duplicates @agoric/vat-data to avoid circular dependencies. +const makeExoUtils = VatData => { + const { defineDurableKind, makeKindHandle, watchPromise } = VatData; + + const provideKindHandle = (baggage, kindName) => + provide(baggage, `${kindName}_kindHandle`, () => makeKindHandle(kindName)); + + const emptyRecord = harden({}); + const initEmpty = () => emptyRecord; + + const defineDurableExoClass = ( + kindHandle, + interfaceGuard, + init, + methods, + options, + ) => + defineDurableKind(kindHandle, init, methods, { + ...options, + thisfulMethods: true, + interfaceGuard, + }); + + const prepareExoClass = ( + baggage, + kindName, + interfaceGuard, + init, + methods, + options = undefined, + ) => + defineDurableExoClass( + provideKindHandle(baggage, kindName), + interfaceGuard, + init, + methods, + options, + ); + + const prepareExo = ( + baggage, + kindName, + interfaceGuard, + methods, + options = undefined, + ) => { + const makeSingleton = prepareExoClass( + baggage, + kindName, + interfaceGuard, + initEmpty, + methods, + options, + ); + return provide(baggage, `the_${kindName}`, () => makeSingleton()); + }; + + return { + defineDurableKind, + makeKindHandle, + watchPromise, + + provideKindHandle, + defineDurableExoClass, + prepareExoClass, + prepareExo, + }; +}; + +// cf. packages/SwingSet/test/vat-durable-promise-watcher.js +const buildPromiseWatcherRootObject = (vatPowers, _vatParameters, baggage) => { + const { VatData } = vatPowers; + const { watchPromise } = VatData; + const { prepareExo } = makeExoUtils(VatData); + // const { makeScalarBigMapStore } = makeStoreUtils(VatData); + const PromiseWatcherI = M.interface('ExtraArgPromiseWatcher', { + onFulfilled: M.call(M.any(), M.string()).returns(), + onRejected: M.call(M.any(), M.string()).returns(), + }); + const watcher = prepareExo( + baggage, + 'DurablePromiseIgnorer', + PromiseWatcherI, + { + onFulfilled(_value, _name) {}, + onRejected(_reason, _name) {}, + }, + ); + + const localPromises = new Map(); + + return Far('root', { + exportPromise: () => [Promise.resolve()], + createLocalPromise: (name, fulfillment, rejection) => { + !localPromises.has(name) || Fail`local promise already exists: ${name}`; + const { promise, resolve, reject } = makePromiseKit(); + if (fulfillment !== undefined) { + resolve(fulfillment); + } else if (rejection !== undefined) { + reject(rejection); + } + localPromises.set(name, promise); + return `created local promise: ${name}`; + }, + watchLocalPromise: name => { + localPromises.has(name) || Fail`local promise not found: ${name}`; + watchPromise(localPromises.get(name), watcher, name); + return `watched local promise: ${name}`; + }, + }); +}; +const kvStoreDataV1 = Object.entries({ + baggageID: 'o+d6/1', + idCounters: '{"exportID":11,"collectionID":5,"promiseID":9}', + kindIDID: '1', + storeKindIDTable: + '{"scalarMapStore":2,"scalarWeakMapStore":3,"scalarSetStore":4,"scalarWeakSetStore":5,"scalarDurableMapStore":6,"scalarDurableWeakMapStore":7,"scalarDurableSetStore":8,"scalarDurableWeakSetStore":9}', + 'vc.1.sDurablePromiseIgnorer_kindHandle': + '{"body":"#\\"$0.Alleged: kind\\"","slots":["o+d1/10"]}', + 'vc.1.sthe_DurablePromiseIgnorer': + '{"body":"#\\"$0.Alleged: DurablePromiseIgnorer\\"","slots":["o+d10/1"]}', + 'vc.1.|entryCount': '2', + 'vc.1.|label': 'baggage', + 'vc.1.|nextOrdinal': '1', + 'vc.1.|schemata': + '{"body":"#[{\\"#tag\\":\\"match:string\\",\\"payload\\":[]}]","slots":[]}', + // non-durable + // 'vc.2.sp+6': '{"body":"#\\"&0\\"","slots":["p+6"]}', + // 'vc.2.|entryCount': '1', + // 'vc.2.|label': 'promiseRegistrations', + // 'vc.2.|nextOrdinal': '1', + // 'vc.2.|schemata': '{"body":"#[{\\"#tag\\":\\"match:scalar\\",\\"payload\\":\\"#undefined\\"}]","slots":[]}', + 'vc.3.|entryCount': '0', + 'vc.3.|label': 'promiseWatcherByKind', + 'vc.3.|nextOrdinal': '1', + 'vc.3.|schemata': + '{"body":"#[{\\"#tag\\":\\"match:scalar\\",\\"payload\\":\\"#undefined\\"}]","slots":[]}', + 'vc.4.sp+6': + '{"body":"#[[\\"$0.Alleged: DurablePromiseIgnorer\\",\\"orphaned\\"]]","slots":["o+d10/1"]}', + 'vc.4.sp-8': + '{"body":"#[[\\"$0.Alleged: DurablePromiseIgnorer\\",\\"unresolved\\"]]","slots":["o+d10/1"]}', + 'vc.4.sp-9': + '{"body":"#[[\\"$0.Alleged: DurablePromiseIgnorer\\",\\"late-rejected\\"]]","slots":["o+d10/1"]}', + 'vc.4.|entryCount': '3', + 'vc.4.|label': 'watchedPromises', + 'vc.4.|nextOrdinal': '1', + 'vc.4.|schemata': + '{"body":"#[{\\"#tag\\":\\"match:and\\",\\"payload\\":[{\\"#tag\\":\\"match:scalar\\",\\"payload\\":\\"#undefined\\"},{\\"#tag\\":\\"match:string\\",\\"payload\\":[]}]}]","slots":[]}', + 'vom.dkind.10': + '{"kindID":"10","tag":"DurablePromiseIgnorer","nextInstanceID":2,"unfaceted":true}', + 'vom.o+d10/1': '{}', + 'vom.rc.o+d1/10': '1', + 'vom.rc.o+d10/1': '3', + 'vom.rc.o+d6/1': '1', + 'vom.rc.o+d6/3': '1', + 'vom.rc.o+d6/4': '1', + watchedPromiseTableID: 'o+d6/4', + watcherTableID: 'o+d6/3', +}); +const kvStoreDataV1VpidsToReject = ['p+6', 'p-9']; +const kvStoreDataV1KeysToDelete = ['vc.4.sp+6', 'vc.4.sp-9']; +const kvStoreDataV1VpidsToKeep = ['p-8']; +const kvStoreDataV1KeysToKeep = ['vc.4.sp-8']; + +test('past-incarnation watched promises', async t => { + const kvStore = new Map(); + let { v, dispatch, dispatchMessage } = await setupTestLiveslots( + t, + buildPromiseWatcherRootObject, + 'durable-promise-watcher', + { kvStore }, + ); + let vatLogs = v.log; + + // Anchor promise counters upon which the other assertions depend. + const firstPImport = 1; + // cf. src/liveslots.js:initialIDCounters + const firstPExport = 5; + let lastPImport = firstPImport - 1; + let lastPExport = firstPExport - 1; + const nextPImport = () => (lastPImport += 1); + const nextPExport = () => (lastPExport += 1); + // Ignore vatstore syscalls. + const getDispatchLogs = () => + vatLogs.splice(0).filter(m => !m.type.startsWith('vatstore')); + const settlementMessage = (vpid, rejected, value) => ({ + type: 'resolve', + resolutions: [[vpid, rejected, kser(value)]], + }); + const fulfillmentMessage = (vpid, value) => + settlementMessage(vpid, false, value); + const rejectionMessage = (vpid, value) => + settlementMessage(vpid, true, value); + const subscribeMessage = vpid => ({ + type: 'subscribe', + target: vpid, + }); + vatLogs.length = 0; + await dispatchMessage('exportPromise'); + t.deepEqual(getDispatchLogs(), [ + fulfillmentMessage(`p-${nextPImport()}`, [kslot(`p+${nextPExport()}`)]), + fulfillmentMessage(`p+${lastPExport}`, undefined), + ]); + + const S = 'settlement'; + await dispatchMessage('createLocalPromise', 'orphaned'); + t.deepEqual(getDispatchLogs(), [ + fulfillmentMessage(`p-${nextPImport()}`, 'created local promise: orphaned'), + ]); + await dispatchMessage('createLocalPromise', 'fulfilled', S); + t.deepEqual(getDispatchLogs(), [ + fulfillmentMessage( + `p-${nextPImport()}`, + 'created local promise: fulfilled', + ), + ]); + await dispatchMessage('createLocalPromise', 'rejected', undefined, S); + t.deepEqual(getDispatchLogs(), [ + fulfillmentMessage(`p-${nextPImport()}`, 'created local promise: rejected'), + ]); + t.deepEqual( + lastPImport - firstPImport + 1, + 4, + 'imported 4 promises (1 per dispatch)', + ); + t.deepEqual(lastPExport - firstPExport + 1, 1, 'exported 1 promise: first'); + + await dispatchMessage('watchLocalPromise', 'orphaned'); + t.deepEqual(getDispatchLogs(), [ + subscribeMessage(`p+${nextPExport()}`), + fulfillmentMessage(`p-${nextPImport()}`, 'watched local promise: orphaned'), + ]); + await dispatchMessage('watchLocalPromise', 'fulfilled'); + t.deepEqual(getDispatchLogs(), [ + subscribeMessage(`p+${nextPExport()}`), + fulfillmentMessage( + `p-${nextPImport()}`, + 'watched local promise: fulfilled', + ), + fulfillmentMessage(`p+${lastPExport}`, S), + ]); + await dispatchMessage('watchLocalPromise', 'rejected'); + t.deepEqual(getDispatchLogs(), [ + subscribeMessage(`p+${nextPExport()}`), + fulfillmentMessage(`p-${nextPImport()}`, 'watched local promise: rejected'), + rejectionMessage(`p+${lastPExport}`, S), + ]); + t.deepEqual( + lastPImport - firstPImport + 1, + 7, + 'imported 7 promises (1 per dispatch)', + ); + t.deepEqual( + lastPExport - firstPExport + 1, + 4, + 'exported 4 promises: first, orphaned, fulfilled, rejected', + ); + + // Simulate upgrade by starting from the non-empty kvStore. + // t.log(Object.fromEntries([...kvStore.entries()].sort(compareEntriesByKey))); + const clonedStore = new Map(kvStore); + ({ v, dispatch, dispatchMessage } = await setupTestLiveslots( + t, + buildPromiseWatcherRootObject, + 'durable-promise-watcher-v2', + { kvStore: clonedStore, nextPromiseImportNumber: lastPImport + 1 }, + )); + vatLogs = v.log; + + // Simulate kernel rejection of promises orphaned by termination/upgrade of their decider vat. + const expectedDeletions = [...clonedStore.entries()].filter(entry => + entry[1].includes('orphaned'), + ); + t.true(expectedDeletions.length >= 1); + await dispatch( + makeReject(`p+${firstPExport + 1}`, kser('tomorrow never came')), + ); + for (const [key, value] of expectedDeletions) { + t.false(clonedStore.has(key), `entry should be removed: ${key}: ${value}`); + } + + // Verify that the data is still in loadable condition. + const finalClonedStore = new Map(clonedStore); + ({ v, dispatch, dispatchMessage } = await setupTestLiveslots( + t, + buildPromiseWatcherRootObject, + 'durable-promise-watcher-final', + { kvStore: finalClonedStore, nextPromiseImportNumber: lastPImport + 1 }, + )); + vatLogs = v.log; + vatLogs.length = 0; + await dispatchMessage('createLocalPromise', 'final', S); + await dispatchMessage('watchLocalPromise', 'final'); + t.deepEqual(getDispatchLogs(), [ + fulfillmentMessage(`p-${nextPImport()}`, 'created local promise: final'), + subscribeMessage(`p+${nextPExport()}`), + fulfillmentMessage(`p-${nextPImport()}`, 'watched local promise: final'), + fulfillmentMessage(`p+${lastPExport}`, S), + ]); +}); + +test('past-incarnation watched promises from original-format kvStore', async t => { + const kvStore = new Map(kvStoreDataV1); + for (const key of [ + ...kvStoreDataV1KeysToDelete, + ...kvStoreDataV1KeysToKeep, + ]) { + t.true(kvStore.has(key), `key must be initially present: ${key}`); + } + + let { v, dispatch, dispatchMessage } = await setupTestLiveslots( + t, + buildPromiseWatcherRootObject, + 'durable-promise-watcher', + { kvStore, nextPromiseImportNumber: 100 }, + ); + let vatLogs = v.log; + for (const vpid of kvStoreDataV1VpidsToReject) { + await dispatch(makeReject(vpid, kser('tomorrow never came'))); + } + for (const key of kvStoreDataV1KeysToDelete) { + t.false(kvStore.has(key), `key should be removed: ${key}`); + } + for (const key of kvStoreDataV1KeysToKeep) { + t.true(kvStore.has(key), `key should remain: ${key}`); + } + + // Verify that the data is still in loadable condition. + const finalClonedStore = new Map(kvStore); + // eslint-disable-next-line no-unused-vars + ({ v, dispatch, dispatchMessage } = await setupTestLiveslots( + t, + buildPromiseWatcherRootObject, + 'durable-promise-watcher-final', + { kvStore: finalClonedStore, nextPromiseImportNumber: 200 }, + )); + vatLogs = v.log; + vatLogs.length = 0; + for (const vpid of kvStoreDataV1VpidsToKeep) { + await dispatch(makeResolve(vpid, kser('finally'))); + } + for (const key of kvStoreDataV1KeysToKeep) { + t.false(finalClonedStore.has(key), `key should be removed: ${key}`); + } +}); diff --git a/packages/swingset-liveslots/test/test-initial-vrefs.js b/packages/swingset-liveslots/test/test-initial-vrefs.js index 2b5371f4c7a..04ce20281a6 100644 --- a/packages/swingset-liveslots/test/test-initial-vrefs.js +++ b/packages/swingset-liveslots/test/test-initial-vrefs.js @@ -51,7 +51,9 @@ function buildRootObject(vatPowers, vatParameters, baggage) { } test('initial vatstore contents', async t => { - const { v } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v } = await setupTestLiveslots(t, buildRootObject, 'bob', { + forceGC: true, + }); const { fakestore } = v; const get = key => fakestore.get(key); @@ -100,7 +102,7 @@ test('vrefs', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); // const { fakestore, dumpFakestore } = v; const { fakestore } = v; diff --git a/packages/swingset-liveslots/test/virtual-objects/test-virtualObjectGC.js b/packages/swingset-liveslots/test/virtual-objects/test-virtualObjectGC.js index 49435cdbd6a..e07ef98bc8d 100644 --- a/packages/swingset-liveslots/test/virtual-objects/test-virtualObjectGC.js +++ b/packages/swingset-liveslots/test/virtual-objects/test-virtualObjectGC.js @@ -417,7 +417,7 @@ async function voLifeCycleTest1(t, isf) { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const vref = facetRef(isf, thingVref(isf, 2), '1'); @@ -452,7 +452,7 @@ async function voLifeCycleTest2(t, isf) { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -533,7 +533,7 @@ async function voLifeCycleTest3(t, isf) { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -577,7 +577,7 @@ test.serial('VO lifecycle 3 faceted', async t => { // test 4: lerv -> Lerv -> LERv -> LeRv -> lerv async function voLifeCycleTest4(t, isf) { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -614,7 +614,7 @@ async function voLifeCycleTest5(t, isf) { dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports, - } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -650,7 +650,7 @@ test.serial('VO lifecycle 5 faceted', async t => { // test 6: lerv -> Lerv -> LERv -> LeRv -> LeRV -> LeRv -> LeRV -> leRV -> lerv async function voLifeCycleTest6(t, isf) { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -699,7 +699,7 @@ test.serial('VO lifecycle 6 faceted', async t => { // test 7: lerv -> Lerv -> LERv -> lERv -> LERv -> lERv -> lerv async function voLifeCycleTest7(t, isf) { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -740,7 +740,7 @@ test.serial('VO lifecycle 7 faceted', async t => { // test 8: lerv -> Lerv -> LERv -> LERV -> LERv -> LERV -> lERV -> lERv -> lerv async function voLifeCycleTest8(t, isf) { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -792,7 +792,7 @@ test.serial('VO multifacet export 1', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const vref = facetRef(true, thingVref(true, 2), '1'); @@ -810,7 +810,7 @@ test.serial('VO multifacet export 1', async t => { // multifacet export test 2a: export A, drop A, retire A test.serial('VO multifacet export 2a', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(true, thingVref(true, 2), '0'); const thingA = kslot(vref, 'thing facetA'); @@ -838,7 +838,7 @@ test.serial('VO multifacet export 2a', async t => { // multifacet export test 2b: export B, drop B, retire B test.serial('VO multifacet export 2b', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(true, thingVref(true, 2), '1'); const thingB = kslot(vref, 'thing facetB'); @@ -865,7 +865,7 @@ test.serial('VO multifacet export 2b', async t => { // multifacet export test 3abba: export A, export B, drop B, drop A, retire test.serial('VO multifacet export 3abba', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vrefA = facetRef(true, thingVref(true, 2), '0'); const thingA = kslot(vrefA, 'thing facetA'); const vrefB = facetRef(true, thingVref(true, 2), '1'); @@ -903,7 +903,7 @@ test.serial('VO multifacet export 3abba', async t => { // multifacet export test 3abab: export A, export B, drop A, drop B, retire test.serial('VO multifacet export 3abab', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vrefA = facetRef(true, thingVref(true, 2), '0'); const thingA = kslot(vrefA, 'thing facetA'); const vrefB = facetRef(true, thingVref(true, 2), '1'); @@ -943,7 +943,7 @@ test.serial('VO multifacet markers only', async t => { t, buildRootObject, 'bob', - true, + { forceGC: true }, ); const vrefA = facetRef(true, `${markerBaseRef}/1`, '0'); const { baseRef } = parseVatSlot(vrefA); @@ -961,7 +961,7 @@ test.serial('VO multifacet markers only', async t => { // prettier-ignore async function voRefcountManagementTest1(t, isf) { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const { baseRef } = parseVatSlot(vref); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -1002,7 +1002,7 @@ test.serial('VO refcount management 1 faceted', async t => { // prettier-ignore async function voRefcountManagementTest2(t, isf) { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const { baseRef } = parseVatSlot(vref); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -1043,7 +1043,7 @@ test.serial('VO refcount management 2 faceted', async t => { // prettier-ignore async function voRefcountManagementTest3(t, isf) { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const { baseRef } = parseVatSlot(vref); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); @@ -1093,7 +1093,7 @@ test.serial('VO refcount management 3 faceted', async t => { // prettier-ignore test.serial('presence refcount management 1', async t => { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; const vref = 'o-5'; @@ -1132,7 +1132,7 @@ test.serial('presence refcount management 1', async t => { // prettier-ignore test.serial('presence refcount management 2', async t => { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; const vref = 'o-5'; @@ -1170,7 +1170,7 @@ test.serial('presence refcount management 2', async t => { // prettier-ignore test.serial('remotable refcount management 1', async t => { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; // holder Kind is the next-to-last created kind, which gets idCounters.exportID-2 @@ -1213,7 +1213,7 @@ test.serial('remotable refcount management 1', async t => { // prettier-ignore test.serial('remotable refcount management 2', async t => { - const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; const holderKindID = JSON.parse(fakestore.get(`idCounters`)).exportID - 2; @@ -1234,7 +1234,7 @@ test.serial('remotable refcount management 2', async t => { // prettier-ignore async function voWeakKeyGCTest(t, isf) { - const { v, dispatchMessageSuccessfully, testHooks } = await setupTestLiveslots(t, buildRootObject, 'bob', true); + const { v, dispatchMessageSuccessfully, testHooks } = await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = facetRef(isf, thingVref(isf, 2), '1'); const thing = kslot(vref, isf ? 'thing facetB' : 'thing'); const { baseRef } = parseVatSlot(vref); @@ -1265,7 +1265,7 @@ test.serial('verify VO weak key GC faceted', async t => { // prettier-ignore test.serial('verify presence weak key GC', async t => { const { v, dispatchMessageSuccessfully, dispatchRetireImports, testHooks } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const vref = 'o-5'; const presence = kslot(vref, 'thing'); // hold a Presence weakly by a VOAwareWeak(Map/Set), also by RAM @@ -1316,7 +1316,7 @@ test.serial('verify presence weak key GC', async t => { // prettier-ignore test.serial('VO holding non-VO', async t => { const { v, dispatchMessageSuccessfully, dispatchDropExports, dispatchRetireExports } = - await setupTestLiveslots(t, buildRootObject, 'bob', true); + await setupTestLiveslots(t, buildRootObject, 'bob', { forceGC: true }); const { fakestore } = v; // lerv -> Lerv Create non-VO diff --git a/packages/swingset-liveslots/tools/fakeVirtualSupport.js b/packages/swingset-liveslots/tools/fakeVirtualSupport.js index b8639f998d1..6db39bd5cfd 100644 --- a/packages/swingset-liveslots/tools/fakeVirtualSupport.js +++ b/packages/swingset-liveslots/tools/fakeVirtualSupport.js @@ -230,6 +230,8 @@ export function makeFakeLiveSlotsStuff(options = {}) { function assertAcceptableSyscallCapdataSize(_capdatas) {} + const maybeExportPromise = _vref => false; + return { syscall, allocateExportID, @@ -250,6 +252,7 @@ export function makeFakeLiveSlotsStuff(options = {}) { dumpStore, setVrm, assertAcceptableSyscallCapdataSize, + maybeExportPromise, }; } @@ -268,31 +271,42 @@ export function makeFakeVirtualReferenceManager( ); } -export function makeFakeWatchedPromiseManager(vrm, vom, cm, fakeStuff) { - return makeWatchedPromiseManager( - fakeStuff.syscall, +export function makeFakeWatchedPromiseManager( + vrm, + vom, + collectionManager, + fakeStuff, +) { + return makeWatchedPromiseManager({ + syscall: fakeStuff.syscall, vrm, vom, - cm, - fakeStuff.convertValToSlot, - fakeStuff.convertSlotToVal, - ); + collectionManager, + convertValToSlot: fakeStuff.convertValToSlot, + convertSlotToVal: fakeStuff.convertSlotToVal, + maybeExportPromise: fakeStuff.maybeExportPromise, + }); } /** * Configure virtual stuff with relaxed durability rules and fake liveslots * * @param {object} [options] + * @param {number} [options.cacheSize=3] * @param {boolean} [options.relaxDurabilityRules=true] - * @param {number} [options.cacheSize] */ export function makeFakeVirtualStuff(options = {}) { - const fakeStuff = makeFakeLiveSlotsStuff(options); - const { relaxDurabilityRules = true } = options; + const actualOptions = { + cacheSize: 3, + relaxDurabilityRules: true, + ...options, + }; + const { relaxDurabilityRules } = actualOptions; + const fakeStuff = makeFakeLiveSlotsStuff(actualOptions); const vrm = makeFakeVirtualReferenceManager(fakeStuff, relaxDurabilityRules); - const vom = makeFakeVirtualObjectManager(vrm, fakeStuff, options); + const vom = makeFakeVirtualObjectManager(vrm, fakeStuff, actualOptions); vom.initializeKindHandleKind(); fakeStuff.setVrm(vrm); - const cm = makeFakeCollectionManager(vrm, fakeStuff, options); + const cm = makeFakeCollectionManager(vrm, fakeStuff, actualOptions); const wpm = makeFakeWatchedPromiseManager(vrm, vom, cm, fakeStuff); return { fakeStuff, vrm, vom, cm, wpm }; } diff --git a/packages/swingset-liveslots/tools/vo-test-harness.js b/packages/swingset-liveslots/tools/vo-test-harness.js index cb13aee62e4..4885246d7d9 100644 --- a/packages/swingset-liveslots/tools/vo-test-harness.js +++ b/packages/swingset-liveslots/tools/vo-test-harness.js @@ -131,8 +131,7 @@ export async function runVOTest(t, prepare, makeTestObject, testTestObject) { t, buildRootObject, 'bob', - true, - true, + { forceGC: true, skipLogging: true }, ); await dispatchMessage('makeAndHold'); diff --git a/packages/swingset-runner/src/dumpstore.js b/packages/swingset-runner/src/dumpstore.js index 14ed012bdb5..74833e48ad7 100644 --- a/packages/swingset-runner/src/dumpstore.js +++ b/packages/swingset-runner/src/dumpstore.js @@ -3,7 +3,7 @@ import process from 'process'; /* eslint-disable no-use-before-define */ export function dumpStore(kernelStorage, outfile, rawMode, truncate = true) { - const streamStore = kernelStorage.streamStore; + const transcriptStore = kernelStorage.transcriptStore; let out; if (outfile) { out = fs.createWriteStream(outfile); @@ -178,11 +178,7 @@ export function dumpStore(kernelStorage, outfile, rawMode, truncate = true) { p(`// transcript of vat ${v} (${vn})`); if (endPos) { let idx = 1; - for (const item of streamStore.readStream( - `transcript-${v}`, - streamStore.STREAM_START, - endPos, - )) { + for (const item of transcriptStore.readTranscript(v, 0, endPos)) { pkvBig('transcript', `${v}.${idx}`, item, 500); idx += 1; } diff --git a/packages/vat-data/src/exo-utils.js b/packages/vat-data/src/exo-utils.js index e84cb504e48..cec5ca1473a 100644 --- a/packages/vat-data/src/exo-utils.js +++ b/packages/vat-data/src/exo-utils.js @@ -1,13 +1,6 @@ import { initEmpty } from '@agoric/store'; -import { provideKindHandle } from './kind-utils.js'; -import { - defineKind, - defineKindMulti, - defineDurableKind, - defineDurableKindMulti, - provide, -} from './vat-data-bindings.js'; +import { provide, VatData as globalVatData } from './vat-data-bindings.js'; /** @template L,R @typedef {import('@endo/eventual-send').RemotableBrand} RemotableBrand */ /** @template T @typedef {import('@endo/far').ERef} ERef */ @@ -17,217 +10,303 @@ import { /** @template T @typedef {import('./types.js').KindFacets} KindFacets */ /** @typedef {import('./types.js').DurableKindHandle} DurableKindHandle */ -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 /** - * @template {(...args: any) => any} I init state function - * @template T behavior - * @param {string} tag - * @param {any} interfaceGuard - * @param {I} init - * @param {T & ThisType<{ self: T, state: ReturnType }>} methods - * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + * Make a version of the argument function that takes a kind context but + * ignores it. + * + * @type {(fn: T) => import('./types.js').PlusContext} */ -export const defineVirtualExoClass = ( - tag, - interfaceGuard, - init, - methods, - options, -) => - // @ts-expect-error The use of `thisfulMethods` to change - // the appropriate static type is the whole point of this method. - defineKind(tag, init, methods, { - ...options, - thisfulMethods: true, - interfaceGuard, - }); -harden(defineVirtualExoClass); +export const ignoreContext = + fn => + (_context, ...args) => + fn(...args); +harden(ignoreContext); -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {(...args: any) => any} I init state function - * @template {Record>} T facets - * @param {string} tag - * @param {any} interfaceGuardKit - * @param {I} init - * @param {T & ThisType<{ facets: T, state: ReturnType }> } facets - * @param {DefineKindOptions<{ facets: T, state: ReturnType }>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} - */ -export const defineVirtualExoClassKit = ( - tag, - interfaceGuardKit, - init, - facets, - options, -) => - // @ts-expect-error The use of `thisfulMethods` to change - // the appropriate static type is the whole point of this method. - defineKindMulti(tag, init, facets, { - ...options, - thisfulMethods: true, - interfaceGuard: interfaceGuardKit, - }); -harden(defineVirtualExoClassKit); +// TODO: Find a good home for this function used by @agoric/vat-data and testing code +export const makeExoUtils = VatData => { + const { + defineKind, + defineKindMulti, + defineDurableKind, + defineDurableKindMulti, + makeKindHandle, + } = VatData; -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {(...args: any) => any} I init state function - * @template {Record} T methods - * @param {DurableKindHandle} kindHandle - * @param {any} interfaceGuard - * @param {I} init - * @param {T & ThisType<{ self: T, state: ReturnType }>} methods - * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} - */ -export const defineDurableExoClass = ( - kindHandle, - interfaceGuard, - init, - methods, - options, -) => - // @ts-expect-error The use of `thisfulMethods` to change - // the appropriate static type is the whole point of this method. - defineDurableKind(kindHandle, init, methods, { - ...options, - thisfulMethods: true, - interfaceGuard, - }); -harden(defineDurableExoClass); + /** + * @param {Baggage} baggage + * @param {string} kindName + * @returns {DurableKindHandle} + */ + const provideKindHandle = (baggage, kindName) => + provide(baggage, `${kindName}_kindHandle`, () => makeKindHandle(kindName)); + harden(provideKindHandle); -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {(...args: any) => any} I init state function - * @template {Record>} T facets - * @param {DurableKindHandle} kindHandle - * @param {any} interfaceGuardKit - * @param {I} init - * @param {T & ThisType<{ facets: T, state: ReturnType}> } facets - * @param {DefineKindOptions<{ facets: T, state: ReturnType}>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} - */ -export const defineDurableExoClassKit = ( - kindHandle, - interfaceGuardKit, - init, - facets, - options, -) => - // @ts-expect-error The use of `thisfulMethods` to change - // the appropriate static type is the whole point of this method. - defineDurableKindMulti(kindHandle, init, facets, { - ...options, - thisfulMethods: true, - interfaceGuard: interfaceGuardKit, - }); -harden(defineDurableExoClassKit); + /** + * @deprecated Use prepareExoClass instead + * @type {import('./types.js').PrepareKind} + */ + const prepareKind = ( + baggage, + kindName, + init, + behavior, + options = undefined, + ) => + defineDurableKind( + provideKindHandle(baggage, kindName), + init, + behavior, + options, + ); + harden(prepareKind); -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {(...args: any) => any} I init state function - * @template {Record} T methods - * @param {Baggage} baggage - * @param {string} kindName - * @param {any} interfaceGuard - * @param {I} init - * @param {T & ThisType<{ self: T, state: ReturnType }>} methods - * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} - */ -export const prepareExoClass = ( - baggage, - kindName, - interfaceGuard, - init, - methods, - options = undefined, -) => - defineDurableExoClass( - provideKindHandle(baggage, kindName), + /** + * @deprecated Use prepareExoClassKit instead + * @type {import('./types.js').PrepareKindMulti} + */ + const prepareKindMulti = ( + baggage, + kindName, + init, + behavior, + options = undefined, + ) => + defineDurableKindMulti( + provideKindHandle(baggage, kindName), + init, + behavior, + options, + ); + harden(prepareKindMulti); + + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template T behavior + * @param {string} tag + * @param {any} interfaceGuard + * @param {I} init + * @param {T & ThisType<{ self: T, state: ReturnType }>} methods + * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const defineVirtualExoClass = (tag, interfaceGuard, init, methods, options) => + defineKind(tag, init, methods, { + ...options, + thisfulMethods: true, + interfaceGuard, + }); + harden(defineVirtualExoClass); + + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template {Record>} T facets + * @param {string} tag + * @param {any} interfaceGuardKit + * @param {I} init + * @param {T & ThisType<{ facets: T, state: ReturnType }> } facets + * @param {DefineKindOptions<{ facets: T, state: ReturnType }>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const defineVirtualExoClassKit = ( + tag, + interfaceGuardKit, + init, + facets, + options, + ) => + defineKindMulti(tag, init, facets, { + ...options, + thisfulMethods: true, + interfaceGuard: interfaceGuardKit, + }); + harden(defineVirtualExoClassKit); + + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template {Record} T methods + * @param {DurableKindHandle} kindHandle + * @param {any} interfaceGuard + * @param {I} init + * @param {T & ThisType<{ self: T, state: ReturnType }>} methods + * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const defineDurableExoClass = ( + kindHandle, interfaceGuard, init, methods, options, - ); -harden(prepareExoClass); + ) => + defineDurableKind(kindHandle, init, methods, { + ...options, + thisfulMethods: true, + interfaceGuard, + }); + harden(defineDurableExoClass); -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {(...args: any) => any} I init state function - * @template {Record>} T facets - * @param {Baggage} baggage - * @param {string} kindName - * @param {any} interfaceGuardKit - * @param {I} init - * @param {T & ThisType<{ facets: T, state: ReturnType }> } facets - * @param {DefineKindOptions<{ facets: T, state: ReturnType }>} [options] - * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} - */ -export const prepareExoClassKit = ( - baggage, - kindName, - interfaceGuardKit, - init, - facets, - options = undefined, -) => - defineDurableExoClassKit( - provideKindHandle(baggage, kindName), + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template {Record>} T facets + * @param {DurableKindHandle} kindHandle + * @param {any} interfaceGuardKit + * @param {I} init + * @param {T & ThisType<{ facets: T, state: ReturnType}> } facets + * @param {DefineKindOptions<{ facets: T, state: ReturnType}>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const defineDurableExoClassKit = ( + kindHandle, interfaceGuardKit, init, facets, options, - ); -harden(prepareExoClassKit); + ) => + defineDurableKindMulti(kindHandle, init, facets, { + ...options, + thisfulMethods: true, + interfaceGuard: interfaceGuardKit, + }); + harden(defineDurableExoClassKit); -// TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 -/** - * @template {Record} M methods - * @param {Baggage} baggage - * @param {string} kindName - * @param {any} interfaceGuard - * @param {M} methods - * @param {DefineKindOptions<{ self: M }>} [options] - * @returns {M & RemotableBrand<{}, M>} - */ -export const prepareExo = ( - baggage, - kindName, - interfaceGuard, - methods, - options = undefined, -) => { - const makeSingleton = prepareExoClass( + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template {Record} T methods + * @param {Baggage} baggage + * @param {string} kindName + * @param {any} interfaceGuard + * @param {I} init + * @param {T & ThisType<{ self: T, state: ReturnType }>} methods + * @param {DefineKindOptions<{ self: T, state: ReturnType }>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const prepareExoClass = ( baggage, kindName, interfaceGuard, - initEmpty, + init, methods, - options, - ); + options = undefined, + ) => + defineDurableExoClass( + provideKindHandle(baggage, kindName), + interfaceGuard, + init, + methods, + options, + ); + harden(prepareExoClass); - // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error -- https://github.com/Agoric/agoric-sdk/issues/4620 - // @ts-ignore could be instantiated with an arbitrary type - return provide(baggage, `the_${kindName}`, () => makeSingleton()); + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {(...args: any) => any} I init state function + * @template {Record>} T facets + * @param {Baggage} baggage + * @param {string} kindName + * @param {any} interfaceGuardKit + * @param {I} init + * @param {T & ThisType<{ facets: T, state: ReturnType }> } facets + * @param {DefineKindOptions<{ facets: T, state: ReturnType }>} [options] + * @returns {(...args: Parameters) => (T & RemotableBrand<{}, T>)} + */ + const prepareExoClassKit = ( + baggage, + kindName, + interfaceGuardKit, + init, + facets, + options = undefined, + ) => + defineDurableExoClassKit( + provideKindHandle(baggage, kindName), + interfaceGuardKit, + init, + facets, + options, + ); + harden(prepareExoClassKit); + + // TODO interfaceGuard type https://github.com/Agoric/agoric-sdk/issues/6206 + /** + * @template {Record} M methods + * @param {Baggage} baggage + * @param {string} kindName + * @param {any} interfaceGuard + * @param {M} methods + * @param {DefineKindOptions<{ self: M }>} [options] + * @returns {M & RemotableBrand<{}, M>} + */ + const prepareExo = ( + baggage, + kindName, + interfaceGuard, + methods, + options = undefined, + ) => { + const makeSingleton = prepareExoClass( + baggage, + kindName, + interfaceGuard, + initEmpty, + methods, + options, + ); + + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error -- https://github.com/Agoric/agoric-sdk/issues/4620 + // @ts-ignore could be instantiated with an arbitrary type + return provide(baggage, `the_${kindName}`, () => makeSingleton()); + }; + harden(prepareExo); + + /** + * @template {Record} M methods + * @deprecated Use prepareExo instead. + * @param {Baggage} baggage + * @param {string} kindName + * @param {M} methods + * @param {DefineKindOptions<{ self: M }>} [options] + * @returns {M & RemotableBrand<{}, M>} + */ + const prepareSingleton = (baggage, kindName, methods, options = undefined) => + prepareExo(baggage, kindName, undefined, methods, options); + harden(prepareSingleton); + + return harden({ + defineVirtualExoClass, + defineVirtualExoClassKit, + defineDurableExoClass, + defineDurableExoClassKit, + prepareExoClass, + prepareExoClassKit, + prepareExo, + prepareSingleton, + + provideKindHandle, + prepareKind, + prepareKindMulti, + }); }; -harden(prepareExo); + +const globalExoUtils = makeExoUtils(globalVatData); + +export const { + defineVirtualExoClass, + defineVirtualExoClassKit, + defineDurableExoClass, + defineDurableExoClassKit, + prepareExoClass, + prepareExoClassKit, + prepareExo, + prepareSingleton, +} = globalExoUtils; /** - * @template {Record} M methods - * @deprecated Use prepareExo instead. - * @param {Baggage} baggage - * @param {string} kindName - * @param {M} methods - * @param {DefineKindOptions<{ self: M }>} [options] - * @returns {M & RemotableBrand<{}, M>} + * @deprecated Use Exos/ExoClasses instead of Kinds */ -export const prepareSingleton = ( - baggage, - kindName, - methods, - options = undefined, -) => prepareExo(baggage, kindName, undefined, methods, options); -harden(prepareSingleton); +export const { provideKindHandle, prepareKind, prepareKindMulti } = + globalExoUtils; diff --git a/packages/vat-data/src/index.js b/packages/vat-data/src/index.js index 4bff2886698..cb0e4076ed7 100644 --- a/packages/vat-data/src/index.js +++ b/packages/vat-data/src/index.js @@ -35,7 +35,7 @@ export { prepareExoClass, prepareExoClassKit, prepareExo, - // deorecated + // deprecated prepareSingleton, } from './exo-utils.js'; @@ -45,10 +45,12 @@ export { // //////////////////////////// deprecated ///////////////////////////////////// +/** + * @deprecated Use Exos/ExoClasses instead of Kinds + */ export { - // deprecated ignoreContext, provideKindHandle, prepareKind, prepareKindMulti, -} from './kind-utils.js'; +} from './exo-utils.js'; diff --git a/packages/vat-data/src/kind-utils.js b/packages/vat-data/src/kind-utils.js deleted file mode 100644 index 1084f4dfdf0..00000000000 --- a/packages/vat-data/src/kind-utils.js +++ /dev/null @@ -1,68 +0,0 @@ -import { - provide, - defineDurableKind, - defineDurableKindMulti, - makeKindHandle, -} from './vat-data-bindings.js'; - -/** @typedef {import('./types.js').Baggage} Baggage */ -/** @typedef {import('./types.js').DurableKindHandle} DurableKindHandle */ - -/** - * Make a version of the argument function that takes a kind context but - * ignores it. - * - * @type {(fn: T) => import('./types.js').PlusContext} - */ -export const ignoreContext = - fn => - (_context, ...args) => - fn(...args); -harden(ignoreContext); - -/** - * @param {Baggage} baggage - * @param {string} kindName - * @returns {DurableKindHandle} - */ -export const provideKindHandle = (baggage, kindName) => - provide(baggage, `${kindName}_kindHandle`, () => makeKindHandle(kindName)); -harden(provideKindHandle); - -/** - * @deprecated Use prepareExoClass instead - * @type {import('./types.js').PrepareKind} - */ -export const prepareKind = ( - baggage, - kindName, - init, - behavior, - options = undefined, -) => - defineDurableKind( - provideKindHandle(baggage, kindName), - init, - behavior, - options, - ); -harden(prepareKind); - -/** - * @deprecated Use prepareExoClassKit instead - * @type {import('./types.js').PrepareKindMulti} - */ -export const prepareKindMulti = ( - baggage, - kindName, - init, - behavior, - options = undefined, -) => - defineDurableKindMulti( - provideKindHandle(baggage, kindName), - init, - behavior, - options, - ); -harden(prepareKindMulti); diff --git a/packages/vat-data/src/vat-data-bindings.js b/packages/vat-data/src/vat-data-bindings.js index a20c005dda0..8a7fd78bc41 100644 --- a/packages/vat-data/src/vat-data-bindings.js +++ b/packages/vat-data/src/vat-data-bindings.js @@ -29,8 +29,11 @@ if ('VatData' in globalThis) { }; } +const VatDataExport = VatDataGlobal; +export { VatDataExport as VatData }; + /** - * @deprecated Use Exos/ExoClasses instead of kinds + * @deprecated Use Exos/ExoClasses instead of Kinds */ export const { defineKind, @@ -140,35 +143,60 @@ harden(partialAssign); */ export const provide = provideLazy; -/** - * @param {import('./types').Baggage} baggage - * @param {string} name - * @param {Omit} options - */ -export const provideDurableMapStore = (baggage, name, options = {}) => - provide(baggage, name, () => - makeScalarBigMapStore(name, { durable: true, ...options }), - ); -harden(provideDurableMapStore); +// TODO: Find a good home for this function used by @agoric/vat-data and testing code +export const makeStoreUtils = VatData => { + const { + // eslint-disable-next-line no-shadow -- these literally do shadow the globals + makeScalarBigMapStore, + // eslint-disable-next-line no-shadow -- these literally do shadow the globals + makeScalarBigWeakMapStore, + // eslint-disable-next-line no-shadow -- these literally do shadow the globals + makeScalarBigSetStore, + } = VatData; -/** - * @param {import('./types').Baggage} baggage - * @param {string} name - * @param {Omit} options - */ -export const provideDurableWeakMapStore = (baggage, name, options = {}) => - provide(baggage, name, () => - makeScalarBigWeakMapStore(name, { durable: true, ...options }), - ); -harden(provideDurableWeakMapStore); + /** + * @param {import('./types').Baggage} baggage + * @param {string} name + * @param {Omit} options + */ + const provideDurableMapStore = (baggage, name, options = {}) => + provide(baggage, name, () => + makeScalarBigMapStore(name, { durable: true, ...options }), + ); + harden(provideDurableMapStore); -/** - * @param {import('./types').Baggage} baggage - * @param {string} name - * @param {Omit} options - */ -export const provideDurableSetStore = (baggage, name, options = {}) => - provide(baggage, name, () => - makeScalarBigSetStore(name, { durable: true, ...options }), - ); -harden(provideDurableSetStore); + /** + * @param {import('./types').Baggage} baggage + * @param {string} name + * @param {Omit} options + */ + const provideDurableWeakMapStore = (baggage, name, options = {}) => + provide(baggage, name, () => + makeScalarBigWeakMapStore(name, { durable: true, ...options }), + ); + harden(provideDurableWeakMapStore); + + /** + * @param {import('./types').Baggage} baggage + * @param {string} name + * @param {Omit} options + */ + const provideDurableSetStore = (baggage, name, options = {}) => + provide(baggage, name, () => + makeScalarBigSetStore(name, { durable: true, ...options }), + ); + harden(provideDurableSetStore); + + return harden({ + provideDurableMapStore, + provideDurableWeakMapStore, + provideDurableSetStore, + }); +}; + +const globalStoreUtils = makeStoreUtils(VatDataGlobal); +export const { + provideDurableMapStore, + provideDurableWeakMapStore, + provideDurableSetStore, +} = globalStoreUtils;