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/build/connectionHealth.js +573 -0
- package/build/connectionHealth.test.js +659 -0
- package/build/index.js +219 -41
- package/package.json +3 -2
- package/src/connectionHealth.js +529 -0
- package/src/connectionHealth.test.js +412 -0
- package/src/index.js +198 -55
|
@@ -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
|
+
};
|