shiba 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/bin/dump_stats +4 -2
- data/bin/explain +5 -3
- data/lib/shiba.rb +6 -5
- data/lib/shiba/checker.rb +2 -4
- data/lib/shiba/explain.rb +37 -75
- data/lib/shiba/explain/checks.rb +37 -22
- data/lib/shiba/explain/result.rb +3 -2
- data/lib/shiba/fuzzer.rb +2 -4
- data/lib/shiba/index_stats.rb +1 -1
- data/lib/shiba/output/tags.yaml +7 -17
- data/lib/shiba/query.rb +45 -9
- data/lib/shiba/review/comment_renderer.rb +7 -5
- data/lib/shiba/reviewer.rb +3 -5
- data/lib/shiba/version.rb +1 -1
- data/web/results.html.erb +26 -13
- metadata +3 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4c19ddf8bb56062725650ff02bede1ee4863ec0de0c6ab56ca420496eb35cc5b
|
4
|
+
data.tar.gz: 8ddfbc998e013cae6ecb5fc0b3ad05e7e76c2f086317cca14699099ffbdb7064
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e73819cb77cdc7efdaf521d76a64a2dc0ad3e27d97260cc45c2e4c59ac021fac6d12ad501fac2c299455963e6cd6c7650366e91ec6f067c873a1ff97e8d397c4
|
7
|
+
data.tar.gz: 302a916584abf9fd32c210498639ab63739d953b569d5b57313bcde0baa563d5fbf66bba1dcff28f16282c3fb8f9e18b56d6187d689f77a378bafc13323a7f49
|
data/Gemfile.lock
CHANGED
data/bin/dump_stats
CHANGED
@@ -10,8 +10,10 @@ parser = Shiba::Configure.make_options_parser(options, only_basics: true)
|
|
10
10
|
parser.banner = "Dump database statistics into yaml file."
|
11
11
|
parser.parse!
|
12
12
|
|
13
|
-
|
14
|
-
|
13
|
+
begin
|
14
|
+
Shiba.configure(options)
|
15
|
+
rescue Shiba::ConfigError => e
|
16
|
+
$stderr.puts(e.message)
|
15
17
|
$stderr.puts(parser.help)
|
16
18
|
exit 1
|
17
19
|
end
|
data/bin/explain
CHANGED
@@ -25,8 +25,10 @@ end
|
|
25
25
|
file = options.delete("file")
|
26
26
|
file = File.open(file, "r") if file
|
27
27
|
|
28
|
-
|
29
|
-
|
28
|
+
begin
|
29
|
+
Shiba.configure(options)
|
30
|
+
rescue Shiba::ConfigError => e
|
31
|
+
$stderr.puts(e.message)
|
30
32
|
$stderr.puts(parser)
|
31
33
|
exit 2
|
32
34
|
end
|
@@ -50,7 +52,7 @@ end
|
|
50
52
|
table_stats = Shiba::TableStats.new(Shiba.index_config, Shiba.connection, {})
|
51
53
|
queries = Shiba::Analyzer.analyze(file, json, table_stats, options)
|
52
54
|
|
53
|
-
problems = queries.select { |q| q[:
|
55
|
+
problems = queries.select { |q| q[:severity] && q[:severity] != 'none' }
|
54
56
|
|
55
57
|
if problems.any?
|
56
58
|
query_word = problems.size == 1 ? 'query' : 'queries'
|
data/lib/shiba.rb
CHANGED
@@ -7,16 +7,17 @@ require "byebug" if ENV['SHIBA_DEBUG']
|
|
7
7
|
|
8
8
|
module Shiba
|
9
9
|
class Error < StandardError; end
|
10
|
+
class ConfigError < StandardError; end
|
10
11
|
|
11
|
-
def self.configure(options
|
12
|
-
configure_mysql_defaults(options
|
12
|
+
def self.configure(options)
|
13
|
+
configure_mysql_defaults(options)
|
13
14
|
|
14
15
|
@connection_hash = options.select { |k, v| [ 'default_file', 'default_group', 'server', 'username', 'database', 'host', 'password', 'port'].include?(k) }
|
15
16
|
@main_config = Configure.read_config_file(options['config'], "config/shiba.yml")
|
16
17
|
@index_config = Configure.read_config_file(options['index'], "config/shiba_index.yml")
|
17
18
|
end
|
18
19
|
|
19
|
-
def self.configure_mysql_defaults(options
|
20
|
+
def self.configure_mysql_defaults(options)
|
20
21
|
option_path = Shiba::Configure.mysql_config_path
|
21
22
|
|
22
23
|
if option_path
|
@@ -40,11 +41,11 @@ module Shiba
|
|
40
41
|
end
|
41
42
|
|
42
43
|
if !options["username"] && !option_file.include?('user')
|
43
|
-
|
44
|
+
raise Shiba::ConfigError.new('Required: --username')
|
44
45
|
end
|
45
46
|
|
46
47
|
if !options["database"] && !option_file.include?('database')
|
47
|
-
|
48
|
+
raise Shiba::ConfigError.new('Required: --database')
|
48
49
|
end
|
49
50
|
end
|
50
51
|
|
data/lib/shiba/checker.rb
CHANGED
@@ -8,8 +8,6 @@ module Shiba
|
|
8
8
|
# Given an explain log and a diff, returns any explain logs
|
9
9
|
# that appear to be caused by the diff.
|
10
10
|
class Checker
|
11
|
-
MAGIC_COST = 100
|
12
|
-
|
13
11
|
Result = Struct.new(:status, :message, :problems)
|
14
12
|
|
15
13
|
attr_reader :options
|
@@ -35,7 +33,7 @@ module Shiba
|
|
35
33
|
end
|
36
34
|
|
37
35
|
explains = select_lines_with_changed_files(log)
|
38
|
-
problems = explains.select { |explain| explain["
|
36
|
+
problems = explains.select { |explain| explain["severity"] && explain["severity"] != 'none' }
|
39
37
|
|
40
38
|
|
41
39
|
if options["verbose"]
|
@@ -164,4 +162,4 @@ module Shiba
|
|
164
162
|
end
|
165
163
|
end
|
166
164
|
end
|
167
|
-
end
|
165
|
+
end
|
data/lib/shiba/explain.rb
CHANGED
@@ -8,14 +8,21 @@ require 'shiba/explain/postgres_explain'
|
|
8
8
|
|
9
9
|
module Shiba
|
10
10
|
class Explain
|
11
|
+
COST_PER_ROW_READ = 2.5e-07 # TBD; data size would be better
|
12
|
+
COST_PER_ROW_SORT = 1.0e-07
|
13
|
+
COST_PER_ROW_RETURNED = 3.0e-05
|
14
|
+
|
11
15
|
include CheckSupport
|
12
16
|
extend CheckSupport::ClassMethods
|
13
|
-
|
14
|
-
|
15
|
-
@
|
17
|
+
|
18
|
+
def initialize(query, stats, options = {})
|
19
|
+
@query = query
|
20
|
+
@sql = query.sql
|
21
|
+
|
22
|
+
@backtrace = query.backtrace
|
16
23
|
|
17
24
|
if options[:force_key]
|
18
|
-
|
25
|
+
@sql = @sql.sub(/(FROM\s*\S+)/i, '\1' + " FORCE INDEX(`#{options[:force_key]}`)")
|
19
26
|
end
|
20
27
|
|
21
28
|
@options = options
|
@@ -35,7 +42,8 @@ module Shiba
|
|
35
42
|
def as_json
|
36
43
|
{
|
37
44
|
sql: @sql,
|
38
|
-
table:
|
45
|
+
table: @query.from_table,
|
46
|
+
md5: @query.md5,
|
39
47
|
messages: @result.messages,
|
40
48
|
cost: @result.cost,
|
41
49
|
severity: severity,
|
@@ -52,17 +60,6 @@ module Shiba
|
|
52
60
|
@result.cost
|
53
61
|
end
|
54
62
|
|
55
|
-
def get_table
|
56
|
-
@sql =~ /\s+from\s*([^\s,]+)/i
|
57
|
-
table = $1
|
58
|
-
return nil unless table
|
59
|
-
|
60
|
-
table = table.downcase
|
61
|
-
table.gsub!('`', '')
|
62
|
-
table.gsub!(/.*\.(.*)/, '\1')
|
63
|
-
table
|
64
|
-
end
|
65
|
-
|
66
63
|
def first
|
67
64
|
@rows.first
|
68
65
|
end
|
@@ -77,30 +74,17 @@ module Shiba
|
|
77
74
|
|
78
75
|
def severity
|
79
76
|
case @result.cost
|
80
|
-
when 0..
|
77
|
+
when 0..0.01
|
78
|
+
"none"
|
79
|
+
when 0.01..0.10
|
81
80
|
"low"
|
82
|
-
when
|
81
|
+
when 0.1..1.0
|
83
82
|
"medium"
|
84
|
-
when 1000..1_000_000_000
|
85
|
-
"high"
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
def limit
|
90
|
-
if @sql =~ /limit\s*(\d+)\s*(offset \d+)?$/i
|
91
|
-
$1.to_i
|
92
83
|
else
|
93
|
-
|
84
|
+
"high"
|
94
85
|
end
|
95
86
|
end
|
96
87
|
|
97
|
-
def aggregation?
|
98
|
-
@sql =~ /select\s*(.*?)from/i
|
99
|
-
select_fields = $1
|
100
|
-
select_fields =~ /(min|max|avg|count|sum|group_concat)\s*\(.*?\)/i
|
101
|
-
end
|
102
|
-
|
103
|
-
|
104
88
|
def ignore?
|
105
89
|
!!ignore_line_and_backtrace_line
|
106
90
|
end
|
@@ -124,16 +108,16 @@ module Shiba
|
|
124
108
|
def check_query_is_ignored
|
125
109
|
if ignore?
|
126
110
|
@result.messages << { tag: "ignored" }
|
127
|
-
@cost = 0
|
111
|
+
@result.cost = 0
|
128
112
|
end
|
129
113
|
end
|
130
114
|
|
131
115
|
check :check_no_matching_row_in_const_table
|
132
116
|
def check_no_matching_row_in_const_table
|
133
117
|
if no_matching_row_in_const_table?
|
134
|
-
@result.messages << { tag: "access_type_const", table:
|
118
|
+
@result.messages << { tag: "access_type_const", table: @query.from_table }
|
135
119
|
first['key'] = 'PRIMARY'
|
136
|
-
@cost =
|
120
|
+
@result.cost = 0
|
137
121
|
end
|
138
122
|
end
|
139
123
|
|
@@ -147,25 +131,7 @@ module Shiba
|
|
147
131
|
check :check_query_shortcircuits
|
148
132
|
def check_query_shortcircuits
|
149
133
|
if first_extra && IGNORE_PATTERNS.any? { |p| first_extra =~ p }
|
150
|
-
@cost = 0
|
151
|
-
end
|
152
|
-
end
|
153
|
-
|
154
|
-
# TODO: need to parse SQL here I think
|
155
|
-
def simple_table_scan?
|
156
|
-
@rows.size == 1 && (@sql !~ /order by/i) &&
|
157
|
-
(@rows.first['using_index'] || !(@sql =~ /\s+WHERE\s+/i))
|
158
|
-
end
|
159
|
-
|
160
|
-
# TODO: we don't catch some cases like SELECT * from foo where index_col = 1 limit 1
|
161
|
-
# bcs we really just need to parse the SQL.
|
162
|
-
check :check_simple_table_scan
|
163
|
-
def check_simple_table_scan
|
164
|
-
if simple_table_scan?
|
165
|
-
if limit
|
166
|
-
@result.messages << { tag: 'limited_scan', cost: limit, table: @rows.first['table'] }
|
167
|
-
@cost = limit
|
168
|
-
end
|
134
|
+
@result.cost = 0
|
169
135
|
end
|
170
136
|
end
|
171
137
|
|
@@ -184,36 +150,32 @@ module Shiba
|
|
184
150
|
end
|
185
151
|
|
186
152
|
def check_return_size
|
187
|
-
if limit
|
188
|
-
return_size = limit
|
189
|
-
elsif aggregation?
|
153
|
+
if @query.limit
|
154
|
+
return_size = [@query.limit, @result.result_size].min
|
155
|
+
elsif @query.aggregation?
|
190
156
|
return_size = 1
|
191
157
|
else
|
192
158
|
return_size = @result.result_size
|
193
159
|
end
|
194
160
|
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
@result.messages << { tag: "retsize_good", result_size: return_size }
|
199
|
-
end
|
161
|
+
cost = COST_PER_ROW_RETURNED * return_size
|
162
|
+
@result.cost += cost
|
163
|
+
@result.messages << { tag: "retsize", result_size: return_size, cost: cost }
|
200
164
|
end
|
201
165
|
|
202
166
|
def run_checks!
|
203
167
|
# first run top-level checks
|
204
168
|
_run_checks! do
|
205
|
-
:stop if @cost
|
169
|
+
:stop if @result.cost
|
206
170
|
end
|
207
171
|
|
208
|
-
if @cost
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
check.run_checks!
|
216
|
-
end
|
172
|
+
return if @result.cost
|
173
|
+
|
174
|
+
@result.cost = 0
|
175
|
+
# run per-table checks
|
176
|
+
0.upto(@rows.size - 1) do |i|
|
177
|
+
check = Checks.new(@rows, i, @stats, @options, @query, @result)
|
178
|
+
check.run_checks!
|
217
179
|
end
|
218
180
|
|
219
181
|
check_return_size
|
@@ -232,7 +194,7 @@ module Shiba
|
|
232
194
|
next [] unless r['possible_keys'] && r['key'].nil?
|
233
195
|
possible = r['possible_keys'] - [r['key']]
|
234
196
|
possible.map do |p|
|
235
|
-
Explain.new(@
|
197
|
+
Explain.new(@query, @stats, force_key: p) rescue nil
|
236
198
|
end.compact
|
237
199
|
end.flatten
|
238
200
|
else
|
data/lib/shiba/explain/checks.rb
CHANGED
@@ -6,12 +6,13 @@ module Shiba
|
|
6
6
|
include CheckSupport
|
7
7
|
extend CheckSupport::ClassMethods
|
8
8
|
|
9
|
-
def initialize(rows, index, stats, options, result)
|
9
|
+
def initialize(rows, index, stats, options, query, result)
|
10
10
|
@rows = rows
|
11
11
|
@row = rows[index]
|
12
12
|
@index = index
|
13
13
|
@stats = stats
|
14
14
|
@options = options
|
15
|
+
@query = query
|
15
16
|
@result = result
|
16
17
|
@tbl_message = {}
|
17
18
|
end
|
@@ -30,6 +31,26 @@ module Shiba
|
|
30
31
|
@result.messages << { tag: tag, table_size: table_size, table: table }.merge(extra)
|
31
32
|
end
|
32
33
|
|
34
|
+
# TODO: need to parse SQL here I think
|
35
|
+
def simple_table_scan?
|
36
|
+
@rows.size == 1 &&
|
37
|
+
(@row['using_index'] || !(@query.sql =~ /\s+WHERE\s+/i)) &&
|
38
|
+
(@row['access_type'] == "index" || (@query.sql !~ /order by/i)) &&
|
39
|
+
@query.limit
|
40
|
+
end
|
41
|
+
|
42
|
+
# TODO: we don't catch some cases like SELECT * from foo where index_col = 1 limit 1
|
43
|
+
# bcs we really just need to parse the SQL.
|
44
|
+
check :check_simple_table_scan
|
45
|
+
def check_simple_table_scan
|
46
|
+
if simple_table_scan?
|
47
|
+
rows_read = [@query.limit, table_size].min
|
48
|
+
@cost = @result.cost = rows_read * Shiba::Explain::COST_PER_ROW_READ
|
49
|
+
@result.messages << { tag: 'limited_scan', cost: @result.cost, table: table, rows_read: rows_read }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
33
54
|
check :check_derived
|
34
55
|
def check_derived
|
35
56
|
if table =~ /<derived.*?>/
|
@@ -75,54 +96,48 @@ module Shiba
|
|
75
96
|
|
76
97
|
check :check_key_size
|
77
98
|
def check_key_size
|
78
|
-
if @
|
79
|
-
|
99
|
+
if @access_type == "access_type_index"
|
100
|
+
# access-type index means a table-scan as performed on an index... all rows.
|
101
|
+
key_size = table_size
|
102
|
+
elsif @row['key']
|
103
|
+
key_size = @stats.estimate_key(table, @row['key'], @row['used_key_parts'])
|
80
104
|
else
|
81
|
-
|
105
|
+
key_size = table_size
|
82
106
|
end
|
83
107
|
|
84
108
|
# TBD: this appears to come from a couple of bugs.
|
85
109
|
# one is we're not handling mysql index-merges, the other is that
|
86
110
|
# we're not handling mysql table aliasing.
|
87
|
-
if
|
88
|
-
|
111
|
+
if key_size.nil?
|
112
|
+
key_size = 1
|
89
113
|
end
|
90
114
|
|
91
115
|
if @row['join_ref']
|
92
|
-
# when joining, we'll say we read "
|
116
|
+
# when joining, we'll say we read "key_size * (previous result size)" rows -- but up to
|
93
117
|
# a max of the table size. I'm not sure this assumption is *exactly*
|
94
118
|
# true but it feels good enough to start; a decent hash join should
|
95
119
|
# nullify the cost of re-reading rows. I think.
|
96
|
-
|
120
|
+
rows_read = [@result.result_size * key_size, table_size || 2**32].min
|
97
121
|
|
98
122
|
# poke holes in this. Is this even remotely accurate?
|
99
123
|
# We're saying that if we join to a a table with 100 rows per item
|
100
124
|
# in the index, for each row we'll be joining in 100 more rows. Is that true?
|
101
|
-
@result.result_size *=
|
125
|
+
@result.result_size *= key_size
|
102
126
|
else
|
103
|
-
|
104
|
-
@result.result_size +=
|
127
|
+
rows_read = key_size
|
128
|
+
@result.result_size += key_size
|
105
129
|
end
|
106
130
|
|
131
|
+
@cost = Shiba::Explain::COST_PER_ROW_READ * rows_read
|
107
132
|
@result.cost += @cost
|
108
133
|
|
109
134
|
@tbl_message['cost'] = @cost
|
135
|
+
@tbl_message['rows_read'] = rows_read
|
110
136
|
@tbl_message['index'] = @row['key']
|
111
137
|
@tbl_message['index_used'] = @row['used_key_parts']
|
112
138
|
add_message(@access_type, @tbl_message)
|
113
139
|
end
|
114
140
|
|
115
|
-
def estimate_row_count_with_key(key)
|
116
|
-
explain = Explain.new(@sql, @stats, @backtrace, force_key: key)
|
117
|
-
explain.run_checks!
|
118
|
-
rescue Mysql2::Error => e
|
119
|
-
if /Key .+? doesn't exist in table/ =~ e.message
|
120
|
-
return nil
|
121
|
-
end
|
122
|
-
|
123
|
-
raise e
|
124
|
-
end
|
125
|
-
|
126
141
|
def run_checks!
|
127
142
|
_run_checks! do
|
128
143
|
:stop if @cost
|
data/lib/shiba/explain/result.rb
CHANGED
@@ -7,11 +7,12 @@ module Shiba
|
|
7
7
|
|
8
8
|
def initialize
|
9
9
|
@messages = []
|
10
|
-
@cost =
|
10
|
+
@cost = nil
|
11
11
|
@result_size = 0
|
12
|
+
@rows_read = 0
|
12
13
|
end
|
13
14
|
|
14
|
-
attr_accessor :messages, :cost, :result_size
|
15
|
+
attr_accessor :messages, :cost, :result_size, :rows_read
|
15
16
|
end
|
16
17
|
end
|
17
18
|
end
|
data/lib/shiba/fuzzer.rb
CHANGED
@@ -40,8 +40,8 @@ module Shiba
|
|
40
40
|
|
41
41
|
private
|
42
42
|
|
43
|
-
BIG_FUZZ_SIZE =
|
44
|
-
SMALL_FUZZ_SIZE =
|
43
|
+
BIG_FUZZ_SIZE = 100_000
|
44
|
+
SMALL_FUZZ_SIZE = 1000
|
45
45
|
|
46
46
|
|
47
47
|
# Create fake table sizes based on the table's index count.
|
@@ -63,11 +63,9 @@ module Shiba
|
|
63
63
|
end
|
64
64
|
|
65
65
|
size = sizes[table_name]
|
66
|
-
# Big
|
67
66
|
if size >= large_table_index_count
|
68
67
|
sizes[table_name] = BIG_FUZZ_SIZE
|
69
68
|
else
|
70
|
-
#small
|
71
69
|
sizes[table_name] = SMALL_FUZZ_SIZE
|
72
70
|
end
|
73
71
|
end
|
data/lib/shiba/index_stats.rb
CHANGED
data/lib/shiba/output/tags.yaml
CHANGED
@@ -13,28 +13,23 @@ possible_key_check:
|
|
13
13
|
MySQL reported that it had keys it could have used, but elected not to use them.
|
14
14
|
Shiba checked each of the possible keys and returned the results from the best
|
15
15
|
key possible. Sometimes "possible_keys" will be inaccurate and no keys were possible.
|
16
|
-
level: info
|
17
16
|
access_type_const:
|
18
17
|
title: One row
|
19
18
|
summary: The database only needs to read a single row from <b>{{table}}</b>.
|
20
19
|
description: |
|
21
20
|
This query selects at *most* one row, which is about as good as things get.
|
22
|
-
level: success
|
23
21
|
access_type_ref:
|
24
22
|
title: Index Scan
|
25
23
|
summary: The database reads {{ formatted_cost }} rows in <b>{{ table }}</b> via the <i>{{ index }}</i> index ({{ key_parts }}).
|
26
24
|
description: |
|
27
25
|
This query uses an index to find rows that match a single value. Often this
|
28
26
|
has very good performance, but it depends on how many rows match that value.
|
29
|
-
level: success
|
30
27
|
join_type_eq_ref:
|
31
28
|
title: Indexed Join
|
32
|
-
summary: <b>{{ table }}</b> is joined to <b>{{ join_to }}</b> via
|
33
|
-
level: success
|
29
|
+
summary: <b>{{ table }}</b> is joined to <b>{{ join_to }}</b> via <i>{{ index }}</i>, reading 1 row per joined item.
|
34
30
|
join_type_ref:
|
35
31
|
title: Indexed Join
|
36
|
-
summary: <b>{{ table }}</b> is joined to <b>{{ join_to }}</b> via
|
37
|
-
level: success
|
32
|
+
summary: <b>{{ table }}</b> is joined to <b>{{ join_to }}</b> via <i>{{ index }}</i>, reading {{ formatted_cost }} rows per joined item.
|
38
33
|
access_type_range:
|
39
34
|
title: Indexed
|
40
35
|
summary: The database uses a "range scan" to read more than {{ formatted_cost }} rows in {{ table }} via the <b>{{ index }}</b> index ({{ key_parts }})
|
@@ -43,7 +38,9 @@ access_type_range:
|
|
43
38
|
`WHERE indexed_value in (1,2,5,6)` or `WHERE indexed_value >= 5 AND indexed_value <= 15`.
|
44
39
|
It's very hard to estimate how many rows this query will consider in production, so we've
|
45
40
|
upped the formatted_cost of this query.
|
46
|
-
|
41
|
+
access_type_index:
|
42
|
+
title: Index Scan
|
43
|
+
summary: The database reads {{ formatted_cost }} of the rows in <b>{{ table }}</b> via <i>{{ index }}</i>
|
47
44
|
access_type_tablescan:
|
48
45
|
title: Table Scan
|
49
46
|
summary: The database reads {{ formatted_cost }} of the rows in <b>{{ table }}</b>, skipping any indexes.
|
@@ -54,14 +51,12 @@ access_type_tablescan:
|
|
54
51
|
This *can* be fine if, say, you're querying a tiny table (less than ~500 rows),
|
55
52
|
but be aware that if this table is not effectively tiny or constant-sized you're entering
|
56
53
|
a world of pain.
|
57
|
-
level: danger
|
58
54
|
limited_scan:
|
59
55
|
title: Limited Scan
|
60
56
|
summary: The database reads {{ formatted_cost }} rows from {{ table }}.
|
61
57
|
description: |
|
62
58
|
This query doesn't use any indexes to find data, but since it doesn't care about
|
63
59
|
ordering and it doesn't have any conditions, it only ever reads {{ formatted_cost }} rows.
|
64
|
-
level: info
|
65
60
|
ignored:
|
66
61
|
title: Ignored
|
67
62
|
summary: This query matched an "ignore" rule in shiba.yml. Any further analysis was skipped.
|
@@ -74,11 +69,6 @@ index_walk:
|
|
74
69
|
can read the index and simply pluck {LIMIT} rows out of the index. It's a very
|
75
70
|
fast way to look up stuff by index.
|
76
71
|
level: success
|
77
|
-
|
78
|
-
title:
|
79
|
-
summary: The database returns {{ result_size }} rows to the client.
|
80
|
-
level: danger
|
81
|
-
retsize_good:
|
82
|
-
title: Small Results
|
72
|
+
retsize:
|
73
|
+
title: Results
|
83
74
|
summary: The database returns {{ result_size }} row(s) to the client.
|
84
|
-
level: success
|
data/lib/shiba/query.rb
CHANGED
@@ -1,17 +1,29 @@
|
|
1
1
|
require 'open3'
|
2
2
|
require 'shiba/explain'
|
3
|
+
require 'timeout'
|
4
|
+
require 'thread'
|
5
|
+
require 'digest'
|
3
6
|
|
4
7
|
module Shiba
|
5
8
|
class Query
|
6
9
|
@@index = 0
|
7
10
|
FINGERPRINTER = Shiba.root + "/bin/fingerprint"
|
8
11
|
|
12
|
+
@@fingerprinter_mutex = Mutex.new
|
9
13
|
def self.get_fingerprint(query)
|
10
|
-
|
11
|
-
|
14
|
+
@@fingerprinter_mutex.synchronize do
|
15
|
+
if !@stdin
|
16
|
+
@stdin, @stdout, _ = Open3.popen2(FINGERPRINTER)
|
17
|
+
end
|
18
|
+
@stdin.puts(query.gsub(/\n/, ' '))
|
19
|
+
begin
|
20
|
+
Timeout.timeout(2) do
|
21
|
+
@stdout.readline.chomp
|
22
|
+
end
|
23
|
+
rescue StandardError => e
|
24
|
+
$stderr.puts("shiba: timed out waiting for fingerprinter on #{query}...")
|
25
|
+
end
|
12
26
|
end
|
13
|
-
@stdin.puts(query.gsub(/\n/, ' '))
|
14
|
-
@stdout.readline.chomp
|
15
27
|
end
|
16
28
|
|
17
29
|
def initialize(sql, stats)
|
@@ -29,19 +41,43 @@ module Shiba
|
|
29
41
|
@index = @@index
|
30
42
|
end
|
31
43
|
|
32
|
-
attr_reader :sql, :index
|
33
|
-
|
44
|
+
attr_reader :sql, :index, :backtrace
|
34
45
|
|
35
46
|
def fingerprint
|
36
47
|
@fingerprint ||= self.class.get_fingerprint(@sql)
|
37
48
|
end
|
38
49
|
|
50
|
+
def md5
|
51
|
+
Digest::MD5.hexdigest(fingerprint)
|
52
|
+
end
|
53
|
+
|
39
54
|
def explain
|
40
|
-
Explain.new(
|
55
|
+
Explain.new(self, @stats)
|
56
|
+
end
|
57
|
+
|
58
|
+
def from_table
|
59
|
+
@sql =~ /\s+from\s*([^\s,]+)/i
|
60
|
+
table = $1
|
61
|
+
return nil unless table
|
62
|
+
|
63
|
+
table = table.downcase
|
64
|
+
table.gsub!('`', '')
|
65
|
+
table.gsub!(/.*\.(.*)/, '\1')
|
66
|
+
table
|
67
|
+
end
|
68
|
+
|
69
|
+
def limit
|
70
|
+
if @sql =~ /limit\s*(\d+)\s*(offset \d+)?$/i
|
71
|
+
$1.to_i
|
72
|
+
else
|
73
|
+
nil
|
74
|
+
end
|
41
75
|
end
|
42
76
|
|
43
|
-
def
|
44
|
-
@
|
77
|
+
def aggregation?
|
78
|
+
@sql =~ /select\s*(.*?)from/i
|
79
|
+
select_fields = $1
|
80
|
+
select_fields =~ /(min|max|avg|count|sum|group_concat)\s*\(.*?\)/i
|
45
81
|
end
|
46
82
|
end
|
47
83
|
end
|
@@ -22,6 +22,7 @@ module Shiba
|
|
22
22
|
body << "\n"
|
23
23
|
end
|
24
24
|
|
25
|
+
body << "Estimated query time: %.2fs" % explain['cost']
|
25
26
|
body
|
26
27
|
end
|
27
28
|
|
@@ -44,6 +45,7 @@ module Shiba
|
|
44
45
|
"table_size" => message["table_size"],
|
45
46
|
"result_size" => message["result_size"],
|
46
47
|
"index" => message["index"],
|
48
|
+
"join_to" => message["join_to"],
|
47
49
|
"key_parts" => (message["index_used"] || []).join(','),
|
48
50
|
"size" => message["size"],
|
49
51
|
"formatted_cost" => formatted_cost(message)
|
@@ -51,13 +53,13 @@ module Shiba
|
|
51
53
|
end
|
52
54
|
|
53
55
|
def formatted_cost(explain)
|
54
|
-
return nil unless explain["
|
55
|
-
percentage = (explain["
|
56
|
+
return nil unless explain["rows_read"] && explain["table_size"]
|
57
|
+
percentage = (explain["rows_read"] / explain["table_size"]) * 100.0;
|
56
58
|
|
57
|
-
if explain["
|
58
|
-
"#{percentage.floor}% (#{explain["
|
59
|
+
if explain["rows_read"] > 100 && percentage > 1
|
60
|
+
"#{percentage.floor}% (#{explain["rows_read"]}) of the"
|
59
61
|
else
|
60
|
-
explain["
|
62
|
+
explain["rows_read"]
|
61
63
|
end
|
62
64
|
end
|
63
65
|
|
data/lib/shiba/reviewer.rb
CHANGED
@@ -11,6 +11,7 @@ module Shiba
|
|
11
11
|
# is semi-corrected but still a problem
|
12
12
|
class Reviewer
|
13
13
|
TEMPLATE_FILE = File.join(Shiba.root, 'lib/shiba/output/tags.yaml')
|
14
|
+
MESSAGE_FILTER_THRESHOLD = 0.005
|
14
15
|
|
15
16
|
attr_reader :repo_url, :problems, :options
|
16
17
|
|
@@ -34,9 +35,7 @@ module Shiba
|
|
34
35
|
|
35
36
|
position = diff.find_position(file, line_number.to_i)
|
36
37
|
|
37
|
-
|
38
|
-
explain = keep_only_dangerous_messages(explain)
|
39
|
-
end
|
38
|
+
explain = keep_only_dangerous_messages(explain)
|
40
39
|
|
41
40
|
{ body: renderer.render(explain),
|
42
41
|
commit_id: @commit_id,
|
@@ -93,8 +92,7 @@ module Shiba
|
|
93
92
|
def keep_only_dangerous_messages(explain)
|
94
93
|
explain_b = explain.dup
|
95
94
|
explain_b["messages"] = explain_b["messages"].select do |message|
|
96
|
-
|
97
|
-
tags[tag]["level"] == "danger"
|
95
|
+
message['cost'] && message['cost'] > MESSAGE_FILTER_THRESHOLD
|
98
96
|
end
|
99
97
|
explain_b
|
100
98
|
end
|
data/lib/shiba/version.rb
CHANGED
data/web/results.html.erb
CHANGED
@@ -25,7 +25,7 @@
|
|
25
25
|
var queriesByTable = [];
|
26
26
|
var queriesByTableLow = [];
|
27
27
|
var queriesHaveFuzzed = false;
|
28
|
-
var severityIndexes = { high: 1, medium: 2, low: 3 };
|
28
|
+
var severityIndexes = { high: 1, medium: 2, low: 3, none: 4 };
|
29
29
|
|
30
30
|
function sortByFunc(fields) {
|
31
31
|
return function(a, b) {
|
@@ -76,7 +76,7 @@
|
|
76
76
|
data.queries.forEach(function(query) {
|
77
77
|
var q = new Query(query);
|
78
78
|
|
79
|
-
if ( q.
|
79
|
+
if ( q.severity == "none" ) {
|
80
80
|
queriesByTableLow.push(q);
|
81
81
|
} else {
|
82
82
|
queriesByTable.push(q);
|
@@ -89,11 +89,11 @@
|
|
89
89
|
|
90
90
|
var rCost = 0;
|
91
91
|
q.messages.forEach(function(m) {
|
92
|
-
if ( m.cost ) {
|
92
|
+
if ( m.cost && m.cost != 0) {
|
93
93
|
rCost += m.cost;
|
94
94
|
m.running_cost = rCost;
|
95
95
|
} else {
|
96
|
-
m.running_cost =
|
96
|
+
m.running_cost = undefined;
|
97
97
|
}
|
98
98
|
});
|
99
99
|
});
|
@@ -129,6 +129,9 @@
|
|
129
129
|
<table class="shiba-messages">
|
130
130
|
<component v-for="message in query.messages" v-bind:is="'tag-' + message.tag" v-bind="message"></component>
|
131
131
|
</table>
|
132
|
+
<% if ENV['SHIBA_DEBUG'] %>
|
133
|
+
<div style="font-size: 10px">md5: {{ query.md5 }}</div>
|
134
|
+
<% end %>
|
132
135
|
<div v-if="!rawExpanded">
|
133
136
|
<a href="#" v-on:click.prevent="rawExpanded = !rawExpanded">See full EXPLAIN</a>
|
134
137
|
</div>
|
@@ -178,24 +181,31 @@
|
|
178
181
|
return str;
|
179
182
|
},
|
180
183
|
formatted_cost: function() {
|
181
|
-
var
|
182
|
-
if ( this.
|
183
|
-
return `${
|
184
|
+
var readPercentage = (this.rows_read / this.table_size) * 100.0;
|
185
|
+
if ( this.rows_read > 100 && readPercentage > 1 ) // todo: make better
|
186
|
+
return `${readPercentage.toFixed()}% (${this.rows_read.toLocaleString()}) of the`;
|
184
187
|
else
|
185
|
-
return this.
|
188
|
+
return this.rows_read.toLocaleString();
|
186
189
|
},
|
187
190
|
costToColor: function() {
|
188
191
|
var goodColor = [34, 160, 60];
|
189
192
|
var endColor = [255, 0, 0];
|
190
|
-
var costScale = this.cost ? this.cost /
|
193
|
+
var costScale = this.cost ? this.cost / 0.5 : 0;
|
191
194
|
|
192
195
|
if ( costScale > 1 )
|
193
196
|
costScale = 1;
|
194
197
|
|
195
198
|
var pos = (costScale * (greenToRedGradient.length - 1)).toFixed();
|
196
199
|
|
197
|
-
debugger;
|
198
200
|
return "border-color: " + greenToRedGradient[pos];
|
201
|
+
},
|
202
|
+
formattedRunningCost: function() {
|
203
|
+
if ( this.running_cost === undefined )
|
204
|
+
return "-";
|
205
|
+
else if ( this.running_cost < 1.0 )
|
206
|
+
return (this.running_cost * 100).toFixed() + "ms";
|
207
|
+
else
|
208
|
+
return this.running_cost.toFixed(1) + "s";
|
199
209
|
}
|
200
210
|
}
|
201
211
|
</script>
|
@@ -209,15 +219,18 @@
|
|
209
219
|
<%= h['summary'] %>
|
210
220
|
</td>
|
211
221
|
<td class="running-totals">
|
212
|
-
{{
|
222
|
+
{{ formattedRunningCost }}
|
213
223
|
</td>
|
214
224
|
</tr>
|
215
225
|
</script>
|
216
226
|
<script>
|
217
227
|
Vue.component('tag-<%= tag %>', {
|
218
228
|
template: '#tag-<%= tag %>-template',
|
219
|
-
props: [ 'table_size', 'result_size', 'table', 'cost', 'index', 'join_to', 'index_used', 'running_cost', 'tables' ],
|
220
|
-
computed: templateComputedFunctions
|
229
|
+
props: [ 'table_size', 'result_size', 'table', 'cost', 'index', 'join_to', 'index_used', 'running_cost', 'tables', 'rows_read' ],
|
230
|
+
computed: templateComputedFunctions,
|
231
|
+
data: function () {
|
232
|
+
return { lastRunnningCost: undefined };
|
233
|
+
}
|
221
234
|
});
|
222
235
|
</script>
|
223
236
|
<% end %>
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shiba
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Osheroff
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2019-
|
12
|
+
date: 2019-03-06 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activesupport
|
@@ -177,8 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
177
177
|
- !ruby/object:Gem::Version
|
178
178
|
version: '0'
|
179
179
|
requirements: []
|
180
|
-
|
181
|
-
rubygems_version: 2.7.6
|
180
|
+
rubygems_version: 3.0.1
|
182
181
|
signing_key:
|
183
182
|
specification_version: 4
|
184
183
|
summary: A gem that attempts to find bad queries before you shoot self in foot
|