@flink-app/flink 2.0.0-alpha.73 → 2.0.0-alpha.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
13
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.LeaderElection = void 0;
40
+ var uuid_1 = require("uuid");
41
+ var FlinkLogFactory_1 = require("./FlinkLogFactory");
42
+ var log = FlinkLogFactory_1.FlinkLogFactory.createLogger("flink.scheduler");
43
+ var LOCK_NAME = "job-scheduler";
44
+ var LeaderElection = /** @class */ (function () {
45
+ function LeaderElection(db, opts) {
46
+ this.instanceId = (0, uuid_1.v4)();
47
+ this._isLeader = false;
48
+ this.timer = null;
49
+ this.stopped = false;
50
+ this.transitioning = false;
51
+ var collectionName = (opts === null || opts === void 0 ? void 0 : opts.collectionName) || "_flink_leader";
52
+ this.leaseDurationMs = (opts === null || opts === void 0 ? void 0 : opts.leaseDurationMs) || 15000;
53
+ this.heartbeatIntervalMs = (opts === null || opts === void 0 ? void 0 : opts.heartbeatIntervalMs) || 5000;
54
+ this.collection = db.collection(collectionName);
55
+ }
56
+ Object.defineProperty(LeaderElection.prototype, "isLeader", {
57
+ get: function () {
58
+ return this._isLeader;
59
+ },
60
+ enumerable: false,
61
+ configurable: true
62
+ });
63
+ /**
64
+ * Start the leader election process.
65
+ * @param onBecameLeader Called when this instance becomes the leader
66
+ * @param onLostLeadership Called when this instance loses leadership
67
+ */
68
+ LeaderElection.prototype.start = function (onBecameLeader, onLostLeadership) {
69
+ return __awaiter(this, void 0, void 0, function () {
70
+ var ttlSeconds, err_1;
71
+ return __generator(this, function (_a) {
72
+ switch (_a.label) {
73
+ case 0:
74
+ this.onBecameLeader = onBecameLeader;
75
+ this.onLostLeadership = onLostLeadership;
76
+ this.stopped = false;
77
+ ttlSeconds = Math.ceil((this.leaseDurationMs * 2) / 1000);
78
+ _a.label = 1;
79
+ case 1:
80
+ _a.trys.push([1, 3, , 8]);
81
+ return [4 /*yield*/, this.collection.createIndex({ lastHeartbeat: 1 }, { expireAfterSeconds: ttlSeconds })];
82
+ case 2:
83
+ _a.sent();
84
+ return [3 /*break*/, 8];
85
+ case 3:
86
+ err_1 = _a.sent();
87
+ if (!(err_1.codeName === "IndexOptionsConflict" || err_1.code === 85)) return [3 /*break*/, 6];
88
+ log.debug("TTL index options changed, recreating index");
89
+ return [4 /*yield*/, this.collection.dropIndex("lastHeartbeat_1")];
90
+ case 4:
91
+ _a.sent();
92
+ return [4 /*yield*/, this.collection.createIndex({ lastHeartbeat: 1 }, { expireAfterSeconds: ttlSeconds })];
93
+ case 5:
94
+ _a.sent();
95
+ return [3 /*break*/, 7];
96
+ case 6: throw err_1;
97
+ case 7: return [3 /*break*/, 8];
98
+ case 8:
99
+ log.info("Leader election started (instance: ".concat(this.instanceId.substring(0, 8), ")"));
100
+ // Run first election attempt immediately
101
+ return [4 /*yield*/, this.tryClaimLeadership()];
102
+ case 9:
103
+ // Run first election attempt immediately
104
+ _a.sent();
105
+ return [2 /*return*/];
106
+ }
107
+ });
108
+ });
109
+ };
110
+ /**
111
+ * Stop the leader election and release leadership if held.
112
+ */
113
+ LeaderElection.prototype.stop = function () {
114
+ return __awaiter(this, void 0, void 0, function () {
115
+ var err_2;
116
+ return __generator(this, function (_a) {
117
+ switch (_a.label) {
118
+ case 0:
119
+ this.stopped = true;
120
+ if (this.timer) {
121
+ clearTimeout(this.timer);
122
+ this.timer = null;
123
+ }
124
+ if (!this._isLeader) return [3 /*break*/, 5];
125
+ _a.label = 1;
126
+ case 1:
127
+ _a.trys.push([1, 3, , 4]);
128
+ return [4 /*yield*/, this.collection.deleteOne({
129
+ _id: LOCK_NAME,
130
+ instanceId: this.instanceId,
131
+ })];
132
+ case 2:
133
+ _a.sent();
134
+ log.info("Leadership released on shutdown");
135
+ return [3 /*break*/, 4];
136
+ case 3:
137
+ err_2 = _a.sent();
138
+ log.error("Failed to release leadership on shutdown: ".concat(err_2));
139
+ return [3 /*break*/, 4];
140
+ case 4:
141
+ this._isLeader = false;
142
+ _a.label = 5;
143
+ case 5: return [2 /*return*/];
144
+ }
145
+ });
146
+ });
147
+ };
148
+ LeaderElection.prototype.tryClaimLeadership = function () {
149
+ return __awaiter(this, void 0, void 0, function () {
150
+ var now, leaseExpiry, result, gotLock, err_3, err_4, err_5, cbErr_1, cbErr_2, nextInterval;
151
+ var _this = this;
152
+ var _a, _b, _c, _d;
153
+ return __generator(this, function (_e) {
154
+ switch (_e.label) {
155
+ case 0:
156
+ if (this.stopped || this.transitioning)
157
+ return [2 /*return*/];
158
+ now = new Date();
159
+ leaseExpiry = new Date(now.getTime() - this.leaseDurationMs);
160
+ _e.label = 1;
161
+ case 1:
162
+ _e.trys.push([1, 14, , 24]);
163
+ return [4 /*yield*/, this.collection.findOneAndUpdate({
164
+ _id: LOCK_NAME,
165
+ $or: [
166
+ { instanceId: this.instanceId },
167
+ { lastHeartbeat: { $lt: leaseExpiry } },
168
+ ],
169
+ }, {
170
+ $set: {
171
+ instanceId: this.instanceId,
172
+ lastHeartbeat: now,
173
+ },
174
+ $setOnInsert: {
175
+ claimedAt: now,
176
+ },
177
+ }, { upsert: true, returnDocument: "after" })];
178
+ case 2:
179
+ result = _e.sent();
180
+ gotLock = result && result.instanceId === this.instanceId;
181
+ if (!(gotLock && !this._isLeader)) return [3 /*break*/, 8];
182
+ log.info("This instance became the leader (instance: ".concat(this.instanceId.substring(0, 8), ")"));
183
+ this._isLeader = true;
184
+ this.transitioning = true;
185
+ _e.label = 3;
186
+ case 3:
187
+ _e.trys.push([3, 5, 6, 7]);
188
+ return [4 /*yield*/, ((_a = this.onBecameLeader) === null || _a === void 0 ? void 0 : _a.call(this))];
189
+ case 4:
190
+ _e.sent();
191
+ return [3 /*break*/, 7];
192
+ case 5:
193
+ err_3 = _e.sent();
194
+ log.error("Error in onBecameLeader callback: ".concat(err_3));
195
+ return [3 /*break*/, 7];
196
+ case 6:
197
+ this.transitioning = false;
198
+ return [7 /*endfinally*/];
199
+ case 7: return [3 /*break*/, 13];
200
+ case 8:
201
+ if (!(!gotLock && this._isLeader)) return [3 /*break*/, 13];
202
+ log.warn("This instance lost leadership (instance: ".concat(this.instanceId.substring(0, 8), ")"));
203
+ this._isLeader = false;
204
+ this.transitioning = true;
205
+ _e.label = 9;
206
+ case 9:
207
+ _e.trys.push([9, 11, 12, 13]);
208
+ return [4 /*yield*/, ((_b = this.onLostLeadership) === null || _b === void 0 ? void 0 : _b.call(this))];
209
+ case 10:
210
+ _e.sent();
211
+ return [3 /*break*/, 13];
212
+ case 11:
213
+ err_4 = _e.sent();
214
+ log.error("Error in onLostLeadership callback: ".concat(err_4));
215
+ return [3 /*break*/, 13];
216
+ case 12:
217
+ this.transitioning = false;
218
+ return [7 /*endfinally*/];
219
+ case 13: return [3 /*break*/, 24];
220
+ case 14:
221
+ err_5 = _e.sent();
222
+ if (!(err_5.code === 11000)) return [3 /*break*/, 19];
223
+ if (!this._isLeader) return [3 /*break*/, 18];
224
+ log.warn("This instance lost leadership (instance: ".concat(this.instanceId.substring(0, 8), ")"));
225
+ this._isLeader = false;
226
+ _e.label = 15;
227
+ case 15:
228
+ _e.trys.push([15, 17, , 18]);
229
+ return [4 /*yield*/, ((_c = this.onLostLeadership) === null || _c === void 0 ? void 0 : _c.call(this))];
230
+ case 16:
231
+ _e.sent();
232
+ return [3 /*break*/, 18];
233
+ case 17:
234
+ cbErr_1 = _e.sent();
235
+ log.error("Error in onLostLeadership callback: ".concat(cbErr_1));
236
+ return [3 /*break*/, 18];
237
+ case 18: return [3 /*break*/, 23];
238
+ case 19:
239
+ log.error("Leader election error: ".concat(err_5));
240
+ if (!this._isLeader) return [3 /*break*/, 23];
241
+ this._isLeader = false;
242
+ _e.label = 20;
243
+ case 20:
244
+ _e.trys.push([20, 22, , 23]);
245
+ return [4 /*yield*/, ((_d = this.onLostLeadership) === null || _d === void 0 ? void 0 : _d.call(this))];
246
+ case 21:
247
+ _e.sent();
248
+ return [3 /*break*/, 23];
249
+ case 22:
250
+ cbErr_2 = _e.sent();
251
+ log.error("Error in onLostLeadership callback: ".concat(cbErr_2));
252
+ return [3 /*break*/, 23];
253
+ case 23: return [3 /*break*/, 24];
254
+ case 24:
255
+ // Schedule next attempt
256
+ if (!this.stopped) {
257
+ nextInterval = this._isLeader
258
+ ? this.heartbeatIntervalMs
259
+ : this.heartbeatIntervalMs * 2;
260
+ this.timer = setTimeout(function () { return _this.tryClaimLeadership(); }, nextInterval);
261
+ }
262
+ return [2 /*return*/];
263
+ }
264
+ });
265
+ });
266
+ };
267
+ return LeaderElection;
268
+ }());
269
+ exports.LeaderElection = LeaderElection;
@@ -10,6 +10,8 @@ export * from "./FlinkRequestContext";
10
10
  export * from "./FlinkErrors";
