kuzzle 2.58.0 → 2.59.0

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.
@@ -50,6 +50,9 @@ const publisher_1 = __importDefault(require("./publisher"));
50
50
  const state_1 = __importDefault(require("./state"));
51
51
  const subscriber_1 = __importDefault(require("./subscriber"));
52
52
  const debug = (0, debug_1.default)("kuzzle:cluster:sync");
53
+ /** Cluster-wide events carrying the collection write locks (see registerEvents) */
54
+ const COLLECTION_LOCK_EVENT = "core:storage:collection:lock";
55
+ const COLLECTION_UNLOCK_EVENT = "core:storage:collection:unlock";
53
56
  /**
54
57
  * How long `addNode()` may wait for a joining node's first sync message before
55
58
  * it answers that node's handshake anyway.
@@ -888,6 +891,13 @@ class ClusterNode {
888
891
  global.kuzzle.on("core:storage:collection:delete:after", ({ collection, index, scope }) => {
889
892
  this.onCollectionRemoved(scope, index, collection);
890
893
  });
894
+ // Collection write locks travel as cluster-wide events rather than as
895
+ // dedicated sync topics: a node evicts the sender of a topic it does not
896
+ // know, which would turn a rolling upgrade into evictions.
897
+ global.kuzzle.on("core:storage:collection:lock:after", (payload) => this.broadcast(COLLECTION_LOCK_EVENT, payload));
898
+ global.kuzzle.on("core:storage:collection:unlock:after", (payload) => this.broadcast(COLLECTION_UNLOCK_EVENT, payload));
899
+ this.eventEmitter.on(COLLECTION_LOCK_EVENT, ({ collection, index, lock, scope }) => global.kuzzle.ask(`core:storage:${scope}:cache:setLock`, index, collection, lock));
900
+ this.eventEmitter.on(COLLECTION_UNLOCK_EVENT, ({ collection, index, scope }) => global.kuzzle.ask(`core:storage:${scope}:cache:removeLock`, index, collection));
891
901
  }
892
902
  /**
893
903
  * Triggered whenever a realtime room is created on this node
@@ -1,6 +1,6 @@
1
1
  import type { JSONObject } from "../../types/JSONObject";
2
2
  import { Elasticsearch } from "../../service/storage/Elasticsearch";
3
- import { IndexCache } from "./indexCache";
3
+ import { IndexCache, type CollectionLock } from "./indexCache";
4
4
  import type { storeScopeEnum } from "./storeScopeEnum";
5
5
  import "../../types/Global";
6
6
  /** Where `createIndex` / `createCollection` / `loadMappings` write, and whether
@@ -43,10 +43,44 @@ declare class ClientAdapter {
43
43
  * Also checks for duplicated index names.
44
44
  */
45
45
  populateCache(): Promise<void>;
46
+ /**
47
+ * Replaces the cached collection write locks with the cluster-wide ones,
48
+ * stored in Redis.
49
+ */
50
+ loadLocks(): Promise<void>;
51
+ /**
52
+ * Write-locks a collection cluster-wide: until it is unlocked, every action
53
+ * writing to it is rejected with `services.storage.collection_locked`, and
54
+ * reads are served as usual. Locking a collection again with the same owner
55
+ * refreshes the lock.
56
+ *
57
+ * The other nodes learn about the lock asynchronously (cluster sync): a write
58
+ * they accepted just before may still land.
59
+ *
60
+ * @throws If the collection does not exist, or is locked by another owner
61
+ */
62
+ lockCollection(index: string, collection: string, { owner, reason }: {
63
+ owner: string;
64
+ reason: string;
65
+ }): Promise<CollectionLock>;
66
+ /**
67
+ * Releases a collection write lock.
68
+ *
69
+ * @returns false if the collection was not locked
70
+ * @throws If the collection is locked by another owner
71
+ */
72
+ unlockCollection(index: string, collection: string, owner: string): Promise<boolean>;
73
+ private get locksKey();
74
+ private fetchLock;
75
+ /**
76
+ * @throws If `lock` is held by someone other than `owner`
77
+ */
78
+ private assertLockOwner;
46
79
  registerCollectionEvents(): void;
47
80
  registerIndexEvents(): void;
48
81
  registerDocumentEvents(): void;
49
82
  registerMappingEvents(): void;
83
+ registerLockEvents(): void;
50
84
  /**
51
85
  * Cache update operations. These events trigger neither any actual change
52
86
  * in the storage layer, nor kuzzle events.
@@ -49,6 +49,12 @@ const kerror = __importStar(require("../../kerror"));
49
49
  const mutex_1 = require("../../util/mutex"); // NOSONAR: see loadMappings
50
50
  require("../../types/Global");
51
51
  const servicesError = kerror.wrap("services", "storage");
52
+ /**
53
+ * Redis hash holding the collection write locks of a storage scope, the
54
+ * cluster-wide source of truth that each node mirrors in its index cache.
55
+ * One field per locked collection: `JSON.stringify([index, collection])`.
56
+ */
57
+ const LOCKS_KEY_PREFIX = "{core/storage}/collection-locks/";
52
58
  /** Hoisted out of the signature: an object literal as a default parameter is
53
59
  * re-allocated on every call, and this one is only ever read. */
