spice-couchbase 2.0.64 → 2.0.65

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/src/index.js CHANGED
@@ -4,7 +4,15 @@ let _ = require("lodash");
4
4
  let couchbase = require("couchbase");
5
5
  const { ConflictResolutionType } = require("couchbase");
6
6
  const { QueryScanConsistency } = require("couchbase");
7
- const { parseResourceList, extractStatementResources } = require("./utility");
7
+ const {
8
+ parseResourceList,
9
+ extractStatementResources,
10
+ statementShape,
11
+ usesManagedIndex,
12
+ renderIndexKey,
13
+ BASE_INDEX,
14
+ MANAGED_INDEX_NAME,
15
+ } = require("./utility");
8
16
  const { createHealthMonitor, openHandles } = require("./connectionHealth");
9
17
 
10
18
  if (!Promise.allSettled) {
@@ -71,6 +79,11 @@ const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
71
79
  process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
72
80
  );
73
81
  let autoOptimizePolicy = null;
82
+ // When the application installs a handler, slow statements are handed to it
83
+ // instead of creating indexes here: it owns every index decision, with its
84
+ // own key rules, caps and build windows. Without one, the legacy covering
85
+ // index path below still runs, so other applications keep their behaviour.
86
+ let slowStatementHandler = null;
74
87
 
75
88
  // One health monitor per process, shared by every copy of this module that
76
89
  // ends up loaded. See connectionHealth.js.
@@ -168,9 +181,39 @@ function buildQueryOptions(queryOptions = {}) {
168
181
  options.scanWait = numericScanWait;
169
182
  }
170
183
 
184
+ // Reported as clientContextID in system:completed_requests, which is how a
185
+ // slow statement is traced back to the route or job that ran it.
186
+ const clientContextId =
187
+ queryOptions.clientContextId ?? queryOptions.client_context_id;
188
+ if (clientContextId !== undefined && clientContextId !== null && clientContextId !== "") {
189
+ options.clientContextId = String(clientContextId).slice(0, 64);
190
+ }
191
+
171
192
  return options;
172
193
  }
173
194
 
