spice-couchbase 2.0.60 → 2.0.62

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.
@@ -0,0 +1,393 @@
1
+ "use strict";
2
+
3
+ const assert = require("node:assert/strict");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const vm = require("node:vm");
7
+ const { test } = require("node:test");
8
+ const { buildCoveringIndex, buildCoveringIndexDefinition } = require("./utility");
9
+
10
+ const keyspace = { bucket: "saturn", scope: "test", collection: "users" };
11
+ const lookupQuery = "SELECT name, email FROM users WHERE status = 'active'";
12
+
13
+ function existingIndex(index_key, overrides = {}) {
14
+ return { name: "legacy_covering_index", using: "gsi", state: "online", index_key, ...overrides };
15
+ }
16
+
17
+ function executionScope(indexes = [], { inventoryError, inventoryResponse, plan } = {}) {
18
+ const calls = [];
19
+ return {
20
+ calls,
21
+ async query(statement, options) {
22
+ calls.push({ statement, options });
23
+ if (statement.startsWith("EXPLAIN ")) {
24
+ return { rows: [plan || { "#operator": "Fetch" }] };
25
+ }
26
+ if (statement.includes("FROM system:indexes")) {
27
+ if (inventoryError) throw inventoryError;
28
+ return inventoryResponse === undefined ? { rows: indexes } : inventoryResponse;
29
+ }
30
+ return { rows: [] };
31
+ },
32
+ };
33
+ }
34
+
35
+ function createStatements(scope) {
36
+ return scope.calls.filter(({ statement }) => statement.startsWith("CREATE INDEX "));
37
+ }
38
+
39
+ // Isolate optimizer state and time without connecting to Couchbase or sleeping.
40
+ // Each load is a fresh module: its own seen-set, cooldown and policy.
41
+ function loadDriver(durationMs, env = {}) {
42
+ let tick = BigInt(0);
43
+ const module = { exports: {} };
44
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
45
+ require,
46
+ module,
47
+ console,
48
+ process: {
49
+ env,
50
+ hrtime: {
51
+ bigint: () => {
52
+ const current = tick;
53
+ tick += BigInt(durationMs) * BigInt(1000000);
54
+ return current;
55
+ },
56
+ },
57
+ },
58
+ });
59
+ return module.exports;
60
+ }
61
+
62
+ function instanceOf(Database) {
63
+ const db = Object.create(Database.prototype);
64
+ db.bucket = "saturn";
65
+ db.scope_name = "test";
66
+ db.collection_name = "users";
67
+ db.cluster = { query: async () => ({ rows: [] }) };
68
+ return db;
69
+ }
70
+
71
+ // Opted in wholesale: per-resource resolution has its own tests further down.
72
+ function createDatabase(durationMs, env = {}) {
73
+ const db = instanceOf(loadDriver(durationMs, env));
74
+ db.auto_optimize = true;
75
+ return db;
76
+ }
77
+
78
+ test("auto-optimization only starts above the default 3-second threshold", async () => {
79
+ for (const duration of [1001, 2999, 3000, 3001]) {
80
+ const db = createDatabase(duration);
81
+ let optimizations = 0;
82
+ db.optimize = async () => { optimizations += 1; };
83
+ await db.query("SELECT name FROM users WHERE status = 'active'");
84
+ assert.equal(optimizations, duration > 3000 ? 1 : 0, `${duration}ms`);
85
+ }
86
+ });
87
+
88
+ test("the explicit slow-query threshold override remains supported", async () => {
89
+ const db = createDatabase(5001, { SPICE_COUCHBASE_SLOW_QUERY_MS: "5000" });
90
+ let optimizations = 0;
91
+ db.optimize = async () => { optimizations += 1; };
92
+ await db.query("SELECT name FROM users WHERE status = 'active'");
93
+ assert.equal(optimizations, 1);
94
+ });
95
+
96
+ test("Fetch plans for full-document projections never create indexes", async () => {
97
+ for (const projection of ["*", "users.*", "`users`.*", "(`users`).*, META(`users`).id AS id"]) {
98
+ const db = createDatabase(0);
99
+ const statements = [];
100
+ const query = `SELECT ${projection} FROM users WHERE status = 'active' ORDER BY created_at DESC`;
101
+ await db.optimize(query, {
102
+ query: async (statement) => {
103
+ statements.push(statement);
104
+ return { rows: [{ "#operator": "Fetch" }] };
105
+ },
106
+ });
107
+ assert.deepEqual(statements, [`EXPLAIN ${query}`]);
108
+ }
109
+ });
110
+
111
+ test("Fetch plans for explicit fields still create a covering index", async () => {
112
+ const db = createDatabase(0);
113
+ const scope = executionScope();
114
+ await db.optimize("SELECT name FROM users WHERE status = 'active'", scope);
115
+ assert.equal(scope.calls.length, 3);
116
+ assert.match(scope.calls[1].statement, /FROM system:indexes/);
117
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify(keyspace));
118
+ assert.match(scope.calls[2].statement, /^CREATE INDEX `adv_covered_/);
119
+ assert.match(scope.calls[2].statement, /`status`, `name`/);
120
+ });
121
+
122
+ test("reordered selected columns and changed literals generate identical DDL", () => {
123
+ const original = buildCoveringIndex(lookupQuery, keyspace);
124
+ const reordered = buildCoveringIndex("SELECT email, name FROM users WHERE status = 'pending'", keyspace);
125
+ assert.equal(original, reordered);
126
+ });
127
+
128
+ test("adding explicit ascending order cannot rename an identical definition", () => {
129
+ const base = "SELECT status, created_at FROM users WHERE status = 'pending'";
130
+ assert.equal(buildCoveringIndex(base, keyspace), buildCoveringIndex(`${base} ORDER BY created_at ASC`, keyspace));
131
+ });
132
+
133
+ test("genuinely different access-key order and direction keep distinct definitions", () => {
134
+ const base = "SELECT name, email FROM users WHERE status = 'active'";
135
+ const asc = buildCoveringIndex(`${base} ORDER BY name, email`, keyspace);
136
+ const desc = buildCoveringIndex(`${base} ORDER BY name DESC, email`, keyspace);
137
+ const reordered = buildCoveringIndex(`${base} ORDER BY email, name`, keyspace);
138
+ assert.notEqual(asc, desc);
139
+ assert.notEqual(asc, reordered);
140
+ assert.notEqual(asc.match(/adv_covered_\w+/)[0], reordered.match(/adv_covered_\w+/)[0]);
141
+ });
142
+
143
+ test("case-distinct document fields are not collapsed", () => {
144
+ const statement = buildCoveringIndex("SELECT Name, name, Status, status FROM users ORDER BY Name DESC", keyspace);
145
+ assert.match(statement, /`Name` DESC/);
146
+ assert.match(statement, /`name`/);
147
+ assert.match(statement, /`Status`/);
148
+ assert.match(statement, /`status`/);
149
+ assert.doesNotMatch(statement, /`name` DESC/);
150
+ });
151
+
152
+ for (const state of ["online", "deferred", "pending", "building", "offline"]) {
153
+ test(`an existing ${state} index with reordered projection keys prevents creation`, async () => {
154
+ const scope = executionScope([
155
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { state }),
156
+ ]);
157
+ await createDatabase(0).optimize(lookupQuery, scope);
158
+ assert.equal(scope.calls.length, 2);
159
+ assert.equal(createStatements(scope).length, 0);
160
+ });
161
+ }
162
+
163
+ test("a wider existing index covers a projection subset without another index", async () => {
164
+ const scope = executionScope([
165
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`phone`", "`email` DESC"]),
166
+ ]);
167
+ await createDatabase(0).optimize(lookupQuery, scope);
168
+ assert.equal(createStatements(scope).length, 0);
169
+ });
170
+
171
+ test("catalog key formatting, explicit ASC and attribute order are normalized", async () => {
172
+ for (const leadingKey of ["((`deleted`)) ASC INCLUDE MISSING", "deleted INCLUDE MISSING ASC"]) {
173
+ const scope = executionScope([
174
+ existingIndex([leadingKey, "(status) ASC", "`name`", "email ASC"]),
175
+ ]);
176
+ await createDatabase(0).optimize(lookupQuery, scope);
177
+ assert.equal(createStatements(scope).length, 0, leadingKey);
178
+ }
179
+ });
180
+
181
+ const incompatibleIndexes = [
182
+ ["missing a projection field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`"])],
183
+ ["different access prefix", existingIndex(["`deleted` INCLUDE MISSING", "`name`", "`status`", "`email`"])],
184
+ ["missing-leading-key exclusion", existingIndex(["`deleted`", "`status`", "`name`", "`email`"])],
185
+ ["case-distinct field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`Name`", "`email`"])],
186
+ ["expression instead of the field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "LOWER(`name`)", "`email`"])],
187
+ ["array key", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`", "DISTINCT ARRAY v FOR v IN tags END"])],
188
+ ["partial index", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { condition: "(`status` = 'pending')" })],
189
+ ["primary index", existingIndex([], { is_primary: true })],
190
+ ];
191
+ for (const [reason, index] of incompatibleIndexes) {
192
+ test(`does not mistake ${reason} for a compatible covering index`, async () => {
193
+ const scope = executionScope([index]);
194
+ await createDatabase(0).optimize(lookupQuery, scope);
195
+ assert.equal(createStatements(scope).length, 1);
196
+ });
197
+ }
198
+
199
+ test("existing opposite sort direction does not satisfy the requested sort", async () => {
200
+ const query = `${lookupQuery} ORDER BY created_at DESC`;
201
+ const scope = executionScope([
202
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`created_at`", "`name`", "`email`"]),
203
+ ]);
204
+ await createDatabase(0).optimize(query, scope);
205
+ assert.equal(createStatements(scope).length, 1);
206
+ assert.match(createStatements(scope)[0].statement, /`created_at` DESC/);
207
+ });
208
+
209
+ test("same generated name is never recreated even if its keys are unavailable", async () => {
210
+ const candidate = buildCoveringIndexDefinition(lookupQuery, keyspace);
211
+ const scope = executionScope([{ name: candidate.name, state: "building" }]);
212
+ await createDatabase(0).optimize(lookupQuery, scope);
213
+ assert.equal(createStatements(scope).length, 0);
214
+ });
215
+
216
+ test("catalog failure or incomplete results prevent creation and trigger cooldown", async () => {
217
+ for (const options of [
218
+ { inventoryError: new Error("query service unavailable") },
219
+ { inventoryResponse: {} },
220
+ { inventoryResponse: { rows: [], meta: { warnings: [{ message: "partial inventory" }] } } },
221
+ ]) {
222
+ const db = createDatabase(3001);
223
+ const scope = executionScope([], options);
224
+ await db.optimize(lookupQuery, scope);
225
+ assert.equal(createStatements(scope).length, 0);
226
+ let retries = 0;
227
+ db.optimize = async () => { retries++; };
228
+ await db.query("SELECT email FROM users WHERE status = 'pending'");
229
+ assert.equal(retries, 0);
230
+ }
231
+ });
232
+
233
+ test("already-covered plans need neither an inventory lookup nor creation", async () => {
234
+ const scope = executionScope([], { plan: { "#operator": "IndexScan3" } });
235
+ await createDatabase(0).optimize(lookupQuery, scope);
236
+ assert.equal(scope.calls.length, 1);
237
+ });
238
+
239
+ test("a default collection uses default names in its inventory lookup", async () => {
240
+ const db = createDatabase(0);
241
+ db.scope_name = undefined;
242
+ db.collection_name = undefined;
243
+ const scope = executionScope();
244
+ await db.optimize("SELECT name FROM saturn WHERE status = 'active'", scope);
245
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify({
246
+ bucket: "saturn", scope: "_default", collection: "_default",
247
+ }));
248
+ assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
249
+ });
250
+
251
+ // ---- Per-resource opt-in for auto cover indexing ----
252
+
253
+ // Nothing decided at instance level: resolution falls through to the call,
254
+ // the application policy and the static lists.
255
+ function createUnconfiguredDatabase(env = {}) {
256
+ return instanceOf(loadDriver(3001, env));
257
+ }
258
+
259
+ async function optimizationsFor(db, statement, ...queryArgs) {
260
+ let optimizations = 0;
261
+ db.optimize = async () => { optimizations += 1; };
262
+ await db.query(statement, ...queryArgs);
263
+ return optimizations;
264
+ }
265
+
266
+ const slowLookup = "SELECT name FROM users WHERE status = 'active'";
267
+ const EVERY_RESOURCE = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "*" };
268
+
269
+ test("no resource is auto-indexed unless something opts it in", async () => {
270
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup), 0);
271
+ assert.equal(await optimizationsFor(
272
+ createUnconfiguredDatabase({ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "" }), slowLookup), 0);
273
+ });
274
+
275
+ test("SPICE_COUCHBASE_OPTIMIZE_RESOURCES enables the instance's collection, case-insensitively", async () => {
276
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: " Users ,audit_log,, `sessions` " };
277
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(env), slowLookup), 1);
278
+
279
+ const sessions = createUnconfiguredDatabase(env);
280
+ sessions.collection_name = "sessions";
281
+ assert.equal(await optimizationsFor(sessions, "SELECT id FROM sessions"), 1);
282
+
283
+ const orders = createUnconfiguredDatabase(env);
284
+ orders.collection_name = "orders";
285
+ assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
286
+ });
287
+
288
+ test("a wildcard restores auto indexing for every resource", async () => {
289
+ const db = createUnconfiguredDatabase(EVERY_RESOURCE);
290
+ db.collection_name = "anything";
291
+ assert.equal(await optimizationsFor(db, "SELECT a FROM anything WHERE b = 1"), 1);
292
+ });
293
+
294
+ test("legacy bucket mode resolves the resource from the type literal the driver adds", async () => {
295
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "audit_log" };
296
+ for (const [type, expected] of [["audit_log", 1], ["users", 0]]) {
297
+ const db = createUnconfiguredDatabase(env);
298
+ db.collection_name = "_default";
299
+ db.scope_name = "_default";
300
+ assert.equal(await optimizationsFor(db,
301
+ `SELECT actor FROM \`saturn\` WHERE type = '${type}' AND deleted = false`), expected, type);
302
+ }
303
+ });
304
+
305
+ test("a fully qualified or aliased FROM keyspace matches on its last segment", async () => {
306
+ const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "logs" };
307
+ for (const statement of [
308
+ "SELECT level FROM `default`:`saturn`.`app`.`logs` WHERE level = 'error'",
309
+ "SELECT l.level FROM saturn.app.logs AS l WHERE l.level = 'error'",
310
+ "SELECT u.name FROM users AS u JOIN `logs` AS l ON l.user_id = META(u).id",
311
+ ]) {
312
+ const db = createUnconfiguredDatabase(env);
313
+ db.collection_name = "_default";
314
+ assert.equal(await optimizationsFor(db, statement), 1, statement);
315
+ }
316
+ });
317
+
318
+ test("connection or constructor lists work without the environment variable", async () => {
319
+ const db = createUnconfiguredDatabase();
320
+ db.enabled_resources = new Set(["users"]);
321
+ assert.equal(await optimizationsFor(db, slowLookup), 1);
322
+ });
323
+
324
+ test("an explicit instance setting overrides every resource list", async () => {
325
+ const on = createUnconfiguredDatabase();
326
+ on.auto_optimize = true;
327
+ assert.equal(await optimizationsFor(on, slowLookup), 1);
328
+
329
+ const off = createUnconfiguredDatabase(EVERY_RESOURCE);
330
+ off.auto_optimize = false;
331
+ assert.equal(await optimizationsFor(off, slowLookup), 0);
332
+ });
333
+
334
+ test("a single call can opt in or out through query options", async () => {
335
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, { auto_optimize: true }), 1);
336
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, { auto_optimize: false }), 0);
337
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", { optimize: false }), 0);
338
+ // The scan-consistency shorthand is not an opt-in or opt-out.
339
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", true), 1);
340
+ assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, "cluster", true), 0);
341
+ });
342
+
343
+ test("an application policy decides per resource and sees the statement context", async () => {
344
+ const Database = loadDriver(3001);
345
+ const seen = [];
346
+ Database.setAutoOptimizePolicy((resource, context) => {
347
+ seen.push([resource, context]);
348
+ return resource === "users";
349
+ });
350
+ assert.equal(Database.getAutoOptimizePolicy().length, 2);
351
+
352
+ const users = instanceOf(Database);
353
+ users.connection_name = "default";
354
+ assert.equal(await optimizationsFor(users, slowLookup), 1);
355
+ // Objects cross the vm boundary, so compare by value rather than prototype.
356
+ assert.equal(JSON.stringify(seen[0]), JSON.stringify(["users", { query: slowLookup, connection: "default", collection: "users" }]));
357
+
358
+ const orders = instanceOf(Database);
359
+ orders.collection_name = "orders";
360
+ assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
361
+ });
362
+
363
+ test("a policy that returns undefined defers to the static lists", async () => {
364
+ for (const [env, expected] of [[{ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "users" }, 1], [{}, 0]]) {
365
+ const Database = loadDriver(3001, env);
366
+ Database.setAutoOptimizePolicy(() => undefined);
367
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), expected);
368
+ }
369
+ });
370
+
371
+ test("an explicit policy refusal wins over the wildcard, and a throwing policy counts as a refusal", async () => {
372
+ for (const policy of [() => false, () => { throw new Error("settings unavailable"); }]) {
373
+ const Database = loadDriver(3001, EVERY_RESOURCE);
374
+ Database.setAutoOptimizePolicy(policy);
375
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 0);
376
+ }
377
+ });
378
+
379
+ test("the policy can be cleared and must be a function", async () => {
380
+ const Database = loadDriver(3001, EVERY_RESOURCE);
381
+ Database.setAutoOptimizePolicy(() => false);
382
+ Database.setAutoOptimizePolicy(null);
383
+ assert.equal(Database.getAutoOptimizePolicy(), null);
384
+ assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 1);
385
+ assert.throws(() => Database.setAutoOptimizePolicy("users"), { name: "TypeError" });
386
+ });
387
+
388
+ test("a declined statement is not remembered as already optimised", async () => {
389
+ const db = createUnconfiguredDatabase();
390
+ assert.equal(await optimizationsFor(db, slowLookup), 0);
391
+ db.auto_optimize = true;
392
+ assert.equal(await optimizationsFor(db, slowLookup), 1);
393
+ });
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ 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
8
 
