spice-couchbase 2.0.62 → 2.0.64

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,461 @@
1
+ "use strict";
2
+
3
+ const assert = require("node:assert/strict");
4
+ const { test } = require("node:test");
5
+ const couchbase = require("couchbase");
6
+ const {
7
+ createHealthMonitor,
8
+ buildConnectOptions,
9
+ isConnectivityError,
10
+ } = require("./connectionHealth");
11
+
12
+ const config = {
13
+ nodes: "couchbase://db",
14
+ bucket: "saturn",
15
+ scope: "app",
16
+ username: "u",
17
+ password: "p",
18
+ };
19
+
20
+ const timeoutError = () => new couchbase.UnambiguousTimeoutError();
21
+
22
+ // A fake cluster whose probe behaviour can be flipped between healthy and
23
+ // wedged. `hang` makes operations never settle, like a stuck SDK.
24
+ function fakeCluster(id, { healthy = true, hang = false } = {}) {
25
+ const cluster = {
26
+ id,
27
+ healthy,
28
+ hang,
29
+ closed: 0,
30
+ queries: 0,
31
+ kvStates: [couchbase.PingState.Ok],
32
+ async close() {
33
+ cluster.closed += 1;
34
+ },
35
+ query() {
36
+ cluster.queries += 1;
37
+ if (cluster.hang) return new Promise(() => {});
38
+ return cluster.healthy ?
39
+ Promise.resolve({ rows: [{ $1: 1 }] })
40
+ : Promise.reject(timeoutError());
41
+ },
42
+ bucket(name) {
43
+ const bucket = {
44
+ name,
45
+ cluster,
46
+ async waitUntilReady() {
47
+ if (!cluster.healthy) throw timeoutError();
48
+ },
49
+ ping() {
50
+ if (cluster.hang) return new Promise(() => {});
51
+ if (!cluster.healthy) return Promise.reject(timeoutError());
52
+ return Promise.resolve({
53
+ services: { kv: cluster.kvStates.map((state) => ({ state })) },
54
+ });
55
+ },
56
+ scope(scopeName) {
57
+ return {
58
+ name: scopeName,
59
+ bucket,
60
+ collection(collectionName) {
61
+ return {
62
+ name: collectionName,
63
+ cluster,
64
+ // Like `_default` inside a named scope: the collection does
65
+ // not exist, so KV operations on it always time out.
66
+ exists() {
67
+ return Promise.reject(timeoutError());
68
+ },
69
+ };
70
+ },
71
+ };
72
+ },
73
+ };
74
+ return bucket;
75
+ },
76
+ };
77
+ return cluster;
78
+ }
79
+
80
+ function handlesFor(cluster) {
81
+ const bucket = cluster.bucket(config.bucket);
82
+ const scope = bucket.scope(config.scope);
83
+ return { cluster, bucket, scope, collection: scope.collection("_default") };
84
+ }
85
+
86
+ function setup({ settings = {}, connect, clock } = {}) {
87
+ const initial = fakeCluster("initial");
88
+ const handles = handlesFor(initial);
89
+ const globals = {
90
+ database: {
91
+ clusters: { default: handles.cluster },
92
+ buckets: { default: handles.bucket },
93
+ scopes: { default: handles.scope },
94
+ connections: { default: handles.collection },
95
+ },
96
+ checked_collections: { users: true },
97
+ db: { collections: new Set(["users"]) },
98
+ };
99
+ const opened = [];
100
+ const time = clock || { now: 1000 };
101
+ const terminated = [];
102
+ const logs = [];
103
+ const log = {
104
+ log: (m) => logs.push(m),
105
+ warn: (m) => logs.push(m),
106
+ error: (m) => logs.push(m),
107
+ };
108
+ const monitor = createHealthMonitor({
109
+ settings: {
110
+ healthIntervalMs: 0,
111
+ probeTimeoutMs: 20,
112
+ probeFailureThreshold: 2,
113
+ failureThreshold: 3,
114
+ failureWindowMs: 1000,
115
+ reconnectTimeoutMs: 50,
116
+ reconnectBackoffMinMs: 100,
117
+ reconnectBackoffMaxMs: 1000,
118
+ retireGraceMs: 5,
119
+ exitAfterMs: 10000,
120
+ ...settings,
121
+ },
122
+ connect:
123
+ connect ||
124
+ (async () => {
125
+ const cluster = fakeCluster(`fresh-${opened.length + 1}`);
126
+ opened.push(cluster);
127
+ return cluster;
128
+ }),
129
+ now: () => time.now,
130
+ random: () => 0.5,
131
+ log,
132
+ getGlobals: () => globals,
133
+ terminate: () => terminated.push(time.now),
134
+ });
135
+ monitor.register("default", config);
136
+ return { monitor, initial, globals, opened, time, terminated, logs };
137
+ }
138
+
139
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
140
+
141
+ test("only connectivity errors count", () => {
142
+ assert.equal(isConnectivityError(timeoutError()), true);
143
+ assert.equal(isConnectivityError(new couchbase.AmbiguousTimeoutError()), true);
144
+ assert.equal(isConnectivityError(new couchbase.RequestCanceledError()), true);
145
+ assert.equal(isConnectivityError(new couchbase.ServiceNotAvailableError()), true);
146
+ assert.equal(isConnectivityError(new couchbase.DocumentNotFoundError()), false);
147
+ assert.equal(isConnectivityError(new Error("syntax error")), false);
148
+ assert.equal(isConnectivityError(undefined), false);
149
+ });
150
+
151
+ test("connect options carry only the timeouts that are set", () => {
152
+ assert.deepEqual(buildConnectOptions(config), { username: "u", password: "p" });
153
+ assert.deepEqual(
154
+ buildConnectOptions({
155
+ ...config,
156
+ configProfile: "wanDevelopment",
157
+ timeouts: { queryTimeout: "30000", kvTimeout: undefined, connectTimeout: "" },
158
+ }),
159
+ {
160
+ username: "u",
161
+ password: "p",
162
+ configProfile: "wanDevelopment",
163
+ timeouts: { queryTimeout: 30000 },
164
+ },
165
+ );
166
+ });
167
+
168
+ test("a healthy probe leaves the connection alone", async () => {
169
+ const { monitor, opened, globals, initial } = setup();
170
+ assert.equal(await monitor.check("default"), true);
171
+ assert.equal(opened.length, 0);
172
+ assert.equal(globals.database.clusters.default, initial);
173
+ assert.equal(monitor.status().default.healthy, true);
174
+ });
175
+
176
+ test("the probe does not depend on the boot-time collection existing", async () => {
177
+ // The fake collection's KV operations always time out; ping still succeeds.
178
+ const { monitor, opened } = setup({ settings: { probeFailureThreshold: 1 } });
179
+ assert.equal(await monitor.check("default"), true);
180
+ assert.equal(opened.length, 0);
181
+ assert.equal(monitor.status().default.healthy, true);
182
+ });
183
+
184
+ test("one answering KV node is enough, none is unhealthy", async () => {
185
+ const { monitor, initial, opened } = setup({
186
+ settings: { probeFailureThreshold: 1 },
187
+ });
188
+ const { Ok, Timeout, Error: Failed } = couchbase.PingState;
189
+ initial.kvStates = [Timeout, Ok, Failed];
190
+ assert.equal(await monitor.check("default"), true);
191
+ assert.equal(opened.length, 0);
192
+
193
+ initial.kvStates = [Timeout, Failed];
194
+ await monitor.check("default");
195
+ assert.equal(opened.length, 1);
196
+ });
197
+
198
+ test("probe failures say which check failed", async () => {
199
+ const { monitor, initial } = setup({
200
+ settings: { probeFailureThreshold: 1 },
201
+ connect: async () => fakeCluster("bad", { healthy: false }),
202
+ });
203
+ initial.kvStates = [];
204
+ await monitor.check("default");
205
+ assert.match(monitor.status().default.lastError, /waitUntilReady failed/);
206
+
207
+ const query = setup({ settings: { probeFailureThreshold: 1 } });
208
+ query.initial.query = () => Promise.reject(timeoutError());
209
+ await query.monitor.check("default");
210
+ assert.match(query.logs.join("\n"), /unhealthy: Query probe failed/);
211
+
212
+ const kv = setup({ settings: { probeFailureThreshold: 1 } });
213
+ kv.initial.kvStates = [];
214
+ await kv.monitor.check("default");
215
+ assert.match(kv.logs.join("\n"), /unhealthy: KV ping failed: no endpoint responded/);
216
+ });
217
+
218
+ test("non-connectivity errors never trigger a probe", async () => {
219
+ const { monitor, initial } = setup();
220
+ for (let i = 0; i < 10; i++) {
221
+ monitor.recordOutcome("default", new couchbase.DocumentNotFoundError());
222
+ }
223
+ await sleep(5);
224
+ assert.equal(initial.queries, 0);
225
+ });
226
+
227
+ test("one failed probe is not enough, two trigger a reconnect", async () => {
228
+ const { monitor, initial, opened } = setup();
229
+ initial.healthy = false;
230
+ await monitor.check("default");
231
+ assert.equal(opened.length, 0);
232
+ assert.equal(monitor.status().default.healthy, true);
233
+ await monitor.check("default");
234
+ assert.equal(opened.length, 1);
235
+ assert.equal(monitor.status().default.healthy, true);
236
+ assert.equal(monitor.status().default.generation, 2);
237
+ });
238
+
239
+ test("a burst of operation timeouts probes and reconnects at once", async () => {
240
+ const { monitor, initial, opened, globals } = setup();
241
+ initial.healthy = false;
242
+ for (let i = 0; i < 3; i++) monitor.recordOutcome("default", timeoutError());
243
+ await sleep(10);
244
+ assert.equal(opened.length, 1);
245
+ assert.equal(globals.database.clusters.default, opened[0]);
246
+ });
247
+
248
+ test("timeouts outside the window do not add up", async () => {
249
+ const { monitor, initial, opened, time } = setup();
250
+ initial.healthy = false;
251
+ monitor.recordOutcome("default", timeoutError());
252
+ monitor.recordOutcome("default", timeoutError());
253
+ time.now += 2000;
254
+ monitor.recordOutcome("default", timeoutError());
255
+ await sleep(10);
256
+ assert.equal(initial.queries, 0);
257
+ assert.equal(opened.length, 0);
258
+ });
259
+
260
+ test("reconnect swaps every global handle and resets collection caches", async () => {
261
+ const { monitor, initial, opened, globals } = setup();
262
+ initial.healthy = false;
263
+ await monitor.check("default");
264
+ await monitor.check("default");
265
+ const fresh = opened[0];
266
+ assert.equal(globals.database.clusters.default, fresh);
267
+ assert.equal(globals.database.buckets.default.cluster, fresh);
268
+ assert.equal(globals.database.scopes.default.bucket.cluster, fresh);
269
+ assert.equal(globals.database.connections.default.cluster, fresh);
270
+ assert.deepEqual(globals.checked_collections, {});
271
+ assert.equal(globals.db.collections.size, 0);
272
+ });
273
+
274
+ test("the replaced cluster is closed after the grace period", async () => {
275
+ const { monitor, initial } = setup();
276
+ initial.healthy = false;
277
+ await monitor.check("default");
278
+ await monitor.check("default");
279
+ assert.equal(initial.closed, 0);
280
+ await sleep(20);
281
+ assert.equal(initial.closed, 1);
282
+ });
283
+
284
+ test("concurrent triggers share a single reconnect", async () => {
285
+ let release;
286
+ const opened = [];
287
+ const { monitor, initial } = setup({
288
+ settings: { probeFailureThreshold: 1, reconnectTimeoutMs: 1000 },
289
+ connect: () =>
290
+ new Promise((resolve) => {
291
+ release = () => {
292
+ const cluster = fakeCluster("fresh");
293
+ opened.push(cluster);
294
+ resolve(cluster);
295
+ };
296
+ }),
297
+ });
298
+ initial.healthy = false;
299
+ const first = monitor.check("default");
300
+ await sleep(5);
301
+ const others = [monitor.check("default"), monitor.reconnect("default")];
302
+ for (let i = 0; i < 5; i++) monitor.recordOutcome("default", timeoutError());
303
+ release();
304
+ await Promise.all([first, ...others]);
305
+ assert.equal(opened.length, 1);
306
+ assert.equal(monitor.status().default.generation, 2);
307
+ });
308
+
309
+ test("a probe that hangs is cut off by the hard deadline", async () => {
310
+ const { monitor, initial, opened } = setup({
311
+ settings: { probeFailureThreshold: 1 },
312
+ });
313
+ initial.hang = true;
314
+ // The deadline timer is unref'd; keep the loop alive while the probe hangs.
315
+ const keepAlive = setInterval(() => {}, 1000);
316
+ try {
317
+ const started = Date.now();
318
+ await monitor.check("default");
319
+ assert.ok(Date.now() - started < 1500);
320
+ assert.equal(opened.length, 1);
321
+ } finally {
322
+ clearInterval(keepAlive);
323
+ }
324
+ });
325
+
326
+ test("a failed reconnect backs off and keeps the old cluster", async () => {
327
+ const opened = [];
328
+ const { monitor, initial, globals, time } = setup({
329
+ settings: { probeFailureThreshold: 1 },
330
+ connect: async () => {
331
+ const cluster = fakeCluster(`bad-${opened.length + 1}`, { healthy: false });
332
+ opened.push(cluster);
333
+ return cluster;
334
+ },
335
+ });
336
+ initial.healthy = false;
337
+ await monitor.check("default");
338
+ assert.equal(opened.length, 1);
339
+ assert.equal(opened[0].closed, 1, "unverified cluster is closed");
340
+ assert.equal(globals.database.clusters.default, initial);
341
+ assert.equal(monitor.status().default.reconnectAttempts, 1);
342
+
343
+ // Inside the backoff window: probe only, no new connect.
344
+ await monitor.check("default");
345
+ assert.equal(opened.length, 1);
346
+
347
+ time.now += 150;
348
+ await monitor.check("default");
349
+ assert.equal(opened.length, 2);
350
+ assert.equal(monitor.status().default.reconnectAttempts, 2);
351
+ });
352
+
353
+ test("a connection that recovers on its own is marked healthy again", async () => {
354
+ const { monitor, initial, time } = setup({
355
+ settings: { probeFailureThreshold: 1 },
356
+ connect: async () => fakeCluster("bad", { healthy: false }),
357
+ });
358
+ initial.healthy = false;
359
+ await monitor.check("default");
360
+ assert.equal(monitor.status().default.healthy, false);
361
+ initial.healthy = true;
362
+ time.now += 5000;
363
+ await monitor.check("default");
364
+ const status = monitor.status().default;
365
+ assert.equal(status.healthy, true);
366
+ assert.equal(status.reconnectAttempts, 0);
367
+ assert.equal(status.unhealthySince, null);
368
+ });
369
+
370
+ test("staying unhealthy past the limit terminates the process once", async () => {
371
+ const { monitor, initial, time, terminated } = setup({
372
+ settings: { probeFailureThreshold: 1, exitAfterMs: 1000 },
373
+ connect: async () => fakeCluster("bad", { healthy: false }),
374
+ });
375
+ initial.healthy = false;
376
+ await monitor.check("default");
377
+ assert.equal(terminated.length, 0);
378
+ time.now += 1500;
379
+ await monitor.check("default");
380
+ time.now += 1500;
381
+ await monitor.check("default");
382
+ assert.equal(terminated.length, 1);
383
+ });
384
+
385
+ test("exitAfterMs of 0 never terminates", async () => {
386
+ const { monitor, initial, time, terminated } = setup({
387
+ settings: { probeFailureThreshold: 1, exitAfterMs: 0 },
388
+ connect: async () => fakeCluster("bad", { healthy: false }),
389
+ });
390
+ initial.healthy = false;
391
+ for (let i = 0; i < 5; i++) {
392
+ time.now += 100000;
393
+ await monitor.check("default");
394
+ }
395
+ assert.equal(terminated.length, 0);
396
+ });
397
+
398
+ test("stop() halts reconnects and closes retired clusters", async () => {
399
+ const { monitor, initial, opened } = setup({
400
+ settings: { probeFailureThreshold: 1, retireGraceMs: 60000 },
401
+ });
402
+ initial.healthy = false;
403
+ await monitor.check("default");
404
+ assert.equal(opened.length, 1);
405
+ await monitor.stop();
406
+ assert.equal(initial.closed, 1);
407
+ opened[0].healthy = false;
408
+ await monitor.check("default");
409
+ await monitor.reconnect("default");
410
+ assert.equal(opened.length, 1);
411
+ });
412
+
413
+ test("the interval probe runs when enabled", async () => {
414
+ const { monitor, initial, opened } = setup({
415
+ settings: { healthIntervalMs: 5, probeFailureThreshold: 1 },
416
+ });
417
+ initial.healthy = false;
418
+ await sleep(40);
419
+ assert.ok(opened.length >= 1);
420
+ await monitor.stop();
421
+ });
422
+
423
+ test("a Database instance follows the swapped globals", async () => {
424
+ const path = require.resolve("./index");
425
+ delete require.cache[path];
426
+ const previousSpice = global.spice;
427
+ const { monitor, initial, opened, globals } = setup({
428
+ settings: { probeFailureThreshold: 1 },
429
+ });
430
+ global.spice = {
431
+ ...globals,
432
+ config: { database: { connections: { default: { ...config } } } },
433
+ };
434
+ // Point the shared module-level monitor at this test's monitor.
435
+ const key = Symbol.for("spice-couchbase.health-monitor");
436
+ const previousMonitor = globalThis[key];
437
+ globalThis[key] = monitor;
438
+ try {
439
+ // Keep a reference to the same globals object the monitor mutates.
440
+ global.spice.database = globals.database;
441
+ // The constructor reads the bucket name off the boot-time collection.
442
+ globals.database.connections.default._scope = { _bucket: { _name: "saturn" } };
443
+ const Database = require("./index");
444
+ const db = new Database("default");
445
+ assert.equal(db.cluster, initial);
446
+ const oldCollection = db.collection;
447
+ assert.equal(oldCollection.cluster, initial);
448
+
449
+ initial.healthy = false;
450
+ await monitor.check("default");
451
+ assert.equal(opened.length, 1);
452
+ assert.equal(db.cluster, opened[0]);
453
+ assert.equal(db.collection.cluster, opened[0]);
454
+ assert.notEqual(db.collection, oldCollection);
455
+ assert.deepEqual(Database.healthStatus().default.generation, 2);
456
+ } finally {
457
+ globalThis[key] = previousMonitor;
458
+ global.spice = previousSpice;
459
+ delete require.cache[path];
460
+ }
461
+ });