sql_genius 0.9.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +195 -0
- data/LICENSE.txt +65 -0
- data/README.md +178 -0
- data/Rakefile +8 -0
- data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
- data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
- data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
- data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
- data/app/controllers/sql_genius/base_controller.rb +29 -0
- data/app/controllers/sql_genius/queries_controller.rb +94 -0
- data/app/views/layouts/sql_genius/application.html.erb +285 -0
- data/config/routes.rb +34 -0
- data/docs/guides/ai-features.md +115 -0
- data/docs/guides/getting-started-rails.md +118 -0
- data/docs/guides/ssh-tunnel-connections.md +151 -0
- data/docs/screenshots/ai_tools.png +0 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/duplicate_indexes.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/screenshots/query_stats.png +0 -0
- data/docs/screenshots/server.png +0 -0
- data/docs/screenshots/table_sizes.png +0 -0
- data/lib/generators/sql_genius/install/install_generator.rb +19 -0
- data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
- data/lib/sql_genius/configuration.rb +114 -0
- data/lib/sql_genius/core/ai/client.rb +155 -0
- data/lib/sql_genius/core/ai/config.rb +47 -0
- data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
- data/lib/sql_genius/core/ai/describe_query.rb +41 -0
- data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
- data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
- data/lib/sql_genius/core/ai/index_planner.rb +91 -0
- data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
- data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
- data/lib/sql_genius/core/ai/optimization.rb +81 -0
- data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
- data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
- data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
- data/lib/sql_genius/core/ai/schema_review.rb +46 -0
- data/lib/sql_genius/core/ai/suggestion.rb +74 -0
- data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
- data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
- data/lib/sql_genius/core/analysis/columns.rb +63 -0
- data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
- data/lib/sql_genius/core/analysis/query_history.rb +50 -0
- data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
- data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
- data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
- data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
- data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
- data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
- data/lib/sql_genius/core/column_definition.rb +30 -0
- data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
- data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
- data/lib/sql_genius/core/connection.rb +37 -0
- data/lib/sql_genius/core/execution_result.rb +27 -0
- data/lib/sql_genius/core/index_definition.rb +23 -0
- data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
- data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
- data/lib/sql_genius/core/query_builders.rb +27 -0
- data/lib/sql_genius/core/query_explainer.rb +113 -0
- data/lib/sql_genius/core/query_runner/config.rb +21 -0
- data/lib/sql_genius/core/query_runner.rb +123 -0
- data/lib/sql_genius/core/result.rb +43 -0
- data/lib/sql_genius/core/server_info.rb +54 -0
- data/lib/sql_genius/core/sql_validator.rb +149 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
- data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
- data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
- data/lib/sql_genius/core.rb +72 -0
- data/lib/sql_genius/engine.rb +31 -0
- data/lib/sql_genius/slow_query_monitor.rb +43 -0
- data/lib/sql_genius/version.rb +5 -0
- data/lib/sql_genius.rb +29 -0
- data/sql_genius.gemspec +47 -0
- metadata +171 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Dashboard snapshot of server state. Dispatches to a dialect-specific
|
|
7
|
+
# implementation that produces a uniform nested hash with four
|
|
8
|
+
# top-level sections: server, connections, innodb, queries.
|
|
9
|
+
#
|
|
10
|
+
# On PostgreSQL the `innodb` section is populated with the closest
|
|
11
|
+
# equivalents (shared_buffers as buffer pool size, blks_hit/blks_read
|
|
12
|
+
# ratio as buffer pool hit rate). Lock-related fields fall back to 0
|
|
13
|
+
# since there is no direct equivalent of InnoDB row lock waits.
|
|
14
|
+
class ServerOverview
|
|
15
|
+
def initialize(connection)
|
|
16
|
+
@connection = connection
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
impl_for(@connection).call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def impl_for(connection)
|
|
26
|
+
if connection.server_version.postgresql?
|
|
27
|
+
Postgresql.new(connection)
|
|
28
|
+
else
|
|
29
|
+
Mysql.new(connection)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# MySQL / MariaDB implementation. Combines SHOW GLOBAL STATUS, SHOW
|
|
34
|
+
# GLOBAL VARIABLES, and SELECT VERSION() into the dashboard hash.
|
|
35
|
+
class Mysql
|
|
36
|
+
def initialize(connection)
|
|
37
|
+
@connection = connection
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
status = load_status
|
|
42
|
+
vars = load_variables
|
|
43
|
+
version = @connection.select_value("SELECT VERSION()")
|
|
44
|
+
|
|
45
|
+
uptime_seconds = status["Uptime"].to_i
|
|
46
|
+
{
|
|
47
|
+
server: server_block(version, uptime_seconds),
|
|
48
|
+
connections: connections_block(status, vars),
|
|
49
|
+
innodb: innodb_block(status, vars),
|
|
50
|
+
queries: queries_block(status, uptime_seconds),
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_status
|
|
57
|
+
result = @connection.exec_query("SHOW GLOBAL STATUS")
|
|
58
|
+
result.to_hashes.each_with_object({}) do |row, acc|
|
|
59
|
+
name = (row["Variable_name"] || row["variable_name"]).to_s
|
|
60
|
+
value = (row["Value"] || row["value"]).to_s
|
|
61
|
+
acc[name] = value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_variables
|
|
66
|
+
result = @connection.exec_query("SHOW GLOBAL VARIABLES")
|
|
67
|
+
result.to_hashes.each_with_object({}) do |row, acc|
|
|
68
|
+
name = (row["Variable_name"] || row["variable_name"]).to_s
|
|
69
|
+
value = (row["Value"] || row["value"]).to_s
|
|
70
|
+
acc[name] = value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def server_block(version, uptime_seconds)
|
|
75
|
+
days = uptime_seconds / 86_400
|
|
76
|
+
hours = (uptime_seconds % 86_400) / 3600
|
|
77
|
+
minutes = (uptime_seconds % 3600) / 60
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
version: version,
|
|
81
|
+
uptime: "#{days}d #{hours}h #{minutes}m",
|
|
82
|
+
uptime_seconds: uptime_seconds,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def connections_block(status, vars)
|
|
87
|
+
max_conn = vars["max_connections"].to_i
|
|
88
|
+
current_conn = status["Threads_connected"].to_i
|
|
89
|
+
usage_pct = max_conn.positive? ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
max: max_conn,
|
|
93
|
+
current: current_conn,
|
|
94
|
+
usage_pct: usage_pct,
|
|
95
|
+
threads_running: status["Threads_running"].to_i,
|
|
96
|
+
threads_cached: status["Threads_cached"].to_i,
|
|
97
|
+
threads_created: status["Threads_created"].to_i,
|
|
98
|
+
aborted_connects: status["Aborted_connects"].to_i,
|
|
99
|
+
aborted_clients: status["Aborted_clients"].to_i,
|
|
100
|
+
max_used: status["Max_used_connections"].to_i,
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def innodb_block(status, vars)
|
|
105
|
+
buffer_pool_bytes = vars["innodb_buffer_pool_size"].to_i
|
|
106
|
+
buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
|
|
107
|
+
|
|
108
|
+
reads = status["Innodb_buffer_pool_read_requests"].to_f
|
|
109
|
+
disk_reads = status["Innodb_buffer_pool_reads"].to_f
|
|
110
|
+
hit_rate = reads.positive? ? (((reads - disk_reads) / reads) * 100).round(2) : 0
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
buffer_pool_mb: buffer_pool_mb,
|
|
114
|
+
buffer_pool_hit_rate: hit_rate,
|
|
115
|
+
buffer_pool_pages_dirty: status["Innodb_buffer_pool_pages_dirty"].to_i,
|
|
116
|
+
buffer_pool_pages_free: status["Innodb_buffer_pool_pages_free"].to_i,
|
|
117
|
+
buffer_pool_pages_total: status["Innodb_buffer_pool_pages_total"].to_i,
|
|
118
|
+
row_lock_waits: status["Innodb_row_lock_waits"].to_i,
|
|
119
|
+
row_lock_time_ms: status["Innodb_row_lock_time"].to_f.round(0),
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def queries_block(status, uptime_seconds)
|
|
124
|
+
tmp_tables = status["Created_tmp_tables"].to_i
|
|
125
|
+
tmp_disk_tables = status["Created_tmp_disk_tables"].to_i
|
|
126
|
+
tmp_disk_pct = tmp_tables.positive? ? ((tmp_disk_tables.to_f / tmp_tables) * 100).round(1) : 0
|
|
127
|
+
|
|
128
|
+
questions = status["Questions"].to_i
|
|
129
|
+
qps = uptime_seconds.positive? ? (questions.to_f / uptime_seconds).round(1) : 0
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
questions: questions,
|
|
133
|
+
qps: qps,
|
|
134
|
+
slow_queries: status["Slow_queries"].to_i,
|
|
135
|
+
tmp_tables: tmp_tables,
|
|
136
|
+
tmp_disk_tables: tmp_disk_tables,
|
|
137
|
+
tmp_disk_pct: tmp_disk_pct,
|
|
138
|
+
select_full_join: status["Select_full_join"].to_i,
|
|
139
|
+
sort_merge_passes: status["Sort_merge_passes"].to_i,
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# PostgreSQL implementation. Reads connection/database stats from
|
|
145
|
+
# pg_stat_activity and pg_stat_database; reads tunable settings via
|
|
146
|
+
# pg_settings; populates the `innodb` block with shared_buffers and
|
|
147
|
+
# the buffer cache hit rate so the existing UI continues to render.
|
|
148
|
+
class Postgresql
|
|
149
|
+
def initialize(connection)
|
|
150
|
+
@connection = connection
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def call
|
|
154
|
+
version = @connection.select_value("SELECT version()").to_s
|
|
155
|
+
uptime_seconds = @connection.select_value(
|
|
156
|
+
"SELECT EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time()))::bigint",
|
|
157
|
+
).to_i
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
server: server_block(version, uptime_seconds),
|
|
161
|
+
connections: connections_block,
|
|
162
|
+
innodb: innodb_block,
|
|
163
|
+
queries: queries_block(uptime_seconds),
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def server_block(version, uptime_seconds)
|
|
170
|
+
days = uptime_seconds / 86_400
|
|
171
|
+
hours = (uptime_seconds % 86_400) / 3600
|
|
172
|
+
minutes = (uptime_seconds % 3600) / 60
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
version: version,
|
|
176
|
+
uptime: "#{days}d #{hours}h #{minutes}m",
|
|
177
|
+
uptime_seconds: uptime_seconds,
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def connections_block
|
|
182
|
+
max_conn = setting_int("max_connections")
|
|
183
|
+
current_conn = @connection.select_value("SELECT count(*) FROM pg_stat_activity").to_i
|
|
184
|
+
running = @connection.select_value(
|
|
185
|
+
"SELECT count(*) FROM pg_stat_activity WHERE state = 'active'",
|
|
186
|
+
).to_i
|
|
187
|
+
usage_pct = max_conn.positive? ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
|
|
188
|
+
|
|
189
|
+
# PostgreSQL doesn't track aborted-connect counts the way MySQL
|
|
190
|
+
# does, and pg_stat_database.xact_rollback (rolled-back
|
|
191
|
+
# transactions) is a different concept — surface it on the
|
|
192
|
+
# PG-specific path elsewhere if needed, rather than mislabeling
|
|
193
|
+
# it as "Aborted Clients" here.
|
|
194
|
+
{
|
|
195
|
+
max: max_conn,
|
|
196
|
+
current: current_conn,
|
|
197
|
+
usage_pct: usage_pct,
|
|
198
|
+
threads_running: running,
|
|
199
|
+
threads_cached: 0,
|
|
200
|
+
threads_created: 0,
|
|
201
|
+
aborted_connects: 0,
|
|
202
|
+
aborted_clients: 0,
|
|
203
|
+
max_used: 0,
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def innodb_block
|
|
208
|
+
buffer_pool_bytes = setting_bytes("shared_buffers")
|
|
209
|
+
buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
|
|
210
|
+
|
|
211
|
+
db_stats = current_db_stats
|
|
212
|
+
reads = db_stats[:blks_hit].to_f + db_stats[:blks_read].to_f
|
|
213
|
+
hit_rate = reads.positive? ? ((db_stats[:blks_hit].to_f / reads) * 100).round(2) : 0
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
buffer_pool_mb: buffer_pool_mb,
|
|
217
|
+
buffer_pool_hit_rate: hit_rate,
|
|
218
|
+
buffer_pool_pages_dirty: 0,
|
|
219
|
+
buffer_pool_pages_free: 0,
|
|
220
|
+
buffer_pool_pages_total: 0,
|
|
221
|
+
row_lock_waits: db_stats[:deadlocks],
|
|
222
|
+
row_lock_time_ms: 0,
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def queries_block(uptime_seconds)
|
|
227
|
+
db_stats = current_db_stats
|
|
228
|
+
questions = db_stats[:xact_commit].to_i + db_stats[:xact_rollback].to_i
|
|
229
|
+
qps = uptime_seconds.positive? ? (questions.to_f / uptime_seconds).round(1) : 0
|
|
230
|
+
# PG's `temp_files` is a single count of files written when sorts/
|
|
231
|
+
# hashes spilled out of work_mem. Surface it on both sides of the
|
|
232
|
+
# fraction with a 0% ratio so the dashboard doesn't flag a red
|
|
233
|
+
# "100% spilled to disk" badge that the metric doesn't actually
|
|
234
|
+
# mean on PostgreSQL.
|
|
235
|
+
temp_files = db_stats[:temp_files]
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
questions: questions,
|
|
239
|
+
qps: qps,
|
|
240
|
+
slow_queries: 0,
|
|
241
|
+
tmp_tables: temp_files,
|
|
242
|
+
tmp_disk_tables: temp_files,
|
|
243
|
+
tmp_disk_pct: 0,
|
|
244
|
+
select_full_join: 0,
|
|
245
|
+
sort_merge_passes: 0,
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def current_db_stats
|
|
250
|
+
@current_db_stats ||= begin
|
|
251
|
+
result = @connection.exec_query(<<~SQL)
|
|
252
|
+
SELECT
|
|
253
|
+
COALESCE(xact_commit, 0) AS xact_commit,
|
|
254
|
+
COALESCE(xact_rollback, 0) AS xact_rollback,
|
|
255
|
+
COALESCE(blks_read, 0) AS blks_read,
|
|
256
|
+
COALESCE(blks_hit, 0) AS blks_hit,
|
|
257
|
+
COALESCE(temp_files, 0) AS temp_files,
|
|
258
|
+
COALESCE(deadlocks, 0) AS deadlocks
|
|
259
|
+
FROM pg_stat_database
|
|
260
|
+
WHERE datname = #{@connection.quote(@connection.current_database)}
|
|
261
|
+
SQL
|
|
262
|
+
row = result.to_hashes.first || {}
|
|
263
|
+
{
|
|
264
|
+
xact_commit: row["xact_commit"].to_i,
|
|
265
|
+
xact_rollback: row["xact_rollback"].to_i,
|
|
266
|
+
blks_read: row["blks_read"].to_i,
|
|
267
|
+
blks_hit: row["blks_hit"].to_i,
|
|
268
|
+
temp_files: row["temp_files"].to_i,
|
|
269
|
+
deadlocks: row["deadlocks"].to_i,
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def setting_int(name)
|
|
275
|
+
@connection.select_value(
|
|
276
|
+
"SELECT setting FROM pg_settings WHERE name = #{@connection.quote(name)}",
|
|
277
|
+
).to_i
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# shared_buffers / work_mem etc. report via current_setting() with
|
|
281
|
+
# their configured unit suffix (e.g. "128MB"). pg_size_bytes()
|
|
282
|
+
# resolves that to a raw byte count. Available since PG 9.6.
|
|
283
|
+
def setting_bytes(name)
|
|
284
|
+
@connection.select_value(
|
|
285
|
+
"SELECT pg_size_bytes(current_setting(#{@connection.quote(name)}))",
|
|
286
|
+
).to_i
|
|
287
|
+
rescue StandardError
|
|
288
|
+
0
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Background sampler that periodically queries performance_schema for the
|
|
7
|
+
# top 50 digests by SUM_TIMER_WAIT, computes per-interval deltas, and
|
|
8
|
+
# records snapshots into a StatsHistory ring buffer.
|
|
9
|
+
#
|
|
10
|
+
# The +connection_provider+ is a callable (lambda/proc) that returns a
|
|
11
|
+
# Core::Connection on each invocation. This allows each adapter to supply
|
|
12
|
+
# its own connection strategy:
|
|
13
|
+
#
|
|
14
|
+
# Rails: -> { ActiveRecordAdapter.new(ActiveRecord::Base.connection) }
|
|
15
|
+
# Desktop: -> { session.checkout { |c| c } }
|
|
16
|
+
class StatsCollector
|
|
17
|
+
DEFAULT_INTERVAL = 60
|
|
18
|
+
STOP_JOIN_TIMEOUT = 5
|
|
19
|
+
TOP_N = 50
|
|
20
|
+
|
|
21
|
+
def initialize(connection_provider:, history:, interval: DEFAULT_INTERVAL)
|
|
22
|
+
@connection_provider = connection_provider
|
|
23
|
+
@history = history
|
|
24
|
+
@interval = interval
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@cv = ConditionVariable.new
|
|
27
|
+
@stop_signal = false
|
|
28
|
+
@running = false
|
|
29
|
+
@thread = nil
|
|
30
|
+
@previous = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
return self if @running
|
|
35
|
+
|
|
36
|
+
@stop_signal = false
|
|
37
|
+
@running = true
|
|
38
|
+
@thread = Thread.new { run_loop }
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@stop_signal = true
|
|
45
|
+
@cv.signal
|
|
46
|
+
end
|
|
47
|
+
@thread&.join(STOP_JOIN_TIMEOUT)
|
|
48
|
+
@thread = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def running?
|
|
52
|
+
@running
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_loop
|
|
58
|
+
loop do
|
|
59
|
+
tick
|
|
60
|
+
break if wait_or_stop(@interval)
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
warn("[SqlGenius] StatsCollector stopped: #{e.message}")
|
|
64
|
+
ensure
|
|
65
|
+
@running = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tick
|
|
69
|
+
connection = @connection_provider.call
|
|
70
|
+
result = connection.exec_query(build_sql(connection))
|
|
71
|
+
current = {}
|
|
72
|
+
|
|
73
|
+
result.to_hashes.each do |row|
|
|
74
|
+
digest_text = (row["DIGEST_TEXT"] || row["digest_text"]).to_s
|
|
75
|
+
next if digest_text.empty?
|
|
76
|
+
|
|
77
|
+
calls = (row["COUNT_STAR"] || row["count_star"]).to_i
|
|
78
|
+
total_time_ms = (row["total_time_ms"] || row["TOTAL_TIME_MS"] || 0).to_f
|
|
79
|
+
|
|
80
|
+
current[digest_text] = { calls: calls, total_time_ms: total_time_ms }
|
|
81
|
+
|
|
82
|
+
next unless @previous.key?(digest_text)
|
|
83
|
+
|
|
84
|
+
record_delta(digest_text, calls, total_time_ms)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@previous = current
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_delta(digest_text, calls, total_time_ms)
|
|
91
|
+
prev = @previous[digest_text]
|
|
92
|
+
delta_calls = [calls - prev[:calls], 0].max
|
|
93
|
+
delta_total_ms = [(total_time_ms - prev[:total_time_ms]).round(1), 0.0].max
|
|
94
|
+
|
|
95
|
+
@history.record(digest_text, {
|
|
96
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
97
|
+
calls: delta_calls,
|
|
98
|
+
total_time_ms: delta_total_ms,
|
|
99
|
+
avg_time_ms: delta_calls.positive? ? (delta_total_ms / delta_calls).round(1) : 0.0,
|
|
100
|
+
})
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_sql(connection)
|
|
104
|
+
QueryBuilders.for(connection).stats_snapshot(connection, limit: TOP_N)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_or_stop(seconds)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
return true if @stop_signal
|
|
110
|
+
|
|
111
|
+
@cv.wait(@mutex, seconds)
|
|
112
|
+
@stop_signal
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Thread-safe in-memory ring buffer that stores per-digest query stats
|
|
7
|
+
# snapshots. Each digest key maps to an array of snapshots capped at
|
|
8
|
+
# +max_samples+. Oldest entries are dropped when the cap is reached.
|
|
9
|
+
class StatsHistory
|
|
10
|
+
DEFAULT_MAX_SAMPLES = 1440
|
|
11
|
+
|
|
12
|
+
def initialize(max_samples: DEFAULT_MAX_SAMPLES)
|
|
13
|
+
@max_samples = max_samples
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@data = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(digest_text, snapshot)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
buf = (@data[digest_text] ||= [])
|
|
21
|
+
buf << snapshot
|
|
22
|
+
buf.shift if buf.length > @max_samples
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def series_for(digest_text)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
(@data[digest_text] || []).dup
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def digests
|
|
33
|
+
@mutex.synchronize { @data.keys.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
@mutex.synchronize { @data.clear }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Returns size/fragmentation metrics for each user table in the current
|
|
7
|
+
# database, plus an exact SELECT COUNT(*) for each table. Delegates SQL
|
|
8
|
+
# generation to the dialect-appropriate QueryBuilder so the same class
|
|
9
|
+
# works against MySQL/MariaDB (information_schema.tables) and PostgreSQL
|
|
10
|
+
# (pg_class + pg_total_relation_size).
|
|
11
|
+
#
|
|
12
|
+
# Takes a Core::Connection. Returns an array of hashes suitable for
|
|
13
|
+
# JSON rendering.
|
|
14
|
+
class TableSizes
|
|
15
|
+
def initialize(connection)
|
|
16
|
+
@connection = connection
|
|
17
|
+
@builder = QueryBuilders.for(connection)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
result = @connection.exec_query(@builder.table_sizes(@connection))
|
|
22
|
+
|
|
23
|
+
result.to_hashes.map do |row|
|
|
24
|
+
table_name = row["table_name"] || row["TABLE_NAME"]
|
|
25
|
+
row_count = begin
|
|
26
|
+
@connection.select_value("SELECT COUNT(*) FROM #{@connection.quote_table_name(table_name)}")
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
total_mb = (row["total_mb"] || 0).to_f
|
|
32
|
+
fragmented_mb = (row["fragmented_mb"] || 0).to_f
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
table: table_name,
|
|
36
|
+
rows: row_count,
|
|
37
|
+
engine: row["engine"] || row["ENGINE"],
|
|
38
|
+
collation: row["table_collation"] || row["TABLE_COLLATION"],
|
|
39
|
+
auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
|
|
40
|
+
updated_at: row["update_time"] || row["UPDATE_TIME"],
|
|
41
|
+
data_mb: (row["data_mb"] || 0).to_f,
|
|
42
|
+
index_mb: (row["index_mb"] || 0).to_f,
|
|
43
|
+
total_mb: total_mb,
|
|
44
|
+
fragmented_mb: fragmented_mb,
|
|
45
|
+
needs_optimize: total_mb.positive? && fragmented_mb > (total_mb * 0.1),
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Indexes whose scan count is at or below `min_scans` (default 0 — never
|
|
7
|
+
# scanned since the underlying stats source was last reset). On MySQL this
|
|
8
|
+
# reads performance_schema.table_io_waits_summary_by_index_usage; on
|
|
9
|
+
# PostgreSQL it reads pg_stat_user_indexes plus pg_relation_size for the
|
|
10
|
+
# index byte size.
|
|
11
|
+
#
|
|
12
|
+
# Returns a Result with:
|
|
13
|
+
# indexes — Array of per-index hashes (sorted by size DESC on PG,
|
|
14
|
+
# by write count DESC on MySQL); each carries a dialect-
|
|
15
|
+
# appropriate `drop_sql` and a `size_bytes` value (nil
|
|
16
|
+
# on MySQL where individual index sizes aren't cheap).
|
|
17
|
+
# stats_reset_at — Time the underlying stats source was last reset
|
|
18
|
+
# (PG only — pg_stat_database.stats_reset; nil on MySQL).
|
|
19
|
+
# min_scans — The scan threshold used for this call, echoed back so
|
|
20
|
+
# callers can display "indexes with ≤ N scans".
|
|
21
|
+
#
|
|
22
|
+
# Skips primary key indexes on both dialects, plus unique indexes (which
|
|
23
|
+
# are usually backing a constraint the application depends on). Raises if
|
|
24
|
+
# the underlying stats source is unavailable.
|
|
25
|
+
class UnusedIndexes
|
|
26
|
+
Result = Struct.new(:indexes, :stats_reset_at, :min_scans, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
def initialize(connection, min_scans: 0)
|
|
29
|
+
@connection = connection
|
|
30
|
+
@builder = QueryBuilders.for(connection)
|
|
31
|
+
@min_scans = [min_scans.to_i, 0].max
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
rows = @connection.exec_query(@builder.unused_indexes(@connection, min_scans: @min_scans)).to_hashes
|
|
36
|
+
Result.new(
|
|
37
|
+
indexes: rows.map { |row| transform(row) },
|
|
38
|
+
stats_reset_at: @builder.stats_reset_at(@connection),
|
|
39
|
+
min_scans: @min_scans,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def transform(row)
|
|
46
|
+
table = row["table_name"] || row["TABLE_NAME"]
|
|
47
|
+
index_name = row["index_name"] || row["INDEX_NAME"]
|
|
48
|
+
size_bytes = row["size_bytes"] || row["SIZE_BYTES"]
|
|
49
|
+
{
|
|
50
|
+
table: table,
|
|
51
|
+
index_name: index_name,
|
|
52
|
+
reads: (row["reads"] || row["READS"] || 0).to_i,
|
|
53
|
+
writes: (row["writes"] || row["WRITES"] || 0).to_i,
|
|
54
|
+
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
|
|
55
|
+
size_bytes: size_bytes&.to_i,
|
|
56
|
+
drop_sql: @builder.drop_index_sql(table: table, index_name: index_name),
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Column metadata as returned by Core::Connection#columns_for. Mirrors
|
|
6
|
+
# the subset of ActiveRecord::ConnectionAdapters::Column that the
|
|
7
|
+
# analyses and AI services rely on.
|
|
8
|
+
class ColumnDefinition
|
|
9
|
+
attr_reader :name, :type, :sql_type, :null, :default, :primary_key
|
|
10
|
+
|
|
11
|
+
def initialize(name:, type:, sql_type:, null:, default:, primary_key:)
|
|
12
|
+
@name = name
|
|
13
|
+
@type = type
|
|
14
|
+
@sql_type = sql_type
|
|
15
|
+
@null = null
|
|
16
|
+
@default = default
|
|
17
|
+
@primary_key = primary_key
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def null?
|
|
22
|
+
@null
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def primary_key?
|
|
26
|
+
@primary_key
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sql_genius/core"
|
|
4
|
+
|
|
5
|
+
module SqlGenius
|
|
6
|
+
module Core
|
|
7
|
+
module Connection
|
|
8
|
+
# Wraps an ActiveRecord::Base.connection and implements the
|
|
9
|
+
# Core::Connection contract.
|
|
10
|
+
class ActiveRecordAdapter
|
|
11
|
+
def initialize(ar_connection)
|
|
12
|
+
@ar = ar_connection
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exec_query(sql, binds: [])
|
|
16
|
+
_ = binds
|
|
17
|
+
ar_result = @ar.exec_query(sql)
|
|
18
|
+
Core::Result.new(columns: ar_result.columns, rows: ar_result.rows)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def select_value(sql)
|
|
22
|
+
@ar.select_value(sql)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def server_version
|
|
26
|
+
@server_version ||= Core::ServerInfo.parse(@ar.select_value("SELECT VERSION()").to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def current_database
|
|
30
|
+
@ar.current_database
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def quote(value)
|
|
34
|
+
@ar.quote(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def quote_table_name(name)
|
|
38
|
+
@ar.quote_table_name(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tables
|
|
42
|
+
@ar.tables
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def columns_for(table)
|
|
46
|
+
pk = @ar.primary_key(table)
|
|
47
|
+
@ar.columns(table).map do |c|
|
|
48
|
+
Core::ColumnDefinition.new(
|
|
49
|
+
name: c.name,
|
|
50
|
+
type: c.type,
|
|
51
|
+
sql_type: c.sql_type,
|
|
52
|
+
null: c.null,
|
|
53
|
+
default: c.default,
|
|
54
|
+
primary_key: c.name == pk,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def indexes_for(table)
|
|
60
|
+
@ar.indexes(table).map do |idx|
|
|
61
|
+
Core::IndexDefinition.new(name: idx.name, columns: idx.columns, unique: idx.unique)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def primary_key(table)
|
|
66
|
+
@ar.primary_key(table)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def close
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|