@sha1n/fungus
Version:
A dependency based service graph controller library
214 lines (169 loc) • 6.22 kB
text/typescript
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 };