shiba 0.4.0 → 0.5.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 +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
|