UNPKG

@darlean/core

Version:

Darlean core functionality for creating applications that define, expose and host actors

643 lines (642 loc) 28 kB
"use strict"; /* eslint-disable @typescript-eslint/ban-types */ /** * Contains types and functions to define (implement) and call actor instances locally (within * the same process). * * Actors are just plain classes with public async action methods, decorated with {@link action|@action}. They can * optionally implement {@link IActivatable} by adding an async {@link IActivatable.activate} method * and/or {@link IDeactivatable} by adding an async {@link IDeactivatable.deactivate} method. * * Although it is possible to instantiate and/or invoke such an actor instance directly by calling * its methods, the locking and automatic activation/deactivation that normally takes place is * then bypassed. Therefore, it is not recommended to directly create and invoke actor instances * (except, for example, for unit tests that do not require this additional behaviour). * * The prefered way of invoking actor instances is by means of an {@link InstanceWrapper}, either * directly by creating a new {@link InstanceWrapper} around a certain actor instance, or by creating * an {@link InstanceContainer} with an {@link InstanceCreator} that creates new instances * on the fly (and also deactivates instances when the configured container capacity is exceeded). * * Both {@link InstanceWrapper} and {@link InstanceContainer} respect the configured locking and * activation/deactivation mechanisms (by means of the {@link action|@action}, {@link actor|@action} * and {@link service|@service} decorators). * * @packageDocumentation */ Object.defineProperty(exports, "__esModule", { value: true }); exports.toActionError = exports.toFrameworkError = exports.VolatileTimer = exports.InstanceWrapper = exports.MultiTypeInstanceContainer = exports.InstanceContainer = exports.FRAMEWORK_ERROR_APPLICATION_ERROR = void 0; const utils_1 = require("@darlean/utils"); const various_1 = require("./various"); const shared_1 = require("./shared"); const events_1 = require("events"); const base_1 = require("@darlean/base"); const ACTIVATOR = 'ACTIVATOR'; const DEACTIVATOR = 'DEACTIVATOR'; const ACTIVATE_METHOD = 'activate'; const DEACTIVATE_METHOD = 'deactivate'; exports.FRAMEWORK_ERROR_APPLICATION_ERROR = 'APPLICATION_ERROR'; /** * Container for instances of a certain type T. The container acts as a cache * for already created instances up to a certain capacity. Instances * are removed by means of a LRU policy. */ class InstanceContainer { constructor(actorType, creator, capacity, actorLock, time, maxAgeSeconds) { this.time = time; this.maxAgeSeconds = maxAgeSeconds; this.finalizing = false; this.actorType = (0, shared_1.normalizeActorType)(actorType); this.creator = creator; this.capacity = capacity; this.maxAgeSeconds = maxAgeSeconds; this.instances = new Map(); this.cleaning = new Map(); this.callCounter = 0; this.actorLock = actorLock; } async delete(id) { const idt = (0, various_1.idToText)(id); return await this.deleteImpl(idt); } obtain(id, lazy) { return this.wrapper(id, lazy).getProxy(); } /** * * @param id * @returns * @throws {@link FrameworkError} when something goes wrong. */ wrapper(id, lazy) { const idt = (0, various_1.idToText)(id); const current = this.instances.get(idt); if (current) { return current; } if (lazy) { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_LAZY_REFUSE, 'Refused to create new instance of [ActorType] because the lazy flag is set on the request', { ActorType: this.actorType }); } // TODO: Is this correct here? Should we not return a proxy, and should the proxy // not throw an error when metods are invoked while finalizing?? if (this.finalizing) { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_FINALIZING, 'Not allowed to create new instance of [ActorType] because the container is finalizing', { ActorType: this.actorType }); } const instanceinfo = this.creator(id); const actorLock = this.actorLock; const instanceWrapperActorLock = actorLock ? (onBroken) => actorLock.acquire([this.actorType, ...id], onBroken) : undefined; const wrapper = new InstanceWrapper(this.actorType, instanceinfo.instance, instanceWrapperActorLock, this.time, this.maxAgeSeconds); wrapper?.on('deactivated', () => { this.instances.delete(idt); this.cleaning.delete(idt); //if (instanceinfo.afterFinalize) { // instanceinfo.afterFinalize(wrapper); //} }); if (instanceinfo.afterCreate) { instanceinfo.afterCreate(wrapper); } this.instances.set(idt, wrapper); this.cleanup(); return wrapper; } async finalize() { // console.log('Finalizing container', this.actorType); this.finalizing = true; // Make a copy of this.instances to avoid the deletion operations to have impact on the // iterator like missing items. Because finalizing = true, we should not add items anymore // to this.instances. const keys = Array.from(this.instances.keys()); for (const instance of keys) { await this.deleteImpl(instance); } } async deleteImpl(id) { const instance = this.instances.get(id); if (instance) { // console.log('Deleting instance', this.actorType, id); try { await instance.deactivate(); // console.log('Deleted instance', this.actorType, id); } catch (e) { console.log('Error deleting instance', this.actorType, id, e); } } } cleanup() { if (this.instances.size - this.cleaning.size > this.capacity) { for (const id of this.instances.keys()) { if (this.instances.size - this.cleaning.size <= this.capacity) { break; } // We add selected instances to the "cleaning" map, and perform asynchronous // cleaning. Once cleaning of an instance is complete, it is removed from the // this.cleaning and this.instances administrations. if (!this.cleaning.has(id)) { this.cleaning.set(id, true); setImmediate(async () => { try { await this.deleteImpl(id); } catch (e) { console.log('Error during finalizing', e); } }); } } } } } exports.InstanceContainer = InstanceContainer; /** * Implementation of {@link IMultiTypeInstanceContainer}. */ class MultiTypeInstanceContainer { constructor() { this.finalizing = false; this.containers = new Map(); } register(type, container) { if (this.finalizing) { throw new Error('Registering a new container is not allowed while finalizing'); } this.containers.set((0, shared_1.normalizeActorType)(type), container); } /** * Returns a proxy to an instance with the specified type and id. * @throws {@link FrameworkError} with code {@link FRAMEWORK_ERROR_UNKNOWN_ACTOR_TYPE} * when the actor type is unknown */ obtain(type, id, lazy) { const container = this.containers.get(type); if (container) { return container.obtain(id, lazy); } else { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_UNKNOWN_ACTOR_TYPE, 'Actor type [ActorType] is unknown', { ActorType: type }); } } /** * Returns an {@link IInstanceWrapper} for the specified type and id. * @throws {@link FrameworkError} with code {@link FRAMEWORK_ERROR_UNKNOWN_ACTOR_TYPE} * when the actor type is unknown */ wrapper(type, id, lazy) { const container = this.containers.get(type); if (container) { return container.wrapper(id, lazy); } else { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_UNKNOWN_ACTOR_TYPE, 'Actor type [ActorType] is unknown', { ActorType: type }); } } async finalize() { if (this.finalizing) { return; } this.finalizing = true; const containers = Array.from(this.containers.values()).reverse(); for (const container of containers) { await container.finalize(); } } } exports.MultiTypeInstanceContainer = MultiTypeInstanceContainer; /** * Wrapper around an instance of type T. The wrapper understands class and method * decorations, and applies the proper locking and activation/deactivation of the * instance. */ class InstanceWrapper extends events_1.EventEmitter { /** * Creates a new wrapper around the provided instance of type T. * @param instance The instance around which the wrapper should be created. * @throws {@link FrameworkError} with code {@link FRAMEWORK_ERROR_UNKNOWN_ACTION} * when methods on this object are invokes that do not exist in the underlying instance. */ constructor(actorType, instance, actorLock, time, maxAgeSeconds) { super(); this.actorType = actorType; this.instance = instance; // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.state = 'created'; this.actorLock = actorLock; this.lifecycleMutex = new utils_1.Mutex(); this.methods = this.obtainMethods(); const p = Proxy.revocable(instance, { get: (target, prop) => { const name = (0, shared_1.normalizeActionName)(prop.toString()); if (name === 'then') { // Otherwise, js thinks we are a promise and invokes "then" upon us when the proxy // is awaited as the return value of an async function. return undefined; } const func = this.methods.get(name); if (func) { return function (...args) { return self.handleCall(func, name, args); }; } else { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_UNKNOWN_ACTION, 'Action [ActionName] does not exist on [ActorType]', { ActorType: this.actorType, ActionName: name }); } } }); this.proxy = p.proxy; this.revoke = p.revoke; this.lock = new utils_1.SharedExclusiveLock('exclusive'); this.callCounter = 0; if (maxAgeSeconds !== undefined && time) { this.finalizeTimer = time.repeat(async () => { try { this.deactivate(); } catch (e) { (0, utils_1.currentScope)().info('Error during automatic deactivation of actor of type [ActorType] after [MaxAge] seconds: [Error]', () => ({ Error: e, ActorType: this.actorType, MaxAge: maxAgeSeconds })); } }, 'Actor auto finalize', -1, maxAgeSeconds * 1000, 0); } } /** * Performs deactivation of the underlying instance (which includes invoking the {@link IDeactivatable.deactivate}) * method when it exists and waiting for it to complete) and then invalidates the internal proxy (that could have previously been * obtained via {@link getProxy}), so that all future requests to the proxy raise an exception. Once deactivated, deactivation cannot be undone. */ deactivate(skipMutex = false) { return (0, utils_1.deeper)('io.darlean.instances.deactivate').perform(async () => { if (!skipMutex) { (0, utils_1.deeper)('io.darlean.instances.try-acquire-lifecycle-mutex').performSync(() => this.lifecycleMutex.tryAcquire()) || (await (0, utils_1.deeper)('io.darlean.instances.acquire-lifecycle-mutex').perform(() => this.lifecycleMutex.acquire())); } this.finalizeTimer?.cancel(); try { if (this.state === 'created') { return; } if (this.state !== 'inactive') { this.state = 'deactivating'; try { const func = this.methods.get(DEACTIVATOR); await this.handleCall(func, DEACTIVATOR, [], { locking: 'exclusive' }, true); } finally { this.revoke(); this.state = 'inactive'; await this.releaseActorLock(); this.emit('deactivated'); } } } finally { if (!skipMutex) { this.lifecycleMutex.release(); } } }); } /** * * @returns Returns a reference to the proxy that can be used to invoke methods on the underlying * instance until {@link deactivate} is invoked. After that, the proxy does not invoke the underlying * instance anymore but only throws exceptions. */ getProxy() { return this.proxy; } getInstance() { return this.instance; } async invoke(method, args) { if (typeof method === 'string') { const name = (0, shared_1.normalizeActionName)(method); method = this.methods.get(name); if (!method) { throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_UNKNOWN_ACTION, 'Action [ActionName] does not exist on [ActorType]', { ActorType: this.actorType, ActionName: name }); } } return this.handleCall(method, typeof method === 'string' ? method : method?.name ?? '', args); } handleCall(method, actionName, args, defaultConfig, shortCircuit = false) { return (0, utils_1.deeper)('io.darlean.instances.handle-call', `${this.actorType}::${method?.name}`).perform(async () => { const config = method?._darlean_options; if (!config && !defaultConfig && method !== undefined) { // Only accept calls to methods that are explicitly marked to be an action. This is // a security measure that stops unintended access to methods that are not intended // to be invoked as action. throw new Error(`Method [${method?.name}] is not an action (is it properly decorated with @action?)`); } const locking = config?.locking ?? defaultConfig?.locking ?? 'exclusive'; const callId = this.callCounter.toString(); this.callCounter++; if (!shortCircuit) { (0, utils_1.deeper)('io.darlean.instances.try-ensure-active').performSync(() => this.tryEnsureActive()) || (await (0, utils_1.deeper)('io.darlean.instances.ensure-active').perform(() => this.ensureActive())); } (0, utils_1.deeper)('io.darlean.instances.try-acquire-local-lock').performSync(() => this.tryAcquireLocalLock(locking, callId)) || (await (0, utils_1.deeper)('io.darlean.instances.acquire-local-lock').perform(() => this.acquireLocalLock(locking, callId))); try { if (method) { // Invoke the actual method on the underlying instance const scope = actionName === ACTIVATOR ? 'actor.invoke-activator' : actionName === DEACTIVATOR ? 'actor.invoke-deactivator' : 'actor.invoke-action'; // We cannot remove the 'await' here because of the surrounding catch clause that then // is not triggered anymore and the surrounding finally. return await (0, utils_1.deeper)(scope, `${this.actorType}::${actionName}`).perform(() => method.apply(this.instance, args)); } } catch (e) { if (e instanceof base_1.FrameworkError) { // When a migration error occurs within the application code, we must literally forward it as // framework error (otherwise receiving side will not properly perform a retry on a modern node). // Because migration errors in remote invocations within the method call are already converted to // framework errors with code FRAMEWORK_ERROR_INVOKE_ERROR (see RemotePortal.retrieve), we have no // risk of accidentally reporting such false migration errors here. if (e.code === base_1.FRAMEWORK_ERROR_MIGRATION_ERROR) { throw e; } } throw (0, base_1.toApplicationError)(e); } finally { this.releaseLocalLock(locking, callId); } }); } tryEnsureActive() { if (!this.lifecycleMutex.tryAcquire()) { return false; } try { if (this.state === 'active') { return true; } return false; } finally { this.lifecycleMutex.release(); } } async ensureActive() { (0, utils_1.deeper)('io.darlean.instances.try-acquire-lifecycle-mutex').performSync(() => this.lifecycleMutex.tryAcquire()) || (await (0, utils_1.deeper)('io.darlean.instances.acquire-lifecycle-mutex').perform(() => this.lifecycleMutex.acquire())); try { if (this.state === 'created') { this.state = 'activating'; try { (0, utils_1.deeper)('io.darlean.instances.try-ensure-actor-lock').performSync(() => this.tryEnsureActorLock()) || (await (0, utils_1.deeper)('io.darlean.instances.ensure-actor-lock').perform(() => this.ensureActorLock())); const func = this.methods.get(ACTIVATOR); if (func) { await this.handleCall(func, ACTIVATOR, [], { locking: 'exclusive' }, true); } this.state = 'active'; } catch (e) { (0, utils_1.currentScope)().error('Error during activate: [Error]', () => ({ Error: e })); await this.deactivate(true); throw e; } } if (this.state === 'inactive') { // TODO Move this code to perform_call because it first has to acquire action lock, and in between, // state may have been changed throw new base_1.FrameworkError(base_1.FRAMEWORK_ERROR_INCORRECT_STATE, 'It is not allowed to execute an action when the state is [State]', { State: this.state }); } } finally { (0, utils_1.deeper)('io.darlean.instances.release-lifecycle-mutex').performSync(() => this.lifecycleMutex.release()); } } tryEnsureActorLock() { // Assume we are already in the lifecycleLock const actorLock = this.actorLock; if (!actorLock) { return true; } if (this.acquiredActorLock) { return true; } if (!this.actorLock) { throw new Error('No actor lock available, instance likely to be deactivated'); } return false; } async ensureActorLock() { // Assume we are already in the lifecycleLock if (this.tryEnsureActorLock()) { return; } const actorLock = this.actorLock; if (actorLock) { this.acquiredActorLock = await actorLock(() => { this.actorLock = undefined; setImmediate(async () => { try { // Note: Deactivate will release the obtained action lock (we do not have to do that fro here). await this.deactivate(); } catch (e) { (0, utils_1.currentScope)().info('Error during deactivating actor of type [ActorType] because the actor lock was broken: [Error]', () => ({ Error: e, ActorType: this.actorType })); } }); }); } } async releaseActorLock() { // Assume we are already in the lifecycle mutex const acquiredActorLock = this.acquiredActorLock; if (!acquiredActorLock) { return; } this.acquiredActorLock = undefined; this.actorLock = undefined; try { await acquiredActorLock.release(); } catch (e) { (0, utils_1.currentScope)().info('Error during release of actor lock for an instance of type [ActorType]: [Error]', () => ({ Error: e, ActorType: this.actorType })); } } tryAcquireLocalLock(locking, callId) { if (locking === 'shared') { return this.lock.tryBeginShared(callId); } else if (locking === 'exclusive') { return this.lock.tryBeginExclusive(callId); } else { return true; } } async acquireLocalLock(locking, callId) { if (locking === 'shared') { await this.lock.beginShared(callId); } else if (locking === 'exclusive') { await this.lock.beginExclusive(callId); } } releaseLocalLock(locking, callId) { if (locking === 'shared') { this.lock.endShared(callId); } else if (locking === 'exclusive') { this.lock.endExclusive(callId); } } obtainMethods() { const prototype = Object.getPrototypeOf(this.instance); if (!prototype._darlean_methods) { const m = new Map(); const methodNames = getMethods(prototype); for (const methodName of methodNames) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const func = prototype[methodName]; const config = func._darlean_options; if (config) { if (config.kind === 'action') { const name = config.name || methodName; const normalized = (0, shared_1.normalizeActionName)(name); m.set(normalized, func); } else if (config.kind === 'activator') { m.set(ACTIVATOR, func); } else if (config.kind === 'deactivator') { m.set(DEACTIVATOR, func); } } } if (!m.has(ACTIVATOR) && methodNames.includes(ACTIVATE_METHOD)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any m.set(ACTIVATOR, prototype[ACTIVATE_METHOD]); } if (!m.has(DEACTIVATOR) && methodNames.includes(DEACTIVATE_METHOD)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any m.set(DEACTIVATOR, prototype[DEACTIVATE_METHOD]); } prototype._darlean_methods = m; return m; } return prototype._darlean_methods; } } exports.InstanceWrapper = InstanceWrapper; class VolatileTimer { constructor(time, wrapper) { this.time = time; this.wrapper = wrapper; } setWrapper(wrapper) { this.wrapper = wrapper; } once(handler, delay, args) { return this.repeat(handler, 0, delay, 0, args); } repeat(handler, interval, delay, repeatCount, args) { const timer = this.time.repeat(async () => { try { //console.log(`Invoking timer for [${handler.name}] with args [${args}]`, !!this.wrapper); await this.wrapper?.invoke(handler, args ?? []); } catch (e) { console.log(`Error in executing volatile timer for [${handler.name}]: ${e}`); } }, handler.name, interval, delay, repeatCount); this.wrapper?.on('deactivated', () => { timer.cancel(); }); return { cancel: () => timer.cancel(), pause: (duration) => timer.pause(duration), resume: (delay) => timer.resume(delay) }; } } exports.VolatileTimer = VolatileTimer; function toFrameworkError(e) { if (e instanceof base_1.FrameworkError) { return e; } if (e instanceof base_1.ApplicationError) { return new base_1.FrameworkError(exports.FRAMEWORK_ERROR_APPLICATION_ERROR, e.code, undefined, e.stack, [e], e.message); } if (typeof e === 'object') { const err = e; return new base_1.FrameworkError(err.name, undefined, undefined, err.stack, undefined, err.message); } else if (typeof e === 'string') { if (e.includes(' ')) { return new base_1.FrameworkError(base_1.APPLICATION_ERROR_UNEXPECTED_ERROR, e); } else { return new base_1.FrameworkError(e, e); } } else { return new base_1.FrameworkError(base_1.APPLICATION_ERROR_UNEXPECTED_ERROR, 'Unexpected error'); } } exports.toFrameworkError = toFrameworkError; function toActionError(e) { if (e instanceof base_1.FrameworkError) { return e; } if (e instanceof base_1.ApplicationError) { return e; } if (typeof e === 'object') { const err = e; return new base_1.FrameworkError(err.name, undefined, undefined, err.stack, undefined, err.message); } else if (typeof e === 'string') { if (e.includes(' ')) { return new base_1.FrameworkError(base_1.APPLICATION_ERROR_UNEXPECTED_ERROR, e); } else { return new base_1.FrameworkError(e, e); } } else { return new base_1.FrameworkError(base_1.APPLICATION_ERROR_UNEXPECTED_ERROR, 'Unexpected error'); } } exports.toActionError = toActionError; // eslint-disable-next-line @typescript-eslint/no-explicit-any function getMethods(obj) { const methods = []; do { for (const prop of Object.getOwnPropertyNames(obj)) { if (obj[prop] instanceof Function) methods.push(prop); } obj = Object.getPrototypeOf(obj); } while (obj !== null); return methods; }