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
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# One or more connection strings, comma-separated: one process can watch
|
|
2
|
+
# several databases at once (mixed engines are fine).
|
|
3
|
+
# Schemes: postgres:// mysql:// sqlserver://
|
|
4
|
+
# Single database:
|
|
5
|
+
DATABASE_URL=postgres://postgres:changeme@localhost:5432/mydb
|
|
6
|
+
# Multiple databases (uncomment; commas in passwords must be encoded as %2C):
|
|
7
|
+
# DATABASE_URL=postgres://user:pass@host1:5432/appdb,mysql://user:pass@host2:3306/shopdb
|
|
8
|
+
|
|
9
|
+
PORT=8000
|
|
10
|
+
SCAN_INTERVAL_MINUTES=15
|
|
11
|
+
MIN_ROWS_FOR_SEQ_SCAN_FLAG=1000
|
|
12
|
+
MIN_IMPROVEMENT_PCT_TO_RECOMMEND=30
|
|
13
|
+
AUTO_CREATE_INDEXES=true
|
|
14
|
+
AUTO_DROP_UNUSED_INDEXES=false
|
|
15
|
+
INDEX_NAME_PREFIX=opt
|
|
16
|
+
UNUSED_INDEX_GRACE_MINUTES=60
|
|
17
|
+
REAPPLY_COOLDOWN_MINUTES=60
|
|
18
|
+
STORAGE_LIMIT_MB=0
|
|
19
|
+
STATE_DB_PATH=optimizer_state.db
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# dbopt-engine
|
|
2
|
+
|
|
3
|
+
Self-optimizing database engine for **PostgreSQL**, **MySQL 8** and
|
|
4
|
+
**Microsoft SQL Server** — the Node.js edition. One process can watch a
|
|
5
|
+
single database or several at once (mixed engines are fine).
|
|
6
|
+
|
|
7
|
+
It watches the queries your application actually runs, finds the ones wasting
|
|
8
|
+
time on full table scans, **verifies** that an index would genuinely help
|
|
9
|
+
*before* creating anything (HypoPG hypothetical indexes on Postgres,
|
|
10
|
+
INVISIBLE-index trials on MySQL, trial builds on SQL Server), then acts on its
|
|
11
|
+
own: it creates the winning indexes and can drop unused ones. Every action is
|
|
12
|
+
recorded with rollback SQL. Ships with a live dashboard and an interactive
|
|
13
|
+
schema-topology explorer with animated query flow.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g dbopt-engine # installs the `dbopt` command
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create a `.env` file in the directory you'll run it from, defining **all** of
|
|
22
|
+
the variables below (copy `.env.example` from the package as a template):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# one database — or several, comma-separated (see "Multiple databases")
|
|
26
|
+
DATABASE_URL=mysql://user:password@host:3306/mydb
|
|
27
|
+
PORT=8000
|
|
28
|
+
SCAN_INTERVAL_MINUTES=15
|
|
29
|
+
MIN_ROWS_FOR_SEQ_SCAN_FLAG=1000
|
|
30
|
+
MIN_IMPROVEMENT_PCT_TO_RECOMMEND=30
|
|
31
|
+
AUTO_CREATE_INDEXES=true
|
|
32
|
+
AUTO_DROP_UNUSED_INDEXES=false
|
|
33
|
+
INDEX_NAME_PREFIX=opt
|
|
34
|
+
UNUSED_INDEX_GRACE_MINUTES=60
|
|
35
|
+
REAPPLY_COOLDOWN_MINUTES=60
|
|
36
|
+
STORAGE_LIMIT_MB=0
|
|
37
|
+
STATE_DB_PATH=optimizer_state.db
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then start it:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
dbopt
|
|
44
|
+
# dashboard: http://localhost:8000/dashboard
|
|
45
|
+
# topology: http://localhost:8000/topology
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or per-project:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install dbopt-engine
|
|
52
|
+
cp node_modules/dbopt-engine/.env.example .env # then edit DATABASE_URL etc.
|
|
53
|
+
npx dbopt-engine
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If any variable is missing, startup fails with a list of what's not set.
|
|
57
|
+
|
|
58
|
+
## Connection strings
|
|
59
|
+
|
|
60
|
+
The scheme picks the engine; the port is optional (engine default is used):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
postgres://user:password@host:5432/mydb
|
|
64
|
+
mysql://user:password@host:3306/mydb
|
|
65
|
+
sqlserver://user:password@host:1433/mydb
|
|
66
|
+
sqlserver://host:1433;database=mydb;user=sa;password=secret (ADO style works too)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
URL-encode special password characters: `!`→`%21` `@`→`%40` `%`→`%25` `#`→`%23` `,`→`%2C`.
|
|
70
|
+
|
|
71
|
+
## Multiple databases
|
|
72
|
+
|
|
73
|
+
Put several comma-separated URLs in `DATABASE_URL` and one process watches
|
|
74
|
+
them all — each gets its own analysis cycle, recommendations, and history:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
DATABASE_URL=postgres://user:pass@host1:5432/appdb,mysql://user:pass@host2:3306/shopdb
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both web pages grow a database selector in the header, and every API
|
|
81
|
+
endpoint accepts `?db=<name>` to pick the database (without it, the first
|
|
82
|
+
one is used). `GET /targets` lists the configured databases and their
|
|
83
|
+
names — normally just the database name, disambiguated with `@host` when
|
|
84
|
+
two targets share it.
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
All variables are **required**; set them in your `.env` (a CLI flag can
|
|
89
|
+
override the ones that have one — flags win).
|
|
90
|
+
|
|
91
|
+
| Flag | Env var | Suggested value | Meaning |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `--database-url` | `DATABASE_URL` | — | one or more comma-separated connection strings |
|
|
94
|
+
| `--port` | `PORT` | 8000 | HTTP port for dashboard/API |
|
|
95
|
+
| `--scan-interval` | `SCAN_INTERVAL_MINUTES` | 15 | minutes between analysis cycles |
|
|
96
|
+
| `--auto-create` | `AUTO_CREATE_INDEXES` | true | create verified indexes automatically |
|
|
97
|
+
| `--auto-drop` | `AUTO_DROP_UNUSED_INDEXES` | false | drop unused indexes automatically |
|
|
98
|
+
| `--min-improvement` | `MIN_IMPROVEMENT_PCT_TO_RECOMMEND` | 30 | required planner-cost drop (%) |
|
|
99
|
+
| `--storage-limit-mb` | `STORAGE_LIMIT_MB` | 0 (off) | capacity for out-of-storage alerts |
|
|
100
|
+
| `--state-db` | `STATE_DB_PATH` | optimizer_state.db | SQLite action-history file |
|
|
101
|
+
| | `MIN_ROWS_FOR_SEQ_SCAN_FLAG` | 1000 | ignore full scans smaller than this |
|
|
102
|
+
| | `INDEX_NAME_PREFIX` | opt | its indexes are named `opt_idx_…` |
|
|
103
|
+
| | `UNUSED_INDEX_GRACE_MINUTES` | 60 | own new indexes can't be "unused" yet |
|
|
104
|
+
| | `REAPPLY_COOLDOWN_MINUTES` | 60 | never repeat the same action within this |
|
|
105
|
+
|
|
106
|
+
A `.env` file in the working directory is loaded automatically.
|
|
107
|
+
|
|
108
|
+
## How the cycle works
|
|
109
|
+
|
|
110
|
+
Every scan interval (and once at startup): **collect** slow queries from the
|
|
111
|
+
engine's own statistics (`pg_stat_statements` / `performance_schema` /
|
|
112
|
+
`sys.dm_exec_query_stats`) → **analyze** plans for large full scans and
|
|
113
|
+
extract the filtered columns (composite `a AND b` supported; partial-index
|
|
114
|
+
predicates on Postgres) → **verify** with a trial index against the exact
|
|
115
|
+
slow query → **execute** only wins above the improvement bar, using
|
|
116
|
+
non-blocking DDL (`CONCURRENTLY` / online DDL), recording rollback SQL.
|
|
117
|
+
|
|
118
|
+
Safety: indexes it creates live in the `opt_` namespace so they never
|
|
119
|
+
collide with ORM/migration-managed names; primary keys, unique constraints,
|
|
120
|
+
(MySQL) FK-required and FULLTEXT indexes are never drop candidates; grace and
|
|
121
|
+
cooldown windows prevent create/delete loops; one state file serves any
|
|
122
|
+
number of targets with rows scoped per database.
|
|
123
|
+
|
|
124
|
+
## HTTP API
|
|
125
|
+
|
|
126
|
+
`/targets` (the configured databases), `/health`, `/queries/slow`,
|
|
127
|
+
`/tables/unused-indexes`, `/tables/growth`,
|
|
128
|
+
`/recommendations` (list · `POST /:id/apply` · `POST /:id/rollback` ·
|
|
129
|
+
`/:id/reject`…), `/analytics/*` (overview, config, actions, wasted-storage),
|
|
130
|
+
`/topology/schema`, `/topology/table/:name`, `POST /topology/analyze-query`,
|
|
131
|
+
`/topology/alerts` — same API surface as the Python edition, so the bundled
|
|
132
|
+
dashboard and topology pages work identically. Every endpoint accepts
|
|
133
|
+
`?db=<name>` to pick one of the configured databases (default: the first).
|
|
134
|
+
|
|
135
|
+
## Notes
|
|
136
|
+
|
|
137
|
+
- Node.js ≥ 18 (better-sqlite3 native module installs prebuilt on common
|
|
138
|
+
platforms).
|
|
139
|
+
- Oracle and MongoDB are available in the Python edition; they're not in
|
|
140
|
+
this package yet.
|
|
141
|
+
- Server prerequisites are checked at startup and reported via `/health`
|
|
142
|
+
and the dashboard banner (e.g. `shared_preload_libraries` on Postgres,
|
|
143
|
+
`GRANT VIEW SERVER STATE` on SQL Server). MySQL needs nothing.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/* Simple CLI: flags map onto env vars before config loads.
|
|
5
|
+
* dbopt --database-url mysql://u:p@host:3306/db --port 8000
|
|
6
|
+
*/
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const FLAG_TO_ENV = {
|
|
9
|
+
"--database-url": "DATABASE_URL",
|
|
10
|
+
"--port": "PORT",
|
|
11
|
+
"--scan-interval": "SCAN_INTERVAL_MINUTES",
|
|
12
|
+
"--auto-create": "AUTO_CREATE_INDEXES",
|
|
13
|
+
"--auto-drop": "AUTO_DROP_UNUSED_INDEXES",
|
|
14
|
+
"--min-improvement": "MIN_IMPROVEMENT_PCT_TO_RECOMMEND",
|
|
15
|
+
"--storage-limit-mb": "STORAGE_LIMIT_MB",
|
|
16
|
+
"--state-db": "STATE_DB_PATH",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
21
|
+
console.log(`dbopt — self-optimizing database engine
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
dbopt --database-url <engine://user:pass@host:port/db> [options]
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--database-url <urls> postgres:// | mysql:// | sqlserver:// connection string;
|
|
28
|
+
comma-separate several to watch multiple databases
|
|
29
|
+
--port <n> HTTP port for dashboard/API (default 8000)
|
|
30
|
+
--scan-interval <min> analysis cycle interval in minutes (default 15)
|
|
31
|
+
--auto-create <bool> create verified indexes automatically (default true)
|
|
32
|
+
--auto-drop <bool> drop unused indexes automatically (default false)
|
|
33
|
+
--min-improvement <pct> required cost improvement to act (default 30)
|
|
34
|
+
--storage-limit-mb <n> capacity for out-of-storage alerts (default off)
|
|
35
|
+
--state-db <path> SQLite history file (default optimizer_state.db)
|
|
36
|
+
|
|
37
|
+
Environment variables (or a .env file) work too; flags win.
|
|
38
|
+
Dashboard: http://localhost:<port>/dashboard · Topology: /topology`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
const env = FLAG_TO_ENV[args[i]];
|
|
42
|
+
if (env) {
|
|
43
|
+
process.env[env] = args[i + 1];
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
require("../src/index").start().catch((e) => {
|
|
49
|
+
console.error("Failed to start:", e.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dbopt-engine",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Self-optimizing database engine: watches real query traffic, verifies missing indexes before creating them, drops unused ones, with a live dashboard and schema topology explorer. PostgreSQL, MySQL 8, SQL Server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Evvo Technology <dev@evvotechnology.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Infantraj123/db_optimizer.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"database",
|
|
13
|
+
"index",
|
|
14
|
+
"optimizer",
|
|
15
|
+
"postgres",
|
|
16
|
+
"mysql",
|
|
17
|
+
"sqlserver",
|
|
18
|
+
"performance",
|
|
19
|
+
"autonomous"
|
|
20
|
+
],
|
|
21
|
+
"main": "src/index.js",
|
|
22
|
+
"bin": {
|
|
23
|
+
"dbopt": "bin/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
".env.example"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"start": "node bin/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"better-sqlite3": "^12.11.1",
|
|
39
|
+
"dotenv": "^16.4.5",
|
|
40
|
+
"express": "^4.21.0",
|
|
41
|
+
"fast-xml-parser": "^4.5.0",
|
|
42
|
+
"mssql": "^11.0.1",
|
|
43
|
+
"mysql2": "^3.11.3",
|
|
44
|
+
"pg": "^8.13.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Engine name -> adapter module. Adapters are stateless: connection
|
|
4
|
+
* parameters come from the target object passed to connect(). */
|
|
5
|
+
function adapterFor(engine) {
|
|
6
|
+
switch (engine) {
|
|
7
|
+
case "postgres": return require("./postgres");
|
|
8
|
+
case "mysql": return require("./mysql");
|
|
9
|
+
case "mssql": return require("./mssql");
|
|
10
|
+
case "oracle":
|
|
11
|
+
case "mongodb":
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Engine '${engine}' is not yet supported in the Node.js package ` +
|
|
14
|
+
"(available in the Python edition). Supported here: postgres, mysql, mssql.");
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(`Unsupported engine '${engine}' (use postgres, mysql or mssql)`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { adapterFor };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** SQL Server 2017+ adapter: DMVs + SHOWPLAN_XML + real trial-index builds.
|
|
3
|
+
* Uses a max-1 connection pool per cycle so session settings (SHOWPLAN)
|
|
4
|
+
* stick across sequential statements. */
|
|
5
|
+
const sql = require("mssql");
|
|
6
|
+
const { XMLParser } = require("fast-xml-parser");
|
|
7
|
+
const { calcImprovementPct } = require("../planParser");
|
|
8
|
+
|
|
9
|
+
const engine = "mssql";
|
|
10
|
+
const supportsPartialIndexes = false;
|
|
11
|
+
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
|
|
12
|
+
|
|
13
|
+
async function connect(target) {
|
|
14
|
+
const pool = new sql.ConnectionPool({
|
|
15
|
+
server: target.host, port: target.port, database: target.database,
|
|
16
|
+
user: target.user, password: target.password,
|
|
17
|
+
pool: { max: 1, min: 0 },
|
|
18
|
+
options: { encrypt: true, trustServerCertificate: true },
|
|
19
|
+
requestTimeout: 120000,
|
|
20
|
+
});
|
|
21
|
+
await pool.connect();
|
|
22
|
+
return pool;
|
|
23
|
+
}
|
|
24
|
+
const close = (p) => p.close().catch(() => {});
|
|
25
|
+
const ping = async (p) => { await p.request().query("SELECT 1"); };
|
|
26
|
+
|
|
27
|
+
async function ensurePrerequisites(target) {
|
|
28
|
+
const status = { checked: true, dmv_access: false, actions_needed: [] };
|
|
29
|
+
let p;
|
|
30
|
+
try {
|
|
31
|
+
p = await connect(target);
|
|
32
|
+
try {
|
|
33
|
+
await p.request().query("SELECT TOP 1 execution_count FROM sys.dm_exec_query_stats;");
|
|
34
|
+
status.dmv_access = true;
|
|
35
|
+
} catch {
|
|
36
|
+
status.actions_needed.push(
|
|
37
|
+
`The login cannot read performance DMVs. Run as an admin (in master): ` +
|
|
38
|
+
`GRANT VIEW SERVER STATE TO [${target.user}]; ` +
|
|
39
|
+
`(on Azure SQL DB: GRANT VIEW DATABASE STATE instead).`);
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
status.actions_needed.push(`Could not connect to the target database: ${e.message}`);
|
|
43
|
+
} finally { if (p) close(p); }
|
|
44
|
+
Object.assign(target.prerequisites, status);
|
|
45
|
+
return status;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getSlowQueries(p, minCalls = 1, limit = 30) {
|
|
49
|
+
try {
|
|
50
|
+
const r = await p.request()
|
|
51
|
+
.input("lim", sql.Int, limit).input("minc", sql.Int, minCalls)
|
|
52
|
+
.query(`
|
|
53
|
+
SELECT TOP (@lim)
|
|
54
|
+
CONVERT(varchar(32), qs.query_hash, 2) AS queryid,
|
|
55
|
+
SUBSTRING(t.text, (qs.statement_start_offset / 2) + 1,
|
|
56
|
+
((CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text)
|
|
57
|
+
ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1) AS query,
|
|
58
|
+
qs.execution_count AS calls,
|
|
59
|
+
qs.total_elapsed_time / 1000.0 / qs.execution_count AS mean_exec_time,
|
|
60
|
+
qs.total_elapsed_time / 1000.0 AS total_exec_time,
|
|
61
|
+
qs.total_rows AS rows
|
|
62
|
+
FROM sys.dm_exec_query_stats qs
|
|
63
|
+
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
|
|
64
|
+
WHERE t.dbid = DB_ID() AND qs.execution_count >= @minc
|
|
65
|
+
AND t.text NOT LIKE 'SET SHOWPLAN%' AND t.text NOT LIKE '%dm_exec_query_stats%'
|
|
66
|
+
AND t.text NOT LIKE 'CREATE%' AND t.text NOT LIKE 'DROP%'
|
|
67
|
+
AND t.text NOT LIKE 'ALTER%' AND t.text NOT LIKE 'INSERT%'
|
|
68
|
+
ORDER BY mean_exec_time DESC;`);
|
|
69
|
+
return r.recordset;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.warn("[mssql] cannot read query stats (VIEW SERVER STATE missing?):", e.message.slice(0, 100));
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function getUnusedIndexes(p, target) {
|
|
77
|
+
try {
|
|
78
|
+
const r = await p.request().query(`
|
|
79
|
+
SELECT o.name AS table_name, i.name AS index_name,
|
|
80
|
+
COALESCE(u.user_seeks + u.user_scans + u.user_lookups, 0) AS idx_scan,
|
|
81
|
+
COALESCE((SELECT SUM(ps.used_page_count) * 8 * 1024 FROM sys.dm_db_partition_stats ps
|
|
82
|
+
WHERE ps.object_id = i.object_id AND ps.index_id = i.index_id), 0) AS size_bytes,
|
|
83
|
+
(SELECT STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal)
|
|
84
|
+
FROM sys.index_columns ic
|
|
85
|
+
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
86
|
+
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
87
|
+
AND ic.is_included_column = 0) AS columns_list
|
|
88
|
+
FROM sys.indexes i
|
|
89
|
+
JOIN sys.objects o ON o.object_id = i.object_id AND o.type = 'U'
|
|
90
|
+
LEFT JOIN sys.dm_db_index_usage_stats u
|
|
91
|
+
ON u.object_id = i.object_id AND u.index_id = i.index_id AND u.database_id = DB_ID()
|
|
92
|
+
WHERE i.type = 2 AND i.is_primary_key = 0 AND i.is_unique = 0
|
|
93
|
+
AND i.is_unique_constraint = 0 AND i.is_hypothetical = 0 AND i.is_disabled = 0
|
|
94
|
+
AND COALESCE(u.user_seeks + u.user_scans + u.user_lookups, 0) = 0;`);
|
|
95
|
+
return r.recordset.map(x => ({
|
|
96
|
+
schemaname: target.database, table_name: x.table_name, index_name: x.index_name,
|
|
97
|
+
idx_scan: Number(x.idx_scan), size_bytes: Number(x.size_bytes || 0),
|
|
98
|
+
indexdef: `CREATE INDEX ${x.index_name} ON ${x.table_name} (${x.columns_list})`,
|
|
99
|
+
}));
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.warn("[mssql] cannot read index usage stats:", e.message.slice(0, 100));
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getAllIndexesForTable(p, table) {
|
|
107
|
+
const r = await p.request().input("t", sql.NVarChar, table).query(`
|
|
108
|
+
SELECT i.name AS n,
|
|
109
|
+
(SELECT STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal)
|
|
110
|
+
FROM sys.index_columns ic
|
|
111
|
+
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
112
|
+
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id) AS cols
|
|
113
|
+
FROM sys.indexes i WHERE i.object_id = OBJECT_ID(@t) AND i.name IS NOT NULL;`);
|
|
114
|
+
return r.recordset.map(x => ({ indexname: x.n, indexdef: `INDEX ${x.n} ON ${table} (${x.cols})` }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function prettySize(b) {
|
|
118
|
+
for (const u of ["bytes", "kB", "MB", "GB"]) {
|
|
119
|
+
if (b < 1024) return `${Math.round(b)} ${u}`;
|
|
120
|
+
b /= 1024;
|
|
121
|
+
}
|
|
122
|
+
return `${Math.round(b)} TB`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function getTableGrowthStats(p) {
|
|
126
|
+
const r = await p.request().query(`
|
|
127
|
+
SELECT t.name AS table_name,
|
|
128
|
+
SUM(CASE WHEN ps.index_id IN (0, 1) THEN ps.row_count ELSE 0 END) AS live_rows,
|
|
129
|
+
SUM(ps.reserved_page_count) * 8 * 1024 AS total_size_bytes
|
|
130
|
+
FROM sys.tables t
|
|
131
|
+
JOIN sys.dm_db_partition_stats ps ON ps.object_id = t.object_id
|
|
132
|
+
GROUP BY t.name ORDER BY total_size_bytes DESC;`);
|
|
133
|
+
return r.recordset.map(x => ({
|
|
134
|
+
table_name: x.table_name, live_rows: Number(x.live_rows || 0), dead_rows: 0,
|
|
135
|
+
total_size: prettySize(Number(x.total_size_bytes || 0)),
|
|
136
|
+
total_size_bytes: Number(x.total_size_bytes || 0), seq_scan: 0, idx_scan: 0,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const substitute = (q, asString) =>
|
|
141
|
+
q.replace(/^\s*\(@[^)]*\)\s*/, "").replace(/@\w+/g, asString ? "'1'" : "1");
|
|
142
|
+
|
|
143
|
+
async function estimatedPlan(p, safeQuery) {
|
|
144
|
+
// max:1 pool -> sequential requests share the session, so SHOWPLAN sticks
|
|
145
|
+
await p.request().batch("SET SHOWPLAN_XML ON;");
|
|
146
|
+
try {
|
|
147
|
+
const r = await p.request().batch(safeQuery);
|
|
148
|
+
const row = r.recordset && r.recordset[0];
|
|
149
|
+
return xml.parse(row[Object.keys(row)[0]]);
|
|
150
|
+
} finally {
|
|
151
|
+
await p.request().batch("SET SHOWPLAN_XML OFF;").catch(() => {});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function collectRelOps(node, out) {
|
|
156
|
+
if (node && typeof node === "object") {
|
|
157
|
+
if (node["@_PhysicalOp"]) out.push(node);
|
|
158
|
+
for (const [k, v] of Object.entries(node)) {
|
|
159
|
+
if (k.startsWith("@_")) continue;
|
|
160
|
+
if (Array.isArray(v)) v.forEach(x => collectRelOps(x, out));
|
|
161
|
+
else collectRelOps(v, out);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function findFirst(node, key) {
|
|
167
|
+
if (!node || typeof node !== "object") return null;
|
|
168
|
+
if (node[key] !== undefined) return node[key];
|
|
169
|
+
for (const [k, v] of Object.entries(node)) {
|
|
170
|
+
if (k.startsWith("@_")) continue;
|
|
171
|
+
const hit = Array.isArray(v) ? v.map(x => findFirst(x, key)).find(Boolean) : findFirst(v, key);
|
|
172
|
+
if (hit) return hit;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cleanPredicate = (s) =>
|
|
178
|
+
s.replace(/[\[\]]/g, "").replace(/\b\w+\.(\w+\.)*/g, "");
|
|
179
|
+
|
|
180
|
+
async function analyzeQuery(p, queryText, minRows) {
|
|
181
|
+
for (const asString of [false, true]) {
|
|
182
|
+
const safeQuery = substitute(queryText, asString);
|
|
183
|
+
if (!safeQuery.trim()) continue;
|
|
184
|
+
let plan;
|
|
185
|
+
try { plan = await estimatedPlan(p, safeQuery); } catch { continue; }
|
|
186
|
+
const stmt = findFirst(plan, "StmtSimple");
|
|
187
|
+
const stmtNode = Array.isArray(stmt) ? stmt[0] : stmt;
|
|
188
|
+
const oldCost = stmtNode ? parseFloat(stmtNode["@_StatementSubTreeCost"]) || null : null;
|
|
189
|
+
const findings = [];
|
|
190
|
+
for (const rel of collectRelOps(plan, [])) {
|
|
191
|
+
if (!["Table Scan", "Clustered Index Scan"].includes(rel["@_PhysicalOp"])) continue;
|
|
192
|
+
const rows = parseFloat(rel["@_EstimatedRowsRead"] || 0)
|
|
193
|
+
|| parseFloat(rel["@_TableCardinality"] || 0)
|
|
194
|
+
|| parseFloat(rel["@_EstimateRows"] || 0);
|
|
195
|
+
if (rows <= minRows) continue;
|
|
196
|
+
const obj = findFirst(rel, "Object");
|
|
197
|
+
const objNode = Array.isArray(obj) ? obj[0] : obj;
|
|
198
|
+
const table = objNode ? String(objNode["@_Table"] || "").replace(/[\[\]]/g, "") : null;
|
|
199
|
+
const pred = findFirst(rel, "Predicate");
|
|
200
|
+
const scalar = pred ? findFirst(pred, "ScalarOperator") : null;
|
|
201
|
+
const scalarNode = Array.isArray(scalar) ? scalar[0] : scalar;
|
|
202
|
+
const filter = scalarNode && scalarNode["@_ScalarString"]
|
|
203
|
+
? cleanPredicate(scalarNode["@_ScalarString"]) : null;
|
|
204
|
+
findings.push({ table, filter, rows_scanned: Math.round(rows), cost: null });
|
|
205
|
+
}
|
|
206
|
+
return { oldCost, findings, safeQuery };
|
|
207
|
+
}
|
|
208
|
+
return { oldCost: null, findings: [], safeQuery: null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function verifyIndexBenefit(p, safeQuery, table, columns, predicate, oldCost) {
|
|
212
|
+
const { indexNameFor, validateIdentifier } = require("../executor");
|
|
213
|
+
if (predicate) throw new Error("Filtered-index candidates are not wired up for SQL Server yet");
|
|
214
|
+
validateIdentifier(table);
|
|
215
|
+
columns.forEach(validateIdentifier);
|
|
216
|
+
const indexName = indexNameFor(engine, table, columns);
|
|
217
|
+
let created = false;
|
|
218
|
+
try {
|
|
219
|
+
await p.request().batch(`CREATE INDEX ${indexName} ON ${table} (${columns.join(", ")})`);
|
|
220
|
+
created = true;
|
|
221
|
+
const plan = await estimatedPlan(p, safeQuery);
|
|
222
|
+
const stmt = findFirst(plan, "StmtSimple");
|
|
223
|
+
const stmtNode = Array.isArray(stmt) ? stmt[0] : stmt;
|
|
224
|
+
const newCost = stmtNode ? parseFloat(stmtNode["@_StatementSubTreeCost"]) || oldCost : oldCost;
|
|
225
|
+
return { improvement: calcImprovementPct(oldCost, newCost), newCost };
|
|
226
|
+
} finally {
|
|
227
|
+
if (created) await p.request().batch(`DROP INDEX ${indexName} ON ${table}`).catch(() => {});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
engine, supportsPartialIndexes,
|
|
233
|
+
connect, close, ping, ensurePrerequisites,
|
|
234
|
+
getSlowQueries, getUnusedIndexes, getAllIndexesForTable, getTableGrowthStats,
|
|
235
|
+
analyzeQuery, verifyIndexBenefit,
|
|
236
|
+
};
|