meadow-connection-mssql 1.0.17 → 1.0.18
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/package.json +2 -2
- package/source/Meadow-Connection-MSSQL.js +180 -30
- package/source/Meadow-MSSQL-Retry.js +347 -0
- package/source/Meadow-Schema-MSSQL.js +141 -45
- package/test/MSSQL-Retry_tests.js +351 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meadow-connection-mssql",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "Meadow MSSQL Plugin",
|
|
5
5
|
"main": "source/Meadow-Connection-MSSQL.js",
|
|
6
6
|
"scripts": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"homepage": "https://github.com/stevenvelozo/meadow-connection-mssql",
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"fable": "^3.1.
|
|
51
|
+
"fable": "^3.1.70",
|
|
52
52
|
"pict-docuserve": "^0.1.5",
|
|
53
53
|
"quackage": "^1.1.0"
|
|
54
54
|
},
|
|
@@ -7,6 +7,17 @@ const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
|
7
7
|
const libMSSQL = require('mssql');
|
|
8
8
|
|
|
9
9
|
const libMeadowSchemaMSSQL = require('./Meadow-Schema-MSSQL.js');
|
|
10
|
+
const libRetry = require('./Meadow-MSSQL-Retry.js');
|
|
11
|
+
|
|
12
|
+
// Default timeouts and retry behavior. All configurable per-provider via
|
|
13
|
+
// the MSSQL options block in fable settings. Defaults lean generous for
|
|
14
|
+
// slow WAN links / firewalled customer networks — better to wait a minute
|
|
15
|
+
// than to false-fail a real sync.
|
|
16
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 120000; // 2 min per query
|
|
17
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 60000; // 1 min to establish a connection
|
|
18
|
+
const DEFAULT_CONNECT_MAX_ATTEMPTS = 5;
|
|
19
|
+
const DEFAULT_CONNECT_INITIAL_DELAY = 3000;
|
|
20
|
+
const DEFAULT_CONNECT_MAX_DELAY = 30000;
|
|
10
21
|
|
|
11
22
|
/*
|
|
12
23
|
Das alt muster:
|
|
@@ -67,7 +78,15 @@ class MeadowConnectionMSSQL extends libFableServiceProviderBase
|
|
|
67
78
|
user: this.options.user,
|
|
68
79
|
password: this.options.password,
|
|
69
80
|
database: this.options.database,
|
|
70
|
-
ConnectionPoolLimit: this.options.ConnectionPoolLimit
|
|
81
|
+
ConnectionPoolLimit: this.options.ConnectionPoolLimit,
|
|
82
|
+
// Reliability tuning — forward through so it ends up on
|
|
83
|
+
// options.MSSQL where _buildConnectionSettings and
|
|
84
|
+
// _connectRetryOptions look for it.
|
|
85
|
+
RequestTimeoutMs: this.options.RequestTimeoutMs,
|
|
86
|
+
ConnectionTimeoutMs: this.options.ConnectionTimeoutMs,
|
|
87
|
+
ConnectRetryOptions: this.options.ConnectRetryOptions,
|
|
88
|
+
DDLRetryOptions: this.options.DDLRetryOptions,
|
|
89
|
+
LegacyPagination: this.options.LegacyPagination
|
|
71
90
|
});
|
|
72
91
|
}
|
|
73
92
|
else if (typeof(this.fable.settings.MSSQL) == 'object')
|
|
@@ -81,12 +100,20 @@ class MeadowConnectionMSSQL extends libFableServiceProviderBase
|
|
|
81
100
|
user: tmpSettings.user || tmpSettings.User,
|
|
82
101
|
password: tmpSettings.password || tmpSettings.Password,
|
|
83
102
|
database: tmpSettings.database || tmpSettings.Database,
|
|
84
|
-
ConnectionPoolLimit: tmpSettings.ConnectionPoolLimit
|
|
103
|
+
ConnectionPoolLimit: tmpSettings.ConnectionPoolLimit,
|
|
104
|
+
RequestTimeoutMs: tmpSettings.RequestTimeoutMs,
|
|
105
|
+
ConnectionTimeoutMs: tmpSettings.ConnectionTimeoutMs,
|
|
106
|
+
ConnectRetryOptions: tmpSettings.ConnectRetryOptions,
|
|
107
|
+
DDLRetryOptions: tmpSettings.DDLRetryOptions,
|
|
108
|
+
LegacyPagination: tmpSettings.LegacyPagination
|
|
85
109
|
});
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
// Schema provider handles DDL operations (create, drop, index, etc.)
|
|
113
|
+
// Give it a back-reference so it can trigger pool recycling via the
|
|
114
|
+
// retry helper when it detects a degraded pool.
|
|
89
115
|
this._SchemaProvider = new libMeadowSchemaMSSQL(this.fable, this.options, `${this.Hash}-Schema`);
|
|
116
|
+
this._SchemaProvider.setConnectionProvider(this);
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
get schemaProvider()
|
|
@@ -187,29 +214,45 @@ class MeadowConnectionMSSQL extends libFableServiceProviderBase
|
|
|
187
214
|
this.connectAsync();
|
|
188
215
|
}
|
|
189
216
|
|
|
190
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Build a node-mssql connection settings object from this provider's
|
|
219
|
+
* configured options. Centralised so both connectAsync and
|
|
220
|
+
* recyclePool produce identical settings.
|
|
221
|
+
*
|
|
222
|
+
* @returns {Object}
|
|
223
|
+
*/
|
|
224
|
+
_buildConnectionSettings()
|
|
191
225
|
{
|
|
192
|
-
let tmpCallback = fCallback;
|
|
193
|
-
if (typeof (tmpCallback) !== 'function')
|
|
194
|
-
{
|
|
195
|
-
this.log.error(`Meadow MSSQL connect() called without a callback; this could lead to connection race conditions.`);
|
|
196
|
-
tmpCallback = () => { };
|
|
197
|
-
}
|
|
198
226
|
let tmpMSSQLSettings = this.options.MSSQL || {};
|
|
199
|
-
|
|
227
|
+
|
|
228
|
+
// Timeouts are configurable — slow WAN links / firewalled customer
|
|
229
|
+
// networks may need much longer than the driver defaults.
|
|
230
|
+
let tmpRequestTimeoutMs = tmpMSSQLSettings.RequestTimeoutMs
|
|
231
|
+
|| tmpMSSQLSettings.requestTimeoutMs
|
|
232
|
+
|| DEFAULT_REQUEST_TIMEOUT_MS;
|
|
233
|
+
let tmpConnectionTimeoutMs = tmpMSSQLSettings.ConnectionTimeoutMs
|
|
234
|
+
|| tmpMSSQLSettings.connectionTimeoutMs
|
|
235
|
+
|| DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
236
|
+
|
|
237
|
+
return (
|
|
200
238
|
{
|
|
201
239
|
server: tmpMSSQLSettings.server,
|
|
202
240
|
user: tmpMSSQLSettings.user,
|
|
203
241
|
password: tmpMSSQLSettings.password,
|
|
204
242
|
database: tmpMSSQLSettings.database,
|
|
205
|
-
requestTimeout:
|
|
206
|
-
connectionTimeout:
|
|
243
|
+
requestTimeout: tmpRequestTimeoutMs,
|
|
244
|
+
connectionTimeout: tmpConnectionTimeoutMs,
|
|
207
245
|
port: tmpMSSQLSettings.port,
|
|
208
246
|
pool:
|
|
209
247
|
{
|
|
210
248
|
max: tmpMSSQLSettings.ConnectionPoolLimit || 10,
|
|
211
249
|
min: 0,
|
|
212
|
-
idleTimeoutMillis: 30000
|
|
250
|
+
idleTimeoutMillis: 30000,
|
|
251
|
+
// Cap how long pool.query() waits for an available
|
|
252
|
+
// connection before giving up with a ResourceRequest
|
|
253
|
+
// timeout. Default matches connectionTimeout so the
|
|
254
|
+
// error classifier sees a recognizable shape.
|
|
255
|
+
acquireTimeoutMillis: tmpConnectionTimeoutMs
|
|
213
256
|
},
|
|
214
257
|
options:
|
|
215
258
|
{
|
|
@@ -217,34 +260,141 @@ class MeadowConnectionMSSQL extends libFableServiceProviderBase
|
|
|
217
260
|
trustServerCertificate: true // change to true for local dev / self-signed customer certs
|
|
218
261
|
},
|
|
219
262
|
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve retry options from this provider's config, falling back to
|
|
267
|
+
* sensible defaults tuned for DDL over a slow network.
|
|
268
|
+
*
|
|
269
|
+
* @returns {Object}
|
|
270
|
+
*/
|
|
271
|
+
_connectRetryOptions()
|
|
272
|
+
{
|
|
273
|
+
let tmpMSSQLSettings = this.options.MSSQL || {};
|
|
274
|
+
let tmpRetry = tmpMSSQLSettings.ConnectRetryOptions || {};
|
|
275
|
+
return (
|
|
276
|
+
{
|
|
277
|
+
OperationName: `Meadow-MSSQL connect to [${tmpMSSQLSettings.server}:${tmpMSSQLSettings.port || 1433}]`,
|
|
278
|
+
MaxAttempts: tmpRetry.MaxAttempts || DEFAULT_CONNECT_MAX_ATTEMPTS,
|
|
279
|
+
InitialDelayMs: tmpRetry.InitialDelayMs || DEFAULT_CONNECT_INITIAL_DELAY,
|
|
280
|
+
MaxDelayMs: tmpRetry.MaxDelayMs || DEFAULT_CONNECT_MAX_DELAY,
|
|
281
|
+
BackoffFactor: tmpRetry.BackoffFactor || 2,
|
|
282
|
+
// Connection-establishment retries never recycle a pool
|
|
283
|
+
// (there isn't one yet). OnRecyclePool is intentionally
|
|
284
|
+
// absent here.
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
connectAsync(fCallback)
|
|
289
|
+
{
|
|
290
|
+
let tmpCallback = fCallback;
|
|
291
|
+
if (typeof (tmpCallback) !== 'function')
|
|
292
|
+
{
|
|
293
|
+
this.log.error(`Meadow MSSQL connect() called without a callback; this could lead to connection race conditions.`);
|
|
294
|
+
tmpCallback = () => { };
|
|
295
|
+
}
|
|
296
|
+
|
|
220
297
|
if (this._ConnectionPool)
|
|
221
298
|
{
|
|
222
|
-
tmpCleansedLogSettings = JSON.parse(JSON.stringify(
|
|
299
|
+
let tmpCleansedLogSettings = JSON.parse(JSON.stringify(this._buildConnectionSettings()));
|
|
223
300
|
// No leaking passwords!
|
|
224
301
|
tmpCleansedLogSettings.password = '*****************';
|
|
225
302
|
this.log.error(`Meadow-Connection-MSSQL trying to connect to MSSQL but is already connected - skipping the generation of extra connections.`, tmpCleansedLogSettings);
|
|
226
303
|
return tmpCallback(null, this._ConnectionPool);
|
|
227
304
|
}
|
|
228
|
-
|
|
305
|
+
|
|
306
|
+
let tmpConnectionSettings = this._buildConnectionSettings();
|
|
307
|
+
this.log.info(`Meadow-Connection-MSSQL connecting to [${tmpConnectionSettings.server} : ${tmpConnectionSettings.port}] as ${tmpConnectionSettings.user} for database ${tmpConnectionSettings.database} at a connection limit of ${tmpConnectionSettings.pool.max} (connectionTimeout: ${(tmpConnectionSettings.connectionTimeout / 1000).toFixed(0)}s, requestTimeout: ${(tmpConnectionSettings.requestTimeout / 1000).toFixed(0)}s)`);
|
|
308
|
+
|
|
309
|
+
// Retry with exponential backoff on transient connect failures
|
|
310
|
+
// (network errors, timeouts). Hard failures like authentication
|
|
311
|
+
// errors get classified as ServerError and propagate immediately.
|
|
312
|
+
libRetry.runWithRetry(this.log, this._connectRetryOptions(),
|
|
313
|
+
(fAttemptDone) =>
|
|
314
|
+
{
|
|
315
|
+
libMSSQL.connect(tmpConnectionSettings)
|
|
316
|
+
.then((pConnectionPool) => fAttemptDone(null, pConnectionPool))
|
|
317
|
+
.catch((pError) => fAttemptDone(pError));
|
|
318
|
+
},
|
|
319
|
+
(pError, pConnectionPool) =>
|
|
320
|
+
{
|
|
321
|
+
if (pError)
|
|
322
|
+
{
|
|
323
|
+
this.log.error(`Meadow-Connection-MSSQL final connection failure to [${tmpConnectionSettings.server} : ${tmpConnectionSettings.port}] as ${tmpConnectionSettings.user} for database ${tmpConnectionSettings.database}: ${libRetry.extractErrorMessage(pError)}`);
|
|
324
|
+
return tmpCallback(pError);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
this.log.info(`Meadow-Connection-MSSQL successfully connected to MSSQL at [${tmpConnectionSettings.server} : ${tmpConnectionSettings.port}] as ${tmpConnectionSettings.user} for database ${tmpConnectionSettings.database} at a connection limit of ${tmpConnectionSettings.pool.max}.`);
|
|
328
|
+
this._ConnectionPool = pConnectionPool;
|
|
329
|
+
this.connected = true;
|
|
330
|
+
this._SchemaProvider.setConnectionPool(this._ConnectionPool);
|
|
331
|
+
return tmpCallback(null, this._ConnectionPool);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Destroy the current pool and create a fresh one. Used by the retry
|
|
337
|
+
* helper when a failure mode (RequestTimeout, PoolDegraded) suggests
|
|
338
|
+
* the pooled connection is in a bad state that new queries on the
|
|
339
|
+
* same pool won't recover from.
|
|
340
|
+
*
|
|
341
|
+
* Idempotent: safe to call even if there is no pool. Always resolves
|
|
342
|
+
* (even on error) so callers can proceed with the retry regardless.
|
|
343
|
+
*
|
|
344
|
+
* @param {Function} fCallback - (err) => ... (err is informational only)
|
|
345
|
+
*/
|
|
346
|
+
recyclePool(fCallback)
|
|
347
|
+
{
|
|
348
|
+
let tmpCallback = (typeof (fCallback) === 'function') ? fCallback : () => {};
|
|
349
|
+
|
|
350
|
+
this.log.info(`Meadow-Connection-MSSQL: recycling connection pool...`);
|
|
351
|
+
|
|
352
|
+
// Close the existing pool. mssql's pool.close() is a promise; handle
|
|
353
|
+
// both shapes (some versions are callback-based).
|
|
354
|
+
let fCloseAndReconnect = () =>
|
|
229
355
|
{
|
|
230
|
-
this.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
356
|
+
this._ConnectionPool = false;
|
|
357
|
+
this.connected = false;
|
|
358
|
+
this._SchemaProvider.setConnectionPool(false);
|
|
359
|
+
this.connectAsync((pConnectError) =>
|
|
360
|
+
{
|
|
361
|
+
if (pConnectError)
|
|
362
|
+
{
|
|
363
|
+
this.log.warn(`Meadow-Connection-MSSQL: pool recycle reconnect failed — ${libRetry.extractErrorMessage(pConnectError)}`);
|
|
364
|
+
return tmpCallback(pConnectError);
|
|
365
|
+
}
|
|
366
|
+
this.log.info(`Meadow-Connection-MSSQL: pool recycle complete.`);
|
|
367
|
+
return tmpCallback();
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (this._ConnectionPool && typeof (this._ConnectionPool.close) === 'function')
|
|
372
|
+
{
|
|
373
|
+
let tmpCloseResult;
|
|
374
|
+
try
|
|
375
|
+
{
|
|
376
|
+
tmpCloseResult = this._ConnectionPool.close();
|
|
377
|
+
}
|
|
378
|
+
catch (pCloseError)
|
|
379
|
+
{
|
|
380
|
+
this.log.warn(`Meadow-Connection-MSSQL: pool.close() threw — ${pCloseError.message || pCloseError} — continuing with reconnect anyway`);
|
|
381
|
+
return fCloseAndReconnect();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (tmpCloseResult && typeof (tmpCloseResult.then) === 'function')
|
|
385
|
+
{
|
|
386
|
+
tmpCloseResult
|
|
387
|
+
.then(() => fCloseAndReconnect())
|
|
388
|
+
.catch((pCloseError) =>
|
|
243
389
|
{
|
|
244
|
-
this.log.
|
|
245
|
-
|
|
390
|
+
this.log.warn(`Meadow-Connection-MSSQL: pool.close() rejected — ${pCloseError.message || pCloseError} — continuing with reconnect anyway`);
|
|
391
|
+
fCloseAndReconnect();
|
|
246
392
|
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
247
395
|
}
|
|
396
|
+
|
|
397
|
+
fCloseAndReconnect();
|
|
248
398
|
}
|
|
249
399
|
|
|
250
400
|
get preparedStatement()
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meadow MSSQL Retry + Error Classification
|
|
3
|
+
*
|
|
4
|
+
* Shared helper used by both the connection provider (Meadow-Connection-MSSQL)
|
|
5
|
+
* and the schema provider (Meadow-Schema-MSSQL) to:
|
|
6
|
+
*
|
|
7
|
+
* 1. Classify MSSQL driver errors into a small set of well-known failure
|
|
8
|
+
* modes, so logs make it obvious WHY an operation failed (network,
|
|
9
|
+
* lock contention, pool degradation, etc.).
|
|
10
|
+
* 2. Retry transient operations with exponential backoff, logging each
|
|
11
|
+
* attempt in a way that makes the failure mode sequence readable.
|
|
12
|
+
* 3. Optionally recycle the connection pool on failures that suggest the
|
|
13
|
+
* pooled connection is in a bad state (timeouts, network drops).
|
|
14
|
+
*
|
|
15
|
+
* Each failure mode is described with a human-readable string so a
|
|
16
|
+
* developer reading a sync log can tell at a glance whether the problem
|
|
17
|
+
* is "slow network", "server-side DDL lock", or "pool went bad after
|
|
18
|
+
* timeout" — these are very different things and each has a different
|
|
19
|
+
* resolution path.
|
|
20
|
+
*
|
|
21
|
+
* @license MIT
|
|
22
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default retry options. Callers may override any of these per-operation.
|
|
29
|
+
*
|
|
30
|
+
* MaxAttempts — total attempts including the first. 5 means
|
|
31
|
+
* "first try + up to 4 retries".
|
|
32
|
+
* InitialDelayMs — wait before the second attempt. Subsequent
|
|
33
|
+
* retries use exponential backoff from here.
|
|
34
|
+
* MaxDelayMs — cap the backoff so we don't wait absurdly long.
|
|
35
|
+
* BackoffFactor — multiplier between retries (2 = double each time).
|
|
36
|
+
* PoolRecycleModes — failure modes that trigger pool recycling before
|
|
37
|
+
* the next attempt. Kept explicit so callers can
|
|
38
|
+
* narrow or widen the set without reading code.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_RETRY_OPTIONS =
|
|
41
|
+
{
|
|
42
|
+
MaxAttempts: 5,
|
|
43
|
+
InitialDelayMs: 2000,
|
|
44
|
+
MaxDelayMs: 30000,
|
|
45
|
+
BackoffFactor: 2,
|
|
46
|
+
PoolRecycleModes: ['NetworkError', 'RequestTimeout', 'PoolDegraded']
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error-mode strings. Exported so callers can reference them in
|
|
51
|
+
* configuration (e.g. extending PoolRecycleModes) without stringly-typing.
|
|
52
|
+
*/
|
|
53
|
+
const ERROR_MODES =
|
|
54
|
+
{
|
|
55
|
+
AlreadyExists: 'AlreadyExists',
|
|
56
|
+
NetworkError: 'NetworkError',
|
|
57
|
+
RequestTimeout: 'RequestTimeout',
|
|
58
|
+
PoolDegraded: 'PoolDegraded',
|
|
59
|
+
ServerError: 'ServerError',
|
|
60
|
+
Unknown: 'Unknown'
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract the best available diagnostic string from a node-mssql error.
|
|
65
|
+
* The driver stashes the real server message in different places depending
|
|
66
|
+
* on the failure path — probe all of them and concatenate what we find.
|
|
67
|
+
*
|
|
68
|
+
* @param {Error} pError
|
|
69
|
+
* @return {string}
|
|
70
|
+
*/
|
|
71
|
+
function extractErrorMessage(pError)
|
|
72
|
+
{
|
|
73
|
+
if (!pError) return '';
|
|
74
|
+
|
|
75
|
+
let tmpTop = pError.message || '';
|
|
76
|
+
|
|
77
|
+
let tmpInner = '';
|
|
78
|
+
if (pError.originalError)
|
|
79
|
+
{
|
|
80
|
+
if (pError.originalError.info && pError.originalError.info.message)
|
|
81
|
+
{
|
|
82
|
+
tmpInner = pError.originalError.info.message;
|
|
83
|
+
}
|
|
84
|
+
else if (pError.originalError.message)
|
|
85
|
+
{
|
|
86
|
+
tmpInner = pError.originalError.message;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (tmpInner && tmpInner !== tmpTop)
|
|
91
|
+
{
|
|
92
|
+
return `${tmpTop} — ${tmpInner}`;
|
|
93
|
+
}
|
|
94
|
+
return tmpTop;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify a node-mssql error into one of the well-known failure modes.
|
|
99
|
+
*
|
|
100
|
+
* @param {Error} pError — the error from the mssql driver
|
|
101
|
+
* @param {number} pElapsedMs — how long the failed attempt took
|
|
102
|
+
* @param {Object} [pPriorInfo] — { lastFailureMode, lastFailureElapsed }
|
|
103
|
+
* from the most recent prior attempt; used
|
|
104
|
+
* to detect the fast-fail-after-timeout
|
|
105
|
+
* pattern that signals pool degradation.
|
|
106
|
+
*
|
|
107
|
+
* @return {{
|
|
108
|
+
* mode: string,
|
|
109
|
+
* description: string,
|
|
110
|
+
* isRetryable: boolean,
|
|
111
|
+
* recommendPoolRecycle: boolean
|
|
112
|
+
* }}
|
|
113
|
+
*/
|
|
114
|
+
function classifyError(pError, pElapsedMs, pPriorInfo)
|
|
115
|
+
{
|
|
116
|
+
let tmpCode = (pError && pError.code) || '';
|
|
117
|
+
let tmpMsg = extractErrorMessage(pError);
|
|
118
|
+
let tmpMsgLower = tmpMsg.toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Benign case — target already exists. Callers should treat as success.
|
|
121
|
+
if (tmpMsgLower.indexOf('there is already an object named') >= 0)
|
|
122
|
+
{
|
|
123
|
+
return {
|
|
124
|
+
mode: ERROR_MODES.AlreadyExists,
|
|
125
|
+
description: 'target already exists (benign on re-deploy)',
|
|
126
|
+
isRetryable: false,
|
|
127
|
+
recommendPoolRecycle: false
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Server-level error (syntax, permissions, constraint violations).
|
|
132
|
+
// These are deterministic — retrying won't help. Checked early so we
|
|
133
|
+
// don't misclassify a server message as a timeout just because it
|
|
134
|
+
// contains the word "timeout" somewhere coincidentally.
|
|
135
|
+
if (tmpCode === 'EREQUEST' && tmpMsgLower.indexOf('timeout') < 0)
|
|
136
|
+
{
|
|
137
|
+
return {
|
|
138
|
+
mode: ERROR_MODES.ServerError,
|
|
139
|
+
description: `server-side SQL error: ${tmpMsg}`,
|
|
140
|
+
isRetryable: false,
|
|
141
|
+
recommendPoolRecycle: false
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Pool-degraded heuristic: a fast failure (<500ms) right after a
|
|
146
|
+
// prior slow failure (>5s) almost always means the pooled connection
|
|
147
|
+
// is in a bad state — the server was slow, the socket went stale,
|
|
148
|
+
// and the next query on the same pool died fast. Check this BEFORE
|
|
149
|
+
// the generic NetworkError/RequestTimeout branches so the specific
|
|
150
|
+
// mode wins in the log.
|
|
151
|
+
if (pElapsedMs < 500 &&
|
|
152
|
+
pPriorInfo &&
|
|
153
|
+
pPriorInfo.lastFailureMode === ERROR_MODES.RequestTimeout &&
|
|
154
|
+
pPriorInfo.lastFailureElapsed > 5000)
|
|
155
|
+
{
|
|
156
|
+
return {
|
|
157
|
+
mode: ERROR_MODES.PoolDegraded,
|
|
158
|
+
description: `query failed in ${pElapsedMs}ms after prior ${(pPriorInfo.lastFailureElapsed / 1000).toFixed(1)}s timeout — connection pool appears degraded (stale/bad connection)`,
|
|
159
|
+
isRetryable: true,
|
|
160
|
+
recommendPoolRecycle: true
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// OS-level socket timeout (ETIMEDOUT) — the kernel gave up trying to
|
|
165
|
+
// complete the TCP operation. Treat as a network failure.
|
|
166
|
+
if (tmpCode === 'ETIMEDOUT')
|
|
167
|
+
{
|
|
168
|
+
return {
|
|
169
|
+
mode: ERROR_MODES.NetworkError,
|
|
170
|
+
description: `network-level timeout (${tmpCode}) — socket didn't complete in OS-level timeout: ${tmpMsg}`,
|
|
171
|
+
isRetryable: true,
|
|
172
|
+
recommendPoolRecycle: true
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Network-level failures — server unreachable, DNS, socket dropped.
|
|
177
|
+
// These all indicate the problem is below the SQL layer.
|
|
178
|
+
if (tmpCode === 'ECONNREFUSED' ||
|
|
179
|
+
tmpCode === 'ECONNRESET' ||
|
|
180
|
+
tmpCode === 'ENETUNREACH' ||
|
|
181
|
+
tmpCode === 'EHOSTUNREACH' ||
|
|
182
|
+
tmpCode === 'ENOTFOUND' ||
|
|
183
|
+
tmpCode === 'EAI_AGAIN' ||
|
|
184
|
+
tmpCode === 'ESOCKET')
|
|
185
|
+
{
|
|
186
|
+
return {
|
|
187
|
+
mode: ERROR_MODES.NetworkError,
|
|
188
|
+
description: `network-level failure (${tmpCode}) — server unreachable or connection dropped: ${tmpMsg}`,
|
|
189
|
+
isRetryable: true,
|
|
190
|
+
recommendPoolRecycle: true
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Request timeout — the query reached the server but didn't respond in
|
|
195
|
+
// time. On DDL operations this almost always means another transaction
|
|
196
|
+
// is holding an exclusive schema lock. On data queries it could mean a
|
|
197
|
+
// long-running query is blocking, or the server is overloaded. Note
|
|
198
|
+
// the code is `ETIMEOUT` (no D), which is node-mssql / tedious's own
|
|
199
|
+
// request-timeout code, distinct from the kernel-level ETIMEDOUT above.
|
|
200
|
+
if (tmpCode === 'ETIMEOUT' ||
|
|
201
|
+
tmpMsgLower.indexOf('operation timed out') >= 0 ||
|
|
202
|
+
tmpMsgLower.indexOf('request timeout') >= 0 ||
|
|
203
|
+
tmpMsgLower.indexOf('timed out for an unknown reason') >= 0)
|
|
204
|
+
{
|
|
205
|
+
return {
|
|
206
|
+
mode: ERROR_MODES.RequestTimeout,
|
|
207
|
+
description: `request timed out after ${(pElapsedMs / 1000).toFixed(1)}s — likely DDL lock contention on the server, a long-running blocking query, or a very slow server response`,
|
|
208
|
+
isRetryable: true,
|
|
209
|
+
recommendPoolRecycle: true
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Everything else — retry cautiously (it might be transient) but don't
|
|
214
|
+
// recycle the pool since we don't know what kind of failure it is.
|
|
215
|
+
return {
|
|
216
|
+
mode: ERROR_MODES.Unknown,
|
|
217
|
+
description: `unclassified error (${tmpCode || 'no code'}): ${tmpMsg}`,
|
|
218
|
+
isRetryable: true,
|
|
219
|
+
recommendPoolRecycle: false
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Run an operation with retry, exponential backoff, error classification,
|
|
225
|
+
* and optional pool recycling. Callback-style to match the rest of the
|
|
226
|
+
* Meadow codebase.
|
|
227
|
+
*
|
|
228
|
+
* The operation function is called with a single `fDone(err, result)`
|
|
229
|
+
* callback. `err` may be null on success. Anything truthy is classified
|
|
230
|
+
* and either retried (if the mode is retryable) or propagated.
|
|
231
|
+
*
|
|
232
|
+
* Log output is structured so a reader can follow the decision tree:
|
|
233
|
+
*
|
|
234
|
+
* [info] Meadow-MSSQL CREATE TABLE Sample: attempt 1/5 starting...
|
|
235
|
+
* [warn] Meadow-MSSQL CREATE TABLE Sample: attempt 1/5 failed after 30012ms — request timed out ... (likely DDL lock contention ...)
|
|
236
|
+
* [info] Meadow-MSSQL CREATE TABLE Sample: recycling connection pool before retry (mode: RequestTimeout)
|
|
237
|
+
* [info] Meadow-MSSQL CREATE TABLE Sample: retrying in 2s (attempt 2/5)
|
|
238
|
+
* [info] Meadow-MSSQL CREATE TABLE Sample: attempt 2/5 starting...
|
|
239
|
+
* [info] Meadow-MSSQL CREATE TABLE Sample: succeeded on attempt 2 (elapsed 3542ms)
|
|
240
|
+
*
|
|
241
|
+
* @param {Object} pLog — a fable logger (has .info, .warn, .error)
|
|
242
|
+
* @param {Object} pOptions — { OperationName, MaxAttempts, InitialDelayMs, MaxDelayMs, BackoffFactor, PoolRecycleModes, OnRecyclePool, SuccessModes }
|
|
243
|
+
* @param {Function} fOperation — (fDone) => ... where fDone(err, result)
|
|
244
|
+
* @param {Function} fCallback — (err, result) => ...
|
|
245
|
+
*/
|
|
246
|
+
function runWithRetry(pLog, pOptions, fOperation, fCallback)
|
|
247
|
+
{
|
|
248
|
+
let tmpOptions = Object.assign({}, DEFAULT_RETRY_OPTIONS, pOptions || {});
|
|
249
|
+
let tmpOpName = tmpOptions.OperationName || 'Meadow-MSSQL operation';
|
|
250
|
+
let tmpPriorInfo = null;
|
|
251
|
+
let tmpAttempt = 0;
|
|
252
|
+
|
|
253
|
+
// SuccessModes lets callers treat certain failure modes as success
|
|
254
|
+
// (e.g. AlreadyExists for CREATE TABLE — not really an error).
|
|
255
|
+
let tmpSuccessModes = tmpOptions.SuccessModes || [ERROR_MODES.AlreadyExists];
|
|
256
|
+
|
|
257
|
+
let fTryOnce = () =>
|
|
258
|
+
{
|
|
259
|
+
tmpAttempt++;
|
|
260
|
+
let tmpStartMs = Date.now();
|
|
261
|
+
|
|
262
|
+
pLog.info(`${tmpOpName}: attempt ${tmpAttempt}/${tmpOptions.MaxAttempts} starting...`);
|
|
263
|
+
|
|
264
|
+
fOperation((pError, pResult) =>
|
|
265
|
+
{
|
|
266
|
+
let tmpElapsedMs = Date.now() - tmpStartMs;
|
|
267
|
+
|
|
268
|
+
if (!pError)
|
|
269
|
+
{
|
|
270
|
+
if (tmpAttempt > 1)
|
|
271
|
+
{
|
|
272
|
+
pLog.info(`${tmpOpName}: succeeded on attempt ${tmpAttempt} (elapsed ${tmpElapsedMs}ms)`);
|
|
273
|
+
}
|
|
274
|
+
return fCallback(null, pResult);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let tmpInfo = classifyError(pError, tmpElapsedMs, tmpPriorInfo);
|
|
278
|
+
|
|
279
|
+
// Treat success-mode failures as success.
|
|
280
|
+
if (tmpSuccessModes.indexOf(tmpInfo.mode) >= 0)
|
|
281
|
+
{
|
|
282
|
+
pLog.info(`${tmpOpName}: ${tmpInfo.description} — treating as success`);
|
|
283
|
+
return fCallback(null, pResult);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pLog.warn(`${tmpOpName}: attempt ${tmpAttempt}/${tmpOptions.MaxAttempts} failed after ${tmpElapsedMs}ms [${tmpInfo.mode}] — ${tmpInfo.description}`);
|
|
287
|
+
|
|
288
|
+
tmpPriorInfo = { lastFailureMode: tmpInfo.mode, lastFailureElapsed: tmpElapsedMs };
|
|
289
|
+
|
|
290
|
+
// Terminal: non-retryable or exhausted attempts.
|
|
291
|
+
if (!tmpInfo.isRetryable)
|
|
292
|
+
{
|
|
293
|
+
pLog.error(`${tmpOpName}: giving up — ${tmpInfo.mode} is not retryable`);
|
|
294
|
+
return fCallback(pError);
|
|
295
|
+
}
|
|
296
|
+
if (tmpAttempt >= tmpOptions.MaxAttempts)
|
|
297
|
+
{
|
|
298
|
+
pLog.error(`${tmpOpName}: giving up — exhausted ${tmpOptions.MaxAttempts} attempts; last failure mode was ${tmpInfo.mode}`);
|
|
299
|
+
return fCallback(pError);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Compute next delay with exponential backoff, capped at MaxDelayMs.
|
|
303
|
+
let tmpDelayMs = Math.min(
|
|
304
|
+
tmpOptions.InitialDelayMs * Math.pow(tmpOptions.BackoffFactor, tmpAttempt - 1),
|
|
305
|
+
tmpOptions.MaxDelayMs);
|
|
306
|
+
|
|
307
|
+
let fScheduleRetry = () =>
|
|
308
|
+
{
|
|
309
|
+
pLog.info(`${tmpOpName}: retrying in ${(tmpDelayMs / 1000).toFixed(1)}s (attempt ${tmpAttempt + 1}/${tmpOptions.MaxAttempts})`);
|
|
310
|
+
setTimeout(fTryOnce, tmpDelayMs);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Recycle the pool if the failure mode suggests it AND the caller
|
|
314
|
+
// provided a recycling hook. Pool recycling is async too — wait
|
|
315
|
+
// for it to finish before scheduling the retry.
|
|
316
|
+
let tmpShouldRecycle = tmpOptions.PoolRecycleModes.indexOf(tmpInfo.mode) >= 0
|
|
317
|
+
&& tmpInfo.recommendPoolRecycle
|
|
318
|
+
&& typeof (tmpOptions.OnRecyclePool) === 'function';
|
|
319
|
+
|
|
320
|
+
if (tmpShouldRecycle)
|
|
321
|
+
{
|
|
322
|
+
pLog.info(`${tmpOpName}: recycling connection pool before retry (mode: ${tmpInfo.mode})`);
|
|
323
|
+
tmpOptions.OnRecyclePool((pRecycleError) =>
|
|
324
|
+
{
|
|
325
|
+
if (pRecycleError)
|
|
326
|
+
{
|
|
327
|
+
pLog.warn(`${tmpOpName}: pool recycle failed — ${pRecycleError.message || pRecycleError}; retrying anyway`);
|
|
328
|
+
}
|
|
329
|
+
fScheduleRetry();
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fScheduleRetry();
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
fTryOnce();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
classifyError: classifyError,
|
|
343
|
+
runWithRetry: runWithRetry,
|
|
344
|
+
extractErrorMessage: extractErrorMessage,
|
|
345
|
+
DEFAULT_RETRY_OPTIONS: DEFAULT_RETRY_OPTIONS,
|
|
346
|
+
ERROR_MODES: ERROR_MODES
|
|
347
|
+
};
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
11
11
|
|
|
12
|
+
const libRetry = require('./Meadow-MSSQL-Retry.js');
|
|
13
|
+
|
|
14
|
+
// Default retry behavior for DDL operations. CREATE TABLE and CREATE
|
|
15
|
+
// INDEX against a heavily-used MSSQL instance can hit schema locks held
|
|
16
|
+
// by unrelated transactions; retry with exponential backoff so a busy
|
|
17
|
+
// server window doesn't kill a whole deploy.
|
|
18
|
+
const DEFAULT_DDL_MAX_ATTEMPTS = 5;
|
|
19
|
+
const DEFAULT_DDL_INITIAL_DELAY = 3000;
|
|
20
|
+
const DEFAULT_DDL_MAX_DELAY = 30000;
|
|
21
|
+
|
|
12
22
|
class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
13
23
|
{
|
|
14
24
|
constructor(pFable, pOptions, pServiceHash)
|
|
@@ -19,6 +29,11 @@ class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
|
19
29
|
|
|
20
30
|
// Reference to the connection pool, set by the connection provider
|
|
21
31
|
this._ConnectionPool = false;
|
|
32
|
+
|
|
33
|
+
// Back-reference to the MeadowConnectionMSSQL that owns this
|
|
34
|
+
// schema provider. Used to request a pool recycle when a DDL
|
|
35
|
+
// failure mode suggests the pooled connection is in a bad state.
|
|
36
|
+
this._ConnectionProvider = null;
|
|
22
37
|
}
|
|
23
38
|
|
|
24
39
|
/**
|
|
@@ -32,6 +47,67 @@ class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
|
32
47
|
return this;
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Set the back-reference to the connection provider. The retry
|
|
52
|
+
* helper uses this to trigger pool recycling on pool-degraded errors.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} pConnectionProvider - MeadowConnectionMSSQL instance
|
|
55
|
+
* @returns {MeadowSchemaMSSQL} this (for chaining)
|
|
56
|
+
*/
|
|
57
|
+
setConnectionProvider(pConnectionProvider)
|
|
58
|
+
{
|
|
59
|
+
this._ConnectionProvider = pConnectionProvider;
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the retry options block used by DDL operations. Honors
|
|
65
|
+
* per-provider overrides via options.MSSQL.DDLRetryOptions.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} pOperationName - name to use in log output
|
|
68
|
+
* @returns {Object}
|
|
69
|
+
*/
|
|
70
|
+
_ddlRetryOptions(pOperationName)
|
|
71
|
+
{
|
|
72
|
+
let tmpMSSQLSettings = this.options.MSSQL || this.fable.settings.MSSQL || {};
|
|
73
|
+
let tmpRetry = tmpMSSQLSettings.DDLRetryOptions || {};
|
|
74
|
+
|
|
75
|
+
let tmpOptions = (
|
|
76
|
+
{
|
|
77
|
+
OperationName: pOperationName,
|
|
78
|
+
MaxAttempts: tmpRetry.MaxAttempts || DEFAULT_DDL_MAX_ATTEMPTS,
|
|
79
|
+
InitialDelayMs: tmpRetry.InitialDelayMs || DEFAULT_DDL_INITIAL_DELAY,
|
|
80
|
+
MaxDelayMs: tmpRetry.MaxDelayMs || DEFAULT_DDL_MAX_DELAY,
|
|
81
|
+
BackoffFactor: tmpRetry.BackoffFactor || 2,
|
|
82
|
+
// "AlreadyExists" is always treated as success for DDL — a
|
|
83
|
+
// re-deploy will naturally hit tables that already exist.
|
|
84
|
+
SuccessModes: [libRetry.ERROR_MODES.AlreadyExists]
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Connect the pool recycle hook if we know how to reach the
|
|
88
|
+
// connection provider (we always do when set up via
|
|
89
|
+
// Meadow-Connection-MSSQL, but test harnesses sometimes wire the
|
|
90
|
+
// schema provider standalone).
|
|
91
|
+
if (this._ConnectionProvider && typeof (this._ConnectionProvider.recyclePool) === 'function')
|
|
92
|
+
{
|
|
93
|
+
tmpOptions.OnRecyclePool = (fRecycleDone) =>
|
|
94
|
+
{
|
|
95
|
+
this._ConnectionProvider.recyclePool((pErr) =>
|
|
96
|
+
{
|
|
97
|
+
// Refresh our pool reference from the connection provider
|
|
98
|
+
// after the recycle so the next attempt uses the fresh pool.
|
|
99
|
+
if (this._ConnectionProvider.pool)
|
|
100
|
+
{
|
|
101
|
+
this._ConnectionPool = this._ConnectionProvider.pool;
|
|
102
|
+
}
|
|
103
|
+
return fRecycleDone(pErr);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return tmpOptions;
|
|
109
|
+
}
|
|
110
|
+
|
|
35
111
|
generateDropTableStatement(pTableName)
|
|
36
112
|
{
|
|
37
113
|
let tmpDropTableStatement = `IF OBJECT_ID('dbo.[${pTableName}]', 'U') IS NOT NULL\n`;
|
|
@@ -42,7 +118,7 @@ class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
|
42
118
|
|
|
43
119
|
generateCreateTableStatement(pMeadowTableSchema)
|
|
44
120
|
{
|
|
45
|
-
this.log.info(`--> Building the table create string for ${pMeadowTableSchema} ...`);
|
|
121
|
+
this.log.info(`--> Building the table create string for ${pMeadowTableSchema && pMeadowTableSchema.TableName ? pMeadowTableSchema.TableName : '(unknown)'} ...`);
|
|
46
122
|
|
|
47
123
|
let tmpPrimaryKey = false;
|
|
48
124
|
let tmpCreateTableStatement = `-- [ ${pMeadowTableSchema.TableName} ]`;
|
|
@@ -136,31 +212,40 @@ class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
|
136
212
|
|
|
137
213
|
createTable(pMeadowTableSchema, fCallback)
|
|
138
214
|
{
|
|
215
|
+
let tmpTableName = (pMeadowTableSchema && pMeadowTableSchema.TableName) || '(unknown)';
|
|
139
216
|
let tmpCreateTableStatement = this.generateCreateTableStatement(pMeadowTableSchema);
|
|
140
|
-
|
|
141
|
-
|
|
217
|
+
|
|
218
|
+
// Wrap the DDL in the retry helper. The helper classifies errors
|
|
219
|
+
// and handles:
|
|
220
|
+
// - AlreadyExists → treat as success (benign re-deploy)
|
|
221
|
+
// - NetworkError → exponential backoff, pool recycle
|
|
222
|
+
// - RequestTimeout → exponential backoff, pool recycle (covers
|
|
223
|
+
// server-side schema-lock contention)
|
|
224
|
+
// - PoolDegraded → exponential backoff, pool recycle
|
|
225
|
+
// - ServerError → fail fast (syntax/permission errors)
|
|
226
|
+
// - Unknown → exponential backoff, no pool recycle
|
|
227
|
+
//
|
|
228
|
+
// Every attempt, failure, and recycle decision is logged so the
|
|
229
|
+
// operator can see at a glance which failure mode is occurring.
|
|
230
|
+
libRetry.runWithRetry(this.log,
|
|
231
|
+
this._ddlRetryOptions(`Meadow-MSSQL CREATE TABLE ${tmpTableName}`),
|
|
232
|
+
(fAttemptDone) =>
|
|
142
233
|
{
|
|
143
|
-
this.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
234
|
+
this._ConnectionPool.query(tmpCreateTableStatement)
|
|
235
|
+
.then((pResult) => fAttemptDone(null, pResult))
|
|
236
|
+
.catch((pError) => fAttemptDone(pError));
|
|
237
|
+
},
|
|
238
|
+
(pError) =>
|
|
148
239
|
{
|
|
149
|
-
if (pError
|
|
150
|
-
// TODO: This check may be extraneous; not familiar enough with the mssql node driver yet
|
|
151
|
-
&& (pError.originalError.hasOwnProperty('info'))
|
|
152
|
-
// TODO: Validate that there isn't a better way to find this (pError.code isn't explicit enough)
|
|
153
|
-
&& (pError.originalError.info.message.indexOf("There is already an object named") == 0)
|
|
154
|
-
&& (pError.originalError.info.message.indexOf('in the database.') > 0))
|
|
155
|
-
{
|
|
156
|
-
// The table already existed; log a warning but keep on keeping on.
|
|
157
|
-
return fCallback();
|
|
158
|
-
}
|
|
159
|
-
else
|
|
240
|
+
if (pError)
|
|
160
241
|
{
|
|
161
|
-
|
|
242
|
+
// runWithRetry already logged the final failure with its
|
|
243
|
+
// classified mode. Propagate the error without adding
|
|
244
|
+
// another noisy error line.
|
|
162
245
|
return fCallback(pError);
|
|
163
246
|
}
|
|
247
|
+
this.log.info(`Meadow-MSSQL CREATE TABLE ${tmpTableName} success`);
|
|
248
|
+
return fCallback();
|
|
164
249
|
});
|
|
165
250
|
}
|
|
166
251
|
|
|
@@ -363,35 +448,46 @@ class MeadowSchemaMSSQL extends libFableServiceProviderBase
|
|
|
363
448
|
return fCallback(new Error('Not connected to MSSQL'));
|
|
364
449
|
}
|
|
365
450
|
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
451
|
+
// Wrap the (check, then create) sequence in the retry helper — on
|
|
452
|
+
// a flaky connection either query can time out, and the classifier
|
|
453
|
+
// will surface whether it's network, lock contention, or a stale
|
|
454
|
+
// pool. The retry helper will recycle the pool between attempts
|
|
455
|
+
// when the failure mode recommends it.
|
|
456
|
+
libRetry.runWithRetry(this.log,
|
|
457
|
+
this._ddlRetryOptions(`Meadow-MSSQL CREATE INDEX ${pIndexStatement.Name}`),
|
|
458
|
+
(fAttemptDone) =>
|
|
369
459
|
{
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (tmpExists)
|
|
373
|
-
{
|
|
374
|
-
this.log.info(`Meadow-MSSQL INDEX ${pIndexStatement.Name} already exists, skipping.`);
|
|
375
|
-
return fCallback();
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Index does not exist; create it
|
|
379
|
-
this._ConnectionPool.query(pIndexStatement.Statement)
|
|
380
|
-
.then(() =>
|
|
460
|
+
this._ConnectionPool.query(pIndexStatement.CheckStatement)
|
|
461
|
+
.then((pCheckResult) =>
|
|
381
462
|
{
|
|
382
|
-
|
|
383
|
-
|
|
463
|
+
let tmpExists = pCheckResult && pCheckResult.recordset && pCheckResult.recordset[0] && pCheckResult.recordset[0].IndexExists > 0;
|
|
464
|
+
if (tmpExists)
|
|
465
|
+
{
|
|
466
|
+
// Signal success with a sentinel result so the
|
|
467
|
+
// outer callback can log the "already exists" case.
|
|
468
|
+
return fAttemptDone(null, { AlreadyExisted: true });
|
|
469
|
+
}
|
|
470
|
+
this._ConnectionPool.query(pIndexStatement.Statement)
|
|
471
|
+
.then(() => fAttemptDone(null, { AlreadyExisted: false }))
|
|
472
|
+
.catch((pCreateError) => fAttemptDone(pCreateError));
|
|
384
473
|
})
|
|
385
|
-
.catch((
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return fCallback(pCreateError);
|
|
389
|
-
});
|
|
390
|
-
})
|
|
391
|
-
.catch((pCheckError) =>
|
|
474
|
+
.catch((pCheckError) => fAttemptDone(pCheckError));
|
|
475
|
+
},
|
|
476
|
+
(pError, pResult) =>
|
|
392
477
|
{
|
|
393
|
-
|
|
394
|
-
|
|
478
|
+
if (pError)
|
|
479
|
+
{
|
|
480
|
+
return fCallback(pError);
|
|
481
|
+
}
|
|
482
|
+
if (pResult && pResult.AlreadyExisted)
|
|
483
|
+
{
|
|
484
|
+
this.log.info(`Meadow-MSSQL INDEX ${pIndexStatement.Name} already exists, skipping.`);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
{
|
|
488
|
+
this.log.info(`Meadow-MSSQL CREATE INDEX ${pIndexStatement.Name} executed successfully.`);
|
|
489
|
+
}
|
|
490
|
+
return fCallback();
|
|
395
491
|
});
|
|
396
492
|
}
|
|
397
493
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Meadow-MSSQL-Retry helper (classifyError + runWithRetry).
|
|
3
|
+
*
|
|
4
|
+
* These are pure-JS tests — no real MSSQL connection needed. The goal is
|
|
5
|
+
* to lock in the behavior of the error classifier and the retry loop so
|
|
6
|
+
* regressions don't silently degrade production observability.
|
|
7
|
+
*
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const Chai = require('chai');
|
|
14
|
+
const Expect = Chai.expect;
|
|
15
|
+
|
|
16
|
+
const libRetry = require('../source/Meadow-MSSQL-Retry.js');
|
|
17
|
+
|
|
18
|
+
// A fake logger that captures every call so we can assert on log output.
|
|
19
|
+
function makeTestLogger()
|
|
20
|
+
{
|
|
21
|
+
let tmpRecords = [];
|
|
22
|
+
let tmpLog = (pLevel) => (pMsg) => tmpRecords.push({ level: pLevel, message: String(pMsg) });
|
|
23
|
+
return {
|
|
24
|
+
info: tmpLog('info'),
|
|
25
|
+
warn: tmpLog('warn'),
|
|
26
|
+
error: tmpLog('error'),
|
|
27
|
+
records: tmpRecords
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helpers that synthesize the shapes real mssql errors take.
|
|
32
|
+
function networkError(pCode) {
|
|
33
|
+
let e = new Error(`Network failure (${pCode})`);
|
|
34
|
+
e.code = pCode;
|
|
35
|
+
return e;
|
|
36
|
+
}
|
|
37
|
+
function timeoutError() {
|
|
38
|
+
let e = new Error('Request timeout');
|
|
39
|
+
e.code = 'ETIMEOUT';
|
|
40
|
+
return e;
|
|
41
|
+
}
|
|
42
|
+
function timeoutUnknownReason() {
|
|
43
|
+
return new Error('operation timed out for an unknown reason');
|
|
44
|
+
}
|
|
45
|
+
function alreadyExistsError() {
|
|
46
|
+
let e = new Error('server error');
|
|
47
|
+
e.code = 'EREQUEST';
|
|
48
|
+
e.originalError = { info: { message: "There is already an object named 'Sample' in the database." } };
|
|
49
|
+
return e;
|
|
50
|
+
}
|
|
51
|
+
function serverError(pMsg) {
|
|
52
|
+
let e = new Error('server error');
|
|
53
|
+
e.code = 'EREQUEST';
|
|
54
|
+
e.originalError = { info: { message: pMsg } };
|
|
55
|
+
return e;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
suite('Meadow-MSSQL-Retry',
|
|
59
|
+
() =>
|
|
60
|
+
{
|
|
61
|
+
suite('classifyError',
|
|
62
|
+
() =>
|
|
63
|
+
{
|
|
64
|
+
test('classifies network errors as NetworkError (retryable, recycle)',
|
|
65
|
+
() =>
|
|
66
|
+
{
|
|
67
|
+
let tmpClasses = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ENOTFOUND'];
|
|
68
|
+
for (let i = 0; i < tmpClasses.length; i++)
|
|
69
|
+
{
|
|
70
|
+
let result = libRetry.classifyError(networkError(tmpClasses[i]), 123);
|
|
71
|
+
Expect(result.mode).to.equal(libRetry.ERROR_MODES.NetworkError);
|
|
72
|
+
Expect(result.isRetryable).to.equal(true);
|
|
73
|
+
Expect(result.recommendPoolRecycle).to.equal(true);
|
|
74
|
+
Expect(result.description).to.include(tmpClasses[i]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('classifies request timeout as RequestTimeout with DDL-lock hint',
|
|
79
|
+
() =>
|
|
80
|
+
{
|
|
81
|
+
let r = libRetry.classifyError(timeoutError(), 30012);
|
|
82
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.RequestTimeout);
|
|
83
|
+
Expect(r.isRetryable).to.equal(true);
|
|
84
|
+
Expect(r.recommendPoolRecycle).to.equal(true);
|
|
85
|
+
Expect(r.description).to.include('DDL lock contention');
|
|
86
|
+
Expect(r.description).to.include('30.0s');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('classifies "timed out for an unknown reason" (customer-log string) as RequestTimeout',
|
|
90
|
+
() =>
|
|
91
|
+
{
|
|
92
|
+
let r = libRetry.classifyError(timeoutUnknownReason(), 30000);
|
|
93
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.RequestTimeout);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('classifies "already exists" as AlreadyExists (non-retryable, no recycle)',
|
|
97
|
+
() =>
|
|
98
|
+
{
|
|
99
|
+
let r = libRetry.classifyError(alreadyExistsError(), 45);
|
|
100
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.AlreadyExists);
|
|
101
|
+
Expect(r.isRetryable).to.equal(false);
|
|
102
|
+
Expect(r.recommendPoolRecycle).to.equal(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('classifies generic server error (EREQUEST) as ServerError (non-retryable)',
|
|
106
|
+
() =>
|
|
107
|
+
{
|
|
108
|
+
let r = libRetry.classifyError(serverError('Invalid object name dbo.BadTable'), 50);
|
|
109
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.ServerError);
|
|
110
|
+
Expect(r.isRetryable).to.equal(false);
|
|
111
|
+
Expect(r.description).to.include('Invalid object name');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('detects PoolDegraded pattern (fast-fail after prior slow timeout)',
|
|
115
|
+
() =>
|
|
116
|
+
{
|
|
117
|
+
// Simulate an ECONNRESET happening 150ms after a prior
|
|
118
|
+
// 30s RequestTimeout — classic stale-pool symptom.
|
|
119
|
+
let priorInfo = {
|
|
120
|
+
lastFailureMode: libRetry.ERROR_MODES.RequestTimeout,
|
|
121
|
+
lastFailureElapsed: 30000
|
|
122
|
+
};
|
|
123
|
+
let r = libRetry.classifyError(networkError('ECONNRESET'), 150, priorInfo);
|
|
124
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.PoolDegraded);
|
|
125
|
+
Expect(r.isRetryable).to.equal(true);
|
|
126
|
+
Expect(r.recommendPoolRecycle).to.equal(true);
|
|
127
|
+
Expect(r.description).to.include('degraded');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('does NOT flag PoolDegraded when prior failure was fast too',
|
|
131
|
+
() =>
|
|
132
|
+
{
|
|
133
|
+
// Two fast failures in a row — not pool degradation,
|
|
134
|
+
// something deterministic.
|
|
135
|
+
let priorInfo = {
|
|
136
|
+
lastFailureMode: libRetry.ERROR_MODES.NetworkError,
|
|
137
|
+
lastFailureElapsed: 100
|
|
138
|
+
};
|
|
139
|
+
let r = libRetry.classifyError(networkError('ECONNRESET'), 200, priorInfo);
|
|
140
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.NetworkError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('classifies unknown errors as Unknown, retryable, no recycle',
|
|
144
|
+
() =>
|
|
145
|
+
{
|
|
146
|
+
let e = new Error('something weird');
|
|
147
|
+
let r = libRetry.classifyError(e, 500);
|
|
148
|
+
Expect(r.mode).to.equal(libRetry.ERROR_MODES.Unknown);
|
|
149
|
+
Expect(r.isRetryable).to.equal(true);
|
|
150
|
+
Expect(r.recommendPoolRecycle).to.equal(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
suite('extractErrorMessage',
|
|
155
|
+
() =>
|
|
156
|
+
{
|
|
157
|
+
test('returns empty string for null/undefined',
|
|
158
|
+
() =>
|
|
159
|
+
{
|
|
160
|
+
Expect(libRetry.extractErrorMessage(null)).to.equal('');
|
|
161
|
+
Expect(libRetry.extractErrorMessage(undefined)).to.equal('');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('surfaces inner mssql driver message when present',
|
|
165
|
+
() =>
|
|
166
|
+
{
|
|
167
|
+
let e = new Error('Wrapper');
|
|
168
|
+
e.originalError = { info: { message: 'The real reason' } };
|
|
169
|
+
Expect(libRetry.extractErrorMessage(e)).to.include('The real reason');
|
|
170
|
+
Expect(libRetry.extractErrorMessage(e)).to.include('Wrapper');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('returns top message alone when inner is identical or missing',
|
|
174
|
+
() =>
|
|
175
|
+
{
|
|
176
|
+
let e = new Error('Only one message');
|
|
177
|
+
Expect(libRetry.extractErrorMessage(e)).to.equal('Only one message');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
suite('runWithRetry',
|
|
182
|
+
() =>
|
|
183
|
+
{
|
|
184
|
+
test('calls operation once on success, no retries',
|
|
185
|
+
(fDone) =>
|
|
186
|
+
{
|
|
187
|
+
let log = makeTestLogger();
|
|
188
|
+
let tmpCalls = 0;
|
|
189
|
+
libRetry.runWithRetry(log, { OperationName: 'test-op', MaxAttempts: 3 },
|
|
190
|
+
(fAttemptDone) =>
|
|
191
|
+
{
|
|
192
|
+
tmpCalls++;
|
|
193
|
+
fAttemptDone(null, 'yay');
|
|
194
|
+
},
|
|
195
|
+
(err, result) =>
|
|
196
|
+
{
|
|
197
|
+
try {
|
|
198
|
+
Expect(err).to.be.null;
|
|
199
|
+
Expect(result).to.equal('yay');
|
|
200
|
+
Expect(tmpCalls).to.equal(1);
|
|
201
|
+
fDone();
|
|
202
|
+
} catch (e) { fDone(e); }
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('retries on NetworkError with exponential backoff and succeeds',
|
|
207
|
+
(fDone) =>
|
|
208
|
+
{
|
|
209
|
+
let log = makeTestLogger();
|
|
210
|
+
let tmpCalls = 0;
|
|
211
|
+
libRetry.runWithRetry(log,
|
|
212
|
+
{ OperationName: 'flaky-op', MaxAttempts: 3, InitialDelayMs: 10, MaxDelayMs: 50, BackoffFactor: 2 },
|
|
213
|
+
(fAttemptDone) =>
|
|
214
|
+
{
|
|
215
|
+
tmpCalls++;
|
|
216
|
+
if (tmpCalls < 3) fAttemptDone(networkError('ECONNRESET'));
|
|
217
|
+
else fAttemptDone(null, 'finally');
|
|
218
|
+
},
|
|
219
|
+
(err, result) =>
|
|
220
|
+
{
|
|
221
|
+
try {
|
|
222
|
+
Expect(err).to.be.null;
|
|
223
|
+
Expect(result).to.equal('finally');
|
|
224
|
+
Expect(tmpCalls).to.equal(3);
|
|
225
|
+
let tmpWarnCount = log.records.filter(r => r.level === 'warn').length;
|
|
226
|
+
Expect(tmpWarnCount).to.equal(2);
|
|
227
|
+
fDone();
|
|
228
|
+
} catch (e) { fDone(e); }
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('logs classified failure mode on each attempt',
|
|
233
|
+
(fDone) =>
|
|
234
|
+
{
|
|
235
|
+
let log = makeTestLogger();
|
|
236
|
+
libRetry.runWithRetry(log,
|
|
237
|
+
{ OperationName: 'logtest', MaxAttempts: 2, InitialDelayMs: 5 },
|
|
238
|
+
(fAttemptDone) => fAttemptDone(networkError('ECONNRESET')),
|
|
239
|
+
(err) =>
|
|
240
|
+
{
|
|
241
|
+
try {
|
|
242
|
+
Expect(err).to.not.be.null;
|
|
243
|
+
let tmpRelevant = log.records.filter(r => r.message.indexOf('[NetworkError]') >= 0);
|
|
244
|
+
Expect(tmpRelevant.length).to.be.at.least(1);
|
|
245
|
+
fDone();
|
|
246
|
+
} catch (e) { fDone(e); }
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('does not retry ServerError — fails fast',
|
|
251
|
+
(fDone) =>
|
|
252
|
+
{
|
|
253
|
+
let log = makeTestLogger();
|
|
254
|
+
let tmpCalls = 0;
|
|
255
|
+
libRetry.runWithRetry(log,
|
|
256
|
+
{ OperationName: 'bad-sql', MaxAttempts: 5, InitialDelayMs: 10 },
|
|
257
|
+
(fAttemptDone) =>
|
|
258
|
+
{
|
|
259
|
+
tmpCalls++;
|
|
260
|
+
fAttemptDone(serverError('Syntax error'));
|
|
261
|
+
},
|
|
262
|
+
(err) =>
|
|
263
|
+
{
|
|
264
|
+
try {
|
|
265
|
+
Expect(err).to.not.be.null;
|
|
266
|
+
Expect(tmpCalls).to.equal(1);
|
|
267
|
+
let tmpGivingUp = log.records.filter(r => r.message.indexOf('not retryable') >= 0);
|
|
268
|
+
Expect(tmpGivingUp.length).to.equal(1);
|
|
269
|
+
fDone();
|
|
270
|
+
} catch (e) { fDone(e); }
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('treats AlreadyExists as success',
|
|
275
|
+
(fDone) =>
|
|
276
|
+
{
|
|
277
|
+
let log = makeTestLogger();
|
|
278
|
+
let tmpCalls = 0;
|
|
279
|
+
libRetry.runWithRetry(log,
|
|
280
|
+
{ OperationName: 'create-table', MaxAttempts: 3, InitialDelayMs: 10 },
|
|
281
|
+
(fAttemptDone) =>
|
|
282
|
+
{
|
|
283
|
+
tmpCalls++;
|
|
284
|
+
fAttemptDone(alreadyExistsError());
|
|
285
|
+
},
|
|
286
|
+
(err) =>
|
|
287
|
+
{
|
|
288
|
+
try {
|
|
289
|
+
Expect(err).to.be.null;
|
|
290
|
+
Expect(tmpCalls).to.equal(1);
|
|
291
|
+
let tmpTreatedAsSuccess = log.records.filter(r => r.message.indexOf('treating as success') >= 0);
|
|
292
|
+
Expect(tmpTreatedAsSuccess.length).to.equal(1);
|
|
293
|
+
fDone();
|
|
294
|
+
} catch (e) { fDone(e); }
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('invokes OnRecyclePool for pool-degrading failure modes',
|
|
299
|
+
(fDone) =>
|
|
300
|
+
{
|
|
301
|
+
let log = makeTestLogger();
|
|
302
|
+
let tmpRecycleCalls = 0;
|
|
303
|
+
let tmpCalls = 0;
|
|
304
|
+
libRetry.runWithRetry(log,
|
|
305
|
+
{
|
|
306
|
+
OperationName: 'ddl',
|
|
307
|
+
MaxAttempts: 3,
|
|
308
|
+
InitialDelayMs: 5,
|
|
309
|
+
OnRecyclePool: (fDoneRecycle) =>
|
|
310
|
+
{
|
|
311
|
+
tmpRecycleCalls++;
|
|
312
|
+
setImmediate(fDoneRecycle);
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
(fAttemptDone) =>
|
|
316
|
+
{
|
|
317
|
+
tmpCalls++;
|
|
318
|
+
if (tmpCalls === 1) fAttemptDone(timeoutUnknownReason());
|
|
319
|
+
else fAttemptDone(null, 'ok');
|
|
320
|
+
},
|
|
321
|
+
(err) =>
|
|
322
|
+
{
|
|
323
|
+
try {
|
|
324
|
+
Expect(err).to.be.null;
|
|
325
|
+
Expect(tmpCalls).to.equal(2);
|
|
326
|
+
Expect(tmpRecycleCalls).to.equal(1);
|
|
327
|
+
fDone();
|
|
328
|
+
} catch (e) { fDone(e); }
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('gives up after MaxAttempts and propagates last error',
|
|
333
|
+
(fDone) =>
|
|
334
|
+
{
|
|
335
|
+
let log = makeTestLogger();
|
|
336
|
+
let tmpLastErr = networkError('ECONNRESET');
|
|
337
|
+
libRetry.runWithRetry(log,
|
|
338
|
+
{ OperationName: 'doomed', MaxAttempts: 3, InitialDelayMs: 5 },
|
|
339
|
+
(fAttemptDone) => fAttemptDone(tmpLastErr),
|
|
340
|
+
(err) =>
|
|
341
|
+
{
|
|
342
|
+
try {
|
|
343
|
+
Expect(err).to.equal(tmpLastErr);
|
|
344
|
+
let tmpGiveUp = log.records.filter(r => r.message.indexOf('exhausted 3 attempts') >= 0);
|
|
345
|
+
Expect(tmpGiveUp.length).to.equal(1);
|
|
346
|
+
fDone();
|
|
347
|
+
} catch (e) { fDone(e); }
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|