UNPKG

nest-leader-election

Version:
161 lines (159 loc) 7.16 kB
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; } }