spice-couchbase 2.0.60 → 2.0.61
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/build/index.auto-optimize.test.js +376 -0
- package/build/index.js +29 -9
- package/build/utility.js +91 -48
- package/build/utility.test.js +4 -4
- package/package.json +2 -1
- package/src/index.auto-optimize.test.js +237 -0
- package/src/index.js +43 -8
- package/src/utility.js +102 -62
- package/src/utility.test.js +4 -4
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
|
|
4
|
+
|
|
5
|
+
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
|
|
6
|
+
|
|
7
|
+
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
|
|
8
|
+
|
|
9
|
+
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
|
|
10
|
+
|
|
11
|
+
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
|
|
12
|
+
|
|
13
|
+
var assert = require("node:assert/strict");
|
|
14
|
+
|
|
15
|
+
var fs = require("node:fs");
|
|
16
|
+
|
|
17
|
+
var path = require("node:path");
|
|
18
|
+
|
|
19
|
+
var vm = require("node:vm");
|
|
20
|
+
|
|
21
|
+
var {
|
|
22
|
+
test
|
|
23
|
+
} = require("node:test");
|
|
24
|
+
|
|
25
|
+
var {
|
|
26
|
+
buildCoveringIndex,
|
|
27
|
+
buildCoveringIndexDefinition
|
|
28
|
+
} = require("./utility");
|
|
29
|
+
|
|
30
|
+
var keyspace = {
|
|
31
|
+
bucket: "saturn",
|
|
32
|
+
scope: "test",
|
|
33
|
+
collection: "users"
|
|
34
|
+
};
|
|
35
|
+
var lookupQuery = "SELECT name, email FROM users WHERE status = 'active'";
|
|
36
|
+
|
|
37
|
+
function existingIndex(index_key) {
|
|
38
|
+
var overrides = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
39
|
+
return _objectSpread({
|
|
40
|
+
name: "legacy_covering_index",
|
|
41
|
+
using: "gsi",
|
|
42
|
+
state: "online",
|
|
43
|
+
index_key
|
|
44
|
+
}, overrides);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function executionScope() {
|
|
48
|
+
var indexes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
49
|
+
var {
|
|
50
|
+
inventoryError,
|
|
51
|
+
inventoryResponse,
|
|
52
|
+
plan
|
|
53
|
+
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
54
|
+
var calls = [];
|
|
55
|
+
return {
|
|
56
|
+
calls,
|
|
57
|
+
|
|
58
|
+
query(statement, options) {
|
|
59
|
+
return _asyncToGenerator(function* () {
|
|
60
|
+
calls.push({
|
|
61
|
+
statement,
|
|
62
|
+
options
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (statement.startsWith("EXPLAIN ")) {
|
|
66
|
+
return {
|
|
67
|
+
rows: [plan || {
|
|
68
|
+
"#operator": "Fetch"
|
|
69
|
+
}]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (statement.includes("FROM system:indexes")) {
|
|
74
|
+
if (inventoryError) throw inventoryError;
|
|
75
|
+
return inventoryResponse === undefined ? {
|
|
76
|
+
rows: indexes
|
|
77
|
+
} : inventoryResponse;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
rows: []
|
|
82
|
+
};
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createStatements(scope) {
|
|
90
|
+
return scope.calls.filter((_ref) => {
|
|
91
|
+
var {
|
|
92
|
+
statement
|
|
93
|
+
} = _ref;
|
|
94
|
+
return statement.startsWith("CREATE INDEX ");
|
|
95
|
+
});
|
|
96
|
+
} // Isolate optimizer state and time without connecting to Couchbase or sleeping.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
function createDatabase(durationMs) {
|
|
100
|
+
var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
101
|
+
var tick = BigInt(0);
|
|
102
|
+
var module = {
|
|
103
|
+
exports: {}
|
|
104
|
+
};
|
|
105
|
+
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
|
|
106
|
+
require,
|
|
107
|
+
module,
|
|
108
|
+
console,
|
|
109
|
+
process: {
|
|
110
|
+
env,
|
|
111
|
+
hrtime: {
|
|
112
|
+
bigint: () => {
|
|
113
|
+
var current = tick;
|
|
114
|
+
tick += BigInt(durationMs) * BigInt(1000000);
|
|
115
|
+
return current;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
var db = Object.create(module.exports.prototype);
|
|
121
|
+
db.bucket = "saturn";
|
|
122
|
+
db.scope_name = "test";
|
|
123
|
+
db.collection_name = "users";
|
|
124
|
+
db.cluster = {
|
|
125
|
+
query: function () {
|
|
126
|
+
var _query = _asyncToGenerator(function* () {
|
|
127
|
+
return {
|
|
128
|
+
rows: []
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function query() {
|
|
133
|
+
return _query.apply(this, arguments);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return query;
|
|
137
|
+
}()
|
|
138
|
+
};
|
|
139
|
+
return db;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("auto-optimization only starts above the default 3-second threshold",
|
|
143
|
+
/*#__PURE__*/
|
|
144
|
+
_asyncToGenerator(function* () {
|
|
145
|
+
for (var duration of [1001, 2999, 3000, 3001]) {
|
|
146
|
+
var db = createDatabase(duration);
|
|
147
|
+
var optimizations = 0;
|
|
148
|
+
db.optimize =
|
|
149
|
+
/*#__PURE__*/
|
|
150
|
+
_asyncToGenerator(function* () {
|
|
151
|
+
optimizations += 1;
|
|
152
|
+
});
|
|
153
|
+
yield db.query("SELECT name FROM users WHERE status = 'active'");
|
|
154
|
+
assert.equal(optimizations, duration > 3000 ? 1 : 0, "".concat(duration, "ms"));
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
test("the explicit slow-query threshold override remains supported",
|
|
158
|
+
/*#__PURE__*/
|
|
159
|
+
_asyncToGenerator(function* () {
|
|
160
|
+
var db = createDatabase(5001, {
|
|
161
|
+
SPICE_COUCHBASE_SLOW_QUERY_MS: "5000"
|
|
162
|
+
});
|
|
163
|
+
var optimizations = 0;
|
|
164
|
+
db.optimize =
|
|
165
|
+
/*#__PURE__*/
|
|
166
|
+
_asyncToGenerator(function* () {
|
|
167
|
+
optimizations += 1;
|
|
168
|
+
});
|
|
169
|
+
yield db.query("SELECT name FROM users WHERE status = 'active'");
|
|
170
|
+
assert.equal(optimizations, 1);
|
|
171
|
+
}));
|
|
172
|
+
test("Fetch plans for full-document projections never create indexes",
|
|
173
|
+
/*#__PURE__*/
|
|
174
|
+
_asyncToGenerator(function* () {
|
|
175
|
+
var _loop = function* _loop(projection) {
|
|
176
|
+
var db = createDatabase(0);
|
|
177
|
+
var statements = [];
|
|
178
|
+
var query = "SELECT ".concat(projection, " FROM users WHERE status = 'active' ORDER BY created_at DESC");
|
|
179
|
+
yield db.optimize(query, {
|
|
180
|
+
query: function () {
|
|
181
|
+
var _query2 = _asyncToGenerator(function* (statement) {
|
|
182
|
+
statements.push(statement);
|
|
183
|
+
return {
|
|
184
|
+
rows: [{
|
|
185
|
+
"#operator": "Fetch"
|
|
186
|
+
}]
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function query(_x) {
|
|
191
|
+
return _query2.apply(this, arguments);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return query;
|
|
195
|
+
}()
|
|
196
|
+
});
|
|
197
|
+
assert.deepEqual(statements, ["EXPLAIN ".concat(query)]);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
for (var projection of ["*", "users.*", "`users`.*", "(`users`).*, META(`users`).id AS id"]) {
|
|
201
|
+
yield* _loop(projection);
|
|
202
|
+
}
|
|
203
|
+
}));
|
|
204
|
+
test("Fetch plans for explicit fields still create a covering index",
|
|
205
|
+
/*#__PURE__*/
|
|
206
|
+
_asyncToGenerator(function* () {
|
|
207
|
+
var db = createDatabase(0);
|
|
208
|
+
var scope = executionScope();
|
|
209
|
+
yield db.optimize("SELECT name FROM users WHERE status = 'active'", scope);
|
|
210
|
+
assert.equal(scope.calls.length, 3);
|
|
211
|
+
assert.match(scope.calls[1].statement, /FROM system:indexes/);
|
|
212
|
+
assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify(keyspace));
|
|
213
|
+
assert.match(scope.calls[2].statement, /^CREATE INDEX `adv_covered_/);
|
|
214
|
+
assert.match(scope.calls[2].statement, /`status`, `name`/);
|
|
215
|
+
}));
|
|
216
|
+
test("reordered selected columns and changed literals generate identical DDL", () => {
|
|
217
|
+
var original = buildCoveringIndex(lookupQuery, keyspace);
|
|
218
|
+
var reordered = buildCoveringIndex("SELECT email, name FROM users WHERE status = 'pending'", keyspace);
|
|
219
|
+
assert.equal(original, reordered);
|
|
220
|
+
});
|
|
221
|
+
test("adding explicit ascending order cannot rename an identical definition", () => {
|
|
222
|
+
var base = "SELECT status, created_at FROM users WHERE status = 'pending'";
|
|
223
|
+
assert.equal(buildCoveringIndex(base, keyspace), buildCoveringIndex("".concat(base, " ORDER BY created_at ASC"), keyspace));
|
|
224
|
+
});
|
|
225
|
+
test("genuinely different access-key order and direction keep distinct definitions", () => {
|
|
226
|
+
var base = "SELECT name, email FROM users WHERE status = 'active'";
|
|
227
|
+
var asc = buildCoveringIndex("".concat(base, " ORDER BY name, email"), keyspace);
|
|
228
|
+
var desc = buildCoveringIndex("".concat(base, " ORDER BY name DESC, email"), keyspace);
|
|
229
|
+
var reordered = buildCoveringIndex("".concat(base, " ORDER BY email, name"), keyspace);
|
|
230
|
+
assert.notEqual(asc, desc);
|
|
231
|
+
assert.notEqual(asc, reordered);
|
|
232
|
+
assert.notEqual(asc.match(/adv_covered_\w+/)[0], reordered.match(/adv_covered_\w+/)[0]);
|
|
233
|
+
});
|
|
234
|
+
test("case-distinct document fields are not collapsed", () => {
|
|
235
|
+
var statement = buildCoveringIndex("SELECT Name, name, Status, status FROM users ORDER BY Name DESC", keyspace);
|
|
236
|
+
assert.match(statement, /`Name` DESC/);
|
|
237
|
+
assert.match(statement, /`name`/);
|
|
238
|
+
assert.match(statement, /`Status`/);
|
|
239
|
+
assert.match(statement, /`status`/);
|
|
240
|
+
assert.doesNotMatch(statement, /`name` DESC/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
var _loop2 = function _loop2(state) {
|
|
244
|
+
test("an existing ".concat(state, " index with reordered projection keys prevents creation"),
|
|
245
|
+
/*#__PURE__*/
|
|
246
|
+
_asyncToGenerator(function* () {
|
|
247
|
+
var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], {
|
|
248
|
+
state
|
|
249
|
+
})]);
|
|
250
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
251
|
+
assert.equal(scope.calls.length, 2);
|
|
252
|
+
assert.equal(createStatements(scope).length, 0);
|
|
253
|
+
}));
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (var state of ["online", "deferred", "pending", "building", "offline"]) {
|
|
257
|
+
_loop2(state);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
test("a wider existing index covers a projection subset without another index",
|
|
261
|
+
/*#__PURE__*/
|
|
262
|
+
_asyncToGenerator(function* () {
|
|
263
|
+
var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`phone`", "`email` DESC"])]);
|
|
264
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
265
|
+
assert.equal(createStatements(scope).length, 0);
|
|
266
|
+
}));
|
|
267
|
+
test("catalog key formatting, explicit ASC and attribute order are normalized",
|
|
268
|
+
/*#__PURE__*/
|
|
269
|
+
_asyncToGenerator(function* () {
|
|
270
|
+
for (var leadingKey of ["((`deleted`)) ASC INCLUDE MISSING", "deleted INCLUDE MISSING ASC"]) {
|
|
271
|
+
var scope = executionScope([existingIndex([leadingKey, "(status) ASC", "`name`", "email ASC"])]);
|
|
272
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
273
|
+
assert.equal(createStatements(scope).length, 0, leadingKey);
|
|
274
|
+
}
|
|
275
|
+
}));
|
|
276
|
+
var incompatibleIndexes = [["missing a projection field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`"])], ["different access prefix", existingIndex(["`deleted` INCLUDE MISSING", "`name`", "`status`", "`email`"])], ["missing-leading-key exclusion", existingIndex(["`deleted`", "`status`", "`name`", "`email`"])], ["case-distinct field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`Name`", "`email`"])], ["expression instead of the field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "LOWER(`name`)", "`email`"])], ["array key", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`", "DISTINCT ARRAY v FOR v IN tags END"])], ["partial index", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], {
|
|
277
|
+
condition: "(`status` = 'pending')"
|
|
278
|
+
})], ["primary index", existingIndex([], {
|
|
279
|
+
is_primary: true
|
|
280
|
+
})]];
|
|
281
|
+
|
|
282
|
+
var _loop3 = function _loop3(reason, index) {
|
|
283
|
+
test("does not mistake ".concat(reason, " for a compatible covering index"),
|
|
284
|
+
/*#__PURE__*/
|
|
285
|
+
_asyncToGenerator(function* () {
|
|
286
|
+
var scope = executionScope([index]);
|
|
287
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
288
|
+
assert.equal(createStatements(scope).length, 1);
|
|
289
|
+
}));
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
for (var [reason, index] of incompatibleIndexes) {
|
|
293
|
+
_loop3(reason, index);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
test("existing opposite sort direction does not satisfy the requested sort",
|
|
297
|
+
/*#__PURE__*/
|
|
298
|
+
_asyncToGenerator(function* () {
|
|
299
|
+
var query = "".concat(lookupQuery, " ORDER BY created_at DESC");
|
|
300
|
+
var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`created_at`", "`name`", "`email`"])]);
|
|
301
|
+
yield createDatabase(0).optimize(query, scope);
|
|
302
|
+
assert.equal(createStatements(scope).length, 1);
|
|
303
|
+
assert.match(createStatements(scope)[0].statement, /`created_at` DESC/);
|
|
304
|
+
}));
|
|
305
|
+
test("same generated name is never recreated even if its keys are unavailable",
|
|
306
|
+
/*#__PURE__*/
|
|
307
|
+
_asyncToGenerator(function* () {
|
|
308
|
+
var candidate = buildCoveringIndexDefinition(lookupQuery, keyspace);
|
|
309
|
+
var scope = executionScope([{
|
|
310
|
+
name: candidate.name,
|
|
311
|
+
state: "building"
|
|
312
|
+
}]);
|
|
313
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
314
|
+
assert.equal(createStatements(scope).length, 0);
|
|
315
|
+
}));
|
|
316
|
+
test("catalog failure or incomplete results prevent creation and trigger cooldown",
|
|
317
|
+
/*#__PURE__*/
|
|
318
|
+
_asyncToGenerator(function* () {
|
|
319
|
+
var _loop4 = function* _loop4(options) {
|
|
320
|
+
var db = createDatabase(3001);
|
|
321
|
+
var scope = executionScope([], options);
|
|
322
|
+
yield db.optimize(lookupQuery, scope);
|
|
323
|
+
assert.equal(createStatements(scope).length, 0);
|
|
324
|
+
var retries = 0;
|
|
325
|
+
db.optimize =
|
|
326
|
+
/*#__PURE__*/
|
|
327
|
+
_asyncToGenerator(function* () {
|
|
328
|
+
retries++;
|
|
329
|
+
});
|
|
330
|
+
yield db.query("SELECT email FROM users WHERE status = 'pending'");
|
|
331
|
+
assert.equal(retries, 0);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
for (var options of [{
|
|
335
|
+
inventoryError: new Error("query service unavailable")
|
|
336
|
+
}, {
|
|
337
|
+
inventoryResponse: {}
|
|
338
|
+
}, {
|
|
339
|
+
inventoryResponse: {
|
|
340
|
+
rows: [],
|
|
341
|
+
meta: {
|
|
342
|
+
warnings: [{
|
|
343
|
+
message: "partial inventory"
|
|
344
|
+
}]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}]) {
|
|
348
|
+
yield* _loop4(options);
|
|
349
|
+
}
|
|
350
|
+
}));
|
|
351
|
+
test("already-covered plans need neither an inventory lookup nor creation",
|
|
352
|
+
/*#__PURE__*/
|
|
353
|
+
_asyncToGenerator(function* () {
|
|
354
|
+
var scope = executionScope([], {
|
|
355
|
+
plan: {
|
|
356
|
+
"#operator": "IndexScan3"
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
yield createDatabase(0).optimize(lookupQuery, scope);
|
|
360
|
+
assert.equal(scope.calls.length, 1);
|
|
361
|
+
}));
|
|
362
|
+
test("a default collection uses default names in its inventory lookup",
|
|
363
|
+
/*#__PURE__*/
|
|
364
|
+
_asyncToGenerator(function* () {
|
|
365
|
+
var db = createDatabase(0);
|
|
366
|
+
db.scope_name = undefined;
|
|
367
|
+
db.collection_name = undefined;
|
|
368
|
+
var scope = executionScope();
|
|
369
|
+
yield db.optimize("SELECT name FROM saturn WHERE status = 'active'", scope);
|
|
370
|
+
assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify({
|
|
371
|
+
bucket: "saturn",
|
|
372
|
+
scope: "_default",
|
|
373
|
+
collection: "_default"
|
|
374
|
+
}));
|
|
375
|
+
assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
|
|
376
|
+
}));
|
package/build/index.js
CHANGED
|
@@ -35,13 +35,13 @@ if (!Promise.allSettled) {
|
|
|
35
35
|
} // Couchbase lookupIn has a limit of 16 operations per call
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
var LOOKUP_IN_MAX_SPECS = 16; // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN
|
|
39
|
-
//
|
|
38
|
+
var LOOKUP_IN_MAX_SPECS = 16; // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN,
|
|
39
|
+
// index inventory check, and possible CREATE INDEX) above SLOW_QUERY_MS. When
|
|
40
40
|
// the cluster is degraded every statement is slow, so without limits each
|
|
41
41
|
// query spawned two more heavy ones and deepened the outage. These caps make it
|
|
42
42
|
// a bounded, once-per-statement background task that backs off on failure.
|
|
43
43
|
|
|
44
|
-
var SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS ||
|
|
44
|
+
var SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 3000);
|
|
45
45
|
var AUTO_OPTIMIZE_ENABLED = !["0", "false", "no", "off"].includes(String((_process$env$SPICE_CO = process.env.SPICE_COUCHBASE_AUTO_OPTIMIZE) !== null && _process$env$SPICE_CO !== void 0 ? _process$env$SPICE_CO : "true").trim().toLowerCase());
|
|
46
46
|
var OPTIMIZE_MAX_INFLIGHT = Number(process.env.SPICE_COUCHBASE_OPTIMIZE_MAX_INFLIGHT || 1);
|
|
47
47
|
var OPTIMIZE_FAILURE_COOLDOWN_MS = Number(process.env.SPICE_COUCHBASE_OPTIMIZE_COOLDOWN_MS || 5 * 60 * 1000); // Statements already optimised in this process. Bounded so it cannot grow
|
|
@@ -904,10 +904,11 @@ module.exports = class Database {
|
|
|
904
904
|
optimizeState.inFlight += 1;
|
|
905
905
|
|
|
906
906
|
try {
|
|
907
|
-
var _explainResponse$rows;
|
|
907
|
+
var _explainResponse$rows, _indexResponse$meta, _indexResponse$meta$w;
|
|
908
908
|
|
|
909
909
|
var {
|
|
910
|
-
|
|
910
|
+
buildCoveringIndexDefinition,
|
|
911
|
+
indexSatisfiesCoveringDefinition
|
|
911
912
|
} = require("./utility"); // 1. Run EXPLAIN to get the query plan
|
|
912
913
|
|
|
913
914
|
|
|
@@ -922,15 +923,34 @@ module.exports = class Database {
|
|
|
922
923
|
} // 3. Build covering index from the query
|
|
923
924
|
|
|
924
925
|
|
|
925
|
-
var
|
|
926
|
+
var candidate = buildCoveringIndexDefinition(query, {
|
|
926
927
|
bucket: _this19.bucket,
|
|
927
928
|
scope: _this19.scope_name,
|
|
928
929
|
collection: _this19.collection_name
|
|
929
|
-
});
|
|
930
|
+
});
|
|
931
|
+
if (!candidate) return; // EXPLAIN only describes the chosen plan. An equivalent or wider index
|
|
932
|
+
// may already exist under an older name, or still be deferred/building.
|
|
933
|
+
// Include both catalog representations of the default collection.
|
|
934
|
+
|
|
935
|
+
var indexResponse = yield exec_scope.query("SELECT RAW i FROM system:indexes AS i\n WHERE i.namespace_id = 'default'\n AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)\n OR ($scope = '_default' AND $collection = '_default'\n AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))", {
|
|
936
|
+
parameters: {
|
|
937
|
+
bucket: candidate.bucket,
|
|
938
|
+
scope: candidate.scope,
|
|
939
|
+
collection: candidate.collection
|
|
940
|
+
}
|
|
941
|
+
}); // A failed or incomplete inventory is not evidence that no index exists.
|
|
930
942
|
|
|
931
|
-
if (
|
|
932
|
-
|
|
943
|
+
if (!Array.isArray(indexResponse === null || indexResponse === void 0 ? void 0 : indexResponse.rows) || (indexResponse === null || indexResponse === void 0 ? void 0 : (_indexResponse$meta = indexResponse.meta) === null || _indexResponse$meta === void 0 ? void 0 : (_indexResponse$meta$w = _indexResponse$meta.warnings) === null || _indexResponse$meta$w === void 0 ? void 0 : _indexResponse$meta$w.length)) {
|
|
944
|
+
throw new Error("Cannot verify existing indexes before auto-optimization");
|
|
933
945
|
}
|
|
946
|
+
|
|
947
|
+
if (indexResponse.rows.some(index => index.name === candidate.name || indexSatisfiesCoveringDefinition(index, candidate))) {
|
|
948
|
+
return;
|
|
949
|
+
} // Identical definitions use the same name across processes. Couchbase's
|
|
950
|
+
// per-keyspace name uniqueness also prevents a concurrent duplicate.
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
yield exec_scope.query(candidate.statement);
|
|
934
954
|
} catch (e) {
|
|
935
955
|
// A failure here (timeout, index already exists, cluster busy) almost
|
|
936
956
|
// always means the cluster is struggling. Stop adding EXPLAIN and
|
package/build/utility.js
CHANGED
|
@@ -235,7 +235,7 @@ function buildOrderDirectionMap(orderByIndexFields) {
|
|
|
235
235
|
direction
|
|
236
236
|
} of orderByIndexFields) {
|
|
237
237
|
if (direction === "DESC") {
|
|
238
|
-
directionMap.set(name
|
|
238
|
+
directionMap.set(name, direction);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -243,14 +243,9 @@ function buildOrderDirectionMap(orderByIndexFields) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
function formatIndexField(field, orderDirectionMap) {
|
|
246
|
-
var direction = orderDirectionMap.get(field
|
|
246
|
+
var direction = orderDirectionMap.get(field);
|
|
247
247
|
return "`".concat(field, "`").concat(direction ? " ".concat(direction) : "");
|
|
248
248
|
}
|
|
249
|
-
|
|
250
|
-
function formatIndexKey(field, orderDirectionMap) {
|
|
251
|
-
var direction = orderDirectionMap.get(field.toLowerCase());
|
|
252
|
-
return "".concat(field).concat(direction ? " ".concat(direction) : "");
|
|
253
|
-
}
|
|
254
249
|
/**
|
|
255
250
|
* Splits SELECT clause by commas, respecting parentheses
|
|
256
251
|
* @param {string} clause - The SELECT clause content
|
|
@@ -284,21 +279,8 @@ function splitSelectClause(clause) {
|
|
|
284
279
|
|
|
285
280
|
return parts;
|
|
286
281
|
}
|
|
287
|
-
|
|
288
|
-
function hasQualifiedWildcardProjection(query) {
|
|
289
|
-
var selectMatch = query.match(/\bSELECT\b([\s\S]+?)\bFROM\b/i);
|
|
290
|
-
|
|
291
|
-
if (!selectMatch) {
|
|
292
|
-
return false;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return splitSelectClause(selectMatch[1].trim()).some(part => {
|
|
296
|
-
var trimmedPart = part.trim();
|
|
297
|
-
return /^\(\s*`?\w+`?\s*\)\s*\.\s*\*$/.test(trimmedPart) || /^`?\w+`?\s*\.\s*\*$/.test(trimmedPart);
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
282
|
/**
|
|
301
|
-
* Builds a covering
|
|
283
|
+
* Builds a covering index CREATE statement from a query
|
|
302
284
|
* @param {string} query - The N1QL query to analyze
|
|
303
285
|
* @param {Object} options - Configuration options
|
|
304
286
|
* @param {string} options.bucket - The bucket name
|
|
@@ -309,6 +291,14 @@ function hasQualifiedWildcardProjection(query) {
|
|
|
309
291
|
|
|
310
292
|
|
|
311
293
|
function buildCoveringIndex(query, options) {
|
|
294
|
+
var _ref, _buildCoveringIndexDe;
|
|
295
|
+
|
|
296
|
+
return (_ref = (_buildCoveringIndexDe = buildCoveringIndexDefinition(query, options)) === null || _buildCoveringIndexDe === void 0 ? void 0 : _buildCoveringIndexDe.statement) !== null && _ref !== void 0 ? _ref : null;
|
|
297
|
+
} // Keep the access keys separate from projection-only keys so an older, wider
|
|
298
|
+
// index can satisfy the same request without depending on its generated name.
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
function buildCoveringIndexDefinition(query, options) {
|
|
312
302
|
var {
|
|
313
303
|
bucket,
|
|
314
304
|
scope = "_default",
|
|
@@ -321,9 +311,10 @@ function buildCoveringIndex(query, options) {
|
|
|
321
311
|
|
|
322
312
|
|
|
323
313
|
var selectFields = extractSelectFields(query);
|
|
324
|
-
var isNonCoveringFallback = selectFields === null && hasQualifiedWildcardProjection(query);
|
|
325
314
|
|
|
326
|
-
if (selectFields === null
|
|
315
|
+
if (selectFields === null) {
|
|
316
|
+
// Full-document projections cannot be covered by the extracted fields.
|
|
317
|
+
// Skip them instead of creating a non-covering fallback index.
|
|
327
318
|
return null;
|
|
328
319
|
} // Extract fields from WHERE clause
|
|
329
320
|
|
|
@@ -336,42 +327,36 @@ function buildCoveringIndex(query, options) {
|
|
|
336
327
|
var orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
|
|
337
328
|
var orderByFields = orderByIndexFields.map(field => field.name); // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
|
|
338
329
|
|
|
339
|
-
var filteredWhereFields = whereFields.filter(f => f
|
|
330
|
+
var filteredWhereFields = whereFields.filter(f => f !== "deleted"); // Track fields already added to avoid duplicates
|
|
340
331
|
|
|
341
|
-
var addedFields = new Set(filteredWhereFields
|
|
332
|
+
var addedFields = new Set(filteredWhereFields); // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
|
|
342
333
|
|
|
343
334
|
var filteredGroupByFields = groupByFields.filter(f => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
335
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
347
336
|
return false;
|
|
348
337
|
}
|
|
349
338
|
|
|
350
|
-
addedFields.add(
|
|
339
|
+
addedFields.add(f);
|
|
351
340
|
return true;
|
|
352
341
|
}); // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
|
|
353
342
|
|
|
354
343
|
var filteredOrderByFields = orderByFields.filter(f => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
344
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
358
345
|
return false;
|
|
359
346
|
}
|
|
360
347
|
|
|
361
|
-
addedFields.add(
|
|
348
|
+
addedFields.add(f);
|
|
362
349
|
return true;
|
|
363
350
|
}); // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
|
|
364
351
|
|
|
365
|
-
var selectFieldsFiltered =
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
352
|
+
var selectFieldsFiltered = selectFields.filter(f => {
|
|
353
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
369
354
|
return false;
|
|
370
355
|
}
|
|
371
356
|
|
|
372
|
-
addedFields.add(
|
|
357
|
+
addedFields.add(f);
|
|
373
358
|
return true;
|
|
374
|
-
}); //
|
|
359
|
+
}).sort(); // Projection order has no effect on coverage. Keep access-key order intact.
|
|
375
360
|
|
|
376
361
|
var allFields = [...filteredWhereFields, ...filteredGroupByFields, ...filteredOrderByFields, ...selectFieldsFiltered];
|
|
377
362
|
|
|
@@ -381,22 +366,80 @@ function buildCoveringIndex(query, options) {
|
|
|
381
366
|
} // Build the field list with deleted INCLUDE MISSING first
|
|
382
367
|
|
|
383
368
|
|
|
384
|
-
var
|
|
385
|
-
//
|
|
369
|
+
var keys = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))]; // Hash the actual ordered definition, not query syntax. In particular, an
|
|
370
|
+
// explicit ORDER BY ... ASC must not rename an otherwise identical index.
|
|
386
371
|
|
|
387
|
-
var
|
|
388
|
-
var orderFieldsKey = orderByIndexFields.map(field => formatIndexKey(field.name, orderDirectionMap)).join(",");
|
|
389
|
-
var indexMode = isNonCoveringFallback ? "noncovered" : "covered";
|
|
390
|
-
var indexKey = "".concat(bucket, "|").concat(scope, "|").concat(collection, "|mode:").concat(indexMode, "|fields:").concat(indexFieldsKey, "|order:").concat(orderFieldsKey);
|
|
372
|
+
var indexKey = JSON.stringify([bucket, scope, collection, keys]);
|
|
391
373
|
var hash = generateHash(indexKey);
|
|
392
|
-
var indexName = "
|
|
374
|
+
var indexName = "adv_covered_".concat(hash); // Build the CREATE INDEX statement
|
|
375
|
+
|
|
376
|
+
var quote = identifier => "`".concat(identifier.replace(/`/g, "``"), "`");
|
|
377
|
+
|
|
378
|
+
var indexStatement = "CREATE INDEX `".concat(indexName, "` ON `default`:").concat(quote(bucket), ".").concat(quote(scope), ".").concat(quote(collection), "(").concat(keys.join(", "), ")");
|
|
379
|
+
return {
|
|
380
|
+
name: indexName,
|
|
381
|
+
statement: indexStatement,
|
|
382
|
+
keys,
|
|
383
|
+
accessKeyCount: 1 + filteredWhereFields.length + filteredGroupByFields.length + filteredOrderByFields.length,
|
|
384
|
+
bucket,
|
|
385
|
+
scope,
|
|
386
|
+
collection
|
|
387
|
+
};
|
|
388
|
+
} // Only compare simple scalar keys. Do not treat expressions, array indexes,
|
|
389
|
+
// partial indexes, or case-distinct document fields as equivalent.
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
function normalizeIndexKey(key) {
|
|
393
|
+
if (typeof key !== "string") return null;
|
|
394
|
+
var match = key.trim().match(/^(\(*[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?:`(?:[\0-_a-\uFFFF]|``)+`|[A-Z_a-z][0-9A-Z_a-z]*)[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*\)*)([\s\S]*?)$/);
|
|
395
|
+
if (!match) return null;
|
|
396
|
+
var expression = match[1].trim();
|
|
397
|
+
|
|
398
|
+
while (expression.startsWith("(") && expression.endsWith(")")) {
|
|
399
|
+
expression = expression.slice(1, -1).trim();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
var identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
|
|
403
|
+
if (!identifier) return null;
|
|
404
|
+
var attributes = match[2].trim().toUpperCase();
|
|
405
|
+
|
|
406
|
+
if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
field: identifier[1] !== undefined ? identifier[1].replace(/``/g, "`") : identifier[2],
|
|
412
|
+
direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
|
|
413
|
+
includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes)
|
|
414
|
+
};
|
|
415
|
+
} // Call with indexes from the candidate's exact bucket/scope/collection. State
|
|
416
|
+
// is deliberately not filtered: a matching deferred/building index is already
|
|
417
|
+
// committed work, even though EXPLAIN cannot use it yet.
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
421
|
+
if (index.is_primary || index.condition || index.using !== "gsi" || !Array.isArray(index.index_key)) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
var existingKeys = index.index_key.map(normalizeIndexKey);
|
|
426
|
+
var requiredKeys = candidate.keys.map(normalizeIndexKey);
|
|
427
|
+
if (existingKeys.some(key => !key) || requiredKeys.some(key => !key)) return false;
|
|
428
|
+
|
|
429
|
+
for (var i = 0; i < candidate.accessKeyCount; i++) {
|
|
430
|
+
var existing = existingKeys[i];
|
|
431
|
+
var required = requiredKeys[i];
|
|
432
|
+
if (!existing || existing.field !== required.field || existing.direction !== required.direction || existing.includeMissing !== required.includeMissing) return false;
|
|
433
|
+
}
|
|
393
434
|
|
|
394
|
-
var
|
|
395
|
-
return
|
|
435
|
+
var existingFields = new Set(existingKeys.map(key => key.field));
|
|
436
|
+
return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
|
|
396
437
|
}
|
|
397
438
|
|
|
398
439
|
module.exports = {
|
|
399
440
|
buildCoveringIndex,
|
|
441
|
+
buildCoveringIndexDefinition,
|
|
442
|
+
indexSatisfiesCoveringDefinition,
|
|
400
443
|
extractWhereFields,
|
|
401
444
|
extractSelectFields,
|
|
402
445
|
extractGroupByFields,
|
package/build/utility.test.js
CHANGED
|
@@ -504,21 +504,21 @@ test("returns null when missing bucket option", () => {
|
|
|
504
504
|
});
|
|
505
505
|
assert.strictEqual(result, null);
|
|
506
506
|
});
|
|
507
|
-
test("
|
|
507
|
+
test("uses the default scope when scope option is missing", () => {
|
|
508
508
|
var query = "SELECT name FROM users WHERE status = 'active'";
|
|
509
509
|
var result = buildCoveringIndex(query, {
|
|
510
510
|
bucket: "test",
|
|
511
511
|
collection: "users"
|
|
512
512
|
});
|
|
513
|
-
assert.
|
|
513
|
+
assert.ok(result.includes("`test`.`_default`.`users`"));
|
|
514
514
|
});
|
|
515
|
-
test("
|
|
515
|
+
test("uses the default collection when collection option is missing", () => {
|
|
516
516
|
var query = "SELECT name FROM users WHERE status = 'active'";
|
|
517
517
|
var result = buildCoveringIndex(query, {
|
|
518
518
|
bucket: "test",
|
|
519
519
|
scope: "scope"
|
|
520
520
|
});
|
|
521
|
-
assert.
|
|
521
|
+
assert.ok(result.includes("`test`.`scope`.`_default`"));
|
|
522
522
|
});
|
|
523
523
|
test("generates unique index name from query hash", () => {
|
|
524
524
|
var query1 = "SELECT name FROM users WHERE status = 'active'";
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spice-couchbase",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.61",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node src/utility.test.js",
|
|
8
|
+
"test:auto-optimize": "node --test src/index.auto-optimize.test.js",
|
|
8
9
|
"test:query-consistency": "node src/index.query-consistency.test.js",
|
|
9
10
|
"test:count-query": "node src/index.count-query.test.js"
|
|
10
11
|
},
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
function createDatabase(durationMs, env = {}) {
|
|
41
|
+
let tick = BigInt(0);
|
|
42
|
+
const module = { exports: {} };
|
|
43
|
+
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
|
|
44
|
+
require,
|
|
45
|
+
module,
|
|
46
|
+
console,
|
|
47
|
+
process: {
|
|
48
|
+
env,
|
|
49
|
+
hrtime: {
|
|
50
|
+
bigint: () => {
|
|
51
|
+
const current = tick;
|
|
52
|
+
tick += BigInt(durationMs) * BigInt(1000000);
|
|
53
|
+
return current;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const db = Object.create(module.exports.prototype);
|
|
59
|
+
db.bucket = "saturn";
|
|
60
|
+
db.scope_name = "test";
|
|
61
|
+
db.collection_name = "users";
|
|
62
|
+
db.cluster = { query: async () => ({ rows: [] }) };
|
|
63
|
+
return db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("auto-optimization only starts above the default 3-second threshold", async () => {
|
|
67
|
+
for (const duration of [1001, 2999, 3000, 3001]) {
|
|
68
|
+
const db = createDatabase(duration);
|
|
69
|
+
let optimizations = 0;
|
|
70
|
+
db.optimize = async () => { optimizations += 1; };
|
|
71
|
+
await db.query("SELECT name FROM users WHERE status = 'active'");
|
|
72
|
+
assert.equal(optimizations, duration > 3000 ? 1 : 0, `${duration}ms`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the explicit slow-query threshold override remains supported", async () => {
|
|
77
|
+
const db = createDatabase(5001, { SPICE_COUCHBASE_SLOW_QUERY_MS: "5000" });
|
|
78
|
+
let optimizations = 0;
|
|
79
|
+
db.optimize = async () => { optimizations += 1; };
|
|
80
|
+
await db.query("SELECT name FROM users WHERE status = 'active'");
|
|
81
|
+
assert.equal(optimizations, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("Fetch plans for full-document projections never create indexes", async () => {
|
|
85
|
+
for (const projection of ["*", "users.*", "`users`.*", "(`users`).*, META(`users`).id AS id"]) {
|
|
86
|
+
const db = createDatabase(0);
|
|
87
|
+
const statements = [];
|
|
88
|
+
const query = `SELECT ${projection} FROM users WHERE status = 'active' ORDER BY created_at DESC`;
|
|
89
|
+
await db.optimize(query, {
|
|
90
|
+
query: async (statement) => {
|
|
91
|
+
statements.push(statement);
|
|
92
|
+
return { rows: [{ "#operator": "Fetch" }] };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
assert.deepEqual(statements, [`EXPLAIN ${query}`]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("Fetch plans for explicit fields still create a covering index", async () => {
|
|
100
|
+
const db = createDatabase(0);
|
|
101
|
+
const scope = executionScope();
|
|
102
|
+
await db.optimize("SELECT name FROM users WHERE status = 'active'", scope);
|
|
103
|
+
assert.equal(scope.calls.length, 3);
|
|
104
|
+
assert.match(scope.calls[1].statement, /FROM system:indexes/);
|
|
105
|
+
assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify(keyspace));
|
|
106
|
+
assert.match(scope.calls[2].statement, /^CREATE INDEX `adv_covered_/);
|
|
107
|
+
assert.match(scope.calls[2].statement, /`status`, `name`/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("reordered selected columns and changed literals generate identical DDL", () => {
|
|
111
|
+
const original = buildCoveringIndex(lookupQuery, keyspace);
|
|
112
|
+
const reordered = buildCoveringIndex("SELECT email, name FROM users WHERE status = 'pending'", keyspace);
|
|
113
|
+
assert.equal(original, reordered);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("adding explicit ascending order cannot rename an identical definition", () => {
|
|
117
|
+
const base = "SELECT status, created_at FROM users WHERE status = 'pending'";
|
|
118
|
+
assert.equal(buildCoveringIndex(base, keyspace), buildCoveringIndex(`${base} ORDER BY created_at ASC`, keyspace));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("genuinely different access-key order and direction keep distinct definitions", () => {
|
|
122
|
+
const base = "SELECT name, email FROM users WHERE status = 'active'";
|
|
123
|
+
const asc = buildCoveringIndex(`${base} ORDER BY name, email`, keyspace);
|
|
124
|
+
const desc = buildCoveringIndex(`${base} ORDER BY name DESC, email`, keyspace);
|
|
125
|
+
const reordered = buildCoveringIndex(`${base} ORDER BY email, name`, keyspace);
|
|
126
|
+
assert.notEqual(asc, desc);
|
|
127
|
+
assert.notEqual(asc, reordered);
|
|
128
|
+
assert.notEqual(asc.match(/adv_covered_\w+/)[0], reordered.match(/adv_covered_\w+/)[0]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("case-distinct document fields are not collapsed", () => {
|
|
132
|
+
const statement = buildCoveringIndex("SELECT Name, name, Status, status FROM users ORDER BY Name DESC", keyspace);
|
|
133
|
+
assert.match(statement, /`Name` DESC/);
|
|
134
|
+
assert.match(statement, /`name`/);
|
|
135
|
+
assert.match(statement, /`Status`/);
|
|
136
|
+
assert.match(statement, /`status`/);
|
|
137
|
+
assert.doesNotMatch(statement, /`name` DESC/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
for (const state of ["online", "deferred", "pending", "building", "offline"]) {
|
|
141
|
+
test(`an existing ${state} index with reordered projection keys prevents creation`, async () => {
|
|
142
|
+
const scope = executionScope([
|
|
143
|
+
existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { state }),
|
|
144
|
+
]);
|
|
145
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
146
|
+
assert.equal(scope.calls.length, 2);
|
|
147
|
+
assert.equal(createStatements(scope).length, 0);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("a wider existing index covers a projection subset without another index", async () => {
|
|
152
|
+
const scope = executionScope([
|
|
153
|
+
existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`phone`", "`email` DESC"]),
|
|
154
|
+
]);
|
|
155
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
156
|
+
assert.equal(createStatements(scope).length, 0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("catalog key formatting, explicit ASC and attribute order are normalized", async () => {
|
|
160
|
+
for (const leadingKey of ["((`deleted`)) ASC INCLUDE MISSING", "deleted INCLUDE MISSING ASC"]) {
|
|
161
|
+
const scope = executionScope([
|
|
162
|
+
existingIndex([leadingKey, "(status) ASC", "`name`", "email ASC"]),
|
|
163
|
+
]);
|
|
164
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
165
|
+
assert.equal(createStatements(scope).length, 0, leadingKey);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const incompatibleIndexes = [
|
|
170
|
+
["missing a projection field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`"])],
|
|
171
|
+
["different access prefix", existingIndex(["`deleted` INCLUDE MISSING", "`name`", "`status`", "`email`"])],
|
|
172
|
+
["missing-leading-key exclusion", existingIndex(["`deleted`", "`status`", "`name`", "`email`"])],
|
|
173
|
+
["case-distinct field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`Name`", "`email`"])],
|
|
174
|
+
["expression instead of the field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "LOWER(`name`)", "`email`"])],
|
|
175
|
+
["array key", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`", "DISTINCT ARRAY v FOR v IN tags END"])],
|
|
176
|
+
["partial index", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { condition: "(`status` = 'pending')" })],
|
|
177
|
+
["primary index", existingIndex([], { is_primary: true })],
|
|
178
|
+
];
|
|
179
|
+
for (const [reason, index] of incompatibleIndexes) {
|
|
180
|
+
test(`does not mistake ${reason} for a compatible covering index`, async () => {
|
|
181
|
+
const scope = executionScope([index]);
|
|
182
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
183
|
+
assert.equal(createStatements(scope).length, 1);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
test("existing opposite sort direction does not satisfy the requested sort", async () => {
|
|
188
|
+
const query = `${lookupQuery} ORDER BY created_at DESC`;
|
|
189
|
+
const scope = executionScope([
|
|
190
|
+
existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`created_at`", "`name`", "`email`"]),
|
|
191
|
+
]);
|
|
192
|
+
await createDatabase(0).optimize(query, scope);
|
|
193
|
+
assert.equal(createStatements(scope).length, 1);
|
|
194
|
+
assert.match(createStatements(scope)[0].statement, /`created_at` DESC/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("same generated name is never recreated even if its keys are unavailable", async () => {
|
|
198
|
+
const candidate = buildCoveringIndexDefinition(lookupQuery, keyspace);
|
|
199
|
+
const scope = executionScope([{ name: candidate.name, state: "building" }]);
|
|
200
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
201
|
+
assert.equal(createStatements(scope).length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("catalog failure or incomplete results prevent creation and trigger cooldown", async () => {
|
|
205
|
+
for (const options of [
|
|
206
|
+
{ inventoryError: new Error("query service unavailable") },
|
|
207
|
+
{ inventoryResponse: {} },
|
|
208
|
+
{ inventoryResponse: { rows: [], meta: { warnings: [{ message: "partial inventory" }] } } },
|
|
209
|
+
]) {
|
|
210
|
+
const db = createDatabase(3001);
|
|
211
|
+
const scope = executionScope([], options);
|
|
212
|
+
await db.optimize(lookupQuery, scope);
|
|
213
|
+
assert.equal(createStatements(scope).length, 0);
|
|
214
|
+
let retries = 0;
|
|
215
|
+
db.optimize = async () => { retries++; };
|
|
216
|
+
await db.query("SELECT email FROM users WHERE status = 'pending'");
|
|
217
|
+
assert.equal(retries, 0);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("already-covered plans need neither an inventory lookup nor creation", async () => {
|
|
222
|
+
const scope = executionScope([], { plan: { "#operator": "IndexScan3" } });
|
|
223
|
+
await createDatabase(0).optimize(lookupQuery, scope);
|
|
224
|
+
assert.equal(scope.calls.length, 1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a default collection uses default names in its inventory lookup", async () => {
|
|
228
|
+
const db = createDatabase(0);
|
|
229
|
+
db.scope_name = undefined;
|
|
230
|
+
db.collection_name = undefined;
|
|
231
|
+
const scope = executionScope();
|
|
232
|
+
await db.optimize("SELECT name FROM saturn WHERE status = 'active'", scope);
|
|
233
|
+
assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify({
|
|
234
|
+
bucket: "saturn", scope: "_default", collection: "_default",
|
|
235
|
+
}));
|
|
236
|
+
assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
|
|
237
|
+
});
|
package/src/index.js
CHANGED
|
@@ -25,12 +25,12 @@ if (!Promise.allSettled) {
|
|
|
25
25
|
// Couchbase lookupIn has a limit of 16 operations per call
|
|
26
26
|
const LOOKUP_IN_MAX_SPECS = 16;
|
|
27
27
|
|
|
28
|
-
// Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN
|
|
29
|
-
//
|
|
28
|
+
// Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN,
|
|
29
|
+
// index inventory check, and possible CREATE INDEX) above SLOW_QUERY_MS. When
|
|
30
30
|
// the cluster is degraded every statement is slow, so without limits each
|
|
31
31
|
// query spawned two more heavy ones and deepened the outage. These caps make it
|
|
32
32
|
// 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 ||
|
|
33
|
+
const SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 3000);
|
|
34
34
|
const AUTO_OPTIMIZE_ENABLED = !["0", "false", "no", "off"].includes(
|
|
35
35
|
String(process.env.SPICE_COUCHBASE_AUTO_OPTIMIZE ?? "true")
|
|
36
36
|
.trim()
|
|
@@ -856,7 +856,10 @@ module.exports = class Database {
|
|
|
856
856
|
async optimize(query, exec_scope) {
|
|
857
857
|
optimizeState.inFlight += 1;
|
|
858
858
|
try {
|
|
859
|
-
const {
|
|
859
|
+
const {
|
|
860
|
+
buildCoveringIndexDefinition,
|
|
861
|
+
indexSatisfiesCoveringDefinition,
|
|
862
|
+
} = require("./utility");
|
|
860
863
|
|
|
861
864
|
// 1. Run EXPLAIN to get the query plan
|
|
862
865
|
const explainQuery = `EXPLAIN ${query}`;
|
|
@@ -870,16 +873,48 @@ module.exports = class Database {
|
|
|
870
873
|
}
|
|
871
874
|
|
|
872
875
|
// 3. Build covering index from the query
|
|
873
|
-
const
|
|
876
|
+
const candidate = buildCoveringIndexDefinition(query, {
|
|
874
877
|
bucket: this.bucket,
|
|
875
878
|
scope: this.scope_name,
|
|
876
879
|
collection: this.collection_name,
|
|
877
880
|
});
|
|
878
881
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
+
if (!candidate) return;
|
|
883
|
+
|
|
884
|
+
// EXPLAIN only describes the chosen plan. An equivalent or wider index
|
|
885
|
+
// may already exist under an older name, or still be deferred/building.
|
|
886
|
+
// Include both catalog representations of the default collection.
|
|
887
|
+
const indexResponse = await exec_scope.query(
|
|
888
|
+
`SELECT RAW i FROM system:indexes AS i
|
|
889
|
+
WHERE i.namespace_id = 'default'
|
|
890
|
+
AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)
|
|
891
|
+
OR ($scope = '_default' AND $collection = '_default'
|
|
892
|
+
AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))`,
|
|
893
|
+
{
|
|
894
|
+
parameters: {
|
|
895
|
+
bucket: candidate.bucket,
|
|
896
|
+
scope: candidate.scope,
|
|
897
|
+
collection: candidate.collection,
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
);
|
|
901
|
+
// A failed or incomplete inventory is not evidence that no index exists.
|
|
902
|
+
if (
|
|
903
|
+
!Array.isArray(indexResponse?.rows) ||
|
|
904
|
+
indexResponse?.meta?.warnings?.length
|
|
905
|
+
) {
|
|
906
|
+
throw new Error("Cannot verify existing indexes before auto-optimization");
|
|
907
|
+
}
|
|
908
|
+
if (indexResponse.rows.some((index) =>
|
|
909
|
+
index.name === candidate.name ||
|
|
910
|
+
indexSatisfiesCoveringDefinition(index, candidate),
|
|
911
|
+
)) {
|
|
912
|
+
return;
|
|
882
913
|
}
|
|
914
|
+
|
|
915
|
+
// Identical definitions use the same name across processes. Couchbase's
|
|
916
|
+
// per-keyspace name uniqueness also prevents a concurrent duplicate.
|
|
917
|
+
await exec_scope.query(candidate.statement);
|
|
883
918
|
} catch (e) {
|
|
884
919
|
// A failure here (timeout, index already exists, cluster busy) almost
|
|
885
920
|
// always means the cluster is struggling. Stop adding EXPLAIN and
|
package/src/utility.js
CHANGED
|
@@ -245,22 +245,17 @@ function buildOrderDirectionMap(orderByIndexFields) {
|
|
|
245
245
|
const directionMap = new Map();
|
|
246
246
|
for (const { name, direction } of orderByIndexFields) {
|
|
247
247
|
if (direction === "DESC") {
|
|
248
|
-
directionMap.set(name
|
|
248
|
+
directionMap.set(name, direction);
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
return directionMap;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
function formatIndexField(field, orderDirectionMap) {
|
|
255
|
-
const direction = orderDirectionMap.get(field
|
|
255
|
+
const direction = orderDirectionMap.get(field);
|
|
256
256
|
return `\`${field}\`${direction ? ` ${direction}` : ""}`;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
function formatIndexKey(field, orderDirectionMap) {
|
|
260
|
-
const direction = orderDirectionMap.get(field.toLowerCase());
|
|
261
|
-
return `${field}${direction ? ` ${direction}` : ""}`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
259
|
/**
|
|
265
260
|
* Splits SELECT clause by commas, respecting parentheses
|
|
266
261
|
* @param {string} clause - The SELECT clause content
|
|
@@ -293,23 +288,8 @@ function splitSelectClause(clause) {
|
|
|
293
288
|
return parts;
|
|
294
289
|
}
|
|
295
290
|
|
|
296
|
-
function hasQualifiedWildcardProjection(query) {
|
|
297
|
-
const selectMatch = query.match(/\bSELECT\b(.+?)\bFROM\b/is);
|
|
298
|
-
if (!selectMatch) {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return splitSelectClause(selectMatch[1].trim()).some((part) => {
|
|
303
|
-
const trimmedPart = part.trim();
|
|
304
|
-
return (
|
|
305
|
-
/^\(\s*`?\w+`?\s*\)\s*\.\s*\*$/.test(trimmedPart) ||
|
|
306
|
-
/^`?\w+`?\s*\.\s*\*$/.test(trimmedPart)
|
|
307
|
-
);
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
291
|
/**
|
|
312
|
-
* Builds a covering
|
|
292
|
+
* Builds a covering index CREATE statement from a query
|
|
313
293
|
* @param {string} query - The N1QL query to analyze
|
|
314
294
|
* @param {Object} options - Configuration options
|
|
315
295
|
* @param {string} options.bucket - The bucket name
|
|
@@ -318,6 +298,12 @@ function hasQualifiedWildcardProjection(query) {
|
|
|
318
298
|
* @returns {string|null} - The CREATE INDEX statement, or null if cannot build
|
|
319
299
|
*/
|
|
320
300
|
function buildCoveringIndex(query, options) {
|
|
301
|
+
return buildCoveringIndexDefinition(query, options)?.statement ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Keep the access keys separate from projection-only keys so an older, wider
|
|
305
|
+
// index can satisfy the same request without depending on its generated name.
|
|
306
|
+
function buildCoveringIndexDefinition(query, options) {
|
|
321
307
|
const { bucket, scope = "_default", collection = "_default" } = options;
|
|
322
308
|
|
|
323
309
|
if (!bucket) {
|
|
@@ -326,9 +312,9 @@ function buildCoveringIndex(query, options) {
|
|
|
326
312
|
|
|
327
313
|
// Extract fields from SELECT clause
|
|
328
314
|
const selectFields = extractSelectFields(query);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
315
|
+
if (selectFields === null) {
|
|
316
|
+
// Full-document projections cannot be covered by the extracted fields.
|
|
317
|
+
// Skip them instead of creating a non-covering fallback index.
|
|
332
318
|
return null;
|
|
333
319
|
}
|
|
334
320
|
|
|
@@ -344,44 +330,41 @@ function buildCoveringIndex(query, options) {
|
|
|
344
330
|
const orderByFields = orderByIndexFields.map((field) => field.name);
|
|
345
331
|
|
|
346
332
|
// Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
|
|
347
|
-
const filteredWhereFields = whereFields.filter(
|
|
348
|
-
(f) => f.toLowerCase() !== "deleted",
|
|
349
|
-
);
|
|
333
|
+
const filteredWhereFields = whereFields.filter((f) => f !== "deleted");
|
|
350
334
|
|
|
351
335
|
// Track fields already added to avoid duplicates
|
|
352
|
-
const addedFields = new Set(filteredWhereFields
|
|
336
|
+
const addedFields = new Set(filteredWhereFields);
|
|
353
337
|
|
|
354
338
|
// Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
|
|
355
339
|
const filteredGroupByFields = groupByFields.filter((f) => {
|
|
356
|
-
|
|
357
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
340
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
358
341
|
return false;
|
|
359
342
|
}
|
|
360
|
-
addedFields.add(
|
|
343
|
+
addedFields.add(f);
|
|
361
344
|
return true;
|
|
362
345
|
});
|
|
363
346
|
|
|
364
347
|
// Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
|
|
365
348
|
const filteredOrderByFields = orderByFields.filter((f) => {
|
|
366
|
-
|
|
367
|
-
if (addedFields.has(lower) || lower === "deleted") {
|
|
349
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
368
350
|
return false;
|
|
369
351
|
}
|
|
370
|
-
addedFields.add(
|
|
352
|
+
addedFields.add(f);
|
|
371
353
|
return true;
|
|
372
354
|
});
|
|
373
355
|
|
|
374
356
|
// Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
|
|
375
|
-
const selectFieldsFiltered =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
357
|
+
const selectFieldsFiltered = selectFields
|
|
358
|
+
.filter((f) => {
|
|
359
|
+
if (addedFields.has(f) || f === "deleted") {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
addedFields.add(f);
|
|
363
|
+
return true;
|
|
364
|
+
})
|
|
365
|
+
.sort();
|
|
383
366
|
|
|
384
|
-
//
|
|
367
|
+
// Projection order has no effect on coverage. Keep access-key order intact.
|
|
385
368
|
const allFields = [
|
|
386
369
|
...filteredWhereFields,
|
|
387
370
|
...filteredGroupByFields,
|
|
@@ -395,33 +378,90 @@ function buildCoveringIndex(query, options) {
|
|
|
395
378
|
}
|
|
396
379
|
|
|
397
380
|
// Build the field list with deleted INCLUDE MISSING first
|
|
398
|
-
const
|
|
381
|
+
const keys = [
|
|
399
382
|
"`deleted` INCLUDE MISSING",
|
|
400
383
|
...allFields.map((f) => formatIndexField(f, orderDirectionMap)),
|
|
401
|
-
]
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
const
|
|
406
|
-
.map((field) => formatIndexKey(field, orderDirectionMap))
|
|
407
|
-
.sort()
|
|
408
|
-
.join(",");
|
|
409
|
-
const orderFieldsKey = orderByIndexFields
|
|
410
|
-
.map((field) => formatIndexKey(field.name, orderDirectionMap))
|
|
411
|
-
.join(",");
|
|
412
|
-
const indexMode = isNonCoveringFallback ? "noncovered" : "covered";
|
|
413
|
-
const indexKey = `${bucket}|${scope}|${collection}|mode:${indexMode}|fields:${indexFieldsKey}|order:${orderFieldsKey}`;
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
// Hash the actual ordered definition, not query syntax. In particular, an
|
|
387
|
+
// explicit ORDER BY ... ASC must not rename an otherwise identical index.
|
|
388
|
+
const indexKey = JSON.stringify([bucket, scope, collection, keys]);
|
|
414
389
|
const hash = generateHash(indexKey);
|
|
415
|
-
const indexName = `
|
|
390
|
+
const indexName = `adv_covered_${hash}`;
|
|
416
391
|
|
|
417
392
|
// Build the CREATE INDEX statement
|
|
418
|
-
const
|
|
393
|
+
const quote = (identifier) => `\`${identifier.replace(/`/g, "``")}\``;
|
|
394
|
+
const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:${quote(bucket)}.${quote(scope)}.${quote(collection)}(${keys.join(", ")})`;
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
name: indexName,
|
|
398
|
+
statement: indexStatement,
|
|
399
|
+
keys,
|
|
400
|
+
accessKeyCount:
|
|
401
|
+
1 + filteredWhereFields.length + filteredGroupByFields.length + filteredOrderByFields.length,
|
|
402
|
+
bucket,
|
|
403
|
+
scope,
|
|
404
|
+
collection,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
419
407
|
|
|
420
|
-
|
|
408
|
+
// Only compare simple scalar keys. Do not treat expressions, array indexes,
|
|
409
|
+
// partial indexes, or case-distinct document fields as equivalent.
|
|
410
|
+
function normalizeIndexKey(key) {
|
|
411
|
+
if (typeof key !== "string") return null;
|
|
412
|
+
const match = key.trim().match(
|
|
413
|
+
/^(\(*\s*(?:`(?:[^`]|``)+`|[A-Za-z_]\w*)\s*\)*)(.*?)$/s,
|
|
414
|
+
);
|
|
415
|
+
if (!match) return null;
|
|
416
|
+
let expression = match[1].trim();
|
|
417
|
+
while (expression.startsWith("(") && expression.endsWith(")")) {
|
|
418
|
+
expression = expression.slice(1, -1).trim();
|
|
419
|
+
}
|
|
420
|
+
const identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
|
|
421
|
+
if (!identifier) return null;
|
|
422
|
+
const attributes = match[2].trim().toUpperCase();
|
|
423
|
+
if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
field: identifier[1] !== undefined
|
|
428
|
+
? identifier[1].replace(/``/g, "`")
|
|
429
|
+
: identifier[2],
|
|
430
|
+
direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
|
|
431
|
+
includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Call with indexes from the candidate's exact bucket/scope/collection. State
|
|
436
|
+
// is deliberately not filtered: a matching deferred/building index is already
|
|
437
|
+
// committed work, even though EXPLAIN cannot use it yet.
|
|
438
|
+
function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
439
|
+
if (
|
|
440
|
+
index.is_primary || index.condition || index.using !== "gsi" ||
|
|
441
|
+
!Array.isArray(index.index_key)
|
|
442
|
+
) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
const existingKeys = index.index_key.map(normalizeIndexKey);
|
|
446
|
+
const requiredKeys = candidate.keys.map(normalizeIndexKey);
|
|
447
|
+
if (existingKeys.some((key) => !key) || requiredKeys.some((key) => !key)) return false;
|
|
448
|
+
for (let i = 0; i < candidate.accessKeyCount; i++) {
|
|
449
|
+
const existing = existingKeys[i];
|
|
450
|
+
const required = requiredKeys[i];
|
|
451
|
+
if (
|
|
452
|
+
!existing || existing.field !== required.field ||
|
|
453
|
+
existing.direction !== required.direction ||
|
|
454
|
+
existing.includeMissing !== required.includeMissing
|
|
455
|
+
) return false;
|
|
456
|
+
}
|
|
457
|
+
const existingFields = new Set(existingKeys.map((key) => key.field));
|
|
458
|
+
return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
|
|
421
459
|
}
|
|
422
460
|
|
|
423
461
|
module.exports = {
|
|
424
462
|
buildCoveringIndex,
|
|
463
|
+
buildCoveringIndexDefinition,
|
|
464
|
+
indexSatisfiesCoveringDefinition,
|
|
425
465
|
extractWhereFields,
|
|
426
466
|
extractSelectFields,
|
|
427
467
|
extractGroupByFields,
|
package/src/utility.test.js
CHANGED
|
@@ -599,16 +599,16 @@ test("returns null when missing bucket option", () => {
|
|
|
599
599
|
assert.strictEqual(result, null);
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
-
test("
|
|
602
|
+
test("uses the default scope when scope option is missing", () => {
|
|
603
603
|
const query = "SELECT name FROM users WHERE status = 'active'";
|
|
604
604
|
const result = buildCoveringIndex(query, { bucket: "test", collection: "users" });
|
|
605
|
-
assert.
|
|
605
|
+
assert.ok(result.includes("`test`.`_default`.`users`"));
|
|
606
606
|
});
|
|
607
607
|
|
|
608
|
-
test("
|
|
608
|
+
test("uses the default collection when collection option is missing", () => {
|
|
609
609
|
const query = "SELECT name FROM users WHERE status = 'active'";
|
|
610
610
|
const result = buildCoveringIndex(query, { bucket: "test", scope: "scope" });
|
|
611
|
-
assert.
|
|
611
|
+
assert.ok(result.includes("`test`.`scope`.`_default`"));
|
|
612
612
|
});
|
|
613
613
|
|
|
614
614
|
test("generates unique index name from query hash", () => {
|