@darlean/core
Version:
Darlean core functionality for creating applications that define, expose and host actors
643 lines (642 loc) • 28 kB
JavaScript
"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 ?)`);
}
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;
}