UNPKG

moleculer

Version:

Fast & powerful microservices framework for Node.JS

479 lines (415 loc) 12 kB
/* * moleculer * Copyright (c) 2023 MoleculerJS (https://github.com/moleculerjs/moleculer) * MIT Licensed */ "use strict"; const _ = require("lodash"); const kleur = require("kleur"); const { BrokerOptionsError } = require("../../errors"); const BaseDiscoverer = require("./base"); const { METRIC } = require("../../metrics"); const Serializers = require("../../serializers"); const { removeFromArray, isFunction, randomInt } = require("../../utils"); const P = require("../../packets"); const C = require("../../constants"); let Redis; /** * Import types * * @typedef {import("./redis")} RedisDiscovererClass * @typedef {import("./redis").RedisDiscovererOptions} RedisDiscovererOptions * @typedef {import("../node")} Node */ /** * Redis-based Discoverer class * * @class RedisDiscoverer * @implements {RedisDiscovererClass} */ class RedisDiscoverer extends BaseDiscoverer { /** * Creates an instance of Discoverer. * * @param {string|RedisDiscovererOptions?} opts * @memberof RedisDiscoverer */ constructor(opts) { if (typeof opts === "string") { opts = { redis: opts }; } super(opts); this.opts = _.defaultsDeep(this.opts, { redis: null, serializer: "JSON", fullCheck: 10, // Disable with `0` or `null` scanLength: 100, monitor: false }); // Loop counter for full checks. Starts from a random value for better distribution this.idx = this.opts.fullCheck > 1 ? randomInt(this.opts.fullCheck - 1) : 0; // Redis client instance this.client = null; // Timer for INFO packets expiration updating this.infoUpdateTimer = null; // Last sequence numbers this.lastInfoSeq = 0; this.lastBeatSeq = 0; this.reconnecting = false; } /** * Initialize Discoverer * * @param {any} registry * * @memberof RedisDiscoverer */ init(registry) { super.init(registry); try { Redis = require("ioredis"); } catch (err) { /* istanbul ignore next */ this.broker.fatal( "The 'ioredis' package is missing. Please install it with 'npm install ioredis --save' command.", err, true ); } this.logger.warn( kleur .yellow() .bold("Redis Discoverer is an EXPERIMENTAL module. Do NOT use it in production!") ); // Using shorter instanceID to reduce the network traffic this.instanceHash = this.broker.instanceID.substring(0, 8); this.PREFIX = `MOL${this.broker.namespace ? "-" + this.broker.namespace : ""}-DSCVR`; this.BEAT_KEY = `${this.PREFIX}-BEAT:${this.broker.nodeID}|${this.instanceHash}`; this.INFO_KEY = `${this.PREFIX}-INFO:${this.broker.nodeID}`; /** * ioredis client instance * @memberof RedisCacher */ if (this.opts.cluster) { if (!this.opts.cluster.nodes || this.opts.cluster.nodes.length === 0) { throw new BrokerOptionsError("No nodes defined for cluster"); } this.client = new Redis.Cluster(this.opts.cluster.nodes, this.opts.cluster.options); } else { this.client = new Redis(this.opts.redis); } this.client.on("connect", () => { /* istanbul ignore next */ this.logger.info("Redis Discoverer client connected."); if (this.reconnecting) { this.reconnecting = false; this.sendLocalNodeInfo(); } }); this.client.on("reconnecting", () => { /* istanbul ignore next */ this.logger.warn("Redis Discoverer client reconnecting..."); this.reconnecting = true; this.lastInfoSeq = 0; this.lastBeatSeq = 0; }); this.client.on("error", err => { /* istanbul ignore next */ this.logger.error(err); this.broker.broadcastLocal("$discoverer.error", { error: err, module: "discoverer", type: C.CLIENT_ERROR }); }); if (this.opts.monitor && isFunction(this.client.monitor)) { this.client.monitor((err, monitor) => { this.logger.debug("Redis Discoverer entering monitoring mode..."); monitor.on("monitor", (time, args /*, source, database*/) => this.logger.debug(args) ); }); } // create an instance of serializer (default to JSON) this.serializer = Serializers.resolve(this.opts.serializer); this.serializer.init(this.broker); this.logger.debug("Redis Discoverer created. Prefix:", this.PREFIX); } /** * Stop discoverer clients. */ stop() { if (this.infoUpdateTimer) clearTimeout(this.infoUpdateTimer); return super.stop().then(() => { if (this.client) return this.client.quit(); }); } /** * Register Moleculer Transit Core metrics. */ registerMoleculerMetrics() { this.broker.metrics.register({ name: METRIC.MOLECULER_DISCOVERER_REDIS_COLLECT_TOTAL, type: METRIC.TYPE_COUNTER, rate: true, description: "Number of Service Registry fetching from Redis" }); this.broker.metrics.register({ name: METRIC.MOLECULER_DISCOVERER_REDIS_COLLECT_TIME, type: METRIC.TYPE_HISTOGRAM, quantiles: true, unit: METRIC.UNIT_MILLISECONDS, description: "Time of Service Registry fetching from Redis" }); } /** * Recreate the INFO key update timer. */ recreateInfoUpdateTimer() { if (this.infoUpdateTimer) clearTimeout(this.infoUpdateTimer); this.infoUpdateTimer = setTimeout( () => { // Reset the INFO packet expiry. this.client.expire(this.INFO_KEY, 60 * 60); // 60 mins this.recreateInfoUpdateTimer(); }, 20 * 60 * 1000 ); // 20 mins this.infoUpdateTimer.unref(); } /** * Sending a local heartbeat to Redis. */ sendHeartbeat() { //console.log("REDIS - HB 1", localNode.id, this.heartbeatTimer); const timeEnd = this.broker.metrics.timer(METRIC.MOLECULER_DISCOVERER_REDIS_COLLECT_TIME); const data = { sender: this.broker.nodeID, ver: this.broker.PROTOCOL_VERSION, // timestamp: Date.now(), cpu: this.localNode.cpu, seq: this.localNode.seq, instanceID: this.broker.instanceID }; const seq = this.localNode.seq; const key = this.BEAT_KEY + "|" + seq; return this.Promise.resolve() .then(() => { // Create a multi pipeline let pl = this.client.multi(); if (seq != this.lastBeatSeq) { // Remove previous BEAT keys pl = pl.del(this.BEAT_KEY + "|" + this.lastBeatSeq); } // Create new HB key pl = pl.setex( key, this.opts.heartbeatTimeout, this.serializer.serialize(data, P.PACKET_HEARTBEAT) ); return pl.exec(); }) .then(() => (this.lastBeatSeq = seq)) .then(() => this.collectOnlineNodes()) .catch(err => { this.logger.error("Error occurred while scanning Redis keys.", err); this.broker.broadcastLocal("$discoverer.error", { error: err, module: "discoverer", type: C.FAILED_KEY_SCAN }); }) .then(() => { timeEnd(); this.broker.metrics.increment(METRIC.MOLECULER_DISCOVERER_REDIS_COLLECT_TOTAL); }); } /** * Collect online nodes from Redis server. */ collectOnlineNodes() { // Get the current node list so that we can check the disconnected nodes. const prevNodes = this.registry.nodes .list({ onlyAvailable: true, withServices: false }) .map(node => node.id) .filter(nodeID => nodeID !== this.broker.nodeID); // Collect the online node keys. return new this.Promise((resolve, reject) => { const stream = this.client.scanStream({ match: `${this.PREFIX}-BEAT:*`, count: this.opts.scanLength }); let scannedKeys = []; stream.on("data", keys => { if (!keys || !keys.length) return; scannedKeys = scannedKeys.concat(keys); }); stream.on("error", err => reject(err)); stream.on("end", () => { if (scannedKeys.length === 0) return resolve(); this.Promise.resolve() .then(() => { if (this.opts.fullCheck && ++this.idx % this.opts.fullCheck === 0) { // Full check //this.logger.debug("Full check", this.idx); this.idx = 0; return this.client.mgetBuffer(...scannedKeys).then(packets => packets.map((raw, i) => { try { const p = scannedKeys[i] .substring(`${this.PREFIX}-BEAT:`.length) .split("|"); return { sender: p[0], instanceID: p[1], seq: Number(p[2]), ...this.serializer.deserialize(raw, P.PACKET_INFO) }; } catch (err) { this.logger.warn( "Unable to parse HEARTBEAT packet", err, raw ); } }) ); } else { //this.logger.debug("Lazy check", this.idx); // Lazy check return scannedKeys.map(key => { const p = key.substring(`${this.PREFIX}-BEAT:`.length).split("|"); return { sender: p[0], instanceID: p[1], seq: Number(p[2]) }; }); } }) .then(packets => { packets.map(packet => { if (packet.sender == this.broker.nodeID) return; removeFromArray(prevNodes, packet.sender); this.heartbeatReceived(packet.sender, packet); }); }) .then(() => resolve()); }); }).then(() => { if (prevNodes.length > 0) { // Disconnected nodes prevNodes.forEach(nodeID => { this.logger.info( `The node '${nodeID}' is not available. Removing from registry...` ); this.remoteNodeDisconnected(nodeID, true); }); } }); } /** * Discover a new or old node. * * @param {String} nodeID * @returns {Promise<Node | void>} */ discoverNode(nodeID) { return this.client.getBuffer(`${this.PREFIX}-INFO:${nodeID}`).then(res => { if (!res) { this.logger.warn(`No INFO for '${nodeID}' node in registry.`); return; } try { const info = this.serializer.deserialize(res, P.PACKET_INFO); return this.processRemoteNodeInfo(nodeID, info); } catch (err) { this.logger.warn("Unable to parse INFO packet", err, res); } }); } /** * Discover all nodes (after connected) * @returns {Promise<Node[] | void>} */ discoverAllNodes() { return this.collectOnlineNodes(); } /** * Local service registry has been changed. We should notify remote nodes. * @param {String=} nodeID * @returns {Promise<void>} */ sendLocalNodeInfo(nodeID) { const info = this.broker.getLocalNodeInfo(); const payload = Object.assign( { ver: this.broker.PROTOCOL_VERSION, sender: this.broker.nodeID }, info ); const key = this.INFO_KEY; const seq = this.localNode.seq; const p = !nodeID && this.broker.options.disableBalancer ? this.transit.tx.makeBalancedSubscriptions() : this.Promise.resolve(); return p .then(() => this.client.setex(key, 30 * 60, this.serializer.serialize(payload, P.PACKET_INFO)) ) .then(() => { this.lastInfoSeq = seq; this.recreateInfoUpdateTimer(); // Sending a new heartbeat because it contains the `seq` if (!nodeID) return this.beat(); }) .catch(err => { this.logger.error("Unable to send INFO to Redis server", err); this.broker.broadcastLocal("$discoverer.error", { error: err, module: "discoverer", type: C.FAILED_SEND_INFO }); }); } /** * Unregister local node after disconnecting. */ localNodeDisconnected() { return this.Promise.resolve() .then(() => super.localNodeDisconnected()) .then(() => this.logger.debug("Remove local node from registry...")) .then(() => this.client.del(this.INFO_KEY)) .then(() => this.scanClean(this.BEAT_KEY + "*")); } /** * Clean Redis key by pattern * @param {String} match */ scanClean(match) { return new Promise((resolve, reject) => { const stream = this.client.scanStream({ match, count: this.opts.scanLength }); stream.on("data", (keys = []) => { if (!keys.length) { return; } stream.pause(); return this.client .del(keys) .then(() => stream.resume()) .catch(err => reject(err)); }); stream.on("error", err => { this.logger.error(`Error occured while deleting Redis keys '${match}'.`, err); reject(err); }); stream.on("end", () => resolve()); }); } } module.exports = RedisDiscoverer;