moleculer-api
Version:
A dynamic API Gateway for MoleculerJS which updates REST endpoints and aggregated GraphQL schema, access control policy for each action calls from metadata of remote services schema without restart or deployment.
405 lines • 19.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Branch = void 0;
const tslib_1 = require("tslib");
const async_lock_1 = tslib_1.__importDefault(require("async-lock"));
const events_1 = require("events");
const kleur = tslib_1.__importStar(require("kleur"));
const _ = tslib_1.__importStar(require("lodash"));
const broker_1 = require("../broker");
const interface_1 = require("../interface");
const catalog_1 = require("./catalog");
const integration_1 = require("./integration");
const version_1 = require("./version");
const { Add, Remove } = integration_1.ServiceAPIIntegration.Type;
class Branch {
constructor(props, opts) {
this.props = props;
this.emitter = new events_1.EventEmitter().setMaxListeners(1);
/* garbage detector */
this.latestUsedAt = new Date();
// setup initial or forked branch
this.serviceCatalog = props.serviceCatalog || new catalog_1.ServiceCatalog();
this.$latestVersion = props.parentVersion || version_1.Version.initialVersion;
// forget it
delete props.serviceCatalog;
delete props.parentVersion;
// adjust options
this.opts = _.defaultsDeep(opts || {}, {
maxVersions: 10,
maxUnusedSeconds: 60 * 30,
});
this.opts.maxVersions = isNaN(this.opts.maxVersions) ? 10 : Math.max(this.opts.maxVersions, 1);
this.opts.maxUnusedSeconds = isNaN(this.opts.maxUnusedSeconds) ? 60 * 30 : Math.max(this.opts.maxUnusedSeconds, 1);
}
toString() {
return `${kleur.bold(kleur.cyan(this.name))} ${kleur.cyan(`(${this.serviceCatalog.size} services)`)}`;
}
getInformation(includeServices = false) {
return {
branch: this.name,
latestUsedAt: this.latestUsedAt,
parentVersion: this.props.parentVersion ? this.props.parentVersion.shortHash : null,
latestVersion: this.$latestVersion.shortHash,
versions: this.versions.map(v => v.information),
services: includeServices ? this.serviceCatalog.services.map(service => service.getInformation(true)) : null,
};
}
;
get name() {
return this.props.name;
}
get isMaster() {
return this.name === Branch.Master;
}
get isUnused() {
return this.unusedSeconds > this.opts.maxUnusedSeconds;
}
get unusedSeconds() {
return Math.floor((new Date().getTime() - this.latestUsedAt.getTime()) / 1000);
}
touch() {
this.latestUsedAt = new Date();
}
/* services */
get services() {
return this.serviceCatalog.services;
}
/* versions */
get latestVersion() {
return this.$latestVersion;
}
get versions() {
const versions = [];
let version = this.$latestVersion;
do {
versions.push(version);
version = version.parentVersion;
} while (version);
return versions;
}
fork(props) {
// wait until queue empty and all on-going integration be progressed
return Branch.lock.acquire(props.name, () => {
return Branch.lock.acquire(this.name, () => {
return new Branch(Object.assign(Object.assign({}, this.props), { name: props.name, logger: props.logger, parentVersion: this.$latestVersion, serviceCatalog: this.serviceCatalog.clone() }), this.opts);
});
});
}
/*
service discovery priority rule:
master branch can see and prefer: master-branched > non-branched services
other branch can see and prefer: own-branched > master-branched > non-branched services
*/
connectService(service, integration) {
return Branch.lock.acquire(this.name, () => {
let priority;
if (!integration) {
priority = 1;
}
else if (integration.schema.branch === Branch.Master) {
priority = 2;
}
else if (integration.schema.branch === this.name) {
priority = 3;
}
else {
this.props.logger.info(`${this} branch ignores ${service} service connection`);
return;
}
// update service catalog
const oldItem = this.serviceCatalog.get(service.id);
this.serviceCatalog.add({ service, integration, priority });
const newItem = this.serviceCatalog.get(service.id);
// on preferred service changes
if (oldItem !== newItem) {
if (oldItem) {
this.props.logger.info(`${this} branch replaced ${oldItem.service} => ${newItem.service} service`);
}
else {
this.props.logger.info(`${this} branch added ${newItem.service} service`);
}
// integrate API if has
const integrations = [];
if (oldItem && oldItem.integration) {
integrations.push(new integration_1.ServiceAPIIntegration({
type: Remove,
serviceCatalog: this.serviceCatalog,
source: oldItem.integration,
}));
}
if (newItem.integration) {
integrations.push(new integration_1.ServiceAPIIntegration({
type: Add,
serviceCatalog: this.serviceCatalog,
source: newItem.integration,
}));
}
if (integrations.length > 0) {
return this.consumeIntegrations(integrations);
}
}
});
}
disconnectService(service) {
return Branch.lock.acquire(this.name, () => tslib_1.__awaiter(this, void 0, void 0, function* () {
const oldItem = this.serviceCatalog.get(service.id);
if (!this.serviceCatalog.remove(service)) {
this.props.logger.info(`${this} branch ignores ${service} service disconnection`);
return;
}
this.props.logger.info(`${this} branch disconnected ${oldItem.service} service`);
const newItem = this.serviceCatalog.get(service.id);
// assert: preferred service changed or all the service connections has been removed
console.assert(oldItem !== newItem);
if (newItem) {
this.props.logger.info(`${this} branch replaced ${oldItem.service} => ${newItem.service} service`);
}
else {
this.props.logger.info(`${this} branch removed ${oldItem.service} service`);
}
// integrate API if has
const integrations = [];
if (oldItem.integration) {
integrations.push(new integration_1.ServiceAPIIntegration({
type: Remove,
serviceCatalog: this.serviceCatalog,
source: oldItem.integration,
}));
}
if (newItem && newItem.integration) {
integrations.push(new integration_1.ServiceAPIIntegration({
type: Add,
serviceCatalog: this.serviceCatalog,
source: newItem.integration,
}));
}
if (integrations.length > 0) {
yield this.consumeIntegrations(integrations);
}
}));
}
/* schema integration */
consumeIntegrations(integrations, initialCompile = false) {
var _a;
return tslib_1.__awaiter(this, void 0, void 0, function* () {
try {
const parentVersion = this.$latestVersion;
// get queue jobs and filter add/remove pairs
const compensatedIntegrations = integrations.filter(integration => {
return integrations
.some(int => int.schemaHash === integration.schemaHash && (int.type === Add && integration.type === Remove || int.type === Remove && integration.type === Add));
});
if (compensatedIntegrations.length > 0) {
for (const integration of compensatedIntegrations) {
integrations.splice(integrations.indexOf(integration), 1);
integration.setSkipped(this, parentVersion);
}
this.props.logger.info(`${this} branch will skip to integrate complementary integrations:\n${compensatedIntegrations.join("\n")}`);
}
// retry failed jobs or finish
if (integrations.length === 0 && !initialCompile) {
return this.retryFailedIntegrationsFrom(parentVersion);
}
// create new schemaHashMap and pick up required schemata to compile
const { schemaHashMap, routeHashMapCache } = parentVersion.getChildVersionProps();
let shouldCompile = initialCompile;
for (const integration of integrations) {
if (integration.type === Add) {
for (const [hash, oldIntegration] of schemaHashMap.entries()) {
if (oldIntegration.service.id === integration.service.id) {
schemaHashMap.delete(hash);
this.props.logger.info(`${this} branch will remove duplicate old integration: ${oldIntegration.service.toString()}`);
break;
}
}
if (!schemaHashMap.has(integration.schemaHash) || integration.status === integration_1.ServiceAPIIntegration.Status.Failed) {
shouldCompile = true;
}
schemaHashMap.set(integration.schemaHash, integration); // override schema
}
else if (integration.type === Remove) {
if (schemaHashMap.has(integration.schemaHash) || integration.status === integration_1.ServiceAPIIntegration.Status.Failed) {
shouldCompile = true;
}
schemaHashMap.delete(integration.schemaHash); // remove
}
else {
console.assert(false, "invalid integration type");
}
}
// no changes
if (!shouldCompile) {
for (const integration of integrations) {
integration.setSkipped(this, parentVersion);
}
this.props.logger.info(`${this} branch skipped ${parentVersion} -> (new) version compile due to no changes:\n${integrations.concat(compensatedIntegrations).join("\n")}`);
// retry
return this.retryFailedIntegrationsFrom(parentVersion);
}
// compile new schemata as new routeHashMap
const mergedIntegrations = Array.from(schemaHashMap.values());
const routeHashes = new Array();
const routes = new Array();
const errors = [];
for (const plugin of this.props.protocolPlugins) {
try {
const pluginIntegrations = mergedIntegrations.filter(integration => integration.schema.protocol && integration.schema.protocol[plugin.key]);
const pluginResult = plugin.compileSchemata(routeHashMapCache, pluginIntegrations, this);
for (const { hash, route } of pluginResult) {
const routeHashIndex = routeHashes.indexOf(hash);
if (routeHashIndex !== -1) {
const existingRoute = routes[routeHashIndex];
errors.push({
type: "routeHashCollision",
field: `api.protocol.${plugin.key}`,
message: `route hash [${hash}] collision occurred between ${existingRoute} and ${route} in ${this.name} branch`,
});
continue;
}
const conflictRoute = routes.find(r => r.isConflict(route));
if (conflictRoute) {
errors.push({
type: "routeConflict",
field: `api.protocol.${plugin.key}`,
message: `route conflict occurred between ${conflictRoute} and ${route} in ${this.name} branch`,
});
continue;
}
routeHashes.push(hash);
routes.push(route);
}
}
catch (error) {
errors.push({
type: "compileError",
field: `api.protocol.${plugin.key}`,
message: error.message,
});
}
}
// failed: report errors and process as failed jobs
if (errors.length > 0) {
for (const integration of integrations) {
integration.setFailed(this, parentVersion, errors);
}
if (initialCompile) { // throw errors when failed in initial compile
const err = new interface_1.FatalError("failed to compile empty schemata initially"); // TODO: normalize error
err.detail = errors;
throw err;
}
else {
const at = new Date();
const errorsTable = broker_1.Reporter.getTable(errors.map(message => ({ type: "error", message, at })));
this.props.logger.error(`${this} branch failed ${parentVersion} -> (new) version compile:\n${integrations.join("\n")}${errorsTable}`);
}
// will not retry on failure
return;
}
// succeed: report success and create new version
const routeHashMap = new Map(routeHashes.map((hash, idx) => [hash, routes[idx]]));
// create new version
const version = new version_1.Version({
schemaHashMap,
routeHashMap,
parentVersion,
});
this.$latestVersion = version;
// report to origin services
const updatedRoutes = [];
const removedRoutes = [];
for (const [hash, route] of routeHashMap) {
if (!routeHashMapCache.has(hash)) {
updatedRoutes.push(route);
}
}
for (const [hash, route] of routeHashMapCache) {
if (!routeHashMap.has(hash)) {
removedRoutes.push(route);
}
}
updatedRoutes.sort((a, b) => a.path > b.path && a.protocol > b.protocol ? 1 : 0);
removedRoutes.sort((a, b) => a.path > b.path && a.protocol > b.protocol ? 1 : 0);
const routeIntegrations = [];
for (const r of removedRoutes) {
routeIntegrations.push(kleur.dim(`(-) ${r.toStringWithoutDescription()}`));
}
for (const r of updatedRoutes) {
routeIntegrations.push(`(+) ${r}`);
}
for (const integration of integrations) {
integration.setSucceed(this, version, routeIntegrations);
}
// log
this.props.logger.info(`${this} branch succeeded ${parentVersion} -> ${version} version compile:\n${[...integrations, ...routeIntegrations].join("\n")}`);
// forget old parent versions if need
for (let cur = this.$latestVersion, parent = cur.parentVersion, i = 1; parent; cur = parent, parent = cur.parentVersion, i++) {
// v1 / v2 / 1
// v2 / v3 / 2
// v3 / null / 3
if (i >= this.opts.maxVersions) {
// for (const integration of parent.integrations) {
// integration.reportRemoved(this, cur);
// }
(_a = parent.integrations[0]) === null || _a === void 0 ? void 0 : _a.reportRemoved(this, cur);
cur.forgetParentVersion();
this.props.logger.info(`${this} branch have forgotten versions older than ${cur}`);
break;
}
}
// emit routes update event
this.emitter.emit(Branch.Event.Updated);
// retry failed jobs or finish
return this.retryFailedIntegrationsFrom(parentVersion);
}
catch (error) {
if (error instanceof interface_1.FatalError) {
throw error;
}
this.props.logger.error(`${this} branch failed to process integration jobs:\n${integrations.join("\n")}`, error);
}
});
}
retryFailedIntegrationsFrom(version) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
// retry merging failed integrations from history
const retryableIntegrations = version.getRetryableIntegrations();
if (retryableIntegrations.length > 0) {
this.props.logger.info(`${this} branch will retry merging ${retryableIntegrations.length} failed integrations:\n${retryableIntegrations.join("\n")}`);
return this.consumeIntegrations(retryableIntegrations, false);
}
});
}
/* lifecycle (updated/removed) */
start(listener) {
return Branch.lock.acquire(this.name, () => {
this.emitter.on(Branch.Event.Updated, listener.updated);
this.emitter.on(Branch.Event.Removed, listener.removed);
listener.started();
// force compile for master branch initialization
if (this.isMaster) {
return this.consumeIntegrations([], true);
}
});
}
stop() {
return Branch.lock.acquire(this.name, () => {
// report version removed
for (const version of this.versions) {
for (const integration of version.integrations) {
integration.reportRemoved(this, version);
}
}
this.emitter.emit(Branch.Event.Removed);
this.emitter.removeAllListeners();
});
}
}
exports.Branch = Branch;
Branch.Master = "master";
Branch.Event = {
Updated: "updated",
Removed: "removed",
};
Branch.lock = new async_lock_1.default({ maxPending: 1000, timeout: 30 * 1000 });
//# sourceMappingURL=branch.js.map