rails-profiler 0.31.3 → 0.31.4
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/CHANGELOG.md +56 -0
- data/app/controllers/profiler/api/console_controller.rb +1 -11
- data/app/controllers/profiler/api/jobs_controller.rb +1 -11
- data/app/controllers/profiler/api/profiles_controller.rb +18 -28
- data/app/controllers/profiler/api/tests_controller.rb +1 -11
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
- data/app/controllers/profiler/application_controller.rb +55 -15
- data/app/controllers/profiler/profiles_controller.rb +0 -16
- data/lib/profiler/ajax_data.rb +40 -0
- data/lib/profiler/cluster/slave_proxy.rb +7 -5
- data/lib/profiler/configuration.rb +15 -4
- data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
- data/lib/profiler/mcp/resources/recent_console.rb +2 -2
- data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
- data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
- data/lib/profiler/mcp/server.rb +8 -2
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/run_tests.rb +1 -1
- data/lib/profiler/middleware/profiler_middleware.rb +4 -1
- data/lib/profiler/railtie.rb +3 -0
- data/lib/profiler/storage/file_store.rb +332 -58
- data/lib/profiler/storage/memory_store.rb +60 -31
- data/lib/profiler/storage/private_files.rb +13 -0
- data/lib/profiler/storage/redis_store.rb +251 -32
- data/lib/profiler/storage/sqlite_store.rb +57 -9
- data/lib/profiler/storage/summary.rb +50 -0
- data/lib/profiler/storage/unavailable.rb +58 -0
- data/lib/profiler/test_helpers/reporter.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +12 -1
- metadata +4 -1
|
@@ -2,33 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
5
7
|
require_relative "base_store"
|
|
6
8
|
require_relative "private_files"
|
|
9
|
+
require_relative "summary"
|
|
7
10
|
require_relative "token"
|
|
8
11
|
require_relative "../models/profile"
|
|
9
12
|
|
|
10
13
|
module Profiler
|
|
11
14
|
module Storage
|
|
12
15
|
# File-based profile storage backend. Persists each profile as a JSON file named after its
|
|
13
|
-
# token, under tmp_path/profiles by default or the directory given as options[:path]
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# token, under tmp_path/profiles by default or the directory given as options[:path]. Only the
|
|
17
|
+
# files named after a token are taken for profiles, so a path shared with other files (tmp_path
|
|
18
|
+
# itself, where the env overrides live) is safe.
|
|
19
|
+
#
|
|
20
|
+
# An index next to the profiles (INDEX_FILE) holds one line per save or delete: the token, the
|
|
21
|
+
# size of the file, the type, the parent and the summary the lists show. Every process keeps it
|
|
22
|
+
# in memory and reads only the lines appended since its last read, its own and those of the
|
|
23
|
+
# other processes writing to the directory (Puma workers, Sidekiq), so a save no longer lists
|
|
24
|
+
# or stats the directory. A save writes its file and its line under a shared flock; the
|
|
25
|
+
# compaction, which evicts the first saved profiles past max_profiles or max_size,
|
|
26
|
+
# resynchronizes the index with the directory (profiles written without it, files removed by
|
|
27
|
+
# hand, temporary files left by a killed writer) and rewrites it, holds the exclusive one: a
|
|
28
|
+
# save past a cap waits for it, a compaction of dead lines only gives up when another process
|
|
29
|
+
# holds it. A missing or damaged index is rebuilt from the files. flock has to work across
|
|
30
|
+
# the processes sharing the directory: one host, not NFS between hosts (see the README).
|
|
17
31
|
class FileStore < BaseStore
|
|
32
|
+
INDEX_FILE = ".profiles-index.jsonl"
|
|
33
|
+
LOCK_FILE = ".profiles-index.lock"
|
|
34
|
+
INDEX_VERSION = 1
|
|
35
|
+
PROFILE_FILE = /\A(\h{32})\.json\z/
|
|
36
|
+
# The temporary files PrivateFiles.write makes for this store: a profile or the index, then
|
|
37
|
+
# the pid and a random part. Any other file of a shared directory is left alone.
|
|
38
|
+
TEMPORARY_FILE = /\A\.(?:\h{32}\.json|#{Regexp.escape(INDEX_FILE)})\.\d+\.\h{8}\.tmp\z/
|
|
39
|
+
# A temporary file older than this is one a killed writer left behind.
|
|
40
|
+
STALE_TEMPORARY_AGE = 60
|
|
41
|
+
# Past a cap, the oldest profiles are evicted down to this share of it.
|
|
42
|
+
LOW_WATER = 0.8
|
|
43
|
+
|
|
44
|
+
Entry = Struct.new(:token, :at, :type, :parent, :bytes, :summary, :sequence)
|
|
45
|
+
|
|
18
46
|
def initialize(options = {})
|
|
19
47
|
super()
|
|
20
48
|
@max_size = options[:max_size] || (100 * 1024 * 1024) # 100 MB
|
|
49
|
+
@max_profiles = options.key?(:max_profiles) ? options[:max_profiles] : Profiler.configuration.max_profiles
|
|
21
50
|
if options[:path]
|
|
22
51
|
@path = options[:path].to_s
|
|
23
52
|
PrivateFiles.mkdir(@path)
|
|
24
53
|
else
|
|
25
54
|
@path = PrivateFiles.tmp_dir("profiles").to_s
|
|
26
55
|
end
|
|
56
|
+
@index_path = File.join(@path, INDEX_FILE)
|
|
57
|
+
@lock_path = File.join(@path, LOCK_FILE)
|
|
58
|
+
# Left with a wider mode by an earlier run: back to 0600 (a link is refused).
|
|
59
|
+
PrivateFiles.restrict(@lock_path)
|
|
60
|
+
PrivateFiles.restrict(@index_path)
|
|
61
|
+
@mutex = Mutex.new
|
|
62
|
+
forget_index
|
|
27
63
|
end
|
|
28
64
|
|
|
29
65
|
def do_save(token, profile)
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
data = profile.to_h
|
|
67
|
+
json = data.to_json
|
|
68
|
+
line = JSON.generate(put_record(token, data, json.bytesize, started_at: profile.started_at))
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
refresh if @generation.nil? # the index exists, with its header, before the first line
|
|
71
|
+
# The file and its line under one shared lock: a compaction sees both or neither, and
|
|
72
|
+
# cannot evict the file before its line is written.
|
|
73
|
+
with_lock(File::LOCK_SH) do
|
|
74
|
+
PrivateFiles.write(profile_file_path(token), json)
|
|
75
|
+
begin
|
|
76
|
+
PrivateFiles.append(@index_path, "#{line}\n")
|
|
77
|
+
rescue StandardError
|
|
78
|
+
# A file the index never learns of would be listed by no one and counted nowhere.
|
|
79
|
+
FileUtils.rm_f(profile_file_path(token))
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
refresh
|
|
84
|
+
if over_cap?
|
|
85
|
+
compact(keep: token)
|
|
86
|
+
elsif compaction_due?
|
|
87
|
+
# Dead lines only: another process compacting meanwhile does it for this one.
|
|
88
|
+
compact(keep: token, wait: false)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
32
91
|
token
|
|
33
92
|
end
|
|
34
93
|
|
|
@@ -45,55 +104,56 @@ module Profiler
|
|
|
45
104
|
nil
|
|
46
105
|
end
|
|
47
106
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
|
|
107
|
+
# Newest first. summary: true reads no profile file, only the index.
|
|
108
|
+
def list(limit: 50, offset: 0, type: nil, summary: false)
|
|
109
|
+
page = @mutex.synchronize do
|
|
110
|
+
refresh
|
|
111
|
+
entries = sorted_entries
|
|
112
|
+
entries = entries.select { |entry| entry.type == type.to_s } if type
|
|
113
|
+
entries.drop(offset).first(limit)
|
|
114
|
+
end
|
|
115
|
+
if summary
|
|
116
|
+
page.filter_map { |entry| Summary.to_profile(entry.summary) if entry.summary }
|
|
117
|
+
else
|
|
118
|
+
page.filter_map { |entry| load(entry.token) }
|
|
119
|
+
end
|
|
56
120
|
end
|
|
57
121
|
|
|
122
|
+
# By the modification time of the files, as before the index: an explicit task, which may
|
|
123
|
+
# stat every profile.
|
|
58
124
|
def cleanup(older_than: 24 * 60 * 60)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
rescue Errno::ENOENT
|
|
63
|
-
nil
|
|
125
|
+
cutoff = Time.now - older_than
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
compact { |entries| entries.values.select { |entry| modified_before?(entry.token, cutoff) } }
|
|
64
128
|
end
|
|
65
129
|
end
|
|
66
130
|
|
|
67
131
|
def find_by_parent(parent_token)
|
|
68
132
|
return [] unless Token.valid?(parent_token)
|
|
69
133
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
children = @mutex.synchronize do
|
|
135
|
+
refresh
|
|
136
|
+
(@children[parent_token] || {}).keys.filter_map { |token| @entries[token] }
|
|
137
|
+
end
|
|
138
|
+
children.filter_map { |entry| load(entry.token) }.sort_by(&:started_at)
|
|
75
139
|
end
|
|
76
140
|
|
|
77
141
|
def delete(token)
|
|
78
142
|
return unless Token.valid?(token)
|
|
79
143
|
|
|
80
|
-
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
refresh if @generation.nil?
|
|
146
|
+
with_lock(File::LOCK_SH) do
|
|
147
|
+
FileUtils.rm_f(profile_file_path(token))
|
|
148
|
+
PrivateFiles.append(@index_path, "#{JSON.generate("op" => "del", "token" => token)}\n")
|
|
149
|
+
end
|
|
150
|
+
refresh
|
|
151
|
+
end
|
|
81
152
|
end
|
|
82
153
|
|
|
83
|
-
def clear(type: nil)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
File.delete(f)
|
|
87
|
-
rescue StandardError
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
else
|
|
91
|
-
profile_files.each do |f|
|
|
92
|
-
profile = load(File.basename(f, ".json"))
|
|
93
|
-
File.delete(f) if profile&.profile_type == type.to_s
|
|
94
|
-
rescue StandardError
|
|
95
|
-
nil
|
|
96
|
-
end
|
|
154
|
+
def clear(type: nil)
|
|
155
|
+
@mutex.synchronize do
|
|
156
|
+
compact { |entries| entries.values.select { |entry| type.nil? || entry.type == type.to_s } }
|
|
97
157
|
end
|
|
98
158
|
end
|
|
99
159
|
|
|
@@ -103,31 +163,245 @@ module Profiler
|
|
|
103
163
|
File.join(@path, "#{token}.json")
|
|
104
164
|
end
|
|
105
165
|
|
|
106
|
-
def
|
|
107
|
-
|
|
166
|
+
def modified_before?(token, cutoff)
|
|
167
|
+
File.mtime(profile_file_path(token)) < cutoff
|
|
168
|
+
rescue Errno::ENOENT
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# started_at: the profile's own, more precise than the second of its JSON form.
|
|
173
|
+
def put_record(token, data, bytes, started_at: nil)
|
|
174
|
+
started_at = started_at&.to_f || (data[:started_at] ? Time.parse(data[:started_at]).to_f : Time.now.to_f)
|
|
175
|
+
{ "op" => "put", "token" => token, "at" => started_at, "type" => (data[:profile_type] || "http").to_s,
|
|
176
|
+
"parent" => data[:parent_token], "bytes" => bytes, "summary" => Summary.build(data) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def forget_index
|
|
180
|
+
@entries = {}
|
|
181
|
+
@sequence = 0
|
|
182
|
+
@children = {}
|
|
183
|
+
@bytes = 0
|
|
184
|
+
@records = 0
|
|
185
|
+
@offset = 0
|
|
186
|
+
@generation = nil
|
|
187
|
+
@damaged = false
|
|
188
|
+
@sorted = nil
|
|
108
189
|
end
|
|
109
190
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
191
|
+
# Brings the in-memory index up to date with the file: the appended lines only, or the whole
|
|
192
|
+
# file when another process rewrote it. A missing or damaged index is rebuilt.
|
|
193
|
+
def refresh
|
|
194
|
+
PrivateFiles.open_for_reading(@index_path) do |file|
|
|
195
|
+
header = parse_line(file.gets)
|
|
196
|
+
if header.nil? || header["profiler_index"] != INDEX_VERSION || !header["generation"].is_a?(String)
|
|
197
|
+
@damaged = true
|
|
198
|
+
elsif header["generation"] != @generation || file.size < @offset
|
|
199
|
+
forget_index
|
|
200
|
+
@generation = header["generation"]
|
|
201
|
+
@offset = file.pos
|
|
202
|
+
read_records(file)
|
|
203
|
+
elsif file.size > @offset
|
|
204
|
+
file.seek(@offset)
|
|
205
|
+
read_records(file)
|
|
206
|
+
end
|
|
115
207
|
end
|
|
116
|
-
|
|
208
|
+
compact if @damaged
|
|
209
|
+
rescue Errno::ENOENT
|
|
210
|
+
compact
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Consumes the complete lines from the file's position; a line still being written by
|
|
214
|
+
# another process is left for the next read.
|
|
215
|
+
def read_records(file)
|
|
216
|
+
data = file.read
|
|
217
|
+
complete = data.rindex("\n")
|
|
218
|
+
return unless complete
|
|
117
219
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
Time.now
|
|
220
|
+
data[0..complete].each_line do |line|
|
|
221
|
+
record = parse_line(line)
|
|
222
|
+
record ? apply(record) : @damaged = true
|
|
122
223
|
end
|
|
224
|
+
@offset += data[0..complete].bytesize
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def parse_line(line)
|
|
228
|
+
return nil if line.nil?
|
|
229
|
+
|
|
230
|
+
record = JSON.parse(line)
|
|
231
|
+
record.is_a?(Hash) ? record : nil
|
|
232
|
+
rescue JSON::ParserError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A record whose token is not one the gem issues is skipped: it is never turned into a path.
|
|
237
|
+
def apply(record)
|
|
238
|
+
token = record["token"]
|
|
239
|
+
return unless Token.valid?(token)
|
|
240
|
+
|
|
241
|
+
@records += 1
|
|
242
|
+
case record["op"]
|
|
243
|
+
when "put"
|
|
244
|
+
remove_entry(token)
|
|
245
|
+
parent = Token.valid?(record["parent"]) ? record["parent"] : nil
|
|
246
|
+
add_entry(Entry.new(token, record["at"].to_f, record["type"], parent, record["bytes"].to_i,
|
|
247
|
+
record["summary"].is_a?(Hash) ? record["summary"] : nil))
|
|
248
|
+
when "del"
|
|
249
|
+
remove_entry(token)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def add_entry(entry)
|
|
254
|
+
entry.sequence = (@sequence += 1)
|
|
255
|
+
@entries[entry.token] = entry
|
|
256
|
+
@bytes += entry.bytes
|
|
257
|
+
(@children[entry.parent] ||= {})[entry.token] = true if entry.parent
|
|
258
|
+
@sorted = nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def remove_entry(token)
|
|
262
|
+
entry = @entries.delete(token)
|
|
263
|
+
return unless entry
|
|
264
|
+
|
|
265
|
+
@bytes -= entry.bytes
|
|
266
|
+
if entry.parent && (siblings = @children[entry.parent])
|
|
267
|
+
siblings.delete(token)
|
|
268
|
+
@children.delete(entry.parent) if siblings.empty?
|
|
269
|
+
end
|
|
270
|
+
@sorted = nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def sorted_entries
|
|
274
|
+
# Two profiles started in the same instant: the one saved last first.
|
|
275
|
+
@sorted ||= @entries.values.sort_by { |entry| [-entry.at, -entry.sequence] }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def over_cap?
|
|
279
|
+
(@max_profiles && @entries.size > @max_profiles) || @bytes >= @max_size
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The file holds more dead lines (replaced or deleted profiles) than live ones.
|
|
283
|
+
def compaction_due?
|
|
284
|
+
over_cap? || @records > (2 * @entries.size) + 64
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Under the exclusive lock: reads the whole index again, resynchronizes it with the
|
|
288
|
+
# directory, takes out the profiles the block returns (if any) and the first saved past the
|
|
289
|
+
# caps, never keep (the profile just saved), rewrites the index, then removes their files:
|
|
290
|
+
# a reader never lists a profile whose file is gone. Another process that compacted
|
|
291
|
+
# meanwhile leaves nothing to do but the rewrite. wait: false gives up when another process
|
|
292
|
+
# holds the lock.
|
|
293
|
+
def compact(keep: nil, wait: true)
|
|
294
|
+
with_lock(wait ? File::LOCK_EX : File::LOCK_EX | File::LOCK_NB) do
|
|
295
|
+
reload_for_compaction
|
|
296
|
+
resynchronize
|
|
297
|
+
doomed = (block_given? ? yield(@entries) : []).map(&:token)
|
|
298
|
+
doomed.each { |token| remove_entry(token) }
|
|
299
|
+
doomed.concat(evict_first_saved(keep)) if over_cap?
|
|
300
|
+
rewrite_index
|
|
301
|
+
doomed.each { |token| FileUtils.rm_f(profile_file_path(token)) }
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @last_compacted_at is kept only from a sound index: with a damaged one, a profile whose
|
|
306
|
+
# line was lost would pass for a file the last compaction evicted (see #resynchronize).
|
|
307
|
+
def reload_for_compaction
|
|
308
|
+
forget_index
|
|
309
|
+
@last_compacted_at = nil
|
|
310
|
+
PrivateFiles.open_for_reading(@index_path) do |file|
|
|
311
|
+
header = parse_line(file.gets)
|
|
312
|
+
next unless header && header["profiler_index"] == INDEX_VERSION && header["generation"].is_a?(String)
|
|
313
|
+
|
|
314
|
+
sound = true
|
|
315
|
+
file.each_line do |line|
|
|
316
|
+
record = parse_line(line)
|
|
317
|
+
record && line.end_with?("\n") ? apply(record) : sound = false
|
|
318
|
+
end
|
|
319
|
+
@last_compacted_at = header["compacted_at"] if sound && header["compacted_at"].is_a?(Numeric)
|
|
320
|
+
end
|
|
321
|
+
rescue Errno::ENOENT
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The directory is the truth: entries whose file is gone are dropped, files the index does
|
|
326
|
+
# not know (written by an older version, or with the index lost) are read once and added,
|
|
327
|
+
# temporary files left by a killed writer are removed. A file the index does not know that
|
|
328
|
+
# is older than the last compaction was evicted by it (a save writes its file and its line
|
|
329
|
+
# under the lock, so a file saved before that compaction is in the index): the process that
|
|
330
|
+
# compacted was killed before it removed the file, which is removed now, not listed again.
|
|
331
|
+
def resynchronize
|
|
332
|
+
names = Dir.children(@path)
|
|
333
|
+
on_disk = names.filter_map { |name| name[PROFILE_FILE, 1] }
|
|
334
|
+
(@entries.keys - on_disk).each { |token| remove_entry(token) }
|
|
335
|
+
unknown = (on_disk - @entries.keys).map { |token| [token, modified_at(token)] }
|
|
336
|
+
evicted, unknown = unknown.partition { |_, mtime| @last_compacted_at && mtime < @last_compacted_at }
|
|
337
|
+
evicted.each { |token, _| FileUtils.rm_f(profile_file_path(token)) }
|
|
338
|
+
# Unknown files in the order they were saved, as far as their modification time tells.
|
|
339
|
+
unknown.sort_by(&:last).each { |token, _| add_entry(entry_from_file(token)) }
|
|
340
|
+
names.grep(TEMPORARY_FILE).each { |name| remove_stale_temporary(File.join(@path, name)) }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A symbolic link in place of a profile is not followed while the modes are restricted.
|
|
344
|
+
def entry_from_file(token)
|
|
345
|
+
json = PrivateFiles.open_for_reading(profile_file_path(token), &:read)
|
|
346
|
+
record = put_record(token, Models::Profile.from_json(json).to_h, json.bytesize)
|
|
347
|
+
Entry.new(token, record["at"], record["type"], record["parent"], record["bytes"], record["summary"])
|
|
348
|
+
rescue StandardError
|
|
349
|
+
# Unreadable: kept in the count, so that it is evicted in turn; listed by no one.
|
|
350
|
+
stat = File.lstat(profile_file_path(token)) rescue nil
|
|
351
|
+
Entry.new(token, stat ? stat.mtime.to_f : 0.0, nil, nil, stat ? stat.size : 0, nil)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def modified_at(token)
|
|
355
|
+
File.lstat(profile_file_path(token)).mtime.to_f
|
|
356
|
+
rescue SystemCallError
|
|
357
|
+
0.0
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def remove_stale_temporary(path)
|
|
361
|
+
File.delete(path) if File.lstat(path).mtime < Time.now - STALE_TEMPORARY_AGE
|
|
362
|
+
rescue SystemCallError
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# In the order they were saved, not started: a job saved when it ends is a new profile. The
|
|
367
|
+
# entries keep the order of the index lines, the order of the saves.
|
|
368
|
+
def evict_first_saved(keep)
|
|
369
|
+
count_target = @max_profiles && [(@max_profiles * LOW_WATER).floor, 1].max
|
|
370
|
+
bytes_target = @bytes >= @max_size ? @max_size * LOW_WATER : nil
|
|
371
|
+
evicted = []
|
|
372
|
+
@entries.values.each do |entry|
|
|
373
|
+
break unless (count_target && @entries.size > count_target) || (bytes_target && @bytes >= bytes_target)
|
|
374
|
+
next if entry.token == keep
|
|
375
|
+
|
|
376
|
+
remove_entry(entry.token)
|
|
377
|
+
evicted << entry.token
|
|
378
|
+
end
|
|
379
|
+
evicted
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def rewrite_index
|
|
383
|
+
generation = SecureRandom.hex(8)
|
|
384
|
+
lines = [JSON.generate("profiler_index" => INDEX_VERSION, "generation" => generation,
|
|
385
|
+
"compacted_at" => Time.now.to_f)]
|
|
386
|
+
@entries.each_value do |entry|
|
|
387
|
+
lines << JSON.generate("op" => "put", "token" => entry.token, "at" => entry.at, "type" => entry.type,
|
|
388
|
+
"parent" => entry.parent, "bytes" => entry.bytes, "summary" => entry.summary)
|
|
389
|
+
end
|
|
390
|
+
data = "#{lines.join("\n")}\n"
|
|
391
|
+
PrivateFiles.write(@index_path, data)
|
|
392
|
+
@generation = generation
|
|
393
|
+
@offset = data.bytesize
|
|
394
|
+
@records = @entries.size
|
|
395
|
+
@damaged = false
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# flock on a file of its own, opened for each use: a lock taken through a descriptor shared
|
|
399
|
+
# with a forked process would not exclude it. The mutex excludes the threads of this one.
|
|
400
|
+
def with_lock(mode)
|
|
401
|
+
PrivateFiles.open(@lock_path) do |file|
|
|
402
|
+
next unless file.flock(mode)
|
|
123
403
|
|
|
124
|
-
|
|
125
|
-
file_size = File.size(file)
|
|
126
|
-
File.delete(file)
|
|
127
|
-
total_size -= file_size
|
|
128
|
-
break if total_size < @max_size * 0.8
|
|
129
|
-
rescue Errno::ENOENT
|
|
130
|
-
nil
|
|
404
|
+
yield
|
|
131
405
|
end
|
|
132
406
|
end
|
|
133
407
|
end
|
|
@@ -2,19 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
require "concurrent-ruby"
|
|
4
4
|
require_relative "base_store"
|
|
5
|
+
require_relative "summary"
|
|
5
6
|
require_relative "../models/profile"
|
|
6
7
|
|
|
7
8
|
module Profiler
|
|
8
9
|
module Storage
|
|
10
|
+
# Keeps the profiles in the process, as the Hashes of Profile#to_h, at most max_profiles of
|
|
11
|
+
# them (100 when neither the option nor config.max_profiles sets it: this store always has a
|
|
12
|
+
# cap). Beside them, the start time, type and parent of each, and the children of each parent,
|
|
13
|
+
# so that listing, finding the children and evicting deserialize only what they return.
|
|
9
14
|
class MemoryStore < BaseStore
|
|
15
|
+
Entry = Struct.new(:at, :type, :parent, :sequence)
|
|
16
|
+
|
|
10
17
|
def initialize(options = {})
|
|
11
18
|
@profiles = Concurrent::Hash.new
|
|
12
19
|
@max_profiles = options[:max_profiles] || Profiler.configuration.max_profiles || 100
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@entries = {}
|
|
22
|
+
@children = {}
|
|
23
|
+
@sequence = 0
|
|
24
|
+
@sorted = nil
|
|
13
25
|
end
|
|
14
26
|
|
|
15
27
|
def do_save(token, profile)
|
|
16
|
-
|
|
17
|
-
@
|
|
28
|
+
data = serialize_profile(profile)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
cleanup_if_needed unless @profiles.key?(token)
|
|
31
|
+
remove_entry(token)
|
|
32
|
+
@profiles[token] = data
|
|
33
|
+
@entries[token] = Entry.new(profile.started_at.to_f, data[:profile_type].to_s, profile.parent_token, @sequence += 1)
|
|
34
|
+
(@children[profile.parent_token] ||= {})[token] = true if profile.parent_token
|
|
35
|
+
@sorted = nil
|
|
36
|
+
end
|
|
18
37
|
token
|
|
19
38
|
end
|
|
20
39
|
|
|
@@ -27,65 +46,75 @@ module Profiler
|
|
|
27
46
|
deserialize_profile(data)
|
|
28
47
|
end
|
|
29
48
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
# Newest first; summary: true leaves the bodies, headers and nested collector data out.
|
|
50
|
+
def list(limit: 50, offset: 0, type: nil, summary: false)
|
|
51
|
+
page = @mutex.synchronize do
|
|
52
|
+
tokens = sorted_tokens
|
|
53
|
+
tokens = tokens.select { |token| @entries[token].type == type.to_s } if type
|
|
54
|
+
tokens.drop(offset).first(limit).filter_map { |token| @profiles[token] }
|
|
55
|
+
end
|
|
56
|
+
page.map { |data| summary ? Summary.to_profile(Summary.build(data)) : deserialize_profile(data) }
|
|
37
57
|
end
|
|
38
58
|
|
|
39
59
|
def cleanup(older_than: 24 * 60 * 60)
|
|
40
|
-
|
|
41
|
-
@
|
|
42
|
-
|
|
43
|
-
profile.started_at < cutoff_time
|
|
60
|
+
cutoff = (Time.now - older_than).to_f
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@entries.select { |_, entry| entry.at < cutoff }.each_key { |token| remove(token) }
|
|
44
63
|
end
|
|
45
64
|
end
|
|
46
65
|
|
|
47
66
|
def find_by_parent(parent_token)
|
|
48
67
|
return [] unless Token.valid?(parent_token)
|
|
49
68
|
|
|
50
|
-
@profiles
|
|
51
|
-
|
|
52
|
-
.select { |profile| profile.parent_token == parent_token }
|
|
53
|
-
.sort_by { |profile| profile.started_at }
|
|
69
|
+
children = @mutex.synchronize { (@children[parent_token] || {}).keys.filter_map { |token| @profiles[token] } }
|
|
70
|
+
children.map { |data| deserialize_profile(data) }.sort_by(&:started_at)
|
|
54
71
|
end
|
|
55
72
|
|
|
56
73
|
def delete(token)
|
|
57
74
|
return unless Token.valid?(token)
|
|
58
75
|
|
|
59
|
-
@
|
|
76
|
+
@mutex.synchronize { remove(token) }
|
|
60
77
|
end
|
|
61
78
|
|
|
62
79
|
def clear(type: nil)
|
|
63
|
-
|
|
64
|
-
@
|
|
65
|
-
else
|
|
66
|
-
@profiles.delete_if do |_token, data|
|
|
67
|
-
deserialize_profile(data).profile_type == type.to_s
|
|
68
|
-
end
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@entries.select { |_, entry| type.nil? || entry.type == type.to_s }.each_key { |token| remove(token) }
|
|
69
82
|
end
|
|
70
83
|
end
|
|
71
84
|
|
|
72
85
|
private
|
|
73
86
|
|
|
87
|
+
# Called before a new profile is added: removes the first saved down to 80% of the cap, in the
|
|
88
|
+
# order of the saves (@entries keeps it), not of the starts: a job saved when it ends is new.
|
|
74
89
|
def cleanup_if_needed
|
|
75
90
|
return if @profiles.size < @max_profiles
|
|
76
91
|
|
|
77
|
-
# Remove oldest profiles until we're under the limit
|
|
78
92
|
profiles_to_remove = @profiles.size - (@max_profiles * 0.8).to_i
|
|
79
93
|
return if profiles_to_remove <= 0
|
|
80
94
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
[token, profile.started_at]
|
|
84
|
-
end.sort_by { |_, time| time }.map(&:first)
|
|
95
|
+
@entries.keys.first(profiles_to_remove).each { |token| remove(token) }
|
|
96
|
+
end
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
def remove(token)
|
|
99
|
+
removed = @profiles.delete(token)
|
|
100
|
+
remove_entry(token)
|
|
101
|
+
removed
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def remove_entry(token)
|
|
105
|
+
entry = @entries.delete(token)
|
|
106
|
+
return unless entry
|
|
107
|
+
|
|
108
|
+
if entry.parent && (siblings = @children[entry.parent])
|
|
109
|
+
siblings.delete(token)
|
|
110
|
+
@children.delete(entry.parent) if siblings.empty?
|
|
88
111
|
end
|
|
112
|
+
@sorted = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Newest first; two profiles started in the same instant, the one saved last first.
|
|
116
|
+
def sorted_tokens
|
|
117
|
+
@sorted ||= @entries.sort_by { |_, entry| [-entry.at, -entry.sequence] }.map(&:first)
|
|
89
118
|
end
|
|
90
119
|
|
|
91
120
|
def serialize_profile(profile)
|
|
@@ -70,6 +70,19 @@ module Profiler
|
|
|
70
70
|
FileUtils.rm_f(tmp) if tmp
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Appends data to path in one write, creating it 0600 when missing: lines appended by
|
|
74
|
+
# several processes this way do not interleave.
|
|
75
|
+
def append(path, data)
|
|
76
|
+
File.open(path.to_s, File::WRONLY | File::APPEND | File::CREAT | nofollow, new_file_mode) do |file|
|
|
77
|
+
file.write(data)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Opens path for reading, refusing a symbolic link while the modes are restricted.
|
|
82
|
+
def open_for_reading(path, &block)
|
|
83
|
+
File.open(path.to_s, File::RDONLY | nofollow, &block)
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
# Opens path for reading and writing, creating it 0600 when missing.
|
|
74
87
|
def open(path, &block)
|
|
75
88
|
File.open(path.to_s, File::RDWR | File::CREAT | nofollow, new_file_mode, &block)
|