kuzzle 2.60.0-beta.3 → 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.
@@ -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`);
@@ -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;
@@ -200,8 +200,14 @@ class ClusterNode {
200
200
  * The id of the ID card this node reserved during `handshake()`.
201
201
  *
202
202
  * Null until the handshake gets that far, and read as a `string` at
203
- * seventeen sites here plus `subscriber.ts` and `command.ts` — all of which
204
- * 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.
205
211
  */
206
212
  get nodeId() {
207
213
  if (this._nodeId === null) {
@@ -209,6 +215,13 @@ class ClusterNode {
209
215
  }
210
216
  return this._nodeId;
211
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
+ }
212
225
  set nodeId(id) {
213
226
  this._nodeId = id;
214
227
  }
@@ -389,8 +402,11 @@ class ClusterNode {
389
402
  this.remoteNodes.delete(nodeId);
390
403
  this.fullState.removeNode(nodeId);
391
404
  subscriber.dispose();
392
- if (broadcast) {
393
- 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);
394
410
  }
395
411
  if (this.countActiveNodes() < this.config.minimumNodes) {
396
412
  global.kuzzle.state = kuzzleStateEnum_1.default.NOT_ENOUGH_NODES;
@@ -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).
@@ -1,6 +1,6 @@
1
1
  import type { JSONObject } from "../../types/JSONObject";
2
2
  import { EmbeddedSDK } from "../shared/sdk/embeddedSdk";
3
- import type { EventDefinition } from "../../../index";
3
+ import type { EventDefinition } from "../../types/EventHandler";
4
4
  import { BackendCluster, BackendConfig, BackendController, BackendHook, BackendImport, BackendPipe, BackendPlugin, BackendStorage, BackendVault, BackendOpenApi, BackendErrors, BackendSubscription } from "./index";
5
5
  import { Logger } from "../../kuzzle/Logger";
6
6
  import Plugin from "../plugin/plugin";
@@ -61,8 +61,8 @@ const node_fs_1 = __importDefault(require("node:fs"));
61
61
  const kuzzle_1 = __importDefault(require("../../kuzzle"));
62
62
  const embeddedSdk_1 = require("../shared/sdk/embeddedSdk");
63
63
  const kerror = __importStar(require("../../kerror"));
64
- const index_1 = require("../../../index");
65
- const index_2 = require("./index");
64
+ const name_generator_1 = require("../../util/name-generator");
65
+ const index_1 = require("./index");
66
66
  const Logger_1 = require("../../kuzzle/Logger");
67
67
  const plugin_1 = __importDefault(require("../plugin/plugin"));
68
68
  const assertionError = kerror.wrap("plugin", "assert");
@@ -130,7 +130,7 @@ class Backend {
130
130
  throw assertionError.get("invalid_application_name", name);
131
131
  }
132
132
  this._name = name;
133
- this.nodeId = index_1.NameGenerator.generateRandomName({ prefix: "knode" });
133
+ this.nodeId = name_generator_1.NameGenerator.generateRandomName({ prefix: "knode" });
134
134
  Reflect.defineProperty(this, "_kuzzle", {
135
135
  writable: true,
136
136
  });
@@ -162,18 +162,18 @@ class Backend {
162
162
  // Silent if no version can be found
163
163
  }
164
164
  global.app = this;
165
- this.pipe = new index_2.BackendPipe(this);
166
- this.hook = new index_2.BackendHook(this);
167
- this.config = new index_2.BackendConfig(this);
168
- this.vault = new index_2.BackendVault(this);
169
- this.controller = new index_2.BackendController(this);
170
- this.plugin = new index_2.BackendPlugin(this);
171
- this.storage = new index_2.BackendStorage(this);
172
- this.import = new index_2.BackendImport(this);
173
- this.cluster = new index_2.BackendCluster();
174
- this.openApi = new index_2.BackendOpenApi(this);
175
- this.errors = new index_2.BackendErrors(this);
176
- this.subscription = new index_2.BackendSubscription(this);
165
+ this.pipe = new index_1.BackendPipe(this);
166
+ this.hook = new index_1.BackendHook(this);
167
+ this.config = new index_1.BackendConfig(this);
168
+ this.vault = new index_1.BackendVault(this);
169
+ this.controller = new index_1.BackendController(this);
170
+ this.plugin = new index_1.BackendPlugin(this);
171
+ this.storage = new index_1.BackendStorage(this);
172
+ this.import = new index_1.BackendImport(this);
173
+ this.cluster = new index_1.BackendCluster();
174
+ this.openApi = new index_1.BackendOpenApi(this);
175
+ this.errors = new index_1.BackendErrors(this);
176
+ this.subscription = new index_1.BackendSubscription(this);
177
177
  this.log = new Logger_1.Logger(this.config.content, "kuzzle:app");
178
178
  this.kerror = kerror;
179
179
  try {
@@ -1,4 +1,4 @@
1
- import type { EventDefinition, ClusterEventHandler } from "../../../index";
1
+ import type { EventDefinition, ClusterEventHandler } from "../../types/EventHandler";
2
2
  export declare class BackendCluster {
3
3
  /**
4
4
  * Broadcasts an event to listeners across all registered Kuzzle nodes
@@ -156,6 +156,10 @@ class Store {
156
156
  }
157
157
  const exist = await global.kuzzle.ask(`core:storage:${this.scope}:collection:exist`, this.index, collection);
158
158
  if (exist) {
159
+ // Another node created it, maybe so recently that its primary
160
+ // shards are still initializing: a document request on it would
161
+ // get a 503 until they are active
162
+ await global.kuzzle.ask(`core:storage:${this.scope}:collection:shards:wait`, this.index, collection);
159
163
  // @deprecated
160
164
  const dynamicSettings = isConfigDeprecated
161
165
  ? null
@@ -41,6 +41,12 @@ declare class ClientAdapter {
41
41
  * Also checks for duplicated index names.
42
42
  */
43
43
  populateCache(): Promise<void>;
44
+ /**
45
+ * Adds the indexes and collections found in storage to the index cache,
46
+ * then reloads the collection write locks. Unlike `populateCache`, it never
47
+ * writes to storage.
48
+ */
49
+ reloadCache(): Promise<void>;
44
50
  /**
45
51
  * Replaces the cached collection write locks with the cluster-wide ones,
46
52
  * stored in Redis.
@@ -56,6 +56,7 @@ const Elasticsearch_1 = require("../../service/storage/Elasticsearch");
56
56
  const indexCache_1 = require("./indexCache");
57
57
  const safeObject_1 = require("../../util/safeObject");
58
58
  const kerror = __importStar(require("../../kerror"));
59
+ const kuzzleError_1 = require("../../kerror/errors/kuzzleError");
59
60
  const distributedLock_1 = require("../../util/distributedLock");
60
61
  require("../../types/Global");
61
62
  const servicesError = kerror.wrap("services", "storage");
@@ -93,10 +94,11 @@ class ClientAdapter {
93
94
  this.registerMappingEvents();
94
95
  this.registerCacheEvents();
95
96
  this.registerLockEvents();
96
- // The locks were first loaded with the cache, before this node joined the
97
- // cluster: one taken in between was broadcast before this node could hear
98
- // it. Reading them again once the cluster is joined closes that gap.
99
- global.kuzzle.onPipe("kuzzle:state:live", () => this.loadLocks());
97
+ // The cache and the locks were first loaded before this node joined the
98
+ // cluster: an index, a collection or a lock a peer added in between was
99
+ // broadcast before this node could hear it. Reading them again once the
100
+ // cluster is joined closes that gap.
101
+ global.kuzzle.onPipe("kuzzle:state:live", () => this.reloadCache());
100
102
  // Global store events registration
101
103
  /**
102
104
  * Manually refresh the index cache (e.g. after alias creation)
@@ -120,7 +122,20 @@ class ClientAdapter {
120
122
  throw servicesError.get("index_already_exists", this.scope, index);
121
123
  }
122
124
  if (!indexCacheOnly) {
123
- await this.client.createIndex(index);
125
+ try {
126
+ await this.client.createIndex(index);
127
+ }
128
+ catch (error) {
129
+ // The index exists in storage but not in this node's cache: a peer
130
+ // created it and its cache broadcast never reached this node (e.g. the
131
+ // peer stopped before this node joined the cluster). Cache it so that
132
+ // the next request to that index finds it, then report the conflict.
133
+ if (error instanceof kuzzleError_1.KuzzleError &&
134
+ error.id === "services.storage.index_already_exists") {
135
+ this.cache.addIndex(index);
136
+ }
137
+ throw error;
138
+ }
124
139
  }
125
140
  this.cache.addIndex(index);
126
141
  if (propagate) {
@@ -191,6 +206,14 @@ class ClientAdapter {
191
206
  if (global.kuzzle.config.services.storageEngine.generateMissingAliases) {
192
207
  await this.client.generateMissingAliases();
193
208
  }
209
+ await this.reloadCache();
210
+ }
211
+ /**
212
+ * Adds the indexes and collections found in storage to the index cache,
213
+ * then reloads the collection write locks. Unlike `populateCache`, it never
214
+ * writes to storage.
215
+ */
216
+ async reloadCache() {
194
217
  // `client` is untyped (see the field's comment), so the schema shape is
195
218
  // stated here: index name -> its collection names.
196
219
  const schema = await this.client.getSchema();
@@ -311,6 +334,13 @@ class ClientAdapter {
311
334
  this.cache.assertCollectionExists(index, collection);
312
335
  return this.client.getSettings(index, collection);
313
336
  });
337
+ /**
338
+ * Wait, for a bounded time, until the primary shards of a collection
339
+ * another node has just created are active
340
+ * @param {string} index
341
+ * @param {string} collection
342
+ */
343
+ global.kuzzle.onAsk(`core:storage:${this.scope}:collection:shards:wait`, (index, collection) => this.client.waitForCollectionShards(index, collection));
314
344
  /**
315
345
  * Check a collection existence
316
346
  * @param {string} index
@@ -132,8 +132,11 @@ class InternalIndexHandler extends store_1.Store {
132
132
  return;
133
133
  }
134
134
  await bluebird_1.default.resolve(this._bootstrapSequence()).timeout(this.timeout);
135
- await this.create("config", { timestamp: Date.now() }, {
136
- id: this._BOOTSTRAP_DONE_ID,
135
+ // A node that stopped while holding the lock may still have its
136
+ // own bootstrap in flight, and write this marker after the check
137
+ // above: replacing it keeps this node from failing on it
138
+ await this.createOrReplace("config", this._BOOTSTRAP_DONE_ID, {
139
+ timestamp: Date.now(),
137
140
  });
138
141
  }
139
142
  catch (error) {
@@ -154,9 +157,11 @@ class InternalIndexHandler extends store_1.Store {
154
157
  await this.createInitialValidations();
155
158
  debug("Bootstrapping JWT secret");
156
159
  await this._initSecret();
157
- // Create datamodel version
158
- await this.create("config", { version: dataModelVersion }, {
159
- id: this._DATAMODEL_VERSION_ID,
160
+ // Replaced rather than created: a bootstrap interrupted after this write
161
+ // but before the `.done` marker leaves it behind, and the next attempt
162
+ // must not fail on it (every step above is idempotent too)
163
+ await this.createOrReplace("config", this._DATAMODEL_VERSION_ID, {
164
+ version: dataModelVersion,
160
165
  });
161
166
  }
162
167
  /**
@@ -195,7 +200,20 @@ class InternalIndexHandler extends store_1.Store {
195
200
  if (!configSeed) {
196
201
  if (!storedSeed) {
197
202
  storedSeed = node_crypto_1.default.randomBytes(512).toString("hex");
198
- await this.create("config", { seed: storedSeed }, { id: this._JWT_SECRET_ID });
203
+ try {
204
+ await this.create("config", { seed: storedSeed }, { id: this._JWT_SECRET_ID });
205
+ }
206
+ catch (error) {
207
+ // An interrupted bootstrap stored one in the meantime: it is the
208
+ // seed other nodes may already sign with, so keep it, and read it
209
+ // back below
210
+ const alreadyStored = error instanceof Error &&
211
+ "id" in error &&
212
+ error.id === "services.storage.document_already_exists";
213
+ if (!alreadyStored) {
214
+ throw error;
215
+ }
216
+ }
199
217
  }
200
218
  this.logger.warn("[!] Kuzzle is using a generated seed for authentication. This is suitable for development but should NEVER be used in production. See https://docs.kuzzle.io/core/2/guides/getting-started/deploy-your-application/");
201
219
  }
@@ -1,6 +1,6 @@
1
1
  import type { Policy, OptimizedPolicy, OptimizedPolicyRestrictions } from "../../types/index";
2
2
  import type { Role } from "./role";
3
- import type { KuzzleRequest } from "../../../index";
3
+ import type { KuzzleRequest } from "../../api/request";
4
4
  /** @internal */
5
5
  type InternalProfilePolicy = {
6
6
  role: Role;
@@ -1,5 +1,5 @@
1
1
  import type { ControllerRight, ControllerRights } from "../../types/controllers/ControllerRights";
2
- import type { KuzzleRequest } from "../../../index";
2
+ import type { KuzzleRequest } from "../../api/request";
3
3
  import type { OptimizedPolicyRestrictions } from "../../types/PolicyRestrictions";
4
4
  /**
5
5
  * @class Role
@@ -1,5 +1,5 @@
1
1
  import type { Profile } from "./profile";
2
- import type { KuzzleRequest } from "../../../index";
2
+ import type { KuzzleRequest } from "../../api/request";
3
3
  /**
4
4
  * @class User
5
5
  */
@@ -452,6 +452,18 @@ export declare class ES7 {
452
452
  mappings?: TypeMapping;
453
453
  settings?: JSONObject;
454
454
  }): Promise<null>;
455
+ /**
456
+ * Waits until the primary shards of a collection are active, for at most
457
+ * `timeout`, and rejects if they are not by then.
458
+ *
459
+ * A collection another node has just created is in the cluster state, so
460
+ * it exists, before its primary shard is active: until then, Elasticsearch
461
+ * answers 503 to any document request on it. The node that creates a
462
+ * collection waits for its shards (`wait_for_active_shards`); a node that
463
+ * finds it already there calls this instead. It answers at once when the
464
+ * shards are already active.
465
+ */
466
+ waitForCollectionShards(index: string, collection: string, timeout?: string): Promise<void>;
455
467
  /**
456
468
  * Retrieves settings definition for index/type
457
469
  *
@@ -89,6 +89,9 @@ const HIDDEN_COLLECTION = "_kuzzle_keep";
89
89
  // is refreshed while held, so a live holder is never overtaken; a crashed
90
90
  // one leaves it for at most `ttl` (30 s).
91
91
  const HIDDEN_COLLECTION_LOCK = { retryAttempts: Number.POSITIVE_INFINITY };
92
+ // How long `waitForCollectionShards` lets Elasticsearch wait for a
93
+ // collection's primary shards, which is also Elasticsearch's own default
94
+ const COLLECTION_SHARDS_TIMEOUT = "30s";
92
95
  const ALIAS_PREFIX = "@"; // @todo next major release: Add ALIAS_PREFIX in FORBIDDEN_CHARS
93
96
  const PRIVATE_PREFIX = "%";
94
97
  const PUBLIC_PREFIX = "&";
@@ -1146,6 +1149,9 @@ class ES7 {
1146
1149
  this._checkDynamicProperty(mappings);
1147
1150
  const exists = await this.hasCollection(index, collection);
1148
1151
  if (exists) {
1152
+ // Another node created it, maybe so recently that its primary shards
1153
+ // are still initializing
1154
+ await this.waitForCollectionShards(index, collection);
1149
1155
  return this.updateCollection(index, collection, { mappings, settings });
1150
1156
  }
1151
1157
  this._checkMappings(mappings);
@@ -1167,7 +1173,9 @@ class ES7 {
1167
1173
  if (lodash_1.default.get(error, "meta.body.error.type") ===
1168
1174
  "resource_already_exists_exception") {
1169
1175
  // race condition: the indice has been created between the "exists"
1170
- // check above and this "create" attempt
1176
+ // check above and this "create" attempt, by a request that may not
1177
+ // have waited for its shards yet
1178
+ await this.waitForCollectionShards(index, collection);
1171
1179
  return null;
1172
1180
  }
1173
1181
  throw this._esWrapper.formatESError(error);
@@ -1187,6 +1195,29 @@ class ES7 {
1187
1195
  }
1188
1196
  return null;
1189
1197
  }
1198
+ /**
1199
+ * Waits until the primary shards of a collection are active, for at most
1200
+ * `timeout`, and rejects if they are not by then.
1201
+ *
1202
+ * A collection another node has just created is in the cluster state, so
1203
+ * it exists, before its primary shard is active: until then, Elasticsearch
1204
+ * answers 503 to any document request on it. The node that creates a
1205
+ * collection waits for its shards (`wait_for_active_shards`); a node that
1206
+ * finds it already there calls this instead. It answers at once when the
1207
+ * shards are already active.
1208
+ */
1209
+ async waitForCollectionShards(index, collection, timeout = COLLECTION_SHARDS_TIMEOUT) {
1210
+ try {
1211
+ await this._client.cluster.health({
1212
+ index: this._getAlias(index, collection),
1213
+ timeout,
1214
+ wait_for_status: "yellow",
1215
+ });
1216
+ }
1217
+ catch (error) {
1218
+ throw this._esWrapper.formatESError(error);
1219
+ }
1220
+ }
1190
1221
  /**
1191
1222
  * Retrieves settings definition for index/type
1192
1223
  *
@@ -35,6 +35,35 @@ const kerror = (0, kerror_1.wrap)("services", "storage");
35
35
  function isResponseError(error) {
36
36
  return Boolean(error.meta);
37
37
  }
38
+ /**
39
+ * What the client puts in `message` when the response carried nothing it could
40
+ * read: this placeholder, or an empty string for a bodyless response.
41
+ */
42
+ const CLIENT_PLACEHOLDER_MESSAGE = "Response Error";
43
+ function isUsableMessage(message) {
44
+ return (typeof message === "string" &&
45
+ message.trim() !== "" &&
46
+ message !== CLIENT_PLACEHOLDER_MESSAGE);
47
+ }
48
+ /**
49
+ * A message for an error that came with none worth keeping (typically a 503
50
+ * with no body while shards initialize), built from what the error still
51
+ * carries: the client's error name, the HTTP status and the response body.
52
+ */
53
+ function describeUnreadableError(error) {
54
+ const statusCode = error.meta?.statusCode;
55
+ const body = error.meta?.body;
56
+ const hasBody = body !== undefined && body !== null && body !== "";
57
+ let description = statusCode === undefined
58
+ ? "Elasticsearch request failed"
59
+ : `Elasticsearch responded ${statusCode}`;
60
+ description += hasBody
61
+ ? `: ${(0, node_util_1.inspect)(body, { breakLength: Infinity, depth: 3 })}`
62
+ : " (no body)";
63
+ return error.name && error.name !== "Error"
64
+ ? `${error.name}: ${description}`
65
+ : description;
66
+ }
38
67
  const errorMessagesMapping = [
39
68
  {
40
69
  regex: /^\[es_rejected_execution_exception] rejected execution .*? on EsThreadPoolExecutor\[(.*?), .*$/,
@@ -258,8 +287,8 @@ class ESWrapper {
258
287
  return kerror.get("unexpected_bad_request", errorMessage);
259
288
  }
260
289
  _handleUnknownError(error, message) {
261
- debug("unhandled elasticsearch error (unhandled type: %s): %o", (0, get_1.default)(error, "error.meta.statusCode", "<no status code>"), error);
262
- return kerror.get("unexpected_error", message);
290
+ debug("unhandled elasticsearch error (unhandled type: %s): %o", (0, get_1.default)(error, "meta.statusCode", "<no status code>"), error);
291
+ return kerror.get("unexpected_error", isUsableMessage(message) ? message : describeUnreadableError(error));
263
292
  }
264
293
  }
265
294
  module.exports = ESWrapper;
@@ -443,6 +443,18 @@ export declare class ES8 {
443
443
  mappings?: estypes.MappingTypeMapping;
444
444
  settings?: estypes.IndicesIndexSettings;
445
445
  }): Promise<null>;
446
+ /**
447
+ * Waits until the primary shards of a collection are active, for at most
448
+ * `timeout`, and rejects if they are not by then.
449
+ *
450
+ * A collection another node has just created is in the cluster state, so
451
+ * it exists, before its primary shard is active: until then, Elasticsearch
452
+ * answers 503 to any document request on it. The node that creates a
453
+ * collection waits for its shards (`wait_for_active_shards`); a node that
454
+ * finds it already there calls this instead. It answers at once when the
455
+ * shards are already active.
456
+ */
457
+ waitForCollectionShards(index: string, collection: string, timeout?: string): Promise<void>;
446
458
  /**
447
459
  * Retrieves settings definition for index/type
448
460
  *
@@ -89,6 +89,9 @@ const HIDDEN_COLLECTION = "_kuzzle_keep";
89
89
  // is refreshed while held, so a live holder is never overtaken; a crashed
90
90
  // one leaves it for at most `ttl` (30 s).
91
91
  const HIDDEN_COLLECTION_LOCK = { retryAttempts: Number.POSITIVE_INFINITY };
92
+ // How long `waitForCollectionShards` lets Elasticsearch wait for a
93
+ // collection's primary shards, which is also Elasticsearch's own default
94
+ const COLLECTION_SHARDS_TIMEOUT = "30s";
92
95
  const ALIAS_PREFIX = "@"; // @todo next major release: Add ALIAS_PREFIX in FORBIDDEN_CHARS
93
96
  const PRIVATE_PREFIX = "%";
94
97
  const PUBLIC_PREFIX = "&";
@@ -1168,6 +1171,9 @@ class ES8 {
1168
1171
  this._checkDynamicProperty(mappings);
1169
1172
  const exists = await this.hasCollection(index, collection);
1170
1173
  if (exists) {
1174
+ // Another node created it, maybe so recently that its primary shards
1175
+ // are still initializing
1176
+ await this.waitForCollectionShards(index, collection);
1171
1177
  return this.updateCollection(index, collection, { mappings, settings });
1172
1178
  }
1173
1179
  this._checkMappings(mappings);
@@ -1189,7 +1195,9 @@ class ES8 {
1189
1195
  if (lodash_1.default.get(error, "meta.body.error.type") ===
1190
1196
  "resource_already_exists_exception") {
1191
1197
  // race condition: the indice has been created between the "exists"
1192
- // check above and this "create" attempt
1198
+ // check above and this "create" attempt, by a request that may not
1199
+ // have waited for its shards yet
1200
+ await this.waitForCollectionShards(index, collection);
1193
1201
  return null;
1194
1202
  }
1195
1203
  throw this._esWrapper.formatESError(error);
@@ -1209,6 +1217,29 @@ class ES8 {
1209
1217
  }
1210
1218
  return null;
1211
1219
  }
1220
+ /**
1221
+ * Waits until the primary shards of a collection are active, for at most
1222
+ * `timeout`, and rejects if they are not by then.
1223
+ *
1224
+ * A collection another node has just created is in the cluster state, so
1225
+ * it exists, before its primary shard is active: until then, Elasticsearch
1226
+ * answers 503 to any document request on it. The node that creates a
1227
+ * collection waits for its shards (`wait_for_active_shards`); a node that
1228
+ * finds it already there calls this instead. It answers at once when the
1229
+ * shards are already active.
1230
+ */
1231
+ async waitForCollectionShards(index, collection, timeout = COLLECTION_SHARDS_TIMEOUT) {
1232
+ try {
1233
+ await this._client.cluster.health({
1234
+ index: this._getAlias(index, collection),
1235
+ timeout,
1236
+ wait_for_status: "yellow",
1237
+ });
1238
+ }
1239
+ catch (error) {
1240
+ throw this._esWrapper.formatESError(error);
1241
+ }
1242
+ }
1212
1243
  /**
1213
1244
  * Retrieves settings definition for index/type
1214
1245
  *
@@ -35,6 +35,35 @@ const kerror = (0, kerror_1.wrap)("services", "storage");
35
35
  function isResponseError(error) {
36
36
  return Boolean(error.meta);
37
37
  }
38
+ /**
39
+ * What the client puts in `message` when the response carried nothing it could
40
+ * read: this placeholder, or an empty string for a bodyless response.
41
+ */
42
+ const CLIENT_PLACEHOLDER_MESSAGE = "Response Error";
43
+ function isUsableMessage(message) {
44
+ return (typeof message === "string" &&
45
+ message.trim() !== "" &&
46
+ message !== CLIENT_PLACEHOLDER_MESSAGE);
47
+ }
48
+ /**
49
+ * A message for an error that came with none worth keeping (typically a 503
50
+ * with no body while shards initialize), built from what the error still
51
+ * carries: the client's error name, the HTTP status and the response body.
52
+ */
53
+ function describeUnreadableError(error) {
54
+ const statusCode = error.meta?.statusCode;
55
+ const body = error.meta?.body;
56
+ const hasBody = body !== undefined && body !== null && body !== "";
57
+ let description = statusCode === undefined
58
+ ? "Elasticsearch request failed"
59
+ : `Elasticsearch responded ${statusCode}`;
60
+ description += hasBody
61
+ ? `: ${(0, node_util_1.inspect)(body, { breakLength: Infinity, depth: 3 })}`
62
+ : " (no body)";
63
+ return error.name && error.name !== "Error"
64
+ ? `${error.name}: ${description}`
65
+ : description;
66
+ }
38
67
  const errorMessagesMapping = [
39
68
  {
40
69
  regex: /^\[es_rejected_execution_exception] rejected execution .*? on EsThreadPoolExecutor\[(.*?), .*$/,
@@ -258,8 +287,8 @@ class ESWrapper {
258
287
  return kerror.get("unexpected_bad_request", errorMessage);
259
288
  }
260
289
  _handleUnknownError(error, message) {
261
- debug("unhandled elasticsearch error (unhandled type: %s): %o", (0, get_1.default)(error, "error.meta.statusCode", "<no status code>"), error);
262
- return kerror.get("unexpected_error", message);
290
+ debug("unhandled elasticsearch error (unhandled type: %s): %o", (0, get_1.default)(error, "meta.statusCode", "<no status code>"), error);
291
+ return kerror.get("unexpected_error", isUsableMessage(message) ? message : describeUnreadableError(error));
263
292
  }
264
293
  }
265
294
  module.exports = ESWrapper;
@@ -1,6 +1,7 @@
1
1
  import type { KDocument, KDocumentContent } from "kuzzle-types";
2
2
  import type { JSONObject } from "../JSONObject";
3
- import type { KuzzleRequest, PipeEventHandler } from "../../../index";
3
+ import type { KuzzleRequest } from "../../api/request";
4
+ import type { PipeEventHandler } from "../EventHandler";
4
5
  /**
5
6
  * Events with documents only having the `_id`
6
7
  */
@@ -1,5 +1,5 @@
1
1
  import type { JSONObject } from "../JSONObject";
2
- import type { PipeEventHandler } from "../../../index";
2
+ import type { PipeEventHandler } from "../EventHandler";
3
3
  import type { ClientConnection } from "../ClientConnection";
4
4
  import type { HttpMessage } from "../HttpMessage";
5
5
  export type EventHTTPBeforeParsingPayload = {
@@ -101,7 +101,8 @@ export declare class Mutex {
101
101
  *
102
102
  * From then on, `lock()` never resolves: the process is about to exit, and
103
103
  * answering `false` would send its caller down a path meant for a lock
104
- * held by another node.
104
+ * held by another node. And `unlock()` on a lock it already freed resolves
105
+ * without doing anything, since its holder may still try to give it back.
105
106
  */
106
107
  static releaseAllBeforeExit(): Promise<void>;
107
108
  get locked(): boolean;
@@ -165,6 +165,11 @@ class Mutex {
165
165
  * @return {Promise}
166
166
  */
167
167
  async unlock() {
168
+ // `releaseAllBeforeExit` already gave this lock back: its holder is only
169
+ // catching up, and throwing would turn a requested shutdown into a crash.
170
+ if (!this._locked && exiting) {
171
+ return;
172
+ }
168
173
  if (!this._locked) {
169
174
  throw fatal.get("assertion_failed", `tried to unlock the resource "${this.resource}", which is not locked (mutex id: ${this.mutexId})`);
170
175
  }
@@ -214,7 +219,8 @@ class Mutex {
214
219
  *
215
220
  * From then on, `lock()` never resolves: the process is about to exit, and
216
221
  * answering `false` would send its caller down a path meant for a lock
217
- * held by another node.
222
+ * held by another node. And `unlock()` on a lock it already freed resolves
223
+ * without doing anything, since its holder may still try to give it back.
218
224
  */
219
225
  static async releaseAllBeforeExit() {
220
226
  exiting = true;
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kuzzle",
3
3
  "author": "The Kuzzle Team <support@kuzzle.io>",
4
- "version": "2.60.0-beta.3",
4
+ "version": "2.60.0-beta.4",
5
5
  "description": "Kuzzle is an open-source solution that handles all the data management through a secured API, with a large choice of protocols.",
6
6
  "scripts": {
7
7
  "build": "rm -Rf ./dist && tsc && node -r ts-node/register/transpile-only ./bin/copy-binaries.ts",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kuzzle",
3
3
  "author": "The Kuzzle Team <support@kuzzle.io>",
4
- "version": "2.60.0-beta.3",
4
+ "version": "2.60.0-beta.4",
5
5
  "description": "Kuzzle is an open-source solution that handles all the data management through a secured API, with a large choice of protocols.",
6
6
  "scripts": {
7
7
  "build": "rm -Rf ./dist && tsc && node -r ts-node/register/transpile-only ./bin/copy-binaries.ts",