spice-couchbase 2.0.62 → 2.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ let couchbase = require("couchbase");
5
5
  const { ConflictResolutionType } = require("couchbase");
6
6
  const { QueryScanConsistency } = require("couchbase");
7
7
  const { parseResourceList, extractStatementResources } = require("./utility");
8
+ const { createHealthMonitor, openHandles } = require("./connectionHealth");
8
9
 
9
10
  if (!Promise.allSettled) {
10
11
  Promise.allSettled = (promises) =>
@@ -71,6 +72,27 @@ const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
71
72
  );
72
73
  let autoOptimizePolicy = null;
73
74
 
75
+ // One health monitor per process, shared by every copy of this module that
76
+ // ends up loaded. See connectionHealth.js.
77
+ const HEALTH_MONITOR_KEY = Symbol.for("spice-couchbase.health-monitor");
78
+ const health =
79
+ globalThis[HEALTH_MONITOR_KEY] ||
80
+ (globalThis[HEALTH_MONITOR_KEY] = createHealthMonitor());
81
+
82
+ // Cluster handles live on the `spice.database` globals, which a reconnect
83
+ // replaces. Instances read them at call time instead of copying them.
84
+ const HANDLE_GLOBALS = {
85
+ cluster: "clusters",
86
+ connection: "connections",
87
+ scope: "scopes",
88
+ bucket_handle: "buckets",
89
+ };
90
+
91
+ function globalHandle(kind, connection_name) {
92
+ if (typeof spice === "undefined") return undefined;
93
+ return spice?.database?.[HANDLE_GLOBALS[kind]]?.[connection_name];
94
+ }
95
+
74
96
  function requestedAutoOptimize(query_options) {
75
97
  if (
76
98
  !query_options ||
@@ -164,13 +186,9 @@ module.exports = class Database {
164
186
  ) {
165
187
  if (connection) {
166
188
  this.connection_name = connection;
167
- this.connection = spice.database.connections[connection];
168
189
  this.query_scope = query_scope;
169
- this.cluster = spice.database.clusters[connection];
170
190
  this.bucket = this.connection._scope._bucket._name;
171
191
  this.db_config = spice.config.database.connections[connection];
172
- this.collection_manager =
173
- spice.database.buckets[connection].collections();
174
192
  this.auto_create_collection =
175
193
  auto_create_collection ||
176
194
  ((collection ||
@@ -196,14 +214,11 @@ module.exports = class Database {
196
214
  scope ||
197
215
  spice.config.database.connections[connection].scope ||
198
216
  "_default";
199
- this.scope = spice.database.scopes[connection];
200
217
  this.full_text_index =
201
218
  spice.config.database.connections[connection].full_text_index ||
202
219
  full_text_index;
203
220
  this.N1qlQuery = couchbase.N1qlQuery;
204
221
  this.SearchQuery = couchbase.SearchQuery;
205
- this.collection = this.scope.collection(this.collection_name);
206
- this.userManager = this.getUserManager(connection);
207
222
  if (!spice?.db?.collections) {
208
223
  spice.db = spice.db || {};
209
224
  spice.db.collections = new Set();
@@ -230,6 +245,137 @@ module.exports = class Database {
230
245
  }
231
246
  }
232
247
 
248
+ // Handles resolve from the globals on every access, so an instance created
249
+ // before a reconnect uses the new cluster afterwards. Assigning one pins it
250
+ // on that instance instead (tests, custom wiring).
251
+ pinHandle(kind, value) {
252
+ if (!Object.prototype.hasOwnProperty.call(this, "_pinned_handles")) {
253
+ Object.defineProperty(this, "_pinned_handles", {
254
+ value: {},
255
+ enumerable: false,
256
+ writable: true,
257
+ });
258
+ }
259
+ this._pinned_handles[kind] = value;
260
+ }
261
+
262
+ pinnedHandle(kind) {
263
+ const pinned = this._pinned_handles;
264
+ return pinned && kind in pinned ? { value: pinned[kind] } : null;
265
+ }
266
+
267
+ handle(kind) {
268
+ const pinned = this.pinnedHandle(kind);
269
+ return pinned ? pinned.value : globalHandle(kind, this.connection_name);
270
+ }
271
+
272
+ get cluster() {
273
+ return this.handle("cluster");
274
+ }
275
+ set cluster(value) {
276
+ this.pinHandle("cluster", value);
277
+ }
278
+
279
+ get connection() {
280
+ return this.handle("connection");
281
+ }
282
+ set connection(value) {
283
+ this.pinHandle("connection", value);
284
+ }
285
+
286
+ get scope() {
287
+ return this.handle("scope");
288
+ }
289
+ set scope(value) {
290
+ this.pinHandle("scope", value);
291
+ }
292
+
293
+ get collection() {
294
+ const pinned = this.pinnedHandle("collection");
295
+ if (pinned) return pinned.value;
296
+ const scope = this.scope;
297
+ if (!scope) return undefined;
298
+ // Collection handles are cheap but not free; cache one per scope object,
299
+ // which changes exactly when the connection is replaced.
300
+ if (this._collection_cache?.scope !== scope) {
301
+ Object.defineProperty(this, "_collection_cache", {
302
+ value: { scope, collection: scope.collection(this.collection_name) },
303
+ enumerable: false,
304
+ writable: true,
305
+ configurable: true,
306
+ });
307
+ }
308
+ return this._collection_cache.collection;
309
+ }
310
+ set collection(value) {
311
+ this.pinHandle("collection", value);
312
+ }
313
+
314
+ get collection_manager() {
315
+ const pinned = this.pinnedHandle("collection_manager");
316
+ if (pinned) return pinned.value;
317
+ return this.handle("bucket_handle")?.collections();
318
+ }
319
+ set collection_manager(value) {
320
+ this.pinHandle("collection_manager", value);
321
+ }
322
+
323
+ get userManager() {
324
+ const pinned = this.pinnedHandle("userManager");
325
+ if (pinned) return pinned.value;
326
+ return this.cluster?.users();
327
+ }
328
+ set userManager(value) {
329
+ this.pinHandle("userManager", value);
330
+ }
331
+
332
+ /**
333
+ * Reports a finished operation to the health monitor. Only connectivity
334
+ * errors (timeouts, cancelled requests, service not available) count.
335
+ */
336
+ recordOutcome(error) {
337
+ health.recordOutcome(this.connection_name, error);
338
+ }
339
+
340
+ recordSettled(results) {
341
+ for (const result of results) {
342
+ if (result?.status === "rejected") this.recordOutcome(result.reason);
343
+ }
344
+ }
345
+
346
+ async observe(operation) {
347
+ try {
348
+ return await operation;
349
+ } catch (error) {
350
+ this.recordOutcome(error);
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ /** Per-connection health: healthy, reconnecting, generation, last error. */
356
+ static healthStatus() {
357
+ return health.status();
358
+ }
359
+
360
+ /** Probes a connection now and reconnects it if the probe fails. */
361
+ static checkHealth(connection_name = "default") {
362
+ return health.check(connection_name);
363
+ }
364
+
365
+ /** Opens a fresh cluster for a connection and swaps it in. */
366
+ static forceReconnect(connection_name = "default") {
367
+ return health.reconnect(connection_name);
368
+ }
369
+
370
+ /**
371
+ * Stops probing and reconnecting and closes replaced clusters still in
372
+ * their grace period. Call before closing `spice.database.clusters` on
373
+ * shutdown so a reconnect cannot race it.
374
+ */
375
+ static stopHealthMonitor() {
376
+ return health.stop();
377
+ }
378
+
233
379
  /**
234
380
  * Lets the application decide per resource whether slow statements may
235
381
  * create covering indexes, e.g. from a per-resource setting an operator
@@ -324,26 +470,9 @@ module.exports = class Database {
324
470
  }
325
471
 
326
472
  async connect(connection_name, config) {
327
- let connectOptions = {
328
- username: config.username,
329
- password: config.password,
330
- };
331
- if (config.configProfile) {
332
- connectOptions.configProfile = config.configProfile;
333
- }
334
-
335
- let cluster = await couchbase.connect(config.nodes, connectOptions);
336
- let bucket = cluster.bucket(config.bucket);
337
- let collection;
338
- let scope_name = this.scope_name || config.scope || "_default";
339
- let collection_name =
340
- this.collection_name || config.collection || "_default";
341
- let scope = bucket.scope(scope_name);
342
-
343
- collection = bucket
344
- .scope(scope_name)
345
- .collection(collection_name || "_default");
346
-
473
+ const { cluster, bucket, scope, collection } = await openHandles(config);
474
+ // Watch the connection from here on and replace it if it wedges.
475
+ health.register(connection_name, config);
347
476
  return {
348
477
  connection: connection_name,
349
478
  bucket,
@@ -354,7 +483,7 @@ module.exports = class Database {
354
483
  }
355
484
 
356
485
  getUserManager(connection) {
357
- return spice.database.clusters[connection].users();
486
+ return globalHandle("cluster", connection).users();
358
487
  }
359
488
 
360
489
  async assignUserPermission() {
@@ -534,7 +663,7 @@ module.exports = class Database {
534
663
  this.use_bucket_name ? this.connection : this.collection;
535
664
 
536
665
  if (!useLookupIn) {
537
- let response = await collection.get(doc_id);
666
+ let response = await this.observe(collection.get(doc_id));
538
667
  return { id: doc_id, ...response.content };
539
668
  }
540
669
 
@@ -549,6 +678,7 @@ module.exports = class Database {
549
678
  });
550
679
 
551
680
  const batchResults = await Promise.allSettled(batchPromises);
681
+ this.recordSettled(batchResults);
552
682
 
553
683
  // Merge results from all batches
554
684
  batchResults.forEach((result, batchIndex) => {
@@ -570,12 +700,14 @@ module.exports = class Database {
570
700
 
571
701
  async insert(doc_id, document, expiry) {
572
702
  try {
573
- await (this.use_bucket_name ? this.connection : this.collection).insert(
574
- doc_id,
575
- document,
576
- {
577
- expiry: expiry,
578
- },
703
+ await this.observe(
704
+ (this.use_bucket_name ? this.connection : this.collection).insert(
705
+ doc_id,
706
+ document,
707
+ {
708
+ expiry: expiry,
709
+ },
710
+ ),
579
711
  );
580
712
  } catch (e) {
581
713
  console.log(e.message, e, doc_id);
@@ -584,38 +716,45 @@ module.exports = class Database {
584
716
  }
585
717
 
586
718
  async touch(doc_id, expiry = 0) {
587
- await (this.use_bucket_name ? this.connection : this.collection).touch(
588
- doc_id,
589
- expiry,
719
+ await this.observe(
720
+ (this.use_bucket_name ? this.connection : this.collection).touch(
721
+ doc_id,
722
+ expiry,
723
+ ),
590
724
  );
591
725
  return await this.get(doc_id);
592
726
  }
593
727
 
594
728
  async count(doc_id, adjustment, initial_value) {
595
- let res = await (
596
- this.use_bucket_name ?
597
- this.connection
598
- : this.collection).counter(doc_id, adjustment, {
599
- initial: initial_value,
600
- });
729
+ let res = await this.observe(
730
+ (this.use_bucket_name ? this.connection : this.collection).counter(
731
+ doc_id,
732
+ adjustment,
733
+ {
734
+ initial: initial_value,
735
+ },
736
+ ),
737
+ );
601
738
  return res.value;
602
739
  }
603
740
 
604
741
  async update(doc_id, document, expiry) {
605
- let res = await (
606
- this.use_bucket_name ?
607
- this.connection
608
- : this.collection).replace(doc_id, document, {
609
- expiry: expiry,
610
- });
742
+ let res = await this.observe(
743
+ (this.use_bucket_name ? this.connection : this.collection).replace(
744
+ doc_id,
745
+ document,
746
+ {
747
+ expiry: expiry,
748
+ },
749
+ ),
750
+ );
611
751
  return res.value;
612
752
  }
613
753
 
614
754
  async delete(doc_id) {
615
- let res = await (
616
- this.use_bucket_name ?
617
- this.connection
618
- : this.collection).remove(doc_id);
755
+ let res = await this.observe(
756
+ (this.use_bucket_name ? this.connection : this.collection).remove(doc_id),
757
+ );
619
758
  return res.value;
620
759
  }
621
760
 
@@ -632,6 +771,7 @@ module.exports = class Database {
632
771
  // Regular get - no batching needed
633
772
  let functions = _.map(keys, (key) => collection.get(key));
634
773
  let returned_all = await Promise.allSettled(functions);
774
+ this.recordSettled(returned_all);
635
775
 
636
776
  let responses = _.flatten(
637
777
  _.map(returned_all, (returned_obj) => returned_obj?.value),
@@ -670,6 +810,7 @@ module.exports = class Database {
670
810
  });
671
811
 
672
812
  const batchResults = await Promise.allSettled(batchPromises);
813
+ this.recordSettled(batchResults);
673
814
 
674
815
  // Merge results from all batches
675
816
  batchResults.forEach((result, batchIndex) => {
@@ -1068,7 +1209,9 @@ module.exports = class Database {
1068
1209
  // Start time
1069
1210
  let startTime = process.hrtime.bigint();
1070
1211
  const options = buildQueryOptions(query_options);
1071
- let response = await exec_scope.query(sanitizedQuery, options);
1212
+ let response = await this.observe(
1213
+ exec_scope.query(sanitizedQuery, options),
1214
+ );
1072
1215
  // End time
1073
1216
  let endTime = process.hrtime.bigint();
1074
1217
  // Calculate duration in microseconds