sqlcache 2.9.0 → 2.9.1
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/.env.example +31 -17
- package/{readme.md → README.md} +106 -28
- package/core/autoInvalidate.js +14 -4
- package/core/autoKey.js +44 -8
- package/dbConnector.js +418 -65
- package/docs/API.md +161 -61
- package/docs/CONFIGURATION.md +155 -47
- package/docs/CONTRIBUTING.md +39 -29
- package/docs/EXAMPLES.md +70 -29
- package/docs/MIGRATION.md +141 -42
- package/docs/README.tr.md +75 -27
- package/docs/USE-CASE-BULK.md +48 -17
- package/docs/USE-CASE-ECOMMERCE.md +8 -2
- package/docs/USE-CASE-REALTIME.md +18 -6
- package/docs/USE-CASE-SAAS.md +24 -15
- package/index.d.ts +163 -50
- package/index.js +50 -23
- package/package.json +11 -5
- package/redis.Connector.js +98 -5
package/dbConnector.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const { performance } = require('perf_hooks');
|
|
2
3
|
const db = require('mysql2/promise');
|
|
3
|
-
const
|
|
4
|
+
const redisConnector = require('./redis.Connector');
|
|
5
|
+
const { getArrayItem, addArrayItem, delPrefixKeyItem } = redisConnector;
|
|
4
6
|
const { generateCacheKey, isAutoKeyEnabled } = require('./core/autoKey');
|
|
5
7
|
const { determineInvalidationPatterns, mergeInvalidationPatterns } = require('./core/autoInvalidate');
|
|
6
8
|
require('dotenv').config();
|
|
@@ -9,6 +11,14 @@ const env = process.env;
|
|
|
9
11
|
// Check if Redis is enabled (default: true)
|
|
10
12
|
const REDIS_ENABLED = env.REDIS_ENABLED !== 'false';
|
|
11
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Parses a positive integer env value.
|
|
16
|
+
*/
|
|
17
|
+
function readPositive(value, fallback) {
|
|
18
|
+
const parsed = parseInt(value, 10);
|
|
19
|
+
return parsed > 0 ? parsed : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/**
|
|
13
23
|
* Validates required environment variables and provides helpful error messages
|
|
14
24
|
* @throws {Error} If any required configuration is missing
|
|
@@ -137,8 +147,9 @@ async function selectDatabase(connection, database) {
|
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
// SQL that may switch the default database by itself (a USE statement, possibly inside a
|
|
140
|
-
// multi-statement query
|
|
141
|
-
|
|
150
|
+
// multi-statement query, or DROP DATABASE, which unsets it when it drops the session's database).
|
|
151
|
+
// Matching is deliberately broad: a false positive only costs one USE.
|
|
152
|
+
const MAY_SWITCH_DATABASE = /\b(?:use|drop\s+(?:database|schema))\b/i;
|
|
142
153
|
|
|
143
154
|
/**
|
|
144
155
|
* Called before running caller-supplied SQL: if it could change the connection's database,
|
|
@@ -164,23 +175,60 @@ async function runQuery(database, sql, parameters, attempt = null) {
|
|
|
164
175
|
// running the query now would only add load (typically to a saturated pool)
|
|
165
176
|
if (attempt && attempt.timedOut) return undefined;
|
|
166
177
|
await selectDatabase(connection, database);
|
|
178
|
+
// ... or while the USE round trip was on its way
|
|
179
|
+
if (attempt && attempt.timedOut) return undefined;
|
|
167
180
|
trackCallerSql(connection, sql);
|
|
168
181
|
if (attempt) attempt.connection = connection;
|
|
169
182
|
const [data] = await connection.query(sql, parameters);
|
|
170
183
|
return data;
|
|
171
184
|
} finally {
|
|
172
185
|
if (attempt) attempt.connection = null;
|
|
173
|
-
|
|
186
|
+
if (attempt && attempt.kill) {
|
|
187
|
+
// A KILL QUERY for this connection's thread was issued and may still be on its way:
|
|
188
|
+
// the connection is dropped instead of being handed to another caller, whose
|
|
189
|
+
// statement the KILL would interrupt (the pool opens a new connection instead)
|
|
190
|
+
if (typeof connection.destroy === 'function') {
|
|
191
|
+
connection.destroy();
|
|
192
|
+
} else {
|
|
193
|
+
connection.release();
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
connection.release();
|
|
197
|
+
}
|
|
174
198
|
}
|
|
175
199
|
}
|
|
176
200
|
|
|
201
|
+
// KILL QUERY connections in progress, by thread id
|
|
202
|
+
const MAX_CONCURRENT_KILLS = 4;
|
|
203
|
+
const pendingKills = new Map();
|
|
204
|
+
|
|
177
205
|
/**
|
|
178
|
-
* Stops the statement running on a pooled connection (KILL QUERY)
|
|
179
|
-
*
|
|
206
|
+
* Stops the statement running on a pooled connection (KILL QUERY), at most once per thread and
|
|
207
|
+
* for at most MAX_CONCURRENT_KILLS threads at a time, so a burst of timeouts cannot open a
|
|
208
|
+
* burst of connections. Returns the kill's promise (it never rejects), or undefined when no
|
|
209
|
+
* kill was issued.
|
|
180
210
|
*/
|
|
181
|
-
|
|
211
|
+
function killRunningQuery(connection) {
|
|
182
212
|
const threadId = connection && connection.threadId;
|
|
183
|
-
if (!threadId) return;
|
|
213
|
+
if (!threadId) return undefined;
|
|
214
|
+
if (pendingKills.has(threadId)) return pendingKills.get(threadId);
|
|
215
|
+
if (pendingKills.size >= MAX_CONCURRENT_KILLS) {
|
|
216
|
+
console.error('Could not cancel timed-out query: too many cancellations in progress');
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const done = () => {
|
|
220
|
+
pendingKills.delete(threadId);
|
|
221
|
+
};
|
|
222
|
+
const kill = sendKillQuery(threadId).then(done, done);
|
|
223
|
+
pendingKills.set(threadId, kill);
|
|
224
|
+
return kill;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sends KILL QUERY for a thread. Uses its own short-lived connection, because the pool may be
|
|
229
|
+
* exhausted exactly when queries time out.
|
|
230
|
+
*/
|
|
231
|
+
async function sendKillQuery(threadId) {
|
|
184
232
|
let killer;
|
|
185
233
|
try {
|
|
186
234
|
killer = await db.createConnection({
|
|
@@ -206,6 +254,19 @@ function invalidate(patterns) {
|
|
|
206
254
|
return Promise.all(mergeInvalidationPatterns(patterns).map(pattern => delPrefixKeyItem(pattern)));
|
|
207
255
|
}
|
|
208
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Invalidation after a write that failed but may have been applied. A problem here is only
|
|
259
|
+
* logged, so the caller gets the error of the write itself.
|
|
260
|
+
*/
|
|
261
|
+
async function invalidateAfterFailure(patterns) {
|
|
262
|
+
if (patterns.length === 0) return;
|
|
263
|
+
try {
|
|
264
|
+
await invalidate(patterns);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error('Cache invalidation error:', err);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
209
270
|
/**
|
|
210
271
|
* Auto-generated cache key. The same SQL returns different rows in different databases
|
|
211
272
|
* (e.g. one database per tenant), so a call with `database` gets its own key; the suffix keeps
|
|
@@ -216,6 +277,116 @@ function autoCacheKey(sql, parameters, database) {
|
|
|
216
277
|
return database ? `${key}@${database}` : key;
|
|
217
278
|
}
|
|
218
279
|
|
|
280
|
+
// Functions that already logged being called without a cacheName (logged once per function)
|
|
281
|
+
const loggedWithoutCacheName = new Set();
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Cache key for getCacheQueryPagination and getCacheQueryWithTimeout: the cacheName, else an
|
|
285
|
+
* automatic key when auto keys are on, else null and the call runs without the cache (a key
|
|
286
|
+
* shared by every call without a cacheName would serve one query's rows for another).
|
|
287
|
+
*/
|
|
288
|
+
function cacheKeyFor(functionName, cacheName, sql, parameters, database) {
|
|
289
|
+
if (cacheName !== null && cacheName !== undefined && cacheName !== '') return cacheName;
|
|
290
|
+
if (!REDIS_ENABLED) return null; // nothing is cached anyway
|
|
291
|
+
if (isAutoKeyEnabled()) return autoCacheKey(sql, parameters, database);
|
|
292
|
+
if (!loggedWithoutCacheName.has(functionName)) {
|
|
293
|
+
loggedWithoutCacheName.add(functionName);
|
|
294
|
+
console.warn(`sqlcache: ${functionName}() was called without a cacheName, so its result is not cached. Pass a cacheName or enable automatic keys (CORE_AUTO_FEATURES=true).`);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether a read-through result is served from the cache and stored in it: only rows. Anything
|
|
301
|
+
* else (an empty array, a non-array value) is a miss, and is not stored, as it would never be served.
|
|
302
|
+
*/
|
|
303
|
+
function hasRows(value) {
|
|
304
|
+
return Array.isArray(value) && value.length > 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Whether a cached pagination result has the shape getCacheQueryPagination stores
|
|
309
|
+
*/
|
|
310
|
+
function isPageResult(value) {
|
|
311
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && Array.isArray(value.detail);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Stale-fill guard ────────────────────────────────────────────
|
|
315
|
+
// redis.Connector skips a read-through cache fill when a delete recorded since its SELECT
|
|
316
|
+
// started covers its key (see "Stale-fill guard" there). Its fill() writes the cache itself, so
|
|
317
|
+
// it is only used together with that connector's own addArrayItem: a replaced addArrayItem (a
|
|
318
|
+
// test double, for instance) keeps receiving the fills, unguarded, as before.
|
|
319
|
+
const fillGuard = redisConnector._fillGuard && redisConnector._fillGuard.addArrayItem === addArrayItem
|
|
320
|
+
? redisConnector._fillGuard
|
|
321
|
+
: null;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Delete sequence number, captured right before a read-through SELECT starts
|
|
325
|
+
*/
|
|
326
|
+
function fillSequence() {
|
|
327
|
+
return fillGuard ? fillGuard.sequence() : 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Stores the rows of a read-through SELECT that started at `sequence`
|
|
332
|
+
*/
|
|
333
|
+
function fillCache(key, data, sequence) {
|
|
334
|
+
return fillGuard ? fillGuard.fill(key, data, sequence) : addArrayItem(key, data);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── bulkInsert identifiers ──────────────────────────────────────
|
|
338
|
+
// bulkInsert puts the table and column names into the statement as given, so only names of the
|
|
339
|
+
// form ^\s*P(?:\.P){0,2}\s*$ are accepted, where P is a bare name (0-9, A-Z, a-z, $, _ and
|
|
340
|
+
// U+0080 to U+FFFF) or a backtick-quoted one, as in IDENTIFIER_PART below. That regex is cubic
|
|
341
|
+
// on runs of whitespace that are also name characters (U+00A0, U+3000, ...), so it is evaluated
|
|
342
|
+
// in linear time instead: the name without surrounding whitespace is tested against the
|
|
343
|
+
// unambiguous IDENTIFIER_BODY. Only a leading or trailing '.' needs the whitespace character next
|
|
344
|
+
// to it, which can only be part of the name as a one-character part when it is a name character.
|
|
345
|
+
const IDENTIFIER_PART = '(?:[0-9A-Za-z$_\\u0080-\\uFFFF]+|`(?:[^`]|``)+`)';
|
|
346
|
+
const IDENTIFIER_BODY = new RegExp(`^${IDENTIFIER_PART}(?:\\.${IDENTIFIER_PART}){0,2}$`);
|
|
347
|
+
const NAME_CHARACTER_FROM = 0x80; // whitespace characters from here on are name characters
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Same result as /^\s*P(?:\.P){0,2}\s*$/.test(name), see above
|
|
351
|
+
*/
|
|
352
|
+
function isValidIdentifier(name) {
|
|
353
|
+
if (typeof name !== 'string') return false;
|
|
354
|
+
const start = name.length - name.trimStart().length;
|
|
355
|
+
const end = name.trimEnd().length;
|
|
356
|
+
if (start >= end) {
|
|
357
|
+
// only whitespace: valid as a single name part made of whitespace name characters
|
|
358
|
+
for (let i = 0; i < name.length; i++) {
|
|
359
|
+
if (name.charCodeAt(i) >= NAME_CHARACTER_FROM) return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
let body = name.slice(start, end);
|
|
364
|
+
if (body[0] === '.') {
|
|
365
|
+
if (!(start > 0 && name.charCodeAt(start - 1) >= NAME_CHARACTER_FROM)) return false;
|
|
366
|
+
body = `x${body}`;
|
|
367
|
+
}
|
|
368
|
+
if (body[body.length - 1] === '.') {
|
|
369
|
+
if (!(end < name.length && name.charCodeAt(end) >= NAME_CHARACTER_FROM)) return false;
|
|
370
|
+
body = `${body}x`;
|
|
371
|
+
}
|
|
372
|
+
return IDENTIFIER_BODY.test(body);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Errors that are never retried: the statement may have taken effect
|
|
376
|
+
const NO_RETRY = Symbol('no retry');
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Marks the error of a failed COMMIT, whose outcome is unknown (e.g. only its reply was lost):
|
|
380
|
+
* it is not retried, which could apply the transaction twice, and err.commitOutcomeUnknown is set.
|
|
381
|
+
*/
|
|
382
|
+
function markCommitOutcomeUnknown(err) {
|
|
383
|
+
if (err !== null && typeof err === 'object' && Object.isExtensible(err)) {
|
|
384
|
+
err.commitOutcomeUnknown = true;
|
|
385
|
+
Object.defineProperty(err, NO_RETRY, { value: true });
|
|
386
|
+
}
|
|
387
|
+
return err;
|
|
388
|
+
}
|
|
389
|
+
|
|
219
390
|
/**
|
|
220
391
|
* Identifies one paginated query (database, SQL and parameters) for DB_PAGINATION_REUSE_TOTAL.
|
|
221
392
|
*/
|
|
@@ -237,15 +408,26 @@ const COALESCING = env.DB_COALESCE_QUERIES !== 'false';
|
|
|
237
408
|
// Opt-in: reuse a page's COUNT(*) total for the other pages of the same query (until the cache
|
|
238
409
|
// name is invalidated), instead of counting again on every page miss
|
|
239
410
|
const REUSE_PAGINATION_TOTAL = env.DB_PAGINATION_REUSE_TOTAL === 'true';
|
|
411
|
+
// A caller joins a shared load for at most DB_COALESCE_MAX_WAIT ms after it started (a load can
|
|
412
|
+
// hang, e.g. on a connection that was silently dropped); after that it shares a fresh one
|
|
413
|
+
const COALESCE_MAX_WAIT = Math.min(readPositive(env.DB_COALESCE_MAX_WAIT, 10000), 2147483647); // setTimeout limit
|
|
414
|
+
// Shared loads in progress, by coalescing key: { promise, startedAt, overdue, whenOverdue, timer }
|
|
240
415
|
const inflightLoads = new Map();
|
|
241
416
|
|
|
242
|
-
// Only single, plain SELECT statements are shared; anything that might write runs per caller
|
|
243
|
-
|
|
417
|
+
// Only single, plain SELECT statements are shared; anything that might write runs per caller.
|
|
418
|
+
// Tested on the statement without its leading whitespace: a leading \s* next to the \s* after
|
|
419
|
+
// the optional '(' would make long whitespace runs quadratic.
|
|
420
|
+
const PLAIN_SELECT = /^\(?\s*SELECT\b/i;
|
|
421
|
+
const WHITESPACE = /\s/;
|
|
244
422
|
|
|
245
423
|
function isPlainRead(sql) {
|
|
246
424
|
if (typeof sql !== 'string') return false;
|
|
247
|
-
|
|
248
|
-
|
|
425
|
+
// Without trailing whitespace and semicolons (a loop: /[\s;]+$/ is quadratic on long runs
|
|
426
|
+
// of whitespace inside the statement)
|
|
427
|
+
let end = sql.length;
|
|
428
|
+
while (end > 0 && (sql.charCodeAt(end - 1) === 59 /* ';' */ || WHITESPACE.test(sql[end - 1]))) end--;
|
|
429
|
+
const body = sql.slice(0, end);
|
|
430
|
+
return PLAIN_SELECT.test(body.trimStart()) && body.indexOf(';') === -1;
|
|
249
431
|
}
|
|
250
432
|
|
|
251
433
|
/**
|
|
@@ -286,36 +468,114 @@ function cloneResult(value) {
|
|
|
286
468
|
if (Buffer.isBuffer(value)) return Buffer.from(value);
|
|
287
469
|
const copy = Object.create(Object.getPrototypeOf(value));
|
|
288
470
|
for (const key of Object.keys(value)) {
|
|
289
|
-
|
|
471
|
+
if (key === '__proto__') {
|
|
472
|
+
// An own "__proto__" key (e.g. from a JSON column) is data: assigning it would set
|
|
473
|
+
// the copy's prototype instead
|
|
474
|
+
Object.defineProperty(copy, key, { value: cloneResult(value[key]), writable: true, enumerable: true, configurable: true });
|
|
475
|
+
} else {
|
|
476
|
+
copy[key] = cloneResult(value[key]);
|
|
477
|
+
}
|
|
290
478
|
}
|
|
291
479
|
return copy;
|
|
292
480
|
}
|
|
293
481
|
|
|
482
|
+
// MAX_EXECUTION_TIME / max_execution_time exceeded: the same statement would only time out again
|
|
483
|
+
function isQueryTimeout(err) {
|
|
484
|
+
return Boolean(err) && (err.errno === 3024 || err.code === 'ER_QUERY_TIMEOUT');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Starts `load` as the shared load for `key`, replacing any entry there
|
|
489
|
+
*/
|
|
490
|
+
function startSharedLoad(key, load) {
|
|
491
|
+
const entry = { promise: null, startedAt: performance.now(), overdue: false, whenOverdue: null, timer: null };
|
|
492
|
+
entry.promise = load();
|
|
493
|
+
inflightLoads.set(key, entry);
|
|
494
|
+
const settled = () => {
|
|
495
|
+
clearTimeout(entry.timer);
|
|
496
|
+
if (inflightLoads.get(key) === entry) inflightLoads.delete(key);
|
|
497
|
+
};
|
|
498
|
+
entry.promise.then(settled, settled);
|
|
499
|
+
return entry;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isOverdue(entry) {
|
|
503
|
+
return entry.overdue || performance.now() - entry.startedAt >= COALESCE_MAX_WAIT;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Resolves once the entry has run for COALESCE_MAX_WAIT. One timer per entry, started for its
|
|
508
|
+
* first waiting caller, and cleared when the load settles.
|
|
509
|
+
*/
|
|
510
|
+
function whenOverdue(entry) {
|
|
511
|
+
if (!entry.whenOverdue) {
|
|
512
|
+
entry.whenOverdue = new Promise((resolve) => {
|
|
513
|
+
entry.timer = setTimeout(() => {
|
|
514
|
+
entry.overdue = true;
|
|
515
|
+
resolve();
|
|
516
|
+
}, Math.max(0, COALESCE_MAX_WAIT - (performance.now() - entry.startedAt)));
|
|
517
|
+
if (entry.timer.unref) entry.timer.unref();
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return entry.whenOverdue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Settles with the first of two shared loads to fulfil (a copy, unless the value is from the
|
|
525
|
+
* caller's own load), or rejects with the fresh load's error once both have rejected.
|
|
526
|
+
*/
|
|
527
|
+
function firstFulfilled(overdue, fresh, freshIsOwn) {
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
let done = false;
|
|
530
|
+
let failures = 0;
|
|
531
|
+
let freshError;
|
|
532
|
+
const fulfil = own => (value) => {
|
|
533
|
+
if (done) return;
|
|
534
|
+
done = true;
|
|
535
|
+
resolve(own ? value : cloneResult(value));
|
|
536
|
+
};
|
|
537
|
+
const fail = () => {
|
|
538
|
+
if (++failures === 2) reject(freshError);
|
|
539
|
+
};
|
|
540
|
+
overdue.then(fulfil(false), fail);
|
|
541
|
+
fresh.then(fulfil(freshIsOwn), (err) => {
|
|
542
|
+
freshError = err;
|
|
543
|
+
fail();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
294
548
|
/**
|
|
295
549
|
* Runs `load` once per key at a time. Callers that arrive while it runs wait for it and get
|
|
296
|
-
* their own copy; if it fails they run `load` themselves, exactly as they would have before
|
|
550
|
+
* their own copy; if it fails they run `load` themselves, exactly as they would have before
|
|
551
|
+
* (except for ER_QUERY_TIMEOUT, which they get as well). A caller waits for a load at most
|
|
552
|
+
* until it has run for COALESCE_MAX_WAIT: then it shares a fresh load (started by the first
|
|
553
|
+
* caller that finds the load overdue, and joined by later callers instead of the overdue one)
|
|
554
|
+
* and gets whichever of the two fulfils first.
|
|
297
555
|
*/
|
|
298
556
|
async function loadOnce(key, load) {
|
|
299
557
|
if (key === null) return load();
|
|
300
558
|
|
|
301
559
|
const inflight = inflightLoads.get(key);
|
|
302
|
-
if (inflight)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
560
|
+
if (!inflight) return startSharedLoad(key, load).promise;
|
|
561
|
+
|
|
562
|
+
if (!isOverdue(inflight)) {
|
|
563
|
+
const outcome = await Promise.race([
|
|
564
|
+
// Copied in the load's first reaction, before the first caller can change the rows
|
|
565
|
+
inflight.promise.then(value => ({ value: cloneResult(value) }), err => ({ err })),
|
|
566
|
+
whenOverdue(inflight)
|
|
567
|
+
]);
|
|
568
|
+
if (outcome) {
|
|
569
|
+
if (!('err' in outcome)) return outcome.value;
|
|
570
|
+
if (isQueryTimeout(outcome.err)) throw outcome.err;
|
|
307
571
|
return load();
|
|
308
572
|
}
|
|
309
|
-
return cloneResult(shared);
|
|
310
573
|
}
|
|
311
574
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
} finally {
|
|
317
|
-
if (inflightLoads.get(key) === pending) inflightLoads.delete(key);
|
|
318
|
-
}
|
|
575
|
+
let fresh = inflightLoads.get(key);
|
|
576
|
+
const own = !fresh || fresh === inflight || isOverdue(fresh);
|
|
577
|
+
if (own) fresh = startSharedLoad(key, load);
|
|
578
|
+
return firstFulfilled(inflight.promise, fresh.promise, own);
|
|
319
579
|
}
|
|
320
580
|
|
|
321
581
|
// Errors MySQL raises because of the COUNT(*) wrapper itself (the same SQL just ran as the page
|
|
@@ -327,7 +587,11 @@ const WRAPPER_ERRORS = new Set([
|
|
|
327
587
|
'ER_WRONG_USAGE', 'ER_NOT_SUPPORTED_YET'
|
|
328
588
|
]);
|
|
329
589
|
const UNCOUNTABLE_SQL_LIMIT = 500;
|
|
330
|
-
const uncountableSql = new Set();
|
|
590
|
+
const uncountableSql = new Set(); // SHA-1 of the SQL text: bounded memory, whatever the SQL size
|
|
591
|
+
|
|
592
|
+
function sqlHash(sql) {
|
|
593
|
+
return crypto.createHash('sha1').update(sql).digest('hex');
|
|
594
|
+
}
|
|
331
595
|
|
|
332
596
|
// A MAX_EXECUTION_TIME optimizer hint only applies to a top-level SELECT, so it is copied onto
|
|
333
597
|
// the COUNT query to keep the caller's time limit
|
|
@@ -346,7 +610,7 @@ function countSql(cleanSql) {
|
|
|
346
610
|
* fetching the full result and counting it, exactly as earlier versions always did.
|
|
347
611
|
*/
|
|
348
612
|
async function countRows(connection, cleanSql, parameters) {
|
|
349
|
-
if (!uncountableSql.has(cleanSql)) {
|
|
613
|
+
if (uncountableSql.size === 0 || !uncountableSql.has(sqlHash(cleanSql))) {
|
|
350
614
|
try {
|
|
351
615
|
const [rows] = await connection.query(countSql(cleanSql), parameters);
|
|
352
616
|
return Number(rows[0].total);
|
|
@@ -355,7 +619,7 @@ async function countRows(connection, cleanSql, parameters) {
|
|
|
355
619
|
if (uncountableSql.size >= UNCOUNTABLE_SQL_LIMIT) {
|
|
356
620
|
uncountableSql.delete(uncountableSql.values().next().value);
|
|
357
621
|
}
|
|
358
|
-
uncountableSql.add(cleanSql);
|
|
622
|
+
uncountableSql.add(sqlHash(cleanSql));
|
|
359
623
|
}
|
|
360
624
|
}
|
|
361
625
|
|
|
@@ -403,6 +667,8 @@ async function executeWithRetry(fn, retries = 3, delay = 1000, database = null,
|
|
|
403
667
|
return await fn(database);
|
|
404
668
|
} catch (error) {
|
|
405
669
|
if (i === retries - 1) throw error;
|
|
670
|
+
// Never retried: the statement may have taken effect (see markCommitOutcomeUnknown)
|
|
671
|
+
if (error && error[NO_RETRY]) throw error;
|
|
406
672
|
|
|
407
673
|
// Retry only on connection errors
|
|
408
674
|
if (error.code === 'ECONNREFUSED' ||
|
|
@@ -485,16 +751,17 @@ module.exports = {
|
|
|
485
751
|
return executeWithRetry(async (db) => {
|
|
486
752
|
if (REDIS_ENABLED) {
|
|
487
753
|
const cachedData = await getArrayItem(finalCacheName);
|
|
488
|
-
if (cachedData
|
|
754
|
+
if (hasRows(cachedData)) {
|
|
489
755
|
return cachedData;
|
|
490
756
|
}
|
|
491
757
|
}
|
|
492
758
|
|
|
493
759
|
return loadOnce(coalescingKey(finalCacheName, db, sql, parameters), async () => {
|
|
760
|
+
const sequence = fillSequence(); // before the SELECT can read anything
|
|
494
761
|
const data = await runQuery(db, sql, parameters);
|
|
495
762
|
|
|
496
|
-
if (REDIS_ENABLED) {
|
|
497
|
-
await
|
|
763
|
+
if (REDIS_ENABLED && hasRows(data)) {
|
|
764
|
+
await fillCache(finalCacheName, data, sequence);
|
|
498
765
|
}
|
|
499
766
|
|
|
500
767
|
return data;
|
|
@@ -510,6 +777,7 @@ module.exports = {
|
|
|
510
777
|
* Uses server-side pagination (SQL LIMIT) for better performance.
|
|
511
778
|
* The total count is computed with SELECT COUNT(*) over the query instead of fetching every row.
|
|
512
779
|
* Handles semicolons at the end of SQL queries properly.
|
|
780
|
+
* Without a cacheName the result is not cached, unless auto keys are on (CORE_AUTO_FEATURES).
|
|
513
781
|
*
|
|
514
782
|
* @param {string} sql - The SQL query to execute.
|
|
515
783
|
* @param {Array} parameters - The parameters to be used in the SQL query.
|
|
@@ -521,6 +789,9 @@ module.exports = {
|
|
|
521
789
|
* @throws {Error} - If an error occurs during the execution of the function.
|
|
522
790
|
*/
|
|
523
791
|
async getCacheQueryPagination(sql, parameters, cacheName, page, pageSize = 30, database = null) {
|
|
792
|
+
const cacheKey = cacheKeyFor('getCacheQueryPagination', cacheName, sql, parameters, database);
|
|
793
|
+
const useCache = REDIS_ENABLED && cacheKey !== null;
|
|
794
|
+
|
|
524
795
|
return executeWithRetry(async (db) => {
|
|
525
796
|
// Validate and normalize pageSize parameter
|
|
526
797
|
let validPageSize = parseInt(pageSize);
|
|
@@ -534,27 +805,35 @@ module.exports = {
|
|
|
534
805
|
validPage = 0;
|
|
535
806
|
}
|
|
536
807
|
|
|
537
|
-
// Create unique cache key including page and pageSize
|
|
538
|
-
|
|
808
|
+
// Create unique cache key including page and pageSize (also the coalescing key of
|
|
809
|
+
// a call without cache)
|
|
810
|
+
const uniqueCacheKey = `${cacheKey === null ? '' : cacheKey}:page${validPage}:size${validPageSize}`;
|
|
539
811
|
|
|
540
|
-
if (
|
|
812
|
+
if (useCache) {
|
|
541
813
|
const cachedData = await getArrayItem(uniqueCacheKey);
|
|
542
|
-
if (
|
|
814
|
+
if (isPageResult(cachedData)) {
|
|
543
815
|
return cachedData;
|
|
544
816
|
}
|
|
545
817
|
}
|
|
546
818
|
|
|
547
|
-
// Prepare SQL: remove trailing semicolon(s) and whitespace
|
|
548
|
-
|
|
819
|
+
// Prepare SQL: remove trailing semicolon(s) and whitespace. After trim() that is the
|
|
820
|
+
// trailing run of semicolons (a loop: /;+\s*$/ is quadratic on long runs of
|
|
821
|
+
// semicolons inside the SQL)
|
|
822
|
+
const trimmedSql = sql.trim();
|
|
823
|
+
let cleanEnd = trimmedSql.length;
|
|
824
|
+
while (cleanEnd > 0 && trimmedSql.charCodeAt(cleanEnd - 1) === 59 /* ';' */) cleanEnd--;
|
|
825
|
+
const cleanSql = trimmedSql.slice(0, cleanEnd);
|
|
549
826
|
|
|
550
827
|
// Calculate offset for pagination
|
|
551
828
|
const offset = validPage * validPageSize;
|
|
552
829
|
|
|
553
830
|
return loadOnce(coalescingKey(uniqueCacheKey, db, cleanSql, parameters), async () => {
|
|
831
|
+
const sequence = fillSequence(); // before anything is read, the stored total included
|
|
832
|
+
|
|
554
833
|
// DB_PAGINATION_REUSE_TOTAL: total stored under the cache name, so invalidating
|
|
555
834
|
// the cache name clears it together with the pages
|
|
556
|
-
const totalKey =
|
|
557
|
-
? `${
|
|
835
|
+
const totalKey = useCache && REUSE_PAGINATION_TOTAL
|
|
836
|
+
? `${cacheKey}:total:${paginationTotalHash(db, cleanSql, parameters)}`
|
|
558
837
|
: null;
|
|
559
838
|
let storedTotal = null;
|
|
560
839
|
if (totalKey) {
|
|
@@ -571,8 +850,9 @@ module.exports = {
|
|
|
571
850
|
await selectDatabase(connection, db);
|
|
572
851
|
trackCallerSql(connection, cleanSql);
|
|
573
852
|
|
|
574
|
-
// Get paginated data with parameterized LIMIT to prevent SQL injection
|
|
575
|
-
|
|
853
|
+
// Get paginated data with parameterized LIMIT to prevent SQL injection. On its
|
|
854
|
+
// own line, so that a trailing -- comment in the SQL cannot swallow it
|
|
855
|
+
const paginatedSql = `${cleanSql}\nLIMIT ?, ?`;
|
|
576
856
|
[data] = await connection.query(paginatedSql, [...parameters, offset, validPageSize]);
|
|
577
857
|
|
|
578
858
|
// A short page is the last one, so it already gives the exact total and the
|
|
@@ -595,10 +875,10 @@ module.exports = {
|
|
|
595
875
|
detail: data
|
|
596
876
|
};
|
|
597
877
|
|
|
598
|
-
if (
|
|
599
|
-
await
|
|
878
|
+
if (useCache) {
|
|
879
|
+
await fillCache(uniqueCacheKey, result, sequence);
|
|
600
880
|
if (totalKey && totalCount !== storedTotal) {
|
|
601
|
-
await
|
|
881
|
+
await fillCache(totalKey, [totalCount], sequence);
|
|
602
882
|
}
|
|
603
883
|
}
|
|
604
884
|
|
|
@@ -614,6 +894,12 @@ module.exports = {
|
|
|
614
894
|
* was not acknowledged instead of inserting the already committed chunks again.
|
|
615
895
|
* With `transaction: true` all chunks are committed together (all or nothing), which is also
|
|
616
896
|
* faster for large inserts; a retry then starts over, as the failed attempt was rolled back.
|
|
897
|
+
* A failed COMMIT is not retried (it may have been applied): its error is thrown with
|
|
898
|
+
* `commitOutcomeUnknown: true`.
|
|
899
|
+
* The table and column names are put into the statement as given; a name that is not a
|
|
900
|
+
* plain (schema.)table or column name is rejected with a TypeError before anything is sent.
|
|
901
|
+
* The cache is invalidated with resetCacheName, else by auto-invalidation (if enabled), also
|
|
902
|
+
* when the call fails after rows may have been written.
|
|
617
903
|
*
|
|
618
904
|
* @param {string} table - The table name to insert into.
|
|
619
905
|
* @param {Array} records - Array of objects with column-value pairs.
|
|
@@ -629,13 +915,28 @@ module.exports = {
|
|
|
629
915
|
}
|
|
630
916
|
|
|
631
917
|
const columns = Object.keys(records[0]);
|
|
918
|
+
// Anything but a plain name could only be a syntax error or SQL injection (e.g. through
|
|
919
|
+
// record keys taken from a request body)
|
|
920
|
+
if (!isValidIdentifier(table)) {
|
|
921
|
+
throw new TypeError('bulkInsert: invalid table name ' + JSON.stringify(String(table)));
|
|
922
|
+
}
|
|
923
|
+
for (const column of columns) {
|
|
924
|
+
if (!isValidIdentifier(column)) {
|
|
925
|
+
throw new TypeError('bulkInsert: invalid column name ' + JSON.stringify(String(column)));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
632
928
|
const rowPlaceholder = `(${columns.map(() => '?').join(',')})`;
|
|
633
929
|
const insertPrefix = `INSERT INTO ${table} (${columns.join(',')}) VALUES `;
|
|
930
|
+
// The patterns QuaryCache would use for this INSERT: resetCacheName, else auto-invalidation
|
|
931
|
+
const patterns = REDIS_ENABLED ? determineInvalidationPatterns(insertPrefix, resetCacheName) : [];
|
|
634
932
|
|
|
635
933
|
// Progress survives retries: only chunks whose INSERT was acknowledged are skipped
|
|
636
934
|
let nextIndex = 0;
|
|
637
935
|
let totalInserted = 0;
|
|
638
936
|
let chunks = 0;
|
|
937
|
+
// Rows may be in MySQL even if the call fails: an INSERT was sent (without a transaction),
|
|
938
|
+
// or the COMMIT failed with an unknown outcome
|
|
939
|
+
let mayHaveWritten = false;
|
|
639
940
|
|
|
640
941
|
await executeWithRetry(async (db) => {
|
|
641
942
|
const connection = await con.getConnection();
|
|
@@ -661,12 +962,20 @@ module.exports = {
|
|
|
661
962
|
|
|
662
963
|
const sql = insertPrefix + chunk.map(() => rowPlaceholder).join(',');
|
|
663
964
|
|
|
965
|
+
if (!transaction) mayHaveWritten = true;
|
|
664
966
|
const [result] = await connection.query(sql, flatValues);
|
|
665
967
|
totalInserted += result.affectedRows;
|
|
666
968
|
chunks++;
|
|
667
969
|
}
|
|
668
970
|
|
|
669
|
-
if (transaction)
|
|
971
|
+
if (transaction) {
|
|
972
|
+
try {
|
|
973
|
+
await connection.commit();
|
|
974
|
+
} catch (err) {
|
|
975
|
+
mayHaveWritten = true;
|
|
976
|
+
throw markCommitOutcomeUnknown(err);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
670
979
|
} catch (err) {
|
|
671
980
|
if (transaction) {
|
|
672
981
|
try {
|
|
@@ -679,11 +988,17 @@ module.exports = {
|
|
|
679
988
|
} finally {
|
|
680
989
|
connection.release();
|
|
681
990
|
}
|
|
682
|
-
}, 3, 1000, database)
|
|
991
|
+
}, 3, 1000, database).catch(async (err) => {
|
|
992
|
+
if (mayHaveWritten) {
|
|
993
|
+
forgetInflightReads();
|
|
994
|
+
await invalidateAfterFailure(patterns);
|
|
995
|
+
}
|
|
996
|
+
throw err;
|
|
997
|
+
});
|
|
683
998
|
forgetInflightReads();
|
|
684
999
|
|
|
685
|
-
if (
|
|
686
|
-
await
|
|
1000
|
+
if (patterns.length > 0) {
|
|
1001
|
+
await invalidate(patterns);
|
|
687
1002
|
}
|
|
688
1003
|
|
|
689
1004
|
return { insertedRows: totalInserted, chunks };
|
|
@@ -692,6 +1007,7 @@ module.exports = {
|
|
|
692
1007
|
/**
|
|
693
1008
|
* Executes a query with timeout protection.
|
|
694
1009
|
* Prevents long-running queries from blocking the application.
|
|
1010
|
+
* Without a cacheName the result is not cached, unless auto keys are on (CORE_AUTO_FEATURES).
|
|
695
1011
|
*
|
|
696
1012
|
* @param {string} sql - The SQL query to execute.
|
|
697
1013
|
* @param {Array} parameters - The parameters to be passed to the SQL query.
|
|
@@ -702,29 +1018,32 @@ module.exports = {
|
|
|
702
1018
|
*/
|
|
703
1019
|
async getCacheQueryWithTimeout(sql, parameters, cacheName, options = {}) {
|
|
704
1020
|
const { timeout = 30000, database = null, cancelOnTimeout = false } = options;
|
|
1021
|
+
const cacheKey = cacheKeyFor('getCacheQueryWithTimeout', cacheName, sql, parameters, database);
|
|
1022
|
+
const useCache = REDIS_ENABLED && cacheKey !== null;
|
|
705
1023
|
let attempt = null;
|
|
706
1024
|
const onTimeout = () => {
|
|
707
1025
|
attempt.timedOut = true;
|
|
708
1026
|
if (cancelOnTimeout && attempt.connection) {
|
|
709
|
-
killRunningQuery(attempt.connection);
|
|
1027
|
+
attempt.kill = killRunningQuery(attempt.connection);
|
|
710
1028
|
}
|
|
711
1029
|
};
|
|
712
1030
|
return executeWithRetry(async (db) => {
|
|
713
|
-
attempt = { timedOut: false, connection: null };
|
|
1031
|
+
attempt = { timedOut: false, connection: null, kill: undefined };
|
|
714
1032
|
const current = attempt;
|
|
715
1033
|
|
|
716
|
-
if (
|
|
717
|
-
const cachedData = await getArrayItem(
|
|
718
|
-
if (cachedData
|
|
1034
|
+
if (useCache) {
|
|
1035
|
+
const cachedData = await getArrayItem(cacheKey);
|
|
1036
|
+
if (hasRows(cachedData)) {
|
|
719
1037
|
return cachedData;
|
|
720
1038
|
}
|
|
721
1039
|
}
|
|
722
1040
|
|
|
1041
|
+
const sequence = fillSequence(); // before the SELECT can read anything
|
|
723
1042
|
const data = await runQuery(db, sql, parameters, current);
|
|
724
1043
|
|
|
725
1044
|
// A query skipped after the timeout has nothing to cache; a cancelled one either
|
|
726
|
-
if (
|
|
727
|
-
await
|
|
1045
|
+
if (useCache && hasRows(data) && !(current.timedOut && cancelOnTimeout)) {
|
|
1046
|
+
await fillCache(cacheKey, data, sequence);
|
|
728
1047
|
}
|
|
729
1048
|
|
|
730
1049
|
return data;
|
|
@@ -767,7 +1086,9 @@ module.exports = {
|
|
|
767
1086
|
/**
|
|
768
1087
|
* Executes queries within a database transaction.
|
|
769
1088
|
* Automatically commits on success or rolls back on error.
|
|
770
|
-
* Cache invalidation is buffered and applied only on successful commit
|
|
1089
|
+
* Cache invalidation is buffered and applied only on successful commit (or when the COMMIT
|
|
1090
|
+
* fails, as it may have been applied). Cache fills of tx.getCacheQuery are buffered too and
|
|
1091
|
+
* written only after a successful commit.
|
|
771
1092
|
*
|
|
772
1093
|
* @param {Function} callback - Async function that receives transaction context (tx)
|
|
773
1094
|
* @param {Object} options - Transaction options
|
|
@@ -787,6 +1108,14 @@ module.exports = {
|
|
|
787
1108
|
let connection;
|
|
788
1109
|
let result;
|
|
789
1110
|
const invalidationBuffer = []; // Buffer cache patterns for commit
|
|
1111
|
+
// Cache fills of tx.getCacheQuery misses (the last one per key), written after COMMIT:
|
|
1112
|
+
// until then the rows may include this transaction's own uncommitted writes
|
|
1113
|
+
const fillBuffer = new Map();
|
|
1114
|
+
// The fills are checked against deletes since the transaction started (its snapshot can
|
|
1115
|
+
// be older than the read that fills)
|
|
1116
|
+
const sequence = fillSequence();
|
|
1117
|
+
let commitFailed = false;
|
|
1118
|
+
let commitError;
|
|
790
1119
|
|
|
791
1120
|
try {
|
|
792
1121
|
if (isShuttingDown) {
|
|
@@ -827,7 +1156,8 @@ module.exports = {
|
|
|
827
1156
|
|
|
828
1157
|
/**
|
|
829
1158
|
* Execute a cached read query within the transaction
|
|
830
|
-
* Note: Reads from cache, but doesn't guarantee transaction isolation
|
|
1159
|
+
* Note: Reads from cache, but doesn't guarantee transaction isolation. A miss is
|
|
1160
|
+
* cached only after the transaction committed.
|
|
831
1161
|
* @param {string} sql - SQL query
|
|
832
1162
|
* @param {Array} parameters - Query parameters
|
|
833
1163
|
* @param {string|null} cacheName - Cache key (optional if auto-key enabled)
|
|
@@ -849,16 +1179,17 @@ module.exports = {
|
|
|
849
1179
|
// Check cache first
|
|
850
1180
|
if (REDIS_ENABLED) {
|
|
851
1181
|
const cachedData = await getArrayItem(finalCacheName);
|
|
852
|
-
if (cachedData
|
|
1182
|
+
if (hasRows(cachedData)) {
|
|
853
1183
|
return cachedData;
|
|
854
1184
|
}
|
|
855
1185
|
}
|
|
856
1186
|
|
|
857
|
-
// Execute in transaction
|
|
1187
|
+
// Execute in transaction; its result is cached after COMMIT (a copy, as the
|
|
1188
|
+
// caller may change the rows it gets meanwhile)
|
|
858
1189
|
trackCallerSql(connection, sql);
|
|
859
1190
|
const [data] = await connection.query(sql, parameters);
|
|
860
|
-
if (REDIS_ENABLED) {
|
|
861
|
-
|
|
1191
|
+
if (REDIS_ENABLED && hasRows(data)) {
|
|
1192
|
+
fillBuffer.set(finalCacheName, cloneResult(data));
|
|
862
1193
|
}
|
|
863
1194
|
|
|
864
1195
|
return data;
|
|
@@ -880,7 +1211,13 @@ module.exports = {
|
|
|
880
1211
|
result = await callback(tx);
|
|
881
1212
|
|
|
882
1213
|
// Commit transaction
|
|
883
|
-
|
|
1214
|
+
try {
|
|
1215
|
+
await connection.commit();
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
commitFailed = true;
|
|
1218
|
+
commitError = error;
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
884
1221
|
forgetInflightReads();
|
|
885
1222
|
} catch (error) {
|
|
886
1223
|
// Rollback on error
|
|
@@ -891,13 +1228,23 @@ module.exports = {
|
|
|
891
1228
|
console.error('Rollback error:', rollbackError);
|
|
892
1229
|
}
|
|
893
1230
|
}
|
|
894
|
-
throw error;
|
|
1231
|
+
if (!commitFailed) throw error;
|
|
895
1232
|
} finally {
|
|
896
1233
|
if (connection) {
|
|
897
1234
|
connection.release();
|
|
898
1235
|
}
|
|
899
1236
|
}
|
|
900
1237
|
|
|
1238
|
+
if (commitFailed) {
|
|
1239
|
+
// The COMMIT may have been applied (e.g. only its reply was lost): invalidate as after
|
|
1240
|
+
// a successful one. The buffered fills are dropped.
|
|
1241
|
+
forgetInflightReads();
|
|
1242
|
+
if (REDIS_ENABLED) {
|
|
1243
|
+
await invalidateAfterFailure([...new Set(invalidationBuffer)]);
|
|
1244
|
+
}
|
|
1245
|
+
throw commitError;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
901
1248
|
// Apply buffered cache invalidations on successful commit, after the connection is
|
|
902
1249
|
// back in the pool (a SCAN-based invalidation can take a while on large keyspaces)
|
|
903
1250
|
if (REDIS_ENABLED && invalidationBuffer.length > 0) {
|
|
@@ -906,6 +1253,12 @@ module.exports = {
|
|
|
906
1253
|
await invalidate(uniquePatterns);
|
|
907
1254
|
}
|
|
908
1255
|
|
|
1256
|
+
// Then the buffered fills, through the stale-fill guard: a fill is skipped when its key was
|
|
1257
|
+
// invalidated (by this transaction or anything else) since the transaction started
|
|
1258
|
+
if (fillBuffer.size > 0) {
|
|
1259
|
+
await Promise.all([...fillBuffer].map(([key, data]) => fillCache(key, data, sequence)));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
909
1262
|
return result;
|
|
910
1263
|
}
|
|
911
1264
|
};
|