@sha1n/fungus
Version:
A dependency based service graph controller library
178 lines (177 loc) • 6 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InternalRuntimeContext = void 0;
exports.createEnvironment = createEnvironment;
const uuid_1 = require("uuid");
const dagraph_1 = __importDefault(require("@sha1n/dagraph"));
const logger_1 = require("./logger");
const ServiceController_1 = require("./ServiceController");
class InternalRuntimeContext {
name;
_catalog = new Map();
shuttingDown;
constructor(name) {
this.name = name;
}
get catalog() {
return this._catalog;
}
register(metadata) {
this._catalog.set(metadata.id, metadata);
}
unregister(id) {
this._catalog.delete(id);
}
}
exports.InternalRuntimeContext = InternalRuntimeContext;
class StoppedEnv {
specs;
logger;
ctx;
servicesGraph;
constructor(specs, name) {
this.specs = specs;
this.logger = (0, logger_1.createLogger)(name);
this.ctx = new InternalRuntimeContext(name);
this.servicesGraph = this.init();
}
async start() {
const outCtx = await this.doStart(this.servicesGraph, this.ctx);
return [outCtx, this.startedEnv()];
}
async stop() {
return Promise.reject(new Error('Not started'));
}
async doStart(serviceGraph, ctx) {
this.logger.info('starting up...');
const allStartedPromise = new Promise((resolve, reject) => {
const services = serviceGraph.getServices();
const onError = (error) => {
ctx.shuttingDown = true;
reject(error);
};
for (const service of services) {
service.prependOnceListener('error', onError);
service.prependOnceListener('started', (metadata, ctx) => {
// This is critical to avoid handling errors that occur after startup
service.removeListener('error', onError);
ctx.register(metadata);
if (ctx.catalog.size === services.length) {
resolve(ctx);
}
});
}
void Promise.allSettled(serviceGraph.getBootstrapServices().map(s => s.start(ctx)));
});
try {
const result = await allStartedPromise;
return result;
}
catch (e) {
await this.startedEnv().stop().catch(this.logger.error);
throw e;
}
}
startedEnv() {
return new StartedEnv(this.servicesGraph, this.specs, this.ctx.name);
}
init() {
const servicesGraph = new ServiceGraph();
const register = (service, dependencies) => {
this.logger.info('registering service %s', service.id);
servicesGraph.addService(service);
dependencies?.forEach(dep => {
servicesGraph.addDependency(service, dep);
});
};
for (const spec of this.specs) {
register(spec.service, spec.dependsOn);
}
return servicesGraph;
}
}
class StartedEnv {
servicesGraph;
specs;
logger;
ctx;
constructor(servicesGraph, specs, name) {
this.servicesGraph = servicesGraph;
this.specs = specs;
this.logger = (0, logger_1.createLogger)(name);
this.ctx = new InternalRuntimeContext(name);
}
async start() {
return Promise.reject(new Error('Already started'));
}
async stop() {
await this.doStop(this.ctx);
return new StoppedEnv(this.specs, this.ctx.name);
}
async doStop(ctx) {
this.logger.info('stopping...');
// The teardown algorithm traverses all the services in reverse topological order
// to ensure that we stop as clean as possible, even if something fails
const errors = [];
for (const service of this.servicesGraph.getTeardownServices()) {
await service
.stop(ctx)
.catch(e => {
errors.push(e);
return Promise.resolve();
})
.finally(() => {
ctx.unregister(service.id);
});
}
if (errors.length > 0) {
throw new Error(errors.map(e => e.message).join('\n'));
}
}
}
class ServiceGraph {
logger = (0, logger_1.createLogger)('srv-graph');
graph = (0, dagraph_1.default)();
addService(service) {
this.graph.addNode(this.getOrCreateControllerFor(service));
}
addDependency(service, dependency) {
const srvController = this.getOrCreateControllerFor(service);
const depController = this.getOrCreateControllerFor(dependency);
this.logger.info('adding dependency: %s depends on %s', service.id, depController.id);
this.graph.addEdge(depController, srvController);
// This is required in order to allow the controller to start once all deps are started.
srvController.addDependency(depController);
}
getServices() {
return [...this.graph.nodes()];
}
getBootstrapServices() {
return [...this.graph.roots()];
}
getTeardownServices() {
return [...this.graph.reverse().topologicalSort()];
}
getOrCreateControllerFor(service) {
return this.graph.getNode(service.id) || new ServiceController_1.ServiceController(service);
}
}
function createEnvironment(specs, options) {
const name = options?.name || `env-${(0, uuid_1.v4)()}`;
const stoppedEnv = new StoppedEnv(specs, name);
let env = stoppedEnv;
return {
name,
start: async () => {
const [ctx, startedEnv] = await env.start();
env = startedEnv;
return ctx;
},
stop: async () => {
env = await env.stop();
}
};
}