kuzzle 2.60.0-beta.2 → 2.60.0-beta.4

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.
Files changed (40) hide show
  1. package/dist/bin/start-kuzzle-server +2 -1
  2. package/dist/lib/api/controllers/documentController.d.ts +1 -1
  3. package/dist/lib/cluster/command.d.ts +7 -0
  4. package/dist/lib/cluster/command.js +67 -28
  5. package/dist/lib/cluster/idCardHandler.d.ts +4 -0
  6. package/dist/lib/cluster/idCardHandler.js +23 -2
  7. package/dist/lib/cluster/node.d.ts +28 -5
  8. package/dist/lib/cluster/node.js +159 -76
  9. package/dist/lib/cluster/subscriber.d.ts +2 -0
  10. package/dist/lib/cluster/subscriber.js +6 -0
  11. package/dist/lib/cluster/workers/IDCardRenewer.d.ts +6 -0
  12. package/dist/lib/cluster/workers/IDCardRenewer.js +14 -8
  13. package/dist/lib/core/auth/passportWrapper.js +3 -1
  14. package/dist/lib/core/backend/backend.d.ts +1 -1
  15. package/dist/lib/core/backend/backend.js +15 -15
  16. package/dist/lib/core/backend/backendCluster.d.ts +1 -1
  17. package/dist/lib/core/shared/store.js +4 -0
  18. package/dist/lib/core/storage/clientAdapter.d.ts +6 -0
  19. package/dist/lib/core/storage/clientAdapter.js +35 -5
  20. package/dist/lib/kerror/codes/8-cluster.json +23 -0
  21. package/dist/lib/kuzzle/exitOnFailure.d.ts +13 -0
  22. package/dist/lib/kuzzle/exitOnFailure.js +60 -0
  23. package/dist/lib/kuzzle/internalIndexHandler.js +24 -6
  24. package/dist/lib/kuzzle/kuzzle.d.ts +7 -0
  25. package/dist/lib/kuzzle/kuzzle.js +45 -2
  26. package/dist/lib/model/security/profile.d.ts +1 -1
  27. package/dist/lib/model/security/role.d.ts +1 -1
  28. package/dist/lib/model/security/user.d.ts +1 -1
  29. package/dist/lib/service/storage/7/elasticsearch.d.ts +12 -0
  30. package/dist/lib/service/storage/7/elasticsearch.js +32 -1
  31. package/dist/lib/service/storage/7/esWrapper.js +31 -2
  32. package/dist/lib/service/storage/8/elasticsearch.d.ts +12 -0
  33. package/dist/lib/service/storage/8/elasticsearch.js +32 -1
  34. package/dist/lib/service/storage/8/esWrapper.js +31 -2
  35. package/dist/lib/types/events/EventGenericDocument.d.ts +2 -1
  36. package/dist/lib/types/events/EventProtocol.d.ts +1 -1
  37. package/dist/lib/util/mutex.d.ts +2 -1
  38. package/dist/lib/util/mutex.js +7 -1
  39. package/dist/package.json +1 -1
  40. package/package.json +1 -1
@@ -69,6 +69,7 @@ const helpers_1 = require("yargs/helpers");
69
69
  * own `main`. Keep it that way.
70
70
  */
71
71
  const index_1 = require("../index");
72
+ const exitOnFailure_1 = require("../lib/kuzzle/exitOnFailure");
72
73
  /**
73
74
  * Parses one of the JSON files the command line points at. The type parameter
74
75
  * is a *claim about the file*, not a check: `JSON.parse` answers `any` and this
@@ -226,7 +227,7 @@ const run = async () => {
226
227
  }
227
228
  catch (error) {
228
229
  console.error(`[x] [ERROR] ${error instanceof Error ? error.stack : String(error)}`);
229
- process.exit(1);
230
+ await (0, exitOnFailure_1.exitOnFailure)();
230
231
  }
231
232
  };
232
233
  run();
@@ -77,7 +77,7 @@ declare class DocumentController extends NativeController {
77
77
  errors: any;
78
78
  successes: any;
79
79
  }>;
80
- export(request: KuzzleRequest): Promise<import("../../..").HttpStream>;
80
+ export(request: KuzzleRequest): Promise<import("../../types").HttpStream>;
81
81
  /**
82
82
  * @param {Request} request
83
83
  * @returns {Promise<Object>}
@@ -21,6 +21,8 @@ type CommandingNode = {
21
21
  };
22
22
  ip: string;
23
23
  nodeId: string;
24
+ /** Whether `nodeId` can be read: false until the handshake sets it. */
25
+ hasNodeId: boolean;
24
26
  activity: Activity[];
