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/src/routes.js ADDED
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ const express = require("express");
3
+ const { settings } = require("./config");
4
+ const { targets } = require("./targets");
5
+ const executor = require("./executor");
6
+
7
+ const router = express.Router();
8
+
9
+ /** run fn with a fresh connection to the request's target, always closed */
10
+ async function withConn(target, fn) {
11
+ const conn = await target.adapter.connect(target);
12
+ try { return await fn(conn); } finally { target.adapter.close(conn); }
13
+ }
14
+ const wrap = (fn) => (req, res) =>
15
+ Promise.resolve(fn(req, res)).catch((e) => res.status(500).json({ detail: e.message }));
16
+
17
+ const sqlPreview = (target, rec) => {
18
+ try { return executor.buildSqlForRecommendation(target, rec); }
19
+ catch (e) { return { error: e.message }; }
20
+ };
21
+
22
+ const targetSummary = (t) => ({
23
+ name: t.name, engine: t.dbEngine, host: t.host, port: t.port, database: t.database,
24
+ prerequisites: t.prerequisites,
25
+ });
26
+
27
+ /* ------------------------------- targets ------------------------------ */
28
+ router.get("/targets", wrap(async (req, res) => res.json(targets.map(targetSummary))));
29
+
30
+ /* ------------------------------- health ------------------------------- */
31
+ router.get("/health", wrap(async (req, res) => {
32
+ const t = req.target;
33
+ try {
34
+ await withConn(t, (c) => t.adapter.ping(c));
35
+ const needed = (t.prerequisites.actions_needed || []).length;
36
+ res.json({ status: needed ? "degraded" : "ok", db_connection: "ok",
37
+ engine: t.dbEngine, database: t.name, prerequisites: t.prerequisites });
38
+ } catch (e) {
39
+ res.json({ status: "degraded", db_connection: "failed",
40
+ engine: t.dbEngine, database: t.name, error: e.message });
41
+ }
42
+ }));
43
+
44
+ /* ------------------------------- queries ------------------------------ */
45
+ router.get("/queries/slow", wrap(async (req, res) => {
46
+ const rows = await withConn(req.target, (c) =>
47
+ req.target.adapter.getSlowQueries(c, parseInt(req.query.min_calls || "1", 10),
48
+ parseInt(req.query.limit || "30", 10)));
49
+ res.json(rows);
50
+ }));
51
+
52
+ /* ------------------------------- tables ------------------------------- */
53
+ const fmtUnused = (raw) => raw.map((r) => ({
54
+ table: r.table_name, index: r.index_name, scans: r.idx_scan,
55
+ size_mb: Math.round((r.size_bytes / 1048576) * 100) / 100, indexdef: r.indexdef,
56
+ }));
57
+
58
+ router.get("/tables/unused-indexes", wrap(async (req, res) =>
59
+ res.json(fmtUnused(await withConn(req.target, (c) => req.target.adapter.getUnusedIndexes(c, req.target))))));
60
+ router.get("/tables/growth", wrap(async (req, res) =>
61
+ res.json(await withConn(req.target, (c) => req.target.adapter.getTableGrowthStats(c)))));
62
+
63
+ /* --------------------------- recommendations -------------------------- */
64
+ router.get("/recommendations", wrap(async (req, res) => {
65
+ let recs = req.target.store.listRecommendations(req.query.status || null);
66
+ if (req.query.type) recs = recs.filter((r) => r.type === req.query.type);
67
+ res.json(recs.map((r) => ({ ...r, sql: sqlPreview(req.target, r) })));
68
+ }));
69
+
70
+ router.get("/recommendations/:id(\\d+)/sql", wrap(async (req, res) => {
71
+ const rec = req.target.store.getRecommendation(+req.params.id);
72
+ if (!rec) return res.status(404).json({ detail: "Recommendation not found" });
73
+ res.json(sqlPreview(req.target, rec));
74
+ }));
75
+
76
+ router.get("/recommendations/:id(\\d+)", wrap(async (req, res) => {
77
+ const rec = req.target.store.getRecommendation(+req.params.id);
78
+ if (!rec) return res.status(404).json({ detail: "Recommendation not found" });
79
+ res.json({ ...rec, sql: sqlPreview(req.target, rec) });
80
+ }));
81
+
82
+ router.post("/recommendations/:id(\\d+)/apply", wrap(async (req, res) => {
83
+ const rec = req.target.store.getRecommendation(+req.params.id);
84
+ if (!rec) return res.status(404).json({ detail: "Recommendation not found" });
85
+ if (!["pending", "failed", "skipped"].includes(rec.status)) {
86
+ return res.status(409).json({ detail: `Recommendation is already '${rec.status}'` });
87
+ }
88
+ const result = await executor.applyRecommendation(req.target, rec);
89
+ if (result.status === "failed") return res.status(500).json({ detail: `Apply failed: ${result.error}` });
90
+ res.json(result);
91
+ }));
92
+
93
+ router.post("/recommendations/:id(\\d+)/rollback", wrap(async (req, res) => {
94
+ const rec = req.target.store.getRecommendation(+req.params.id);
95
+ if (!rec) return res.status(404).json({ detail: "Recommendation not found" });
96
+ try { res.json(await executor.rollbackRecommendation(req.target, rec)); }
97
+ catch (e) { res.status(409).json({ detail: e.message }); }
98
+ }));
99
+
100
+ for (const [action, status] of [["acknowledge", "acknowledged"], ["reject", "rejected"], ["reopen", "pending"]]) {
101
+ router.post(`/recommendations/:id(\\d+)/${action}`, wrap(async (req, res) => {
102
+ const rec = req.target.store.updateStatus(+req.params.id, status);
103
+ if (!rec) return res.status(404).json({ detail: "Recommendation not found" });
104
+ res.json(rec);
105
+ }));
106
+ }
107
+
108
+ /* ------------------------------ analytics ----------------------------- */
109
+ router.get("/analytics/config", wrap(async (req, res) => res.json({
110
+ engine: req.target.dbEngine, database: req.target.database, target: req.target.name,
111
+ databases: targets.map((t) => t.name),
112
+ scan_interval_minutes: settings.scanIntervalMinutes,
113
+ auto_create_indexes: settings.autoCreateIndexes,
114
+ auto_drop_unused_indexes: settings.autoDropUnusedIndexes,
115
+ min_improvement_pct: settings.minImprovementPct,
116
+ min_rows_for_flag: settings.minRowsForSeqScanFlag,
117
+ index_name_prefix: settings.indexNamePrefix,
118
+ })));
119
+
120
+ router.get("/analytics/actions", wrap(async (req, res) =>
121
+ res.json(req.target.store.listActions(parseInt(req.query.limit || "50", 10)))));
122
+
123
+ router.get("/analytics/overview", wrap(async (req, res) => {
124
+ const t = req.target;
125
+ const all = t.store.listRecommendations();
126
+ const pending = all.filter((r) => r.status === "pending");
127
+ const byStatus = {};
128
+ for (const r of all) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
129
+ const wasted = pending.filter((r) => r.type === "unused_index")
130
+ .reduce((s, r) => s + (r.detail.size_mb || 0), 0);
131
+ const [slow, growth] = await withConn(t, async (c) => [
132
+ await t.adapter.getSlowQueries(c, 1, 1), await t.adapter.getTableGrowthStats(c)]);
133
+ res.json({
134
+ pending_missing_index_count: pending.filter((r) => r.type === "missing_index").length,
135
+ pending_unused_index_count: pending.filter((r) => r.type === "unused_index").length,
136
+ total_recommendations_by_status: byStatus,
137
+ total_wasted_index_storage_mb: Math.round(wasted * 100) / 100,
138
+ slowest_query_right_now: slow[0] || null,
139
+ biggest_tables: growth.slice(0, 5),
140
+ });
141
+ }));
142
+
143
+ router.get("/analytics/tables/top-by-size", wrap(async (req, res) => {
144
+ const stats = await withConn(req.target, (c) => req.target.adapter.getTableGrowthStats(c));
145
+ res.json(stats.slice(0, parseInt(req.query.limit || "10", 10)));
146
+ }));
147
+ router.get("/analytics/queries/top-slow", wrap(async (req, res) =>
148
+ res.json(await withConn(req.target, (c) =>
149
+ req.target.adapter.getSlowQueries(c, 1, parseInt(req.query.limit || "10", 10))))));
150
+
151
+ router.get("/analytics/wasted-storage", wrap(async (req, res) => {
152
+ const unused = req.target.store.listRecommendations("pending").filter((r) => r.type === "unused_index");
153
+ res.json({
154
+ unused_index_count: unused.length,
155
+ total_reclaimable_mb: Math.round(unused.reduce((s, r) => s + (r.detail.size_mb || 0), 0) * 100) / 100,
156
+ indexes: unused.map((r) => ({ table: r.table, index: r.detail.index_name, size_mb: r.detail.size_mb })),
157
+ });
158
+ }));
159
+
160
+ module.exports = { router, withConn, wrap };
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /** The autonomous analysis cycle: collect -> analyze -> verify -> execute.
3
+ * Runs once per target database, sequentially, every scan interval. */
4
+ const { settings } = require("./config");
5
+ const { targets } = require("./targets");
6
+ const executor = require("./executor");
7
+ const { extractColumnsFromFilter, extractPartialPredicate } = require("./planParser");
8
+
9
+ const log = (t, m) => console.log(`[scheduler] [${t.name}] ${m}`);
10
+
11
+ async function columnsAlreadyIndexed(target, conn, table, columns) {
12
+ const existing = await target.adapter.getAllIndexesForTable(conn, table);
13
+ return existing.some((idx) => columns.every((col) => (idx.indexdef || "").includes(col)));
14
+ }
15
+
16
+ async function analyzeMissingIndexes(target, conn) {
17
+ const { adapter, store } = target;
18
+ const slow = await adapter.getSlowQueries(conn, 1, 50);
19
+ log(target, `fetched ${slow.length} candidate slow queries (${adapter.engine})`);
20
+
21
+ for (const q of slow) {
22
+ const text = String(q.query || "");
23
+ if (!text.trim().toUpperCase().startsWith("SELECT")) continue;
24
+
25
+ const { oldCost, findings, safeQuery } = await adapter.analyzeQuery(
26
+ conn, text, settings.minRowsForSeqScanFlag);
27
+ if (!findings.length) continue;
28
+
29
+ for (const f of findings) {
30
+ if (!f.table || !f.filter) continue;
31
+ let columns = extractColumnsFromFilter(f.filter);
32
+ let predicate = extractPartialPredicate(f.filter);
33
+ if (predicate && (!columns.length || (columns.length === 1 && columns[0] === predicate.column))) {
34
+ columns = [predicate.column];
35
+ if (!adapter.supportsPartialIndexes) predicate = null;
36
+ } else {
37
+ predicate = null;
38
+ }
39
+ if (!columns.length) continue;
40
+ if (await columnsAlreadyIndexed(target, conn, f.table, columns)) continue;
41
+
42
+ const desc = `${f.table}(${columns.join(", ")})` +
43
+ (predicate ? ` WHERE ${predicate.column} ${predicate.op}${predicate.value != null ? " " + predicate.value : ""}` : "");
44
+ try {
45
+ const baseCost = oldCost || f.cost || 1;
46
+ const { improvement, newCost } = await adapter.verifyIndexBenefit(
47
+ conn, safeQuery, f.table, columns, predicate, baseCost);
48
+ if (improvement >= settings.minImprovementPct) {
49
+ const detail = {
50
+ columns, reason: `Full scan on ${f.rows_scanned} rows`,
51
+ estimated_old_cost: baseCost, estimated_new_cost: newCost,
52
+ };
53
+ if (predicate) detail.predicate = predicate;
54
+ store.addRecommendation("missing_index", f.table, detail, improvement);
55
+ log(target, `recommendation: index on ${desc} -> ${improvement}% improvement`);
56
+ }
57
+ } catch (e) {
58
+ console.warn(`[scheduler] [${target.name}] verification failed for ${desc}: ${e.message}`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ async function analyzeUnusedIndexes(target, conn) {
65
+ const { adapter, store } = target;
66
+ const raw = await adapter.getUnusedIndexes(conn, target);
67
+ log(target, `found ${raw.length} unused indexes`);
68
+ for (const idx of raw) {
69
+ if (idx.index_name.startsWith(`${settings.indexNamePrefix}_`) &&
70
+ store.ownIndexCreatedWithin(idx.index_name, settings.unusedIndexGraceMinutes)) continue;
71
+ store.addRecommendation("unused_index", idx.table_name, {
72
+ index_name: idx.index_name,
73
+ size_mb: Math.round((idx.size_bytes / 1048576) * 100) / 100,
74
+ scans: idx.idx_scan,
75
+ original_index_definition: (idx.indexdef || "") + ";",
76
+ });
77
+ }
78
+ }
79
+
80
+ async function autoApplyPending(target) {
81
+ const { store } = target;
82
+ for (const rec of store.listRecommendations("pending")) {
83
+ if (rec.type === "missing_index" && !settings.autoCreateIndexes) continue;
84
+ if (rec.type === "unused_index" && !settings.autoDropUnusedIndexes) continue;
85
+ const fp = store.fingerprint(rec.type, rec.table, rec.detail);
86
+ if (store.recentlyActioned(fp, settings.reapplyCooldownMinutes)) {
87
+ store.updateStatus(rec.id, "skipped");
88
+ continue;
89
+ }
90
+ const result = await executor.applyRecommendation(target, rec);
91
+ log(target, `auto-applied rec #${rec.id} -> ${result.status}`);
92
+ }
93
+ }
94
+
95
+ async function runCycleFor(target) {
96
+ const { adapter, store } = target;
97
+ log(target, "starting analysis cycle...");
98
+ let conn;
99
+ try {
100
+ conn = await adapter.connect(target);
101
+ await analyzeMissingIndexes(target, conn);
102
+ await analyzeUnusedIndexes(target, conn);
103
+ try {
104
+ const tables = await adapter.getTableGrowthStats(conn);
105
+ store.recordSizeSnapshot(tables.reduce((s, t) => s + (t.total_size_bytes || 0), 0));
106
+ } catch (e) { console.warn(`[scheduler] [${target.name}] size snapshot failed:`, e.message); }
107
+ if (conn) { adapter.close(conn); conn = null; }
108
+ if (settings.autoCreateIndexes || settings.autoDropUnusedIndexes) await autoApplyPending(target);
109
+ log(target, "analysis cycle completed.");
110
+ } catch (e) {
111
+ console.error(`[scheduler] [${target.name}] cycle failed:`, e.message);
112
+ } finally {
113
+ if (conn) adapter.close(conn);
114
+ }
115
+ }
116
+
117
+ let running = false;
118
+ async function runAnalysisCycle() {
119
+ if (running) return;
120
+ running = true;
121
+ try {
122
+ for (const target of targets) await runCycleFor(target);
123
+ } finally {
124
+ running = false;
125
+ }
126
+ }
127
+
128
+ function startScheduler() {
129
+ setInterval(runAnalysisCycle, settings.scanIntervalMinutes * 60e3);
130
+ }
131
+
132
+ module.exports = { runAnalysisCycle, startScheduler };