redis-smq-common
Version:
Provides essential components and utilities shared across RedisSMQ packages.
177 lines • 6.6 kB
JavaScript
import { Runnable } from '../runnable/index.js';
import { CallbackEmptyReplyError } from '../errors/index.js';
import { ExponentialBackoff } from '../backoff/index.js';
import { Timer } from '../timer/index.js';
export class Heartbeat extends Runnable {
static MIN_HEARTBEAT_TTL = 3_000;
static DEFAULT_HEARTBEAT_TTL = Heartbeat.MIN_HEARTBEAT_TTL * 20;
redisClient;
componentId;
componentType;
heartbeatKey;
heartbeatTTL;
heartbeatInterval;
logger;
dataFn;
timer;
backoff;
constructor(redisClient, logger, config, dataFn) {
super();
if (!config.componentId || !config.componentType || !config.heartbeatKey) {
throw new Error('Heartbeat: componentId, componentType and heartbeatKey are required');
}
this.redisClient = redisClient;
this.componentId = config.componentId;
this.componentType = config.componentType;
this.heartbeatKey = config.heartbeatKey;
this.logger = logger.createLogger(this.constructor.name);
if (config.heartbeatTTL &&
config.heartbeatTTL < Heartbeat.MIN_HEARTBEAT_TTL) {
throw new Error('heartbeatTTL must be not longer than 3000 milliseconds');
}
this.heartbeatTTL = config.heartbeatTTL ?? Heartbeat.DEFAULT_HEARTBEAT_TTL;
this.heartbeatInterval = Math.floor(Math.max(1, this.heartbeatTTL / 3));
this.backoff = new ExponentialBackoff(this.logger, { maxAttempts: 3 });
this.timer = new Timer(this.logger);
this.dataFn = dataFn;
this.logger.debug(`Heartbeat initialized for ${this.componentType}:${this.componentId} (interval: ${this.heartbeatInterval}ms, TTL: ${this.heartbeatTTL}ms)`);
}
static isComponentAlive(redisClient, heartbeatKey, cb) {
redisClient.get(heartbeatKey, (err, heartbeat) => {
if (err)
return cb(err);
cb(null, !!heartbeat);
});
}
static areComponentsAlive(redisClient, heartbeatKeys, cb) {
if (!heartbeatKeys.length)
return cb(null, {});
redisClient.mget(heartbeatKeys, (err, replies = []) => {
if (err)
return cb(err);
const result = {};
heartbeatKeys.forEach((key, index) => {
result[key] = !!replies[index];
});
cb(null, result);
});
}
getPayload(cb) {
const timestamp = Date.now();
this.dataFn((err, data) => {
if (err)
return cb(err);
if (data == null) {
return cb(new CallbackEmptyReplyError({
message: 'dataFn returned null/undefined',
}));
}
cb(null, {
timestamp,
componentId: this.componentId,
componentType: this.componentType,
data,
});
});
}
scheduleNextBeat() {
if (!this.isOperational()) {
this.logger.debug('Not scheduling next beat – component not operational');
return;
}
this.timer.schedule(() => {
this.backoff.execute(this.beat, (err, payload) => {
if (err)
return this.handleError(err);
if (payload) {
this.emit('heartbeat.beat', this.componentId, this.componentType, payload.timestamp, payload);
this.scheduleNextBeat();
}
});
}, this.heartbeatInterval);
}
beat = (cb) => {
if (!this.isOperational()) {
this.logger.debug('Skipping heartbeat – component not operational');
return;
}
this.getPayload((err, payload) => {
if (err || !payload) {
cb(err || new CallbackEmptyReplyError());
return;
}
if (!this.isOperational()) {
this.logger.debug('Skipping Redis SET – shutdown in progress');
return;
}
let payloadStr;
try {
payloadStr = JSON.stringify(payload);
}
catch (serializeErr) {
cb(new Error(`Failed to serialize payload: ${serializeErr}`));
return;
}
this.redisClient.set(this.heartbeatKey, payloadStr, { expire: { mode: 'PX', value: this.heartbeatTTL } }, (err) => {
if (err)
return cb(err);
cb(null, payload);
});
});
};
handleError(err) {
if (!this.isOperational())
return;
this.emit('heartbeat.error', err, this.componentId, this.componentType);
super.handleError(err);
}
finalizeUp() {
super.finalizeUp();
this.emit('heartbeat.up', this.componentId, this.componentType);
this.scheduleNextBeat();
}
finalizeDown() {
super.finalizeDown();
this.emit('heartbeat.down', this.componentId, this.componentType);
}
goingUp() {
this.emit('heartbeat.goingUp', this.componentId, this.componentType);
return super.goingUp().concat([
(cb) => this.timer.run(cb),
(cb) => this.backoff.run(cb),
(cb) => {
const onHeartbeat = () => {
cleanup();
cb();
};
const onError = (err) => {
cleanup();
cb(err);
};
const cleanup = () => {
this.removeListener('heartbeat.beat', onHeartbeat);
this.removeListener('heartbeat.error', onError);
};
this.once('heartbeat.beat', onHeartbeat);
this.once('heartbeat.error', onError);
this.beat((err) => cb(err));
},
]);
}
goingDown() {
this.emit('heartbeat.goingDown', this.componentId, this.componentType);
return [
(cb) => this.timer.shutdown(cb),
(cb) => this.backoff.shutdown(cb),
(cb) => {
this.logger.debug(`Deleting heartbeat key ${this.heartbeatKey}`);
this.redisClient.del(this.heartbeatKey, (err) => {
if (err)
this.logger.warn(`Error deleting heartbeat key: ${err.message}`);
cb();
});
},
].concat(super.goingDown());
}
}
//# sourceMappingURL=heartbeart.js.map