@flink-app/flink 2.0.0-alpha.73 → 2.0.0-alpha.75
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.
- package/CHANGELOG.md +12 -0
- package/dist/src/FlinkApp.d.ts +32 -0
- package/dist/src/FlinkApp.js +114 -19
- package/dist/src/FlinkJob.d.ts +10 -0
- package/dist/src/FlinkRepo.d.ts +9 -1
- package/dist/src/FlinkRepo.js +11 -1
- package/dist/src/LeaderElection.d.ts +45 -0
- package/dist/src/LeaderElection.js +269 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +3 -1
- package/package.json +1 -1
- package/spec/FlinkJob.spec.ts +76 -0
- package/spec/FlinkRepo.spec.ts +1 -1
- package/spec/LeaderElection.spec.ts +174 -0
- package/src/FlinkApp.ts +113 -34
- package/src/FlinkJob.ts +11 -0
- package/src/FlinkRepo.ts +9 -2
- package/src/LeaderElection.ts +203 -0
- package/src/index.ts +2 -0
|
@@ -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;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
package/spec/FlinkJob.spec.ts
CHANGED
|
@@ -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
|
+
});
|
package/spec/FlinkRepo.spec.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|