node-firebird 2.13.0 → 2.14.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.
package/README.md CHANGED
@@ -355,6 +355,48 @@ console.log({
355
355
  is attached, so existing applications keep working unchanged.
356
356
  - Metrics are plain getters — reading them has no side effects.
357
357
 
358
+ #### Multi-host pooling (PoolCluster)
359
+
360
+ For primary/replica topologies (Firebird 4+ logical replication) or plain
361
+ redundancy, `Firebird.poolCluster` manages one pool per named node with
362
+ pattern-based selection and automatic failover — the mysql2 `PoolCluster`
363
+ model:
364
+
365
+ ```js
366
+ const cluster = Firebird.poolCluster({
367
+ defaults: { user: 'SYSDBA', password: 'masterkey', database: '/data/app.fdb', connectTimeout: 5000 },
368
+ nodes: {
369
+ primary: { host: 'db-primary' },
370
+ replica1: { host: 'db-replica-1' },
371
+ replica2: { host: 'db-replica-2' },
372
+ },
373
+ max: 4, // per-node pool size
374
+ selector: 'rr', // 'rr' | 'random' | 'order' (first online match)
375
+ removeNodeErrorCount: 5, // offline a node after N consecutive connection failures
376
+ restoreNodeTimeout: 30000, // put it back into rotation after 30s (0 = manual restore())
377
+ });
378
+
379
+ // writes go to the primary, reads round-robin across replicas
380
+ await cluster.withConnection('primary', db => db.queryAsync('UPDATE ...'));
381
+ const rows = await cluster.withConnection('replica*', db => db.queryAsync('SELECT ...'));
382
+
383
+ // or bind a pattern once (mysql2's cluster.of)
384
+ const replicas = cluster.of('replica*', 'rr');
385
+ const db = await replicas.getAsync(); // release with db.detach(), as with a plain pool
386
+ ```
387
+
388
+ A failed connection attempt marks the node and **fails over** to the next
389
+ matching online node; only when every candidate has failed does the call
390
+ error (set `connectTimeout` in `defaults` so dead-but-routable hosts fail
391
+ fast). Nodes taken offline emit `'offline'`, restorations emit
392
+ `'online'`, and `cluster.status()` returns per-node
393
+ `{ online, errorCount, totalCount, idleCount, activeCount, waitingCount }`
394
+ for monitoring. Each node's pool is a regular
395
+ [connection pool](#pool-events-and-metrics) — health checks, idle
396
+ reaping, `maxUses`/`maxLifetimeMillis` recycling and keepalive all apply
397
+ per node. `add(name, overrides)` / `remove(name)` manage nodes at
398
+ runtime; `destroy()` closes everything.
399
+
358
400
  #### Advanced Pooling Features
359
401
 
360
402
  The pool implementation includes several safeguards for reliability:
package/lib/callback.d.ts CHANGED
@@ -28,6 +28,13 @@ export declare function toError(err: any): Error;
28
28
  * Run a callback-style operation and return a Promise for its result.
29
29
  * Usage: fromCallback<Database>(cb => attach(options, cb))
30
30
  */
31
+ /**
32
+ * Run `work` with a pooled connection and always return it to its pool
33
+ * (detach) when the promise settles — a detach hiccup never masks the
34
+ * outcome of `work`. Shared by Pool.withConnection and
35
+ * PoolCluster.withConnection so the release semantics cannot drift.
36
+ */
37
+ export declare function withPooledConnection<T>(getAsync: () => Promise<any>, work: (db: any) => Promise<T> | T): Promise<T>;
31
38
  export declare function fromCallback<T = any>(executor: (cb: Callback<T>) => void): Promise<T>;
32
39
  export declare function doError(obj: any, callback?: (...args: any[]) => void): void;
33
40
  export declare function doCallback<T>(obj: T, callback?: Callback<T>): void;
package/lib/callback.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.toError = toError;
4
+ exports.withPooledConnection = withPooledConnection;
4
5
  exports.fromCallback = fromCallback;
5
6
  exports.doError = doError;
6
7
  exports.doCallback = doCallback;
@@ -23,6 +24,21 @@ function toError(err) {
23
24
  * Run a callback-style operation and return a Promise for its result.
24
25
  * Usage: fromCallback<Database>(cb => attach(options, cb))
25
26
  */
27
+ /**
28
+ * Run `work` with a pooled connection and always return it to its pool
29
+ * (detach) when the promise settles — a detach hiccup never masks the
30
+ * outcome of `work`. Shared by Pool.withConnection and
31
+ * PoolCluster.withConnection so the release semantics cannot drift.
32
+ */
33
+ async function withPooledConnection(getAsync, work) {
34
+ const db = await getAsync();
35
+ try {
36
+ return await work(db);
37
+ }
38
+ finally {
39
+ await new Promise(function (resolve) { db.detach(function () { resolve(); }); });
40
+ }
41
+ }
26
42
  function fromCallback(executor) {
27
43
  return new Promise(function (resolve, reject) {
28
44
  executor(function (err, result) {
package/lib/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import Connection from './wire/connection';
2
+ import PoolCluster from './pool-cluster';
3
+ import type { PoolClusterOptions } from './pool-cluster';
2
4
  import { escape as escapeValue } from './utils';
3
5
  import { parseConnectionUri, parseConnectionString } from './uri';
4
6
  import type { Options, SvcMgrOptions, DatabaseCallback, ServiceManagerCallback, SimpleCallback, ConnectionPool, Database, ServiceManager } from './types';
@@ -41,6 +43,14 @@ export declare function drop(options: Options | string, callback: SimpleCallback
41
43
  export declare function create(options: Options | string, callback: DatabaseCallback): void;
42
44
  export declare function attachOrCreate(options: Options | string, callback: DatabaseCallback): void;
43
45
  export declare function pool(max: number, options: Options | string): ConnectionPool;
46
+ /**
47
+ * Multi-host pooling (primaries/replicas, failover): named nodes, each
48
+ * backed by a regular pool, selected by glob pattern + 'rr'/'random'/
49
+ * 'order' selector, with connection-failure failover and error-based
50
+ * node offlining. See README § Multi-host pooling.
51
+ */
52
+ export declare function poolCluster(options?: PoolClusterOptions): PoolCluster;
53
+ export type { PoolClusterOptions, ClusterSelector } from './pool-cluster';
44
54
  export { parseConnectionUri, parseConnectionString };
45
55
  export { parseNamedPlaceholders } from './named-params';
46
56
  export declare function attachAsync(options: SvcMgrOptions): Promise<ServiceManager>;
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ exports.drop = drop;
23
23
  exports.create = create;
24
24
  exports.attachOrCreate = attachOrCreate;
25
25
  exports.pool = pool;
26
+ exports.poolCluster = poolCluster;
26
27
  exports.attachAsync = attachAsync;
27
28
  exports.createAsync = createAsync;
28
29
  exports.attachOrCreateAsync = attachOrCreateAsync;
@@ -31,6 +32,7 @@ const const_1 = __importDefault(require("./wire/const"));
31
32
  const callback_1 = require("./callback");
32
33
  const connection_1 = __importDefault(require("./wire/connection"));
33
34
  const pool_1 = __importDefault(require("./pool"));
35
+ const pool_cluster_1 = __importDefault(require("./pool-cluster"));
34
36
  const utils_1 = require("./utils");
35
37
  const uri_1 = require("./uri");
36
38
  Object.defineProperty(exports, "parseConnectionUri", { enumerable: true, get: function () { return uri_1.parseConnectionUri; } });
@@ -178,6 +180,17 @@ function attachOrCreate(options, callback) {
178
180
  function pool(max, options) {
179
181
  return new pool_1.default(attach, max, Object.assign({}, (0, uri_1.normalizeOptions)(options), { isPool: true }));
180
182
  }
183
+ /**
184
+ * Multi-host pooling (primaries/replicas, failover): named nodes, each
185
+ * backed by a regular pool, selected by glob pattern + 'rr'/'random'/
186
+ * 'order' selector, with connection-failure failover and error-based
187
+ * node offlining. See README § Multi-host pooling.
188
+ */
189
+ function poolCluster(options) {
190
+ const normalized = { ...(options || {}) };
191
+ normalized.defaults = (0, uri_1.normalizeOptions)(normalized.defaults || {});
192
+ return new pool_cluster_1.default(attach, normalized);
193
+ }
181
194
  var named_params_1 = require("./named-params");
182
195
  Object.defineProperty(exports, "parseNamedPlaceholders", { enumerable: true, get: function () { return named_params_1.parseNamedPlaceholders; } });
183
196
  function attachAsync(options) {
@@ -0,0 +1,89 @@
1
+ /***************************************
2
+ *
3
+ * PoolCluster — multi-host pooling (primaries/replicas, failover)
4
+ *
5
+ * The mysql2 PoolCluster model on top of this driver's Pool: named
6
+ * nodes, each backed by a regular connection pool (health checks,
7
+ * recycling and metrics included), selected by glob pattern +
8
+ * selector. Consecutive connection failures take a node offline
9
+ * (with optional timed restoration), and get() fails over to the
10
+ * next matching online node.
11
+ *
12
+ ***************************************/
13
+ import Events from 'events';
14
+ import type { Callback } from './callback';
15
+ type AttachFn = (options: any, callback: Callback) => void;
16
+ export type ClusterSelector = 'rr' | 'random' | 'order';
17
+ export interface PoolClusterOptions {
18
+ /** Options shared by every node (user, password, database, …). */
19
+ defaults?: any;
20
+ /** name → per-node option overrides (host, port, …). */
21
+ nodes?: Record<string, any>;
22
+ /** Per-node pool size (default 4). */
23
+ max?: number;
24
+ /** Default selector for get()/of() (default 'rr'). */
25
+ selector?: ClusterSelector;
26
+ /**
27
+ * Consecutive connection failures after which a node goes offline
28
+ * (default 5; 0 disables offlining).
29
+ */
30
+ removeNodeErrorCount?: number;
31
+ /**
32
+ * Milliseconds after which an offline node is restored and probed
33
+ * again (default 30000; 0 = stay offline until restore()/remove()).
34
+ */
35
+ restoreNodeTimeout?: number;
36
+ }
37
+ /**
38
+ * Events: 'online' (name) — node restored; 'offline' (name) — node taken
39
+ * out of rotation after too many connection failures; 'remove' (name) —
40
+ * node removed via remove().
41
+ */
42
+ declare class PoolCluster extends Events.EventEmitter {
43
+ private attach;
44
+ private nodes;
45
+ private rrIndex;
46
+ private max;
47
+ private defaults;
48
+ private selector;
49
+ private removeNodeErrorCount;
50
+ private restoreNodeTimeout;
51
+ private _destroyed;
52
+ constructor(attach: AttachFn, options?: PoolClusterOptions);
53
+ /** Register a node; its pool is created lazily-safe right away. */
54
+ add(name: string, overrides?: any): this;
55
+ /** Remove a node for good, destroying its pool. */
56
+ remove(name: string, callback?: (err?: any) => void): void;
57
+ /** Bring an offline node back into rotation immediately. */
58
+ restore(name: string): void;
59
+ /** name → { online, errorCount, pool metrics } for every node. */
60
+ status(): Record<string, any>;
61
+ private matching;
62
+ private pick;
63
+ private noteFailure;
64
+ /**
65
+ * Acquire a connection from a node matching `pattern` (default '*').
66
+ * Connection failures mark the node and FAIL OVER to the next
67
+ * matching online node; only when every candidate has failed does the
68
+ * callback receive the last error. Release connections with
69
+ * db.detach(), exactly like a plain pool.
70
+ */
71
+ get(pattern: string | Callback, selector?: ClusterSelector | Callback, callback?: Callback): void;
72
+ getAsync(pattern?: string, selector?: ClusterSelector): Promise<any>;
73
+ /**
74
+ * A pool-like facade bound to a pattern (mysql2's cluster.of):
75
+ * { get, getAsync, withConnection } routed through the cluster's
76
+ * selection and failover.
77
+ */
78
+ of(pattern: string, selector?: ClusterSelector): {
79
+ get(callback: Callback): void;
80
+ getAsync(): Promise<any>;
81
+ withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T>;
82
+ };
83
+ /** Run `work` with a connection from a matching node, always released. */
84
+ withConnection<T>(pattern: string, work: (db: any) => Promise<T> | T, selector?: ClusterSelector): Promise<T>;
85
+ /** Destroy every node's pool. */
86
+ destroy(callback?: (err?: any) => void): void;
87
+ destroyAsync(): Promise<void>;
88
+ }
89
+ export default PoolCluster;
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ /***************************************
3
+ *
4
+ * PoolCluster — multi-host pooling (primaries/replicas, failover)
5
+ *
6
+ * The mysql2 PoolCluster model on top of this driver's Pool: named
7
+ * nodes, each backed by a regular connection pool (health checks,
8
+ * recycling and metrics included), selected by glob pattern +
9
+ * selector. Consecutive connection failures take a node offline
10
+ * (with optional timed restoration), and get() fails over to the
11
+ * next matching online node.
12
+ *
13
+ ***************************************/
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ const events_1 = __importDefault(require("events"));
19
+ const callback_1 = require("./callback");
20
+ const uri_1 = require("./uri");
21
+ const pool_1 = __importDefault(require("./pool"));
22
+ function patternToRegExp(pattern) {
23
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
24
+ return new RegExp('^' + escaped + '$');
25
+ }
26
+ /**
27
+ * Events: 'online' (name) — node restored; 'offline' (name) — node taken
28
+ * out of rotation after too many connection failures; 'remove' (name) —
29
+ * node removed via remove().
30
+ */
31
+ class PoolCluster extends events_1.default.EventEmitter {
32
+ constructor(attach, options) {
33
+ super();
34
+ this.nodes = new Map();
35
+ this.rrIndex = new Map();
36
+ this._destroyed = false;
37
+ options = options || {};
38
+ this.attach = attach;
39
+ this.defaults = options.defaults || {};
40
+ this.max = options.max && options.max > 0 ? options.max : 4;
41
+ this.selector = options.selector || 'rr';
42
+ this.removeNodeErrorCount = options.removeNodeErrorCount !== undefined ? options.removeNodeErrorCount : 5;
43
+ this.restoreNodeTimeout = options.restoreNodeTimeout !== undefined ? options.restoreNodeTimeout : 30000;
44
+ for (const [name, overrides] of Object.entries(options.nodes || {})) {
45
+ this.add(name, overrides);
46
+ }
47
+ }
48
+ /** Register a node; its pool is created lazily-safe right away. */
49
+ add(name, overrides) {
50
+ if (this._destroyed) {
51
+ throw new Error('PoolCluster has been destroyed');
52
+ }
53
+ if (this.nodes.has(name)) {
54
+ throw new Error('PoolCluster node already exists: ' + name);
55
+ }
56
+ // a connection-string override must be parsed, not object-spread
57
+ // into character-indexed garbage
58
+ if (typeof overrides === 'string') {
59
+ overrides = (0, uri_1.parseConnectionString)(overrides);
60
+ }
61
+ const nodeOptions = { ...this.defaults, ...(overrides || {}) };
62
+ this.nodes.set(name, {
63
+ name,
64
+ options: nodeOptions,
65
+ pool: new pool_1.default(this.attach, nodeOptions.max || this.max, { ...nodeOptions, isPool: true }),
66
+ online: true,
67
+ errorCount: 0,
68
+ restoreTimer: null,
69
+ });
70
+ return this;
71
+ }
72
+ /** Remove a node for good, destroying its pool. */
73
+ remove(name, callback) {
74
+ const node = this.nodes.get(name);
75
+ if (!node) {
76
+ if (callback)
77
+ callback();
78
+ return;
79
+ }
80
+ this.nodes.delete(name);
81
+ if (node.restoreTimer) {
82
+ clearTimeout(node.restoreTimer);
83
+ }
84
+ this.emit('remove', name);
85
+ node.pool.destroy(callback);
86
+ }
87
+ /** Bring an offline node back into rotation immediately. */
88
+ restore(name) {
89
+ const node = this.nodes.get(name);
90
+ if (!node || node.online) {
91
+ return;
92
+ }
93
+ if (node.restoreTimer) {
94
+ clearTimeout(node.restoreTimer);
95
+ node.restoreTimer = null;
96
+ }
97
+ node.online = true;
98
+ node.errorCount = 0;
99
+ this.emit('online', name);
100
+ }
101
+ /** name → { online, errorCount, pool metrics } for every node. */
102
+ status() {
103
+ const out = {};
104
+ for (const node of this.nodes.values()) {
105
+ out[node.name] = {
106
+ online: node.online,
107
+ errorCount: node.errorCount,
108
+ totalCount: node.pool.totalCount,
109
+ idleCount: node.pool.idleCount,
110
+ activeCount: node.pool.activeCount,
111
+ waitingCount: node.pool.waitingCount,
112
+ };
113
+ }
114
+ return out;
115
+ }
116
+ matching(pattern) {
117
+ const re = patternToRegExp(pattern);
118
+ const out = [];
119
+ for (const node of this.nodes.values()) {
120
+ if (re.test(node.name)) {
121
+ out.push(node);
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ pick(pattern, selector, exclude) {
127
+ const candidates = this.matching(pattern).filter((n) => n.online && !exclude.has(n.name));
128
+ if (!candidates.length) {
129
+ return null;
130
+ }
131
+ if (selector === 'random') {
132
+ return candidates[Math.floor(Math.random() * candidates.length)];
133
+ }
134
+ if (selector === 'order') {
135
+ return candidates[0];
136
+ }
137
+ // round-robin per pattern; only the FIRST pick of a get() advances
138
+ // the counter — failover re-picks reuse it, or a run of failovers
139
+ // would skew the distribution toward nodes after the failing ones
140
+ const index = this.rrIndex.get(pattern) || 0;
141
+ if (exclude.size === 0) {
142
+ this.rrIndex.set(pattern, index + 1);
143
+ }
144
+ return candidates[index % candidates.length];
145
+ }
146
+ noteFailure(node) {
147
+ // a node removed while a get was in flight must not accumulate
148
+ // counters, emit 'offline', or arm a restore timer nobody clears
149
+ if (!this.nodes.has(node.name)) {
150
+ return;
151
+ }
152
+ node.errorCount++;
153
+ if (!this.removeNodeErrorCount || node.errorCount < this.removeNodeErrorCount || !node.online) {
154
+ return;
155
+ }
156
+ node.online = false;
157
+ this.emit('offline', node.name);
158
+ if (this.restoreNodeTimeout > 0) {
159
+ node.restoreTimer = setTimeout(() => {
160
+ node.restoreTimer = null;
161
+ this.restore(node.name);
162
+ }, this.restoreNodeTimeout);
163
+ if (node.restoreTimer.unref) {
164
+ node.restoreTimer.unref();
165
+ }
166
+ }
167
+ }
168
+ /**
169
+ * Acquire a connection from a node matching `pattern` (default '*').
170
+ * Connection failures mark the node and FAIL OVER to the next
171
+ * matching online node; only when every candidate has failed does the
172
+ * callback receive the last error. Release connections with
173
+ * db.detach(), exactly like a plain pool.
174
+ */
175
+ get(pattern, selector, callback) {
176
+ if (typeof pattern === 'function') {
177
+ callback = pattern;
178
+ pattern = '*';
179
+ }
180
+ if (typeof selector === 'function') {
181
+ callback = selector;
182
+ selector = undefined;
183
+ }
184
+ if (this._destroyed) {
185
+ callback(new Error('PoolCluster has been destroyed'), null);
186
+ return;
187
+ }
188
+ const sel = selector || this.selector;
189
+ const tried = new Set();
190
+ const self = this;
191
+ const attempt = (lastError) => {
192
+ const node = self.pick(pattern, sel, tried);
193
+ if (!node) {
194
+ callback(lastError || new Error('PoolCluster: no online node matches pattern "' + pattern + '"'), null);
195
+ return;
196
+ }
197
+ tried.add(node.name);
198
+ node.pool.get((err, db) => {
199
+ if (err) {
200
+ self.noteFailure(node);
201
+ attempt(err);
202
+ return;
203
+ }
204
+ node.errorCount = 0;
205
+ callback(null, db);
206
+ });
207
+ };
208
+ attempt();
209
+ }
210
+ getAsync(pattern, selector) {
211
+ const self = this;
212
+ return (0, callback_1.fromCallback)((cb) => self.get(pattern || '*', selector, cb));
213
+ }
214
+ /**
215
+ * A pool-like facade bound to a pattern (mysql2's cluster.of):
216
+ * { get, getAsync, withConnection } routed through the cluster's
217
+ * selection and failover.
218
+ */
219
+ of(pattern, selector) {
220
+ const self = this;
221
+ return {
222
+ get(callback) {
223
+ self.get(pattern, selector, callback);
224
+ },
225
+ getAsync() {
226
+ return self.getAsync(pattern, selector);
227
+ },
228
+ withConnection(work) {
229
+ return self.withConnection(pattern, work, selector);
230
+ },
231
+ };
232
+ }
233
+ /** Run `work` with a connection from a matching node, always released. */
234
+ withConnection(pattern, work, selector) {
235
+ return (0, callback_1.withPooledConnection)(() => this.getAsync(pattern, selector), work);
236
+ }
237
+ /** Destroy every node's pool. */
238
+ destroy(callback) {
239
+ this._destroyed = true;
240
+ const nodes = [...this.nodes.values()];
241
+ this.nodes.clear();
242
+ let remaining = nodes.length;
243
+ if (!remaining) {
244
+ if (callback)
245
+ callback();
246
+ return;
247
+ }
248
+ let firstError = null;
249
+ for (const node of nodes) {
250
+ if (node.restoreTimer) {
251
+ clearTimeout(node.restoreTimer);
252
+ }
253
+ node.pool.destroy((err) => {
254
+ if (err && !firstError)
255
+ firstError = err;
256
+ if (--remaining === 0 && callback)
257
+ callback(firstError);
258
+ });
259
+ }
260
+ }
261
+ destroyAsync() {
262
+ const self = this;
263
+ return (0, callback_1.fromCallback)((cb) => self.destroy(cb));
264
+ }
265
+ }
266
+ exports.default = PoolCluster;
package/lib/pool.js CHANGED
@@ -352,16 +352,8 @@ class Pool extends events_1.default.EventEmitter {
352
352
  * Run `work` with a connection from the pool, returning it to the pool
353
353
  * (detach) when the returned promise settles — success or failure.
354
354
  */
355
- async withConnection(work) {
356
- const db = await this.getAsync();
357
- try {
358
- return await work(db);
359
- }
360
- finally {
361
- // A pooled detach only returns the connection to the pool; do not
362
- // let a detach hiccup mask the outcome of `work`.
363
- await new Promise(function (resolve) { db.detach(function () { resolve(); }); });
364
- }
355
+ withConnection(work) {
356
+ return (0, callback_1.withPooledConnection)(() => this.getAsync(), work);
365
357
  }
366
358
  }
367
359
  module.exports = Pool;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-firebird",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Pure JavaScript and Asynchronous Firebird client for Node.js.",
5
5
  "keywords": [
6
6
  "firebird",
package/src/callback.ts CHANGED
@@ -45,6 +45,21 @@ export function toError(err: any): Error {
45
45
  * Run a callback-style operation and return a Promise for its result.
46
46
  * Usage: fromCallback<Database>(cb => attach(options, cb))
47
47
  */
48
+ /**
49
+ * Run `work` with a pooled connection and always return it to its pool
50
+ * (detach) when the promise settles — a detach hiccup never masks the
51
+ * outcome of `work`. Shared by Pool.withConnection and
52
+ * PoolCluster.withConnection so the release semantics cannot drift.
53
+ */
54
+ export async function withPooledConnection<T>(getAsync: () => Promise<any>, work: (db: any) => Promise<T> | T): Promise<T> {
55
+ const db = await getAsync();
56
+ try {
57
+ return await work(db);
58
+ } finally {
59
+ await new Promise<void>(function(resolve) { db.detach(function() { resolve(); }); });
60
+ }
61
+ }
62
+
48
63
  export function fromCallback<T = any>(executor: (cb: Callback<T>) => void): Promise<T> {
49
64
  return new Promise<T>(function(resolve, reject) {
50
65
  executor(function(err?: any, result?: T) {
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ import Const from './wire/const';
2
2
  import { doError, doCallback, fromCallback, type Callback } from './callback';
3
3
  import Connection from './wire/connection';
4
4
  import Pool from './pool';
5
+ import PoolCluster from './pool-cluster';
6
+ import type { PoolClusterOptions } from './pool-cluster';
5
7
  import { escape as escapeValue } from './utils';
6
8
  import { parseConnectionUri, parseConnectionString, normalizeOptions } from './uri';
7
9
  import type {
@@ -194,6 +196,19 @@ export function pool(max: number, options: Options | string): ConnectionPool {
194
196
  return new Pool(attach, max, Object.assign({}, normalizeOptions(options), { isPool: true }));
195
197
  }
196
198
 
199
+ /**
200
+ * Multi-host pooling (primaries/replicas, failover): named nodes, each
201
+ * backed by a regular pool, selected by glob pattern + 'rr'/'random'/
202
+ * 'order' selector, with connection-failure failover and error-based
203
+ * node offlining. See README § Multi-host pooling.
204
+ */
205
+ export function poolCluster(options?: PoolClusterOptions): PoolCluster {
206
+ const normalized: PoolClusterOptions = { ...(options || {}) };
207
+ normalized.defaults = normalizeOptions(normalized.defaults || {});
208
+ return new PoolCluster(attach, normalized);
209
+ }
210
+ export type { PoolClusterOptions, ClusterSelector } from './pool-cluster';
211
+
197
212
  export { parseConnectionUri, parseConnectionString };
198
213
  export { parseNamedPlaceholders } from './named-params';
199
214
 
@@ -0,0 +1,319 @@
1
+ /***************************************
2
+ *
3
+ * PoolCluster — multi-host pooling (primaries/replicas, failover)
4
+ *
5
+ * The mysql2 PoolCluster model on top of this driver's Pool: named
6
+ * nodes, each backed by a regular connection pool (health checks,
7
+ * recycling and metrics included), selected by glob pattern +
8
+ * selector. Consecutive connection failures take a node offline
9
+ * (with optional timed restoration), and get() fails over to the
10
+ * next matching online node.
11
+ *
12
+ ***************************************/
13
+
14
+ import Events from 'events';
15
+ import { fromCallback, withPooledConnection } from './callback';
16
+ import type { Callback } from './callback';
17
+ import { parseConnectionString } from './uri';
18
+ import Pool from './pool';
19
+
20
+ type AttachFn = (options: any, callback: Callback) => void;
21
+
22
+ export type ClusterSelector = 'rr' | 'random' | 'order';
23
+
24
+ export interface PoolClusterOptions {
25
+ /** Options shared by every node (user, password, database, …). */
26
+ defaults?: any;
27
+ /** name → per-node option overrides (host, port, …). */
28
+ nodes?: Record<string, any>;
29
+ /** Per-node pool size (default 4). */
30
+ max?: number;
31
+ /** Default selector for get()/of() (default 'rr'). */
32
+ selector?: ClusterSelector;
33
+ /**
34
+ * Consecutive connection failures after which a node goes offline
35
+ * (default 5; 0 disables offlining).
36
+ */
37
+ removeNodeErrorCount?: number;
38
+ /**
39
+ * Milliseconds after which an offline node is restored and probed
40
+ * again (default 30000; 0 = stay offline until restore()/remove()).
41
+ */
42
+ restoreNodeTimeout?: number;
43
+ }
44
+
45
+ interface ClusterNode {
46
+ name: string;
47
+ options: any;
48
+ pool: Pool;
49
+ online: boolean;
50
+ errorCount: number;
51
+ restoreTimer: NodeJS.Timeout | null;
52
+ }
53
+
54
+ function patternToRegExp(pattern: string): RegExp {
55
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
56
+ return new RegExp('^' + escaped + '$');
57
+ }
58
+
59
+ /**
60
+ * Events: 'online' (name) — node restored; 'offline' (name) — node taken
61
+ * out of rotation after too many connection failures; 'remove' (name) —
62
+ * node removed via remove().
63
+ */
64
+ class PoolCluster extends Events.EventEmitter {
65
+ private attach: AttachFn;
66
+ private nodes = new Map<string, ClusterNode>();
67
+ private rrIndex = new Map<string, number>();
68
+ private max: number;
69
+ private defaults: any;
70
+ private selector: ClusterSelector;
71
+ private removeNodeErrorCount: number;
72
+ private restoreNodeTimeout: number;
73
+ private _destroyed = false;
74
+
75
+ constructor(attach: AttachFn, options?: PoolClusterOptions) {
76
+ super();
77
+ options = options || {};
78
+ this.attach = attach;
79
+ this.defaults = options.defaults || {};
80
+ this.max = options.max && options.max > 0 ? options.max : 4;
81
+ this.selector = options.selector || 'rr';
82
+ this.removeNodeErrorCount = options.removeNodeErrorCount !== undefined ? options.removeNodeErrorCount : 5;
83
+ this.restoreNodeTimeout = options.restoreNodeTimeout !== undefined ? options.restoreNodeTimeout : 30000;
84
+
85
+ for (const [name, overrides] of Object.entries(options.nodes || {})) {
86
+ this.add(name, overrides);
87
+ }
88
+ }
89
+
90
+ /** Register a node; its pool is created lazily-safe right away. */
91
+ add(name: string, overrides?: any): this {
92
+ if (this._destroyed) {
93
+ throw new Error('PoolCluster has been destroyed');
94
+ }
95
+ if (this.nodes.has(name)) {
96
+ throw new Error('PoolCluster node already exists: ' + name);
97
+ }
98
+ // a connection-string override must be parsed, not object-spread
99
+ // into character-indexed garbage
100
+ if (typeof overrides === 'string') {
101
+ overrides = parseConnectionString(overrides);
102
+ }
103
+ const nodeOptions = { ...this.defaults, ...(overrides || {}) };
104
+ this.nodes.set(name, {
105
+ name,
106
+ options: nodeOptions,
107
+ pool: new Pool(this.attach, nodeOptions.max || this.max, { ...nodeOptions, isPool: true }),
108
+ online: true,
109
+ errorCount: 0,
110
+ restoreTimer: null,
111
+ });
112
+ return this;
113
+ }
114
+
115
+ /** Remove a node for good, destroying its pool. */
116
+ remove(name: string, callback?: (err?: any) => void): void {
117
+ const node = this.nodes.get(name);
118
+ if (!node) {
119
+ if (callback) callback();
120
+ return;
121
+ }
122
+ this.nodes.delete(name);
123
+ if (node.restoreTimer) {
124
+ clearTimeout(node.restoreTimer);
125
+ }
126
+ this.emit('remove', name);
127
+ node.pool.destroy(callback);
128
+ }
129
+
130
+ /** Bring an offline node back into rotation immediately. */
131
+ restore(name: string): void {
132
+ const node = this.nodes.get(name);
133
+ if (!node || node.online) {
134
+ return;
135
+ }
136
+ if (node.restoreTimer) {
137
+ clearTimeout(node.restoreTimer);
138
+ node.restoreTimer = null;
139
+ }
140
+ node.online = true;
141
+ node.errorCount = 0;
142
+ this.emit('online', name);
143
+ }
144
+
145
+ /** name → { online, errorCount, pool metrics } for every node. */
146
+ status(): Record<string, any> {
147
+ const out: Record<string, any> = {};
148
+ for (const node of this.nodes.values()) {
149
+ out[node.name] = {
150
+ online: node.online,
151
+ errorCount: node.errorCount,
152
+ totalCount: node.pool.totalCount,
153
+ idleCount: node.pool.idleCount,
154
+ activeCount: node.pool.activeCount,
155
+ waitingCount: node.pool.waitingCount,
156
+ };
157
+ }
158
+ return out;
159
+ }
160
+
161
+ private matching(pattern: string): ClusterNode[] {
162
+ const re = patternToRegExp(pattern);
163
+ const out: ClusterNode[] = [];
164
+ for (const node of this.nodes.values()) {
165
+ if (re.test(node.name)) {
166
+ out.push(node);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ private pick(pattern: string, selector: ClusterSelector, exclude: Set<string>): ClusterNode | null {
173
+ const candidates = this.matching(pattern).filter((n) => n.online && !exclude.has(n.name));
174
+ if (!candidates.length) {
175
+ return null;
176
+ }
177
+ if (selector === 'random') {
178
+ return candidates[Math.floor(Math.random() * candidates.length)];
179
+ }
180
+ if (selector === 'order') {
181
+ return candidates[0];
182
+ }
183
+ // round-robin per pattern; only the FIRST pick of a get() advances
184
+ // the counter — failover re-picks reuse it, or a run of failovers
185
+ // would skew the distribution toward nodes after the failing ones
186
+ const index = this.rrIndex.get(pattern) || 0;
187
+ if (exclude.size === 0) {
188
+ this.rrIndex.set(pattern, index + 1);
189
+ }
190
+ return candidates[index % candidates.length];
191
+ }
192
+
193
+ private noteFailure(node: ClusterNode): void {
194
+ // a node removed while a get was in flight must not accumulate
195
+ // counters, emit 'offline', or arm a restore timer nobody clears
196
+ if (!this.nodes.has(node.name)) {
197
+ return;
198
+ }
199
+ node.errorCount++;
200
+ if (!this.removeNodeErrorCount || node.errorCount < this.removeNodeErrorCount || !node.online) {
201
+ return;
202
+ }
203
+ node.online = false;
204
+ this.emit('offline', node.name);
205
+ if (this.restoreNodeTimeout > 0) {
206
+ node.restoreTimer = setTimeout(() => {
207
+ node.restoreTimer = null;
208
+ this.restore(node.name);
209
+ }, this.restoreNodeTimeout);
210
+ if (node.restoreTimer.unref) {
211
+ node.restoreTimer.unref();
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Acquire a connection from a node matching `pattern` (default '*').
218
+ * Connection failures mark the node and FAIL OVER to the next
219
+ * matching online node; only when every candidate has failed does the
220
+ * callback receive the last error. Release connections with
221
+ * db.detach(), exactly like a plain pool.
222
+ */
223
+ get(pattern: string | Callback, selector?: ClusterSelector | Callback, callback?: Callback): void {
224
+ if (typeof pattern === 'function') {
225
+ callback = pattern;
226
+ pattern = '*';
227
+ }
228
+ if (typeof selector === 'function') {
229
+ callback = selector;
230
+ selector = undefined;
231
+ }
232
+ if (this._destroyed) {
233
+ callback!(new Error('PoolCluster has been destroyed'), null);
234
+ return;
235
+ }
236
+
237
+ const sel = (selector as ClusterSelector) || this.selector;
238
+ const tried = new Set<string>();
239
+ const self = this;
240
+
241
+ const attempt = (lastError?: any) => {
242
+ const node = self.pick(pattern as string, sel, tried);
243
+ if (!node) {
244
+ callback!(lastError || new Error('PoolCluster: no online node matches pattern "' + pattern + '"'), null);
245
+ return;
246
+ }
247
+ tried.add(node.name);
248
+ node.pool.get((err: any, db: any) => {
249
+ if (err) {
250
+ self.noteFailure(node);
251
+ attempt(err);
252
+ return;
253
+ }
254
+ node.errorCount = 0;
255
+ callback!(null, db);
256
+ });
257
+ };
258
+ attempt();
259
+ }
260
+
261
+ getAsync(pattern?: string, selector?: ClusterSelector): Promise<any> {
262
+ const self = this;
263
+ return fromCallback((cb) => self.get(pattern || '*', selector, cb));
264
+ }
265
+
266
+ /**
267
+ * A pool-like facade bound to a pattern (mysql2's cluster.of):
268
+ * { get, getAsync, withConnection } routed through the cluster's
269
+ * selection and failover.
270
+ */
271
+ of(pattern: string, selector?: ClusterSelector) {
272
+ const self = this;
273
+ return {
274
+ get(callback: Callback) {
275
+ self.get(pattern, selector, callback);
276
+ },
277
+ getAsync() {
278
+ return self.getAsync(pattern, selector);
279
+ },
280
+ withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
281
+ return self.withConnection(pattern, work, selector);
282
+ },
283
+ };
284
+ }
285
+
286
+ /** Run `work` with a connection from a matching node, always released. */
287
+ withConnection<T>(pattern: string, work: (db: any) => Promise<T> | T, selector?: ClusterSelector): Promise<T> {
288
+ return withPooledConnection(() => this.getAsync(pattern, selector), work);
289
+ }
290
+
291
+ /** Destroy every node's pool. */
292
+ destroy(callback?: (err?: any) => void): void {
293
+ this._destroyed = true;
294
+ const nodes = [...this.nodes.values()];
295
+ this.nodes.clear();
296
+ let remaining = nodes.length;
297
+ if (!remaining) {
298
+ if (callback) callback();
299
+ return;
300
+ }
301
+ let firstError: any = null;
302
+ for (const node of nodes) {
303
+ if (node.restoreTimer) {
304
+ clearTimeout(node.restoreTimer);
305
+ }
306
+ node.pool.destroy((err?: any) => {
307
+ if (err && !firstError) firstError = err;
308
+ if (--remaining === 0 && callback) callback(firstError);
309
+ });
310
+ }
311
+ }
312
+
313
+ destroyAsync(): Promise<void> {
314
+ const self = this;
315
+ return fromCallback((cb) => self.destroy(cb));
316
+ }
317
+ }
318
+
319
+ export default PoolCluster;
package/src/pool.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  ***************************************/
6
6
 
7
7
  import Events from 'events';
8
- import { fromCallback } from './callback';
8
+ import { fromCallback, withPooledConnection } from './callback';
9
9
  import type { Callback } from './callback';
10
10
 
11
11
  type AttachFn = (options: any, callback: Callback) => void;
@@ -385,15 +385,8 @@ class Pool extends Events.EventEmitter {
385
385
  * Run `work` with a connection from the pool, returning it to the pool
386
386
  * (detach) when the returned promise settles — success or failure.
387
387
  */
388
- async withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
389
- const db = await this.getAsync();
390
- try {
391
- return await work(db);
392
- } finally {
393
- // A pooled detach only returns the connection to the pool; do not
394
- // let a detach hiccup mask the outcome of `work`.
395
- await new Promise<void>(function(resolve) { db.detach(function() { resolve(); }); });
396
- }
388
+ withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
389
+ return withPooledConnection(() => this.getAsync(), work);
397
390
  }
398
391
  }
399
392