8
9
  if (!Promise.allSettled) {
9
10
  Promise.allSettled = (promises) =>
@@ -25,12 +26,12 @@ if (!Promise.allSettled) {
25
26
  // Couchbase lookupIn has a limit of 16 operations per call
26
27
  const LOOKUP_IN_MAX_SPECS = 16;
27
28
 
28
- // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN plus
29
- // a possible CREATE INDEX) for any statement slower than SLOW_QUERY_MS. When
29
+ // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN,
30
+ // index inventory check, and possible CREATE INDEX) above SLOW_QUERY_MS. When
30
31
  // the cluster is degraded every statement is slow, so without limits each
31
32
  // query spawned two more heavy ones and deepened the outage. These caps make it
32
33
  // a bounded, once-per-statement background task that backs off on failure.
33
- const SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 1000);
34
+ const SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 3000);
34
35
  const AUTO_OPTIMIZE_ENABLED = !["0", "false", "no", "off"].includes(
35
36
  String(process.env.SPICE_COUCHBASE_AUTO_OPTIMIZE ?? "true")
36
37
  .trim()
@@ -50,6 +51,37 @@ const optimizeState = {
50
51
  seen: new Set(),
51
52
  cooldownUntil: 0,
52
53
  };
54
+ // Auto cover indexing is opt-in per resource. A resource is the collection a
55
+ // Database instance serves (scoped deployments) or the `type = '...'` literal
56
+ // legacy bucket mode adds to every statement; raw statements also count their
57
+ // FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
58
+ // optimised when, in order of precedence:
59
+ // 1. the call says so: query_options.auto_optimize (true / false)
60
+ // 2. the instance says so: constructor option or connection config
61
+ // `auto_optimize` (true / false)
62
+ // 3. the application policy: Database.setAutoOptimizePolicy(fn), where
63
+ // fn(resource, context) returns true / false, or
64
+ // undefined to defer to the static lists
65
+ // 4. a static list names a resource it touches: comma-separated
66
+ // SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
67
+ // pre-2.0.62 behaviour), or connection config / constructor option
68
+ // `auto_optimize_resources`.
69
+ const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
70
+ process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
71
+ );
72
+ let autoOptimizePolicy = null;
73
+
74
+ function requestedAutoOptimize(query_options) {
75
+ if (
76
+ !query_options ||
77
+ typeof query_options !== "object" ||
78
+ Array.isArray(query_options)
79
+ ) {
80
+ return undefined;
81
+ }
82
+ const value = query_options.auto_optimize ?? query_options.optimize;
83
+ return typeof value === "boolean" ? value : undefined;
84
+ }
53
85
 