11
11
  export * from "./FlinkPlugin";
12
12
  export * from "./FlinkJob";
13
+ export { LeaderElection } from "./LeaderElection";
14
+ export type { LeaderElectionOptions } from "./LeaderElection";
13
15
  export * from "./auth/FlinkAuthUser";
14
16
  export * from "./auth/FlinkAuthPlugin";
15
17
  export * from "./ai/FlinkTool";
package/dist/src/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.loadPluginSchemas = exports.agentInstructions = void 0;
17
+ exports.loadPluginSchemas = exports.agentInstructions = exports.LeaderElection = void 0;
18
18
  __exportStar(require("./FlinkLog"), exports);
19
19
  __exportStar(require("./FlinkLogFactory"), exports);
20
20
  __exportStar(require("./FlinkApp"), exports);
@@ -27,6 +27,8 @@ __exportStar(require("./FlinkRequestContext"), exports);
27
27
  __exportStar(require("./FlinkErrors"), exports);
28
28
  __exportStar(require("./FlinkPlugin"), exports);
29
29
  __exportStar(require("./FlinkJob"), exports);
30
+ var LeaderElection_1 = require("./LeaderElection");
31
+ Object.defineProperty(exports, "LeaderElection", { enumerable: true, get: function () { return LeaderElection_1.LeaderElection; } });
30
32
  __exportStar(require("./auth/FlinkAuthUser"), exports);