54
60
  const DEFAULT_LOAD_MAPPINGS_OPTIONS = Object.freeze({
@@ -76,6 +82,11 @@ class ClientAdapter {
76
82
  this.registerDocumentEvents();
77
83
  this.registerMappingEvents();
78
84
  this.registerCacheEvents();
85
+ this.registerLockEvents();
86
+ // The locks were first loaded with the cache, before this node joined the
87
+ // cluster: one taken in between was broadcast before this node could hear
88
+ // it. Reading them again once the cluster is joined closes that gap.
89
+ global.kuzzle.onPipe("kuzzle:state:live", () => this.loadLocks());
79
90
  // Global store events registration
80
91
  /**
81
92
  * Manually refresh the index cache (e.g. after alias creation)
@@ -111,6 +122,8 @@ class ClientAdapter {
111
122
  }
112
123
  async createCollection(index, collection, opts, { indexCacheOnly = false, propagate = true } = {}) {
113
124
  if (!indexCacheOnly) {
125
+ // Creating an existing collection updates its mappings and settings
126
+ this.cache.assertCollectionWritable(index, collection);
114
127
  await this.client.createCollection(index, collection, opts);
115
128
  }
116
129
  this.cache.addCollection(index, collection);
@@ -124,6 +137,7 @@ class ClientAdapter {
124
137
  }
125
138
  async deleteIndex(index) {
126
139
  this.cache.assertIndexExists(index);
140
+ this.cache.assertIndexWritable(index);
127
141
  await this.client.deleteIndex(index);
128
142
  this.cache.removeIndex(index);
129
143
  global.kuzzle.emit("core:storage:index:delete:after", {
@@ -134,6 +148,7 @@ class ClientAdapter {
134
148
  async deleteIndexes(indexes) {
135
149
  for (const index of indexes) {
136
150
  this.cache.assertIndexExists(index);
151
+ this.cache.assertIndexWritable(index);
137
152
  }
138
153
  const deleted = await this.client.deleteIndexes(indexes);
139
154
  if (deleted.length > 0) {
@@ -149,6 +164,7 @@ class ClientAdapter {
149
164
  }
150
165
  async deleteCollection(index, collection) {
151
166
  this.cache.assertCollectionExists(index, collection);
167
+ this.cache.assertCollectionWritable(index, collection);
152
168
  await this.client.deleteCollection(index, collection);
153
169
  this.cache.removeCollection(index, collection);
154
170
  global.kuzzle.emit("core:storage:collection:delete:after", {
@@ -174,6 +190,88 @@ class ClientAdapter {
174
190
  this.cache.addCollection(index, collection);
175
191
  }
176
192
  }
193
+ await this.loadLocks();
194
+ }
195
+ /**
196
+ * Replaces the cached collection write locks with the cluster-wide ones,
197
+ * stored in Redis.
198
+ */
199
+ async loadLocks() {
200
+ const fields = await global.kuzzle.ask("core:cache:internal:execute", "hgetall", this.locksKey);
201
+ this.cache.clearLocks();
202
+ for (const [field, value] of Object.entries(fields ?? {})) {
203
+ const [index, collection] = JSON.parse(field);
204
+ this.cache.setLock(index, collection, JSON.parse(value));
205
+ }
206
+ }
207
+ /**
208
+ * Write-locks a collection cluster-wide: until it is unlocked, every action
209
+ * writing to it is rejected with `services.storage.collection_locked`, and
210
+ * reads are served as usual. Locking a collection again with the same owner
211
+ * refreshes the lock.
212
+ *
213
+ * The other nodes learn about the lock asynchronously (cluster sync): a write
214
+ * they accepted just before may still land.
215
+ *
216
+ * @throws If the collection does not exist, or is locked by another owner
217
+ */
218
+ async lockCollection(index, collection, { owner, reason }) {
219
+ this.cache.assertCollectionExists(index, collection);
220
+ const field = JSON.stringify([index, collection]);
221
+ const lock = { lockedAt: Date.now(), owner, reason };
222
+ // Redis decides, not the local copy: two nodes locking the same collection
223
+ // at once must not both win.
224
+ const created = await global.kuzzle.ask("core:cache:internal:execute", "hsetnx", this.locksKey, field, JSON.stringify(lock));
225
+ if (created === 0) {
226
+ this.assertLockOwner(index, collection, await this.fetchLock(field), owner);
227
+ await global.kuzzle.ask("core:cache:internal:execute", "hset", this.locksKey, field, JSON.stringify(lock));
228
+ }
229
+ this.cache.setLock(index, collection, lock);
230
+ global.kuzzle.emit("core:storage:collection:lock:after", {
231
+ collection,
232
+ index,
233
+ lock,
234
+ scope: this.scope,
235
+ });
236
+ return lock;
237
+ }
238
+ /**
239
+ * Releases a collection write lock.
240
+ *
241
+ * @returns false if the collection was not locked
242
+ * @throws If the collection is locked by another owner
243
+ */
244
+ async unlockCollection(index, collection, owner) {
245
+ const field = JSON.stringify([index, collection]);
246
+ const current = await this.fetchLock(field);
247
+ if (current === null) {
248
+ this.cache.removeLock(index, collection);
249
+ return false;
250
+ }
251
+ this.assertLockOwner(index, collection, current, owner);
252
+ await global.kuzzle.ask("core:cache:internal:execute", "hdel", this.locksKey, field);
253
+ this.cache.removeLock(index, collection);
254
+ global.kuzzle.emit("core:storage:collection:unlock:after", {
255
+ collection,
256
+ index,
257
+ scope: this.scope,
258
+ });
259
+ return true;
260
+ }
261
+ get locksKey() {
262
+ return `${LOCKS_KEY_PREFIX}${this.scope}`;
263
+ }
264
+ async fetchLock(field) {
265
+ const value = await global.kuzzle.ask("core:cache:internal:execute", "hget", this.locksKey, field);
266
+ return value === null ? null : JSON.parse(value);
267
+ }
268
+ /**
269
+ * @throws If `lock` is held by someone other than `owner`
270
+ */
271
+ assertLockOwner(index, collection, lock, owner) {
272
+ if (lock !== null && lock.owner !== owner) {
273
+ throw servicesError.get("collection_locked", index, collection, lock.owner, lock.reason);
274
+ }
177
275
  }
178
276
  registerCollectionEvents() {
179
277
  /**
@@ -233,6 +331,7 @@ class ClientAdapter {
233
331
  */
234
332
  global.kuzzle.onAsk(`core:storage:${this.scope}:collection:truncate`, (index, collection) => {
235
333
  this.cache.assertCollectionExists(index, collection);
334
+ this.cache.assertCollectionWritable(index, collection);
236
335
  return this.client.truncateCollection(index, collection);
237
336
  });
238
337
  /**
@@ -244,6 +343,7 @@ class ClientAdapter {
244
343
  */
245
344
  global.kuzzle.onAsk(`core:storage:${this.scope}:collection:update`, (index, collection, changes) => {
246
345
  this.cache.assertCollectionExists(index, collection);
346
+ this.cache.assertCollectionWritable(index, collection);
247
347
  return this.client.updateCollection(index, collection, changes);
248
348
  });
249
349
  }
@@ -299,6 +399,7 @@ class ClientAdapter {
299
399
  */
300
400
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:bulk`, (index, collection, bulk, opts) => {
301
401
  this.cache.assertCollectionExists(index, collection);
402
+ this.cache.assertCollectionWritable(index, collection);
302
403
  return this.client.import(index, collection, bulk, opts);
303
404
  });
304
405
  /**
@@ -324,6 +425,7 @@ class ClientAdapter {
324
425
  */
325
426
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:create`, (index, collection, content, opts) => {
326
427
  this.cache.assertCollectionExists(index, collection);
428
+ this.cache.assertCollectionWritable(index, collection);
327
429
  return this.client.create(index, collection, content, opts);
328
430
  });
329
431
  /**
@@ -338,6 +440,7 @@ class ClientAdapter {
338
440
  */
339
441
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:createOrReplace`, (index, collection, id, content, opts) => {
340
442
  this.cache.assertCollectionExists(index, collection);
443
+ this.cache.assertCollectionWritable(index, collection);
341
444
  return this.client.createOrReplace(index, collection, id, content, opts);
342
445
  });
343
446
  /**
@@ -351,6 +454,7 @@ class ClientAdapter {
351
454
  */
352
455
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:delete`, (index, collection, id, opts) => {
353
456
  this.cache.assertCollectionExists(index, collection);
457
+ this.cache.assertCollectionWritable(index, collection);
354
458
  return this.client.delete(index, collection, id, opts);
355
459
  });
356
460
  /**
@@ -364,6 +468,7 @@ class ClientAdapter {
364
468
  */
365
469
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:deleteByQuery`, (index, collection, query, opts) => {
366
470
  this.cache.assertCollectionExists(index, collection);
471
+ this.cache.assertCollectionWritable(index, collection);
367
472
  return this.client.deleteByQuery(index, collection, query, opts);
368
473
  });
369
474
  /**
@@ -378,6 +483,7 @@ class ClientAdapter {
378
483
  */
379
484
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:deleteFields`, (index, collection, id, fields, opts) => {
380
485
  this.cache.assertCollectionExists(index, collection);
486
+ this.cache.assertCollectionWritable(index, collection);
381
487
  return this.client.deleteFields(index, collection, id, fields, opts);
382
488
  });
383
489
  /**
@@ -433,6 +539,7 @@ class ClientAdapter {
433
539
  */
434
540
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mCreate`, (index, collection, documents, opts) => {
435
541
  this.cache.assertCollectionExists(index, collection);
542
+ this.cache.assertCollectionWritable(index, collection);
436
543
  return this.client.mCreate(index, collection, documents, opts);
437
544
  });
438
545
  /**
@@ -446,6 +553,7 @@ class ClientAdapter {
446
553
  */
447
554
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mCreateOrReplace`, (index, collection, documents, opts) => {
448
555
  this.cache.assertCollectionExists(index, collection);
556
+ this.cache.assertCollectionWritable(index, collection);
449
557
  return this.client.mCreateOrReplace(index, collection, documents, opts);
450
558
  });
