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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meadow-connection-mssql",
3
- "version": "1.0.17",
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.63",
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
- connectAsync(fCallback)
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
- let tmpConnectionSettings = (
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: 80000,
206
- connectionTimeout: 80000,
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(tmpConnectionSettings));
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
- else
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.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}`);
231
- libMSSQL.connect(tmpConnectionSettings)
232
- .then(
233
- (pConnectionPool) =>
234
- {
235
- 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}.`);
236
- this._ConnectionPool = pConnectionPool;
237
- this.connected = true;
238
- this._SchemaProvider.setConnectionPool(this._ConnectionPool);
239
- return tmpCallback(null, this._ConnectionPool)
240
- })
241
- .catch(
242
- (pError) =>
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.error(`Meadow-Connection-MSSQL error connecting to MSSQL at [${tmpConnectionSettings.server} : ${tmpConnectionSettings.port}] as ${tmpConnectionSettings.user} for database ${tmpConnectionSettings.database} at a connection limit of ${tmpConnectionSettings.pool.max}.`, pError);
245
- return tmpCallback(pError);
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
- this._ConnectionPool.query(tmpCreateTableStatement)
141
- .then((pResult) =>
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.log.info(`Meadow-MSSQL CREATE TABLE ${pMeadowTableSchema.TableName} Success`);
144
- this.log.warn(`Meadow-MSSQL Create Table Statement: ${tmpCreateTableStatement}`)
145
- return fCallback();
146
- })
147
- .catch((pError) =>
234
+ this._ConnectionPool.query(tmpCreateTableStatement)
235
+ .then((pResult) => fAttemptDone(null, pResult))
236
+ .catch((pError) => fAttemptDone(pError));
237
+ },
238
+ (pError) =>
148
239
  {
149
- if (pError.hasOwnProperty('originalError')
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
- this.log.error(`Meadow-MSSQL CREATE TABLE ${pMeadowTableSchema.TableName} failed!`, pError);
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
- // First check if the index already exists
367
- this._ConnectionPool.query(pIndexStatement.CheckStatement)
368
- .then((pCheckResult) =>
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
- let tmpExists = pCheckResult && pCheckResult.recordset && pCheckResult.recordset[0] && pCheckResult.recordset[0].IndexExists > 0;
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
- this.log.info(`Meadow-MSSQL CREATE INDEX ${pIndexStatement.Name} executed successfully.`);
383
- return fCallback();
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((pCreateError) =>
386
- {
387
- this.log.error(`Meadow-MSSQL CREATE INDEX ${pIndexStatement.Name} failed!`, pCreateError);
388
- return fCallback(pCreateError);
389
- });
390
- })
391
- .catch((pCheckError) =>
474
+ .catch((pCheckError) => fAttemptDone(pCheckError));
475
+ },
476
+ (pError, pResult) =>
392
477
  {
393
- this.log.error(`Meadow-MSSQL CHECK INDEX ${pIndexStatement.Name} failed!`, pCheckError);
394
- return fCallback(pCheckError);
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
+ });