dbopt-engine 0.2.0
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/.env.example +19 -0
- package/README.md +143 -0
- package/bin/cli.js +51 -0
- package/package.json +46 -0
- package/src/adapters/index.js +20 -0
- package/src/adapters/mssql.js +236 -0
- package/src/adapters/mysql.js +177 -0
- package/src/adapters/postgres.js +137 -0
- package/src/config.js +140 -0
- package/src/executor.js +127 -0
- package/src/index.js +55 -0
- package/src/planParser.js +35 -0
- package/src/routes.js +160 -0
- package/src/scheduler.js +132 -0
- package/src/static/dashboard.html +551 -0
- package/src/static/topology.html +926 -0
- package/src/store.js +155 -0
- package/src/targets.js +17 -0
- package/src/topology.js +217 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** MySQL 8 adapter: performance_schema + EXPLAIN FORMAT=JSON + INVISIBLE
|
|
3
|
+
* index verification. FK-required and FULLTEXT indexes are protected. */
|
|
4
|
+
const mysql = require("mysql2/promise");
|
|
5
|
+
const { calcImprovementPct } = require("../planParser");
|
|
6
|
+
|
|
7
|
+
const engine = "mysql";
|
|
8
|
+
const supportsPartialIndexes = false;
|
|
9
|
+
|
|
10
|
+
const connect = (target) => mysql.createConnection({
|
|
11
|
+
host: target.host, port: target.port, database: target.database,
|
|
12
|
+
user: target.user, password: target.password,
|
|
13
|
+
});
|
|
14
|
+
const close = (c) => c.end().catch(() => {});
|
|
15
|
+
const ping = async (c) => { await c.query("SELECT 1"); };
|
|
16
|
+
|
|
17
|
+
async function ensurePrerequisites(target) {
|
|
18
|
+
const status = { checked: true, performance_schema: false, actions_needed: [] };
|
|
19
|
+
let c;
|
|
20
|
+
try {
|
|
21
|
+
c = await connect(target);
|
|
22
|
+
const [[row]] = await c.query("SELECT @@performance_schema AS ps");
|
|
23
|
+
if (row.ps === 1) status.performance_schema = true;
|
|
24
|
+
else status.actions_needed.push(
|
|
25
|
+
"performance_schema is OFF. Set performance_schema=ON in my.cnf and restart MySQL.");
|
|
26
|
+
} catch (e) {
|
|
27
|
+
status.actions_needed.push(`Could not connect to the target database: ${e.message}`);
|
|
28
|
+
} finally { if (c) close(c); }
|
|
29
|
+
Object.assign(target.prerequisites, status);
|
|
30
|
+
return status;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getSlowQueries(c, minCalls = 1, limit = 30) {
|
|
34
|
+
const [rows] = await c.query(`
|
|
35
|
+
SELECT DIGEST AS queryid, DIGEST_TEXT AS query, COUNT_STAR AS calls,
|
|
36
|
+
AVG_TIMER_WAIT / 1e9 AS mean_exec_time, SUM_TIMER_WAIT / 1e9 AS total_exec_time,
|
|
37
|
+
SUM_ROWS_SENT AS \`rows\`
|
|
38
|
+
FROM performance_schema.events_statements_summary_by_digest
|
|
39
|
+
WHERE SCHEMA_NAME = DATABASE() AND COUNT_STAR >= ?
|
|
40
|
+
AND DIGEST_TEXT NOT LIKE 'EXPLAIN%' AND DIGEST_TEXT NOT LIKE 'CREATE%'
|
|
41
|
+
AND DIGEST_TEXT NOT LIKE 'DROP%' AND DIGEST_TEXT NOT LIKE 'ALTER%'
|
|
42
|
+
AND DIGEST_TEXT NOT LIKE 'INSERT%' AND DIGEST_TEXT NOT LIKE 'SET %'
|
|
43
|
+
AND DIGEST_TEXT NOT LIKE '%performance_schema%' AND DIGEST_TEXT NOT LIKE '%information_schema%'
|
|
44
|
+
ORDER BY AVG_TIMER_WAIT DESC LIMIT ?;`, [minCalls, limit]);
|
|
45
|
+
return rows.map(r => ({ ...r, calls: Number(r.calls), mean_exec_time: Number(r.mean_exec_time),
|
|
46
|
+
total_exec_time: Number(r.total_exec_time), rows: Number(r.rows) }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getUnusedIndexes(c, target) {
|
|
50
|
+
const [rows] = await c.query(`
|
|
51
|
+
SELECT p.OBJECT_NAME AS table_name, p.INDEX_NAME AS index_name, p.COUNT_STAR AS idx_scan,
|
|
52
|
+
GROUP_CONCAT(s.COLUMN_NAME ORDER BY s.SEQ_IN_INDEX) AS columns_list
|
|
53
|
+
FROM performance_schema.table_io_waits_summary_by_index_usage p
|
|
54
|
+
JOIN information_schema.STATISTICS s
|
|
55
|
+
ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA AND s.TABLE_NAME = p.OBJECT_NAME
|
|
56
|
+
AND s.INDEX_NAME = p.INDEX_NAME
|
|
57
|
+
WHERE p.OBJECT_SCHEMA = DATABASE() AND p.INDEX_NAME IS NOT NULL
|
|
58
|
+
AND p.INDEX_NAME <> 'PRIMARY' AND p.COUNT_STAR = 0 AND s.NON_UNIQUE = 1
|
|
59
|
+
AND s.INDEX_TYPE = 'BTREE'
|
|
60
|
+
AND NOT EXISTS (
|
|
61
|
+
SELECT 1 FROM information_schema.STATISTICS s1
|
|
62
|
+
JOIN information_schema.KEY_COLUMN_USAGE k
|
|
63
|
+
ON k.TABLE_SCHEMA = s1.TABLE_SCHEMA AND k.TABLE_NAME = s1.TABLE_NAME
|
|
64
|
+
AND k.COLUMN_NAME = s1.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
65
|
+
WHERE s1.TABLE_SCHEMA = p.OBJECT_SCHEMA AND s1.TABLE_NAME = p.OBJECT_NAME
|
|
66
|
+
AND s1.INDEX_NAME = p.INDEX_NAME AND s1.SEQ_IN_INDEX = 1)
|
|
67
|
+
GROUP BY p.OBJECT_NAME, p.INDEX_NAME, p.COUNT_STAR;`);
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const r of rows) {
|
|
70
|
+
let size = 0;
|
|
71
|
+
try {
|
|
72
|
+
const [[sz]] = await c.query(
|
|
73
|
+
`SELECT stat_value * @@innodb_page_size AS b FROM mysql.innodb_index_stats
|
|
74
|
+
WHERE database_name = DATABASE() AND table_name = ? AND index_name = ? AND stat_name = 'size'`,
|
|
75
|
+
[r.table_name, r.index_name]);
|
|
76
|
+
size = sz ? Number(sz.b) : 0;
|
|
77
|
+
} catch { /* size is cosmetic */ }
|
|
78
|
+
out.push({ schemaname: target.database, table_name: r.table_name, index_name: r.index_name,
|
|
79
|
+
idx_scan: Number(r.idx_scan), size_bytes: size,
|
|
80
|
+
indexdef: `CREATE INDEX ${r.index_name} ON ${r.table_name} (${r.columns_list})` });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function getAllIndexesForTable(c, table) {
|
|
86
|
+
const [rows] = await c.query(`
|
|
87
|
+
SELECT INDEX_NAME AS n, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
|
88
|
+
FROM information_schema.STATISTICS
|
|
89
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? GROUP BY INDEX_NAME;`, [table]);
|
|
90
|
+
return rows.map(r => ({ indexname: r.n, indexdef: `INDEX ${r.n} ON ${table} (${r.cols})` }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function prettySize(b) {
|
|
94
|
+
for (const u of ["bytes", "kB", "MB", "GB"]) {
|
|
95
|
+
if (b < 1024) return `${Math.round(b)} ${u}`;
|
|
96
|
+
b /= 1024;
|
|
97
|
+
}
|
|
98
|
+
return `${Math.round(b)} TB`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function getTableGrowthStats(c) {
|
|
102
|
+
const [rows] = await c.query(`
|
|
103
|
+
SELECT TABLE_NAME AS table_name, COALESCE(TABLE_ROWS, 0) AS live_rows,
|
|
104
|
+
COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS total_size_bytes
|
|
105
|
+
FROM information_schema.TABLES
|
|
106
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
107
|
+
ORDER BY total_size_bytes DESC;`);
|
|
108
|
+
return rows.map(r => ({
|
|
109
|
+
table_name: r.table_name, live_rows: Number(r.live_rows), dead_rows: 0,
|
|
110
|
+
total_size: prettySize(Number(r.total_size_bytes)),
|
|
111
|
+
total_size_bytes: Number(r.total_size_bytes), seq_scan: 0, idx_scan: 0,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const substitute = (q, asString) =>
|
|
116
|
+
q.replace(/\.\.\./g, "?").replace(/\?/g, asString ? "'1'" : "1");
|
|
117
|
+
|
|
118
|
+
function walkTables(node, out) {
|
|
119
|
+
if (node && typeof node === "object") {
|
|
120
|
+
if (!Array.isArray(node) && node.access_type !== undefined && node.table_name !== undefined) out.push(node);
|
|
121
|
+
for (const v of Object.values(node)) walkTables(v, out);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function explainJson(c, safeQuery) {
|
|
127
|
+
const [rows] = await c.query(`EXPLAIN FORMAT=JSON ${safeQuery}`);
|
|
128
|
+
return JSON.parse(rows[0].EXPLAIN);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function analyzeQuery(c, queryText, minRows) {
|
|
132
|
+
for (const asString of [false, true]) {
|
|
133
|
+
const safeQuery = substitute(queryText, asString);
|
|
134
|
+
let explained;
|
|
135
|
+
try { explained = await explainJson(c, safeQuery); } catch { continue; }
|
|
136
|
+
const qb = explained.query_block || {};
|
|
137
|
+
const oldCost = parseFloat(qb.cost_info?.query_cost) || null;
|
|
138
|
+
const findings = [];
|
|
139
|
+
for (const t of walkTables(qb, [])) {
|
|
140
|
+
if (t.access_type !== "ALL") continue;
|
|
141
|
+
const rows = t.rows_examined_per_scan || 0;
|
|
142
|
+
if (rows <= minRows) continue;
|
|
143
|
+
let filter = (t.attached_condition || "").replace(/`/g, "");
|
|
144
|
+
filter = filter.replace(/\b\w+\.(\w+\.)?/g, "");
|
|
145
|
+
findings.push({ table: t.table_name, filter: filter || null, rows_scanned: rows, cost: null });
|
|
146
|
+
}
|
|
147
|
+
return { oldCost, findings, safeQuery };
|
|
148
|
+
}
|
|
149
|
+
return { oldCost: null, findings: [], safeQuery: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function verifyIndexBenefit(c, safeQuery, table, columns, predicate, oldCost) {
|
|
153
|
+
const { indexNameFor, validateIdentifier } = require("../executor");
|
|
154
|
+
if (predicate) throw new Error("MySQL does not support partial indexes");
|
|
155
|
+
validateIdentifier(table);
|
|
156
|
+
columns.forEach(validateIdentifier);
|
|
157
|
+
const indexName = indexNameFor(engine, table, columns);
|
|
158
|
+
let created = false;
|
|
159
|
+
try {
|
|
160
|
+
await c.query(`CREATE INDEX ${indexName} ON ${table} (${columns.join(", ")}) INVISIBLE`);
|
|
161
|
+
created = true;
|
|
162
|
+
await c.query("SET SESSION optimizer_switch = 'use_invisible_indexes=on'");
|
|
163
|
+
const plan = await explainJson(c, safeQuery);
|
|
164
|
+
const newCost = parseFloat(plan.query_block?.cost_info?.query_cost) || oldCost;
|
|
165
|
+
return { improvement: calcImprovementPct(oldCost, newCost), newCost };
|
|
166
|
+
} finally {
|
|
167
|
+
await c.query("SET SESSION optimizer_switch = 'use_invisible_indexes=off'").catch(() => {});
|
|
168
|
+
if (created) await c.query(`DROP INDEX ${indexName} ON ${table}`).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
engine, supportsPartialIndexes,
|
|
174
|
+
connect, close, ping, ensurePrerequisites,
|
|
175
|
+
getSlowQueries, getUnusedIndexes, getAllIndexesForTable, getTableGrowthStats,
|
|
176
|
+
analyzeQuery, verifyIndexBenefit,
|
|
177
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** PostgreSQL adapter: pg_stat_statements + EXPLAIN ANALYZE + HypoPG. */
|
|
3
|
+
const { Client } = require("pg");
|
|
4
|
+
const { calcImprovementPct } = require("../planParser");
|
|
5
|
+
|
|
6
|
+
const engine = "postgres";
|
|
7
|
+
const supportsPartialIndexes = true;
|
|
8
|
+
|
|
9
|
+
async function connect(target) {
|
|
10
|
+
const c = new Client({
|
|
11
|
+
host: target.host, port: target.port, database: target.database,
|
|
12
|
+
user: target.user, password: target.password,
|
|
13
|
+
});
|
|
14
|
+
await c.connect();
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
17
|
+
const close = (c) => c.end().catch(() => {});
|
|
18
|
+
const ping = async (c) => { await c.query("SELECT 1"); };
|
|
19
|
+
|
|
20
|
+
async function ensurePrerequisites(target) {
|
|
21
|
+
const status = { checked: true, pg_stat_statements: false, hypopg: false, actions_needed: [] };
|
|
22
|
+
let c;
|
|
23
|
+
try {
|
|
24
|
+
c = await connect(target);
|
|
25
|
+
for (const ext of ["pg_stat_statements", "hypopg"]) {
|
|
26
|
+
try { await c.query(`CREATE EXTENSION IF NOT EXISTS ${ext};`); }
|
|
27
|
+
catch (e) {
|
|
28
|
+
const msg = String(e.message).split("\n")[0];
|
|
29
|
+
status.actions_needed.push(
|
|
30
|
+
/is not available|extension control file/.test(msg)
|
|
31
|
+
? `Extension '${ext}' is not installed on the server. Install the OS package and restart.`
|
|
32
|
+
: `Could not create extension '${ext}': ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try { await c.query("SELECT count(*) FROM pg_stat_statements;"); status.pg_stat_statements = true; }
|
|
36
|
+
catch { status.actions_needed.push(
|
|
37
|
+
"pg_stat_statements needs preloading: ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; then restart PostgreSQL."); }
|
|
38
|
+
try { await c.query("SELECT hypopg_reset();"); status.hypopg = true; }
|
|
39
|
+
catch { if (!status.actions_needed.some(a => a.includes("hypopg")))
|
|
40
|
+
status.actions_needed.push("hypopg extension exists but is not functional."); }
|
|
41
|
+
} catch (e) {
|
|
42
|
+
status.actions_needed.push(`Could not connect to the target database: ${e.message}`);
|
|
43
|
+
} finally { if (c) close(c); }
|
|
44
|
+
Object.assign(target.prerequisites, status);
|
|
45
|
+
return status;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getSlowQueries(c, minCalls = 1, limit = 30) {
|
|
49
|
+
const { rows } = await c.query(`
|
|
50
|
+
SELECT queryid, query, calls, mean_exec_time, total_exec_time, rows
|
|
51
|
+
FROM pg_stat_statements
|
|
52
|
+
WHERE calls >= $1
|
|
53
|
+
AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
54
|
+
AND query NOT ILIKE '%pg_stat_statements%' AND query NOT ILIKE 'EXPLAIN%'
|
|
55
|
+
AND query NOT ILIKE 'CREATE%' AND query NOT ILIKE 'DROP%'
|
|
56
|
+
AND query NOT ILIKE 'ALTER%' AND query NOT ILIKE 'INSERT%' AND query NOT ILIKE 'DO %'
|
|
57
|
+
ORDER BY mean_exec_time DESC LIMIT $2;`, [minCalls, limit]);
|
|
58
|
+
return rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function getUnusedIndexes(c, target) {
|
|
62
|
+
const { rows } = await c.query(`
|
|
63
|
+
SELECT s.schemaname, s.relname AS table_name, s.indexrelname AS index_name,
|
|
64
|
+
s.idx_scan, pg_relation_size(s.indexrelid) AS size_bytes, pi.indexdef
|
|
65
|
+
FROM pg_stat_user_indexes s
|
|
66
|
+
JOIN pg_index i ON s.indexrelid = i.indexrelid
|
|
67
|
+
JOIN pg_indexes pi ON pi.indexname = s.indexrelname
|
|
68
|
+
WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
|
|
69
|
+
ORDER BY pg_relation_size(s.indexrelid) DESC;`);
|
|
70
|
+
return rows.map(r => ({ ...r, size_bytes: Number(r.size_bytes), idx_scan: Number(r.idx_scan) }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getAllIndexesForTable(c, table) {
|
|
74
|
+
const { rows } = await c.query(
|
|
75
|
+
"SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;", [table]);
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function getTableGrowthStats(c) {
|
|
80
|
+
const { rows } = await c.query(`
|
|
81
|
+
SELECT relname AS table_name, n_live_tup AS live_rows, n_dead_tup AS dead_rows,
|
|
82
|
+
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
|
|
83
|
+
pg_total_relation_size(relid) AS total_size_bytes,
|
|
84
|
+
seq_scan, COALESCE(idx_scan, 0) AS idx_scan
|
|
85
|
+
FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC;`);
|
|
86
|
+
return rows.map(r => ({ ...r, live_rows: Number(r.live_rows), dead_rows: Number(r.dead_rows),
|
|
87
|
+
total_size_bytes: Number(r.total_size_bytes), seq_scan: Number(r.seq_scan), idx_scan: Number(r.idx_scan) }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const substitute = (q, asString) => q.replace(/\$\d+/g, asString ? "'1'" : "1");
|
|
91
|
+
|
|
92
|
+
function findSeqScans(node, out, minRows) {
|
|
93
|
+
if (["Seq Scan", "Parallel Seq Scan"].includes(node["Node Type"])) {
|
|
94
|
+
const scanned = (node["Actual Rows"] || 0) + (node["Rows Removed by Filter"] || 0);
|
|
95
|
+
if (scanned > minRows) out.push({
|
|
96
|
+
table: node["Relation Name"], filter: node["Filter"] || null,
|
|
97
|
+
rows_scanned: scanned, cost: node["Total Cost"],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const child of node.Plans || []) findSeqScans(child, out, minRows);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function analyzeQuery(c, queryText, minRows) {
|
|
105
|
+
for (const asString of [false, true]) {
|
|
106
|
+
const safeQuery = substitute(queryText, asString);
|
|
107
|
+
try {
|
|
108
|
+
const { rows } = await c.query(`EXPLAIN (ANALYZE, FORMAT JSON) ${safeQuery}`);
|
|
109
|
+
const plan = rows[0]["QUERY PLAN"][0].Plan;
|
|
110
|
+
return { oldCost: plan["Total Cost"], findings: findSeqScans(plan, [], minRows), safeQuery };
|
|
111
|
+
} catch { await c.query("ROLLBACK").catch(() => {}); }
|
|
112
|
+
}
|
|
113
|
+
return { oldCost: null, findings: [], safeQuery: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function verifyIndexBenefit(c, safeQuery, table, columns, predicate, oldCost) {
|
|
117
|
+
const { predicateToSql, validateIdentifier } = require("../executor");
|
|
118
|
+
validateIdentifier(table);
|
|
119
|
+
columns.forEach(validateIdentifier);
|
|
120
|
+
let create = `CREATE INDEX ON ${table} (${columns.join(", ")})`;
|
|
121
|
+
if (predicate) create += ` WHERE ${predicateToSql(predicate)}`;
|
|
122
|
+
try {
|
|
123
|
+
await c.query("SELECT * FROM hypopg_create_index($1)", [create]);
|
|
124
|
+
const { rows } = await c.query(`EXPLAIN (FORMAT JSON) ${safeQuery}`);
|
|
125
|
+
const newCost = rows[0]["QUERY PLAN"][0].Plan["Total Cost"] ?? oldCost;
|
|
126
|
+
return { improvement: calcImprovementPct(oldCost, newCost), newCost };
|
|
127
|
+
} finally {
|
|
128
|
+
await c.query("SELECT hypopg_reset();").catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
engine, supportsPartialIndexes,
|
|
134
|
+
connect, close, ping, ensurePrerequisites,
|
|
135
|
+
getSlowQueries, getUnusedIndexes, getAllIndexesForTable, getTableGrowthStats,
|
|
136
|
+
analyzeQuery, verifyIndexBenefit,
|
|
137
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
require("dotenv").config();
|
|
3
|
+
|
|
4
|
+
const SCHEME_TO_ENGINE = {
|
|
5
|
+
postgres: "postgres", postgresql: "postgres",
|
|
6
|
+
mysql: "mysql", mariadb: "mysql",
|
|
7
|
+
mssql: "mssql", sqlserver: "mssql",
|
|
8
|
+
oracle: "oracle", mongodb: "mongodb", mongo: "mongodb",
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, mssql: 1433, oracle: 1521, mongodb: 27017 };
|
|
11
|
+
|
|
12
|
+
function env(name, fallback) {
|
|
13
|
+
const v = process.env[name];
|
|
14
|
+
return v === undefined || v === "" ? fallback : v;
|
|
15
|
+
}
|
|
16
|
+
const bool = (v) => String(v).toLowerCase() === "true";
|
|
17
|
+
|
|
18
|
+
// Every setting must be provided explicitly — via a .env file in the working
|
|
19
|
+
// directory, real environment variables, or the matching CLI flag.
|
|
20
|
+
const REQUIRED_VARS = [
|
|
21
|
+
"DATABASE_URL",
|
|
22
|
+
"PORT",
|
|
23
|
+
"SCAN_INTERVAL_MINUTES",
|
|
24
|
+
"MIN_ROWS_FOR_SEQ_SCAN_FLAG",
|
|
25
|
+
"MIN_IMPROVEMENT_PCT_TO_RECOMMEND",
|
|
26
|
+
"AUTO_CREATE_INDEXES",
|
|
27
|
+
"AUTO_DROP_UNUSED_INDEXES",
|
|
28
|
+
"INDEX_NAME_PREFIX",
|
|
29
|
+
"UNUSED_INDEX_GRACE_MINUTES",
|
|
30
|
+
"REAPPLY_COOLDOWN_MINUTES",
|
|
31
|
+
"STORAGE_LIMIT_MB",
|
|
32
|
+
"STATE_DB_PATH",
|
|
33
|
+
];
|
|
34
|
+
{
|
|
35
|
+
const missing = REQUIRED_VARS.filter((n) => env(n) === undefined);
|
|
36
|
+
if (missing.length) {
|
|
37
|
+
console.error(
|
|
38
|
+
`dbopt: missing required environment variables:\n` +
|
|
39
|
+
missing.map((n) => ` - ${n}`).join("\n") +
|
|
40
|
+
`\n\nCreate a .env file in the directory you run dbopt from,\n` +
|
|
41
|
+
`defining all of them (copy .env.example from the package for a template):\n` +
|
|
42
|
+
` node_modules/dbopt-engine/.env.example`
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const settings = {
|
|
49
|
+
httpPort: parseInt(env("PORT", "8000"), 10),
|
|
50
|
+
scanIntervalMinutes: parseInt(env("SCAN_INTERVAL_MINUTES", "15"), 10),
|
|
51
|
+
minRowsForSeqScanFlag: parseInt(env("MIN_ROWS_FOR_SEQ_SCAN_FLAG", "1000"), 10),
|
|
52
|
+
minImprovementPct: parseFloat(env("MIN_IMPROVEMENT_PCT_TO_RECOMMEND", "30")),
|
|
53
|
+
autoCreateIndexes: bool(env("AUTO_CREATE_INDEXES", "true")),
|
|
54
|
+
autoDropUnusedIndexes: bool(env("AUTO_DROP_UNUSED_INDEXES", "false")),
|
|
55
|
+
indexNamePrefix: env("INDEX_NAME_PREFIX", "opt"),
|
|
56
|
+
unusedIndexGraceMinutes: parseInt(env("UNUSED_INDEX_GRACE_MINUTES", "60"), 10),
|
|
57
|
+
reapplyCooldownMinutes: parseInt(env("REAPPLY_COOLDOWN_MINUTES", "60"), 10),
|
|
58
|
+
storageLimitMb: parseInt(env("STORAGE_LIMIT_MB", "0"), 10),
|
|
59
|
+
historyRetentionDays: parseInt(env("HISTORY_RETENTION_DAYS", "90"), 10),
|
|
60
|
+
stateDbPath: env("STATE_DB_PATH", "optimizer_state.db"),
|
|
61
|
+
targets: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Parse one connection URL: engine://user:pass@host:port/db, plus the
|
|
65
|
+
* ADO-style variant engine://host:port;database=..;user=..;password=.. */
|
|
66
|
+
function parseDatabaseUrl(url) {
|
|
67
|
+
url = url.trim().replace(/^["']|["']$/g, "");
|
|
68
|
+
const sep = url.indexOf("://");
|
|
69
|
+
if (sep < 0) throw new Error("DATABASE_URL must look like engine://user:pass@host:port/dbname");
|
|
70
|
+
const scheme = url.slice(0, sep).toLowerCase();
|
|
71
|
+
const rest = url.slice(sep + 3);
|
|
72
|
+
const engine = SCHEME_TO_ENGINE[scheme];
|
|
73
|
+
if (!engine) throw new Error(`Unknown DATABASE_URL scheme '${scheme}'`);
|
|
74
|
+
|
|
75
|
+
const t = {
|
|
76
|
+
dbEngine: engine,
|
|
77
|
+
host: env("TARGET_DB_HOST", ""),
|
|
78
|
+
port: 0,
|
|
79
|
+
database: env("TARGET_DB_NAME", ""),
|
|
80
|
+
user: env("TARGET_DB_USER", ""),
|
|
81
|
+
password: env("TARGET_DB_PASSWORD", ""),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (rest.includes(";")) {
|
|
85
|
+
const [hostPart, ...params] = rest.split(";");
|
|
86
|
+
const [host, port] = hostPart.split(":");
|
|
87
|
+
const kv = {};
|
|
88
|
+
for (const p of params) {
|
|
89
|
+
const eq = p.indexOf("=");
|
|
90
|
+
if (eq > 0) kv[p.slice(0, eq).trim().toLowerCase()] = p.slice(eq + 1).trim();
|
|
91
|
+
}
|
|
92
|
+
t.host = host;
|
|
93
|
+
t.port = parseInt(port || kv.port || DEFAULT_PORTS[engine], 10);
|
|
94
|
+
t.database = kv.database || kv.dbname || t.database;
|
|
95
|
+
t.user = kv.user || kv.uid || kv.username || t.user;
|
|
96
|
+
t.password = kv.password || kv.pwd || t.password;
|
|
97
|
+
} else {
|
|
98
|
+
const u = new URL(`${scheme === "mongodb" ? "mongodb" : "http"}://${rest}`);
|
|
99
|
+
t.host = u.hostname;
|
|
100
|
+
t.port = u.port ? parseInt(u.port, 10) : DEFAULT_PORTS[engine];
|
|
101
|
+
t.database = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
102
|
+
if (u.username) t.user = decodeURIComponent(u.username);
|
|
103
|
+
if (u.password) t.password = decodeURIComponent(u.password);
|
|
104
|
+
}
|
|
105
|
+
if (!t.database) throw new Error(`DATABASE_URL '${scheme}://…' is missing the database name (/dbname)`);
|
|
106
|
+
if (!t.port) t.port = DEFAULT_PORTS[engine] || 5432;
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* DATABASE_URL holds one or more comma-separated connection URLs, so one
|
|
111
|
+
* process can watch several databases at once. Commas inside passwords must
|
|
112
|
+
* be URL-encoded as %2C (like the other special characters). */
|
|
113
|
+
const urls = String(process.env.DATABASE_URL).split(",").map((s) => s.trim()).filter(Boolean);
|
|
114
|
+
if (!urls.length) {
|
|
115
|
+
console.error("dbopt: DATABASE_URL is empty — set at least one connection URL.");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
settings.targets = urls.map(parseDatabaseUrl);
|
|
119
|
+
|
|
120
|
+
/* Give each target a stable, human-friendly name for the API (?db=<name>)
|
|
121
|
+
* and the dashboard selector: the database name, disambiguated by host and
|
|
122
|
+
* port only when two targets share it. */
|
|
123
|
+
{
|
|
124
|
+
const count = (fn) => settings.targets.reduce((m, t) => {
|
|
125
|
+
const k = fn(t); m[k] = (m[k] || 0) + 1; return m;
|
|
126
|
+
}, {});
|
|
127
|
+
const byDb = count((t) => t.database);
|
|
128
|
+
const byDbHost = count((t) => `${t.database}@${t.host}`);
|
|
129
|
+
const seen = {};
|
|
130
|
+
for (const t of settings.targets) {
|
|
131
|
+
let name = t.database;
|
|
132
|
+
if (byDb[t.database] > 1) name = `${t.database}@${t.host}`;
|
|
133
|
+
if (byDbHost[`${t.database}@${t.host}`] > 1) name = `${t.database}@${t.host}:${t.port}`;
|
|
134
|
+
if (seen[name]) name += `-${++seen[name]}`;
|
|
135
|
+
else seen[name] = 1;
|
|
136
|
+
t.name = name;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { settings };
|
package/src/executor.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Builds and executes index DDL with identifier validation; every action is
|
|
3
|
+
* recorded with rollback SQL. All functions are scoped to one target. */
|
|
4
|
+
const { settings } = require("./config");
|
|
5
|
+
|
|
6
|
+
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
7
|
+
const MAX_IDENTIFIER = { postgres: 63, mysql: 64, mssql: 128 };
|
|
8
|
+
|
|
9
|
+
function validateIdentifier(name) {
|
|
10
|
+
if (!name || !SAFE_IDENTIFIER.test(name)) {
|
|
11
|
+
throw new Error(`Unsafe identifier rejected: ${JSON.stringify(name)}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function indexNameFor(engine, table, columns, partial = false) {
|
|
16
|
+
let name = `${settings.indexNamePrefix}_idx_${table}_${columns.join("_")}`;
|
|
17
|
+
if (partial) name += "_part";
|
|
18
|
+
return name.slice(0, MAX_IDENTIFIER[engine] || 63);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function predicateToSql(predicate) {
|
|
22
|
+
validateIdentifier(predicate.column || "");
|
|
23
|
+
if (predicate.op === "IS NOT NULL") return `${predicate.column} IS NOT NULL`;
|
|
24
|
+
if (predicate.op === "=" && ["true", "false"].includes(predicate.value)) {
|
|
25
|
+
return `${predicate.column} = ${predicate.value}`;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Unsupported partial-index predicate: ${JSON.stringify(predicate)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildSqlForRecommendation(target, rec) {
|
|
31
|
+
validateIdentifier(rec.table);
|
|
32
|
+
const engine = target.dbEngine;
|
|
33
|
+
const prefixNote = `The ${settings.indexNamePrefix}_ name prefix keeps this index outside any ORM/migration namespace.`;
|
|
34
|
+
|
|
35
|
+
if (rec.type === "missing_index") {
|
|
36
|
+
const columns = rec.detail.columns;
|
|
37
|
+
columns.forEach(validateIdentifier);
|
|
38
|
+
const predicate = rec.detail.predicate || null;
|
|
39
|
+
const colList = columns.join(", ");
|
|
40
|
+
|
|
41
|
+
if (engine === "mysql" || engine === "mssql") {
|
|
42
|
+
if (predicate) throw new Error("Partial indexes are Postgres-only");
|
|
43
|
+
const indexName = indexNameFor(engine, rec.table, columns);
|
|
44
|
+
validateIdentifier(indexName);
|
|
45
|
+
return {
|
|
46
|
+
index_name: indexName,
|
|
47
|
+
apply_sql: `CREATE INDEX ${indexName} ON ${rec.table} (${colList});`,
|
|
48
|
+
rollback_sql: `DROP INDEX ${indexName} ON ${rec.table};`,
|
|
49
|
+
notes: (engine === "mysql"
|
|
50
|
+
? "InnoDB online DDL builds the index without locking the table. "
|
|
51
|
+
: "On Enterprise/Azure use WITH (ONLINE = ON) for fully non-blocking builds. ") + prefixNote,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const where = predicate ? ` WHERE ${predicateToSql(predicate)}` : "";
|
|
55
|
+
const indexName = indexNameFor(engine, rec.table, columns, !!predicate);
|
|
56
|
+
validateIdentifier(indexName);
|
|
57
|
+
return {
|
|
58
|
+
index_name: indexName,
|
|
59
|
+
apply_sql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexName} ON ${rec.table} (${colList})${where};`,
|
|
60
|
+
rollback_sql: `DROP INDEX CONCURRENTLY IF EXISTS ${indexName};`,
|
|
61
|
+
notes: "CONCURRENTLY avoids locking the table. " + prefixNote,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (rec.type === "unused_index") {
|
|
66
|
+
const indexName = rec.detail.index_name;
|
|
67
|
+
validateIdentifier(indexName);
|
|
68
|
+
const rollback = rec.detail.original_index_definition ||
|
|
69
|
+
`-- Original definition not captured for ${indexName}.`;
|
|
70
|
+
const dropSql = engine === "postgres"
|
|
71
|
+
? `DROP INDEX CONCURRENTLY IF EXISTS ${indexName};`
|
|
72
|
+
: `DROP INDEX ${indexName} ON ${rec.table};`;
|
|
73
|
+
return {
|
|
74
|
+
index_name: indexName,
|
|
75
|
+
apply_sql: dropSql,
|
|
76
|
+
rollback_sql: rollback,
|
|
77
|
+
notes: "Zero usage only reflects activity since the last stats reset / server start. " +
|
|
78
|
+
"The original definition is stored as rollback_sql so the drop is fully reversible.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Unknown recommendation type: ${rec.type}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function execDdl(target, sqlText) {
|
|
85
|
+
const conn = await target.adapter.connect(target);
|
|
86
|
+
try {
|
|
87
|
+
if (target.adapter.engine === "mssql") await conn.request().batch(sqlText);
|
|
88
|
+
else await conn.query(sqlText);
|
|
89
|
+
} finally {
|
|
90
|
+
target.adapter.close(conn);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function applyRecommendation(target, rec) {
|
|
95
|
+
try {
|
|
96
|
+
const built = buildSqlForRecommendation(target, rec);
|
|
97
|
+
await execDdl(target, built.apply_sql);
|
|
98
|
+
console.log(`[executor] [${target.name}] applied rec #${rec.id}: ${built.apply_sql}`);
|
|
99
|
+
target.store.updateDetail(rec.id, { ...rec.detail, index_name: built.index_name });
|
|
100
|
+
return target.store.markApplied(rec.id, built.apply_sql, built.rollback_sql);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`[executor] [${target.name}] failed rec #${rec.id}: ${e.message}`);
|
|
103
|
+
return target.store.markFailed(rec.id, e.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function rollbackRecommendation(target, rec) {
|
|
108
|
+
let rollbackSql = rec.rollback_sql;
|
|
109
|
+
if (rec.status !== "applied" || !rollbackSql) {
|
|
110
|
+
throw new Error("Recommendation is not in an applied state with rollback SQL.");
|
|
111
|
+
}
|
|
112
|
+
if (rollbackSql.trimStart().startsWith("--")) {
|
|
113
|
+
throw new Error("No executable rollback SQL was captured for this recommendation.");
|
|
114
|
+
}
|
|
115
|
+
if (target.dbEngine === "postgres" && rec.type === "unused_index" &&
|
|
116
|
+
rollbackSql.toUpperCase().startsWith("CREATE INDEX ")) {
|
|
117
|
+
rollbackSql = "CREATE INDEX CONCURRENTLY " + rollbackSql.slice("CREATE INDEX ".length);
|
|
118
|
+
}
|
|
119
|
+
await execDdl(target, rollbackSql);
|
|
120
|
+
console.log(`[executor] [${target.name}] rolled back rec #${rec.id}`);
|
|
121
|
+
return target.store.updateStatus(rec.id, "rolled_back");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
validateIdentifier, indexNameFor, predicateToSql,
|
|
126
|
+
buildSqlForRecommendation, applyRecommendation, rollbackRecommendation,
|
|
127
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const { settings } = require("./config");
|
|
5
|
+
const { targets, getTarget } = require("./targets");
|
|
6
|
+
const { router } = require("./routes");
|
|
7
|
+
const { topologyRouter } = require("./topology");
|
|
8
|
+
const { runAnalysisCycle, startScheduler } = require("./scheduler");
|
|
9
|
+
|
|
10
|
+
function createApp() {
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(express.json());
|
|
13
|
+
|
|
14
|
+
// Every API call is scoped to one target database, chosen with ?db=<name>
|
|
15
|
+
// (see GET /targets). Without the parameter the first target is used.
|
|
16
|
+
app.use((req, res, next) => {
|
|
17
|
+
req.target = getTarget(req.query.db);
|
|
18
|
+
if (!req.target) {
|
|
19
|
+
return res.status(404).json({
|
|
20
|
+
detail: `Unknown database '${req.query.db}'. Configured: ${targets.map((t) => t.name).join(", ")}`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
app.use(router);
|
|
27
|
+
app.use(topologyRouter);
|
|
28
|
+
|
|
29
|
+
const page = (file) => (req, res) => {
|
|
30
|
+
res.set("Cache-Control", "no-store");
|
|
31
|
+
res.sendFile(path.join(__dirname, "static", file));
|
|
32
|
+
};
|
|
33
|
+
app.get("/dashboard", page("dashboard.html"));
|
|
34
|
+
app.get("/topology", page("topology.html"));
|
|
35
|
+
app.get("/", (req, res) => res.redirect("/dashboard"));
|
|
36
|
+
return app;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function start() {
|
|
40
|
+
const app = createApp();
|
|
41
|
+
app.listen(settings.httpPort, () => {
|
|
42
|
+
console.log(`dbopt listening on http://localhost:${settings.httpPort}`);
|
|
43
|
+
for (const t of targets) {
|
|
44
|
+
console.log(` target '${t.name}': ${t.dbEngine} · ${t.host}:${t.port}/${t.database}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(` dashboard: http://localhost:${settings.httpPort}/dashboard`);
|
|
47
|
+
console.log(` topology: http://localhost:${settings.httpPort}/topology`);
|
|
48
|
+
});
|
|
49
|
+
for (const t of targets) await t.adapter.ensurePrerequisites(t);
|
|
50
|
+
startScheduler();
|
|
51
|
+
runAnalysisCycle(); // immediate first cycle, like the Python edition
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createApp, start };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Shared filter-string parsing (engine adapters normalize their plan
|
|
3
|
+
* predicates to "col = value" style strings before calling these). */
|
|
4
|
+
|
|
5
|
+
const stripCasts = (s) => s.replace(/::\w+(\s+\w+)?/g, "");
|
|
6
|
+
|
|
7
|
+
/** '(a = 1) AND (b = 2)' -> ['a','b']. OR can't be served by one index ->
|
|
8
|
+
* first column only. */
|
|
9
|
+
function extractColumnsFromFilter(filterStr) {
|
|
10
|
+
if (!filterStr) return [];
|
|
11
|
+
const cleaned = stripCasts(filterStr);
|
|
12
|
+
const cols = [...cleaned.matchAll(/\(*\s*(\w+)\s*\)*\s*=/g)].map((m) => m[1]);
|
|
13
|
+
const unique = [...new Set(cols)];
|
|
14
|
+
return / OR /.test(cleaned) ? unique.slice(0, 1) : unique;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Detect filters that call for a partial index (Postgres only). */
|
|
18
|
+
function extractPartialPredicate(filterStr) {
|
|
19
|
+
if (!filterStr) return null;
|
|
20
|
+
const cleaned = stripCasts(filterStr);
|
|
21
|
+
let m = cleaned.match(/(\w+)\s+IS NOT NULL/);
|
|
22
|
+
if (m) return { column: m[1], op: "IS NOT NULL" };
|
|
23
|
+
m = cleaned.match(/(\w+)\s*=\s*(true|false)\b/);
|
|
24
|
+
if (m) return { column: m[1], op: "=", value: m[2] };
|
|
25
|
+
m = cleaned.match(/(?<![A-Z] )NOT\s+\(?(\w+)/);
|
|
26
|
+
if (m) return { column: m[1], op: "=", value: "false" };
|
|
27
|
+
m = cleaned.match(/^\(*\s*(\w+)\s*\)*$/);
|
|
28
|
+
if (m) return { column: m[1], op: "=", value: "true" };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const calcImprovementPct = (oldCost, newCost) =>
|
|
33
|
+
!oldCost ? 0 : Math.round((1 - newCost / oldCost) * 1000) / 10;
|
|
34
|
+
|
|
35
|
+
module.exports = { extractColumnsFromFilter, extractPartialPredicate, calcImprovementPct };
|