node-raft-redis
Version:
Consensus for node microservices, based on a simplified version of the [raft algorithm](https://raft.github.io/). Requires redis as a medium. Features automatic discovery of the services of same kind. Selects one instance of a microservice as leader. For
441 lines (440 loc) • 16.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Candidate = void 0;
const redis_1 = require("redis");
const events_1 = require("events");
const randomTimeout = () => {
const min = 1000;
const max = 1500;
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const randomString = () => {
return (Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15));
};
class Candidate extends events_1.EventEmitter {
kind = "";
__meta = {};
running = false;
__instanceId = randomString();
redisClient;
subscriptionClient = null;
leaderSubscription = false;
followerSubscription = false;
privateSubsciption = false;
votes = new Set();
votedFor = "";
__currentTerm = 1;
__state = "follower";
timeout = null;
requestInterval = null;
leadershipInterval = null;
countNodesInterval = null;
stopCheckInterval = null;
startCheckInterval = null;
constructor(options) {
super();
const { redis, kind, meta } = options;
this.kind = kind || "";
this.__meta = meta || "";
if (redis.url) {
this.redisClient = (0, redis_1.createClient)({ url: redis.url });
}
else {
this.redisClient = (0, redis_1.createClient)({
socket: {
host: redis.host,
port: redis.port,
},
});
}
this.redisClient.on("error", (err) => {
this.emit("error", err);
});
}
keepAlive() {
return this.redisClient.set(`consensus-nodes:${this.kind}:${this.__instanceId}`, JSON.stringify({ ts: Date.now(), meta: this.__meta, state: this.__state }));
}
async getInstanceCount() {
return this.getInstances().then((instances) => instances.length);
}
async getInstances() {
const keys = await this.redisClient.keys(`consensus-nodes:${this.kind}:*`);
const alive = [];
const dead = [];
for (const key of keys) {
const val = await this.redisClient.get(key);
if (!val) {
dead.push(key);
continue;
}
const { ts, meta, state } = JSON.parse(val);
if (Date.now() - parseInt(ts, 10) > 2000) {
dead.push(key);
}
else {
const instanceId = key.split(":")[2];
const instance = {
id: instanceId,
meta,
state,
};
alive.push(instance);
}
}
if (dead.length) {
await this.redisClient.del(dead);
}
return alive;
}
async startTimeout() {
this.votes = new Set();
if (this.timeout) {
clearInterval(this.timeout);
}
if (this.leadershipInterval) {
clearInterval(this.leadershipInterval);
}
if (this.requestInterval) {
clearInterval(this.requestInterval);
this.requestInterval = null;
}
this.timeout = setInterval(() => {
this.setState("candidate");
this.__currentTerm++;
this.votedFor = this.__instanceId;
this.votes.add(this.__instanceId);
const sendRequest = () => {
const message = {
type: "request",
from: this.__instanceId,
currentTerm: this.__currentTerm,
ignore: [...this.votes],
};
this.redisClient.publish(`consensus-events:${this.kind}`, JSON.stringify(message));
};
this.requestInterval ??= setInterval(() => {
sendRequest();
}, 400);
sendRequest();
}, randomTimeout());
}
startLeadership() {
if (this.timeout) {
clearInterval(this.timeout);
}
if (this.leadershipInterval) {
clearInterval(this.leadershipInterval);
}
if (this.requestInterval) {
clearInterval(this.requestInterval);
}
const _message = {
type: "check",
from: this.__instanceId,
currentTerm: this.__currentTerm,
};
this.redisClient.publish(`consensus-events:${this.kind}`, JSON.stringify(_message));
this.leadershipInterval = setInterval(() => {
this.redisClient.publish(`consensus-events:${this.kind}`, JSON.stringify(_message));
}, 400);
this.setState("leader");
}
setState(state, initiator) {
if (state !== "leader") {
if (this.leadershipInterval) {
clearInterval(this.leadershipInterval);
}
}
if (state !== "candidate") {
this.emit("__init");
}
if (this.__state !== state) {
this.__state = state;
this.keepAlive();
if (state === "leader") {
this.subscribeLeaderChannel();
this.emit("elected");
}
else {
this.unsubscribeLeaderChannel();
}
if (state === "follower") {
this.subscribeFollowerChannel();
this.emit("defeated", initiator);
}
else {
this.unsubscribeFollowerChannel();
}
this.emit("statechange", state);
}
}
async _stop() {
try {
if (this.timeout) {
clearInterval(this.timeout);
}
if (this.leadershipInterval) {
clearInterval(this.leadershipInterval);
}
if (this.countNodesInterval) {
clearInterval(this.countNodesInterval);
}
if (this.subscriptionClient?.isOpen) {
await this.subscriptionClient.unsubscribe();
await this.subscriptionClient.quit();
}
if (this.redisClient?.isOpen) {
await this.redisClient.quit();
}
this.running = false;
}
catch (error) {
this.emit("error", error);
}
}
async subscribePrivateChannel() {
if (this.privateSubsciption) {
return;
}
this.privateSubsciption = true;
await this.subscriptionClient?.subscribe(`consensus-messages:${this.kind}:${this.__instanceId}`, (str) => {
const { message, from, fromLeader } = JSON.parse(str);
if (from !== this.__instanceId) {
this.emit("message", { message, from });
if (this.state === "leader") {
this.emit("message:from:follower", { message, from });
}
if (fromLeader) {
this.emit("message:from:leader", { message, from });
}
}
});
}
async unsubscribePrivateChannel() {
await this.subscriptionClient?.unsubscribe(`consensus-messages:${this.kind}:${this.__instanceId}`);
this.privateSubsciption = false;
}
async subscribeFollowerChannel() {
if (this.followerSubscription) {
return;
}
this.followerSubscription = true;
await this.subscriptionClient?.subscribe(`consensus-messages:${this.kind}:followers`, (str) => {
const { message, from, fromLeader } = JSON.parse(str);
if (this.__state === "follower" && from !== this.__instanceId) {
this.emit("message", { message, from });
if (fromLeader) {
this.emit("message:from:leader", { message, from });
}
}
});
}
async unsubscribeFollowerChannel() {
await this.subscriptionClient?.unsubscribe(`consensus-messages:${this.kind}:followers`);
this.followerSubscription = false;
}
async subscribeLeaderChannel() {
if (this.leaderSubscription) {
return;
}
this.leaderSubscription = true;
await this.subscriptionClient?.subscribe(`consensus-messages:${this.kind}:leader`, (str) => {
const { message, from } = JSON.parse(str);
if (this.__state === "leader" && from !== this.__instanceId) {
this.emit("message", { message, from });
this.emit("message:from:follower", { message, from });
}
});
}
async unsubscribeLeaderChannel() {
await this.subscriptionClient?.unsubscribe(`consensus-messages:${this.kind}:leader`);
this.leaderSubscription = false;
}
async _start() {
this.running = true;
await this.redisClient.connect();
await new Promise((r) => setTimeout(r, randomTimeout()));
await this.keepAlive();
await new Promise((r) => setTimeout(r, 1000));
await this.keepAlive();
let count = await this.getInstanceCount();
this.countNodesInterval = setInterval(async () => {
await this.keepAlive();
count = await this.getInstanceCount();
if (count === 1 && this.__state !== "leader") {
this.startLeadership();
}
}, 1000);
this.subscriptionClient = this.redisClient.duplicate();
this.subscriptionClient.on("error", (err) => {
this.emit("error", err);
});
this.subscribeFollowerChannel();
this.subscribePrivateChannel();
this.subscriptionClient.subscribe(`consensus-messages:${this.kind}`, (str) => {
const { message, from, fromLeader } = JSON.parse(str);
if (from !== this.__instanceId) {
this.emit("message", { message, from });
if (fromLeader) {
this.emit("message:from:leader", { message, from });
}
}
});
this.subscriptionClient.subscribe(`consensus-events:${this.kind}`, (str) => {
const message = JSON.parse(str);
if (message.from === this.__instanceId ||
message.ignore?.includes(this.__instanceId)) {
return;
}
if (message.type === "request" || message.type === "check") {
if (this.__currentTerm < message.currentTerm ||
(message.type === "check" &&
this.__currentTerm <= message.currentTerm)) {
if (this.leadershipInterval) {
clearInterval(this.leadershipInterval);
}
this.__currentTerm = message.currentTerm;
this.votedFor = message.from;
this.setState("follower", message.from);
const reply = {
type: "vote",
from: this.__instanceId,
to: message.from,
granted: true,
currentTerm: this.__currentTerm,
};
this.redisClient.publish(`consensus-events:${this.kind}`, JSON.stringify(reply));
this.startTimeout();
}
else {
const reply = {
type: "vote",
from: this.__instanceId,
to: message.from,
granted: false,
currentTerm: this.__currentTerm,
};
this.redisClient.publish(`consensus-events:${this.kind}`, JSON.stringify(reply));
}
}
else {
if (this.__state === "candidate" &&
message.to === this.__instanceId &&
message.granted &&
message.currentTerm === this.__currentTerm) {
this.votes.add(message.from);
if (this.votes.size >= Math.floor(count / 2) + 1) {
this.startLeadership();
}
}
}
});
await this.subscriptionClient.connect();
this.startTimeout();
await new Promise((resolve) => {
this.once("__init", resolve);
});
}
async start() {
if (this.startCheckInterval) {
clearInterval(this.startCheckInterval);
}
if (this.stopCheckInterval) {
clearInterval(this.stopCheckInterval);
}
if (this.running) {
return;
}
return new Promise((resolve) => {
this.startCheckInterval = setInterval(async () => {
if (!this.redisClient.isOpen &&
!this.subscriptionClient?.isOpen &&
!this.running) {
if (this.startCheckInterval) {
clearInterval(this.startCheckInterval);
}
await this._start();
resolve();
}
}, 100);
});
}
async stop() {
if (this.startCheckInterval) {
clearInterval(this.startCheckInterval);
if (!this.running) {
return;
}
}
if (this.stopCheckInterval) {
clearInterval(this.stopCheckInterval);
}
return new Promise((resolve) => {
this.stopCheckInterval = setInterval(async () => {
if (this.redisClient.isOpen && this.subscriptionClient?.isOpen) {
if (this.stopCheckInterval) {
clearInterval(this.stopCheckInterval);
}
await this._stop();
resolve();
}
}, 100);
});
}
async reelect() {
if (this.__state === "leader") {
this.votedFor = "";
this.startTimeout();
}
}
stepdown() {
return this.reelect();
}
async messageFollowers(message) {
await this.redisClient.publish(`consensus-messages:${this.kind}:followers`, JSON.stringify({
message,
from: this.__instanceId,
fromLeader: this.__state === "leader",
}));
}
async messageTo(to, message) {
await this.redisClient.publish(`consensus-messages:${this.kind}:${to}`, JSON.stringify({
message,
from: this.__instanceId,
fromLeader: this.__state === "leader",
}));
}
async messageAll(message) {
await this.redisClient.publish(`consensus-messages:${this.kind}`, JSON.stringify({
message,
from: this.__instanceId,
fromLeader: this.__state === "leader",
}));
}
async messageLeader(message) {
await this.redisClient.publish(`consensus-messages:${this.kind}:leader`, JSON.stringify({ message, from: this.__instanceId }));
}
get currentTerm() {
return this.__currentTerm;
}
get id() {
return this.__instanceId;
}
get state() {
return this.__state;
}
async getLeader() {
const instances = await this.getInstances();
return instances.find((instance) => instance.state === "leader");
}
async setMeta(meta) {
this.__meta = meta;
await this.keepAlive();
}
get meta() {
return this.__meta;
}
}
exports.Candidate = Candidate;
exports.default = Candidate;