451
559
  /**
@@ -459,6 +567,7 @@ class ClientAdapter {
459
567
  */
460
568
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mDelete`, (index, collection, ids, opts) => {
461
569
  this.cache.assertCollectionExists(index, collection);
570
+ this.cache.assertCollectionWritable(index, collection);
462
571
  return this.client.mDelete(index, collection, ids, opts);
463
572
  });
464
573
  /**
@@ -472,6 +581,7 @@ class ClientAdapter {
472
581
  */
473
582
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mReplace`, (index, collection, documents, opts) => {
474
583
  this.cache.assertCollectionExists(index, collection);
584
+ this.cache.assertCollectionWritable(index, collection);
475
585
  return this.client.mReplace(index, collection, documents, opts);
476
586
  });
477
587
  /**
@@ -485,6 +595,7 @@ class ClientAdapter {
485
595
  */
486
596
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mUpdate`, (index, collection, documents, opts) => {
487
597
  this.cache.assertCollectionExists(index, collection);
598
+ this.cache.assertCollectionWritable(index, collection);
488
599
  return this.client.mUpdate(index, collection, documents, opts);
489
600
  });
490
601
  /**
@@ -499,6 +610,7 @@ class ClientAdapter {
499
610
  */
500
611
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:mUpsert`, (index, collection, documents, opts) => {
501
612
  this.cache.assertCollectionExists(index, collection);
613
+ this.cache.assertCollectionWritable(index, collection);
502
614
  return this.client.mUpsert(index, collection, documents, opts);
503
615
  });
