nest-leader-election
Version:
Distributed leader election for NestJS
161 lines (159 loc) • 7.16 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { Logger } from "@nestjs/common";
import { createLeaderLeaseEntity } from "../entities/index.js";
export class LeaderElectorCore {
constructor(leaderLeaseRepository, config) {
var _a, _b, _c, _d, _e, _f, _g, _h;
this.leaderLeaseRepository = leaderLeaseRepository;
this.name = "LeaderElector";
this.logger = new Logger(LeaderElectorCore.name);
this.isLeader = false;
this.baseLeaseDuration = (_a = config.leaseDuration) !== null && _a !== void 0 ? _a : 30000;
this.baseCleanInterval =
(_b = config.baseCleanInterval) !== null && _b !== void 0 ? _b : this.baseLeaseDuration * 2;
this.baseRenewalInterval =
(_c = config.renewalInterval) !== null && _c !== void 0 ? _c : this.baseLeaseDuration / 3;
this.jitterRange = (_d = config.jitterRange) !== null && _d !== void 0 ? _d : 2000;
this.LOCK_ID = (_e = config.lockId) !== null && _e !== void 0 ? _e : 1;
this.schema = (_f = config.schema) !== null && _f !== void 0 ? _f : "public";
this.createTableOnInit = (_g = config.createTableOnInit) !== null && _g !== void 0 ? _g : true;
this.instanceId =
(_h = config.instanceId) !== null && _h !== void 0 ? _h : Math.random().toString(36).substring(2, 8);
}
static create(dataSource, config) {
return __awaiter(this, void 0, void 0, function* () {
const entity = createLeaderLeaseEntity(config.schema);
const repository = dataSource.getRepository(entity);
const elector = new LeaderElectorCore(repository, config);
yield elector.initialize();
return elector;
});
}
initialize() {
return __awaiter(this, void 0, void 0, function* () {
if (this.createTableOnInit)
yield this.createLockTableIfNotExists();
// TODO: может стоит перенести очистку только на лидера?
yield this.startCleanupJob();
yield this.tryAcquireLease();
this.tryAcquireLeaseWithJitter();
});
}
getJitter() {
return Math.random() * this.jitterRange - this.jitterRange / 2;
}
createLockTableIfNotExists() {
return __awaiter(this, void 0, void 0, function* () {
yield this.leaderLeaseRepository.query(`
CREATE TABLE IF NOT EXISTS ${this.schema}.leader_lease (
id INT PRIMARY KEY,
leader_id TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (expires_at > created_at)
);
CREATE INDEX IF NOT EXISTS leader_lease_expires
ON ${this.schema}.leader_lease (expires_at);
`);
});
}
startCleanupJob() {
return __awaiter(this, void 0, void 0, function* () {
yield this.cleanupExpiredLeases();
setInterval(() => this.cleanupExpiredLeases(), this.baseCleanInterval + this.getJitter());
});
}
cleanupExpiredLeases() {
return __awaiter(this, void 0, void 0, function* () {
try {
const result = yield this.leaderLeaseRepository
.createQueryBuilder()
.delete()
.where("expiresAt < NOW() - INTERVAL '5 seconds'")
.execute();
this.logger.log(`Cleaned ${result.affected} expired leases`);
}
catch (error) {
this.logger.error("Cleanup failed:", error);
}
});
}
tryAcquireLeaseWithJitter() {
if (this.renewalTimer)
clearTimeout(this.renewalTimer);
const interval = this.baseRenewalInterval + this.getJitter();
this.renewalTimer = setTimeout(() => this.tryAcquireLease(), interval);
}
tryAcquireLease() {
return __awaiter(this, void 0, void 0, function* () {
const queryRunner = this.leaderLeaseRepository.manager.connection.createQueryRunner();
yield queryRunner.connect();
yield queryRunner.startTransaction();
try {
// 1. Попытка атомарного upsert
const updated = yield queryRunner.manager.query(`INSERT INTO ${this.schema}."leader_lease"("id", "leader_id", "expires_at", "created_at")
VALUES ($1, $2, NOW() + INTERVAL '15000 ms', DEFAULT)
ON CONFLICT ("id") DO UPDATE SET "expires_at" = EXCLUDED."expires_at"
WHERE ${this.schema}."leader_lease".id = EXCLUDED.id AND ${this.schema}."leader_lease".leader_id = EXCLUDED.leader_id
RETURNING "created_at"`, [this.LOCK_ID, this.instanceId]);
console.log("updated", updated);
if (updated.length > 0) {
this.handleLeadershipAcquired();
}
else if (this.isLeader) {
yield this.release();
}
else {
this.logger.log("Failed to acquire leadership (leader someone else)");
}
yield queryRunner.commitTransaction();
}
catch (error) {
yield queryRunner.rollbackTransaction();
this.logger.error("Lease operation failed:", error);
yield this.release();
}
finally {
yield queryRunner.release();
this.tryAcquireLeaseWithJitter();
}
});
}
handleLeadershipAcquired() {
if (!this.isLeader) {
this.logger.log("🎉 Acquired leadership");
this.isLeader = true;
}
}
release() {
return __awaiter(this, void 0, void 0, function* () {
// сначала выключаем локальную логику
if (this.isLeader) {
this.isLeader = false;
if (this.renewalTimer)
clearTimeout(this.renewalTimer);
yield this.leaderLeaseRepository.delete({
id: this.LOCK_ID,
leaderId: this.instanceId,
});
this.logger.log("Released leadership gracefully");
}
});
}
shutdown() {
return __awaiter(this, void 0, void 0, function* () {
yield this.release();
});
}
amILeader() {
return this.isLeader;
}
}