@xeplr/utils 1.0.1 → 1.0.3
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/index.js +8 -1
- package/lib/email.js +139 -12
- package/lib/queue.js +130 -11
- package/lib/sql-error.js +208 -0
- package/package.json +4 -1
package/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
const { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv } = require('./lib/email');
|
|
1
|
+
const { sendEmail, configureEmail, emailConfigFromEnv, smtpFromEnv, configureFromEnv } = require('./lib/email');
|
|
2
2
|
const { configureLogger, createSession, log, closeSession, getSessionLogs } = require('./lib/logger');
|
|
3
3
|
const { generateId, formatDbDateTime, mysqlDateTime } = require('./lib/helpers');
|
|
4
4
|
const { respond, sanitizeError } = require('./lib/response');
|
|
5
5
|
const cache = require('./lib/cache');
|
|
6
6
|
const Queue = require('./lib/queue');
|
|
7
|
+
// Which SQL failures are worth trying again — see lib/sql-error.js.
|
|
8
|
+
const { classifySqlError, isRetryableSqlError } = require('./lib/sql-error');
|
|
7
9
|
const FileUploader = require('./lib/fileUploader');
|
|
8
10
|
const RateLimiter = require('./lib/rateLimiter');
|
|
9
11
|
const sms = require('./lib/sms');
|
|
@@ -14,6 +16,7 @@ module.exports = {
|
|
|
14
16
|
sendEmail,
|
|
15
17
|
configureEmail,
|
|
16
18
|
emailConfigFromEnv,
|
|
19
|
+
smtpFromEnv,
|
|
17
20
|
configureFromEnv,
|
|
18
21
|
|
|
19
22
|
// Logger client
|
|
@@ -38,6 +41,10 @@ module.exports = {
|
|
|
38
41
|
// Queue
|
|
39
42
|
Queue,
|
|
40
43
|
|
|
44
|
+
// SQL error classification (retry decisions, and the code a log line carries)
|
|
45
|
+
classifySqlError,
|
|
46
|
+
isRetryableSqlError,
|
|
47
|
+
|
|
41
48
|
// File Uploader
|
|
42
49
|
FileUploader,
|
|
43
50
|
|
package/lib/email.js
CHANGED
|
@@ -25,7 +25,7 @@ let _deadLetterQueue = null;
|
|
|
25
25
|
* Configure the email service.
|
|
26
26
|
* @param {object} config
|
|
27
27
|
* @param {string} config.provider - 'smtp' | 'aws' | 'azure' | 'brevo'
|
|
28
|
-
* @param {object} [config.smtp] - { host, port, user, pass, from }
|
|
28
|
+
* @param {object} [config.smtp] - { host, port, user, pass, from, secure }
|
|
29
29
|
* @param {object} [config.aws] - { region, accessKeyId, secretAccessKey, from }
|
|
30
30
|
* @param {object} [config.azure] - { connectionString, from }
|
|
31
31
|
* @param {object} [config.brevo] - { apiKey, fromEmail, fromName }
|
|
@@ -116,19 +116,62 @@ function buildAttachments(attachments) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
async function sendViaSMTP(to, subject, html, cc, attachments) {
|
|
119
|
-
var cfg = getConfig().smtp;
|
|
120
|
-
var
|
|
119
|
+
var cfg = getConfig().smtp || {};
|
|
120
|
+
var port = _num(cfg.port, 587);
|
|
121
|
+
// 465 is implicit TLS from the first byte; 587 and 25 start in the clear and
|
|
122
|
+
// negotiate STARTTLS. A hardcoded false made every 465 relay fail on the
|
|
123
|
+
// handshake, so infer from the port unless it is stated.
|
|
124
|
+
var secure = cfg.secure === undefined || cfg.secure === null ? port === 465 : !!cfg.secure;
|
|
125
|
+
|
|
126
|
+
// Everything the caller set is forwarded; everything it did not is absent,
|
|
127
|
+
// so nodemailer's defaults stand. Same shape as the email-send action's
|
|
128
|
+
// connection object, so one SMTP server is described identically whether it
|
|
129
|
+
// is configured on a workflow step or in this install's env.
|
|
130
|
+
var transport = _compact({
|
|
121
131
|
host: cfg.host,
|
|
122
|
-
port:
|
|
123
|
-
secure:
|
|
124
|
-
|
|
132
|
+
port: port,
|
|
133
|
+
secure: secure,
|
|
134
|
+
requireTLS: cfg.requireTLS,
|
|
135
|
+
ignoreTLS: cfg.ignoreTLS,
|
|
136
|
+
name: cfg.name,
|
|
137
|
+
authMethod: cfg.authMethod,
|
|
138
|
+
connectionTimeout: cfg.connectionTimeout,
|
|
139
|
+
greetingTimeout: cfg.greetingTimeout,
|
|
140
|
+
socketTimeout: cfg.socketTimeout,
|
|
141
|
+
pool: cfg.pool,
|
|
142
|
+
maxConnections: cfg.maxConnections,
|
|
143
|
+
maxMessages: cfg.maxMessages,
|
|
144
|
+
logger: cfg.debug ? undefined : false,
|
|
145
|
+
debug: cfg.debug,
|
|
146
|
+
tls: cfg.tls && Object.keys(cfg.tls).length ? cfg.tls : undefined
|
|
125
147
|
});
|
|
148
|
+
if (cfg.user || cfg.pass) transport.auth = _compact({ user: cfg.user, pass: cfg.pass });
|
|
149
|
+
|
|
150
|
+
// Unrecognised options last, so a server that needs something this library
|
|
151
|
+
// does not name is still reachable without editing this library. `tls` is
|
|
152
|
+
// merged a level deeper instead of replaced — SMTP_TLS_* and a tls block in
|
|
153
|
+
// SMTP_OPTIONS are both ways of saying the same thing, and dropping one
|
|
154
|
+
// because the other was set would be a silent downgrade.
|
|
155
|
+
if (cfg.options) {
|
|
156
|
+
var extraTls = cfg.options.tls;
|
|
157
|
+
Object.keys(cfg.options).forEach(function (k) {
|
|
158
|
+
if (k !== 'tls') transport[k] = cfg.options[k];
|
|
159
|
+
});
|
|
160
|
+
if (extraTls) transport.tls = Object.assign({}, transport.tls, extraTls);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var transporter = loadNodemailer().createTransport(transport);
|
|
126
164
|
|
|
127
165
|
var mailOptions = { from: cfg.from, to, subject, html };
|
|
128
166
|
if (cc && cc.length) mailOptions.cc = cc;
|
|
129
167
|
if (attachments && attachments.length) mailOptions.attachments = buildAttachments(attachments);
|
|
130
168
|
|
|
131
|
-
|
|
169
|
+
try {
|
|
170
|
+
await transporter.sendMail(mailOptions);
|
|
171
|
+
} finally {
|
|
172
|
+
// A pooled transport holds sockets open and would keep the process alive.
|
|
173
|
+
if (typeof transporter.close === 'function') transporter.close();
|
|
174
|
+
}
|
|
132
175
|
}
|
|
133
176
|
|
|
134
177
|
async function sendViaAWS(to, subject, html, cc, attachments) {
|
|
@@ -315,6 +358,93 @@ async function replayDeadLetters() {
|
|
|
315
358
|
return items.length;
|
|
316
359
|
}
|
|
317
360
|
|
|
361
|
+
// ── SMTP from env ───────────────────────────────────────────────────────
|
|
362
|
+
//
|
|
363
|
+
// The same knobs the email-send ACTION already exposes on its connection
|
|
364
|
+
// object (see @xeplr/actions lib/drivers/email/smtp.js), read from env instead
|
|
365
|
+
// of from a workflow step. Kept deliberately open-ended rather than curated
|
|
366
|
+
// per provider: "which SMTP server is this" is not a question this library can
|
|
367
|
+
// answer, and every provider-specific list eventually meets a server that
|
|
368
|
+
// needs one more option. Anything left unset is left OUT of the transport, so
|
|
369
|
+
// nodemailer's own defaults apply.
|
|
370
|
+
|
|
371
|
+
function _num(v, fallback) {
|
|
372
|
+
if (v === undefined || v === null || v === '') return fallback;
|
|
373
|
+
var n = Number(v);
|
|
374
|
+
return isFinite(n) ? n : fallback;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function _bool(v, fallback) {
|
|
378
|
+
if (v === undefined || v === null || v === '') return fallback;
|
|
379
|
+
var t = String(v).trim().toLowerCase();
|
|
380
|
+
if (['1', 'true', 'yes', 'on'].indexOf(t) !== -1) return true;
|
|
381
|
+
if (['0', 'false', 'no', 'off'].indexOf(t) !== -1) return false;
|
|
382
|
+
return fallback;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function _compact(obj) {
|
|
386
|
+
var out = {};
|
|
387
|
+
Object.keys(obj || {}).forEach(function (k) {
|
|
388
|
+
if (obj[k] !== undefined) out[k] = obj[k];
|
|
389
|
+
});
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function smtpFromEnv() {
|
|
394
|
+
var env = process.env;
|
|
395
|
+
|
|
396
|
+
var tls = _compact({
|
|
397
|
+
rejectUnauthorized: _bool(env.SMTP_TLS_REJECT_UNAUTHORIZED, undefined),
|
|
398
|
+
servername: env.SMTP_TLS_SERVERNAME || undefined
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return _compact({
|
|
402
|
+
host: env.SMTP_HOST,
|
|
403
|
+
port: _num(env.SMTP_PORT, 587),
|
|
404
|
+
user: env.SMTP_USER,
|
|
405
|
+
pass: env.SMTP_PASS,
|
|
406
|
+
from: env.SMTP_FROM,
|
|
407
|
+
// Undefined unless stated, so sendViaSMTP infers it from the port.
|
|
408
|
+
secure: _bool(env.SMTP_SECURE, undefined),
|
|
409
|
+
requireTLS: _bool(env.SMTP_REQUIRE_TLS, undefined),
|
|
410
|
+
ignoreTLS: _bool(env.SMTP_IGNORE_TLS, undefined),
|
|
411
|
+
name: env.SMTP_NAME || undefined, // EHLO/HELO — some servers are picky
|
|
412
|
+
authMethod: env.SMTP_AUTH_METHOD || undefined,
|
|
413
|
+
connectionTimeout: _num(env.SMTP_CONNECTION_TIMEOUT, undefined),
|
|
414
|
+
greetingTimeout: _num(env.SMTP_GREETING_TIMEOUT, undefined),
|
|
415
|
+
socketTimeout: _num(env.SMTP_SOCKET_TIMEOUT, undefined),
|
|
416
|
+
pool: _bool(env.SMTP_POOL, undefined),
|
|
417
|
+
maxConnections: _num(env.SMTP_MAX_CONNECTIONS, undefined),
|
|
418
|
+
maxMessages: _num(env.SMTP_MAX_MESSAGES, undefined),
|
|
419
|
+
debug: _bool(env.SMTP_DEBUG, undefined),
|
|
420
|
+
tls: Object.keys(tls).length ? tls : undefined,
|
|
421
|
+
// THE ESCAPE HATCH. Named options cover the common ground; this carries
|
|
422
|
+
// anything a particular server needs that this library has never heard of
|
|
423
|
+
// (dkim, proxy, tls.ciphers, a vendor's own flag). Merged last, so it can
|
|
424
|
+
// also override any of the above.
|
|
425
|
+
options: _smtpOptionsFromEnv(env.SMTP_OPTIONS)
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Invalid JSON THROWS rather than being skipped. A typo here would otherwise
|
|
430
|
+
// drop the one option the server actually needed and fail later as a refused
|
|
431
|
+
// connection or, worse, an unencrypted send that looked fine.
|
|
432
|
+
function _smtpOptionsFromEnv(raw) {
|
|
433
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return undefined;
|
|
434
|
+
var parsed;
|
|
435
|
+
try {
|
|
436
|
+
parsed = JSON.parse(raw);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
throw new Error('SMTP_OPTIONS is not valid JSON: ' + err.message +
|
|
439
|
+
' — expected a JSON object, e.g. {"dkim":{"domainName":"x.com"}}');
|
|
440
|
+
}
|
|
441
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
442
|
+
throw new Error('SMTP_OPTIONS must be a JSON OBJECT of nodemailer transport options, got ' +
|
|
443
|
+
(Array.isArray(parsed) ? 'an array' : typeof parsed) + '.');
|
|
444
|
+
}
|
|
445
|
+
return parsed;
|
|
446
|
+
}
|
|
447
|
+
|
|
318
448
|
// Build an email config from env (EMAIL_PROVIDER + provider vars). Pure — returns
|
|
319
449
|
// the config or null if no provider is set. The single source for env→email config.
|
|
320
450
|
function emailConfigFromEnv() {
|
|
@@ -329,10 +459,7 @@ function emailConfigFromEnv() {
|
|
|
329
459
|
fromName: process.env.BREVO_FROM_NAME
|
|
330
460
|
};
|
|
331
461
|
} else if (provider === 'smtp') {
|
|
332
|
-
config.smtp =
|
|
333
|
-
host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
|
|
334
|
-
user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM
|
|
335
|
-
};
|
|
462
|
+
config.smtp = smtpFromEnv();
|
|
336
463
|
}
|
|
337
464
|
// aws / azure read their own vars here when those providers are used.
|
|
338
465
|
return config;
|
|
@@ -346,4 +473,4 @@ function configureFromEnv() {
|
|
|
346
473
|
return true;
|
|
347
474
|
}
|
|
348
475
|
|
|
349
|
-
module.exports = { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
|
|
476
|
+
module.exports = { sendEmail, configureEmail, emailConfigFromEnv, smtpFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
|
package/lib/queue.js
CHANGED
|
@@ -157,6 +157,8 @@ class Queue {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
var { classifySqlError } = require('./sql-error');
|
|
161
|
+
|
|
160
162
|
// ─────────────────────────────────────────────────────────────────────────
|
|
161
163
|
// SqlQueue — in-memory concurrent worker pool for SQL execution.
|
|
162
164
|
//
|
|
@@ -263,7 +265,8 @@ class SqlQueue {
|
|
|
263
265
|
if (it.movementId === movementId) {
|
|
264
266
|
this._byteTotal -= it.bytes;
|
|
265
267
|
state.queued--;
|
|
266
|
-
state.dropped
|
|
268
|
+
state.dropped += rowsOf(it);
|
|
269
|
+
state.statementsDropped++;
|
|
267
270
|
} else {
|
|
268
271
|
kept.push(it);
|
|
269
272
|
}
|
|
@@ -275,6 +278,31 @@ class SqlQueue {
|
|
|
275
278
|
catch (e) { /* swallow — never let a hook block */ }
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
// Drop everything queued for a movement that has already aborted.
|
|
282
|
+
//
|
|
283
|
+
// Same bookkeeping as abort()'s own sweep — these rows are lost either way,
|
|
284
|
+
// and a count that omits them is the same lie by a slower route.
|
|
285
|
+
_purgeAborted() {
|
|
286
|
+
if (!this._items.length) return;
|
|
287
|
+
var kept = [];
|
|
288
|
+
for (var i = 0; i < this._items.length; i++) {
|
|
289
|
+
var it = this._items[i];
|
|
290
|
+
var st = this._stateFor(it.movementId);
|
|
291
|
+
if (st.aborted) {
|
|
292
|
+
this._byteTotal -= it.bytes;
|
|
293
|
+
st.queued--;
|
|
294
|
+
st.dropped += rowsOf(it);
|
|
295
|
+
st.statementsDropped++;
|
|
296
|
+
} else {
|
|
297
|
+
kept.push(it);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (kept.length !== this._items.length) {
|
|
301
|
+
this._items = kept;
|
|
302
|
+
this._releaseBackpressure();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
278
306
|
// Stats: per-movement if id passed, else global.
|
|
279
307
|
stats(movementId) {
|
|
280
308
|
if (movementId) {
|
|
@@ -302,7 +330,23 @@ class SqlQueue {
|
|
|
302
330
|
_stateFor(movementId) {
|
|
303
331
|
var s = this._movements.get(movementId);
|
|
304
332
|
if (!s) {
|
|
305
|
-
|
|
333
|
+
// `completed` and `dropped` count ROWS, not statements.
|
|
334
|
+
//
|
|
335
|
+
// They counted statements, and one statement is a multi-row INSERT — so a
|
|
336
|
+
// 12,000-row movement in three batches reported "completed: 3". That
|
|
337
|
+
// number is surfaced to people as ROWS LOADED (import_meta.completed, and
|
|
338
|
+
// the import history page reading it), which meant every import in the
|
|
339
|
+
// product has been reporting its batch count as a row count.
|
|
340
|
+
//
|
|
341
|
+
// Every queued item already carries meta.rowCount; the counters just were
|
|
342
|
+
// not using it. Statements are counted separately for anyone who wants
|
|
343
|
+
// them — see statements/statementsDropped.
|
|
344
|
+
s = {
|
|
345
|
+
movementId: movementId, queued: 0, inFlight: 0,
|
|
346
|
+
completed: 0, dropped: 0,
|
|
347
|
+
statements: 0, statementsDropped: 0,
|
|
348
|
+
aborted: false, abortReason: null, consecutiveDrops: 0
|
|
349
|
+
};
|
|
306
350
|
this._movements.set(movementId, s);
|
|
307
351
|
}
|
|
308
352
|
return s;
|
|
@@ -321,7 +365,25 @@ class SqlQueue {
|
|
|
321
365
|
var m = this._items[i].movementId;
|
|
322
366
|
if (!this._stateFor(m).aborted) { idx = i; break; }
|
|
323
367
|
}
|
|
324
|
-
|
|
368
|
+
// NOTHING LEFT THAT IS ALLOWED TO RUN — every queued item belongs to a
|
|
369
|
+
// movement that has aborted. They can never be picked up (this loop
|
|
370
|
+
// skips aborted movements) and nothing else removes them, so without
|
|
371
|
+
// this they sit in _items forever and drain() waits on a queue that
|
|
372
|
+
// will never empty: a movement that has already finished its work and
|
|
373
|
+
// hangs, with no summary line ever written.
|
|
374
|
+
//
|
|
375
|
+
// Reachable because abort() clears what is queued AT THAT MOMENT, while
|
|
376
|
+
// items keep arriving after it — a bisect re-enqueueing children of an
|
|
377
|
+
// in-flight failure, or a still-running spool adding the next batch.
|
|
378
|
+
// Masked until deterministic errors stopped being retried: the retry
|
|
379
|
+
// backoff used to hold those children in _pendingRetries (which DOES
|
|
380
|
+
// check aborted) rather than in the queue.
|
|
381
|
+
if (idx < 0) {
|
|
382
|
+
this._purgeAborted();
|
|
383
|
+
await sleep(25);
|
|
384
|
+
this._maybeSignalIdle();
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
325
387
|
|
|
326
388
|
var item = this._items.splice(idx, 1)[0];
|
|
327
389
|
this._byteTotal -= item.bytes;
|
|
@@ -345,7 +407,8 @@ class SqlQueue {
|
|
|
345
407
|
try { conn = await this._resolveConnection(item.connection); }
|
|
346
408
|
catch (err) {
|
|
347
409
|
// Connection resolution failure = fatal for the movement.
|
|
348
|
-
state.dropped
|
|
410
|
+
state.dropped += rowsOf(item);
|
|
411
|
+
state.statementsDropped++;
|
|
349
412
|
this.abort(item.movementId, 'connection_resolve_failed: ' + err.message);
|
|
350
413
|
return;
|
|
351
414
|
}
|
|
@@ -353,7 +416,8 @@ class SqlQueue {
|
|
|
353
416
|
item.attempt++;
|
|
354
417
|
try {
|
|
355
418
|
await this.executor(item, conn);
|
|
356
|
-
state.completed
|
|
419
|
+
state.completed += rowsOf(item);
|
|
420
|
+
state.statements++;
|
|
357
421
|
state.consecutiveDrops = 0;
|
|
358
422
|
return;
|
|
359
423
|
} catch (err) {
|
|
@@ -380,6 +444,15 @@ class SqlQueue {
|
|
|
380
444
|
}
|
|
381
445
|
|
|
382
446
|
if (decision.decision === 'bisect' && item.meta && Array.isArray(item.meta.rows) && item.meta.rows.length > 1) {
|
|
447
|
+
// Not into an aborted movement. Splitting a batch to find out which
|
|
448
|
+
// half is bad is only worth doing if the halves can still run, and
|
|
449
|
+
// they cannot — the movement has given up. Counted as dropped,
|
|
450
|
+
// because that is what happens to them.
|
|
451
|
+
if (state.aborted) {
|
|
452
|
+
state.dropped += rowsOf(item);
|
|
453
|
+
state.statementsDropped++;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
383
456
|
var mid = Math.floor(item.meta.rows.length / 2);
|
|
384
457
|
var left = cloneItemWithRows(item, item.meta.rows.slice(0, mid));
|
|
385
458
|
var right = cloneItemWithRows(item, item.meta.rows.slice(mid));
|
|
@@ -394,14 +467,22 @@ class SqlQueue {
|
|
|
394
467
|
}
|
|
395
468
|
|
|
396
469
|
// 'error-table' | 'drop' | anything else past max attempts → drop
|
|
397
|
-
state.dropped
|
|
470
|
+
state.dropped += rowsOf(item);
|
|
471
|
+
state.statementsDropped++;
|
|
398
472
|
state.consecutiveDrops++;
|
|
399
473
|
try {
|
|
400
474
|
this.onErrorTable({
|
|
401
475
|
item: item,
|
|
402
476
|
error: err,
|
|
403
477
|
rowNum: item.meta && item.meta.rowNum,
|
|
404
|
-
reason: decision.reason || err.message
|
|
478
|
+
reason: decision.reason || err.message,
|
|
479
|
+
// The database's OWN identifier for what went wrong ('23502', 515,
|
|
480
|
+
// 'ER_BAD_NULL_ERROR') and whether it was ever worth retrying —
|
|
481
|
+
// facts a log analyzer can group by, where the message is prose
|
|
482
|
+
// that varies by server version and locale.
|
|
483
|
+
errorCode: decision.errorCode != null ? decision.errorCode : null,
|
|
484
|
+
errorKind: decision.errorKind || null,
|
|
485
|
+
attempts: item.attempt
|
|
405
486
|
});
|
|
406
487
|
} catch (_) { /* swallow */ }
|
|
407
488
|
if (state.consecutiveDrops >= this.maxConsecutiveDrops) {
|
|
@@ -446,11 +527,40 @@ class SqlQueue {
|
|
|
446
527
|
function defaultErrorExecutor(ctx) {
|
|
447
528
|
// Default: retry until final attempt, then route the row (or the whole
|
|
448
529
|
// batch if not bisectable) to the error table.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
530
|
+
//
|
|
531
|
+
// EXCEPT when the database has already given its final answer. A NOT NULL
|
|
532
|
+
// violation, a failed cast or a missing column fails identically however
|
|
533
|
+
// many times it is sent, so retrying one buys nothing and costs the whole
|
|
534
|
+
// backoff — 65 seconds by default, per item, and a bisect resets its
|
|
535
|
+
// children to attempt 0 so that multiplies down the tree. Measured on a
|
|
536
|
+
// 1000-row movement into a NOT NULL column: 105 seconds, nearly all of it
|
|
537
|
+
// asleep, for a verdict available immediately.
|
|
538
|
+
//
|
|
539
|
+
// Straight to bisect instead, which is the fast path to the SAME outcome —
|
|
540
|
+
// it isolates which rows are bad instead of waiting to find out.
|
|
541
|
+
// classifySqlError treats anything it does not recognise as retryable, so
|
|
542
|
+
// this only ever skips waiting when the error is known to be deterministic.
|
|
543
|
+
var verdict = classifySqlError(ctx.error);
|
|
544
|
+
var bisectable = ctx.item.meta && Array.isArray(ctx.item.meta.rows) && ctx.item.meta.rows.length > 1;
|
|
545
|
+
|
|
546
|
+
if (!verdict.retryable) {
|
|
547
|
+
if (bisectable) return { decision: 'bisect', errorCode: verdict.code, errorKind: verdict.kind };
|
|
548
|
+
return {
|
|
549
|
+
decision: 'error-table',
|
|
550
|
+
reason: ctx.error && ctx.error.message,
|
|
551
|
+
errorCode: verdict.code,
|
|
552
|
+
errorKind: verdict.kind
|
|
553
|
+
};
|
|
452
554
|
}
|
|
453
|
-
|
|
555
|
+
|
|
556
|
+
if (!ctx.isFinal) return { decision: 'retry' };
|
|
557
|
+
if (bisectable) return { decision: 'bisect', errorCode: verdict.code, errorKind: verdict.kind };
|
|
558
|
+
return {
|
|
559
|
+
decision: 'error-table',
|
|
560
|
+
reason: ctx.error && ctx.error.message,
|
|
561
|
+
errorCode: verdict.code,
|
|
562
|
+
errorKind: verdict.kind
|
|
563
|
+
};
|
|
454
564
|
}
|
|
455
565
|
|
|
456
566
|
function cloneItemWithRows(item, rows) {
|
|
@@ -471,6 +581,14 @@ function cloneItemWithRows(item, rows) {
|
|
|
471
581
|
return next;
|
|
472
582
|
}
|
|
473
583
|
|
|
584
|
+
// Rows in one queued item. The uploader puts the real count on meta; anything
|
|
585
|
+
// that queues without one counts as a single row, which keeps a caller that
|
|
586
|
+
// enqueues row-at-a-time honest rather than reporting zero.
|
|
587
|
+
function rowsOf(item) {
|
|
588
|
+
var n = item && item.meta && item.meta.rowCount;
|
|
589
|
+
return typeof n === 'number' && n >= 0 ? n : 1;
|
|
590
|
+
}
|
|
591
|
+
|
|
474
592
|
function approximateBytes(item) {
|
|
475
593
|
try { return JSON.stringify(item).length; } catch (_) { return 128; }
|
|
476
594
|
}
|
|
@@ -480,3 +598,4 @@ function sleep(ms) { return new Promise(function(res) { setTimeout(res, ms); });
|
|
|
480
598
|
module.exports = Queue;
|
|
481
599
|
module.exports.Queue = Queue;
|
|
482
600
|
module.exports.SqlQueue = SqlQueue;
|
|
601
|
+
module.exports.classifySqlError = classifySqlError;
|
package/lib/sql-error.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// WHICH SQL FAILURES ARE WORTH TRYING AGAIN.
|
|
2
|
+
//
|
|
3
|
+
// A retry is a bet that the same statement, sent again, behaves differently.
|
|
4
|
+
// That bet pays off for a dropped connection, a deadlock victim or a server
|
|
5
|
+
// out of connections — and never for a NOT NULL violation, a bad cast or a
|
|
6
|
+
// column that does not exist. Those fail identically every time, so retrying
|
|
7
|
+
// one costs the backoff (5s + 15s + 45s by default) and then drops the rows
|
|
8
|
+
// anyway.
|
|
9
|
+
//
|
|
10
|
+
// Measured on a real 1000-row movement into a NOT NULL column: 105 seconds,
|
|
11
|
+
// almost all of it asleep, for a verdict available on the first attempt. Worse
|
|
12
|
+
// than slow — a bisect restarts its children at attempt 0, so the wasted
|
|
13
|
+
// backoff multiplies down the bisect tree while a person watches a movement
|
|
14
|
+
// that looks hung.
|
|
15
|
+
//
|
|
16
|
+
// ── why one function serves postgres, mysql and mssql ────────────────────
|
|
17
|
+
//
|
|
18
|
+
// The three drivers throw their native error untouched (see each driver's
|
|
19
|
+
// query()), and each dialect stamps its identity on a DIFFERENT property:
|
|
20
|
+
//
|
|
21
|
+
// postgres err.code 5-char SQLSTATE — '23502', '40001'
|
|
22
|
+
// mysql err.code ER_* name, plus err.errno — 'ER_BAD_NULL_ERROR', 1048
|
|
23
|
+
// mssql err.number integer — 515, 1205
|
|
24
|
+
//
|
|
25
|
+
// So the shapes are self-identifying and no dbType has to be threaded through
|
|
26
|
+
// the queue to read them. A caller that HAS the dbType is not asked for it,
|
|
27
|
+
// which keeps this usable from the queue's default error executor — the one
|
|
28
|
+
// place that decides retries, and the one place with no idea what it is
|
|
29
|
+
// talking to.
|
|
30
|
+
//
|
|
31
|
+
// ── unknown means retry ─────────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// An unrecognised error is treated as transient, i.e. the behavior before this
|
|
34
|
+
// file existed. Wrongly retrying something deterministic costs time; wrongly
|
|
35
|
+
// dropping something transient loses rows. The default belongs on the side
|
|
36
|
+
// that cannot lose data.
|
|
37
|
+
|
|
38
|
+
// ── postgres ────────────────────────────────────────────────────────────
|
|
39
|
+
// SQLSTATE is classified by its two-character class where the whole class
|
|
40
|
+
// agrees, which is most of them — listing 200 individual codes would go stale
|
|
41
|
+
// against the next server release for no gain.
|
|
42
|
+
//
|
|
43
|
+
// 22 data exception invalid text, numeric overflow, bad date
|
|
44
|
+
// 23 integrity constraint not null, unique, foreign key, check
|
|
45
|
+
// 42 syntax error / access no such column, no such table, no privilege
|
|
46
|
+
// 3F invalid schema name
|
|
47
|
+
// 0A feature not supported
|
|
48
|
+
var PG_DETERMINISTIC_CLASSES = ['22', '23', '42', '3F', '0A'];
|
|
49
|
+
// 08 connection exception server closed the connection, failure
|
|
50
|
+
// 40 transaction rollback serialization failure, deadlock detected
|
|
51
|
+
// 53 insufficient resources too many connections, out of memory/disk
|
|
52
|
+
// 57 operator intervention shutdown, cannot connect now, admin cancel
|
|
53
|
+
// 58 system error io error
|
|
54
|
+
var PG_TRANSIENT_CLASSES = ['08', '40', '53', '57', '58'];
|
|
55
|
+
|
|
56
|
+
// ── mysql ───────────────────────────────────────────────────────────────
|
|
57
|
+
// By errno, because err.code (the ER_ name) is absent on some client
|
|
58
|
+
// versions while errno is always there. Names kept alongside for reading.
|
|
59
|
+
var MYSQL_DETERMINISTIC = {
|
|
60
|
+
1048: 'ER_BAD_NULL_ERROR', // column cannot be null
|
|
61
|
+
1062: 'ER_DUP_ENTRY', // duplicate key
|
|
62
|
+
1064: 'ER_PARSE_ERROR',
|
|
63
|
+
1136: 'ER_WRONG_VALUE_COUNT_ON_ROW',
|
|
64
|
+
1146: 'ER_NO_SUCH_TABLE',
|
|
65
|
+
1054: 'ER_BAD_FIELD_ERROR', // unknown column
|
|
66
|
+
1264: 'ER_WARN_DATA_OUT_OF_RANGE',
|
|
67
|
+
1265: 'WARN_DATA_TRUNCATED',
|
|
68
|
+
1292: 'ER_TRUNCATED_WRONG_VALUE', // bad date / bad cast
|
|
69
|
+
1366: 'ER_TRUNCATED_WRONG_VALUE_FOR_FIELD',
|
|
70
|
+
1406: 'ER_DATA_TOO_LONG',
|
|
71
|
+
1451: 'ER_ROW_IS_REFERENCED_2',
|
|
72
|
+
1452: 'ER_NO_REFERENCED_ROW_2', // foreign key
|
|
73
|
+
3819: 'ER_CHECK_CONSTRAINT_VIOLATED',
|
|
74
|
+
1364: 'ER_NO_DEFAULT_FOR_FIELD',
|
|
75
|
+
1305: 'ER_SP_DOES_NOT_EXIST'
|
|
76
|
+
};
|
|
77
|
+
var MYSQL_TRANSIENT = {
|
|
78
|
+
1040: 'ER_CON_COUNT_ERROR', // too many connections
|
|
79
|
+
1203: 'ER_TOO_MANY_USER_CONNECTIONS',
|
|
80
|
+
1205: 'ER_LOCK_WAIT_TIMEOUT',
|
|
81
|
+
1213: 'ER_LOCK_DEADLOCK',
|
|
82
|
+
1290: 'ER_OPTION_PREVENTS_STATEMENT', // read-only, usually a failover
|
|
83
|
+
1053: 'ER_SERVER_SHUTDOWN',
|
|
84
|
+
2002: 'CONNECTION_REFUSED',
|
|
85
|
+
2003: 'CANT_CONNECT',
|
|
86
|
+
2006: 'SERVER_GONE_AWAY',
|
|
87
|
+
2013: 'LOST_CONNECTION'
|
|
88
|
+
};
|
|
89
|
+
// Client-side codes carry no errno at all — a socket that died before the
|
|
90
|
+
// server ever answered.
|
|
91
|
+
var MYSQL_TRANSIENT_CODES = [
|
|
92
|
+
'PROTOCOL_CONNECTION_LOST', 'PROTOCOL_SEQUENCE_TIMEOUT', 'PROTOCOL_ENQUEUE_AFTER_QUIT',
|
|
93
|
+
'POOL_CLOSED', 'POOL_CONNLIMIT'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// ── mssql ───────────────────────────────────────────────────────────────
|
|
97
|
+
var MSSQL_DETERMINISTIC = {
|
|
98
|
+
515: 'cannot insert NULL',
|
|
99
|
+
547: 'foreign key / check constraint',
|
|
100
|
+
2601: 'duplicate key in unique index',
|
|
101
|
+
2627: 'unique constraint violation',
|
|
102
|
+
245: 'conversion failed',
|
|
103
|
+
8114: 'error converting data type',
|
|
104
|
+
8152: 'string or binary data would be truncated',
|
|
105
|
+
2628: 'string or binary data would be truncated (verbose)',
|
|
106
|
+
207: 'invalid column name',
|
|
107
|
+
208: 'invalid object name',
|
|
108
|
+
102: 'incorrect syntax',
|
|
109
|
+
220: 'arithmetic overflow',
|
|
110
|
+
232: 'arithmetic overflow for type',
|
|
111
|
+
8115: 'arithmetic overflow converting'
|
|
112
|
+
};
|
|
113
|
+
var MSSQL_TRANSIENT = {
|
|
114
|
+
1205: 'deadlock victim',
|
|
115
|
+
1222: 'lock request timeout',
|
|
116
|
+
3960: 'snapshot isolation update conflict',
|
|
117
|
+
4060: 'cannot open database',
|
|
118
|
+
40197: 'azure: service error, request processing',
|
|
119
|
+
40501: 'azure: service busy',
|
|
120
|
+
40613: 'azure: database unavailable',
|
|
121
|
+
49918: 'azure: cannot process request, not enough resources',
|
|
122
|
+
49919: 'azure: cannot process create or update request',
|
|
123
|
+
49920: 'azure: cannot process request, too many operations',
|
|
124
|
+
10053: 'transport-level error',
|
|
125
|
+
10054: 'connection reset by peer',
|
|
126
|
+
10060: 'connection timeout',
|
|
127
|
+
233: 'no process on the other end of the pipe',
|
|
128
|
+
64: 'transport-level error on receive'
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Node's own socket-level failures, dialect-independent: the driver never got
|
|
132
|
+
// far enough for the server to have an opinion.
|
|
133
|
+
var NODE_TRANSIENT_CODES = [
|
|
134
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKET', 'ESOCKETTIMEOUT',
|
|
135
|
+
'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN',
|
|
136
|
+
'ECONNCLOSED', 'ETIMEOUT', 'ENOTOPEN'
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classify one database error.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{ retryable: boolean, kind: string, code: string|null, dialect: string|null }}
|
|
143
|
+
* kind is 'transient' | 'deterministic' | 'unknown'. `code` is whatever the
|
|
144
|
+
* dialect stamped on it, as a string, so a log line can carry the database's
|
|
145
|
+
* own identifier rather than a paraphrase of it.
|
|
146
|
+
*/
|
|
147
|
+
function classifySqlError(err) {
|
|
148
|
+
var unknown = { retryable: true, kind: 'unknown', code: null, dialect: null };
|
|
149
|
+
if (!err) return unknown;
|
|
150
|
+
|
|
151
|
+
var code = err.code != null ? String(err.code) : null;
|
|
152
|
+
|
|
153
|
+
// Socket-level, before any dialect has spoken.
|
|
154
|
+
if (code && NODE_TRANSIENT_CODES.indexOf(code) !== -1) {
|
|
155
|
+
return { retryable: true, kind: 'transient', code: code, dialect: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// postgres — SQLSTATE: exactly five characters, digits and capitals.
|
|
159
|
+
if (code && /^[0-9A-Z]{5}$/.test(code)) {
|
|
160
|
+
var cls = code.slice(0, 2);
|
|
161
|
+
if (PG_DETERMINISTIC_CLASSES.indexOf(cls) !== -1) {
|
|
162
|
+
return { retryable: false, kind: 'deterministic', code: code, dialect: 'postgres' };
|
|
163
|
+
}
|
|
164
|
+
if (PG_TRANSIENT_CLASSES.indexOf(cls) !== -1) {
|
|
165
|
+
return { retryable: true, kind: 'transient', code: code, dialect: 'postgres' };
|
|
166
|
+
}
|
|
167
|
+
return { retryable: true, kind: 'unknown', code: code, dialect: 'postgres' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// mysql — errno is the reliable half; the ER_ name is not always present.
|
|
171
|
+
var errno = typeof err.errno === 'number' ? err.errno : null;
|
|
172
|
+
if (errno != null && (MYSQL_DETERMINISTIC[errno] || MYSQL_TRANSIENT[errno])) {
|
|
173
|
+
var mysqlCode = code || MYSQL_DETERMINISTIC[errno] || MYSQL_TRANSIENT[errno];
|
|
174
|
+
return MYSQL_DETERMINISTIC[errno]
|
|
175
|
+
? { retryable: false, kind: 'deterministic', code: mysqlCode, dialect: 'mysql' }
|
|
176
|
+
: { retryable: true, kind: 'transient', code: mysqlCode, dialect: 'mysql' };
|
|
177
|
+
}
|
|
178
|
+
if (code && MYSQL_TRANSIENT_CODES.indexOf(code) !== -1) {
|
|
179
|
+
return { retryable: true, kind: 'transient', code: code, dialect: 'mysql' };
|
|
180
|
+
}
|
|
181
|
+
// An ER_-named error with an errno this file has never heard of is still
|
|
182
|
+
// mysql, and still unknown — reported as such rather than guessed at.
|
|
183
|
+
if (code && /^ER_/.test(code)) {
|
|
184
|
+
return { retryable: true, kind: 'unknown', code: code, dialect: 'mysql' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// mssql — tedious puts the server's error number on `number`.
|
|
188
|
+
var number = typeof err.number === 'number' ? err.number : null;
|
|
189
|
+
if (number != null) {
|
|
190
|
+
if (MSSQL_DETERMINISTIC[number]) {
|
|
191
|
+
return { retryable: false, kind: 'deterministic', code: String(number), dialect: 'mssql' };
|
|
192
|
+
}
|
|
193
|
+
if (MSSQL_TRANSIENT[number]) {
|
|
194
|
+
return { retryable: true, kind: 'transient', code: String(number), dialect: 'mssql' };
|
|
195
|
+
}
|
|
196
|
+
return { retryable: true, kind: 'unknown', code: String(number), dialect: 'mssql' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return code ? { retryable: true, kind: 'unknown', code: code, dialect: null } : unknown;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** True only when trying the identical statement again could plausibly work. */
|
|
203
|
+
function isRetryableSqlError(err) { return classifySqlError(err).retryable; }
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
classifySqlError: classifySqlError,
|
|
207
|
+
isRetryableSqlError: isRetryableSqlError
|
|
208
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Utility functions: email (SMTP, AWS SES, Azure, Brevo), cache, queue, logging client, and helpers",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -69,5 +69,8 @@
|
|
|
69
69
|
"nodemailer": {
|
|
70
70
|
"optional": true
|
|
71
71
|
}
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"test": "node --test test/*.test.js"
|
|
72
75
|
}
|
|
73
76
|
}
|