504
616
  /**
@@ -539,6 +651,7 @@ class ClientAdapter {
539
651
  */
540
652
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:replace`, (index, collection, id, content, opts) => {
541
653
  this.cache.assertCollectionExists(index, collection);
654
+ this.cache.assertCollectionWritable(index, collection);
542
655
  return this.client.replace(index, collection, id, content, opts);
543
656
  });
544
657
  /**
@@ -590,6 +703,7 @@ class ClientAdapter {
590
703
  */
591
704
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:update`, (index, collection, id, content, opts) => {
592
705
  this.cache.assertCollectionExists(index, collection);
706
+ this.cache.assertCollectionWritable(index, collection);
593
707
  return this.client.update(index, collection, id, content, opts);
594
708
  });
595
709
  /**
@@ -605,6 +719,7 @@ class ClientAdapter {
605
719
  */
606
720
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:updateByQuery`, (index, collection, query, changes, opts) => {
607
721
  this.cache.assertCollectionExists(index, collection);
722
+ this.cache.assertCollectionWritable(index, collection);
608
723
  return this.client.updateByQuery(index, collection, query, changes, opts);
609
724
  });
610
725
  /**
@@ -620,6 +735,7 @@ class ClientAdapter {
620
735
  */
621
736
  global.kuzzle.onAsk(`core:storage:${this.scope}:bulk:updateByQuery`, (index, collection, query, changes, opts) => {
622
737
  this.cache.assertCollectionExists(index, collection);
738
+ this.cache.assertCollectionWritable(index, collection);
623
739
  return this.client.bulkUpdateByQuery(index, collection, query, changes, opts);
624
740
  });
625
741
  /**
@@ -635,6 +751,7 @@ class ClientAdapter {
635
751
  */
636
752
  global.kuzzle.onAsk(`core:storage:${this.scope}:document:upsert`, (index, collection, id, content, opts) => {
637
753
  this.cache.assertCollectionExists(index, collection);
754
+ this.cache.assertCollectionWritable(index, collection);
638
755
  return this.client.upsert(index, collection, id, content, opts);
639
756
  });
640
757
  }
@@ -671,9 +788,37 @@ class ClientAdapter {
671
788
  */
672
789
  global.kuzzle.onAsk(`core:storage:${this.scope}:mappings:update`, (index, collection, mappings) => {
673
790
  this.cache.assertCollectionExists(index, collection);
791
+ this.cache.assertCollectionWritable(index, collection);
674
792
  return this.client.updateMapping(index, collection, mappings);
675
793
  });
676
794
  }
795
+ registerLockEvents() {
796
+ /**
797
+ * Write-lock a collection, cluster-wide
798
+ * @param {string} index
799
+ * @param {string} collection
800
+ * @param {Object} options -- { owner, reason }
801
+ * @returns {Promise.<{ owner, reason, lockedAt }>}
802
+ * @throws If the collection does not exist, or is locked by another owner
803
+ */
804
+ global.kuzzle.onAsk(`core:storage:${this.scope}:collection:lock`, (index, collection, options) => this.lockCollection(index, collection, options));
805
+ /**
806
+ * Release a collection write lock
807
+ * @param {string} index
808
+ * @param {string} collection
809
+ * @param {string} owner
810
+ * @returns {Promise.<boolean>} false if the collection was not locked
811
+ * @throws If the collection is locked by another owner
812
+ */
813
+ global.kuzzle.onAsk(`core:storage:${this.scope}:collection:unlock`, (index, collection, owner) => this.unlockCollection(index, collection, owner));
814
+ /**
815
+ * Return a collection write lock
816
+ * @param {string} index
817
+ * @param {string} collection
818
+ * @returns {{ owner, reason, lockedAt }|null}
819
+ */
820
+ global.kuzzle.onAsk(`core:storage:${this.scope}:collection:lock:get`, (index, collection) => this.cache.getLock(index, collection));
821
+ }
677
822
  /**
678
823
  * Cache update operations. These events trigger neither any actual change
679
824
  * in the storage layer, nor kuzzle events.
@@ -705,6 +850,19 @@ class ClientAdapter {
705
850
  * @param {string} collection
706
851
  */
707
852
  global.kuzzle.onAsk(`core:storage:${this.scope}:cache:removeCollection`, (index, collection) => this.cache.removeCollection(index, collection));
853
+ /**
854
+ * Caches a collection write lock taken on another node
855
+ * @param {string} index
856
+ * @param {string} collection
857
+ * @param {Object} lock -- { owner, reason, lockedAt }
858
+ */
859
+ global.kuzzle.onAsk(`core:storage:${this.scope}:cache:setLock`, (index, collection, lock) => this.cache.setLock(index, collection, lock));
860
+ /**
861
+ * Removes a collection write lock released on another node
862
+ * @param {string} index
863
+ * @param {string} collection
864
+ */
865
+ global.kuzzle.onAsk(`core:storage:${this.scope}:cache:removeLock`, (index, collection) => this.cache.removeLock(index, collection));
708
866
  }
709
867
  /**
710
868
  * Load database fixtures into Kuzzle
@@ -725,6 +883,7 @@ class ClientAdapter {
725
883
  }
726
884
  for (const [collection, payload] of Object.entries(collections)) {
727
885
  this.cache.assertCollectionExists(index, collection);
886
+ this.cache.assertCollectionWritable(index, collection);
728
887
  const { errors } = await this.client.import(index, collection, payload, { refresh });
729
888
  if (errors.length > 0) {
730
889
  throw servicesError.get("import_failed", errors);
@@ -1,11 +1,30 @@
1
+ /**
2
+ * A collection write lock: while it is held, the storage layer rejects every
3
+ * action writing to the collection, and serves reads as usual.
4
+ */
5
+ export interface CollectionLock {
6
+ /** Who holds the lock (e.g. a plugin name), the only one who can release it */
7
+ owner: string;
8
+ /** Why the collection is locked, quoted in the error writers receive */
9
+ reason: string;
10
+ /** Lock timestamp (Epoch-millis) */
11
+ lockedAt: number;
12
+ }
1
13
  export declare class IndexCache {
2
14
  /**
3
15
  * Index map: each entry holds a set of collection names
4
16
  *
5
17
  * Map<index, Set<collection>>
6
18
  */
7
- private indexes;
8
- constructor();
19
+ private readonly indexes;
20
+ /**
21
+ * Collection write locks, a local copy of the cluster-wide state (see
22
+ * `ClientAdapter`): checking a lock on every write must not cost a round
23
+ * trip to Redis.
24
+ *
25
+ * Map<index, Map<collection, CollectionLock>>
26
+ */
27
+ private readonly locks;
9
28
  /**
10
29
  * Cache a new index
11
30
  *
@@ -60,4 +79,32 @@ export declare class IndexCache {
60
79
  * Assert that the provided index and collection exist
61
80
  */
62
81
  assertCollectionExists(index: string, collection: string): void;
82
+ /**
83
+ * Cache a collection write lock, replacing the one it may already hold
84
+ */
85
+ setLock(index: string, collection: string, lock: CollectionLock): void;
86
+ /**
87
+ * Remove a collection write lock from the cache
88
+ */
89
+ removeLock(index: string, collection: string): void;
90
+ /**
91
+ * Remove every cached collection write lock
92
+ */
93
+ clearLocks(): void;
94
+ /**
95
+ * Return a collection write lock, or null if the collection is not locked
96
+ */
97
+ getLock(index: string, collection: string): CollectionLock | null;
98
+ /**
99
+ * Assert that the provided collection is not write-locked
100
+ *
101
+ * @throws If the collection is locked
102
+ */
103
+ assertCollectionWritable(index: string, collection: string): void;
104
+ /**
105
+ * Assert that none of the provided index collections is write-locked
106
+ *
107
+ * @throws If one of the index collections is locked
108
+ */
109
+ assertIndexWritable(index: string): void;
63
110
  }
@@ -54,7 +54,14 @@ class IndexCache {
54
54
  * Map<index, Set<collection>>
55
55
  */
56
56
  this.indexes = new Map();
57
- this.indexes = new Map();
57
+ /**
58
+ * Collection write locks, a local copy of the cluster-wide state (see
59
+ * `ClientAdapter`): checking a lock on every write must not cost a round
60
+ * trip to Redis.
61
+ *
62
+ * Map<index, Map<collection, CollectionLock>>
63
+ */
64
+ this.locks = new Map();
58
65
  }
59
66
  /**
60
67
  * Cache a new index
@@ -150,6 +157,66 @@ class IndexCache {
150
157
  throw storageError.get("unknown_collection", index, collection);
151
158
  }
152
159
  }
160
+ /**
161
+ * Cache a collection write lock, replacing the one it may already hold
162
+ */
163
+ setLock(index, collection, lock) {
164
+ let collections = this.locks.get(index);
165
+ if (collections === undefined) {
166
+ collections = new Map();
167
+ this.locks.set(index, collections);
168
+ }
169
+ collections.set(collection, lock);
170
+ }
171
+ /**
172
+ * Remove a collection write lock from the cache
173
+ */
174
+ removeLock(index, collection) {
175
+ const collections = this.locks.get(index);
176
+ if (collections) {
177
+ collections.delete(collection);
178
+ if (collections.size === 0) {
179
+ this.locks.delete(index);
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Remove every cached collection write lock
185
+ */
186
+ clearLocks() {
187
+ this.locks.clear();
188
+ }
189
+ /**
190
+ * Return a collection write lock, or null if the collection is not locked
191
+ */
192
+ getLock(index, collection) {
193
+ return this.locks.get(index)?.get(collection) ?? null;
194
+ }
195
+ /**
196
+ * Assert that the provided collection is not write-locked
197
+ *
198
+ * @throws If the collection is locked
199
+ */
200
+ assertCollectionWritable(index, collection) {
201
+ const lock = this.getLock(index, collection);
202
+ if (lock !== null) {
203
+ throw storageError.get("collection_locked", index, collection, lock.owner, lock.reason);
204
+ }
205
+ }
206
+ /**
207
+ * Assert that none of the provided index collections is write-locked
208
+ *
209
+ * @throws If one of the index collections is locked
210
+ */
211
+ assertIndexWritable(index) {
212
+ const collections = this.locks.get(index);
213
+ if (collections === undefined) {
214
+ return;
215
+ }
216
+ for (const collection of collections.keys()) {
217
+ this.assertCollectionWritable(index, collection);
218
+ }
219
+ }
153
220
  }
154
221
  exports.IndexCache = IndexCache;
155
222
  //# sourceMappingURL=indexCache.js.map
@@ -293,6 +293,12 @@
293
293
  "message": "Attempt to recreate an existing collection %s of index %s of scope %s with non matching static setting : %s at %s while existing one is at %s",
294
294
  "code": 51,
295
295
  "class": "BadRequestError"
296
+ },
297
+ "collection_locked": {
298
+ "description": "The collection is locked against writes, typically during a maintenance operation such as a mapping migration. Reads are still served: retry the write once the lock is released",
299
+ "message": "The collection \"%s\":\"%s\" is locked by \"%s\" (%s): write actions are rejected until it is unlocked.",
300
+ "code": 52,
301
+ "class": "ServiceUnavailableError"
296
302
  }
297
303
  }
298
304
  },
@@ -877,6 +877,10 @@ export declare class ES8 {
877
877
  * applies, which is what a row without one used to throw on.
878
878
  */
879
879
  _catAliases(name?: string): Promise<CatAliasRecord[]>;
880
+ /**
881
+ * Returns the indices marked with `UNMANAGED_INDICE_ALIAS`.
882
+ */
883
+ _listUnmanagedIndices(): Promise<Set<string>>;
880
884
  /**
881
885
  * Check for each indice whether it has an alias or not.
882
886
  * When the latter is missing, create one based on the indice name.
@@ -79,6 +79,14 @@ const PUBLIC_PREFIX = "&";
79
79
  const INDEX_PREFIX_POSITION_IN_INDICE = 0;
80
80
  const INDEX_PREFIX_POSITION_IN_ALIAS = 1;
81
81
  const NAME_SEPARATOR = ".";
82
+ /**
83
+ * Marks an indice the core must leave alone: neither given a missing alias
84
+ * (`generateMissingAliases`) nor counted in the storage stats. Set by tools
85
+ * building a collection's next indice beside it (e.g. a mapping migration).
86
+ * It sits outside the `@&` / `@%` alias convention, so every alias reader
87
+ * already ignores it.
88
+ */
89
+ const UNMANAGED_INDICE_ALIAS = "kuzzle-unmanaged";
82
90
  const FORBIDDEN_CHARS = `\\/*?"<>| \t\r\n,+#:${NAME_SEPARATOR}${PUBLIC_PREFIX}${PRIVATE_PREFIX}`;
83
91
  const DYNAMIC_PROPERTY_VALUES = ["true", "false", "strict"];
84
92
  // used to check whether we need to wait for ES to initialize or not
@@ -211,13 +219,15 @@ class ES8 {
211
219
  metric: ["docs", "store"],
212
220
  };
213
221
  const stats = await this._client.indices.stats(esRequest);
222
+ const unmanaged = await this._listUnmanagedIndices();
214
223
  const indexes = {};
215
224
  let size = 0;
216
225
  for (const [indice, indiceInfo] of Object.entries(stats.indices ?? {})) {
217
226
  const infos = indiceInfo;
218
- // Ignore non-Kuzzle indices
219
- if (!indice.startsWith(PRIVATE_PREFIX) &&
220
- !indice.startsWith(PUBLIC_PREFIX)) {
227
+ // Ignore non-Kuzzle indices, and the ones Kuzzle must leave alone
228
+ if ((!indice.startsWith(PRIVATE_PREFIX) &&
229
+ !indice.startsWith(PUBLIC_PREFIX)) ||
230
+ unmanaged.has(indice)) {
221
231
  continue;
222
232
  }
223
233
  const aliases = await this._getAliasFromIndice(indice);
@@ -2547,6 +2557,15 @@ class ES8 {
2547
2557
  index: index ?? "",
2548
2558
  }));
2549
2559
  }
2560
+ /**
2561
+ * Returns the indices marked with `UNMANAGED_INDICE_ALIAS`.
2562
+ */
2563
+ async _listUnmanagedIndices() {
2564
+ const body = await this._catAliases();
2565
+ return new Set(body
2566
+ .filter(({ alias }) => alias === UNMANAGED_INDICE_ALIAS)
2567
+ .map(({ index }) => index));
2568
+ }
2550
2569
  /**
2551
2570
  * Check for each indice whether it has an alias or not.
2552
2571
  * When the latter is missing, create one based on the indice name.
@@ -2559,7 +2578,9 @@ class ES8 {
2559
2578
  const body = await this._client.cat.indices({ format: "json" });
2560
2579
  const indices = body.flatMap(({ index: indice }) => indice === undefined ? [] : [indice]);
2561
2580
  const aliases = await this.listAliases();
2581
+ const unmanaged = await this._listUnmanagedIndices();
2562
2582
  const indicesWithoutAlias = indices.filter((indice) => indice[INDEX_PREFIX_POSITION_IN_INDICE] === this._indexPrefix &&
2583
+ !unmanaged.has(indice) &&
2563
2584
  !aliases.some((alias) => alias.indice === indice));
2564
2585
  const esRequest = {
2565
2586
  body: { actions: [] },
@@ -3,6 +3,12 @@ import type { PolicyRestrictions, OptimizedPolicyRestrictions } from "./PolicyRe
3
3
  * Policy definition for a role
4
4
  * and its restrictions
5
5
  *
6
+ * The same policy as the API contract's `ProfilePolicy`, typed differently on
7
+ * purpose: `ProfilePolicy.restrictedTo` is a one-element tuple and leaves
8
+ * `collections` optional; here it is a list of any length and
9
+ * `PolicyRestrictions.collections` is required. Kept apart, since aligning
10
+ * either one would stop code written against it from compiling.
11
+ *
6
12
  * @example
7
13
  * {
8
14
  * "roleId": "admin",
@@ -1,5 +1,7 @@
1
+ import type { RoleRightsDefinition } from "kuzzle-types";
1
2
  /**
2
- * A role definition
3
+ * A role definition: its rights, under `controllers`, in the shape of the API
4
+ * contract's `RoleRightsDefinition`
3
5
  *
4
6
  * @see https://docs.kuzzle.io/core/2/guides/main-concepts/permissions/#roles
5
7
  *
@@ -17,11 +19,5 @@
17
19
  * }
18
20
  */
