UNPKG

@sha1n/fungus

Version:

A dependency based service graph controller library

214 lines (169 loc) 6.22 kB
import { v4 as uuid } from 'uuid'; import createDAG from '@sha1n/dagraph'; import { createLogger, Logger } from './logger'; import { ServiceController } from './ServiceController'; import { ServiceSpec, Environment, RuntimeContext, Service, ServiceId, ServiceMetadata } from './types'; class InternalRuntimeContext implements RuntimeContext { private readonly _catalog = new Map<ServiceId, ServiceMetadata>(); shuttingDown: boolean; constructor(readonly name: string) {} get catalog(): ReadonlyMap<ServiceId, ServiceMetadata> { return this._catalog; } register(metadata: ServiceMetadata): void { this._catalog.set(metadata.id, metadata); } unregister(id: ServiceId): void { this._catalog.delete(id); } } type EnvState = { start(): Promise<[RuntimeContext, StartedEnv]>; stop(): Promise<StoppedEnv>; }; class StoppedEnv { private readonly logger: Logger; private readonly ctx: InternalRuntimeContext; private readonly servicesGraph: ServiceGraph; constructor(private readonly specs: readonly ServiceSpec[], name: string) { this.logger = createLogger(name); this.ctx = new InternalRuntimeContext(name); this.servicesGraph = this.init(); } async start(): Promise<[RuntimeContext, StartedEnv]> { const outCtx = await this.doStart(this.servicesGraph, this.ctx); return [outCtx, this.startedEnv()]; } async stop(): Promise<StoppedEnv> { return Promise.reject(new Error('Not started')); } private async doStart(serviceGraph: ServiceGraph, ctx: InternalRuntimeContext): Promise<RuntimeContext> { this.logger.info('starting up...'); const allStartedPromise = new Promise<RuntimeContext>((resolve, reject) => { const services = serviceGraph.getServices(); const onError = (error: Error) => { ctx.shuttingDown = true; reject(error); }; for (const service of services) { service.prependOnceListener('error', onError); service.prependOnceListener('started', (metadata: ServiceMetadata, ctx: InternalRuntimeContext) => { // 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); } }); } 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; } } private startedEnv(): StartedEnv { return new StartedEnv(this.servicesGraph, this.specs, this.ctx.name); } private init(): ServiceGraph { const servicesGraph = new ServiceGraph(); const register = (service: Service, dependencies?: ReadonlyArray<Service>) => { 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 { private readonly logger: Logger; private readonly ctx: InternalRuntimeContext; constructor( private readonly servicesGraph: ServiceGraph, private readonly specs: readonly ServiceSpec[], name: string ) { this.logger = createLogger(name); this.ctx = new InternalRuntimeContext(name); } async start(): Promise<[RuntimeContext, StartedEnv]> { return Promise.reject(new Error('Already started')); } async stop(): Promise<StoppedEnv> { await this.doStop(this.ctx); return new StoppedEnv(this.specs, this.ctx.name); } private async doStop(ctx: InternalRuntimeContext): Promise<void> { 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: Array<Error> = []; 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 { private readonly logger = createLogger('srv-graph'); private readonly graph = createDAG<ServiceController>(); addService(service: Service): void { this.graph.addNode(this.getOrCreateControllerFor(service)); } addDependency(service: Service, dependency: Service): void { 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(): readonly ServiceController[] { return [...this.graph.nodes()]; } getBootstrapServices(): readonly ServiceController[] { return [...this.graph.roots()]; } getTeardownServices(): readonly ServiceController[] { return [...this.graph.reverse().topologicalSort()]; } private getOrCreateControllerFor(service: Service): ServiceController { return this.graph.getNode(service.id) || new ServiceController(service); } } function createEnvironment(specs: readonly ServiceSpec[], options?: { name?: string }): Environment { const name = options?.name || `env-${uuid()}`; const stoppedEnv = new StoppedEnv(specs, name); let env: EnvState = stoppedEnv; return { name, start: async () => { const [ctx, startedEnv] = await env.start(); env = startedEnv; return ctx; }, stop: async () => { env = await env.stop(); } }; } export { InternalRuntimeContext, createEnvironment };