spice-couchbase 2.0.61 → 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.
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+
3
+ const couchbase = require("couchbase");
4
+
5
+ // Connection health monitor and self-healing reconnect.
6
+ //
7
+ // The cluster object is created once at boot and stored on the `spice.database`
8
+ // globals. When the SDK wedges (every operation times out, but the SDK never
9
+ // recovers on its own) the only remedy used to be a process restart. This
10
+ // module notices that state and opens a fresh cluster in-process:
11
+ //
12
+ // 1. Passive signal: every driver operation reports its outcome. Enough
13
+ // connectivity errors (timeouts, cancelled requests, service not
14
+ // available) inside a sliding window trigger a probe straight away.
15
+ // 2. Active probe: on an interval, a KV `exists` and a `SELECT 1`, each with
16
+ // a short timeout and a hard deadline in case the SDK ignores it.
17
+ // 3. Reconnect: after repeated probe failures, build a new cluster from the
18
+ // same config, verify it, then swap it into the globals and retire the old
19
+ // one after a grace period. Single-flight, with exponential backoff.
20
+ // 4. Escalation: if the connection stays unhealthy for too long, terminate
21
+ // the process so the supervisor (PM2) restarts it.
22
+
23
+ function envNumber(name, fallback) {
24
+ const raw = process.env[name];
25
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
26
+ return fallback;
27
+ }
28
+ const value = Number(raw);
29
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
30
+ }
31
+
32
+ function defaultSettings() {
33
+ return {
34
+ // 0 disables the interval probe (the passive signal still works).
35
+ healthIntervalMs: envNumber("SPICE_COUCHBASE_HEALTH_INTERVAL_MS", 15000),
36
+ probeTimeoutMs: envNumber("SPICE_COUCHBASE_PROBE_TIMEOUT_MS", 3000),
37
+ // Consecutive failed probes before the connection is declared unhealthy.
38
+ probeFailureThreshold: Math.max(
39
+ 1,
40
+ envNumber("SPICE_COUCHBASE_PROBE_FAILURE_THRESHOLD", 2),
41
+ ),
42
+ // Connectivity errors inside the window that trigger an immediate probe.
43
+ failureThreshold: Math.max(
44
+ 1,
45
+ envNumber("SPICE_COUCHBASE_FAILURE_THRESHOLD", 5),
46
+ ),
47
+ failureWindowMs: envNumber("SPICE_COUCHBASE_FAILURE_WINDOW_MS", 30000),
48
+ reconnectTimeoutMs: envNumber("SPICE_COUCHBASE_RECONNECT_TIMEOUT_MS", 20000),
49
+ reconnectBackoffMinMs: envNumber(
50
+ "SPICE_COUCHBASE_RECONNECT_BACKOFF_MIN_MS",
51
+ 5000,
52
+ ),
53
+ reconnectBackoffMaxMs: envNumber(
54
+ "SPICE_COUCHBASE_RECONNECT_BACKOFF_MAX_MS",
55
+ 60000,
56
+ ),
57
+ // How long a replaced cluster stays open so in-flight operations settle.
58
+ retireGraceMs: envNumber("SPICE_COUCHBASE_RETIRE_GRACE_MS", 30000),
59
+ // 0 disables the escalation.
60
+ exitAfterMs: envNumber("SPICE_COUCHBASE_EXIT_AFTER_MS", 300000),
61
+ };
62
+ }
63
+
64
+ const CONNECTIVITY_ERRORS = [
65
+ couchbase.TimeoutError,
66
+ couchbase.RequestCanceledError,
67
+ couchbase.ServiceNotAvailableError,
68
+ ].filter((type) => typeof type === "function");
69
+
70
+ function isConnectivityError(error) {
71
+ if (!error || typeof error !== "object") return false;
72
+ if (CONNECTIVITY_ERRORS.some((type) => error instanceof type)) return true;
73
+ // Our own deadlines, and SDK errors that crossed a module boundary.
74
+ return (
75
+ error.spiceConnectivity === true ||
76
+ [
77
+ "TimeoutError",
78
+ "AmbiguousTimeoutError",
79
+ "UnambiguousTimeoutError",
80
+ "RequestCanceledError",
81
+ "ServiceNotAvailableError",
82
+ ].includes(error.name)
83
+ );
84
+ }
85
+
86
+ function deadline(promise, ms, label) {
87
+ let timer;
88
+ return Promise.race([
89
+ promise,
90
+ new Promise((_resolve, reject) => {
91
+ timer = setTimeout(() => {
92
+ const error = new Error(`${label} timed out after ${ms}ms`);
93
+ error.spiceConnectivity = true;
94
+ reject(error);
95
+ }, ms);
96
+ timer.unref?.();
97
+ }),
98
+ ]).finally(() => clearTimeout(timer));
99
+ }
100
+
101
+ function describe(error) {
102
+ return error?.message || String(error);
103
+ }
104
+
105
+ async function closeQuietly(cluster, log, label) {
106
+ try {
107
+ await cluster?.close?.();
108
+ } catch (error) {
109
+ log.warn(`[spice-couchbase] Closing ${label} failed: ${describe(error)}`);
110
+ }
111
+ }
112
+
113
+ function buildConnectOptions(config = {}) {
114
+ const options = {
115
+ username: config.username,
116
+ password: config.password,
117
+ };
118
+ if (config.configProfile) {
119
+ options.configProfile = config.configProfile;
120
+ }
121
+ const timeouts = {};
122
+ for (const key of [
123
+ "kvTimeout",
124
+ "kvDurableTimeout",
125
+ "queryTimeout",
126
+ "searchTimeout",
127
+ "analyticsTimeout",
128
+ "managementTimeout",
129
+ "connectTimeout",
130
+ "bootstrapTimeout",
131
+ "resolveTimeout",
132
+ ]) {
133
+ const raw = config.timeouts?.[key];
134
+ if (raw === undefined || raw === null || raw === "") continue;
135
+ const value = Number(raw);
136
+ if (Number.isFinite(value) && value > 0) timeouts[key] = value;
137
+ }
138
+ if (Object.keys(timeouts).length > 0) {
139
+ options.timeouts = timeouts;
140
+ }
141
+ return options;
142
+ }
143
+
144
+ /**
145
+ * Opens a cluster and resolves the bucket, scope and collection handles for a
146
+ * connection config. Shared by the boot-time connect and the reconnect path.
147
+ */
148
+ async function openHandles(config, { connect = couchbase.connect } = {}) {
149
+ const cluster = await connect(config.nodes, buildConnectOptions(config));
150
+ const bucket = cluster.bucket(config.bucket);
151
+ const scope = bucket.scope(config.scope || "_default");
152
+ const collection = scope.collection(config.collection || "_default");
153
+ return { cluster, bucket, scope, collection };
154
+ }
155
+
156
+ function createHealthMonitor({
157
+ settings: overrides = {},
158
+ connect = couchbase.connect,
159
+ now = () => Date.now(),
160
+ random = Math.random,
161
+ log = console,
162
+ getGlobals = () => (typeof spice !== "undefined" ? spice : undefined),
163
+ terminate,
164
+ } = {}) {
165
+ const settings = { ...defaultSettings(), ...overrides };
166
+ const states = new Map();
167
+ let stopped = false;
168
+
169
+ const terminateProcess =
170
+ terminate ||
171
+ (() => {
172
+ // SIGTERM runs the application's graceful shutdown when it has one,
173
+ // and terminates the process when it does not. The fallback covers a
174
+ // shutdown that hangs on the very connection that is stuck.
175
+ process.exitCode = 1;
176
+ const fallback = setTimeout(() => process.exit(1), 15000);
177
+ fallback.unref?.();
178
+ process.kill(process.pid, "SIGTERM");
179
+ });
180
+
181
+ function stateFor(name) {
182
+ let state = states.get(name);
183
+ if (!state) {
184
+ state = {
185
+ name,
186
+ config: null,
187
+ generation: 1,
188
+ healthy: true,
189
+ unhealthySince: null,
190
+ failures: [],
191
+ probeFailures: 0,
192
+ probing: null,
193
+ reconnecting: null,
194
+ reconnectAttempts: 0,
195
+ nextReconnectAt: 0,
196
+ lastError: null,
197
+ lastReconnectAt: null,
198
+ timer: null,
199
+ retired: new Set(),
200
+ terminated: false,
201
+ };
202
+ states.set(name, state);
203
+ }
204
+ return state;
205
+ }
206
+
207
+ function currentHandles(name) {
208
+ const database = getGlobals()?.database;
209
+ if (!database) return null;
210
+ return {
211
+ cluster: database.clusters?.[name],
212
+ bucket: database.buckets?.[name],
213
+ scope: database.scopes?.[name],
214
+ collection: database.connections?.[name],
215
+ };
216
+ }
217
+
218
+ async function probe(handles) {
219
+ if (!handles?.cluster || !handles?.collection) {
220
+ const error = new Error("No cluster handles to probe");
221
+ error.spiceConnectivity = true;
222
+ throw error;
223
+ }
224
+ const timeout = settings.probeTimeoutMs;
225
+ // The hard deadline is the point: a wedged SDK may never honour its own.
226
+ await deadline(
227
+ Promise.all([
228
+ handles.collection.exists("__spice_health_probe__", { timeout }),
229
+ handles.cluster.query("SELECT 1", { timeout }),
230
+ ]),
231
+ timeout + 1000,
232
+ "Couchbase health probe",
233
+ );
234
+ }
235
+
236
+ function markHealthy(state) {
237
+ state.probeFailures = 0;
238
+ state.failures = [];
239
+ state.reconnectAttempts = 0;
240
+ state.nextReconnectAt = 0;
241
+ if (!state.healthy) {
242
+ const downFor = state.unhealthySince ? now() - state.unhealthySince : 0;
243
+ log.log(
244
+ `[spice-couchbase] Connection "${state.name}" healthy again ` +
245
+ `(generation ${state.generation}, unhealthy for ${downFor}ms)`,
246
+ );
247
+ }
248
+ state.healthy = true;
249
+ state.unhealthySince = null;
250
+ state.lastError = null;
251
+ }
252
+
253
+ function markUnhealthy(state, error) {
254
+ state.lastError = describe(error);
255
+ if (state.healthy) {
256
+ state.healthy = false;
257
+ state.unhealthySince = now();
258
+ log.warn(
259
+ `[spice-couchbase] Connection "${state.name}" unhealthy: ${state.lastError}`,
260
+ );
261
+ }
262
+ }
263
+
264
+ function escalateIfStuck(state) {
265
+ if (
266
+ state.terminated ||
267
+ stopped ||
268
+ !settings.exitAfterMs ||
269
+ !state.unhealthySince ||
270
+ now() - state.unhealthySince < settings.exitAfterMs
271
+ ) {
272
+ return;
273
+ }
274
+ state.terminated = true;
275
+ log.error(
276
+ `[spice-couchbase] Connection "${state.name}" has been unhealthy for ` +
277
+ `${now() - state.unhealthySince}ms after ${state.reconnectAttempts} ` +
278
+ `failed reconnect(s). Terminating so the supervisor restarts the process.`,
279
+ );
280
+ terminateProcess();
281
+ }
282
+
283
+ function retire(state, cluster) {
284
+ if (!cluster) return;
285
+ state.retired.add(cluster);
286
+ const timer = setTimeout(() => {
287
+ state.retired.delete(cluster);
288
+ closeQuietly(cluster, log, `retired cluster for "${state.name}"`);
289
+ }, settings.retireGraceMs);
290
+ timer.unref?.();
291
+ }
292
+
293
+ function backoffDelay(attempt) {
294
+ const base = Math.min(
295
+ settings.reconnectBackoffMaxMs,
296
+ settings.reconnectBackoffMinMs * 2 ** Math.max(0, attempt - 1),
297
+ );
298
+ // +/- 20% jitter so a fleet of pods does not reconnect in lockstep.
299
+ return Math.round(base * (0.8 + random() * 0.4));
300
+ }
301
+
302
+ async function doReconnect(state) {
303
+ const started = now();
304
+ const attempt = state.reconnectAttempts + 1;
305
+ log.warn(
306
+ `[spice-couchbase] Reconnecting "${state.name}" (attempt ${attempt})`,
307
+ );
308
+ let abandoned = false;
309
+ let handles;
310
+ try {
311
+ const opening = openHandles(state.config, { connect });
312
+ // If the connect outlives the deadline, close whatever it produces.
313
+ opening
314
+ .then((late) => {
315
+ if (abandoned) closeQuietly(late.cluster, log, "abandoned cluster");
316
+ })
317
+ .catch(() => {});
318
+ handles = await deadline(
319
+ opening,
320
+ settings.reconnectTimeoutMs,
321
+ "Couchbase reconnect",
322
+ );
323
+ if (typeof handles.bucket?.waitUntilReady === "function") {
324
+ const serviceTypes = [
325
+ couchbase.ServiceType?.KeyValue,
326
+ couchbase.ServiceType?.Query,
327
+ ].filter(Boolean);
328
+ await deadline(
329
+ handles.bucket.waitUntilReady(
330
+ settings.reconnectTimeoutMs,
331
+ serviceTypes.length ? { serviceTypes } : undefined,
332
+ ),
333
+ settings.reconnectTimeoutMs + 1000,
334
+ "Couchbase waitUntilReady",
335
+ );
336
+ }
337
+ await probe(handles);
338
+ } catch (error) {
339
+ abandoned = true;
340
+ if (handles) closeQuietly(handles.cluster, log, "unverified cluster");
341
+ state.reconnectAttempts = attempt;
342
+ const delay = backoffDelay(attempt);
343
+ state.nextReconnectAt = now() + delay;
344
+ state.lastError = describe(error);
345
+ log.error(
346
+ `[spice-couchbase] Reconnect of "${state.name}" failed: ` +
347
+ `${state.lastError}. Next attempt in ~${delay}ms`,
348
+ );
349
+ return false;
350
+ }
351
+
352
+ if (stopped) {
353
+ closeQuietly(handles.cluster, log, "cluster opened during shutdown");
354
+ return false;
355
+ }
356
+
357
+ const globals = getGlobals();
358
+ const database = globals?.database;
359
+ const previous = database?.clusters?.[state.name];
360
+ if (database) {
361
+ database.clusters[state.name] = handles.cluster;
362
+ database.buckets[state.name] = handles.bucket;
363
+ database.scopes[state.name] = handles.scope;
364
+ database.connections[state.name] = handles.collection;
365
+ }
366
+ // Collection existence was learnt through the old cluster; re-learn it.
367
+ if (globals) {
368
+ globals.checked_collections = {};
369
+ globals.db?.collections?.clear?.();
370
+ }
371
+ state.generation += 1;
372
+ state.lastReconnectAt = now();
373
+ if (previous && previous !== handles.cluster) retire(state, previous);
374
+ log.log(
375
+ `[spice-couchbase] Reconnected "${state.name}" ` +
376
+ `(generation ${state.generation}, took ${now() - started}ms)`,
377
+ );
378
+ markHealthy(state);
379
+ return true;
380
+ }
381
+
382
+ function reconnect(name) {
383
+ const state = stateFor(name);
384
+ if (stopped || !state.config) return Promise.resolve(false);
385
+ if (!state.reconnecting) {
386
+ state.reconnecting = doReconnect(state)
387
+ .catch((error) => {
388
+ log.error(
389
+ `[spice-couchbase] Reconnect of "${name}" crashed: ${describe(error)}`,
390
+ );
391
+ return false;
392
+ })
393
+ .finally(() => {
394
+ state.reconnecting = null;
395
+ });
396
+ }
397
+ return state.reconnecting;
398
+ }
399
+
400
+ async function doCheck(state, { suspected = false } = {}) {
401
+ try {
402
+ await probe(currentHandles(state.name));
403
+ markHealthy(state);
404
+ return true;
405
+ } catch (error) {
406
+ state.probeFailures += 1;
407
+ // A failed probe that follows a burst of operation timeouts is two
408
+ // independent signals, so it does not wait for a second probe.
409
+ if (suspected || state.probeFailures >= settings.probeFailureThreshold) {
410
+ markUnhealthy(state, error);
411
+ }
412
+ if (!state.healthy) {
413
+ if (!state.reconnecting && now() >= state.nextReconnectAt) {
414
+ await reconnect(state.name);
415
+ }
416
+ escalateIfStuck(state);
417
+ }
418
+ return false;
419
+ }
420
+ }
421
+
422
+ /** Probes the connection now. Single-flight per connection. */
423
+ function check(name, options) {
424
+ const state = stateFor(name);
425
+ if (stopped || !state.config) return Promise.resolve(state.healthy);
426
+ if (state.reconnecting) return state.reconnecting;
427
+ if (!state.probing) {
428
+ state.probing = doCheck(state, options).finally(() => {
429
+ state.probing = null;
430
+ });
431
+ }
432
+ return state.probing;
433
+ }
434
+
435
+ /**
436
+ * Called by the driver after an operation. Only connectivity errors count,
437
+ * and only inside the sliding window, so a busy cluster that answers most
438
+ * requests is not torn down for one slow statement.
439
+ */
440
+ function recordOutcome(name, error) {
441
+ if (stopped || !name || !isConnectivityError(error)) return;
442
+ const state = states.get(name);
443
+ if (!state?.config) return;
444
+ const at = now();
445
+ state.failures.push(at);
446
+ const cutoff = at - settings.failureWindowMs;
447
+ while (state.failures.length && state.failures[0] < cutoff) {
448
+ state.failures.shift();
449
+ }
450
+ if (state.failures.length >= settings.failureThreshold) {
451
+ state.failures = [];
452
+ check(name, { suspected: true }).catch(() => {});
453
+ }
454
+ }
455
+
456
+ /** Starts monitoring a connection. Called once its first connect succeeds. */
457
+ function register(name, config) {
458
+ const state = stateFor(name);
459
+ state.config = config;
460
+ if (stopped || state.timer || !settings.healthIntervalMs) return;
461
+ state.timer = setInterval(() => {
462
+ check(name).catch(() => {});
463
+ }, settings.healthIntervalMs);
464
+ state.timer.unref?.();
465
+ }
466
+
467
+ function generation(name) {
468
+ return states.get(name)?.generation || 1;
469
+ }
470
+
471
+ function status() {
472
+ const result = {};
473
+ for (const [name, state] of states) {
474
+ result[name] = {
475
+ healthy: state.healthy,
476
+ reconnecting: Boolean(state.reconnecting),
477
+ generation: state.generation,
478
+ recentFailures: state.failures.length,
479
+ probeFailures: state.probeFailures,
480
+ reconnectAttempts: state.reconnectAttempts,
481
+ unhealthySince: state.unhealthySince
482
+ ? new Date(state.unhealthySince).toISOString()
483
+ : null,
484
+ lastReconnectAt: state.lastReconnectAt
485
+ ? new Date(state.lastReconnectAt).toISOString()
486
+ : null,
487
+ lastError: state.lastError,
488
+ };
489
+ }
490
+ return result;
491
+ }
492
+
493
+ /**
494
+ * Stops probing and reconnecting, and closes clusters that were replaced
495
+ * but are still in their grace period. The current clusters stay open: the
496
+ * application closes those through `spice.database.clusters`.
497
+ */
498
+ async function stop() {
499
+ stopped = true;
500
+ const closing = [];
501
+ for (const state of states.values()) {
502
+ if (state.timer) clearInterval(state.timer);
503
+ state.timer = null;
504
+ for (const cluster of state.retired) {
505
+ closing.push(closeQuietly(cluster, log, `retired cluster for "${state.name}"`));
506
+ }
507
+ state.retired.clear();
508
+ }
509
+ await Promise.all(closing);
510
+ }
511
+
512
+ return {
513
+ settings,
514
+ register,
515
+ recordOutcome,
516
+ check,
517
+ reconnect,
518
+ generation,
519
+ status,
520
+ stop,
521
+ };
522
+ }
523
+
524
+ module.exports = {
525
+ createHealthMonitor,
526
+ openHandles,
527
+ buildConnectOptions,
528
+ isConnectivityError,
529
+ };