19
21
  export type RoleDefinition = {
20
- controllers: {
21
- [controllerName: string]: {
22
- actions: {
23
- [actionName: string]: boolean;
24
- };
25
- };
26
- };
22
+ controllers: RoleRightsDefinition;
27
23
  };
@@ -1,5 +1,6 @@
1
1
  import type { ByteSize, ClusterNodesStats } from "sdk-es7/api/types";
2
2
  import type { JSONObject } from "../../JSONObject";
3
+ export type { KRequestBody, KuzzleInfo } from "../KuzzleInfo";
3
4
  export type InfoResult = {
4
5
  type: string;
5
6
  version: string;
@@ -8,27 +9,6 @@ export type InfoResult = {
8
9
  spaceUsed?: ByteSize;
9
10
  nodes?: ClusterNodesStats;
10
11
  };
11
- /**
12
- * Kuzzle's own metadata, stored alongside every document it writes.
13
- *
14
- * `author` and `updater` are kuids, and `getKuid` answers `null` for an
15
- * anonymous write — so both are nullable, as is `updatedAt` on a document that
16
- * has only ever been created.
17
- */
18
- export type KuzzleInfo = {
19
- author: string | null;
20
- createdAt: number;
21
- updatedAt: number | null;
22
- updater: string | null;
23
- };
24
- /**
25
- * `_kuzzle_info` is `Partial` because a partial update writes only the two
26
- * fields it owns (`updatedAt`, `updater`) and leaves the creation pair to the
27
- * document already in the index.
28
- */
29
- export type KRequestBody<T> = T & {
30
- _kuzzle_info?: Partial<KuzzleInfo>;
31
- };
32
12
  export type { JSONObject } from "../../JSONObject";
33
13
  export type KImportError = {
34
14
  _id?: string | null;
@@ -1,5 +1,6 @@
1
1
  import type { estypes } from "sdk-es8";
2
2
  import type { JSONObject } from "../../JSONObject";
3
+ export type { KRequestBody, KuzzleInfo } from "../KuzzleInfo";
3
4
  export type InfoResult = {
4
5
  type: string;
5
6
  version: string;
@@ -30,27 +31,6 @@ export type KStats = {
30
31
  indexes: KStatsIndex[];
31
32
  size: estypes.ByteSize;
32
33
  };
33
- /**
34
- * Kuzzle's own metadata, stored alongside every document it writes.
35
- *
36
- * `author` and `updater` are kuids, and `getKuid` answers `null` for an
37
- * anonymous write — so both are nullable, as is `updatedAt` on a document that
38
- * has only ever been created.
39
- */
40
- export type KuzzleInfo = {
41
- author: string | null;
42
- createdAt: number;
43
- updatedAt: number | null;
44
- updater: string | null;
45
- };
46
- /**
47
- * `_kuzzle_info` is `Partial` because a partial update writes only the two
48
- * fields it owns (`updatedAt`, `updater`) and leaves the creation pair to the
49
- * document already in the index.
50
- */
51
- export type KRequestBody<T> = T & {
52
- _kuzzle_info?: Partial<KuzzleInfo>;
53
- };
54
34
  export type { JSONObject } from "../../JSONObject";
55
35
  export type KImportError = {
56
36
  _id?: string | null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Kuzzle's own metadata, stored alongside every document it writes.
3
+ *
4
+ * `author` and `updater` are kuids, and `getKuid` answers `null` for an
5
+ * anonymous write — so both are nullable, as is `updatedAt` on a document that
6
+ * has only ever been created.
7
+ *
8
+ * Wider than the API contract's `KDocumentKuzzleInfo`, whose `author` is a
9
+ * `string`: that type describes what a client is handed, this one what the
10
+ * storage layer may write. Kept apart on purpose.
11
+ */
12
+ export type KuzzleInfo = {
13
+ author: string | null;
14
+ createdAt: number;
15
+ updatedAt: number | null;
16
+ updater: string | null;
17
+ };
18
+ /**
19
+ * `_kuzzle_info` is `Partial` because a partial update writes only the two
20
+ * fields it owns (`updatedAt`, `updater`) and leaves the creation pair to the
21
+ * document already in the index.
22
+ */
23
+ export type KRequestBody<T> = T & {
24
+ _kuzzle_info?: Partial<KuzzleInfo>;
25
+ };
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ // Shared by the Elasticsearch 7 and 8 storage types, which re-export both
3
+ // under the names they have always exported.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ //# sourceMappingURL=KuzzleInfo.js.map
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.58.0",
4
+ "version": "2.59.0",
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.58.0",
4
+ "version": "2.59.0",
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",