diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 6bc5eaf5d..5a9aeae09 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -22,8 +22,8 @@ import type { import { processGCActionSet } from './services/garbage-collection.ts'; import type { SlotValue } from './services/kernel-marshal.ts'; import { kser, kunser, krefOf, kslot } from './services/kernel-marshal.ts'; -import { makeKernelStore } from './store/kernel-store.ts'; -import type { KernelStore } from './store/kernel-store.ts'; +import { makeKernelStore } from './store/index.ts'; +import type { KernelStore } from './store/index.ts'; import { parseRef } from './store/utils/parse-ref.ts'; import { isPromiseRef } from './store/utils/promise-ref.ts'; import type { diff --git a/packages/kernel/src/VatHandle.test.ts b/packages/kernel/src/VatHandle.test.ts index 0b688d825..8b0aba66b 100644 --- a/packages/kernel/src/VatHandle.test.ts +++ b/packages/kernel/src/VatHandle.test.ts @@ -8,8 +8,8 @@ import type { MockInstance } from 'vitest'; import { Kernel } from './Kernel.ts'; import { isVatCommandReply, VatCommandMethod } from './messages/index.ts'; import type { VatCommand, VatCommandReply } from './messages/index.ts'; -import { makeKernelStore } from './store/kernel-store.ts'; -import type { KernelStore } from './store/kernel-store.ts'; +import { makeKernelStore } from './store/index.ts'; +import type { KernelStore } from './store/index.ts'; import { VatHandle } from './VatHandle.ts'; import { makeMapKernelDatabase } from '../test/storage.ts'; diff --git a/packages/kernel/src/VatHandle.ts b/packages/kernel/src/VatHandle.ts index 5f9d956bd..c5d6ea66f 100644 --- a/packages/kernel/src/VatHandle.ts +++ b/packages/kernel/src/VatHandle.ts @@ -19,7 +19,7 @@ import type { VatCommandReturnType, } from './messages/index.ts'; import { kser } from './services/kernel-marshal.ts'; -import type { KernelStore } from './store/kernel-store.ts'; +import type { KernelStore } from './store/index.ts'; import { parseRef } from './store/utils/parse-ref.ts'; import type { PromiseCallbacks, diff --git a/packages/kernel/src/services/garbage-collection.test.ts b/packages/kernel/src/services/garbage-collection.test.ts index 2f4a4de56..b739efe6f 100644 --- a/packages/kernel/src/services/garbage-collection.test.ts +++ b/packages/kernel/src/services/garbage-collection.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { processGCActionSet } from './garbage-collection.ts'; import { makeMapKernelDatabase } from '../../test/storage.ts'; -import { makeKernelStore } from '../store/kernel-store.ts'; +import { makeKernelStore } from '../store/index.ts'; import { RunQueueItemType } from '../types.ts'; describe('garbage-collection', () => { diff --git a/packages/kernel/src/services/garbage-collection.ts b/packages/kernel/src/services/garbage-collection.ts index be599967e..37c64eece 100644 --- a/packages/kernel/src/services/garbage-collection.ts +++ b/packages/kernel/src/services/garbage-collection.ts @@ -1,4 +1,4 @@ -import type { KernelStore } from '../store/kernel-store.ts'; +import type { KernelStore } from '../store/index.ts'; import { insistKernelType } from '../store/utils/kernel-slots.ts'; import type { GCAction, diff --git a/packages/kernel/src/store/kernel-store.test.ts b/packages/kernel/src/store/index.test.ts similarity index 98% rename from packages/kernel/src/store/kernel-store.test.ts rename to packages/kernel/src/store/index.test.ts index 277002c92..ba7587c1b 100644 --- a/packages/kernel/src/store/kernel-store.test.ts +++ b/packages/kernel/src/store/index.test.ts @@ -2,7 +2,7 @@ import type { Message } from '@agoric/swingset-liveslots'; import type { KernelDatabase } from '@ocap/store'; import { describe, it, expect, beforeEach } from 'vitest'; -import { makeKernelStore } from './kernel-store.ts'; +import { makeKernelStore } from './index.ts'; import { makeMapKernelDatabase } from '../../test/storage.ts'; import type { RunQueueItem } from '../types.ts'; @@ -77,6 +77,8 @@ describe('kernel store', () => { 'getGCActions', 'getKernelPromise', 'getKernelPromiseMessageQueue', + 'getNextObjectId', + 'getNextPromiseId', 'getNextRemoteId', 'getNextVatId', 'getObjectRefCount', @@ -97,6 +99,7 @@ describe('kernel store', () => { 'kv', 'makeVatStore', 'nextReapAction', + 'refCountKey', 'reset', 'resolveKernelPromise', 'runQueueLength', diff --git a/packages/kernel/src/store/index.ts b/packages/kernel/src/store/index.ts new file mode 100644 index 000000000..dea1e268d --- /dev/null +++ b/packages/kernel/src/store/index.ts @@ -0,0 +1,195 @@ +/* + * Organization of keys in the key value store: + * + * Definitions + * NN ::= some decimal integer + * CAPDATA ::= capdata encoded structure value + * JSON(${xx}) ::= JSON encoding of ${xx} + * + * ${koid} ::= ko${NN} // kernel object ID + * ${kpid} ::= kp${NN} // kernel promise ID + * ${kref} ::= ${koid} | ${kpid} // kernel reference + * ${dir} ::= + | - // direction (for remote and vat references) + * ${roid} ::= ro${dir}${NN} // remote object ID + * ${rpid} ::= rp${dir}${NN} // remote promise ID + * ${rref} ::= ${roid} | ${rpid} // remote reference + * ${void} ::= o${dir}${NN} // vat object ID + * ${vpid} ::= p${dir}${NN} // vat promise ID + * ${vref} ::= ${void} | ${vpid} // vat reference + * ${eref} ::= ${vref} | ${rref} // external reference + * ${vatid} ::= v${NN} // vat ID + * ${remid} ::= r${NN} // remote ID + * ${endid} ::= ${vatid} | ${remid} // endpoint ID + * ${queueName} ::= run | ${kpid} + * + * Queues + * queue.${queueName}.head = NN // queue head index + * queue.${queueName}.tail = NN // queue tail index + * queue.${queueName}.${NN} = JSON(CAPDATA) // queue entry #NN + * + * Kernel objects + * ${koid}.refCount = NN // reference count + * ${koid}.owner = ${vatid} // owner (where the object is) + * + * Kernel promises + * ${kpid}.refCount = NN // reference count + * ${kpid}.state = unresolved | fulfilled | rejected // current state of settlement + * ${kpid}.subscribers = JSON([${endid}]) // array of who is waiting for settlement + * ${kpid}.decider = ${endid} // who decides on settlement + * ${kpid}.value = JSON(CAPDATA) // value settled to, if settled + * + * C-lists + * cle.${endid}.${eref} = ${kref} // ERef->KRef mapping + * clk.${endid}.${kref} = ${eref} // KRef->ERef mapping + * + * Vat bookkeeping + * e.nextObjectId.${endid} = NN // allocation counter for imported object ERefs + * e.nextPromiseId.${endid} = NN // allocation counter for imported promise ERefs + * vatConfig.${vatid} = JSON(CONFIG) // vat's configuration object + * + * Kernel bookkeeping + * initialized = true // if set, indicates the store has been initialized + * nextVatId = NN // allocation counter for vat IDs + * nextRemoteId = NN // allocation counter for remote IDs + * k.nextObjectId = NN // allocation counter for object KRefs + * k.nextPromiseId = NN // allocation counter for promise KRefs + */ + +import type { KernelDatabase, KVStore, VatStore } from '@ocap/store'; + +import { getBaseMethods } from './methods/base.ts'; +import { getCListMethods } from './methods/clist.ts'; +import { getGCMethods } from './methods/gc.ts'; +import { getIdMethods } from './methods/id.ts'; +import { getObjectMethods } from './methods/object.ts'; +import { getPromiseMethods } from './methods/promise.ts'; +import { getQueueMethods } from './methods/queue.ts'; +import { getRefCountMethods } from './methods/refcount.ts'; +import { getVatMethods } from './methods/vat.ts'; +import type { StoreContext } from './types.ts'; +import type { KRef, VatId } from '../types.ts'; + +/** + * Create a new KernelStore object wrapped around a raw kernel database. The + * resulting object provides a variety of operations for accessing various + * kernel-relevent persistent data structure abstractions on their own terms, + * without burdening the kernel with the particular details of how they are + * represented in storage. It is our hope that these operations may be later + * reimplemented on top of a more sophisticated database layer that can realize + * them more directly (and thus, one hopes, more efficiently) without requiring + * the kernel itself to be any the wiser. + * + * @param kdb - The kernel database this store is based on. + * @returns A KernelStore object that maps various persistent kernel data + * structures onto `kdb`. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function makeKernelStore(kdb: KernelDatabase) { + // Initialize core state + + /** KV store in which all the kernel's own state is kept. */ + const kv: KVStore = kdb.kernelKVStore; + + const { provideCachedStoredValue, provideStoredQueue } = getBaseMethods(kv); + + const context: StoreContext = { + kv, + /** The kernel's run queue. */ + runQueue: provideStoredQueue('run', true), + /** Cache of the run queue's current length */ + runQueueLengthCache: -1, + /** Counter for allocating kernel object IDs */ + nextObjectId: provideCachedStoredValue('nextObjectId', '1'), + /** Counter for allocating kernel promise IDs */ + nextPromiseId: provideCachedStoredValue('nextPromiseId', '1'), + /** Counter for allocating VatIDs */ + nextVatId: provideCachedStoredValue('nextVatId', '1'), + /** Counter for allocating RemoteIDs */ + nextRemoteId: provideCachedStoredValue('nextRemoteId', '1'), + // As refcounts are decremented, we accumulate a set of krefs for which + // action might need to be taken: + // * promises which are now resolved and unreferenced can be deleted + // * objects which are no longer reachable: export can be dropped + // * objects which are no longer recognizable: export can be retired + // This set is ephemeral: it lives in RAM, grows as deliveries and syscalls + // cause decrefs, and will be harvested by processRefcounts(). This needs to be + // called in the same transaction window as the syscalls/etc which prompted + // the change, else removals might be lost (not performed during the next + // replay). + maybeFreeKrefs: new Set(), + // Garbage collection + gcActions: provideCachedStoredValue('gcActions', '[]'), + reapQueue: provideCachedStoredValue('reapQueue', '[]'), + }; + + const id = getIdMethods(context); + const refCount = getRefCountMethods(context); + const object = getObjectMethods(context); + const promise = getPromiseMethods(context); + const gc = getGCMethods(context); + const cList = getCListMethods(context); + const queue = getQueueMethods(context); + const vat = getVatMethods(context); + + /** + * Create a new VatStore for a vat. + * + * @param vatID - The vat for which this is being done. + * + * @returns a a VatStore object for the given vat. + */ + function makeVatStore(vatID: string): VatStore { + return kdb.makeVatStore(vatID); + } + + /** + * Delete all persistent state associated with a vat. + * + * @param vatId - The vat whose state is to be deleted. + */ + function deleteVat(vatId: VatId): void { + vat.deleteEndpoint(vatId); + vat.deleteVatConfig(vatId); + kdb.deleteVatStore(vatId); + } + + /** + * Reset the kernel's persistent state and reset all counters. + */ + function reset(): void { + kdb.clear(); + context.maybeFreeKrefs.clear(); + context.runQueue = provideStoredQueue('run', true); + context.gcActions = provideCachedStoredValue('gcActions', '[]'); + context.reapQueue = provideCachedStoredValue('reapQueue', '[]'); + context.nextObjectId = provideCachedStoredValue('nextObjectId', '1'); + context.nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); + context.nextVatId = provideCachedStoredValue('nextVatId', '1'); + context.nextRemoteId = provideCachedStoredValue('nextRemoteId', '1'); + } + + /** + * Delete everything from the database. + */ + function clear(): void { + kdb.clear(); + } + + return harden({ + ...id, + ...queue, + ...refCount, + ...object, + ...promise, + ...gc, + ...cList, + ...vat, + makeVatStore, + deleteVat, + clear, + reset, + kv, + }); +} + +export type KernelStore = ReturnType; diff --git a/packages/kernel/src/store/kernel-store.ts b/packages/kernel/src/store/kernel-store.ts deleted file mode 100644 index 5aed217dd..000000000 --- a/packages/kernel/src/store/kernel-store.ts +++ /dev/null @@ -1,1230 +0,0 @@ -/* - * Organization of keys in the key value store: - * - * Definitions - * NN ::= some decimal integer - * CAPDATA ::= capdata encoded structure value - * JSON(${xx}) ::= JSON encoding of ${xx} - * - * ${koid} ::= ko${NN} // kernel object ID - * ${kpid} ::= kp${NN} // kernel promise ID - * ${kref} ::= ${koid} | ${kpid} // kernel reference - * ${dir} ::= + | - // direction (for remote and vat references) - * ${roid} ::= ro${dir}${NN} // remote object ID - * ${rpid} ::= rp${dir}${NN} // remote promise ID - * ${rref} ::= ${roid} | ${rpid} // remote reference - * ${void} ::= o${dir}${NN} // vat object ID - * ${vpid} ::= p${dir}${NN} // vat promise ID - * ${vref} ::= ${void} | ${vpid} // vat reference - * ${eref} ::= ${vref} | ${rref} // external reference - * ${vatid} ::= v${NN} // vat ID - * ${remid} ::= r${NN} // remote ID - * ${endid} ::= ${vatid} | ${remid} // endpoint ID - * ${queueName} ::= run | ${kpid} - * - * Queues - * queue.${queueName}.head = NN // queue head index - * queue.${queueName}.tail = NN // queue tail index - * queue.${queueName}.${NN} = JSON(CAPDATA) // queue entry #NN - * - * Kernel objects - * ${koid}.refCount = NN // reference count - * ${koid}.owner = ${vatid} // owner (where the object is) - * - * Kernel promises - * ${kpid}.refCount = NN // reference count - * ${kpid}.state = unresolved | fulfilled | rejected // current state of settlement - * ${kpid}.subscribers = JSON([${endid}]) // array of who is waiting for settlement - * ${kpid}.decider = ${endid} // who decides on settlement - * ${kpid}.value = JSON(CAPDATA) // value settled to, if settled - * - * C-lists - * cle.${endid}.${eref} = ${kref} // ERef->KRef mapping - * clk.${endid}.${kref} = ${eref} // KRef->ERef mapping - * - * Vat bookkeeping - * e.nextObjectId.${endid} = NN // allocation counter for imported object ERefs - * e.nextPromiseId.${endid} = NN // allocation counter for imported promise ERefs - * vatConfig.${vatid} = JSON(CONFIG) // vat's configuration object - * - * Kernel bookkeeping - * initialized = true // if set, indicates the store has been initialized - * nextVatId = NN // allocation counter for vat IDs - * nextRemoteId = NN // allocation counter for remote IDs - * k.nextObjectId = NN // allocation counter for object KRefs - * k.nextPromiseId = NN // allocation counter for promise KRefs - */ - -import type { Message } from '@agoric/swingset-liveslots'; -import { Fail } from '@endo/errors'; -import type { CapData } from '@endo/marshal'; -import type { KVStore, VatStore, KernelDatabase } from '@ocap/store'; - -import { insistKernelType } from './utils/kernel-slots.ts'; -import { parseRef } from './utils/parse-ref.ts'; -import { - buildReachableAndVatSlot, - parseReachableAndVatSlot, -} from './utils/reachable.ts'; -import type { - VatId, - RemoteId, - EndpointId, - KRef, - ERef, - RunQueueItem, - RunQueueItemSend, - PromiseState, - KernelPromise, - RunQueueItemBringOutYourDead, - GCAction, - VatConfig, -} from '../types.ts'; -import { insistGCActionType, insistVatId, RunQueueItemType } from '../types.ts'; - -type StoredValue = { - get(): string | undefined; - set(newValue: string): void; - delete(): void; -}; - -type StoredQueue = { - enqueue(item: object): void; - dequeue(): object | undefined; - delete(): void; -}; - -type VatRecord = { - vatID: VatId; - vatConfig: VatConfig; -}; - -const VAT_CONFIG_BASE = 'vatConfig.'; -const VAT_CONFIG_BASE_LEN = VAT_CONFIG_BASE.length; - -/** - * Test if a KRef designates a promise. - * - * @param kref - The KRef to test. - * - * @returns true iff the given KRef references a promise. - */ -export function isPromiseRef(kref: KRef): boolean { - return kref[1] === 'p'; -} - -/** - * Create a new KernelStore object wrapped around a raw kernel database. The - * resulting object provides a variety of operations for accessing various - * kernel-relevent persistent data structure abstractions on their own terms, - * without burdening the kernel with the particular details of how they are - * represented in storage. It is our hope that these operations may be later - * reimplemented on top of a more sophisticated database layer that can realize - * them more directly (and thus, one hopes, more efficiently) without requiring - * the kernel itself to be any the wiser. - * - * @param kdb - The kernel database this store is based on. - * @returns A KernelStore object that maps various persistent kernel data - * structures onto `kdb`. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function makeKernelStore(kdb: KernelDatabase) { - // Initialize core state - - /** KV store in which all the kernel's own state is kept. */ - const kv: KVStore = kdb.kernelKVStore; - - /** The kernel's run queue. */ - let runQueue = provideStoredQueue('run', true); - /** Cache of the run queue's current length */ - let runQueueLengthCache = -1; - /** Counter for allocating VatIDs */ - let nextVatId = provideCachedStoredValue('nextVatId', '1'); - /** Counter for allocating RemoteIDs */ - let nextRemoteId = provideCachedStoredValue('nextRemoteId', '1'); - /** Counter for allocating kernel object IDs */ - let nextObjectId = provideCachedStoredValue('nextObjectId', '1'); - /** Counter for allocating kernel promise IDs */ - let nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); - - // As refcounts are decremented, we accumulate a set of krefs for which - // action might need to be taken: - // * promises which are now resolved and unreferenced can be deleted - // * objects which are no longer reachable: export can be dropped - // * objects which are no longer recognizable: export can be retired - // This set is ephemeral: it lives in RAM, grows as deliveries and syscalls - // cause decrefs, and will be harvested by processRefcounts(). This needs to be - // called in the same transaction window as the syscalls/etc which prompted - // the change, else removals might be lost (not performed during the next - // replay). - const maybeFreeKrefs = new Set(); - // Garbage collection - let gcActions = provideCachedStoredValue('gcActions', '[]'); - let reapQueue = provideCachedStoredValue('reapQueue', '[]'); - - /** - * Provide a stored value object for which we keep an in-memory cache. We only - * touch persistent storage if the value hasn't ever been read of if it is - * modified; otherwise we can service read requests from memory. - * - * IMPORTANT NOTE: in order for the cache to work, all subsequent accesses to - * the value MUST be made through a common stored value object. - * - * @param key - A key string that identifies the value. - * @param init - If provided, an initial setting if the stored entity does not exist. - * @returns An object for interacting with the value. - */ - function provideCachedStoredValue(key: string, init?: string): StoredValue { - let value: string | undefined = kv.get(key); - if (value === undefined && init !== undefined) { - kv.set(key, init); - value = init; - } - return harden({ - get(): string | undefined { - return value; - }, - set(newValue: string): void { - value = newValue; - kv.set(key, value); - }, - delete(): void { - value = undefined; - kv.delete(key); - }, - }); - } - - /** - * Provide a stored value object that is kept in persistent storage without caching. - * - * @param key - A key string that identifies the value. - * @param init - If provided, an initial setting if the stored entity does not exist. - * @returns An object for interacting with the value. - */ - function provideRawStoredValue(key: string, init?: string): StoredValue { - if (kv.get(key) === undefined && init !== undefined) { - kv.set(key, init); - } - return harden({ - get: () => kv.get(key), - set: (newValue: string) => kv.set(key, newValue), - delete: () => kv.delete(key), - }); - } - - /** - * Increment the value of a persistently stored counter. - * - * Note that the while the value is interpreted as an integer (in order to - * enable it to be incremented), it is stored and returned in the form of a - * string. This is because (a) our persistent storage only stores strings, and - * (b) the sole purpose of one of these counters is simply to provide an - * unending sequence of unique values; we don't actually use them as numbers - * or, indeed, even care at all if this sequence is produced using numbers. - * - * @param value - Reference to the stored value to be incremented. - * @returns The value as it was prior to being incremented. - */ - function incCounter(value: StoredValue): string { - const current = value.get(); - const next = `${Number(current) + 1}`; - value.set(next); - return current as string; - } - - /** - * Find out how long some queue is. - * - * @param queueName - The name of the queue of interest. - * - * @returns the number of items in the given queue. - */ - function getQueueLength(queueName: string): number { - const qk = `queue.${queueName}`; - const head = kv.get(`${qk}.head`); - const tail = kv.get(`${qk}.tail`); - if (head === undefined || tail === undefined) { - throw Error(`unknown queue ${queueName}`); - } - return Number(head) - Number(tail); - } - - /** - * Produce an object to access a persistently stored queue. - * - * @param queueName - The name for the queue (must be unique among queues). - * @param cached - Optional flag: set to true if the queue should cache its - * limit indices in memory (only do this if the queue is going to be accessed or - * checked frequently). - * @returns An object for interacting with the queue. - */ - function provideStoredQueue( - queueName: string, - cached: boolean = false, - ): StoredQueue { - const qk = `queue.${queueName}`; - // Note: cached=true ==> caches only the head & tail indices, NOT the queue entries themselves - const provideValue = cached - ? provideCachedStoredValue - : provideRawStoredValue; - const head = provideValue(`${qk}.head`, '1'); - const tail = provideValue(`${qk}.tail`, '1'); - if (head.get() === undefined || tail.get() === undefined) { - throw Error(`queue ${queueName} not initialized`); - } - return { - enqueue(item: object): void { - if (head.get() === undefined) { - throw Error(`enqueue into deleted queue ${queueName}`); - } - const entryPos = incCounter(head); - kv.set(`${qk}.${entryPos}`, JSON.stringify(item)); - }, - dequeue(): object | undefined { - const headPos = head.get(); - if (headPos === undefined) { - return undefined; - } - const tailPos = tail.get(); - if (tailPos !== headPos) { - const entry = kv.getRequired(`${qk}.${tailPos}`); - kv.delete(`${qk}.${tailPos}`); - incCounter(tail); - return JSON.parse(entry) as object; - } - return undefined; - }, - delete(): void { - const headPos = head.get(); - if (headPos !== undefined) { - let tailPos = tail.get(); - while (tailPos !== headPos) { - kv.delete(`${qk}.${tailPos}`); - tailPos = `${Number(tailPos) + 1}`; - } - head.delete(); - tail.delete(); - } - }, - }; - } - - /** - * Append a message to the kernel's run queue. - * - * @param message - The message to enqueue. - */ - function enqueueRun(message: RunQueueItem): void { - runQueueLengthCache += 1; - runQueue.enqueue(message); - } - - /** - * Fetch the next message on the kernel's run queue. - * - * @returns The next message on the run queue, or undefined if the queue is - * empty. - */ - function dequeueRun(): RunQueueItem | undefined { - runQueueLengthCache -= 1; - return runQueue.dequeue() as RunQueueItem | undefined; - } - - /** - * Obtain the number of entries in the run queue. - * - * @returns the number of items in the run queue. - */ - function runQueueLength(): number { - if (runQueueLengthCache < 0) { - runQueueLengthCache = getQueueLength('run'); - } - return runQueueLengthCache; - } - - /** - * Obtain an ID for a new vat. - * - * @returns The next VatID use. - */ - function getNextVatId(): VatId { - return `v${incCounter(nextVatId)}`; - } - - /** - * Obtain an ID for a new remote connection. - * - * @returns The next remote ID use. - */ - function getNextRemoteId(): RemoteId { - return `r${incCounter(nextRemoteId)}`; - } - - /** - * Initialize persistent state for a new endpoint. - * - * @param endpointId - The ID of the endpoint being added. - */ - function initEndpoint(endpointId: EndpointId): void { - kv.set(`e.nextPromiseId.${endpointId}`, '1'); - kv.set(`e.nextObjectId.${endpointId}`, '1'); - } - - /** - * Generate a new eref for a kernel object or promise being imported into an - * endpoint. - * - * @param endpointId - The endpoint the kref is being imported into. - * @param kref - The kref for the kernel object or promise in question. - * - * @returns A new eref in the scope of the given endpoint for the given kernel entity. - */ - function allocateErefForKref(endpointId: EndpointId, kref: KRef): ERef { - let id; - const refTag = endpointId.startsWith('v') ? '' : endpointId[0]; - let refType; - if (isPromiseRef(kref)) { - id = kv.get(`e.nextPromiseId.${endpointId}`); - kv.set(`e.nextPromiseId.${endpointId}`, `${Number(id) + 1}`); - refType = 'p'; - } else { - id = kv.get(`e.nextObjectId.${endpointId}`); - kv.set(`e.nextObjectId.${endpointId}`, `${Number(id) + 1}`); - refType = 'o'; - } - const eref = `${refTag}${refType}-${id}`; - addClistEntry(endpointId, kref, eref); - return eref; - } - - /** - * Obtain a KRef for the next unallocated kernel object. - * - * @returns The next koId use. - */ - function getNextObjectId(): KRef { - return `ko${incCounter(nextObjectId)}`; - } - - /** - * Generate the storage key for a kernel entity's reference count. - * - * @param kref - The KRef of interest. - * @returns the key to store the indicated reference count at. - */ - function refCountKey(kref: KRef): string { - return `${kref}.refCount`; - } - - /** - * Get a kernel entity's reference count. - * - * @param kref - The KRef of interest. - * @returns the reference count of the indicated kernel entity. - */ - function getRefCount(kref: KRef): number { - return Number(kv.get(refCountKey(kref))); - } - - /** - * Increment a kernel entity's reference count. - * - * @param kref - The KRef of the entity to increment the ref count of. - * @returns the new reference count after incrementing. - */ - function incRefCount(kref: KRef): number { - const key = refCountKey(kref); - const newCount = Number(kv.get(key)) + 1; - kv.set(key, `${newCount}`); - return newCount; - } - - /** - * Decrement a kernel entity's reference count. - * - * @param kref - The KRef of the entity to decrement the ref count of. - * @returns the new reference count after decrementing. - */ - function decRefCount(kref: KRef): number { - const key = refCountKey(kref); - const newCount = Number(kv.get(key)) - 1; - kv.set(key, `${newCount}`); - return newCount; - } - - /** - * Create a new kernel object. The new object will be born with reference and - * recognizability counts of 1, on the assumption that the new object - * corresponds to an object that has just been imported from somewhere. - * - * @param owner - The endpoint that is the owner of the new object. - * @returns The new object's KRef. - */ - function initKernelObject(owner: EndpointId): KRef { - const koId = getNextObjectId(); - kv.set(`${koId}.owner`, owner); - setObjectRefCount(koId, { reachable: 1, recognizable: 1 }); - return koId; - } - - /** - * Get a kernel object's owner. - * - * @param koId - The KRef of the kernel object of interest. - * @returns The identity of the vat or remote that owns the object. - */ - function getOwner(koId: KRef): EndpointId { - const owner = kv.get(`${koId}.owner`); - if (owner === undefined) { - throw Error(`unknown kernel object ${koId}`); - } - return owner; - } - - /** - * Expunge a kernel object from the kernel's persistent state. - * - * @param koId - The KRef of the kernel object to delete. - */ - function deleteKernelObject(koId: KRef): void { - kv.delete(`${koId}.owner`); - kv.delete(refCountKey(koId)); - } - - /** - * Obtain a KRef for the next unallocated kernel promise. - * - * @returns The next kpid use. - */ - function getNextPromiseId(): KRef { - return `kp${incCounter(nextPromiseId)}`; - } - - /** - * Create a new, unresolved kernel promise. The new promise will be born with - * a reference count of 1 on the assumption that the promise has just been - * imported from somewhere. - * - * @returns A tuple of the new promise's KRef and an object describing the - * new promise itself. - */ - function initKernelPromise(): [KRef, KernelPromise] { - const kpr: KernelPromise = { - state: 'unresolved', - subscribers: [], - }; - const kpid = getNextPromiseId(); - provideStoredQueue(kpid, false); - kv.set(`${kpid}.state`, 'unresolved'); - kv.set(`${kpid}.subscribers`, '[]'); - kv.set(refCountKey(kpid), '1'); - return [kpid, kpr]; - } - - /** - * Append a message to a promise's message queue. - * - * @param kpid - The KRef of the promise to enqueue on. - * @param message - The message to enqueue. - */ - function enqueuePromiseMessage(kpid: KRef, message: Message): void { - provideStoredQueue(kpid, false).enqueue(message); - } - - /** - * Add a new subscriber to a kernel promise's collection of subscribers. - * - * @param vatId - The vat that is subscribing. - * @param kpid - The KRef of the promise being subscribed to. - */ - function addPromiseSubscriber(vatId: VatId, kpid: KRef): void { - insistVatId(vatId); - const kp = getKernelPromise(kpid); - kp.state === 'unresolved' || - Fail`attempt to add subscriber to resolved promise ${kpid}`; - const tempSet = new Set(kp.subscribers); - tempSet.add(vatId); - const newSubscribers = Array.from(tempSet).sort(); - const key = `${kpid}.subscribers`; - kv.set(key, JSON.stringify(newSubscribers)); - } - - /** - * Assign a kernel promise's decider. - * - * @param kpid - The KRef of promise whose decider is being set. - * @param vatId - The vat which will become the decider. - */ - function setPromiseDecider(kpid: KRef, vatId: VatId): void { - insistVatId(vatId); - if (kpid) { - kv.set(`${kpid}.decider`, vatId); - } - } - - /** - * Fetch the descriptive record for a kernel promise. - * - * @param kpid - The KRef of the kernel promise of interest. - * @returns An object describing the requested kernel promise. - */ - function getKernelPromise(kpid: KRef): KernelPromise { - const { context, isPromise } = parseRef(kpid); - assert(context === 'kernel' && isPromise); - const state = kv.get(`${kpid}.state`) as PromiseState; - if (state === undefined) { - throw Error(`unknown kernel promise ${kpid}`); - } - const result: KernelPromise = { state }; - switch (state as string) { - case 'unresolved': { - const decider = kv.get(`${kpid}.decider`); - if (decider !== '' && decider !== undefined) { - result.decider = decider; - } - const subscribers = kv.getRequired(`${kpid}.subscribers`); - result.subscribers = JSON.parse(subscribers); - break; - } - case 'fulfilled': - case 'rejected': { - result.value = JSON.parse(kv.getRequired(`${kpid}.value`)); - break; - } - default: - throw Error(`unknown state for ${kpid}: ${state}`); - } - return result; - } - - /** - * Fetch the messages in a kernel promise's message queue. - * - * @param kpid - The KRef of the kernel promise of interest. - * @returns An array of all the messages in the given promise's message queue. - */ - function getKernelPromiseMessageQueue(kpid: KRef): Message[] { - const result: Message[] = []; - const queue = provideStoredQueue(kpid, false); - for (;;) { - const message = queue.dequeue() as Message; - if (message) { - result.push(message); - } else { - return result; - } - } - } - - /** - * Record the resolution of a kernel promise. - * - * @param kpid - The ref of the promise being resolved. - * @param rejected - True if the promise is being rejected, false if fulfilled. - * @param value - The value the promise is being fulfilled to or rejected with. - */ - function resolveKernelPromise( - kpid: KRef, - rejected: boolean, - value: CapData, - ): void { - const queue = provideStoredQueue(kpid, false); - for (const message of getKernelPromiseMessageQueue(kpid)) { - const messageItem: RunQueueItemSend = { - type: 'send', - target: kpid, - message, - }; - enqueueRun(messageItem); - } - kv.set(`${kpid}.state`, rejected ? 'rejected' : 'fulfilled'); - kv.set(`${kpid}.value`, JSON.stringify(value)); - kv.delete(`${kpid}.decider`); - kv.delete(`${kpid}.subscribers`); - queue.delete(); - } - - /** - * Expunge a kernel promise from the kernel's persistent state. - * - * @param kpid - The KRef of the kernel promise to delete. - */ - function deleteKernelPromise(kpid: KRef): void { - kv.delete(`${kpid}.state`); - kv.delete(`${kpid}.decider`); - kv.delete(`${kpid}.subscribers`); - kv.delete(`${kpid}.value`); - kv.delete(refCountKey(kpid)); - provideStoredQueue(kpid).delete(); - } - - /** - * Look up the ERef that and endpoint's c-list maps a KRef to. - * - * @param endpointId - The endpoint in question. - * @param eref - The ERef to look up. - * @returns The KRef corresponding to `eref` in the given endpoints c-list, or undefined - * if there is no such mapping. - */ - function erefToKref(endpointId: EndpointId, eref: ERef): KRef | undefined { - return kv.get(getSlotKey(endpointId, eref)); - } - - /** - * Look up the KRef that and endpoint's c-list maps an ERef to. - * - * @param endpointId - The endpoint in question. - * @param kref - The KRef to look up. - * @returns The given endpoint's ERef corresponding to `kref`, or undefined if - * there is no such mapping. - */ - function krefToEref(endpointId: EndpointId, kref: KRef): ERef | undefined { - const key = getSlotKey(endpointId, kref); - const data = kv.get(key); - if (!data) { - return undefined; - } - const { vatSlot } = parseReachableAndVatSlot(data); - return vatSlot; - } - - /** - * Add an entry to an endpoint's c-list, creating a new bidirectional mapping - * between an ERef belonging to the endpoint and a KRef belonging to the - * kernel. - * - * @param endpointId - The endpoint whose c-list is to be added to. - * @param kref - The KRef. - * @param eref - The ERef. - */ - function addClistEntry(endpointId: EndpointId, kref: KRef, eref: ERef): void { - kv.set(getSlotKey(endpointId, kref), buildReachableAndVatSlot(true, eref)); - kv.set(getSlotKey(endpointId, eref), kref); - } - - /** - * Remove an entry from an endpoint's c-list. - * - * @param endpointId - The endpoint whose c-list entry is to be removed. - * @param kref - The KRef. - * @param eref - The ERef. - */ - function deleteClistEntry( - endpointId: EndpointId, - kref: KRef, - eref: ERef, - ): void { - const kernelKey = getSlotKey(endpointId, kref); - const vatKey = getSlotKey(endpointId, eref); - assert(kv.get(kernelKey)); - clearReachableFlag(endpointId, kref); - const { direction } = parseRef(eref); - decrementRefCount(kref, { - isExport: direction === 'export', - onlyRecognizable: true, - }); - kv.delete(kernelKey); - kv.delete(vatKey); - } - - /** - * Remove an entry from an endpoint's c-list given an eref. - * - * @param endpointId - The endpoint whose c-list entry is to be removed. - * @param eref - The ERef. - */ - function forgetEref(endpointId: EndpointId, eref: ERef): void { - const kref = erefToKref(endpointId, eref); - if (kref) { - deleteClistEntry(endpointId, kref, eref); - } - } - - /** - * Remove an entry from an endpoint's c-list given a kref. - * - * @param endpointId - The endpoint whose c-list entry is to be removed. - * @param kref - The Kref. - */ - function forgetKref(endpointId: EndpointId, kref: KRef): void { - const eref = krefToEref(endpointId, kref); - if (eref) { - deleteClistEntry(endpointId, kref, eref); - } - } - - /** - * Generator that yields all the keys beginning with a given prefix. - * - * @param prefix - The prefix of interest. - * - * @yields the keys that start with `prefix`. - */ - function* getPrefixedKeys(prefix: string): Generator { - let key: string | undefined = prefix; - for (;;) { - key = kv.getNextKey(key); - if (!key) { - break; - } - if (!key.startsWith(prefix)) { - break; - } - yield key; - } - } - - /** - * Generator that yield the promises decided by a given vat. - * - * @param decider - The vat ID of the vat of interest. - * - * @yields the kpids of all the promises decided by `decider`. - */ - function* getPromisesByDecider(decider: VatId): Generator { - const basePrefix = `cle.${decider}.`; - for (const key of getPrefixedKeys(`${basePrefix}p`)) { - const kpid = kv.getRequired(key); - const kp = getKernelPromise(kpid); - if (kp.state === 'unresolved' && kp.decider === decider) { - yield kpid; - } - } - } - - /** - * Delete all persistent state associated with an endpoint. - * - * @param endpointId - The endpoint whose state is to be deleted. - */ - function deleteEndpoint(endpointId: EndpointId): void { - for (const key of getPrefixedKeys(`cle.${endpointId}.`)) { - kv.delete(key); - } - for (const key of getPrefixedKeys(`clk.${endpointId}.`)) { - kv.delete(key); - } - kv.delete(`e.nextObjectId.${endpointId}`); - kv.delete(`e.nextPromiseId.${endpointId}`); - } - - /** - * Delete all persistent state associated with a vat. - * - * @param vatId - The vat whose state is to be deleted. - */ - function deleteVat(vatId: VatId): void { - deleteEndpoint(vatId); - kv.delete(`${VAT_CONFIG_BASE}${vatId}`); - kdb.deleteVatStore(vatId); - } - - /** - * Generator that yields the configurations of running vats. - * - * @yields a series of vat records for all configured vats. - */ - function* getAllVatRecords(): Generator { - for (const vatKey of getPrefixedKeys(VAT_CONFIG_BASE)) { - const vatID = vatKey.slice(VAT_CONFIG_BASE_LEN); - const vatConfig = getVatConfig(vatID); - yield { vatID, vatConfig }; - } - } - - /** - * Fetch the stored configuration for a vat. - * - * @param vatID - The vat whose configuration is sought. - * - * @returns the configuration for the given vat. - */ - function getVatConfig(vatID: VatId): VatConfig { - return JSON.parse( - kv.getRequired(`${VAT_CONFIG_BASE}${vatID}`), - ) as VatConfig; - } - - /** - * Store the configuration for a vat. - * - * @param vatID - The vat whose configuration is to be set. - * @param vatConfig - The configuration to write. - */ - function setVatConfig(vatID: VatId, vatConfig: VatConfig): void { - kv.set(`${VAT_CONFIG_BASE}${vatID}`, JSON.stringify(vatConfig)); - } - - /** - * Delete the stored configuration for a vat. - * - * @param vatID - The vat whose configuration is to be deleted. - */ - function deleteVatConfig(vatID: VatId): void { - kv.delete(`${VAT_CONFIG_BASE}${vatID}`); - } - - /** - * Delete everything from the database. - */ - function clear(): void { - kdb.clear(); - } - - /** - * Create a new VatStore for a vat. - * - * @param vatID - The vat for which this is being done. - * - * @returns a a VatStore object for the given vat. - */ - function makeVatStore(vatID: string): VatStore { - return kdb.makeVatStore(vatID); - } - - /** - * Reset the kernel's persistent queues and counters. - */ - function reset(): void { - kdb.clear(); - runQueue = provideStoredQueue('run', true); - nextVatId = provideCachedStoredValue('nextVatId', '1'); - nextRemoteId = provideCachedStoredValue('nextRemoteId', '1'); - nextObjectId = provideCachedStoredValue('nextObjectId', '1'); - nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); - maybeFreeKrefs.clear(); - gcActions = provideCachedStoredValue('gcActions', '[]'); - reapQueue = provideCachedStoredValue('reapQueue', '[]'); - } - - /** - * Check if a kernel object exists in the kernel's persistent state. - * - * @param kref - The KRef of the kernel object in question. - * @returns True if the kernel object exists, false otherwise. - */ - function kernelRefExists(kref: KRef): boolean { - return Boolean(kv.get(refCountKey(kref))); - } - - /** - * Get the reference counts for a kernel object - * - * @param kref - The KRef of the object of interest. - * @returns The reference counts for the object. - */ - function getObjectRefCount(kref: KRef): { - reachable: number; - recognizable: number; - } { - const data = kv.get(refCountKey(kref)); - if (!data) { - return { reachable: 0, recognizable: 0 }; - } - const [reachable = 0, recognizable = 0] = data.split(',').map(Number); - reachable <= recognizable || - Fail`refMismatch(get) ${kref} ${reachable},${recognizable}`; - return { reachable, recognizable }; - } - - /** - * Set the reference counts for a kernel object - * - * @param kref - The KRef of the object of interest. - * @param counts - The reference counts to set. - * @param counts.reachable - The reachable reference count. - * @param counts.recognizable - The recognizable reference count. - */ - function setObjectRefCount( - kref: KRef, - counts: { reachable: number; recognizable: number }, - ): void { - const { reachable, recognizable } = counts; - assert.typeof(reachable, 'number'); - assert.typeof(recognizable, 'number'); - (reachable >= 0 && recognizable >= 0) || - Fail`${kref} underflow ${reachable},${recognizable}`; - reachable <= recognizable || - Fail`refMismatch(set) ${kref} ${reachable},${recognizable}`; - kv.set(refCountKey(kref), `${reachable},${recognizable}`); - } - - /** - * Get the set of GC actions to perform. - * - * @returns The set of GC actions to perform. - */ - function getGCActions(): Set { - return new Set(JSON.parse(gcActions.get() ?? '[]')); - } - - /** - * Set the set of GC actions to perform. - * - * @param actions - The set of GC actions to perform. - */ - function setGCActions(actions: Set): void { - const a = Array.from(actions); - a.sort(); - gcActions.set(JSON.stringify(a)); - } - - /** - * Add a new GC action to the set of GC actions to perform. - * - * @param newActions - The new GC action to add. - */ - function addGCActions(newActions: GCAction[]): void { - const actions = getGCActions(); - for (const action of newActions) { - assert.typeof(action, 'string', 'addGCActions given bad action'); - const [vatId, type, kref] = action.split(' '); - insistVatId(vatId); - insistGCActionType(type); - insistKernelType('object', kref); - actions.add(action); - } - setGCActions(actions); - } - - /** - * Check if a kernel object is reachable. - * - * @param endpointId - The endpoint for which the reachable flag is being checked. - * @param kref - The kref. - * @returns True if the kernel object is reachable, false otherwise. - */ - function getReachableFlag(endpointId: EndpointId, kref: KRef): boolean { - const key = getSlotKey(endpointId, kref); - const data = kv.getRequired(key); - const { isReachable } = parseReachableAndVatSlot(data); - return isReachable; - } - - /** - * Clear the reachable flag for a given endpoint and kref. - * - * @param endpointId - The endpoint for which the reachable flag is being cleared. - * @param kref - The kref. - */ - function clearReachableFlag(endpointId: EndpointId, kref: KRef): void { - const key = getSlotKey(endpointId, kref); - const { isReachable, vatSlot } = parseReachableAndVatSlot( - kv.getRequired(key), - ); - kv.set(key, buildReachableAndVatSlot(false, vatSlot)); - const { direction, isPromise } = parseRef(vatSlot); - // decrement 'reachable' part of refcount, but only for object imports - if ( - isReachable && - !isPromise && - direction === 'import' && - kernelRefExists(kref) - ) { - const counts = getObjectRefCount(kref); - counts.reachable -= 1; - setObjectRefCount(kref, counts); - if (counts.reachable === 0) { - maybeFreeKrefs.add(kref); - } - } - } - - /** - * Schedule a vat for reaping. - * - * @param vatId - The vat to schedule for reaping. - */ - function scheduleReap(vatId: VatId): void { - const queue = JSON.parse(reapQueue.get() ?? '[]'); - if (!queue.includes(vatId)) { - queue.push(vatId); - reapQueue.set(JSON.stringify(queue)); - } - } - - /** - * Get the next reap action. - * - * @returns The next reap action, or undefined if the queue is empty. - */ - function nextReapAction(): RunQueueItemBringOutYourDead | undefined { - const queue = JSON.parse(reapQueue.get() ?? '[]'); - if (queue.length > 0) { - const vatId = queue.shift(); - reapQueue.set(JSON.stringify(queue)); - return harden({ type: RunQueueItemType.bringOutYourDead, vatId }); - } - return undefined; - } - - /** - * Get the key for the reachable flag and vatSlot for a given endpoint and kref. - * - * @param endpointId - The endpoint for which the reachable flag is being set. - * @param kref - The kref. - * @returns The key for the reachable flag and vatSlot. - */ - function getSlotKey(endpointId: EndpointId, kref: KRef): string { - return `${endpointId}.c.${kref}`; - } - - /** - * Test if there's a c-list entry for some slot. - * - * @param endpointId - The endpoint of interest - * @param slot - The slot of interest - * @returns true iff this vat has a c-list entry mapping for `slot`. - */ - function hasCListEntry(endpointId: EndpointId, slot: string): boolean { - return kv.get(getSlotKey(endpointId, slot)) !== undefined; - } - - /** - * Increment the reference count associated with some kernel object. - * - * We track references to promises and objects, but not devices. Promises - * have only a "reachable" count, whereas objects track both "reachable" - * and "recognizable" counts. - * - * @param kref - The kernel slot whose refcount is to be incremented. - * @param options - Options for the increment. - * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. - * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. - */ - function incrementRefCount( - kref: KRef, - { - isExport = false, - onlyRecognizable = false, - }: { isExport?: boolean; onlyRecognizable?: boolean }, - ): void { - kref || Fail`incrementRefCount called with empty kref`; - - const { isPromise } = parseRef(kref); - if (isPromise) { - const refCount = Number(kv.get(refCountKey(kref))) + 1; - kv.set(refCountKey(kref), `${refCount}`); - return; - } - - // If `isExport` the reference comes from a clist export, which counts for promises but not objects - if (isExport) { - return; - } - - const counts = getObjectRefCount(kref); - if (!onlyRecognizable) { - counts.reachable += 1; - } - counts.recognizable += 1; - setObjectRefCount(kref, counts); - } - - /** - * Decrement the reference count associated with some kernel object. - * - * @param kref - The kernel slot whose refcount is to be decremented. - * @param options - Options for the decrement. - * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. - * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. - * @returns True if the reference count has been decremented to zero, false if it is still non-zero. - * @throws if this tries to decrement the reference count below zero. - */ - function decrementRefCount( - kref: KRef, - { - isExport = false, - onlyRecognizable = false, - }: { isExport?: boolean; onlyRecognizable?: boolean }, - ): boolean { - kref || Fail`decrementRefCount called with empty kref`; - - const { isPromise } = parseRef(kref); - if (isPromise) { - let refCount = Number(kv.get(refCountKey(kref))); - refCount > 0 || Fail`refCount underflow ${kref}`; - refCount -= 1; - kv.set(refCountKey(kref), `${refCount}`); - if (refCount === 0) { - maybeFreeKrefs.add(kref); - return true; - } - return false; - } - - if (isExport || !kernelRefExists(kref)) { - return false; - } - - const counts = getObjectRefCount(kref); - if (!onlyRecognizable) { - counts.reachable -= 1; - } - counts.recognizable -= 1; - if (!counts.reachable || !counts.recognizable) { - maybeFreeKrefs.add(kref); - } - setObjectRefCount(kref, counts); - kv.set('initialized', 'true'); - return false; - } - - return harden({ - enqueueRun, - dequeueRun, - runQueueLength, - getNextVatId, - getNextRemoteId, - initEndpoint, - getRefCount, - incRefCount, - decRefCount, - initKernelObject, - getOwner, - deleteKernelObject, - initKernelPromise, - getKernelPromise, - enqueuePromiseMessage, - setPromiseDecider, - getKernelPromiseMessageQueue, - resolveKernelPromise, - deleteKernelPromise, - addPromiseSubscriber, - erefToKref, - allocateErefForKref, - krefToEref, - addClistEntry, - forgetEref, - forgetKref, - getAllVatRecords, - getVatConfig, - setVatConfig, - deleteVatConfig, - getPromisesByDecider, - clear, - deleteEndpoint, - deleteVat, - makeVatStore, - reset, - kv, - kernelRefExists, - hasCListEntry, - getReachableFlag, - clearReachableFlag, - getObjectRefCount, - setObjectRefCount, - getGCActions, - setGCActions, - addGCActions, - nextReapAction, - scheduleReap, - incrementRefCount, - decrementRefCount, - deleteClistEntry, - getQueueLength, - }); -} - -export type KernelStore = ReturnType; diff --git a/packages/kernel/src/store/methods/base.test.ts b/packages/kernel/src/store/methods/base.test.ts new file mode 100644 index 000000000..63ae297d3 --- /dev/null +++ b/packages/kernel/src/store/methods/base.test.ts @@ -0,0 +1,281 @@ +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { getBaseMethods } from './base.ts'; +import { makeMapKVStore } from '../../../test/storage.ts'; + +describe('base-methods', () => { + let kv: KVStore; + let baseStore: ReturnType; + + beforeEach(() => { + kv = makeMapKVStore(); + baseStore = getBaseMethods(kv); + }); + + describe('getSlotKey', () => { + it('generates correct slot keys', () => { + expect(baseStore.getSlotKey('v1', 'ko123')).toBe('v1.c.ko123'); + expect(baseStore.getSlotKey('r2', 'kp456')).toBe('r2.c.kp456'); + }); + }); + + describe('incCounter', () => { + it('increments a stored counter value', () => { + // Create a stored value to increment + const storedValue = baseStore.provideCachedStoredValue( + 'test-counter', + '5', + ); + + // Increment and check return value + expect(baseStore.incCounter(storedValue)).toBe('5'); + expect(storedValue.get()).toBe('6'); + + // Increment again + expect(baseStore.incCounter(storedValue)).toBe('6'); + expect(storedValue.get()).toBe('7'); + }); + }); + + describe('provideCachedStoredValue', () => { + it('creates a new value if it does not exist', () => { + const value = baseStore.provideCachedStoredValue('new-key', 'initial'); + expect(value.get()).toBe('initial'); + expect(kv.get('new-key')).toBe('initial'); + }); + + it('retrieves an existing value', () => { + kv.set('existing-key', 'existing-value'); + const value = baseStore.provideCachedStoredValue('existing-key'); + expect(value.get()).toBe('existing-value'); + }); + + it('caches values in memory', () => { + const value = baseStore.provideCachedStoredValue('cached-key', 'initial'); + + // Change the value through the stored value object + value.set('updated'); + expect(value.get()).toBe('updated'); + expect(kv.get('cached-key')).toBe('updated'); + + // Change the value directly in the KV store + kv.set('cached-key', 'changed-externally'); + + // The cached value should still return the cached value, not the updated KV store value + // This is because the value is cached in memory + expect(value.get()).toBe('updated'); + + // But a new stored value object should see the updated KV store value + const newValue = baseStore.provideCachedStoredValue('cached-key'); + expect(newValue.get()).toBe('changed-externally'); + }); + + it('deletes values correctly', () => { + const value = baseStore.provideCachedStoredValue( + 'delete-key', + 'to-delete', + ); + expect(value.get()).toBe('to-delete'); + + value.delete(); + expect(value.get()).toBeUndefined(); + expect(kv.get('delete-key')).toBeUndefined(); + }); + }); + + describe('provideRawStoredValue', () => { + it('creates a new value if it does not exist', () => { + const value = baseStore.provideRawStoredValue('new-raw-key', 'initial'); + expect(value.get()).toBe('initial'); + expect(kv.get('new-raw-key')).toBe('initial'); + }); + + it('retrieves an existing value', () => { + kv.set('existing-raw-key', 'existing-value'); + const value = baseStore.provideRawStoredValue('existing-raw-key'); + expect(value.get()).toBe('existing-value'); + }); + + it('does not cache values in memory', () => { + const value = baseStore.provideRawStoredValue('raw-key', 'initial'); + + // Change the value through the stored value object + value.set('updated'); + expect(value.get()).toBe('updated'); + expect(kv.get('raw-key')).toBe('updated'); + + // Change the value directly in the KV store + kv.set('raw-key', 'changed-externally'); + + // The raw value should always read from the KV store + expect(value.get()).toBe('changed-externally'); + }); + + it('deletes values correctly', () => { + const value = baseStore.provideRawStoredValue( + 'delete-raw-key', + 'to-delete', + ); + expect(value.get()).toBe('to-delete'); + + value.delete(); + expect(value.get()).toBeUndefined(); + expect(kv.get('delete-raw-key')).toBeUndefined(); + }); + }); + + describe('integration', () => { + it('works with multiple stored values', () => { + const counter1 = baseStore.provideCachedStoredValue('counter1', '1'); + const counter2 = baseStore.provideCachedStoredValue('counter2', '10'); + + expect(baseStore.incCounter(counter1)).toBe('1'); + expect(baseStore.incCounter(counter2)).toBe('10'); + + expect(counter1.get()).toBe('2'); + expect(counter2.get()).toBe('11'); + }); + + it('supports both cached and raw stored values', () => { + const cachedValue = baseStore.provideCachedStoredValue( + 'cached', + 'cached-value', + ); + const rawValue = baseStore.provideRawStoredValue('raw', 'raw-value'); + + expect(cachedValue.get()).toBe('cached-value'); + expect(rawValue.get()).toBe('raw-value'); + + // Modify directly in KV store + kv.set('cached', 'modified-cached'); + kv.set('raw', 'modified-raw'); + + // Cached value should still return the cached value + expect(cachedValue.get()).toBe('cached-value'); + // Raw value should return the updated value + expect(rawValue.get()).toBe('modified-raw'); + }); + }); + + describe('provideStoredQueue', () => { + it('throws when enqueueing into a deleted queue', () => { + // Create a queue properly + const queue = baseStore.provideStoredQueue('test', false); + + // Manually delete the head to simulate a deleted queue + kv.delete('queue.test.head'); + + // This should throw when trying to enqueue + expect(() => queue.enqueue({ data: 'test' })).toThrow( + 'enqueue into deleted queue test', + ); + }); + + it('returns undefined when dequeueing from an empty queue', () => { + // Create a queue with matching head and tail (empty) + kv.set('queue.test.head', '1'); + kv.set('queue.test.tail', '1'); + const queue = baseStore.provideStoredQueue('test', false); + + // Should return undefined for an empty queue + expect(queue.dequeue()).toBeUndefined(); + }); + + it('deletes all queue items and metadata', () => { + const queue = baseStore.provideStoredQueue('test'); + + // Add some items + queue.enqueue({ id: 1 }); + queue.enqueue({ id: 2 }); + + // Delete the queue + queue.delete(); + + // Verify head and tail are gone + expect(kv.get('queue.test.head')).toBeUndefined(); + expect(kv.get('queue.test.tail')).toBeUndefined(); + + // Verify items are gone + expect(kv.get('queue.test.1')).toBeUndefined(); + expect(kv.get('queue.test.2')).toBeUndefined(); + }); + + it('does nothing when deleting an already deleted queue', () => { + const queue = baseStore.provideStoredQueue('test'); + + // Delete the queue through KV directly + kv.delete('queue.test.head'); + + // Should not throw when calling delete + expect(() => queue.delete()).not.toThrow(); + }); + }); + + describe('getPrefixedKeys', () => { + beforeEach(() => { + // Add a minimal set of test keys + kv.set('test.a', 'value1'); + kv.set('test.b', 'value2'); + kv.set('other', 'value3'); + }); + + it('yields keys with the given prefix', () => { + // Mock the getNextKey function for predictable testing + const originalGetNextKey = kv.getNextKey; + const mockKeys = ['test.a', 'test.b', 'other']; + let keyIndex = 0; + + vi.spyOn(kv, 'getNextKey').mockImplementation(() => { + if (keyIndex < mockKeys.length) { + // eslint-disable-next-line no-plusplus + return mockKeys[keyIndex++]; + } + return undefined; + }); + + const keys = Array.from(baseStore.getPrefixedKeys('test.')); + + // Restore original function + kv.getNextKey = originalGetNextKey; + + // Verify results + expect(keys).toStrictEqual(['test.a', 'test.b']); + expect(keys).not.toContain('other'); + }); + + it('stops iteration when key does not match prefix', () => { + // Create a custom mock implementation + const originalGetNextKey = kv.getNextKey; + + // This mocks a sequence of keys where we first get matching keys, + // then a non-matching key + vi.spyOn(kv, 'getNextKey') + .mockReturnValueOnce('test.a') + .mockReturnValueOnce('test.b') + .mockReturnValueOnce('other'); // This should stop the iteration + + const keys = Array.from(baseStore.getPrefixedKeys('test.')); + + // Restore original function + kv.getNextKey = originalGetNextKey; + + // Should only contain keys before the non-matching key + expect(keys).toStrictEqual(['test.a', 'test.b']); + expect(keys).toHaveLength(2); + }); + + it('handles case when no keys are found', () => { + // Mock getNextKey to return undefined (no keys) + const originalGetNextKey = kv.getNextKey; + vi.spyOn(kv, 'getNextKey').mockReturnValue(undefined); + + const keys = Array.from(baseStore.getPrefixedKeys('nonexistent.')); + + // Restore original function + kv.getNextKey = originalGetNextKey; + + expect(keys).toHaveLength(0); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/base.ts b/packages/kernel/src/store/methods/base.ts new file mode 100644 index 000000000..bf90dc1d4 --- /dev/null +++ b/packages/kernel/src/store/methods/base.ts @@ -0,0 +1,185 @@ +import type { KVStore } from '@ocap/store'; + +import type { EndpointId, KRef } from '../../types.ts'; +import type { StoredQueue, StoredValue } from '../types.ts'; + +/** + * Get the base store methods for managing stored values and queues. + * + * @param kv - The key/value store to provide the underlying persistence mechanism. + * @returns An object with methods for managing stored values and queues. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getBaseMethods(kv: KVStore) { + /** + * Get the key for the reachable flag and vatSlot for a given endpoint and kref. + * + * @param endpointId - The endpoint for which the reachable flag is being set. + * @param kref - The kref. + * @returns The key for the reachable flag and vatSlot. + */ + function getSlotKey(endpointId: EndpointId, kref: KRef): string { + return `${endpointId}.c.${kref}`; + } + + /** + * Increment the value of a persistently stored counter. + * + * Note that the while the value is interpreted as an integer (in order to + * enable it to be incremented), it is stored and returned in the form of a + * string. This is because (a) our persistent storage only stores strings, and + * (b) the sole purpose of one of these counters is simply to provide an + * unending sequence of unique values; we don't actually use them as numbers + * or, indeed, even care at all if this sequence is produced using numbers. + * + * @param value - Reference to the stored value to be incremented. + * @returns The value as it was prior to being incremented. + */ + function incCounter(value: StoredValue): string { + const current = value.get(); + const next = `${Number(current) + 1}`; + value.set(next); + return current as string; + } + + /** + * Provide a stored value object for which we keep an in-memory cache. We only + * touch persistent storage if the value hasn't ever been read of if it is + * modified; otherwise we can service read requests from memory. + * + * IMPORTANT NOTE: in order for the cache to work, all subsequent accesses to + * the value MUST be made through a common stored value object. + * + * @param key - A key string that identifies the value. + * @param init - If provided, an initial setting if the stored entity does not exist. + * @returns An object for interacting with the value. + */ + function provideCachedStoredValue(key: string, init?: string): StoredValue { + let value: string | undefined = kv.get(key); + if (value === undefined && init !== undefined) { + kv.set(key, init); + value = init; + } + return harden({ + get(): string | undefined { + return value; + }, + set(newValue: string): void { + value = newValue; + kv.set(key, value); + }, + delete(): void { + value = undefined; + kv.delete(key); + }, + }); + } + + /** + * Provide a stored value object that is kept in persistent storage without caching. + * + * @param key - A key string that identifies the value. + * @param init - If provided, an initial setting if the stored entity does not exist. + * @returns An object for interacting with the value. + */ + function provideRawStoredValue(key: string, init?: string): StoredValue { + if (kv.get(key) === undefined && init !== undefined) { + kv.set(key, init); + } + return harden({ + get: () => kv.get(key), + set: (newValue: string) => kv.set(key, newValue), + delete: () => kv.delete(key), + }); + } + + /** + * Produce an object to access a persistently stored queue. + * + * @param queueName - The name for the queue (must be unique among queues). + * @param cached - Optional flag: set to true if the queue should cache its + * limit indices in memory (only do this if the queue is going to be accessed or + * checked frequently). + * @returns An object for interacting with the queue. + */ + function provideStoredQueue( + queueName: string, + cached: boolean = false, + ): StoredQueue { + const qk = `queue.${queueName}`; + // Note: cached=true ==> caches only the head & tail indices, NOT the queue entries themselves + const provideValue = cached + ? provideCachedStoredValue + : provideRawStoredValue; + const head = provideValue(`${qk}.head`, '1'); + const tail = provideValue(`${qk}.tail`, '1'); + if (head.get() === undefined || tail.get() === undefined) { + throw Error(`queue ${queueName} not initialized`); + } + return { + enqueue(item: object): void { + if (head.get() === undefined) { + throw Error(`enqueue into deleted queue ${queueName}`); + } + const entryPos = incCounter(head); + kv.set(`${qk}.${entryPos}`, JSON.stringify(item)); + }, + dequeue(): object | undefined { + const headPos = head.get(); + if (headPos === undefined) { + return undefined; + } + const tailPos = tail.get(); + if (tailPos !== headPos) { + const entry = kv.getRequired(`${qk}.${tailPos}`); + kv.delete(`${qk}.${tailPos}`); + incCounter(tail); + return JSON.parse(entry) as object; + } + return undefined; + }, + delete(): void { + const headPos = head.get(); + if (headPos !== undefined) { + let tailPos = tail.get(); + while (tailPos !== headPos) { + kv.delete(`${qk}.${tailPos}`); + tailPos = `${Number(tailPos) + 1}`; + } + head.delete(); + tail.delete(); + } + }, + }; + } + + /** + * Generator that yields all the keys beginning with a given prefix. + * + * @param prefix - The prefix of interest. + * + * @yields the keys that start with `prefix`. + */ + function* getPrefixedKeys(prefix: string): Generator { + let key: string | undefined = prefix; + for (;;) { + key = kv.getNextKey(key); + if (!key) { + break; + } + if (!key.startsWith(prefix)) { + break; + } + yield key; + } + } + + return { + getSlotKey, + incCounter, + provideCachedStoredValue, + provideRawStoredValue, + provideStoredQueue, + getPrefixedKeys, + }; +} diff --git a/packages/kernel/src/store/methods/clist.test.ts b/packages/kernel/src/store/methods/clist.test.ts new file mode 100644 index 000000000..c48b9ca7b --- /dev/null +++ b/packages/kernel/src/store/methods/clist.test.ts @@ -0,0 +1,259 @@ +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { getCListMethods } from './clist.ts'; +import { makeMapKVStore } from '../../../test/storage.ts'; +import type { EndpointId, KRef, ERef } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +describe('clist-methods', () => { + let kv: KVStore; + let clistMethods: ReturnType; + let maybeFreeKrefs: Set; + + // Mock dependencies + const mockGetObjectRefCount = vi.fn(); + const mockSetObjectRefCount = vi.fn(); + const mockClearReachableFlag = vi.fn(); + + beforeEach(() => { + kv = makeMapKVStore(); + maybeFreeKrefs = new Set(); + + // Initialize endpoint counters + kv.set('e.nextPromiseId.v1', '1'); + kv.set('e.nextObjectId.v1', '1'); + kv.set('e.nextPromiseId.r1', '1'); + kv.set('e.nextObjectId.r1', '1'); + + // Reset mocks + mockGetObjectRefCount.mockReset(); + mockSetObjectRefCount.mockReset(); + mockClearReachableFlag.mockReset(); + + // Default mock implementations + mockGetObjectRefCount.mockImplementation(() => ({ + reachable: 1, + recognizable: 1, + })); + + // Create the store with mocked dependencies + clistMethods = getCListMethods({ + kv, + maybeFreeKrefs, + } as StoreContext); + }); + + describe('addClistEntry', () => { + it('adds a bidirectional mapping between KRef and ERef', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'ko1'; + const eref: ERef = 'o-1'; + + clistMethods.addClistEntry(endpointId, kref, eref); + + // Check that both mappings are stored + expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); + expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); + }); + + it('works with promise refs', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'kp1'; + const eref: ERef = 'p+2'; + + clistMethods.addClistEntry(endpointId, kref, eref); + + expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); + expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); + }); + + it('works with remote endpoints', () => { + const endpointId: EndpointId = 'r1'; + const kref: KRef = 'ko2'; + const eref: ERef = 'ro+3'; + + clistMethods.addClistEntry(endpointId, kref, eref); + + expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); + expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); + }); + }); + + describe('hasCListEntry', () => { + it('returns true for existing entries', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'ko1'; + const eref: ERef = 'o-1'; + + clistMethods.addClistEntry(endpointId, kref, eref); + + expect(clistMethods.hasCListEntry(endpointId, kref)).toBe(true); + expect(clistMethods.hasCListEntry(endpointId, eref)).toBe(true); + }); + + it('returns false for non-existent entries', () => { + const endpointId: EndpointId = 'v1'; + + expect(clistMethods.hasCListEntry(endpointId, 'ko99')).toBe(false); + expect(clistMethods.hasCListEntry(endpointId, 'o-99')).toBe(false); + }); + }); + + describe('allocateErefForKref', () => { + it('allocates a new object ERef for a KRef', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'ko1'; + + const eref = clistMethods.allocateErefForKref(endpointId, kref); + + // Check the allocated ERef format + expect(eref).toBe('o-1'); + + // Check that the counter was incremented + expect(kv.get(`e.nextObjectId.${endpointId}`)).toBe('2'); + + // Check that the mapping was added + expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); + expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); + }); + + it('allocates a new promise ERef for a KRef', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'kp1'; + + const eref = clistMethods.allocateErefForKref(endpointId, kref); + + // Check the allocated ERef format + expect(eref).toBe('p-1'); + + // Check that the counter was incremented + expect(kv.get(`e.nextPromiseId.${endpointId}`)).toBe('2'); + + // Check that the mapping was added + expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); + expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); + }); + + it('allocates ERefs with remote prefix for remote endpoints', () => { + const endpointId: EndpointId = 'r1'; + const kref: KRef = 'ko1'; + + const eref = clistMethods.allocateErefForKref(endpointId, kref); + + // Check the allocated ERef format (should have 'r' prefix) + expect(eref).toBe('ro-1'); + + // Check that the counter was incremented + expect(kv.get(`e.nextObjectId.${endpointId}`)).toBe('2'); + }); + }); + + describe('erefToKref and krefToEref', () => { + it('converts between ERef and KRef', () => { + const endpointId: EndpointId = 'v1'; + const kref: KRef = 'ko1'; + const eref: ERef = 'o-1'; + + clistMethods.addClistEntry(endpointId, kref, eref); + + expect(clistMethods.erefToKref(endpointId, eref)).toBe(kref); + expect(clistMethods.krefToEref(endpointId, kref)).toBe(eref); + }); + + it('returns undefined for non-existent mappings', () => { + const endpointId: EndpointId = 'v1'; + + expect(clistMethods.erefToKref(endpointId, 'o-99')).toBeUndefined(); + expect(clistMethods.krefToEref(endpointId, 'ko99')).toBeUndefined(); + }); + }); + + describe('incrementRefCount', () => { + it('increments promise reference counts', () => { + const kref: KRef = 'kp1'; + + // Set up initial refCount + kv.set(`${kref}.refCount`, '1'); + + clistMethods.incrementRefCount(kref, {}); + + // Check that the refCount was incremented + expect(kv.get(`${kref}.refCount`)).toBe('2'); + }); + + it('does not increment object counts for exports', () => { + const kref: KRef = 'ko1'; + + clistMethods.incrementRefCount(kref, { isExport: true }); + + // Should not call getObjectRefCount or setObjectRefCount + expect(mockGetObjectRefCount).not.toHaveBeenCalled(); + expect(mockSetObjectRefCount).not.toHaveBeenCalled(); + }); + + it('throws for empty kref', () => { + expect(() => clistMethods.incrementRefCount('' as KRef, {})).toThrow( + 'incrementRefCount called with empty kref', + ); + }); + }); + + describe('decrementRefCount', () => { + it('decrements promise reference counts', () => { + const kref: KRef = 'kp1'; + + // Set up initial refCount + kv.set(`${kref}.refCount`, '2'); + + const result = clistMethods.decrementRefCount(kref, {}); + + // Check that the refCount was decremented + expect(kv.get(`${kref}.refCount`)).toBe('1'); + + expect(result).toBe(false); // Not zero yet + }); + + it('adds promise to maybeFreeKrefs when count reaches zero', () => { + const kref: KRef = 'kp1'; + + // Set up initial refCount + kv.set(`${kref}.refCount`, '1'); + + const result = clistMethods.decrementRefCount(kref, {}); + + // Check that the refCount was decremented to zero + expect(kv.get(`${kref}.refCount`)).toBe('0'); + expect(result).toBe(true); // Now zero + expect(maybeFreeKrefs.has(kref)).toBe(true); + }); + + it('does not decrement object counts for exports', () => { + const kref: KRef = 'ko1'; + + const result = clistMethods.decrementRefCount(kref, { isExport: true }); + + // Should not call getObjectRefCount or setObjectRefCount + expect(mockGetObjectRefCount).not.toHaveBeenCalled(); + expect(mockSetObjectRefCount).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('throws for empty kref', () => { + expect(() => clistMethods.decrementRefCount('' as KRef, {})).toThrow( + 'decrementRefCount called with empty kref', + ); + }); + + it('throws for underflow on promise refCount', () => { + const kref: KRef = 'kp1'; + + // Set up initial refCount at 0 + kv.set(`${kref}.refCount`, '0'); + + expect(() => clistMethods.decrementRefCount(kref, {})).toThrow( + /refCount underflow/u, + ); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/clist.ts b/packages/kernel/src/store/methods/clist.ts new file mode 100644 index 000000000..6cab68ec0 --- /dev/null +++ b/packages/kernel/src/store/methods/clist.ts @@ -0,0 +1,270 @@ +import { Fail } from '@endo/errors'; + +import { getBaseMethods } from './base.ts'; +import { getGCMethods } from './gc.ts'; +import { getObjectMethods } from './object.ts'; +import { getRefCountMethods } from './refcount.ts'; +import type { EndpointId, KRef, ERef } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; +import { parseRef } from '../utils/parse-ref.ts'; +import { isPromiseRef } from '../utils/promise-ref.ts'; +import { + buildReachableAndVatSlot, + parseReachableAndVatSlot, +} from '../utils/reachable.ts'; + +/** + * Get the c-list methods that provide functionality for managing c-lists. + * + * @param ctx - The store context. + * @returns The c-list store. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getCListMethods(ctx: StoreContext) { + const { getSlotKey } = getBaseMethods(ctx.kv); + const { clearReachableFlag } = getGCMethods(ctx); + const { getObjectRefCount, setObjectRefCount } = getObjectMethods(ctx); + const { kernelRefExists, refCountKey } = getRefCountMethods(ctx); + + /** + * Add an entry to an endpoint's c-list, creating a new bidirectional mapping + * between an ERef belonging to the endpoint and a KRef belonging to the + * kernel. + * + * @param endpointId - The endpoint whose c-list is to be added to. + * @param kref - The KRef. + * @param eref - The ERef. + */ + function addClistEntry(endpointId: EndpointId, kref: KRef, eref: ERef): void { + ctx.kv.set( + getSlotKey(endpointId, kref), + buildReachableAndVatSlot(true, eref), + ); + ctx.kv.set(getSlotKey(endpointId, eref), kref); + } + + /** + * Test if there's a c-list entry for some slot. + * + * @param endpointId - The endpoint of interest + * @param slot - The slot of interest + * @returns true iff this vat has a c-list entry mapping for `slot`. + */ + function hasCListEntry(endpointId: EndpointId, slot: string): boolean { + return ctx.kv.get(getSlotKey(endpointId, slot)) !== undefined; + } + + /** + * Remove an entry from an endpoint's c-list. + * + * @param endpointId - The endpoint whose c-list entry is to be removed. + * @param kref - The KRef. + * @param eref - The ERef. + */ + function deleteClistEntry( + endpointId: EndpointId, + kref: KRef, + eref: ERef, + ): void { + const kernelKey = getSlotKey(endpointId, kref); + const vatKey = getSlotKey(endpointId, eref); + assert(ctx.kv.get(kernelKey)); + clearReachableFlag(endpointId, kref); + const { direction } = parseRef(eref); + decrementRefCount(kref, { + isExport: direction === 'export', + onlyRecognizable: true, + }); + ctx.kv.delete(kernelKey); + ctx.kv.delete(vatKey); + } + + /** + * Generate a new eref for a kernel object or promise being imported into an + * endpoint. + * + * @param endpointId - The endpoint the kref is being imported into. + * @param kref - The kref for the kernel object or promise in question. + * + * @returns A new eref in the scope of the given endpoint for the given kernel entity. + */ + function allocateErefForKref(endpointId: EndpointId, kref: KRef): ERef { + let id; + const refTag = endpointId.startsWith('v') ? '' : endpointId[0]; + let refType; + if (isPromiseRef(kref)) { + id = ctx.kv.get(`e.nextPromiseId.${endpointId}`); + ctx.kv.set(`e.nextPromiseId.${endpointId}`, `${Number(id) + 1}`); + refType = 'p'; + } else { + id = ctx.kv.get(`e.nextObjectId.${endpointId}`); + ctx.kv.set(`e.nextObjectId.${endpointId}`, `${Number(id) + 1}`); + refType = 'o'; + } + const eref = `${refTag}${refType}-${id}`; + addClistEntry(endpointId, kref, eref); + return eref; + } + + /** + * Look up the ERef that and endpoint's c-list maps a KRef to. + * + * @param endpointId - The endpoint in question. + * @param eref - The ERef to look up. + * @returns The KRef corresponding to `eref` in the given endpoints c-list, or undefined + * if there is no such mapping. + */ + function erefToKref(endpointId: EndpointId, eref: ERef): KRef | undefined { + return ctx.kv.get(getSlotKey(endpointId, eref)); + } + + /** + * Look up the KRef that and endpoint's c-list maps an ERef to. + * + * @param endpointId - The endpoint in question. + * @param kref - The KRef to look up. + * @returns The given endpoint's ERef corresponding to `kref`, or undefined if + * there is no such mapping. + */ + function krefToEref(endpointId: EndpointId, kref: KRef): ERef | undefined { + const key = getSlotKey(endpointId, kref); + const data = ctx.kv.get(key); + if (!data) { + return undefined; + } + const { vatSlot } = parseReachableAndVatSlot(data); + return vatSlot; + } + + /** + * Remove an entry from an endpoint's c-list given an eref. + * + * @param endpointId - The endpoint whose c-list entry is to be removed. + * @param eref - The ERef. + */ + function forgetEref(endpointId: EndpointId, eref: ERef): void { + const kref = erefToKref(endpointId, eref); + if (kref) { + deleteClistEntry(endpointId, kref, eref); + } + } + + /** + * Remove an entry from an endpoint's c-list given a kref. + * + * @param endpointId - The endpoint whose c-list entry is to be removed. + * @param kref - The Kref. + */ + function forgetKref(endpointId: EndpointId, kref: KRef): void { + const eref = krefToEref(endpointId, kref); + if (eref) { + deleteClistEntry(endpointId, kref, eref); + } + } + + /** + * Increment the reference count associated with some kernel object. + * + * We track references to promises and objects, but not devices. Promises + * have only a "reachable" count, whereas objects track both "reachable" + * and "recognizable" counts. + * + * @param kref - The kernel slot whose refcount is to be incremented. + * @param options - Options for the increment. + * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. + * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. + */ + function incrementRefCount( + kref: KRef, + { + isExport = false, + onlyRecognizable = false, + }: { isExport?: boolean; onlyRecognizable?: boolean }, + ): void { + kref || Fail`incrementRefCount called with empty kref`; + + const { isPromise } = parseRef(kref); + if (isPromise) { + const refCount = Number(ctx.kv.get(refCountKey(kref))) + 1; + ctx.kv.set(refCountKey(kref), `${refCount}`); + return; + } + + // If `isExport` the reference comes from a clist export, which counts for promises but not objects + if (isExport) { + return; + } + + const counts = getObjectRefCount(kref); + if (!onlyRecognizable) { + counts.reachable += 1; + } + counts.recognizable += 1; + setObjectRefCount(kref, counts); + } + + /** + * Decrement the reference count associated with some kernel object. + * + * @param kref - The kernel slot whose refcount is to be decremented. + * @param options - Options for the decrement. + * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. + * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. + * @returns True if the reference count has been decremented to zero, false if it is still non-zero. + * @throws if this tries to decrement the reference count below zero. + */ + function decrementRefCount( + kref: KRef, + { + isExport = false, + onlyRecognizable = false, + }: { isExport?: boolean; onlyRecognizable?: boolean }, + ): boolean { + kref || Fail`decrementRefCount called with empty kref`; + + const { isPromise } = parseRef(kref); + if (isPromise) { + let refCount = Number(ctx.kv.get(refCountKey(kref))); + refCount > 0 || Fail`refCount underflow ${kref}`; + refCount -= 1; + ctx.kv.set(refCountKey(kref), `${refCount}`); + if (refCount === 0) { + ctx.maybeFreeKrefs.add(kref); + return true; + } + return false; + } + + if (isExport || !kernelRefExists(kref)) { + return false; + } + + const counts = getObjectRefCount(kref); + if (!onlyRecognizable) { + counts.reachable -= 1; + } + counts.recognizable -= 1; + if (!counts.reachable || !counts.recognizable) { + ctx.maybeFreeKrefs.add(kref); + } + setObjectRefCount(kref, counts); + ctx.kv.set('initialized', 'true'); + return false; + } + + return { + // C-List entries + addClistEntry, + hasCListEntry, + deleteClistEntry, + // Eref allocation + allocateErefForKref, + erefToKref, + krefToEref, + forgetEref, + forgetKref, + // Refcount management + incrementRefCount, + decrementRefCount, + }; +} diff --git a/packages/kernel/src/store/methods/gc.test.ts b/packages/kernel/src/store/methods/gc.test.ts new file mode 100644 index 000000000..4f5f395e1 --- /dev/null +++ b/packages/kernel/src/store/methods/gc.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { makeMapKernelDatabase } from '../../../test/storage.ts'; +import { RunQueueItemType } from '../../types.ts'; +import type { GCAction } from '../../types.ts'; +import { makeKernelStore } from '../index.ts'; + +describe('GC methods', () => { + let kernelStore: ReturnType; + + beforeEach(() => { + kernelStore = makeKernelStore(makeMapKernelDatabase()); + }); + + describe('GC actions', () => { + it('manages all valid GC action types', () => { + const ko1 = kernelStore.initKernelObject('v1'); + const ko2 = kernelStore.initKernelObject('v1'); + const ko3 = kernelStore.initKernelObject('v2'); + + const validActions: GCAction[] = [ + `v1 dropExport ${ko1}`, + `v1 retireExport ${ko2}`, + `v2 retireImport ${ko3}`, + ]; + + kernelStore.addGCActions(validActions); + + const actions = kernelStore.getGCActions(); + expect(actions.size).toBe(3); + expect(actions).toStrictEqual(new Set(validActions)); + }); + + it('rejects invalid GC actions', () => { + const ko1 = kernelStore.initKernelObject('v1'); + + // Invalid vat ID + expect(() => { + kernelStore.addGCActions(['x1 dropExport ko1']); + }).toThrow('not a valid VatId'); + + // Invalid action type + expect(() => { + kernelStore.addGCActions([`v1 invalidAction ${ko1}`] as GCAction[]); + }).toThrow('not a valid GCActionType "invalidAction"'); + + // Invalid kref (must be kernel object, not promise) + expect(() => { + kernelStore.addGCActions(['v1 dropExport kp1']); + }).toThrow('kernelSlot "kp1" is not of type "object"'); + + // Malformed action string + expect(() => { + kernelStore.addGCActions(['v1 dropExport'] as unknown as GCAction[]); + }).toThrow('kernelSlot is undefined'); + }); + + it('maintains action order when storing', () => { + const ko1 = kernelStore.initKernelObject('v1'); + const ko2 = kernelStore.initKernelObject('v2'); + const ko3 = kernelStore.initKernelObject('v3'); + + const actions = [ + `v3 retireImport ${ko3}`, + `v1 dropExport ${ko1}`, + `v2 retireExport ${ko2}`, + ]; + + kernelStore.setGCActions(new Set(actions) as Set); + + // Actions should be sorted when retrieved + const sortedActions = Array.from(kernelStore.getGCActions()); + expect(sortedActions).toStrictEqual([ + `v1 dropExport ${ko1}`, + `v2 retireExport ${ko2}`, + `v3 retireImport ${ko3}`, + ]); + }); + }); + + describe('reachability tracking', () => { + it('manages reachable flags', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o-1'); + + expect(kernelStore.getReachableFlag('v1', ko1)).toBe(true); + + kernelStore.clearReachableFlag('v1', ko1); + expect(kernelStore.getReachableFlag('v1', ko1)).toBe(false); + + const refCounts = kernelStore.getObjectRefCount(ko1); + expect(refCounts.reachable).toBe(0); + }); + }); + + describe('reaping', () => { + it('processes reap queue in order', () => { + const vatIds = ['v1', 'v2', 'v3']; + + // Schedule multiple vats for reaping + vatIds.forEach((vatId) => kernelStore.scheduleReap(vatId)); + + // Verify they are processed in order + vatIds.forEach((vatId) => { + expect(kernelStore.nextReapAction()).toStrictEqual({ + type: RunQueueItemType.bringOutYourDead, + vatId, + }); + }); + + // Queue should be empty after processing all items + expect(kernelStore.nextReapAction()).toBeUndefined(); + }); + + it('handles duplicate reap scheduling', () => { + kernelStore.scheduleReap('v1'); + kernelStore.scheduleReap('v1'); // Duplicate scheduling + kernelStore.scheduleReap('v2'); + + // Should only process v1 once + expect(kernelStore.nextReapAction()).toStrictEqual({ + type: RunQueueItemType.bringOutYourDead, + vatId: 'v1', + }); + + expect(kernelStore.nextReapAction()).toStrictEqual({ + type: RunQueueItemType.bringOutYourDead, + vatId: 'v2', + }); + + expect(kernelStore.nextReapAction()).toBeUndefined(); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/gc.ts b/packages/kernel/src/store/methods/gc.ts new file mode 100644 index 000000000..3f2c6753f --- /dev/null +++ b/packages/kernel/src/store/methods/gc.ts @@ -0,0 +1,157 @@ +import { getBaseMethods } from './base.ts'; +import { getObjectMethods } from './object.ts'; +import { getRefCountMethods } from './refcount.ts'; +import type { + VatId, + EndpointId, + KRef, + GCAction, + RunQueueItemBringOutYourDead, +} from '../../types.ts'; +import { + insistGCActionType, + insistVatId, + RunQueueItemType, +} from '../../types.ts'; +import type { StoreContext } from '../types.ts'; +import { insistKernelType } from '../utils/kernel-slots.ts'; +import { parseRef } from '../utils/parse-ref.ts'; +import { + buildReachableAndVatSlot, + parseReachableAndVatSlot, +} from '../utils/reachable.ts'; + +/** + * Create a store for garbage collection. + * + * @param ctx - The store context. + * @returns The GC store. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getGCMethods(ctx: StoreContext) { + const { getSlotKey } = getBaseMethods(ctx.kv); + const { getObjectRefCount, setObjectRefCount } = getObjectMethods(ctx); + const { kernelRefExists } = getRefCountMethods(ctx); + + /** + * Get the set of GC actions to perform. + * + * @returns The set of GC actions to perform. + */ + function getGCActions(): Set { + return new Set(JSON.parse(ctx.gcActions.get() ?? '[]')); + } + + /** + * Set the set of GC actions to perform. + * + * @param actions - The set of GC actions to perform. + */ + function setGCActions(actions: Set): void { + const a = Array.from(actions); + a.sort(); + ctx.gcActions.set(JSON.stringify(a)); + } + + /** + * Add a new GC action to the set of GC actions to perform. + * + * @param newActions - The new GC action to add. + */ + function addGCActions(newActions: GCAction[]): void { + const actions = getGCActions(); + for (const action of newActions) { + assert.typeof(action, 'string', 'addGCActions given bad action'); + const [vatId, type, kref] = action.split(' '); + insistVatId(vatId); + insistGCActionType(type); + insistKernelType('object', kref); + actions.add(action); + } + setGCActions(actions); + } + + /** + * Check if a kernel object is reachable. + * + * @param endpointId - The endpoint for which the reachable flag is being checked. + * @param kref - The kref. + * @returns True if the kernel object is reachable, false otherwise. + */ + function getReachableFlag(endpointId: EndpointId, kref: KRef): boolean { + const key = getSlotKey(endpointId, kref); + const data = ctx.kv.getRequired(key); + const { isReachable } = parseReachableAndVatSlot(data); + return isReachable; + } + + /** + * Clear the reachable flag for a given endpoint and kref. + * + * @param endpointId - The endpoint for which the reachable flag is being cleared. + * @param kref - The kref. + */ + function clearReachableFlag(endpointId: EndpointId, kref: KRef): void { + const key = getSlotKey(endpointId, kref); + const { isReachable, vatSlot } = parseReachableAndVatSlot( + ctx.kv.getRequired(key), + ); + ctx.kv.set(key, buildReachableAndVatSlot(false, vatSlot)); + const { direction, isPromise } = parseRef(vatSlot); + // decrement 'reachable' part of refcount, but only for object imports + if ( + isReachable && + !isPromise && + direction === 'import' && + kernelRefExists(kref) + ) { + const counts = getObjectRefCount(kref); + counts.reachable -= 1; + setObjectRefCount(kref, counts); + if (counts.reachable === 0) { + ctx.maybeFreeKrefs.add(kref); + } + } + } + + /** + * Schedule a vat for reaping. + * + * @param vatId - The vat to schedule for reaping. + */ + function scheduleReap(vatId: VatId): void { + const queue = JSON.parse(ctx.reapQueue.get() ?? '[]'); + if (!queue.includes(vatId)) { + queue.push(vatId); + ctx.reapQueue.set(JSON.stringify(queue)); + } + } + + /** + * Get the next reap action. + * + * @returns The next reap action, or undefined if the queue is empty. + */ + function nextReapAction(): RunQueueItemBringOutYourDead | undefined { + const queue = JSON.parse(ctx.reapQueue.get() ?? '[]'); + if (queue.length > 0) { + const vatId = queue.shift(); + ctx.reapQueue.set(JSON.stringify(queue)); + return harden({ type: RunQueueItemType.bringOutYourDead, vatId }); + } + return undefined; + } + + return { + // GC actions + getGCActions, + setGCActions, + addGCActions, + // Reachability tracking + getReachableFlag, + clearReachableFlag, + // Reaping + scheduleReap, + nextReapAction, + }; +} diff --git a/packages/kernel/src/store/methods/id.test.ts b/packages/kernel/src/store/methods/id.test.ts new file mode 100644 index 000000000..2c507db25 --- /dev/null +++ b/packages/kernel/src/store/methods/id.test.ts @@ -0,0 +1,172 @@ +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { getIdMethods } from './id.ts'; +import { makeMapKVStore } from '../../../test/storage.ts'; +import type { EndpointId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +describe('id-methods', () => { + let kv: KVStore; + let idStore: ReturnType; + let nextVatId: { get: () => string; set: (value: string) => void }; + let nextRemoteId: { get: () => string; set: (value: string) => void }; + + beforeEach(() => { + kv = makeMapKVStore(); + + // Initialize ID counters + kv.set('nextVatId', '0'); + kv.set('nextRemoteId', '0'); + + nextVatId = { + get: () => kv.get('nextVatId') ?? '0', + set: (value: string) => kv.set('nextVatId', value), + }; + + nextRemoteId = { + get: () => kv.get('nextRemoteId') ?? '0', + set: (value: string) => kv.set('nextRemoteId', value), + }; + + idStore = getIdMethods({ + kv, + nextVatId, + nextRemoteId, + } as StoreContext); + }); + + describe('getNextVatId', () => { + it('returns sequential vat IDs', () => { + expect(idStore.getNextVatId()).toBe('v0'); + expect(idStore.getNextVatId()).toBe('v1'); + expect(idStore.getNextVatId()).toBe('v2'); + }); + + it('increments the vat ID counter', () => { + idStore.getNextVatId(); + expect(kv.get('nextVatId')).toBe('1'); + + idStore.getNextVatId(); + expect(kv.get('nextVatId')).toBe('2'); + }); + + it('continues from existing counter value', () => { + // Set an existing counter value + kv.set('nextVatId', '42'); + + expect(idStore.getNextVatId()).toBe('v42'); + expect(idStore.getNextVatId()).toBe('v43'); + }); + }); + + describe('getNextRemoteId', () => { + it('returns sequential remote IDs', () => { + expect(idStore.getNextRemoteId()).toBe('r0'); + expect(idStore.getNextRemoteId()).toBe('r1'); + expect(idStore.getNextRemoteId()).toBe('r2'); + }); + + it('increments the remote ID counter', () => { + idStore.getNextRemoteId(); + expect(kv.get('nextRemoteId')).toBe('1'); + + idStore.getNextRemoteId(); + expect(kv.get('nextRemoteId')).toBe('2'); + }); + + it('continues from existing counter value', () => { + // Set an existing counter value + kv.set('nextRemoteId', '99'); + + expect(idStore.getNextRemoteId()).toBe('r99'); + expect(idStore.getNextRemoteId()).toBe('r100'); + }); + }); + + describe('initEndpoint', () => { + it('initializes a vat endpoint', () => { + const vatId: EndpointId = 'v1'; + + idStore.initEndpoint(vatId); + + expect(kv.get(`e.nextPromiseId.${vatId}`)).toBe('1'); + expect(kv.get(`e.nextObjectId.${vatId}`)).toBe('1'); + }); + + it('initializes a remote endpoint', () => { + const remoteId: EndpointId = 'r2'; + + idStore.initEndpoint(remoteId); + + expect(kv.get(`e.nextPromiseId.${remoteId}`)).toBe('1'); + expect(kv.get(`e.nextObjectId.${remoteId}`)).toBe('1'); + }); + + it('can initialize multiple endpoints', () => { + idStore.initEndpoint('v1'); + idStore.initEndpoint('v2'); + idStore.initEndpoint('r1'); + + expect(kv.get('e.nextPromiseId.v1')).toBe('1'); + expect(kv.get('e.nextObjectId.v1')).toBe('1'); + + expect(kv.get('e.nextPromiseId.v2')).toBe('1'); + expect(kv.get('e.nextObjectId.v2')).toBe('1'); + + expect(kv.get('e.nextPromiseId.r1')).toBe('1'); + expect(kv.get('e.nextObjectId.r1')).toBe('1'); + }); + + it('can reinitialize an existing endpoint', () => { + const vatId: EndpointId = 'v3'; + + // Initialize with default values + idStore.initEndpoint(vatId); + + // Modify the values + kv.set(`e.nextPromiseId.${vatId}`, '10'); + kv.set(`e.nextObjectId.${vatId}`, '20'); + + // Reinitialize + idStore.initEndpoint(vatId); + + // Values should be reset to 1 + expect(kv.get(`e.nextPromiseId.${vatId}`)).toBe('1'); + expect(kv.get(`e.nextObjectId.${vatId}`)).toBe('1'); + }); + }); + + describe('integration', () => { + it('supports creating and initializing endpoints', () => { + // Get new vat and remote IDs + const vatId = idStore.getNextVatId(); + const remoteId = idStore.getNextRemoteId(); + + // Initialize them as endpoints + idStore.initEndpoint(vatId); + idStore.initEndpoint(remoteId); + + // Check that they're properly initialized + expect(kv.get(`e.nextPromiseId.${vatId}`)).toBe('1'); + expect(kv.get(`e.nextObjectId.${vatId}`)).toBe('1'); + + expect(kv.get(`e.nextPromiseId.${remoteId}`)).toBe('1'); + expect(kv.get(`e.nextObjectId.${remoteId}`)).toBe('1'); + }); + + it('maintains separate counters for vats and remotes', () => { + // Generate multiple IDs of each type + const vatId1 = idStore.getNextVatId(); + const vatId2 = idStore.getNextVatId(); + const remoteId1 = idStore.getNextRemoteId(); + const remoteId2 = idStore.getNextRemoteId(); + + // Check that they follow the expected sequence + expect(vatId1).toBe('v0'); + expect(vatId2).toBe('v1'); + expect(remoteId1).toBe('r0'); + expect(remoteId2).toBe('r1'); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/id.ts b/packages/kernel/src/store/methods/id.ts new file mode 100644 index 000000000..964839825 --- /dev/null +++ b/packages/kernel/src/store/methods/id.ts @@ -0,0 +1,49 @@ +import { getBaseMethods } from './base.ts'; +import type { VatId, RemoteId, EndpointId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +/** + * Create a store for allocating IDs. + * + * @param ctx - The store context. + * @returns The ID store. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getIdMethods(ctx: StoreContext) { + const { kv } = ctx; + const { incCounter } = getBaseMethods(kv); + + /** + * Obtain an ID for a new vat. + * + * @returns The next VatID use. + */ + function getNextVatId(): VatId { + return `v${incCounter(ctx.nextVatId)}`; + } + + /** + * Obtain an ID for a new remote connection. + * + * @returns The next remote ID use. + */ + function getNextRemoteId(): RemoteId { + return `r${incCounter(ctx.nextRemoteId)}`; + } + + /** + * Initialize persistent state for a new endpoint. + * + * @param endpointId - The ID of the endpoint being added. + */ + function initEndpoint(endpointId: EndpointId): void { + kv.set(`e.nextPromiseId.${endpointId}`, '1'); + kv.set(`e.nextObjectId.${endpointId}`, '1'); + } + + return { + getNextVatId, + getNextRemoteId, + initEndpoint, + }; +} diff --git a/packages/kernel/src/store/methods/object.test.ts b/packages/kernel/src/store/methods/object.test.ts new file mode 100644 index 000000000..e7ca703e6 --- /dev/null +++ b/packages/kernel/src/store/methods/object.test.ts @@ -0,0 +1,310 @@ +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { getObjectMethods } from './object.ts'; +import { makeMapKVStore } from '../../../test/storage.ts'; +import type { EndpointId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +describe('object-methods', () => { + let kv: KVStore; + let objectStore: ReturnType; + let nextObjectId: { get: () => string; set: (value: string) => void }; + + beforeEach(() => { + kv = makeMapKVStore(); + // Initialize nextObjectId counter + kv.set('nextObjectId', '0'); + nextObjectId = { + get: () => kv.get('nextObjectId') ?? '0', + set: (value: string) => kv.set('nextObjectId', value), + }; + + objectStore = getObjectMethods({ + kv, + nextObjectId, + } as StoreContext); + }); + + describe('initKernelObject', () => { + it('creates a new kernel object with initial reference counts', () => { + const owner: EndpointId = 'v1'; + const koId = objectStore.initKernelObject(owner); + + // Check the object ID format + expect(koId).toBe('ko0'); + + // Check the owner is set correctly + expect(kv.get(`${koId}.owner`)).toBe(owner); + + // Check reference counts are initialized to 1,1 + expect(kv.get(`${koId}.refCount`)).toBe('1,1'); + + // Check via the API + const refCounts = objectStore.getObjectRefCount(koId); + expect(refCounts.reachable).toBe(1); + expect(refCounts.recognizable).toBe(1); + }); + + it('increments the object ID counter', () => { + const koId1 = objectStore.initKernelObject('v1'); + const koId2 = objectStore.initKernelObject('v2'); + const koId3 = objectStore.initKernelObject('r1'); + + expect(koId1).toBe('ko0'); + expect(koId2).toBe('ko1'); + expect(koId3).toBe('ko2'); + }); + }); + + describe('getOwner', () => { + it('returns the owner of a kernel object', () => { + const owner1: EndpointId = 'v1'; + const owner2: EndpointId = 'r2'; + + const koId1 = objectStore.initKernelObject(owner1); + const koId2 = objectStore.initKernelObject(owner2); + + expect(objectStore.getOwner(koId1)).toBe(owner1); + expect(objectStore.getOwner(koId2)).toBe(owner2); + }); + + it('throws for unknown kernel objects', () => { + expect(() => objectStore.getOwner('ko99')).toThrow( + 'unknown kernel object ko99', + ); + }); + }); + + describe('deleteKernelObject', () => { + it('removes a kernel object from storage', () => { + const koId = objectStore.initKernelObject('v1'); + + // Object exists before deletion + expect(kv.get(`${koId}.owner`)).toBeDefined(); + expect(kv.get(`${koId}.refCount`)).toBeDefined(); + + // Delete the object + objectStore.deleteKernelObject(koId); + + // Object should be gone + expect(kv.get(`${koId}.owner`)).toBeUndefined(); + expect(kv.get(`${koId}.refCount`)).toBeUndefined(); + + // getOwner should throw + expect(() => objectStore.getOwner(koId)).toThrow( + `unknown kernel object ${koId}`, + ); + }); + }); + + describe('getNextObjectId', () => { + it('returns sequential object IDs', () => { + expect(objectStore.getNextObjectId()).toBe('ko0'); + expect(objectStore.getNextObjectId()).toBe('ko1'); + expect(objectStore.getNextObjectId()).toBe('ko2'); + }); + }); + + describe('getObjectRefCount', () => { + it('returns reference counts for existing objects', () => { + const koId = objectStore.initKernelObject('v1'); + + const refCounts = objectStore.getObjectRefCount(koId); + expect(refCounts).toStrictEqual({ reachable: 1, recognizable: 1 }); + }); + + it('returns zero counts for non-existent objects', () => { + const refCounts = objectStore.getObjectRefCount('ko99'); + expect(refCounts).toStrictEqual({ reachable: 0, recognizable: 0 }); + }); + + it('parses reference counts correctly', () => { + const koId = objectStore.initKernelObject('v1'); + + // Manually set reference counts + kv.set(`${koId}.refCount`, '5,10'); + + const refCounts = objectStore.getObjectRefCount(koId); + expect(refCounts).toStrictEqual({ reachable: 5, recognizable: 10 }); + }); + + it('throws when stored reachable count exceeds recognizable count', () => { + const koId = objectStore.initKernelObject('v1'); + + // Manually set invalid reference counts where reachable > recognizable + // This simulates corruption in the storage + kv.set(`${koId}.refCount`, '10,5'); + + expect(() => objectStore.getObjectRefCount(koId)).toThrow( + /refMismatch\(get\)/u, + ); + }); + }); + + describe('setObjectRefCount', () => { + it('sets reference counts for an object', () => { + const koId = objectStore.initKernelObject('v1'); + + // Set new reference counts + objectStore.setObjectRefCount(koId, { reachable: 3, recognizable: 5 }); + + // Check via direct KV access + expect(kv.get(`${koId}.refCount`)).toBe('3,5'); + + // Check via API + const refCounts = objectStore.getObjectRefCount(koId); + expect(refCounts).toStrictEqual({ reachable: 3, recognizable: 5 }); + }); + + it('allows zero reference counts', () => { + const koId = objectStore.initKernelObject('v1'); + + objectStore.setObjectRefCount(koId, { reachable: 0, recognizable: 0 }); + + const refCounts = objectStore.getObjectRefCount(koId); + expect(refCounts).toStrictEqual({ reachable: 0, recognizable: 0 }); + }); + + it('throws when reachable count exceeds recognizable count', () => { + const koId = objectStore.initKernelObject('v1'); + + expect(() => + objectStore.setObjectRefCount(koId, { reachable: 5, recognizable: 3 }), + ).toThrow(/refMismatch/u); + }); + + it('throws when counts are negative', () => { + const koId = objectStore.initKernelObject('v1'); + + expect(() => + objectStore.setObjectRefCount(koId, { reachable: -1, recognizable: 1 }), + ).toThrow(/underflow/u); + + expect(() => + objectStore.setObjectRefCount(koId, { reachable: 0, recognizable: -1 }), + ).toThrow(/underflow/u); + }); + + it('prevents storing and retrieving invalid reference count combinations', () => { + const koId = objectStore.initKernelObject('v1'); + + // Test setting invalid counts (should throw) + expect(() => + objectStore.setObjectRefCount(koId, { reachable: 5, recognizable: 3 }), + ).toThrow(/refMismatch\(set\)/u); + + // Set a valid reference count + objectStore.setObjectRefCount(koId, { reachable: 3, recognizable: 5 }); + + // Manually corrupt the stored value + kv.set(`${koId}.refCount`, '10,5'); + + // Retrieving should now throw due to the corrupted data + expect(() => objectStore.getObjectRefCount(koId)).toThrow( + /refMismatch\(get\)/u, + ); + }); + }); + + describe('integration', () => { + it('supports the full kernel object lifecycle', () => { + // Create an object + const koId = objectStore.initKernelObject('v1'); + + // Check initial state + expect(objectStore.getOwner(koId)).toBe('v1'); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 1, + recognizable: 1, + }); + + // Update reference counts + objectStore.setObjectRefCount(koId, { reachable: 2, recognizable: 3 }); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 2, + recognizable: 3, + }); + + // Reduce to zero + objectStore.setObjectRefCount(koId, { reachable: 0, recognizable: 0 }); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 0, + recognizable: 0, + }); + + // Object still exists with zero counts + expect(objectStore.getOwner(koId)).toBe('v1'); + + // Delete the object + objectStore.deleteKernelObject(koId); + + // Object should be gone + expect(() => objectStore.getOwner(koId)).toThrow( + `unknown kernel object ${koId}`, + ); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 0, + recognizable: 0, + }); + }); + + it('handles multiple objects simultaneously', () => { + // Create multiple objects with different owners + const koId1 = objectStore.initKernelObject('v1'); + const koId2 = objectStore.initKernelObject('r2'); + + // Check owners + expect(objectStore.getOwner(koId1)).toBe('v1'); + expect(objectStore.getOwner(koId2)).toBe('r2'); + + // Set different reference counts + objectStore.setObjectRefCount(koId1, { reachable: 2, recognizable: 2 }); + objectStore.setObjectRefCount(koId2, { reachable: 3, recognizable: 5 }); + + // Check counts + expect(objectStore.getObjectRefCount(koId1)).toStrictEqual({ + reachable: 2, + recognizable: 2, + }); + expect(objectStore.getObjectRefCount(koId2)).toStrictEqual({ + reachable: 3, + recognizable: 5, + }); + + // Delete one object + objectStore.deleteKernelObject(koId1); + + // First object should be gone, second still exists + expect(() => objectStore.getOwner(koId1)).toThrow( + `unknown kernel object ${koId1}`, + ); + expect(objectStore.getOwner(koId2)).toBe('r2'); + }); + + it('handles reference count patterns correctly', () => { + const koId = objectStore.initKernelObject('v1'); + + // Equal reachable and recognizable + objectStore.setObjectRefCount(koId, { reachable: 5, recognizable: 5 }); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 5, + recognizable: 5, + }); + + // Reachable less than recognizable + objectStore.setObjectRefCount(koId, { reachable: 3, recognizable: 5 }); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 3, + recognizable: 5, + }); + + // Both zero + objectStore.setObjectRefCount(koId, { reachable: 0, recognizable: 0 }); + expect(objectStore.getObjectRefCount(koId)).toStrictEqual({ + reachable: 0, + recognizable: 0, + }); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/object.ts b/packages/kernel/src/store/methods/object.ts new file mode 100644 index 000000000..e69a1e107 --- /dev/null +++ b/packages/kernel/src/store/methods/object.ts @@ -0,0 +1,119 @@ +import { Fail } from '@endo/errors'; + +import { getBaseMethods } from './base.ts'; +import { getRefCountMethods } from './refcount.ts'; +import type { EndpointId, KRef } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; +import { makeKernelSlot } from '../utils/kernel-slots.ts'; + +/** + * Create an object store object that provides functionality for managing kernel objects. + * + * @param ctx - The store context. + * @returns An object store object that maps various persistent kernel data + * structures onto `kv`. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getObjectMethods(ctx: StoreContext) { + const { incCounter } = getBaseMethods(ctx.kv); + const { refCountKey } = getRefCountMethods(ctx); + + /** + * Create a new kernel object. The new object will be born with reference and + * recognizability counts of 1, on the assumption that the new object + * corresponds to an object that has just been imported from somewhere. + * + * @param owner - The endpoint that is the owner of the new object. + * @returns The new object's KRef. + */ + function initKernelObject(owner: EndpointId): KRef { + const koId = getNextObjectId(); + ctx.kv.set(`${koId}.owner`, owner); + setObjectRefCount(koId, { reachable: 1, recognizable: 1 }); + return koId; + } + + /** + * Get a kernel object's owner. + * + * @param koId - The KRef of the kernel object of interest. + * @returns The identity of the vat or remote that owns the object. + */ + function getOwner(koId: KRef): EndpointId { + const owner = ctx.kv.get(`${koId}.owner`); + if (owner === undefined) { + throw Error(`unknown kernel object ${koId}`); + } + return owner; + } + + /** + * Expunge a kernel object from the kernel's persistent state. + * + * @param koId - The KRef of the kernel object to delete. + */ + function deleteKernelObject(koId: KRef): void { + ctx.kv.delete(`${koId}.owner`); + ctx.kv.delete(refCountKey(koId)); + } + + /** + * Obtain a KRef for the next unallocated kernel object. + * + * @returns The next koId use. + */ + function getNextObjectId(): KRef { + return makeKernelSlot('object', incCounter(ctx.nextObjectId)); + } + + /** + * Get the reference counts for a kernel object + * + * @param kref - The KRef of the object of interest. + * @returns The reference counts for the object. + */ + function getObjectRefCount(kref: KRef): { + reachable: number; + recognizable: number; + } { + const data = ctx.kv.get(refCountKey(kref)); + if (!data) { + return { reachable: 0, recognizable: 0 }; + } + const [reachable = 0, recognizable = 0] = data.split(',').map(Number); + reachable <= recognizable || + Fail`refMismatch(get) ${kref} ${reachable},${recognizable}`; + return { reachable, recognizable }; + } + + /** + * Set the reference counts for a kernel object + * + * @param kref - The KRef of the object of interest. + * @param counts - The reference counts to set. + * @param counts.reachable - The reachable reference count. + * @param counts.recognizable - The recognizable reference count. + */ + function setObjectRefCount( + kref: KRef, + counts: { reachable: number; recognizable: number }, + ): void { + const { reachable, recognizable } = counts; + assert.typeof(reachable, 'number'); + assert.typeof(recognizable, 'number'); + (reachable >= 0 && recognizable >= 0) || + Fail`${kref} underflow ${reachable},${recognizable}`; + reachable <= recognizable || + Fail`refMismatch(set) ${kref} ${reachable},${recognizable}`; + ctx.kv.set(refCountKey(kref), `${reachable},${recognizable}`); + } + + return { + initKernelObject, + getOwner, + deleteKernelObject, + getNextObjectId, + getObjectRefCount, + setObjectRefCount, + }; +} diff --git a/packages/kernel/src/store/methods/promise.test.ts b/packages/kernel/src/store/methods/promise.test.ts new file mode 100644 index 000000000..e0e3be279 --- /dev/null +++ b/packages/kernel/src/store/methods/promise.test.ts @@ -0,0 +1,461 @@ +import type { Message } from '@agoric/swingset-liveslots'; +import type { CapData } from '@endo/marshal'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { getBaseMethods } from './base.ts'; +import { getPromiseMethods } from './promise.ts'; +import { getQueueMethods } from './queue.ts'; +import { getRefCountMethods } from './refcount.ts'; +import type { KRef, VatId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +vi.mock('./base.ts', () => ({ + getBaseMethods: vi.fn(), +})); + +vi.mock('./queue.ts', () => ({ + getQueueMethods: vi.fn(), +})); + +vi.mock('./refcount.ts', () => ({ + getRefCountMethods: vi.fn(), +})); + +vi.mock('../utils/kernel-slots.ts', () => ({ + makeKernelSlot: vi.fn((type, id) => + type === 'promise' ? `kp${id}` : `ko${id}`, + ), +})); + +vi.mock('../utils/parse-ref.ts', () => ({ + parseRef: vi.fn((kref) => { + if (kref.startsWith('kp')) { + return { context: 'kernel', isPromise: true }; + } + return { context: 'vat', isPromise: false }; + }), +})); + +describe('promise store methods', () => { + let mockKV: Map; + let mockEnqueueRun = vi.fn(); + let mockIncCounter = vi.fn(); + let mockProvideStoredQueue = vi.fn(); + let mockGetPrefixedKeys = vi.fn(); + let mockRefCountKey = vi.fn((id) => `refcount.${id}`); + let mockQueue = { + enqueue: vi.fn(), + dequeue: vi.fn(), + delete: vi.fn(), + }; + let context: StoreContext; + let promiseMethods: ReturnType; + + beforeEach(() => { + mockKV = new Map(); + mockEnqueueRun = vi.fn(); + mockIncCounter = vi.fn(); + mockGetPrefixedKeys = vi.fn(); + mockRefCountKey = vi.fn((id) => `refcount.${id}`); + mockQueue = { + enqueue: vi.fn(), + dequeue: vi.fn(), + delete: vi.fn(), + }; + mockProvideStoredQueue = vi.fn(() => mockQueue); + + (getBaseMethods as ReturnType).mockReturnValue({ + incCounter: mockIncCounter, + provideStoredQueue: mockProvideStoredQueue, + getPrefixedKeys: mockGetPrefixedKeys, + }); + + (getQueueMethods as ReturnType).mockReturnValue({ + enqueueRun: mockEnqueueRun, + }); + + (getRefCountMethods as ReturnType).mockReturnValue({ + refCountKey: mockRefCountKey, + }); + + context = { + kv: { + get: (key: string): string | undefined => mockKV.get(key), + getRequired: (key: string): string => { + const value = mockKV.get(key); + if (value === undefined) { + throw new Error(`Required key ${key} not found`); + } + return value; + }, + set: (key: string, value: string): void => { + mockKV.set(key, value); + }, + delete: (key: string): void => { + mockKV.delete(key); + }, + }, + nextPromiseId: 'nextPromiseId', + } as unknown as StoreContext; + + promiseMethods = getPromiseMethods(context); + }); + + describe('initKernelPromise', () => { + it('creates a new unresolved kernel promise with reference count 1', () => { + mockIncCounter.mockReturnValue('42'); + + const [kpid, kpr] = promiseMethods.initKernelPromise(); + + expect(kpid).toBe('kp42'); + expect(kpr).toStrictEqual({ + state: 'unresolved', + subscribers: [], + }); + + expect(mockIncCounter).toHaveBeenCalledWith('nextPromiseId'); + expect(mockProvideStoredQueue).toHaveBeenCalledWith('kp42', false); + expect(mockKV.get('kp42.state')).toBe('unresolved'); + expect(mockKV.get('kp42.subscribers')).toBe('[]'); + expect(mockKV.get('refcount.kp42')).toBe('1'); + }); + }); + + describe('getKernelPromise', () => { + it('retrieves an unresolved promise without decider', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.subscribers`, '["v1", "v2"]'); + + const result = promiseMethods.getKernelPromise(kpid); + + expect(result).toStrictEqual({ + state: 'unresolved', + subscribers: ['v1', 'v2'], + }); + }); + + it('retrieves an unresolved promise with decider', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.decider`, 'v3'); + mockKV.set(`${kpid}.subscribers`, '["v1", "v2"]'); + + const result = promiseMethods.getKernelPromise(kpid); + + expect(result).toStrictEqual({ + state: 'unresolved', + decider: 'v3', + subscribers: ['v1', 'v2'], + }); + }); + + it('retrieves a fulfilled promise', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'fulfilled'); + mockKV.set(`${kpid}.value`, '{"body":"someValue","slots":[]}'); + + const result = promiseMethods.getKernelPromise(kpid); + + expect(result).toStrictEqual({ + state: 'fulfilled', + value: { body: 'someValue', slots: [] }, + }); + }); + + it('retrieves a rejected promise', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'rejected'); + mockKV.set(`${kpid}.value`, '{"body":"error","slots":[]}'); + + const result = promiseMethods.getKernelPromise(kpid); + + expect(result).toStrictEqual({ + state: 'rejected', + value: { body: 'error', slots: [] }, + }); + }); + + it('throws for unknown promise', () => { + const kpid = 'kp999'; + + expect(() => promiseMethods.getKernelPromise(kpid)).toThrow( + `unknown kernel promise ${kpid}`, + ); + }); + + it('throws for unknown promise state', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'invalid-state'); + + expect(() => promiseMethods.getKernelPromise(kpid)).toThrow( + `unknown state for ${kpid}: invalid-state`, + ); + }); + }); + + describe('deleteKernelPromise', () => { + it('removes all data associated with a kernel promise', () => { + const kpid = 'kp123'; + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.decider`, 'v1'); + mockKV.set(`${kpid}.subscribers`, '["v2", "v3"]'); + mockKV.set(`${kpid}.value`, '{"body":"someValue","slots":[]}'); + mockKV.set(`refcount.${kpid}`, '2'); + + promiseMethods.deleteKernelPromise(kpid); + + expect(mockKV.has(`${kpid}.state`)).toBe(false); + expect(mockKV.has(`${kpid}.decider`)).toBe(false); + expect(mockKV.has(`${kpid}.subscribers`)).toBe(false); + expect(mockKV.has(`${kpid}.value`)).toBe(false); + expect(mockKV.has(`refcount.${kpid}`)).toBe(false); + expect(mockProvideStoredQueue).toHaveBeenCalledWith(kpid); + expect(mockQueue.delete).toHaveBeenCalled(); + }); + }); + + describe('getNextPromiseId', () => { + it('increments the counter and returns a new promise ID', () => { + mockIncCounter.mockReturnValue('456'); + + const result = promiseMethods.getNextPromiseId(); + + expect(result).toBe('kp456'); + expect(mockIncCounter).toHaveBeenCalledWith('nextPromiseId'); + }); + }); + + describe('addPromiseSubscriber', () => { + it('adds a new subscriber to an unresolved promise', () => { + const kpid = 'kp123'; + const vatId = 'v2' as VatId; + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.subscribers`, '["v1"]'); + + promiseMethods.addPromiseSubscriber(vatId, kpid); + + expect(mockKV.get(`${kpid}.subscribers`)).toBe('["v1","v2"]'); + }); + + it('does not add duplicate subscribers', () => { + const kpid = 'kp123'; + const vatId = 'v1' as VatId; + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.subscribers`, '["v1"]'); + + promiseMethods.addPromiseSubscriber(vatId, kpid); + + expect(mockKV.get(`${kpid}.subscribers`)).toBe('["v1"]'); + }); + + it('throws if promise is already resolved', () => { + const kpid = 'kp123'; + const vatId = 'v2' as VatId; + mockKV.set(`${kpid}.state`, 'fulfilled'); + mockKV.set(`${kpid}.value`, '{"body":"someValue","slots":[]}'); + + expect(() => promiseMethods.addPromiseSubscriber(vatId, kpid)).toThrow( + `attempt to add subscriber to resolved promise "${kpid}"`, + ); + }); + }); + + describe('setPromiseDecider', () => { + it('sets the decider for a kernel promise', () => { + const kpid = 'kp123'; + const vatId = 'v1' as VatId; + + promiseMethods.setPromiseDecider(kpid, vatId); + + expect(mockKV.get(`${kpid}.decider`)).toBe(vatId); + }); + + it('does nothing when kpid is falsy', () => { + const kpid = '' as KRef; + const vatId = 'v1' as VatId; + + promiseMethods.setPromiseDecider(kpid, vatId); + + expect(mockKV.get(`${kpid}.decider`)).toBeUndefined(); + }); + }); + + describe('resolveKernelPromise', () => { + it('fulfills a promise and enqueues pending messages', () => { + const kpid = 'kp123'; + const value: CapData = { body: 'someValue', slots: [] }; + const message1: Message = { method: 'method1' } as unknown as Message; + const message2: Message = { method: 'method2' } as unknown as Message; + + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.decider`, 'v1'); + mockKV.set(`${kpid}.subscribers`, '["v2", "v3"]'); + + mockQueue.dequeue + .mockReturnValueOnce(message1) + .mockReturnValueOnce(message2) + .mockReturnValueOnce(undefined); + + promiseMethods.resolveKernelPromise(kpid, false, value); + + expect(mockQueue.dequeue).toHaveBeenCalledTimes(3); + expect(mockEnqueueRun).toHaveBeenCalledTimes(2); + expect(mockEnqueueRun).toHaveBeenNthCalledWith(1, { + type: 'send', + target: kpid, + message: message1, + }); + expect(mockEnqueueRun).toHaveBeenNthCalledWith(2, { + type: 'send', + target: kpid, + message: message2, + }); + + expect(mockKV.get(`${kpid}.state`)).toBe('fulfilled'); + expect(mockKV.get(`${kpid}.value`)).toBe(JSON.stringify(value)); + expect(mockKV.has(`${kpid}.decider`)).toBe(false); + expect(mockKV.has(`${kpid}.subscribers`)).toBe(false); + expect(mockQueue.delete).toHaveBeenCalled(); + }); + + it('rejects a promise and enqueues pending messages', () => { + const kpid = 'kp123'; + const value: CapData = { body: 'error', slots: [] }; + + mockKV.set(`${kpid}.state`, 'unresolved'); + mockKV.set(`${kpid}.decider`, 'v1'); + mockKV.set(`${kpid}.subscribers`, '["v2", "v3"]'); + + mockQueue.dequeue.mockReturnValue(undefined); + + promiseMethods.resolveKernelPromise(kpid, true, value); + + expect(mockKV.get(`${kpid}.state`)).toBe('rejected'); + expect(mockKV.get(`${kpid}.value`)).toBe(JSON.stringify(value)); + expect(mockKV.has(`${kpid}.decider`)).toBe(false); + expect(mockKV.has(`${kpid}.subscribers`)).toBe(false); + expect(mockQueue.delete).toHaveBeenCalled(); + }); + }); + + describe('enqueuePromiseMessage', () => { + it('adds a message to the promise queue', () => { + const kpid = 'kp123'; + const message: Message = { method: 'someMethod' } as unknown as Message; + + promiseMethods.enqueuePromiseMessage(kpid, message); + + expect(mockProvideStoredQueue).toHaveBeenCalledWith(kpid, false); + expect(mockQueue.enqueue).toHaveBeenCalledWith(message); + }); + }); + + describe('getKernelPromiseMessageQueue', () => { + it('retrieves all messages from the promise queue', () => { + const kpid = 'kp123'; + const message1: Message = { method: 'method1' } as unknown as Message; + const message2: Message = { method: 'method2' } as unknown as Message; + + mockQueue.dequeue + .mockReturnValueOnce(message1) + .mockReturnValueOnce(message2) + .mockReturnValueOnce(undefined); + + const result = promiseMethods.getKernelPromiseMessageQueue(kpid); + + expect(result).toStrictEqual([message1, message2]); + expect(mockProvideStoredQueue).toHaveBeenCalledWith(kpid, false); + expect(mockQueue.dequeue).toHaveBeenCalledTimes(3); + }); + + it('returns an empty array for an empty queue', () => { + const kpid = 'kp123'; + + mockQueue.dequeue.mockReturnValue(undefined); + + const result = promiseMethods.getKernelPromiseMessageQueue(kpid); + + expect(result).toStrictEqual([]); + expect(mockProvideStoredQueue).toHaveBeenCalledWith(kpid, false); + expect(mockQueue.dequeue).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPromisesByDecider', () => { + it('yields promises decided by a specific vat', () => { + const vatId = 'v1' as VatId; + const kpid1 = 'kp101'; + const kpid2 = 'kp102'; + const kpid3 = 'kp103'; + + // Set up mock data + mockGetPrefixedKeys.mockReturnValue([ + `cle.${vatId}.p1`, + `cle.${vatId}.p2`, + `cle.${vatId}.p3`, + ]); + + mockKV.set(`cle.${vatId}.p1`, kpid1); + mockKV.set(`cle.${vatId}.p2`, kpid2); + mockKV.set(`cle.${vatId}.p3`, kpid3); + + // kpid1 is decided by vatId + mockKV.set(`${kpid1}.state`, 'unresolved'); + mockKV.set(`${kpid1}.decider`, vatId); + mockKV.set(`${kpid1}.subscribers`, '[]'); + + // kpid2 is also decided by vatId + mockKV.set(`${kpid2}.state`, 'unresolved'); + mockKV.set(`${kpid2}.decider`, vatId); + mockKV.set(`${kpid2}.subscribers`, '[]'); + + // kpid3 is unresolved but decided by a different vat + mockKV.set(`${kpid3}.state`, 'unresolved'); + mockKV.set(`${kpid3}.decider`, 'v2'); + mockKV.set(`${kpid3}.subscribers`, '[]'); + + const result = Array.from(promiseMethods.getPromisesByDecider(vatId)); + + expect(result).toStrictEqual([kpid1, kpid2]); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`cle.${vatId}.p`); + }); + + it('does not yield resolved promises', () => { + const vatId = 'v1' as VatId; + const kpid1 = 'kp101'; + const kpid2 = 'kp102'; + + mockGetPrefixedKeys.mockReturnValue([ + `cle.${vatId}.p1`, + `cle.${vatId}.p2`, + ]); + + mockKV.set(`cle.${vatId}.p1`, kpid1); + mockKV.set(`cle.${vatId}.p2`, kpid2); + + // kpid1 is fulfilled + mockKV.set(`${kpid1}.state`, 'fulfilled'); + mockKV.set(`${kpid1}.value`, '{"body":"value","slots":[]}'); + + // kpid2 is unresolved and decided by vatId + mockKV.set(`${kpid2}.state`, 'unresolved'); + mockKV.set(`${kpid2}.decider`, vatId); + mockKV.set(`${kpid2}.subscribers`, '[]'); + + const result = Array.from(promiseMethods.getPromisesByDecider(vatId)); + + expect(result).toStrictEqual([kpid2]); + }); + + it('yields nothing if no promises are decided by the vat', () => { + const vatId = 'v1' as VatId; + + mockGetPrefixedKeys.mockReturnValue([]); + + const result = Array.from(promiseMethods.getPromisesByDecider(vatId)); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/promise.ts b/packages/kernel/src/store/methods/promise.ts new file mode 100644 index 000000000..29b53bc52 --- /dev/null +++ b/packages/kernel/src/store/methods/promise.ts @@ -0,0 +1,231 @@ +import type { Message } from '@agoric/swingset-liveslots'; +import { Fail } from '@endo/errors'; +import type { CapData } from '@endo/marshal'; + +import { getBaseMethods } from './base.ts'; +import { getQueueMethods } from './queue.ts'; +import { getRefCountMethods } from './refcount.ts'; +import type { + KRef, + KernelPromise, + PromiseState, + RunQueueItemSend, + VatId, +} from '../../types.ts'; +import { insistVatId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; +import { makeKernelSlot } from '../utils/kernel-slots.ts'; +import { parseRef } from '../utils/parse-ref.ts'; + +/** + * Create a promise store object that provides functionality for managing kernel promises. + * + * @param ctx - The store context. + * @returns A promise store object that maps various persistent kernel data + * structures onto `kv`. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getPromiseMethods(ctx: StoreContext) { + const { incCounter, provideStoredQueue, getPrefixedKeys } = getBaseMethods( + ctx.kv, + ); + const { enqueueRun } = getQueueMethods(ctx); + const { refCountKey } = getRefCountMethods(ctx); + + /** + * Create a new, unresolved kernel promise. The new promise will be born with + * a reference count of 1 on the assumption that the promise has just been + * imported from somewhere. + * + * @returns A tuple of the new promise's KRef and an object describing the + * new promise itself. + */ + function initKernelPromise(): [KRef, KernelPromise] { + const kpr: KernelPromise = { + state: 'unresolved', + subscribers: [], + }; + const kpid = getNextPromiseId(); + provideStoredQueue(kpid, false); + ctx.kv.set(`${kpid}.state`, 'unresolved'); + ctx.kv.set(`${kpid}.subscribers`, '[]'); + ctx.kv.set(refCountKey(kpid), '1'); + return [kpid, kpr]; + } + + /** + * Fetch the descriptive record for a kernel promise. + * + * @param kpid - The KRef of the kernel promise of interest. + * @returns An object describing the requested kernel promise. + */ + function getKernelPromise(kpid: KRef): KernelPromise { + const { context, isPromise } = parseRef(kpid); + assert(context === 'kernel' && isPromise); + const state = ctx.kv.get(`${kpid}.state`) as PromiseState; + if (state === undefined) { + throw Error(`unknown kernel promise ${kpid}`); + } + const result: KernelPromise = { state }; + switch (state as string) { + case 'unresolved': { + const decider = ctx.kv.get(`${kpid}.decider`); + if (decider !== '' && decider !== undefined) { + result.decider = decider; + } + const subscribers = ctx.kv.getRequired(`${kpid}.subscribers`); + result.subscribers = JSON.parse(subscribers); + break; + } + case 'fulfilled': + case 'rejected': { + result.value = JSON.parse(ctx.kv.getRequired(`${kpid}.value`)); + break; + } + default: + throw Error(`unknown state for ${kpid}: ${state}`); + } + return result; + } + + /** + * Expunge a kernel promise from the kernel's persistent state. + * + * @param kpid - The KRef of the kernel promise to delete. + */ + function deleteKernelPromise(kpid: KRef): void { + ctx.kv.delete(`${kpid}.state`); + ctx.kv.delete(`${kpid}.decider`); + ctx.kv.delete(`${kpid}.subscribers`); + ctx.kv.delete(`${kpid}.value`); + ctx.kv.delete(refCountKey(kpid)); + provideStoredQueue(kpid).delete(); + } + + /** + * Obtain a KRef for the next unallocated kernel promise. + * + * @returns The next kpid use. + */ + function getNextPromiseId(): KRef { + return makeKernelSlot('promise', incCounter(ctx.nextPromiseId)); + } + + /** + * Add a new subscriber to a kernel promise's collection of subscribers. + * + * @param vatId - The vat that is subscribing. + * @param kpid - The KRef of the promise being subscribed to. + */ + function addPromiseSubscriber(vatId: VatId, kpid: KRef): void { + insistVatId(vatId); + const kp = getKernelPromise(kpid); + kp.state === 'unresolved' || + Fail`attempt to add subscriber to resolved promise ${kpid}`; + const tempSet = new Set(kp.subscribers); + tempSet.add(vatId); + const newSubscribers = Array.from(tempSet).sort(); + const key = `${kpid}.subscribers`; + ctx.kv.set(key, JSON.stringify(newSubscribers)); + } + + /** + * Assign a kernel promise's decider. + * + * @param kpid - The KRef of promise whose decider is being set. + * @param vatId - The vat which will become the decider. + */ + function setPromiseDecider(kpid: KRef, vatId: VatId): void { + insistVatId(vatId); + if (kpid) { + ctx.kv.set(`${kpid}.decider`, vatId); + } + } + + /** + * Record the resolution of a kernel promise. + * + * @param kpid - The ref of the promise being resolved. + * @param rejected - True if the promise is being rejected, false if fulfilled. + * @param value - The value the promise is being fulfilled to or rejected with. + */ + function resolveKernelPromise( + kpid: KRef, + rejected: boolean, + value: CapData, + ): void { + const queue = provideStoredQueue(kpid, false); + for (const message of getKernelPromiseMessageQueue(kpid)) { + const messageItem: RunQueueItemSend = { + type: 'send', + target: kpid, + message, + }; + enqueueRun(messageItem); + } + ctx.kv.set(`${kpid}.state`, rejected ? 'rejected' : 'fulfilled'); + ctx.kv.set(`${kpid}.value`, JSON.stringify(value)); + ctx.kv.delete(`${kpid}.decider`); + ctx.kv.delete(`${kpid}.subscribers`); + queue.delete(); + } + + /** + * Append a message to a promise's message queue. + * + * @param kpid - The KRef of the promise to enqueue on. + * @param message - The message to enqueue. + */ + function enqueuePromiseMessage(kpid: KRef, message: Message): void { + provideStoredQueue(kpid, false).enqueue(message); + } + + /** + * Fetch the messages in a kernel promise's message queue. + * + * @param kpid - The KRef of the kernel promise of interest. + * @returns An array of all the messages in the given promise's message queue. + */ + function getKernelPromiseMessageQueue(kpid: KRef): Message[] { + const result: Message[] = []; + const queue = provideStoredQueue(kpid, false); + for (;;) { + const message = queue.dequeue() as Message; + if (message) { + result.push(message); + } else { + return result; + } + } + } + /** + * Generator that yield the promises decided by a given vat. + * + * @param decider - The vat ID of the vat of interest. + * + * @yields the kpids of all the promises decided by `decider`. + */ + function* getPromisesByDecider(decider: VatId): Generator { + const basePrefix = `cle.${decider}.`; + for (const key of getPrefixedKeys(`${basePrefix}p`)) { + const kpid = ctx.kv.getRequired(key); + const kp = getKernelPromise(kpid); + if (kp.state === 'unresolved' && kp.decider === decider) { + yield kpid; + } + } + } + + return { + initKernelPromise, + getKernelPromise, + deleteKernelPromise, + getNextPromiseId, + addPromiseSubscriber, + setPromiseDecider, + resolveKernelPromise, + enqueuePromiseMessage, + getKernelPromiseMessageQueue, + getPromisesByDecider, + }; +} diff --git a/packages/kernel/src/store/methods/queue.test.ts b/packages/kernel/src/store/methods/queue.test.ts new file mode 100644 index 000000000..d44a3dffb --- /dev/null +++ b/packages/kernel/src/store/methods/queue.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { getQueueMethods } from './queue.ts'; +import type { RunQueueItem } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +describe('queue store methods', () => { + let mockKV: Map; + let mockRunQueue = { + enqueue: vi.fn(), + dequeue: vi.fn(), + }; + let context: StoreContext; + let queueMethods: ReturnType; + + beforeEach(() => { + mockKV = new Map(); + mockRunQueue = { + enqueue: vi.fn(), + dequeue: vi.fn(), + }; + + context = { + kv: { + get: (key: string): string | undefined => mockKV.get(key), + getRequired: (key: string): string => { + const value = mockKV.get(key); + if (value === undefined) { + throw new Error(`Required key ${key} not found`); + } + return value; + }, + set: (key: string, value: string): void => { + mockKV.set(key, value); + }, + delete: (key: string): void => { + mockKV.delete(key); + }, + }, + runQueue: mockRunQueue, + runQueueLengthCache: 0, + } as unknown as StoreContext; + + queueMethods = getQueueMethods(context); + }); + + describe('getQueueLength', () => { + it('calculates queue length from head and tail', () => { + mockKV.set('queue.test.head', '10'); + mockKV.set('queue.test.tail', '3'); + + const result = queueMethods.getQueueLength('test'); + + expect(result).toBe(7); + }); + + it('returns zero for empty queue', () => { + mockKV.set('queue.test.head', '5'); + mockKV.set('queue.test.tail', '5'); + + const result = queueMethods.getQueueLength('test'); + + expect(result).toBe(0); + }); + + it('throws error if queue does not exist', () => { + expect(() => queueMethods.getQueueLength('nonexistent')).toThrow( + 'unknown queue nonexistent', + ); + }); + + it('throws error if only head exists', () => { + mockKV.set('queue.test.head', '5'); + + expect(() => queueMethods.getQueueLength('test')).toThrow( + 'unknown queue test', + ); + }); + + it('throws error if only tail exists', () => { + mockKV.set('queue.test.tail', '3'); + + expect(() => queueMethods.getQueueLength('test')).toThrow( + 'unknown queue test', + ); + }); + }); + + describe('enqueueRun', () => { + it('increments runQueueLengthCache and enqueues the message', () => { + const message: RunQueueItem = { + type: 'message', + data: { some: 'data' }, + } as unknown as RunQueueItem; + + queueMethods.enqueueRun(message); + + expect(context.runQueueLengthCache).toBe(1); + expect(mockRunQueue.enqueue).toHaveBeenCalledWith(message); + }); + + it('increments runQueueLengthCache multiple times correctly', () => { + const message1: RunQueueItem = { + type: 'message', + data: { id: 1 }, + } as unknown as RunQueueItem; + const message2: RunQueueItem = { + type: 'message', + data: { id: 2 }, + } as unknown as RunQueueItem; + + queueMethods.enqueueRun(message1); + queueMethods.enqueueRun(message2); + + expect(context.runQueueLengthCache).toBe(2); + expect(mockRunQueue.enqueue).toHaveBeenCalledTimes(2); + expect(mockRunQueue.enqueue).toHaveBeenNthCalledWith(1, message1); + expect(mockRunQueue.enqueue).toHaveBeenNthCalledWith(2, message2); + }); + }); + + describe('dequeueRun', () => { + it('decrements runQueueLengthCache and returns the dequeued message', () => { + const message: RunQueueItem = { + type: 'message', + data: { some: 'data' }, + } as unknown as RunQueueItem; + mockRunQueue.dequeue.mockReturnValue(message); + context.runQueueLengthCache = 1; + + const result = queueMethods.dequeueRun(); + + expect(context.runQueueLengthCache).toBe(0); + expect(mockRunQueue.dequeue).toHaveBeenCalled(); + expect(result).toStrictEqual(message); + }); + + it('decrements runQueueLengthCache and returns undefined when queue is empty', () => { + mockRunQueue.dequeue.mockReturnValue(undefined); + context.runQueueLengthCache = 1; + + const result = queueMethods.dequeueRun(); + + expect(context.runQueueLengthCache).toBe(0); + expect(mockRunQueue.dequeue).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('works correctly when called multiple times', () => { + const message1: RunQueueItem = { + type: 'message', + data: { id: 1 }, + } as unknown as RunQueueItem; + const message2: RunQueueItem = { + type: 'message', + data: { id: 2 }, + } as unknown as RunQueueItem; + + mockRunQueue.dequeue + .mockReturnValueOnce(message1) + .mockReturnValueOnce(message2) + .mockReturnValueOnce(undefined); + + context.runQueueLengthCache = 3; + + expect(queueMethods.dequeueRun()).toStrictEqual(message1); + expect(context.runQueueLengthCache).toBe(2); + + expect(queueMethods.dequeueRun()).toStrictEqual(message2); + expect(context.runQueueLengthCache).toBe(1); + + expect(queueMethods.dequeueRun()).toBeUndefined(); + expect(context.runQueueLengthCache).toBe(0); + }); + }); + + describe('runQueueLength', () => { + it('returns the cached run queue length when cache is valid', () => { + context.runQueueLengthCache = 5; + + const result = queueMethods.runQueueLength(); + + expect(result).toBe(5); + }); + + it('recalculates queue length when cache is negative', () => { + context.runQueueLengthCache = -1; + mockKV.set('queue.run.head', '8'); + mockKV.set('queue.run.tail', '3'); + + const result = queueMethods.runQueueLength(); + + expect(result).toBe(5); + expect(context.runQueueLengthCache).toBe(5); + }); + + it('keeps the recalculated value in cache for subsequent calls', () => { + context.runQueueLengthCache = -1; + mockKV.set('queue.run.head', '8'); + mockKV.set('queue.run.tail', '3'); + + queueMethods.runQueueLength(); // First call recalculates + const result = queueMethods.runQueueLength(); // Second call should use cache + + expect(result).toBe(5); + expect(context.runQueueLengthCache).toBe(5); + }); + + it('throws error when recalculating if run queue does not exist', () => { + context.runQueueLengthCache = -1; + + expect(() => queueMethods.runQueueLength()).toThrow('unknown queue run'); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/queue.ts b/packages/kernel/src/store/methods/queue.ts new file mode 100644 index 000000000..54693580e --- /dev/null +++ b/packages/kernel/src/store/methods/queue.ts @@ -0,0 +1,69 @@ +import type { RunQueueItem } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +/** + * Get a queue store object that provides functionality for managing queues. + * + * @param ctx - The store context. + * @returns A queue store object that maps various persistent kernel data + * structures onto `kv`. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getQueueMethods(ctx: StoreContext) { + /** + * Find out how long some queue is. + * + * @param queueName - The name of the queue of interest. + * + * @returns the number of items in the given queue. + */ + function getQueueLength(queueName: string): number { + const qk = `queue.${queueName}`; + const head = ctx.kv.get(`${qk}.head`); + const tail = ctx.kv.get(`${qk}.tail`); + if (head === undefined || tail === undefined) { + throw Error(`unknown queue ${queueName}`); + } + return Number(head) - Number(tail); + } + + /** + * Append a message to the kernel's run queue. + * + * @param message - The message to enqueue. + */ + function enqueueRun(message: RunQueueItem): void { + ctx.runQueueLengthCache += 1; + ctx.runQueue.enqueue(message); + } + + /** + * Fetch the next message on the kernel's run queue. + * + * @returns The next message on the run queue, or undefined if the queue is + * empty. + */ + function dequeueRun(): RunQueueItem | undefined { + ctx.runQueueLengthCache -= 1; + return ctx.runQueue.dequeue() as RunQueueItem | undefined; + } + + /** + * Obtain the number of entries in the run queue. + * + * @returns the number of items in the run queue. + */ + function runQueueLength(): number { + if (ctx.runQueueLengthCache < 0) { + ctx.runQueueLengthCache = getQueueLength('run'); + } + return ctx.runQueueLengthCache; + } + + return { + getQueueLength, + enqueueRun, + dequeueRun, + runQueueLength, + }; +} diff --git a/packages/kernel/src/store/methods/refcount.test.ts b/packages/kernel/src/store/methods/refcount.test.ts new file mode 100644 index 000000000..aa6e5c852 --- /dev/null +++ b/packages/kernel/src/store/methods/refcount.test.ts @@ -0,0 +1,164 @@ +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { getRefCountMethods } from './refcount.ts'; +import { makeMapKVStore } from '../../../test/storage.ts'; +import type { KRef } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +describe('refcount-methods', () => { + let kv: KVStore; + let refCountStore: ReturnType; + + beforeEach(() => { + kv = makeMapKVStore(); + refCountStore = getRefCountMethods({ kv } as StoreContext); + }); + + describe('refCountKey', () => { + it('generates correct reference count keys', () => { + expect(refCountStore.refCountKey('ko1')).toBe('ko1.refCount'); + expect(refCountStore.refCountKey('kp42')).toBe('kp42.refCount'); + expect(refCountStore.refCountKey('v7')).toBe('v7.refCount'); + }); + }); + + describe('getRefCount', () => { + it('returns 0 for non-existent references', () => { + kv.set(refCountStore.refCountKey('ko99'), '0'); + expect(refCountStore.getRefCount('ko99')).toBe(0); + }); + + it('handles undefined values by returning NaN', () => { + expect(Number.isNaN(refCountStore.getRefCount('nonexistent'))).toBe(true); + }); + + it('returns the correct reference count for existing references', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '5'); + expect(refCountStore.getRefCount(kref)).toBe(5); + }); + }); + + describe('incRefCount', () => { + it('initializes and increments reference count for new references', () => { + const kref: KRef = 'ko1'; + + kv.set(refCountStore.refCountKey(kref), '0'); + + expect(refCountStore.incRefCount(kref)).toBe(1); + expect(kv.get(`${kref}.refCount`)).toBe('1'); + + expect(refCountStore.incRefCount(kref)).toBe(2); + expect(kv.get(`${kref}.refCount`)).toBe('2'); + }); + + it('handles incrementing from undefined', () => { + const kref: KRef = 'ko2'; + expect(Number.isNaN(refCountStore.incRefCount(kref))).toBe(true); + expect(kv.get(refCountStore.refCountKey(kref))).toBe('NaN'); + }); + + it('increments existing reference counts', () => { + const kref: KRef = 'kp42'; + kv.set(`${kref}.refCount`, '10'); + + expect(refCountStore.incRefCount(kref)).toBe(11); + expect(kv.get(`${kref}.refCount`)).toBe('11'); + }); + }); + + describe('decRefCount', () => { + it('decrements reference counts correctly', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '5'); + + expect(refCountStore.decRefCount(kref)).toBe(4); + expect(kv.get(`${kref}.refCount`)).toBe('4'); + + expect(refCountStore.decRefCount(kref)).toBe(3); + expect(kv.get(`${kref}.refCount`)).toBe('3'); + }); + + it('can decrement to zero', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '1'); + + expect(refCountStore.decRefCount(kref)).toBe(0); + expect(kv.get(`${kref}.refCount`)).toBe('0'); + }); + + it('can decrement below zero (though this should be avoided in practice)', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '0'); + + expect(refCountStore.decRefCount(kref)).toBe(-1); + expect(kv.get(`${kref}.refCount`)).toBe('-1'); + }); + }); + + describe('kernelRefExists', () => { + it('returns false for non-existent references', () => { + expect(refCountStore.kernelRefExists('ko99')).toBe(false); + }); + + it('returns true for existing references with non-zero count', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '5'); + expect(refCountStore.kernelRefExists(kref)).toBe(true); + }); + + it('returns true for existing references with zero count', () => { + const kref: KRef = 'ko1'; + kv.set(`${kref}.refCount`, '0'); + expect(refCountStore.kernelRefExists(kref)).toBe(true); + }); + }); + + describe('integration', () => { + it('supports full reference counting lifecycle', () => { + const kref: KRef = 'ko42'; + + expect(refCountStore.kernelRefExists(kref)).toBe(false); + + kv.set(refCountStore.refCountKey(kref), '0'); + expect(refCountStore.getRefCount(kref)).toBe(0); + + expect(refCountStore.kernelRefExists(kref)).toBe(true); + + expect(refCountStore.incRefCount(kref)).toBe(1); + + refCountStore.incRefCount(kref); + refCountStore.incRefCount(kref); + expect(refCountStore.getRefCount(kref)).toBe(3); + + refCountStore.decRefCount(kref); + expect(refCountStore.getRefCount(kref)).toBe(2); + + refCountStore.decRefCount(kref); + refCountStore.decRefCount(kref); + expect(refCountStore.getRefCount(kref)).toBe(0); + + expect(refCountStore.kernelRefExists(kref)).toBe(true); + }); + + it('works with multiple references simultaneously', () => { + const kref1: KRef = 'ko1'; + const kref2: KRef = 'kp2'; + + kv.set(refCountStore.refCountKey(kref1), '0'); + kv.set(refCountStore.refCountKey(kref2), '0'); + + refCountStore.incRefCount(kref1); + refCountStore.incRefCount(kref2); + refCountStore.incRefCount(kref2); + + expect(refCountStore.getRefCount(kref1)).toBe(1); + expect(refCountStore.getRefCount(kref2)).toBe(2); + + refCountStore.decRefCount(kref1); + expect(refCountStore.getRefCount(kref1)).toBe(0); + expect(refCountStore.getRefCount(kref2)).toBe(2); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/refcount.ts b/packages/kernel/src/store/methods/refcount.ts new file mode 100644 index 000000000..5a8c040d8 --- /dev/null +++ b/packages/kernel/src/store/methods/refcount.ts @@ -0,0 +1,76 @@ +import type { KRef } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +/** + * Create a refcount store object that provides functionality for managing reference counts. + * + * @param ctx - The store context. + * @returns A refcount store object that maps various persistent kernel data + * structures onto `kv`. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getRefCountMethods(ctx: StoreContext) { + /** + * Generate the storage key for a kernel entity's reference count. + * + * @param kref - The KRef of interest. + * @returns the key to store the indicated reference count at. + */ + function refCountKey(kref: KRef): string { + return `${kref}.refCount`; + } + + /** + * Get a kernel entity's reference count. + * + * @param kref - The KRef of interest. + * @returns the reference count of the indicated kernel entity. + */ + function getRefCount(kref: KRef): number { + return Number(ctx.kv.get(refCountKey(kref))); + } + + /** + * Increment a kernel entity's reference count. + * + * @param kref - The KRef of the entity to increment the ref count of. + * @returns the new reference count after incrementing. + */ + function incRefCount(kref: KRef): number { + const key = refCountKey(kref); + const newCount = Number(ctx.kv.get(key)) + 1; + ctx.kv.set(key, `${newCount}`); + return newCount; + } + + /** + * Decrement a kernel entity's reference count. + * + * @param kref - The KRef of the entity to decrement the ref count of. + * @returns the new reference count after decrementing. + */ + function decRefCount(kref: KRef): number { + const key = refCountKey(kref); + const newCount = Number(ctx.kv.get(key)) - 1; + ctx.kv.set(key, `${newCount}`); + return newCount; + } + + /** + * Check if a kernel object exists in the kernel's persistent state. + * + * @param kref - The KRef of the kernel object in question. + * @returns True if the kernel object exists, false otherwise. + */ + function kernelRefExists(kref: KRef): boolean { + return Boolean(ctx.kv.get(refCountKey(kref))); + } + + return { + refCountKey, + getRefCount, + incRefCount, + decRefCount, + kernelRefExists, + }; +} diff --git a/packages/kernel/src/store/methods/vat.test.ts b/packages/kernel/src/store/methods/vat.test.ts new file mode 100644 index 000000000..a462f3a4f --- /dev/null +++ b/packages/kernel/src/store/methods/vat.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { getBaseMethods } from './base.ts'; +import { getVatMethods } from './vat.ts'; +import type { VatConfig, VatId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +vi.mock('./base.ts', () => ({ + getBaseMethods: vi.fn(), +})); + +describe('vat store methods', () => { + let mockKV: Map; + let mockGetPrefixedKeys = vi.fn(); + let context: StoreContext; + let vatMethods: ReturnType; + const vatID1 = 'v1' as VatId; + const vatID2 = 'v2' as VatId; + const vatConfig1: VatConfig = { + name: 'test-vat-1', + path: '/path/to/vat1', + options: { manualStart: true }, + } as unknown as VatConfig; + const vatConfig2: VatConfig = { + name: 'test-vat-2', + path: '/path/to/vat2', + options: { manualStart: false }, + } as unknown as VatConfig; + + beforeEach(() => { + mockKV = new Map(); + mockGetPrefixedKeys = vi.fn(); + + (getBaseMethods as ReturnType).mockReturnValue({ + getPrefixedKeys: mockGetPrefixedKeys, + }); + + context = { + kv: { + get: (key: string): string | undefined => mockKV.get(key), + getRequired: (key: string): string => { + const value = mockKV.get(key); + if (value === undefined) { + throw new Error(`Required key ${key} not found`); + } + return value; + }, + set: (key: string, value: string): void => { + mockKV.set(key, value); + }, + delete: (key: string): void => { + mockKV.delete(key); + }, + }, + } as StoreContext; + + vatMethods = getVatMethods(context); + }); + + describe('getVatConfig', () => { + it('retrieves vat configuration from storage', () => { + mockKV.set(`vatConfig.${vatID1}`, JSON.stringify(vatConfig1)); + + const result = vatMethods.getVatConfig(vatID1); + + expect(result).toStrictEqual(vatConfig1); + }); + + it('throws error if vat configuration does not exist', () => { + expect(() => vatMethods.getVatConfig(vatID1)).toThrow( + 'Required key vatConfig.v1 not found', + ); + }); + }); + + describe('setVatConfig', () => { + it('stores vat configuration in storage', () => { + vatMethods.setVatConfig(vatID1, vatConfig1); + + const storedConfig = JSON.parse( + mockKV.get(`vatConfig.${vatID1}`) as string, + ); + expect(storedConfig).toStrictEqual(vatConfig1); + }); + + it('overwrites existing vat configuration', () => { + mockKV.set(`vatConfig.${vatID1}`, JSON.stringify(vatConfig1)); + + const updatedConfig = { + ...vatConfig1, + name: 'updated-vat', + } as unknown as VatConfig; + + vatMethods.setVatConfig(vatID1, updatedConfig); + + const storedConfig = JSON.parse( + mockKV.get(`vatConfig.${vatID1}`) as string, + ); + expect(storedConfig).toStrictEqual(updatedConfig); + }); + }); + + describe('deleteVatConfig', () => { + it('removes vat configuration from storage', () => { + mockKV.set(`vatConfig.${vatID1}`, JSON.stringify(vatConfig1)); + + vatMethods.deleteVatConfig(vatID1); + + expect(mockKV.has(`vatConfig.${vatID1}`)).toBe(false); + }); + + it('does nothing if vat configuration does not exist', () => { + expect(() => vatMethods.deleteVatConfig(vatID1)).not.toThrow(); + }); + }); + + describe('getAllVatRecords', () => { + it('yields all stored vat records', () => { + mockKV.set(`vatConfig.${vatID1}`, JSON.stringify(vatConfig1)); + mockKV.set(`vatConfig.${vatID2}`, JSON.stringify(vatConfig2)); + + mockGetPrefixedKeys.mockReturnValue([ + `vatConfig.${vatID1}`, + `vatConfig.${vatID2}`, + ]); + + const records = Array.from(vatMethods.getAllVatRecords()); + + expect(records).toStrictEqual([ + { vatID: vatID1, vatConfig: vatConfig1 }, + { vatID: vatID2, vatConfig: vatConfig2 }, + ]); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith('vatConfig.'); + }); + + it('yields an empty array when no vats are configured', () => { + mockGetPrefixedKeys.mockReturnValue([]); + + const records = Array.from(vatMethods.getAllVatRecords()); + + expect(records).toStrictEqual([]); + }); + }); + + describe('deleteEndpoint', () => { + it('deletes all keys related to the endpoint', () => { + const endpointId = 'e1'; + + // Setup mock data + mockKV.set(`cle.${endpointId}.obj1`, 'data1'); + mockKV.set(`cle.${endpointId}.obj2`, 'data2'); + mockKV.set(`clk.${endpointId}.prom1`, 'data3'); + mockKV.set(`e.nextObjectId.${endpointId}`, '10'); + mockKV.set(`e.nextPromiseId.${endpointId}`, '5'); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `cle.${endpointId}.`) { + return [`cle.${endpointId}.obj1`, `cle.${endpointId}.obj2`]; + } + if (prefix === `clk.${endpointId}.`) { + return [`clk.${endpointId}.prom1`]; + } + return []; + }); + + vatMethods.deleteEndpoint(endpointId); + + expect(mockKV.has(`cle.${endpointId}.obj1`)).toBe(false); + expect(mockKV.has(`cle.${endpointId}.obj2`)).toBe(false); + expect(mockKV.has(`clk.${endpointId}.prom1`)).toBe(false); + expect(mockKV.has(`e.nextObjectId.${endpointId}`)).toBe(false); + expect(mockKV.has(`e.nextPromiseId.${endpointId}`)).toBe(false); + + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`cle.${endpointId}.`); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`clk.${endpointId}.`); + }); + + it('does nothing if endpoint has no associated keys', () => { + const endpointId = 'nonexistent'; + + mockGetPrefixedKeys.mockReturnValue([]); + + expect(() => vatMethods.deleteEndpoint(endpointId)).not.toThrow(); + + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`cle.${endpointId}.`); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`clk.${endpointId}.`); + }); + }); +}); diff --git a/packages/kernel/src/store/methods/vat.ts b/packages/kernel/src/store/methods/vat.ts new file mode 100644 index 000000000..faae4787d --- /dev/null +++ b/packages/kernel/src/store/methods/vat.ts @@ -0,0 +1,92 @@ +import { getBaseMethods } from './base.ts'; +import type { EndpointId, VatConfig, VatId } from '../../types.ts'; +import type { StoreContext } from '../types.ts'; + +type VatRecord = { + vatID: VatId; + vatConfig: VatConfig; +}; + +const VAT_CONFIG_BASE = 'vatConfig.'; +const VAT_CONFIG_BASE_LEN = VAT_CONFIG_BASE.length; + +/** + * Get a vat store object that provides functionality for managing vat records. + * + * @param ctx - The store context. + * @returns A vat store object that maps various persistent kernel data + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getVatMethods(ctx: StoreContext) { + const { kv } = ctx; + const { getPrefixedKeys } = getBaseMethods(ctx.kv); + + /** + * Delete all persistent state associated with an endpoint. + * + * @param endpointId - The endpoint whose state is to be deleted. + */ + function deleteEndpoint(endpointId: EndpointId): void { + for (const key of getPrefixedKeys(`cle.${endpointId}.`)) { + kv.delete(key); + } + for (const key of getPrefixedKeys(`clk.${endpointId}.`)) { + kv.delete(key); + } + kv.delete(`e.nextObjectId.${endpointId}`); + kv.delete(`e.nextPromiseId.${endpointId}`); + } + + /** + * Generator that yields the configurations of running vats. + * + * @yields a series of vat records for all configured vats. + */ + function* getAllVatRecords(): Generator { + for (const vatKey of getPrefixedKeys(VAT_CONFIG_BASE)) { + const vatID = vatKey.slice(VAT_CONFIG_BASE_LEN); + const vatConfig = getVatConfig(vatID); + yield { vatID, vatConfig }; + } + } + + /** + * Fetch the stored configuration for a vat. + * + * @param vatID - The vat whose configuration is sought. + * + * @returns the configuration for the given vat. + */ + function getVatConfig(vatID: VatId): VatConfig { + return JSON.parse( + kv.getRequired(`${VAT_CONFIG_BASE}${vatID}`), + ) as VatConfig; + } + + /** + * Store the configuration for a vat. + * + * @param vatID - The vat whose configuration is to be set. + * @param vatConfig - The configuration to write. + */ + function setVatConfig(vatID: VatId, vatConfig: VatConfig): void { + kv.set(`${VAT_CONFIG_BASE}${vatID}`, JSON.stringify(vatConfig)); + } + + /** + * Delete the stored configuration for a vat. + * + * @param vatID - The vat whose configuration is to be deleted. + */ + function deleteVatConfig(vatID: VatId): void { + kv.delete(`${VAT_CONFIG_BASE}${vatID}`); + } + + return { + deleteEndpoint, + getAllVatRecords, + getVatConfig, + setVatConfig, + deleteVatConfig, + }; +} diff --git a/packages/kernel/src/store/types.ts b/packages/kernel/src/store/types.ts new file mode 100644 index 000000000..3a7910a75 --- /dev/null +++ b/packages/kernel/src/store/types.ts @@ -0,0 +1,28 @@ +import type { KVStore } from '@ocap/store'; + +import type { KRef } from '../types.ts'; + +export type StoreContext = { + kv: KVStore; + runQueue: StoredQueue; + runQueueLengthCache: number; + nextObjectId: StoredValue; + nextPromiseId: StoredValue; + nextVatId: StoredValue; + nextRemoteId: StoredValue; + maybeFreeKrefs: Set; + gcActions: StoredValue; + reapQueue: StoredValue; +}; + +export type StoredValue = { + get(): string | undefined; + set(newValue: string): void; + delete(): void; +}; + +export type StoredQueue = { + enqueue(item: object): void; + dequeue(): object | undefined; + delete(): void; +}; diff --git a/packages/kernel/src/utils/key-search.test.ts b/packages/kernel/src/utils/key-search.test.ts new file mode 100644 index 000000000..4bf6fd4e6 --- /dev/null +++ b/packages/kernel/src/utils/key-search.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; + +import { keySearch } from './key-search.ts'; + +describe('keySearch', () => { + it('returns the index of an exact match', () => { + const arr = ['a', 'b', 'c', 'd', 'e']; + expect(keySearch(arr, 'c')).toBe(2); + expect(keySearch(arr, 'a')).toBe(0); + expect(keySearch(arr, 'e')).toBe(4); + }); + + it('returns the index of the first key greater than the search key', () => { + const arr = ['a', 'c', 'e', 'g', 'i']; + expect(keySearch(arr, 'b')).toBe(1); + expect(keySearch(arr, 'd')).toBe(2); + expect(keySearch(arr, 'f')).toBe(3); + }); + + it('returns 0 when the search key is less than the first element', () => { + const arr = ['b', 'c', 'd', 'e']; + expect(keySearch(arr, 'a')).toBe(0); + }); + + it('returns -1 when the search key is greater than the last element', () => { + const arr = ['a', 'b', 'c', 'd']; + expect(keySearch(arr, 'e')).toBe(-1); + expect(keySearch(arr, 'z')).toBe(-1); + }); + + it('handles arrays with a single element', () => { + const arr = ['a']; + expect(keySearch(arr, 'a')).toBe(0); + expect(keySearch(arr, '0')).toBe(0); + expect(keySearch(arr, 'b')).toBe(-1); + }); + + it('handles empty arrays', () => { + const arr: string[] = []; + expect(keySearch(arr, 'a')).toBe(-1); + }); + + it('handles null arrays', () => { + // @ts-expect-error Testing null input for robustness + expect(keySearch(null, 'a')).toBe(-1); + }); + + it('performs correctly with duplicate keys', () => { + const arr = ['a', 'b', 'b', 'b', 'c', 'd']; + const result = keySearch(arr, 'b'); + expect(result === 1 || result === 2 || result === 3).toBe(true); + }); + + it('works with long arrays', () => { + const arr = Array.from({ length: 1000 }, (_, i) => String(i)); + expect(keySearch(arr, '0')).toBe(0); + expect(keySearch(arr, '500')).toBe(500); + expect(keySearch(arr, '999')).toBe(999); + expect(keySearch(arr, '500.5')).toBe(501); + expect(keySearch(arr, '-1')).toBe(0); + const result = keySearch(arr, '1000'); + expect(result === -1 || result < arr.length).toBe(true); + }); + + it('handles edge case where beg equals end in the while loop', () => { + const arr = ['a', 'c', 'e', 'g', 'i']; + expect(keySearch(arr, 'h')).toBe(4); + }); + + it('works with different string lengths', () => { + const arr = ['a', 'bb', 'ccc', 'dddd', 'eeeee']; + expect(keySearch(arr, 'bb')).toBe(1); + expect(keySearch(arr, 'b')).toBe(1); + expect(keySearch(arr, 'cc')).toBe(2); + }); + + it('preserves lexicographic ordering', () => { + const arr = ['1', '10', '2', '20']; + const sorted = arr.slice().sort(); + expect(sorted).toStrictEqual(['1', '10', '2', '20']); + const result = keySearch(sorted, '15'); + expect([-1, 2]).toContain(result); + }); +}); diff --git a/packages/kernel/src/utils/key-search.ts b/packages/kernel/src/utils/key-search.ts index 9bf03b5d8..49669ad7f 100644 --- a/packages/kernel/src/utils/key-search.ts +++ b/packages/kernel/src/utils/key-search.ts @@ -12,31 +12,43 @@ * `key`, or -1 if no such key exists. */ export function keySearch(arr: string[], key: string): number { - if (arr === null) { - // This shouldn't happen, but just in case... + if (arr === null || arr.length === 0) { return -1; } + let beg = 0; let end = arr.length - 1; - if (key < (arr[beg] as string)) { - return beg; + + // Key is less than first element + if (key < (arr[0] as string)) { + return 0; } - if ((arr[end] as string) < key) { + + // Key is greater than last element + if (key > (arr[arr.length - 1] as string)) { return -1; } - while (beg <= end) { + + // Exact match with first element + if (key === arr[0]) { + return 0; + } + + // Binary search algorithm + while (beg < end) { const mid = Math.floor((beg + end) / 2); + + // Exact match if (arr[mid] === key) { return mid; } + if (key < (arr[mid] as string)) { - end = mid - 1; + end = mid; } else { beg = mid + 1; } - if (beg === end) { - return beg; - } } - return -1; + + return beg; } diff --git a/vitest.config.ts b/vitest.config.ts index bb273e399..591352bd4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -88,10 +88,10 @@ export default defineConfig({ lines: 81.03, }, 'packages/kernel/**': { - statements: 82.52, - functions: 90.14, - branches: 65.15, - lines: 82.63, + statements: 85.02, + functions: 91.46, + branches: 71.45, + lines: 85.13, }, 'packages/nodejs/**': { statements: 72.91,