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
@@ -104,18 +104,24 @@ class IDCardRenewer {
104
104
  // Unable to refresh the key in time before it expires
105
105
  // => this node is too slow, we need to remove it from the cluster
106
106
  if (refreshed === 0) {
107
- await this.dispose();
108
- process.send?.({
109
- error: "Node too slow: ID card expired",
110
- });
107
+ await this.reportFailure("Node too slow: ID card expired");
111
108
  }
112
109
  }
113
110
  catch (error) {
114
- await this.dispose();
115
- process.send?.({
116
- error: `Failed to refresh ID Card: ${messageOf(error)}`,
117
- });
111
+ await this.reportFailure(`Failed to refresh ID Card: ${messageOf(error)}`);
112
+ }
113
+ }
114
+ /**
115
+ * Disposes the worker and tells the parent why, once: the renewals queued
116
+ * behind a stalled redis all fail together, and each report evicts the node
117
+ * again (#3017)
118
+ */
119
+ async reportFailure(error) {
120
+ if (this.disposed) {
121
+ return;
118
122
  }
123
+ await this.dispose();
124
+ process.send?.({ error });
119
125
  }
120
126
  async dispose() {
121
127
  if (this.disposed) {
@@ -99,7 +99,9 @@ class PassportWrapper {
99
99
  fail(err);
100
100
  }
101
101
  else if (!user) {
102
- const message = typeof info === "object" && "message" in info
102
+ const message =
103
+ // `typeof null` is "object" too: a strategy may fail with no info
104
+ typeof info === "object" && info !== null && "message" in info
103
105
  ? info.message
104
106
  : undefined;
105
107
  reject(kerror.get("plugin", "strategy", "missing_user", message));
@@ -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
@@ -11,6 +11,29 @@
11
11
  "class": "InternalError"
12
12
  }
13
13
  }
14
+ },
15
+ "join": {
16
+ "code": 1,
17
+ "errors": {
18
+ "duplicate_ip": {
19
+ "description": "Another node of the cluster has the same IP address as the one joining it. The joining node does not start.",
20
+ "code": 1,
21
+ "message": "Another node shares the same IP address as this one (%s): %s.",
22
+ "class": "InternalError"
23
+ },
24
+ "full_state_unavailable": {
25
+ "description": "None of the cluster nodes discovered could give the joining node the cluster state, twice in a row (network split). The joining node does not start.",
26
+ "code": 2,
27
+ "message": "Could not connect to the discovered cluster nodes (network split detected).",
28
+ "class": "InternalError"
29
+ },
30
+ "timeout": {
31
+ "description": "The node did not join the cluster within the configured cluster.joinTimeout. It does not start.",
32
+ "code": 3,
33
+ "message": "Failed to join the cluster: timed out (joinTimeout: %sms).",
34
+ "class": "InternalError"
35
+ }
36
+ }
14
37
  }
15
38
  }