54
86
  function shouldOptimize(query) {
55
87
  if (!AUTO_OPTIMIZE_ENABLED) return false;
@@ -126,6 +158,8 @@ module.exports = class Database {
126
158
  scope,
127
159
  query_scope = "cluster",
128
160
  auto_create_collection,
161
+ auto_optimize,
162
+ auto_optimize_resources,
129
163
  } = {},
130
164
  ) {
131
165
  if (connection) {
@@ -181,7 +215,94 @@ module.exports = class Database {
181
215
  this.use_bucket_name =
182
216
  spice.config.database.connections[this.connection_name].scope ==
183
217
  undefined;
218
+
219
+ // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
220
+ const configured_auto_optimize =
221
+ auto_optimize ?? this.db_config.auto_optimize;
222
+ this.auto_optimize =
223
+ typeof configured_auto_optimize === "boolean"
224
+ ? configured_auto_optimize
225
+ : undefined;
226
+ this.enabled_resources = parseResourceList([
227
+ ...parseResourceList(this.db_config.auto_optimize_resources),
228
+ ...parseResourceList(auto_optimize_resources),
229
+ ]);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Lets the application decide per resource whether slow statements may
235
+ * create covering indexes, e.g. from a per-resource setting an operator
236
+ * toggles at runtime. `policy(resource, { query, connection, collection })`
237
+ * returns true or false, or undefined to defer to the static lists. It is
238
+ * synchronous because it runs on the query path. Pass null to clear.
239
+ */
240
+ static setAutoOptimizePolicy(policy) {
241
+ if (
242
+ policy !== null &&
243
+ policy !== undefined &&
244
+ typeof policy !== "function"
245
+ ) {
246
+ throw new TypeError("Auto-optimize policy must be a function or null");
184
247
  }
248
+ autoOptimizePolicy = policy || null;
249
+ }
250
+
251
+ static getAutoOptimizePolicy() {
252
+ return autoOptimizePolicy;
253
+ }
254
+
255
+ /**
256
+ * Resources a statement touches, plus this instance's collection: that is
257
+ * the keyspace `optimize()` would create the index on.
258
+ */
259
+ statementResources(query) {
260
+ const resources = extractStatementResources(query);
261
+ if (this.collection_name && this.collection_name !== "_default") {
262
+ resources.add(String(this.collection_name).toLowerCase());
263
+ }
264
+ return resources;
265
+ }
266
+
267
+ /**
268
+ * True when a slow statement may create a covering index. See
269
+ * OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
270
+ */
271
+ shouldAutoOptimize(query, query_options = {}) {
272
+ const requested = requestedAutoOptimize(query_options);
273
+ if (requested !== undefined) return requested;
274
+ if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
275
+
276
+ const resources = this.statementResources(query);
277
+ if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
278
+ let deferred = false;
279
+ for (const resource of resources) {
280
+ let verdict;
281
+ try {
282
+ verdict = autoOptimizePolicy(resource, {
283
+ query,
284
+ connection: this.connection_name,
285
+ collection: this.collection_name,
286
+ });
287
+ } catch (e) {
288
+ // A broken policy must not create indexes nobody asked for.
289
+ verdict = false;
290
+ }
291
+ if (verdict === true) return true;
292
+ if (verdict === undefined || verdict === null) deferred = true;
293
+ }
294
+ if (!deferred) return false;
295
+ }
296
+
297
+ const enabled = new Set([
298
+ ...OPTIMIZE_ENABLED_RESOURCES,
299
+ ...(this.enabled_resources || []),
300
+ ]);
301
+ if (enabled.has("*")) return true;
302
+ for (const resource of resources) {
303
+ if (enabled.has(resource)) return true;
304
+ }
305
+ return false;
185
306
  }
186
307
 
187
308
  setGlobalConnectionObject(collection) {
@@ -856,7 +977,10 @@ module.exports = class Database {
856
977
  async optimize(query, exec_scope) {
857
978
  optimizeState.inFlight += 1;
858
979
  try {
859
- const { buildCoveringIndex } = require("./utility");
980
+ const {
981
+ buildCoveringIndexDefinition,
982
+ indexSatisfiesCoveringDefinition,
983
+ } = require("./utility");
860
984
 
861
985
  // 1. Run EXPLAIN to get the query plan
862
986
  const explainQuery = `EXPLAIN ${query}`;
@@ -870,16 +994,48 @@ module.exports = class Database {
870
994
  }
871
995
 
872
996
  // 3. Build covering index from the query
873
- const indexStatement = buildCoveringIndex(query, {
997
+ const candidate = buildCoveringIndexDefinition(query, {
874
998
  bucket: this.bucket,
875
999
  scope: this.scope_name,
876
1000
  collection: this.collection_name,
877
1001
  });
878
1002
 
879
- // 4. Execute the index creation if we got a valid statement
880
- if (indexStatement) {
881
- await exec_scope.query(indexStatement);
1003
+ if (!candidate) return;
1004
+
1005
+ // EXPLAIN only describes the chosen plan. An equivalent or wider index
1006
+ // may already exist under an older name, or still be deferred/building.
1007
+ // Include both catalog representations of the default collection.
1008
+ const indexResponse = await exec_scope.query(
1009
+ `SELECT RAW i FROM system:indexes AS i
1010
+ WHERE i.namespace_id = 'default'
1011
+ AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)
1012
+ OR ($scope = '_default' AND $collection = '_default'
1013
+ AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))`,
1014
+ {
1015
+ parameters: {
1016
+ bucket: candidate.bucket,
1017
+ scope: candidate.scope,
1018
+ collection: candidate.collection,
1019
+ },
1020
+ },
1021
+ );
1022
+ // A failed or incomplete inventory is not evidence that no index exists.
1023
+ if (
1024
+ !Array.isArray(indexResponse?.rows) ||
1025
+ indexResponse?.meta?.warnings?.length
1026
+ ) {
1027
+ throw new Error("Cannot verify existing indexes before auto-optimization");
882
1028
  }
1029
+ if (indexResponse.rows.some((index) =>
1030
+ index.name === candidate.name ||
1031
+ indexSatisfiesCoveringDefinition(index, candidate),
1032
+ )) {
1033
+ return;
1034
+ }
1035
+
1036
+ // Identical definitions use the same name across processes. Couchbase's
1037
+ // per-keyspace name uniqueness also prevents a concurrent duplicate.
1038
+ await exec_scope.query(candidate.statement);
883
1039
  } catch (e) {
884
1040
  // A failure here (timeout, index already exists, cluster busy) almost
885
1041
  // always means the cluster is struggling. Stop adding EXPLAIN and
@@ -921,6 +1077,7 @@ module.exports = class Database {
921
1077
  // declared at the top of this file. Never awaited, never allowed to reject.
922
1078
  if (
923
1079
  durationInMilliseconds > SLOW_QUERY_MS &&
1080
+ this.shouldAutoOptimize(sanitizedQuery, query_options) &&
924
1081
  shouldOptimize(sanitizedQuery)
925
1082
  ) {
926
1083
  this.optimize(sanitizedQuery, exec_scope).catch(() => {});