25
27
  /** Only `serialize()`: the command layer never mutates the state. */
26
28
  fullState: {
@@ -102,6 +104,11 @@ declare class ClusterCommand {
102
104
  * This method can (and should) never return a rejected promise.
103
105
  */
104
106
  listen(): Promise<void>;
107
+ /**
108
+ * Answers one request. Every request gets exactly one reply: REP/REQ
109
+ * requires it, and a request left unanswered wedges the peer's socket.
110
+ */
111
+ private answer;
105
112
  /**
106
113
  * Closes opened sockets and marks this server as closed.
107
114
  */
@@ -159,32 +159,9 @@ class ClusterCommand {
159
159
  while (this.state === stateEnum.RUNNING) {
160
160
  try {
161
161
  const [type, data] = await this.server.receive();
162
- // A REQ frame carries a topic and, for a handshake, a payload. A
163
- // request missing either is answered with DISCARDED, which is what
164
- // this socket owes every REQ — the two frames were read unchecked.
165
- switch (type?.toString()) {
166
- case commandMessages_1.commandTopic.FULLSTATE:
167
- await this.sendFullState();
168
- break;
169
- case commandMessages_1.commandTopic.HANDSHAKE:
170
- if (data === undefined) {
171
- await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
172
- break;
173
- }
174
- await this.handleHandshake(data);
175
- break;
176
- case commandMessages_1.commandTopic.RETRANSMIT:
177
- if (data === undefined) {
178
- await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
179
- break;
180
- }
181
- await this.handleRetransmit(data);
182
- break;
183
- default:
184
- // REP/REQ sockets expect a reply to each request made, so we have
185
- // to send some kind of response on an invalid request received
186
- await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
187
- }
162
+ // NOSONAR (S9382): a REP socket strictly alternates receive and send,
163
+ // so a request must be answered before the next one can be read.
164
+ await this.answer(type, data); // NOSONAR
188
165
  }
189
166
  catch (e) {
190
167
  if (this.state !== stateEnum.CLOSED) {
@@ -193,6 +170,47 @@ class ClusterCommand {
193
170
  }
194
171
  }
195
172
  }
173
+ /**
174
+ * Answers one request. Every request gets exactly one reply: REP/REQ
175
+ * requires it, and a request left unanswered wedges the peer's socket.
176
+ */
177
+ async answer(type, data) {
178
+ // The socket is bound before the handshake sets this node's id, and a
179
+ // peer still talking to a dead node on this IP can reach it (#3031). A
180
+ // node with no id yet has no state to serve and cannot take a peer in:
181
+ // every request is answered DISCARDED, which the requester treats as no
182
+ // answer.
183
+ if (!this.node.hasNodeId) {
184
+ await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
185
+ return;
186
+ }
187
+ // A REQ frame carries a topic and, for a handshake, a payload. A request
188
+ // missing either is answered with DISCARDED, which is what this socket
189
+ // owes every REQ — the two frames were read unchecked.
190
+ switch (type?.toString()) {
191
+ case commandMessages_1.commandTopic.FULLSTATE:
192
+ await this.sendFullState();
193
+ break;
194
+ case commandMessages_1.commandTopic.HANDSHAKE:
195
+ if (data === undefined) {
196
+ await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
197
+ break;
198
+ }
199
+ await this.handleHandshake(data);
200
+ break;
201
+ case commandMessages_1.commandTopic.RETRANSMIT:
202
+ if (data === undefined) {
203
+ await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
204
+ break;
205
+ }
206
+ await this.handleRetransmit(data);
207
+ break;
208
+ default:
209
+ // REP/REQ sockets expect a reply to each request made, so we have
210
+ // to send some kind of response on an invalid request received
211
+ await this.server.send([commandMessages_1.commandTopic.DISCARDED, null]);
212
+ }
213
+ }
196
214
  /**
197
215
  * Closes opened sockets and marks this server as closed.
198
216
  */
@@ -339,7 +357,19 @@ class ClusterCommand {
339
357
  // No payload message for a full state request
340
358
  await req.send([commandMessages_1.commandTopic.FULLSTATE, null]);
341
359
  try {
342
- [, fullState = null] = await req.receive();
360
+ // NOSONAR (S9382): one node at a time by design, the loop stops at
361
+ // the first one that serves a full state.
362
+ const [topic, payload] = await req.receive(); // NOSONAR
363
+ // DISCARDED carries an empty frame, which would decode as an empty
364
+ // full state: a node that cannot serve one (#3031) is skipped like
365
+ // one that does not answer.
366
+ if (topic?.toString() === commandMessages_1.commandTopic.FULLSTATE && payload) {
367
+ fullState = payload;
368
+ }
369
+ else {
370
+ this.logger.warn(`Unable to fetch a full state from node ${id} (request discarded)`);
371
+ idx = (idx + 1) % nodes.length;
372
+ }
343
373
  }
344
374
  catch {
345
375
  // no response from the remote node in a timely fashion... retrying
@@ -403,7 +433,16 @@ class ClusterCommand {
403
433
  await req.send([commandMessages_1.commandTopic.HANDSHAKE, payload]);
404
434
  let response = null;
405
435
  try {
406
- [, response = null] = await req.receive();
436
+ const [topic, answer] = await req.receive();
437
+ // DISCARDED carries an empty frame, which would decode as a handshake
438
+ // answer with nothing in it: a node that refused the handshake (#3031)
439
+ // is one that did not answer.
440
+ if (topic?.toString() === commandMessages_1.commandTopic.HANDSHAKE && answer) {
441
+ response = answer;
442
+ }
443
+ else {
444
+ this.logger.warn(`Couldn't complete handshake with node ${id}: request discarded`);
445
+ }
407
446
  }
408
447
  catch {
409
448
  this.logger.warn(`Couldn't complete handshake with node ${id}: no response received`);
@@ -87,6 +87,10 @@ export declare class ClusterIdCardHandler {
87
87
  private disposed;
88
88
  private readonly logger;
89
89
  constructor(node: ClusterNode);
90
+ /**
91
+ * How long an ID card outlives the last renewal of its node, in ms
92
+ */
93
+ get ttl(): number;
90
94
  /**
91
95
  * Generates and reserves a unique ID for this node instance.
92
96
  * Makes sure that the ID is not already taken by another node instance.
@@ -94,6 +94,12 @@ class ClusterIdCardHandler {
94
94
  this.ip = node.ip;
95
95
  this.refreshDelay = node.heartbeatDelay;
96
96
  }
97
+ /**
98
+ * How long an ID card outlives the last renewal of its node, in ms
99
+ */
100
+ get ttl() {
101
+ return this.refreshDelay * this.refreshMultiplier;
102
+ }
97
103
  /**
98
104
  * Generates and reserves a unique ID for this node instance.
99
105
  * Makes sure that the ID is not already taken by another node instance.
@@ -139,7 +145,8 @@ class ClusterIdCardHandler {
139
145
  const refreshWorker = this.constructWorker(`${__dirname}/workers/IDCardRenewer${(0, node_path_1.extname)(__filename)}`);
140
146
  this.refreshWorker = refreshWorker;
141
147
  refreshWorker.on("message", async (message) => {
142
- if (message.error) {
148
+ // A renewal racing the disposal finds the card deleted: not a failure
149
+ if (message.error && !this.disposed) {
143
150
  await this.node.evictSelf(message.error);
144
151
  }
145
152
  });
@@ -228,6 +235,20 @@ class ClusterIdCardHandler {
228
235
  // It could happens that the worker has been killed before the dispose causing send to fail
229
236
  }
230
237
  }
238
+ clearInterval(this.refreshTimer ?? undefined);
239
+ this.refreshTimer = null;
240
+ // The worker deletes the card too, but nothing waits for it: the process
241
+ // usually exits first, and the card then outlives it by a TTL, long
242
+ // enough for a node restarted on this IP to take it for a live duplicate
243
+ if (this.nodeIdKey !== null) {
244
+ try {
245
+ await global.kuzzle.ask("core:cache:internal:del", this.nodeIdKey);
246
+ await global.kuzzle.ask("core:cache:internal:execute", "srem", REDIS_ID_CARDS_INDEX, this.nodeIdKey);
247
+ }
248
+ catch (error) {
249
+ this.logger.warn(`Could not delete the ID card ${this.nodeIdKey}: it expires in ${this.ttl}ms (${error instanceof Error ? error.message : String(error)})`);
250
+ }
251
+ }
231
252
  }
232
253
  /**
233
254
  * Retrieves the ID cards from other nodes
@@ -309,7 +330,7 @@ class ClusterIdCardHandler {
309
330
  if (!this.idCard) {
310
331
  return false;
311
332
  }
312
- return global.kuzzle.ask("core:cache:internal:store", this.nodeIdKey, JSON.stringify(this.idCard.serialize()), { onlyIfNew: creation, ttl: this.refreshDelay * this.refreshMultiplier });
333
+ return global.kuzzle.ask("core:cache:internal:store", this.nodeIdKey, JSON.stringify(this.idCard.serialize()), { onlyIfNew: creation, ttl: this.ttl });
313
334
  }
314
335
  }
315
336
  exports.ClusterIdCardHandler = ClusterIdCardHandler;
@@ -41,10 +41,21 @@ declare class ClusterNode {
41
41
  * The id of the ID card this node reserved during `handshake()`.
42
42
  *
43
43
  * Null until the handshake gets that far, and read as a `string` at
44
- * seventeen sites here plus `subscriber.ts` and `command.ts` — all of which
45
- * run after it. The accessor says once where that can fail.
44
+ * seventeen sites here plus `subscriber.ts` and `command.ts`. The accessor
45
+ * says once where that can fail: an internal read before the handshake is a
46
+ * bug, and it throws.
47
+ *
48
+ * Network input is not internal: the command socket is bound before the
49
+ * handshake, and a node restarted on the IP of a dead one can be reached by
50
+ * peers still talking to that dead node (#3031). The paths network input
51
+ * reaches check `hasNodeId` first.
46
52
  */
47
53
  get nodeId(): string;
54
+ /**
55
+ * Whether the handshake has set this node's id yet: the non-throwing check
56
+ * for the paths network input reaches before it has (#3031).
57
+ */
58
+ get hasNodeId(): boolean;
48
59
  set nodeId(id: string);
49
60
  private heartbeatTimer;
50
61
  readonly idCardHandler: ClusterIdCardHandler;
@@ -143,16 +154,28 @@ declare class ClusterNode {
143
154
  *
144
155
  * Extracted from `handshake()` unchanged.
145
156
  *
146
- * @returns false when the caller must stop — the cluster is split and this
147
- * node is shutting down
157
+ * @throws {cluster.join.full_state_unavailable} on the second failure: the
158
+ * cluster is split
148
159
  */
149
160
  private retryFullState;
161
+ /**
162
+ * The ID cards of the other nodes, none of which has this node's IP.
163
+ *
164
+ * A card with this IP may be the stale one of a previous run of this node
165
+ * (a process killed, a container restarted at once): it expires within a
166
+ * TTL, so it is only a real duplicate if it is still there after that.
167
+ *
168
+ * @throws {cluster.join.duplicate_ip} if it is
169
+ */
170
+ private discoverNodes;
150
171
  /**
151
172
  * Discovers other active nodes from the cluster and, if other nodes exist,
152
173
  * starts a handshake procedure to sync this node and to make it able to
153
174
  * handle new client requests
154
175
  *
155
- * @return {void}
176
+ * Rejects with a `cluster.join.*` error, and shuts the node down, when it
177
+ * cannot join: the node must not go on starting as if it had (a duplicate
178
+ * IP, a split cluster, `joinTimeout` reached).
156
179
  */
157
180
  handshake(): Promise<void>;
158
181
  /**
@@ -19,6 +19,39 @@
19
19
  * See the License for the specific language governing permissions and
20
20
  * limitations under the License.
21
21
  */
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
22
55
  var __importDefault = (this && this.__importDefault) || function (mod) {
23
56
  return (mod && mod.__esModule) ? mod : { "default": mod };
24
57
  };
@@ -37,6 +70,8 @@ const eventemitter3_1 = __importDefault(require("eventemitter3"));
37
70
  const intersection_1 = __importDefault(require("lodash/intersection"));
38
71
  const xor_1 = __importDefault(require("lodash/xor"));
39
72
  const long_1 = __importDefault(require("long"));
73
+ const kerror = __importStar(require("../kerror"));
74
+ const kuzzleError_1 = require("../kerror/errors/kuzzleError");
40
75
  const kuzzleStateEnum_1 = __importDefault(require("../kuzzle/kuzzleStateEnum"));
41
76
  const debug_1 = __importDefault(require("../util/debug"));
42
77
  const koncordeCompat_1 = require("../util/koncordeCompat");
@@ -47,6 +82,7 @@ const publisher_1 = __importDefault(require("./publisher"));
47
82
  const state_1 = __importDefault(require("./state"));
48
83
  const subscriber_1 = __importDefault(require("./subscriber"));
49
84
  const debug = (0, debug_1.default)("kuzzle:cluster:sync");
85
+ const joinError = kerror.wrap("cluster", "join");
50
86
  /** Cluster-wide events carrying the collection write locks (see registerEvents) */
51
87
  const COLLECTION_LOCK_EVENT = "core:storage:collection:lock";
52
88
  const COLLECTION_UNLOCK_EVENT = "core:storage:collection:unlock";
@@ -164,8 +200,14 @@ class ClusterNode {
164
200
  * The id of the ID card this node reserved during `handshake()`.
165
201
  *
166
202
  * Null until the handshake gets that far, and read as a `string` at
167
- * seventeen sites here plus `subscriber.ts` and `command.ts` — all of which
168
- * run after it. The accessor says once where that can fail.
203
+ * seventeen sites here plus `subscriber.ts` and `command.ts`. The accessor
204
+ * says once where that can fail: an internal read before the handshake is a
205
+ * bug, and it throws.
206
+ *
207
+ * Network input is not internal: the command socket is bound before the
208
+ * handshake, and a node restarted on the IP of a dead one can be reached by
209
+ * peers still talking to that dead node (#3031). The paths network input
210
+ * reaches check `hasNodeId` first.
169
211
  */
170
212
  get nodeId() {
171
213
  if (this._nodeId === null) {
@@ -173,6 +215,13 @@ class ClusterNode {
173
215
  }
174
216
  return this._nodeId;
175
217
  }
218
+ /**
219
+ * Whether the handshake has set this node's id yet: the non-throwing check
220
+ * for the paths network input reaches before it has (#3031).
221
+ */
222
+ get hasNodeId() {
223
+ return this._nodeId !== null;
224
+ }
176
225
  set nodeId(id) {
177
226
  this._nodeId = id;
178
227
  }
@@ -332,7 +381,7 @@ class ClusterNode {
332
381
  //
333
382
  // Exit code 1: this is a failure, and a node that exits 0 is not restarted
334
383
  // by an `on-failure` policy (#2785).
335
- global.kuzzle.shutdown(1);
384
+ void global.kuzzle.shutdown(1);
336
385
  }
337
386
  /**
338
387
  * Evicts a remote from the list
@@ -353,8 +402,11 @@ class ClusterNode {
353
402
  this.remoteNodes.delete(nodeId);
354
403
  this.fullState.removeNode(nodeId);
355
404
  subscriber.dispose();
356
- if (broadcast) {
357
- this.publisher.sendNodeEvicted(this.nodeId, nodeId, reason);
405
+ // A subscriber can evict its remote node (heartbeat lost, gap, invalid
406
+ // message) before this node's handshake: there is no evictor id to
407
+ // broadcast then, and the eviction stays local (#3031).
408
+ if (broadcast && this._nodeId !== null) {
409
+ this.publisher.sendNodeEvicted(this._nodeId, nodeId, reason);
358
410
  }
359
411
  if (this.countActiveNodes() < this.config.minimumNodes) {
360
412
  global.kuzzle.state = kuzzleStateEnum_1.default.NOT_ENOUGH_NODES;
@@ -388,14 +440,15 @@ class ClusterNode {
388
440
  const candidates = this.electShutdownCandidates(splits, idCards);
389
441
  if (candidates.includes(this.nodeId)) {
390
442
  this.logger.error("[CLUSTER] Network split detected. This node is outside the cluster: shutting down.");
391
- global.kuzzle.shutdown();
443
+ // A failure, like `evictSelf`'s: exit non-zero (#2785)
444
+ void global.kuzzle.shutdown(1);
392
445
  return;
393
446
  }
394
447
  }
395
448
  catch (err) {
396
449
  this.logger.error("[CLUSTER] Unexpected exception caught during a cluster consistency check. Shutting down...");
397
450
  this.logger.error(err instanceof Error ? err.stack : (0, util_1.inspect)(err));
398
- global.kuzzle.shutdown();
451
+ void global.kuzzle.shutdown(1);
399
452
  }
400
453
  }
401
454
  /**
@@ -523,8 +576,8 @@ class ClusterNode {
523
576
  *
524
577
  * Extracted from `handshake()` unchanged.
525
578
  *
526
- * @returns false when the caller must stop — the cluster is split and this
527
- * node is shutting down
579
+ * @throws {cluster.join.full_state_unavailable} on the second failure: the
580
+ * cluster is split
528
581
  */
529
582
  async retryFullState(retried) {
530
583
  // Uh oh... no node was able to give us the full state.
@@ -533,9 +586,7 @@ class ClusterNode {
533
586
  // means we're probably facing a network split, and we must then shut
534
587
  // down.
535
588
  if (retried) {
536
- this.logger.error("[CLUSTER] Could not connect to discovered cluster nodes (network split detected). Shutting down.");
537
- global.kuzzle.shutdown();
538
- return false;
589
+ throw joinError.get("full_state_unavailable");
539
590
  }
540
591
  // Disposes all subscribers
541
592
  for (const subscriber of this.remoteNodes.values()) {
@@ -546,81 +597,110 @@ class ClusterNode {
546
597
  const retryDelay = this.heartbeatDelay * 1.5;
547
598
  this.logger.warn(`[CLUSTER] Unable to connect to discovered cluster nodes. Retrying in ${retryDelay}ms...`);
548
599
  await bluebird_1.default.delay(retryDelay);
549
- return true;
600
+ }
601
+ /**
602
+ * The ID cards of the other nodes, none of which has this node's IP.
603
+ *
604
+ * A card with this IP may be the stale one of a previous run of this node
605
+ * (a process killed, a container restarted at once): it expires within a
606
+ * TTL, so it is only a real duplicate if it is still there after that.
607
+ *
608
+ * @throws {cluster.join.duplicate_ip} if it is
609
+ */
610
+ async discoverNodes() {
611
+ const nodes = await this.idCardHandler.getRemoteIdCards();
612
+ if (!nodes.some((node) => node.ip === this.ip)) {
613
+ return nodes;
614
+ }
615
+ // A heartbeat more than the TTL: the card's last renewal may be in flight
616
+ const delay = this.idCardHandler.ttl + this.heartbeatDelay;
617
+ this.logger.warn(`[CLUSTER] Another node has the same IP address as this one (${this.ip}). Waiting ${delay}ms for it to expire, in case it is a stale ID card...`);
618
+ await bluebird_1.default.delay(delay);
619
+ const remaining = await this.idCardHandler.getRemoteIdCards();
620
+ const duplicate = remaining.find((node) => node.ip === this.ip);
621
+ if (duplicate) {
622
+ throw joinError.get("duplicate_ip", this.ip, duplicate.id);
623
+ }
624
+ return remaining;
550
625
  }
551
626
  /**
552
627
  * Discovers other active nodes from the cluster and, if other nodes exist,
553
628
  * starts a handshake procedure to sync this node and to make it able to
554
629
  * handle new client requests
555
630
  *
556
- * @return {void}
631
+ * Rejects with a `cluster.join.*` error, and shuts the node down, when it
632
+ * cannot join: the node must not go on starting as if it had (a duplicate
633
+ * IP, a split cluster, `joinTimeout` reached).
557
634
  */
558
635
  async handshake() {
559
- const handshakeTimeout = setTimeout(() => {
560
- this.logger.error(`[CLUSTER] Failed to join the cluster: timed out (joinTimeout: ${this.config.joinTimeout}ms)`);
561
- global.kuzzle.shutdown();
562
- }, this.config.joinTimeout);
636
+ let timedOut = false;
637
+ let handshakeTimeout;
638
+ const timeout = new Promise((_resolve, reject) => {
639
+ handshakeTimeout = setTimeout(() => {
640
+ timedOut = true;
641
+ reject(joinError.get("timeout", this.config.joinTimeout));
642
+ }, this.config.joinTimeout);
643
+ });
563
644
  // No retry limit: the handshakes are taken one at a time, and the
564
645
  // `joinTimeout` above is what gives up on a cluster that never lets this
565
646
  // node in.
647
+ const join = (0, distributedLock_1.withLock)("clusterHandshake", async () => {
648
+ // Given up on while waiting for the lock: do not join after all
649
+ if (timedOut) {
650
+ return;
651
+ }
652
+ // Create the ID Key AFTER the handshake lock is actually taken,
653
+ // to prevent race conditions (other nodes attempting to connect to this
654
+ // node while it's still initializing)
655
+ await this.idCardHandler.createIdCard();
656
+ debug("[CLUSTER] ID Card created");
657
+ const nodeId = this.idCardHandler.nodeId;
658
+ (0, assert_1.default)(nodeId !== null, "[CLUSTER] createIdCard() returned without reserving a node id");
659
+ this.nodeId = nodeId;
660
+ await this.startHeartbeat(); // NOSONAR: TD-26
661
+ debug("[CLUSTER] Start heartbeat");
662
+ let retried = false;
663
+ let fullState;
664
+ let nodes;
665
+ debug("[CLUSTER] Start retrieving full state..");
666
+ do {
667
+ nodes = await this.discoverNodes();
668
+ debug("[CLUSTER] %s remote nodes discovered", nodes.length);
669
+ // No other nodes detected = no handshake required
670
+ if (nodes.length === 0) {
671
+ this.trackActivity(this.nodeId, this.ip, nodeActivityEnum.ADDED);
672
+ return;
673
+ }
674
+ await this.subscribeToNodes(nodes);
675
+ debug("[CLUSTER] Successfully subscribed to nodes");
676
+ fullState = await this.command.getFullState(nodes);
677
+ if (fullState === null) {
678
+ // Sequential by design: the retry waits for a heartbeat round
679
+ await this.retryFullState(retried); // NOSONAR
680
+ retried = true;
681
+ }
682
+ } while (fullState === null);
683
+ debug("[CLUSTER] Fullstate retrieved, loading into node..");
684
+ await this.fullState.loadFullState(fullState); // NOSONAR: TD-26
685
+ this.activity = fullState.activity ?? this.activity;
686
+ debug("[CLUSTER] Fullstate loaded.");
687
+ const handshakeResponses = await this.command.broadcastHandshake(nodes);
688
+ debug("[CLUSTER] Successful handshakes with other nodes.");
689
+ await this.syncWithPeers(handshakeResponses, fullState);
690
+ // `createIdCard()` adopts `global.nodeId`, so these are normally the same
691
+ // string and the line reads as one name (TD-59, #2764). They still differ
692
+ // in the two cases the adoption cannot cover — a stale IdCard holding the
693
+ // key, and a Kuzzle running without a Backend — and those are exactly the
694
+ // cases where a reader needs to be told, so the line says both.
695
+ this.logger.info(global.nodeId && global.nodeId !== this.nodeId
696
+ ? `[CLUSTER] Successfully joined the cluster as "${this.nodeId}" (process node id: ${global.nodeId}).`
697
+ : `[CLUSTER] Successfully joined the cluster as "${this.nodeId}".`);
698
+ this.trackActivity(this.nodeId, this.ip, nodeActivityEnum.ADDED);
699
+ }, { retryAttempts: Number.POSITIVE_INFINITY });
566
700
  try {
567
- await (0, distributedLock_1.withLock)("clusterHandshake", async () => {
568
- // Create the ID Key AFTER the handshake lock is actually taken,
569
- // to prevent race conditions (other nodes attempting to connect to this
570
- // node while it's still initializing)
571
- await this.idCardHandler.createIdCard();
572
- debug("[CLUSTER] ID Card created");
573
- const nodeId = this.idCardHandler.nodeId;
574
- (0, assert_1.default)(nodeId !== null, "[CLUSTER] createIdCard() returned without reserving a node id");
575
- this.nodeId = nodeId;
576
- await this.startHeartbeat(); // NOSONAR: TD-26
577
- debug("[CLUSTER] Start heartbeat");
578
- let retried = false;
579
- let fullState;
580
- let nodes;
581
- debug("[CLUSTER] Start retrieving full state..");
582
- do {
583
- nodes = await this.idCardHandler.getRemoteIdCards();
584
- debug("[CLUSTER] %s remote nodes discovered", nodes.length);
585
- // No other nodes detected = no handshake required
586
- if (nodes.length === 0) {
587
- this.trackActivity(this.nodeId, this.ip, nodeActivityEnum.ADDED);
588
- return;
589
- }
590
- // Verify that no other node share the same IP address as this one
591
- const duplicate = nodes.filter((node) => node.ip === this.ip);
592
- if (duplicate.length > 0) {
593
- this.logger.error(`[CLUSTER] Another node share the same IP address as this one (${this.ip}): ${duplicate[0]?.id}. Shutting down.`);
594
- global.kuzzle.shutdown();
595
- return;
596
- }
597
- await this.subscribeToNodes(nodes);
598
- debug("[CLUSTER] Successfully subscribed to nodes");
599
- fullState = await this.command.getFullState(nodes);
600
- if (fullState === null) {
601
- if (!(await this.retryFullState(retried))) {
602
- return;
603
- }
604
- retried = true;
605
- }
606
- } while (fullState === null);
607
- debug("[CLUSTER] Fullstate retrieved, loading into node..");
608
- await this.fullState.loadFullState(fullState); // NOSONAR: TD-26
609
- this.activity = fullState.activity ?? this.activity;
610
- debug("[CLUSTER] Fullstate loaded.");
611
- const handshakeResponses = await this.command.broadcastHandshake(nodes);
612
- debug("[CLUSTER] Successful handshakes with other nodes.");
613
- await this.syncWithPeers(handshakeResponses, fullState);
614
- // `createIdCard()` adopts `global.nodeId`, so these are normally the same
615
- // string and the line reads as one name (TD-59, #2764). They still differ
616
- // in the two cases the adoption cannot cover — a stale IdCard holding the
617
- // key, and a Kuzzle running without a Backend — and those are exactly the
618
- // cases where a reader needs to be told, so the line says both.
619
- this.logger.info(global.nodeId && global.nodeId !== this.nodeId
620
- ? `[CLUSTER] Successfully joined the cluster as "${this.nodeId}" (process node id: ${global.nodeId}).`
621
- : `[CLUSTER] Successfully joined the cluster as "${this.nodeId}".`);
622
- this.trackActivity(this.nodeId, this.ip, nodeActivityEnum.ADDED);
623
- }, { retryAttempts: Number.POSITIVE_INFINITY });
701
+ // A timeout does not stop `join`: the node it settles later is shutting
702
+ // down already
703
+ await Promise.race([join, timeout]);
624
704
  }
625
705
  catch (error) {
626
706
  // The handshake cannot be stopped where it is: it may still introduce
@@ -628,7 +708,10 @@ class ClusterNode {
628
708
  // half-joined, whatever the application does with the failed startup.
629
709
  if (error instanceof distributedLock_1.MutexLockLostError) {
630
710
  this.logger.error("[CLUSTER] Lost the handshake lock while joining the cluster. Shutting down.");
631
- void global.kuzzle.shutdown();
711
+ }
712
+ if (error instanceof distributedLock_1.MutexLockLostError ||
713
+ (error instanceof kuzzleError_1.KuzzleError && error.id.startsWith("cluster.join."))) {
714
+ void global.kuzzle.shutdown(1);
632
715
  }
633
716
  throw error;
634
717
  }
@@ -15,6 +15,8 @@ type SubscribingNode = {
15
15
  config: IKuzzleConfiguration["cluster"];
16
16
  heartbeatDelay: number;
17
17
  nodeId: string;
18
+ /** Whether `nodeId` can be read: false until the handshake sets it. */
19
+ hasNodeId: boolean;
18
20
  fullState: State;
19
21
  eventEmitter: {
20
22
  emit(event: string, payload: unknown): void;
@@ -383,6 +383,12 @@ class ClusterSubscriber {
383
383
  * @param message - decoded NodeEvicted protobuf message
384
384
  */
385
385
  async handleNodeEviction(message) {
386
+ // Before the handshake this node has no id to compare, and is not part of
387
+ // the cluster the eviction is about: the message is ignored (#3031).
388
+ if (!this.localNode.hasNodeId) {
389
+ debug("[CLUSTER] Eviction of node %s by %s ignored: the handshake has not run", message.nodeId, message.evictor);
390
+ return;
391
+ }
386
392
  if (message.nodeId === this.localNode.nodeId) {
387
393
  this.logger.error(`[CLUSTER] Node evicted by ${message.evictor}. Reason: ${message.reason}`);
388
394
  // A failure, like `evictSelf`'s: exit non-zero (#2785).
@@ -38,6 +38,12 @@ declare class IDCardRenewer {
38
38
  init(config: IDCardRenewerConfig): Promise<void>;
39
39
  initRedis(config?: RedisServiceConfig, name?: string): Promise<void>;
40
40
  renewIDCard(): Promise<void>;
41
+ /**
42
+ * Disposes the worker and tells the parent why, once: the renewals queued
43
+ * behind a stalled redis all fail together, and each report evicts the node
44
+ * again (#3017)
45
+ */
46
+ private reportFailure;
41
47
  dispose(): Promise<void>;
42
48
  }
43
49
  export { IDCardRenewer };