195
+ function wantsScanWaitFallback(query_options) {
196
+ return (
197
+ query_options &&
198
+ typeof query_options === "object" &&
199
+ (query_options.scan_wait_fallback === true ||
200
+ query_options.scanWaitFallback === true)
201
+ );
202
+ }
203
+
204
+ // The query service reports an expired scan_wait as an index scan timeout
205
+ // (12015), which is a query error, not a connectivity timeout.
206
+ function isScanWaitExpired(error) {
207
+ const code =
208
+ error?.cause?.first_error_code ?? error?.context?.first_error_code;
209
+ const message =
210
+ error?.cause?.first_error_message ||
211
+ error?.context?.first_error_message ||
212
+ error?.message ||
213
+ "";
214
+ return code === 12015 || /index scan timed out|scan.{0,20}timed? ?out/i.test(message);
215
+ }
216
+
174
217
  module.exports = class Database {
175
218
  constructor(
176
219
  connection,
@@ -398,6 +441,38 @@ module.exports = class Database {
398
441
  return autoOptimizePolicy;
399
442
  }
400
443
 
444
+ /**
445
+ * Hands slow statements to the application instead of creating covering
446
+ * indexes here. `handler({ statement, shape, plan, resources, connection,
447
+ * bucket, scope, collection, duration_ms })` runs in the background, once
448
+ * per statement shape, after the resource opt-in policy has allowed it and
449
+ * only when the plan is not already served by a managed index. Pass null to
450
+ * restore the legacy behaviour.
451
+ */
452
+ static setSlowStatementHandler(handler) {
453
+ if (
454
+ handler !== null &&
455
+ handler !== undefined &&
456
+ typeof handler !== "function"
457
+ ) {
458
+ throw new TypeError("Slow statement handler must be a function or null");
459
+ }
460
+ slowStatementHandler = handler || null;
461
+ }
462
+
463
+ static getSlowStatementHandler() {
464
+ return slowStatementHandler;
465
+ }
466
+
467
+ /** The fallback index every collection gets in place of a primary index. */
468
+ static get BASE_INDEX() {
469
+ return BASE_INDEX;
470
+ }
471
+
472
+ static get MANAGED_INDEX_NAME() {
473
+ return MANAGED_INDEX_NAME;
474
+ }
475
+
401
476
  /**
402
477
  * Resources a statement touches, plus this instance's collection: that is
403
478
  * the keyspace `optimize()` would create the index on.
@@ -543,7 +618,19 @@ module.exports = class Database {
543
618
  if (!collection_exists) {
544
619
  await this.createCollection(this.collection_name, this.scope_name);
545
620
  await this.wait(2000);
546
- await this.createPrimaryIndex(this.scope_name, this.collection_name);
621
+ // `primary_index: false` on the connection replaces the primary
622
+ // index with the base secondary index. A new collection is empty,
623
+ // so building it straight away costs nothing.
624
+ if (this.db_config?.primary_index === false) {
625
+ await this.createIndex({
626
+ ...BASE_INDEX,
627
+ defer_build: false,
628
+ collection: this.collection_name,
629
+ scope: this.scope_name,
630
+ });
631
+ } else {
632
+ await this.createPrimaryIndex(this.scope_name, this.collection_name);
633
+ }
547
634
  }
548
635
  }
549
636
  } catch (e) {
@@ -656,6 +743,121 @@ module.exports = class Database {
656
743
  }
657
744
  }
658
745
 
746
+ // Index management. Every statement here is DDL or a catalog read against a
747
+ // fully qualified keyspace, run at cluster level and outside `query()` so a
748
+ // slow build never feeds back into the slow-statement path.
749
+
750
+ async runDdl(statement, options = {}) {
751
+ return this.observe(this.cluster.query(statement, options));
752
+ }
753
+
754
+ /**
755
+ * Every index on a collection, from system:indexes, including the legacy
756
+ * catalog form of the default collection. Rows carry name, index_key,
757
+ * condition, state, is_primary, using.
758
+ */
759
+ async listIndexes({
760
+ collection = this.collection_name,
761
+ scope = this.scope_name,
762
+ } = {}) {
763
+ const response = await this.runDdl(
764
+ `SELECT RAW i FROM system:indexes AS i
765
+ WHERE i.namespace_id = 'default'
766
+ AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)
767
+ OR ($scope = '_default' AND $collection = '_default'
768
+ AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))`,
769
+ {
770
+ parameters: {
771
+ bucket: this.db_config.bucket,
772
+ scope,
773
+ collection,
774
+ },
775
+ },
776
+ );
777
+ // A failed or partial inventory is not evidence that no index exists.
778
+ if (!Array.isArray(response?.rows) || response?.meta?.warnings?.length) {
779
+ throw new Error(`Cannot read the index inventory for ${scope}.${collection}`);
780
+ }
781
+ return response.rows;
782
+ }
783
+
784
+ /**
785
+ * `CREATE INDEX IF NOT EXISTS`. Deferred by default so several indexes on
786
+ * one collection can be built together with `buildIndexes`, in one pass
787
+ * over the data.
788
+ */
789
+ async createIndex({
790
+ name,
791
+ keys,
792
+ where,
793
+ defer_build = true,
794
+ num_replica,
795
+ collection = this.collection_name,
796
+ scope = this.scope_name,
797
+ } = {}) {
798
+ if (!name || !Array.isArray(keys) || keys.length === 0) {
799
+ throw new TypeError("createIndex needs a name and at least one key");
800
+ }
801
+ const withClause = { defer_build: Boolean(defer_build) };
802
+ if (Number.isInteger(num_replica) && num_replica >= 0) {
803
+ withClause.num_replica = num_replica;
804
+ }
805
+ const statement =
806
+ `CREATE INDEX IF NOT EXISTS ${this.quoteIdentifier(name)} ` +
807
+ `ON ${this.collectionPath(scope, collection)}(${keys.map(renderIndexKey).join(", ")})` +
808
+ (where ? ` WHERE ${where}` : "") +
809
+ ` WITH ${JSON.stringify(withClause)}`;
810
+ await this.runDdl(statement);
811
+ return statement;
812
+ }
813
+
814
+ /** Builds deferred indexes on one collection together. */
815
+ async buildIndexes(names, {
816
+ collection = this.collection_name,
817
+ scope = this.scope_name,
818
+ } = {}) {
819
+ const list = [...new Set(names || [])];
820
+ if (list.length === 0) return null;
821
+ const statement =
822
+ `BUILD INDEX ON ${this.collectionPath(scope, collection)}` +
823
+ `(${list.map((name) => this.quoteIdentifier(name)).join(", ")})`;
824
+ await this.runDdl(statement);
825
+ return statement;
826
+ }
827
+
828
+ async dropIndex(name, {
829
+ collection = this.collection_name,
830
+ scope = this.scope_name,
831
+ } = {}) {
832
+ const statement =
833
+ `DROP INDEX IF EXISTS ${this.quoteIdentifier(name)} ON ${this.collectionPath(scope, collection)}`;
834
+ await this.runDdl(statement);
835
+ return statement;
836
+ }
837
+
838
+ async dropPrimaryIndex({
839
+ collection = this.collection_name,
840
+ scope = this.scope_name,
841
+ } = {}) {
842
+ const statement = `DROP PRIMARY INDEX IF EXISTS ON ${this.collectionPath(scope, collection)}`;
843
+ await this.runDdl(statement);
844
+ return statement;
845
+ }
846
+
847
+ /** The plan for a statement, resolved in this instance's scope by default. */
848
+ async explain(statement, query_scope = this.use_bucket_name ? "cluster" : "scope") {
849
+ const exec_scope = query_scope === "cluster" ? this.cluster : this.scope;
850
+ const response = await this.observe(exec_scope.query(`EXPLAIN ${statement}`));
851
+ return response?.rows?.[0];
852
+ }
853
+
854
+ /** Couchbase's own index advice for a statement (the ADVISE statement). */
855
+ async advise(statement, query_scope = this.use_bucket_name ? "cluster" : "scope") {
856
+ const exec_scope = query_scope === "cluster" ? this.cluster : this.scope;
857
+ const response = await this.observe(exec_scope.query(`ADVISE ${statement}`));
858
+ return response?.rows?.[0];
859
+ }
860
+
659
861
  async get(doc_id, { columns = [] } = {}) {
660
862
  try {
661
863
  const useLookupIn = columns.length > 0;
@@ -1209,9 +1411,31 @@ module.exports = class Database {
1209
1411
  // Start time
1210
1412
  let startTime = process.hrtime.bigint();
1211
1413
  const options = buildQueryOptions(query_options);
1212
- let response = await this.observe(
1213
- exec_scope.query(sanitizedQuery, options),
1214
- );
1414
+ let response;
1415
+ try {
1416
+ response = await this.observe(exec_scope.query(sanitizedQuery, options));
1417
+ } catch (error) {
1418
+ // `scan_wait_fallback`: a request_plus read that could not get a fresh
1419
+ // index snapshot within `scan_wait` runs once more without waiting, for
1420
+ // callers that prefer a slightly stale answer to an error.
1421
+ if (
1422
+ !wantsScanWaitFallback(query_options) ||
1423
+ options.scanConsistency !== QueryScanConsistency.RequestPlus ||
1424
+ options.scanWait === undefined ||
1425
+ !isScanWaitExpired(error)
1426
+ ) {
1427
+ throw error;
1428
+ }
1429
+ console.warn(
1430
+ `[spice-couchbase] index not current within ${options.scanWait}ms; reading without waiting`,
1431
+ );
1432
+ response = await this.observe(
1433
+ exec_scope.query(sanitizedQuery, {
1434
+ ..._.omit(options, ["scanWait"]),
1435
+ scanConsistency: QueryScanConsistency.NotBounded,
1436
+ }),
1437
+ );
1438
+ }
1215
1439
  // End time
1216
1440
  let endTime = process.hrtime.bigint();
1217
1441
  // Calculate duration in microseconds
@@ -1220,11 +1444,49 @@ module.exports = class Database {
1220
1444
  // declared at the top of this file. Never awaited, never allowed to reject.
1221
1445
  if (
1222
1446
  durationInMilliseconds > SLOW_QUERY_MS &&
1223
- this.shouldAutoOptimize(sanitizedQuery, query_options) &&
1224
- shouldOptimize(sanitizedQuery)
1447
+ this.shouldAutoOptimize(sanitizedQuery, query_options)
1225
1448
  ) {
1226
- this.optimize(sanitizedQuery, exec_scope).catch(() => {});
1449
+ if (slowStatementHandler) {
1450
+ if (shouldOptimize(statementShape(sanitizedQuery))) {
1451
+ this.reportSlowStatement(
1452
+ sanitizedQuery,
1453
+ exec_scope,
1454
+ Number(durationInMilliseconds),
1455
+ ).catch(() => {});
1456
+ }
1457
+ } else if (shouldOptimize(sanitizedQuery)) {
1458
+ this.optimize(sanitizedQuery, exec_scope).catch(() => {});
1459
+ }
1227
1460
  }
1228
1461
  return response;
1229
1462
  }
1463
+
1464
+ /**
1465
+ * Hands a slow statement to the application's handler, unless its plan is
1466
+ * already served by a managed index. Runs under the same in-flight and
1467
+ * cooldown guard rails as `optimize()`.
1468
+ */
1469
+ async reportSlowStatement(query, exec_scope, duration_ms) {
1470
+ optimizeState.inFlight += 1;
1471
+ try {
1472
+ const explainResponse = await exec_scope.query(`EXPLAIN ${query}`);
1473
+ const plan = explainResponse?.rows?.[0];
1474
+ if (usesManagedIndex(plan)) return;
1475
+ await slowStatementHandler({
1476
+ statement: query,
1477
+ shape: statementShape(query),
1478
+ plan,
1479
+ resources: [...this.statementResources(query)],
1480
+ connection: this.connection_name,
1481
+ bucket: this.bucket,
1482
+ scope: this.scope_name,
1483
+ collection: this.collection_name,
1484
+ duration_ms,
1485
+ });
1486
+ } catch (e) {
1487
+ optimizeState.cooldownUntil = Date.now() + OPTIMIZE_FAILURE_COOLDOWN_MS;
1488
+ } finally {
1489
+ optimizeState.inFlight -= 1;
1490
+ }
1491
+ }
1230
1492
  };
package/src/utility.js CHANGED
@@ -519,7 +519,85 @@ function extractStatementResources(query) {
519
519
  return names;
520
520
  }
521
521
 
522
+ // Names the application's index manager owns. A statement whose plan already
523
+ // scans one of these is being served by a deliberate index, so the slow-query
524
+ // path leaves it alone. `ix_base` is the per-collection fallback and does not
525
+ // count: a statement that only finds the fallback is exactly what to report.
526
+ const MANAGED_INDEX_NAME = /^ix_(sort|eq|agg|core|obs)_[0-9a-f]{6}_[0-9a-f]{4}$/;
527
+
528
+ // Replaces the primary index on every collection: usable by any statement
529
+ // because the leading key includes MISSING, and ordered for the default list
530
+ // sort (`created_at DESC`).
531
+ const BASE_INDEX = Object.freeze({
532
+ name: "ix_base",
533
+ keys: Object.freeze([
534
+ Object.freeze({ expr: "`created_at`", desc: true, include_missing: true }),
535
+ Object.freeze({ expr: "`deleted`" }),
536
+ ]),
537
+ });
538
+
539
+ /**
540
+ * The statement with its literals replaced by `?`, so one query shape is
541
+ * examined once however many values it runs with. IN lists collapse to `[?]`.
542
+ * @param {string} query
543
+ * @returns {string}
544
+ */
545
+ function statementShape(query) {
546
+ return String(query ?? "")
547
+ .replace(/'(?:[^'\\]|\\.|'')*'/g, "?")
548
+ .replace(/"(?:[^"\\]|\\.)*"/g, "?")
549
+ .replace(/\b\d+(?:\.\d+)?\b/g, "?")
550
+ .replace(/\[\s*\?(?:\s*,\s*\?)*\s*\]/g, "[?]")
551
+ .replace(/\s+/g, " ")
552
+ .trim();
553
+ }
554
+
555
+ /**
556
+ * Names of the indexes an EXPLAIN plan scans.
557
+ * @param {object} plan
558
+ * @returns {string[]}
559
+ */
560
+ function planIndexNames(plan) {
561
+ const names = new Set();
562
+ const visit = (node) => {
563
+ if (!node || typeof node !== "object") return;
564
+ if (Array.isArray(node)) {
565
+ node.forEach(visit);
566
+ return;
567
+ }
568
+ if (/Scan/.test(String(node["#operator"] || "")) && typeof node.index === "string") {
569
+ names.add(node.index);
570
+ }
571
+ Object.values(node).forEach(visit);
572
+ };
573
+ visit(plan);
574
+ return [...names];
575
+ }
576
+
577
+ function usesManagedIndex(plan) {
578
+ return planIndexNames(plan).some((name) => MANAGED_INDEX_NAME.test(name));
579
+ }
580
+
581
+ /**
582
+ * Renders one index key. Accepts a ready string or `{ expr, desc, include_missing }`.
583
+ * @param {string|{expr: string, desc?: boolean, include_missing?: boolean}} key
584
+ * @returns {string}
585
+ */
586
+ function renderIndexKey(key) {
587
+ if (typeof key === "string") return key;
588
+ if (!key || typeof key.expr !== "string" || !key.expr.trim()) {
589
+ throw new TypeError("An index key needs an expression");
590
+ }
591
+ return `${key.expr}${key.desc ? " DESC" : ""}${key.include_missing ? " INCLUDE MISSING" : ""}`;
592
+ }
593
+
522
594
  module.exports = {
595
+ MANAGED_INDEX_NAME,
596
+ BASE_INDEX,
597
+ statementShape,
598
+ planIndexNames,
599
+ usesManagedIndex,
600
+ renderIndexKey,
523
601
  buildCoveringIndex,
524
602
  buildCoveringIndexDefinition,
525
603
  indexSatisfiesCoveringDefinition,