31
33
  __exportStar(require("./auth/FlinkAuthPlugin"), exports);
32
34
  __exportStar(require("./ai/FlinkTool"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.73",
3
+ "version": "2.0.0-alpha.74",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -1,6 +1,7 @@
1
1
  import { FlinkApp, autoRegisteredJobs } from "../src/FlinkApp";
2
2
  import { FlinkContext } from "../src/FlinkContext";
3
3
  import { FlinkJobFile } from "../src/FlinkJob";
4
+ import { FlinkLogFactory } from "../src/FlinkLogFactory";
4
5
 
5
6
  interface TestContext extends FlinkContext {}
6
7
 
@@ -93,3 +94,78 @@ describe("FlinkJob error handling", () => {
93
94
  expect(runCount).toBe(1);
94
95
  });
95
96
  });
97
+
98
+ describe("FlinkJob leader election with runOnAllInstances", () => {
99
+ let app: FlinkApp<TestContext>;
100
+
101
+ afterEach(async () => {
102
+ autoRegisteredJobs.length = 0;
103
+ if (app?.started) {
104
+ await app.stop();
105
+ }
106
+ });
107
+
108
+ it("should run all jobs when leader election is enabled but no db is configured", async () => {
109
+ const schedulerLog = FlinkLogFactory.createLogger("flink.scheduler");
110
+ const warnSpy = spyOn(schedulerLog, "warn");
111
+
112
+ let leaderJobRan = false;
113
+ let allInstanceJobRan = false;
114
+
115
+ autoRegisteredJobs.push({
116
+ Job: { id: "leader-only-job", afterDelay: "0ms" },
117
+ default: async () => {
118
+ leaderJobRan = true;
119
+ },
120
+ });
121
+
122
+ autoRegisteredJobs.push({
123
+ Job: { id: "all-instance-job", afterDelay: "0ms", runOnAllInstances: true },
124
+ default: async () => {
125
+ allInstanceJobRan = true;
126
+ },
127
+ });
128
+
129
+ app = new FlinkApp<TestContext>({
130
+ name: "test-leader-no-db",
131
+ disableHttpServer: true,
132
+ scheduling: { leaderElection: true },
133
+ });
134
+
135
+ await app.start();
136
+ await new Promise((resolve) => setTimeout(resolve, 50));
137
+
138
+ // Without db, falls back to running all jobs
139
+ expect(leaderJobRan).toBe(true);
140
+ expect(allInstanceJobRan).toBe(true);
141
+ expect(warnSpy).toHaveBeenCalled();
142
+ const warnMessage = warnSpy.calls.mostRecent().args[0];
143
+ expect(warnMessage).toContain("Leader election is enabled but no database is configured");
144
+ });
145
+
146
+ it("should not start scheduler for leader-only jobs when not leader", async () => {
147
+ // Without a real MongoDB, we can't fully test leader election.
148
+ // This test verifies that when leaderElection is enabled without db,
149
+ // the warning is shown and all jobs still run as a fallback.
150
+ let jobRanCount = 0;
151
+
152
+ autoRegisteredJobs.push({
153
+ Job: { id: "interval-job", interval: "50ms" },
154
+ default: async () => {
155
+ jobRanCount++;
156
+ },
157
+ });
158
+
159
+ app = new FlinkApp<TestContext>({
160
+ name: "test-no-db-fallback",
161
+ disableHttpServer: true,
162
+ scheduling: { leaderElection: true },
163
+ });
164
+
165
+ await app.start();
166
+ await new Promise((resolve) => setTimeout(resolve, 200));
167
+
168
+ // Should have run at least once since it falls back without db
169
+ expect(jobRanCount).toBeGreaterThan(0);
170
+ });
171
+ });
@@ -60,7 +60,7 @@ describe("FlinkRepo", () => {
60
60
  it("should update document", async () => {
61
61
  const createdDoc = await repo.create({ name: "bar" });
62
62
 
63
- const updatedDoc = await repo.updateOne(createdDoc._id + "", {
63
+ const updatedDoc = await repo.updateById(createdDoc._id + "", {
64
64
  name: "foo",
65
65
  "nested.field": 1,
66
66
  });
@@ -0,0 +1,174 @@
1
+ import { LeaderElection } from "../src/LeaderElection";
2
+
3
+ describe("LeaderElection", () => {
4
+ let mockCollection: any;
5
+ let mockDb: any;
6
+
7
+ beforeEach(() => {
8
+ mockCollection = {
9
+ createIndex: jasmine.createSpy("createIndex").and.resolveTo(undefined),
10
+ findOneAndUpdate: jasmine.createSpy("findOneAndUpdate"),
11
+ deleteOne: jasmine.createSpy("deleteOne").and.resolveTo(undefined),
12
+ };
13
+
14
+ mockDb = {
15
+ collection: jasmine.createSpy("collection").and.returnValue(mockCollection),
16
+ };
17
+ });
18
+
19
+ it("should create collection with provided name", () => {
20
+ new LeaderElection(mockDb, { collectionName: "_my_leader" });
21
+ expect(mockDb.collection).toHaveBeenCalledWith("_my_leader");
22
+ });
23
+
24
+ it("should use default collection name", () => {
25
+ new LeaderElection(mockDb);
26
+ expect(mockDb.collection).toHaveBeenCalledWith("_flink_leader");
27
+ });
28
+
29
+ it("should create TTL index on start", async () => {
30
+ const le = new LeaderElection(mockDb, { leaseDurationMs: 10000 });
31
+
32
+ mockCollection.findOneAndUpdate.and.resolveTo({
33
+ instanceId: "will-not-match",
34
+ });
35
+
36
+ await le.start(
37
+ () => {},
38
+ () => {}
39
+ );
40
+ await le.stop();
41
+
42
+ expect(mockCollection.createIndex).toHaveBeenCalledWith(
43
+ { lastHeartbeat: 1 },
44
+ { expireAfterSeconds: 20 } // 2x lease duration in seconds
45
+ );
46
+ });
47
+
48
+ it("should call onBecameLeader when claiming leadership", async () => {
49
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
50
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
51
+
52
+ const le = new LeaderElection(mockDb, {
53
+ leaseDurationMs: 10000,
54
+ heartbeatIntervalMs: 50000,
55
+ });
56
+
57
+ // findOneAndUpdate returns a document with our instanceId
58
+ // We need to intercept the instanceId set in the update
59
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
60
+ return Promise.resolve({
61
+ instanceId: update.$set.instanceId,
62
+ });
63
+ });
64
+
65
+ await le.start(onBecameLeader, onLostLeadership);
66
+
67
+ expect(le.isLeader).toBe(true);
68
+ expect(onBecameLeader).toHaveBeenCalledTimes(1);
69
+ expect(onLostLeadership).not.toHaveBeenCalled();
70
+
71
+ await le.stop();
72
+ });
73
+
74
+ it("should not become leader when another instance holds the lock", async () => {
75
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
76
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
77
+
78
+ const le = new LeaderElection(mockDb, {
79
+ leaseDurationMs: 10000,
80
+ heartbeatIntervalMs: 50000,
81
+ });
82
+
83
+ // findOneAndUpdate returns a document with a different instanceId
84
+ mockCollection.findOneAndUpdate.and.resolveTo({
85
+ instanceId: "other-instance",
86
+ });
87
+
88
+ await le.start(onBecameLeader, onLostLeadership);
89
+
90
+ expect(le.isLeader).toBe(false);
91
+ expect(onBecameLeader).not.toHaveBeenCalled();
92
+
93
+ await le.stop();
94
+ });
95
+
96
+ it("should handle duplicate key error gracefully", async () => {
97
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
98
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
99
+
100
+ const le = new LeaderElection(mockDb, {
101
+ leaseDurationMs: 10000,
102
+ heartbeatIntervalMs: 50000,
103
+ });
104
+
105
+ const duplicateKeyError = new Error("E11000 duplicate key");
106
+ (duplicateKeyError as any).code = 11000;
107
+ mockCollection.findOneAndUpdate.and.rejectWith(duplicateKeyError);
108
+
109
+ await le.start(onBecameLeader, onLostLeadership);
110
+
111
+ expect(le.isLeader).toBe(false);
112
+ expect(onBecameLeader).not.toHaveBeenCalled();
113
+
114
+ await le.stop();
115
+ });
116
+
117
+ it("should release leadership on stop", async () => {
118
+ const le = new LeaderElection(mockDb, {
119
+ leaseDurationMs: 10000,
120
+ heartbeatIntervalMs: 50000,
121
+ });
122
+
123
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
124
+ return Promise.resolve({
125
+ instanceId: update.$set.instanceId,
126
+ });
127
+ });
128
+
129
+ await le.start(
130
+ () => {},
131
+ () => {}
132
+ );
133
+
134
+ expect(le.isLeader).toBe(true);
135
+
136
+ await le.stop();
137
+
138
+ expect(mockCollection.deleteOne).toHaveBeenCalled();
139
+ expect(le.isLeader).toBe(false);
140
+ });
141
+
142
+ it("should call onLostLeadership when losing the lock", async () => {
143
+ const onBecameLeader = jasmine.createSpy("onBecameLeader");
144
+ const onLostLeadership = jasmine.createSpy("onLostLeadership");
145
+
146
+ const le = new LeaderElection(mockDb, {
147
+ leaseDurationMs: 10000,
148
+ heartbeatIntervalMs: 100,
149
+ });
150
+
151
+ let callCount = 0;
152
+ mockCollection.findOneAndUpdate.and.callFake((_filter: any, update: any) => {
153
+ callCount++;
154
+ if (callCount === 1) {
155
+ // First call: we become leader
156
+ return Promise.resolve({ instanceId: update.$set.instanceId });
157
+ }
158
+ // Subsequent calls: another instance took over
159
+ return Promise.resolve({ instanceId: "other-instance" });
160
+ });
161
+
162
+ await le.start(onBecameLeader, onLostLeadership);
163
+
164
+ expect(le.isLeader).toBe(true);
165
+
166
+ // Wait for next heartbeat cycle
167
+ await new Promise((resolve) => setTimeout(resolve, 200));
168
+
169
+ expect(onLostLeadership).toHaveBeenCalledTimes(1);
170
+ expect(le.isLeader).toBe(false);
171
+
172
+ await le.stop();
173
+ });
174
+ });