16
39
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Ends the process with exit code 1, for a start script whose Kuzzle failed
3
+ * to start or to run.
4
+ *
5
+ * A bare `process.exit(1)` cuts Kuzzle's graceful shutdown short: the locks
6
+ * the node holds and its ID card stay in Redis until they expire (#3022).
7
+ * Once a Kuzzle instance exists, this goes through `shutdown(1)` instead,
8
+ * which joins the one a failed start already began (#3020). Before that,
9
+ * there is nothing to release.
10
+ *
11
+ * Internal to Kuzzle: not exported by the package.
12
+ */
13
+ export declare function exitOnFailure(): Promise<void>;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /*
3
+ * Kuzzle, a backend software, self-hostable and ready to use
4
+ * to power modern apps
5
+ *
6
+ * Copyright 2015-2022 Kuzzle
7
+ * mailto: support AT kuzzle.io
8
+ * website: http://kuzzle.io
9
+ *
10
+ * Licensed under the Apache License, Version 2.0 (the "License");
11
+ * you may not use this file except in compliance with the License.
12
+ * You may obtain a copy of the License at
13
+ *
14
+ * https://www.apache.org/licenses/LICENSE-2.0
15
+ *
16
+ * Unless required by applicable law or agreed to in writing, software
17
+ * distributed under the License is distributed on an "AS IS" BASIS,
18
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ * See the License for the specific language governing permissions and
20
+ * limitations under the License.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.exitOnFailure = exitOnFailure;
24
+ const node_util_1 = require("node:util");
25
+ /**
26
+ * Ends the process with exit code 1, for a start script whose Kuzzle failed
27
+ * to start or to run.
28
+ *
29
+ * A bare `process.exit(1)` cuts Kuzzle's graceful shutdown short: the locks
30
+ * the node holds and its ID card stay in Redis until they expire (#3022).
31
+ * Once a Kuzzle instance exists, this goes through `shutdown(1)` instead,
32
+ * which joins the one a failed start already began (#3020). Before that,
33
+ * there is nothing to release.
34
+ *
35
+ * Internal to Kuzzle: not exported by the package.
36
+ */
37
+ async function exitOnFailure() {
38
+ const kuzzle = currentKuzzle();
39
+ if (kuzzle) {
40
+ try {
41
+ // Ends with `process.exit`
42
+ await kuzzle.shutdown(1);
43
+ return;
44
+ }
45
+ catch (error) {
46
+ kuzzle.log.error(`Unable to shut down: ${error instanceof Error ? error.message : (0, node_util_1.inspect)(error)}`);
47
+ }
48
+ }
49
+ process.exit(1);
50
+ }
51
+ function currentKuzzle() {
52
+ try {
53
+ return global.kuzzle;
54
+ }
55
+ catch {
56
+ // The `global.kuzzle` getter throws until an instance is built
57
+ return undefined;
58
+ }
59
+ }
60
+ //# sourceMappingURL=exitOnFailure.js.map
@@ -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
  }
@@ -17,6 +17,12 @@ import { Logger } from "./Logger";
17
17
  declare class Kuzzle extends KuzzleEventEmitter {
18
18
  config: IKuzzleConfiguration;
19
19
  private _state;
20
+ /** The running shutdown, which later `shutdown()` calls wait for (#3017) */
21
+ private shutdownPromise;
22
+ /** The highest exit code asked for by the `shutdown()` calls so far */
23
+ private shutdownExitCode;
24
+ /** Ends a failure's shutdown that does not finish in time */
25
+ private shutdownWatchdog;
20
26
  log: Logger;
21
27
  /** Read by `PluginsManager` to locate the plugins directories. */
22
28
  rootPath: string;
@@ -117,6 +123,7 @@ declare class Kuzzle extends KuzzleEventEmitter {
117
123
  * @returns {Promise}
118
124
  */
119
125
  shutdown(exitCode?: number): Promise<void>;
126
+ private runShutdown;
120
127
  /**
121
128
  * Execute multiple handlers only once on any given environment
122
129
  *
@@ -93,6 +93,12 @@ const Logger_1 = require("./Logger");
93
93
  const vault_1 = __importDefault(require("./vault"));
94
94
  const name_generator_1 = require("../util/name-generator");
95
95
  let _kuzzle = null;
96
+ /**
97
+ * How long a shutdown caused by a failure may run before the process exits
98
+ * anyway: a `kuzzle:shutdown` pipe or a lock release that never settles must
99
+ * not keep a failed node alive, which a bare `process.exit(1)` never did
100
+ */
101
+ const FAILURE_SHUTDOWN_TIMEOUT = 30000;
96
102
  Reflect.defineProperty(global, "kuzzle", {
97
103
  configurable: true,
98
104
  enumerable: false,
@@ -113,6 +119,12 @@ class Kuzzle extends KuzzleEventEmitter_1.default {
113
119
  constructor(config) {
114
120
  super(config.plugins.common.maxConcurrentPipes, config.plugins.common.pipesBufferSize);
115
121
  this._state = kuzzleStateEnum_1.default.STARTING;
122
+ /** The running shutdown, which later `shutdown()` calls wait for (#3017) */
123
+ this.shutdownPromise = null;
124
+ /** The highest exit code asked for by the `shutdown()` calls so far */
125
+ this.shutdownExitCode = 0;
126
+ /** Ends a failure's shutdown that does not finish in time */
127
+ this.shutdownWatchdog = null;
116
128
  global.kuzzle = this;
117
129
  this._state = kuzzleStateEnum_1.default.STARTING;
118
130
  this.config = config;
@@ -204,6 +216,16 @@ class Kuzzle extends KuzzleEventEmitter_1.default {
204
216
  }
205
217
  catch (error) {
206
218
  this.log.error(`[X] Cannot start Kuzzle ${this.version}: ${error instanceof Error ? error.message : (0, node_util_1.inspect)(error)}`);
219
+ // A failed start cannot be retried (a second Kuzzle instance cannot be
220
+ // built), and what it did start keeps the process alive: in production,
221
+ // a node with no listener and an ID card still renewed (#3020). Not
222
+ // awaited, so the caller still gets the error before the process exits.
223
+ this.shutdown(1).catch((shutdownError) => {
224
+ this.log.error(`Unable to shut down after a failed start: ${shutdownError instanceof Error
225
+ ? shutdownError.message
226
+ : (0, node_util_1.inspect)(shutdownError)}`);
227
+ process.exit(1);
228
+ });
207
229
  throw error;
208
230
  }
209
231
  }
@@ -231,6 +253,22 @@ class Kuzzle extends KuzzleEventEmitter_1.default {
231
253
  * @returns {Promise}
232
254
  */
233
255
  async shutdown(exitCode = 0) {
256
+ // Several sources can ask for it at once (a signal, a cluster eviction,
257
+ // the `exit` handler that `process.exit` below fires): they share the one
258
+ // shutdown, and a failure reported by any of them keeps the exit non-zero
259
+ this.shutdownExitCode = Math.max(this.shutdownExitCode, exitCode);
260
+ // Not for a requested shutdown: it waits for the requests still running,
261
+ // however long they take
262
+ if (exitCode !== 0) {
263
+ this.shutdownWatchdog ?? (this.shutdownWatchdog = setTimeout(() => {
264
+ this.log.error(`[shutdown] Not finished after ${FAILURE_SHUTDOWN_TIMEOUT}ms: exiting now`);
265
+ process.exit(this.shutdownExitCode);
266
+ }, FAILURE_SHUTDOWN_TIMEOUT));
267
+ }
268
+ this.shutdownPromise ?? (this.shutdownPromise = this.runShutdown());
269
+ return this.shutdownPromise;
270
+ }
271
+ async runShutdown() {
234
272
  this._state = kuzzleStateEnum_1.default.SHUTTING_DOWN;
235
273
  this.log.info("Initiating shutdown...");
236
274
  // Ask the network layer to stop accepting new request
@@ -257,7 +295,10 @@ class Kuzzle extends KuzzleEventEmitter_1.default {
257
295
  // `pluginsManager?.application?.log?.flush?.()` was a silent no-op and the
258
296
  // application's buffered logs were dropped on shutdown (TD-51).
259
297
  await this?.pluginsManager?.application?.instance?.log?.flush?.();
260
- process.exit(exitCode);
298
+ if (this.shutdownWatchdog !== null) {
299
+ clearTimeout(this.shutdownWatchdog);
300
+ }
301
+ process.exit(this.shutdownExitCode);
261
302
  }
262
303
  /**
263
304
  * Execute multiple handlers only once on any given environment
@@ -608,7 +649,9 @@ class Kuzzle extends KuzzleEventEmitter_1.default {
608
649
  // nothing to do with that error
609
650
  }
610
651
  }
611
- await this.shutdown();
652
+ // Only abnormal terminations get here: exit non-zero, so that an
653
+ // `on-failure` restart policy brings the node back (#3020)
654
+ await this.shutdown(1);
612
655
  }
613
656
  }
614
657
  exports.Kuzzle = Kuzzle;
@@ -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
  *