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/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 { getArrayItem, addArrayItem, delPrefixKeyItem } = require('./redis.Connector');
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). Matching is deliberately broad: a false positive only costs one USE.
141
- const MAY_SWITCH_DATABASE = /\buse\b/i;
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
- connection.release();
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). Uses its own short-lived
179
- * connection, because the pool may be exhausted exactly when queries time out.
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
- async function killRunningQuery(connection) {
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
- const PLAIN_SELECT = /^\s*\(?\s*SELECT\b/i;
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
- const body = sql.replace(/[\s;]+$/, '');
248
- return PLAIN_SELECT.test(body) && body.indexOf(';') === -1;
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
- copy[key] = cloneResult(value[key]);
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
- let shared;
304
- try {
305
- shared = await inflight;
306
- } catch (_) {
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
- const pending = load();
313
- inflightLoads.set(key, pending);
314
- try {
315
- return await pending;
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.length > 0) {
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 addArrayItem(finalCacheName, data);
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
- const uniqueCacheKey = `${cacheName}:page${validPage}:size${validPageSize}`;
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 (REDIS_ENABLED) {
812
+ if (useCache) {
541
813
  const cachedData = await getArrayItem(uniqueCacheKey);
542
- if (typeof cachedData === 'object' && !Array.isArray(cachedData) && cachedData !== null) {
814
+ if (isPageResult(cachedData)) {
543
815
  return cachedData;
544
816
  }
545
817
  }
546
818
 
547
- // Prepare SQL: remove trailing semicolon(s) and whitespace
548
- const cleanSql = sql.trim().replace(/;+\s*$/, '');
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 = REDIS_ENABLED && REUSE_PAGINATION_TOTAL
557
- ? `${cacheName}:total:${paginationTotalHash(db, cleanSql, parameters)}`
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
- const paginatedSql = `${cleanSql} LIMIT ?, ?`;
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 (REDIS_ENABLED) {
599
- await addArrayItem(uniqueCacheKey, result);
878
+ if (useCache) {
879
+ await fillCache(uniqueCacheKey, result, sequence);
600
880
  if (totalKey && totalCount !== storedTotal) {
601
- await addArrayItem(totalKey, [totalCount]);
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) await connection.commit();
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 (resetCacheName && REDIS_ENABLED) {
686
- await delPrefixKeyItem(resetCacheName);
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 (REDIS_ENABLED) {
717
- const cachedData = await getArrayItem(cacheName);
718
- if (cachedData.length > 0) {
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 (REDIS_ENABLED && data !== undefined && !(current.timedOut && cancelOnTimeout)) {
727
- await addArrayItem(cacheName, data);
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.length > 0) {
1182
+ if (hasRows(cachedData)) {
853
1183
  return cachedData;
854
1184
  }
855
1185
  }
856
1186
 
857
- // Execute in transaction and cache result
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
- await addArrayItem(finalCacheName, data);
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
- await connection.commit();
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
  };