spice-couchbase 2.0.63 → 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/build/connectionHealth.js +114 -149
- package/build/connectionHealth.test.js +301 -316
- package/build/index.auto-optimize.test.js +122 -204
- package/build/index.count-query.test.js +27 -63
- package/build/index.index-management.test.js +283 -0
- package/build/index.js +486 -418
- package/build/index.multi_get.test.js +9 -56
- package/build/index.query-consistency.test.js +28 -72
- package/build/utility.js +182 -152
- package/build/utility.test.js +79 -59
- package/package.json +1 -1
- package/src/connectionHealth.js +48 -7
- package/src/connectionHealth.test.js +53 -4
- package/src/index.index-management.test.js +232 -0
- package/src/index.js +270 -8
- package/src/utility.js +78 -0
|
@@ -0,0 +1,232 @@
|
|
|
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 {
|
|
9
|
+
statementShape,
|
|
10
|
+
planIndexNames,
|
|
11
|
+
usesManagedIndex,
|
|
12
|
+
renderIndexKey,
|
|
13
|
+
BASE_INDEX,
|
|
14
|
+
} = require("./utility");
|
|
15
|
+
|
|
16
|
+
// Same isolation as index.auto-optimize.test.js: a fresh module per load, so
|
|
17
|
+
// the seen-set, cooldown and handler never leak between tests.
|
|
18
|
+
function loadDriver(durationMs, env = {}) {
|
|
19
|
+
let tick = BigInt(0);
|
|
20
|
+
const module = { exports: {} };
|
|
21
|
+
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
|
|
22
|
+
require,
|
|
23
|
+
module,
|
|
24
|
+
console,
|
|
25
|
+
process: {
|
|
26
|
+
env,
|
|
27
|
+
hrtime: {
|
|
28
|
+
bigint: () => {
|
|
29
|
+
const current = tick;
|
|
30
|
+
tick += BigInt(durationMs) * BigInt(1000000);
|
|
31
|
+
return current;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return module.exports;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function recordingCluster(rowsFor = () => []) {
|
|
40
|
+
const calls = [];
|
|
41
|
+
return {
|
|
42
|
+
calls,
|
|
43
|
+
async query(statement, options) {
|
|
44
|
+
calls.push({ statement, options });
|
|
45
|
+
return { rows: rowsFor(statement), meta: {} };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createDatabase(Database, cluster) {
|
|
51
|
+
const db = Object.create(Database.prototype);
|
|
52
|
+
db.bucket = "saturn";
|
|
53
|
+
db.scope_name = "test";
|
|
54
|
+
db.collection_name = "users";
|
|
55
|
+
db.db_config = { bucket: "saturn" };
|
|
56
|
+
db.auto_optimize = true;
|
|
57
|
+
db.cluster = cluster;
|
|
58
|
+
db.scope = cluster;
|
|
59
|
+
return db;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const KEYSPACE = "default:`saturn`.`test`.`users`";
|
|
63
|
+
|
|
64
|
+
test("statement shapes ignore literal values and IN list lengths", () => {
|
|
65
|
+
assert.equal(
|
|
66
|
+
statementShape("SELECT * FROM `users` WHERE email = 'a@b.c' AND age > 30 AND id IN ['x', 'y']"),
|
|
67
|
+
statementShape("SELECT * FROM `users` WHERE email = \"d@e.f\" AND age > 7 AND id IN ['z']"),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("managed indexes are recognised in a plan, the base fallback is not", () => {
|
|
72
|
+
const plan = (index) => ({ "#operator": "Sequence", "~children": [{ "#operator": "IndexScan3", index }] });
|
|
73
|
+
assert.deepEqual(planIndexNames(plan("ix_sort_1a2b3c_9f0e")), ["ix_sort_1a2b3c_9f0e"]);
|
|
74
|
+
assert.equal(usesManagedIndex(plan("ix_sort_1a2b3c_9f0e")), true);
|
|
75
|
+
assert.equal(usesManagedIndex(plan("ix_base")), false);
|
|
76
|
+
assert.equal(usesManagedIndex(plan("adv_covered_1a3952f8")), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("index keys render direction and INCLUDE MISSING", () => {
|
|
80
|
+
assert.deepEqual(BASE_INDEX.keys.map(renderIndexKey), ["`created_at` DESC INCLUDE MISSING", "`deleted`"]);
|
|
81
|
+
assert.equal(renderIndexKey("`name`"), "`name`");
|
|
82
|
+
assert.throws(() => renderIndexKey({}), TypeError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("createIndex is idempotent, deferred by default and fully qualified", async () => {
|
|
86
|
+
const cluster = recordingCluster();
|
|
87
|
+
const db = createDatabase(loadDriver(0), cluster);
|
|
88
|
+
await db.createIndex({
|
|
89
|
+
name: "ix_sort_1a2b3c_9f0e",
|
|
90
|
+
keys: [{ expr: "`priority`", desc: true, include_missing: true }, { expr: "IFMISSINGORNULL(`due_at`, {})" }],
|
|
91
|
+
where: "IFMISSING(`deleted`, false) = false",
|
|
92
|
+
num_replica: 1,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(
|
|
95
|
+
cluster.calls[0].statement,
|
|
96
|
+
`CREATE INDEX IF NOT EXISTS \`ix_sort_1a2b3c_9f0e\` ON ${KEYSPACE}` +
|
|
97
|
+
"(`priority` DESC INCLUDE MISSING, IFMISSINGORNULL(`due_at`, {})) " +
|
|
98
|
+
'WHERE IFMISSING(`deleted`, false) = false WITH {"defer_build":true,"num_replica":1}',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("build, drop and primary drop target the collection", async () => {
|
|
103
|
+
const cluster = recordingCluster();
|
|
104
|
+
const db = createDatabase(loadDriver(0), cluster);
|
|
105
|
+
await db.buildIndexes(["ix_a", "ix_b", "ix_a"]);
|
|
106
|
+
await db.dropIndex("ix_a");
|
|
107
|
+
await db.dropPrimaryIndex();
|
|
108
|
+
assert.deepEqual(cluster.calls.map(({ statement }) => statement), [
|
|
109
|
+
`BUILD INDEX ON ${KEYSPACE}(\`ix_a\`, \`ix_b\`)`,
|
|
110
|
+
`DROP INDEX IF EXISTS \`ix_a\` ON ${KEYSPACE}`,
|
|
111
|
+
`DROP PRIMARY INDEX IF EXISTS ON ${KEYSPACE}`,
|
|
112
|
+
]);
|
|
113
|
+
assert.equal(await db.buildIndexes([]), null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a partial index inventory is an error, not an empty list", async () => {
|
|
117
|
+
const Database = loadDriver(0);
|
|
118
|
+
const db = createDatabase(Database, {
|
|
119
|
+
async query() {
|
|
120
|
+
return { rows: [], meta: { warnings: [{ msg: "partial" }] } };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await assert.rejects(() => db.listIndexes(), /index inventory/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the client context id reaches the SDK options", async () => {
|
|
127
|
+
const cluster = recordingCluster();
|
|
128
|
+
const db = createDatabase(loadDriver(0), cluster);
|
|
129
|
+
await db.query("SELECT 1", "cluster", { client_context_id: "scheduler.due" });
|
|
130
|
+
assert.equal(cluster.calls[0].options.clientContextId, "scheduler.due");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function scanTimeout() {
|
|
134
|
+
const error = new Error("Index scan timed out");
|
|
135
|
+
error.cause = { first_error_code: 12015, first_error_message: "Index scan timed out" };
|
|
136
|
+
return error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test("an expired scan_wait reruns without waiting only when the caller allows it", async () => {
|
|
140
|
+
const Database = loadDriver(0);
|
|
141
|
+
const attempts = [];
|
|
142
|
+
const cluster = {
|
|
143
|
+
async query(statement, options) {
|
|
144
|
+
attempts.push(options);
|
|
145
|
+
if (attempts.length === 1) throw scanTimeout();
|
|
146
|
+
return { rows: [{ ok: true }] };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const db = createDatabase(Database, cluster);
|
|
150
|
+
const response = await db.query("SELECT 1", "cluster", {
|
|
151
|
+
scan_consistency: "request_plus",
|
|
152
|
+
scan_wait: 2000,
|
|
153
|
+
scan_wait_fallback: true,
|
|
154
|
+
});
|
|
155
|
+
assert.deepEqual(response.rows, [{ ok: true }]);
|
|
156
|
+
assert.equal(attempts.length, 2);
|
|
157
|
+
assert.equal(attempts[1].scanWait, undefined);
|
|
158
|
+
assert.notEqual(attempts[1].scanConsistency, attempts[0].scanConsistency);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("without the fallback an expired scan_wait is an error", async () => {
|
|
162
|
+
const Database = loadDriver(0);
|
|
163
|
+
const db = createDatabase(Database, {
|
|
164
|
+
async query() {
|
|
165
|
+
throw scanTimeout();
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() => db.query("SELECT 1", "cluster", { scan_consistency: "request_plus", scan_wait: 2000 }),
|
|
170
|
+
/Index scan timed out/,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("with a handler installed, slow statements are handed off once per shape and never indexed here", async () => {
|
|
175
|
+
const Database = loadDriver(5000);
|
|
176
|
+
const cluster = recordingCluster((statement) =>
|
|
177
|
+
statement.startsWith("EXPLAIN ") ? [{ "#operator": "IndexScan3", index: "ix_base" }] : [],
|
|
178
|
+
);
|
|
179
|
+
const db = createDatabase(Database, cluster);
|
|
180
|
+
const reports = [];
|
|
181
|
+
Database.setSlowStatementHandler(async (report) => reports.push(report));
|
|
182
|
+
let legacy = 0;
|
|
183
|
+
db.optimize = async () => { legacy += 1; };
|
|
184
|
+
|
|
185
|
+
await db.query("SELECT name FROM users WHERE email = 'a@b.c'");
|
|
186
|
+
await db.query("SELECT name FROM users WHERE email = 'd@e.f'");
|
|
187
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
188
|
+
|
|
189
|
+
assert.equal(reports.length, 1);
|
|
190
|
+
assert.equal(reports[0].shape, "SELECT name FROM users WHERE email = ?");
|
|
191
|
+
assert.equal(reports[0].collection, "users");
|
|
192
|
+
assert.equal(legacy, 0);
|
|
193
|
+
assert.equal(cluster.calls.some(({ statement }) => statement.startsWith("CREATE ")), false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a slow statement already served by a managed index is not reported", async () => {
|
|
197
|
+
const Database = loadDriver(5000);
|
|
198
|
+
const cluster = recordingCluster((statement) =>
|
|
199
|
+
statement.startsWith("EXPLAIN ") ? [{ "#operator": "IndexScan3", index: "ix_sort_1a2b3c_9f0e" }] : [],
|
|
200
|
+
);
|
|
201
|
+
const db = createDatabase(Database, cluster);
|
|
202
|
+
const reports = [];
|
|
203
|
+
Database.setSlowStatementHandler(async (report) => reports.push(report));
|
|
204
|
+
await db.query("SELECT name FROM users ORDER BY priority DESC");
|
|
205
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
206
|
+
assert.equal(reports.length, 0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("without a handler the legacy covering-index path still runs", async () => {
|
|
210
|
+
const Database = loadDriver(5000);
|
|
211
|
+
const db = createDatabase(Database, recordingCluster());
|
|
212
|
+
let legacy = 0;
|
|
213
|
+
db.optimize = async () => { legacy += 1; };
|
|
214
|
+
await db.query("SELECT name FROM users WHERE status = 'active'");
|
|
215
|
+
assert.equal(legacy, 1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a new collection gets the base index instead of a primary index when asked", async () => {
|
|
219
|
+
const Database = loadDriver(0);
|
|
220
|
+
const cluster = recordingCluster();
|
|
221
|
+
const db = createDatabase(Database, cluster);
|
|
222
|
+
db.db_config.primary_index = false;
|
|
223
|
+
db.checkGlobelConnectionObject = () => false;
|
|
224
|
+
db.collectionExists = async () => false;
|
|
225
|
+
db.createCollection = async () => {};
|
|
226
|
+
db.wait = async () => {};
|
|
227
|
+
let primary = 0;
|
|
228
|
+
db.createPrimaryIndex = async () => { primary += 1; };
|
|
229
|
+
await db.upsertCollection();
|
|
230
|
+
assert.equal(primary, 0);
|
|
231
|
+
assert.match(cluster.calls[0].statement, /^CREATE INDEX IF NOT EXISTS `ix_base` ON .*\(`created_at` DESC INCLUDE MISSING, `deleted`\) WITH \{"defer_build":false\}$/);
|
|
232
|
+
});
|
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 {
|
|
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
|
-
|
|
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
|
|
1213
|
-
|
|
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
|
-
|
|
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,
|