rails-profiler 0.31.3 → 0.31.5

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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/api/console_controller.rb +1 -11
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  6. data/app/controllers/profiler/api/profiles_controller.rb +18 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -18
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/master_client.rb +7 -15
  13. data/lib/profiler/cluster/security.rb +3 -3
  14. data/lib/profiler/cluster/slave_proxy.rb +14 -9
  15. data/lib/profiler/collectors/lifecycle.rb +2 -2
  16. data/lib/profiler/collectors/log_collector.rb +54 -5
  17. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  18. data/lib/profiler/collectors/request_collector.rb +6 -1
  19. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  20. data/lib/profiler/configuration.rb +30 -8
  21. data/lib/profiler/console_profiler.rb +1 -1
  22. data/lib/profiler/engine.rb +2 -1
  23. data/lib/profiler/env_override_store.rb +4 -4
  24. data/lib/profiler/explain_runner.rb +5 -1
  25. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  26. data/lib/profiler/job_profiler.rb +1 -1
  27. data/lib/profiler/local_request.rb +2 -6
  28. data/lib/profiler/mcp/file_cache.rb +2 -1
  29. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  30. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  31. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  32. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  33. data/lib/profiler/mcp/server.rb +9 -3
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -2
  38. data/lib/profiler/mcp/tools/query_jobs.rb +6 -2
  39. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  40. data/lib/profiler/mcp/tools/query_profiles.rb +6 -2
  41. data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -2
  42. data/lib/profiler/mcp/tools/run_tests.rb +5 -2
  43. data/lib/profiler/middleware/profiler_middleware.rb +22 -16
  44. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  45. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  46. data/lib/profiler/models/profile.rb +60 -2
  47. data/lib/profiler/process_snapshot.rb +8 -3
  48. data/lib/profiler/railtie.rb +4 -1
  49. data/lib/profiler/redaction.rb +1 -3
  50. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  51. data/lib/profiler/storage/base_store.rb +4 -2
  52. data/lib/profiler/storage/blob_store.rb +1 -1
  53. data/lib/profiler/storage/file_store.rb +337 -58
  54. data/lib/profiler/storage/memory_store.rb +60 -31
  55. data/lib/profiler/storage/private_files.rb +16 -3
  56. data/lib/profiler/storage/redis_store.rb +252 -33
  57. data/lib/profiler/storage/sqlite_store.rb +59 -11
  58. data/lib/profiler/storage/summary.rb +50 -0
  59. data/lib/profiler/storage/unavailable.rb +57 -0
  60. data/lib/profiler/test_helpers/reporter.rb +2 -2
  61. data/lib/profiler/test_profiler.rb +1 -1
  62. data/lib/profiler/test_runner/runner.rb +3 -7
  63. data/lib/profiler/version.rb +1 -1
  64. data/lib/profiler.rb +138 -4
  65. 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], and
14
- # evicts the oldest files when total size exceeds max_size. Only the files named after a
15
- # token are taken for profiles, so a path shared with other files (tmp_path itself, where the
16
- # env overrides live) is safe.
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
- PrivateFiles.write(profile_file_path(token), profile.to_json)
31
- cleanup_if_needed
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
 
@@ -41,59 +100,60 @@ module Profiler
41
100
  json_data = File.read(file_path)
42
101
  Models::Profile.from_json(json_data)
43
102
  rescue StandardError => e
44
- warn "Failed to load profile #{token}: #{e.message}"
103
+ Profiler.log_error("FileStore: could not load profile #{token}", e)
45
104
  nil
46
105
  end
47
106
 
48
- def list(limit: 50, offset: 0)
49
- profile_files
50
- .sort_by { |f| File.mtime(f) }
51
- .reverse
52
- .drop(offset)
53
- .take(limit)
54
- .map { |f| load(File.basename(f, ".json")) }
55
- .compact
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
- cutoff_time = Time.now - older_than
60
- profile_files.each do |file|
61
- File.delete(file) if File.mtime(file) < cutoff_time
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
- profile_files
71
- .map { |f| load(File.basename(f, ".json")) }
72
- .compact
73
- .select { |profile| profile.parent_token == parent_token }
74
- .sort_by(&:started_at)
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
- FileUtils.rm_f(profile_file_path(token))
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) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
84
- if type.nil?
85
- profile_files.each do |f|
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,32 +163,251 @@ module Profiler
103
163
  File.join(@path, "#{token}.json")
104
164
  end
105
165
 
106
- def profile_files
107
- Dir.glob(File.join(@path, "*.json")).select { |f| Token.valid?(File.basename(f, ".json")) }
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
189
+ end
190
+
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
207
+ end
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
219
+
220
+ data[0..complete].each_line do |line|
221
+ record = parse_line(line)
222
+ record ? apply(record) : @damaged = true
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
108
285
  end
109
286
 
110
- def cleanup_if_needed # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
111
- total_size = profile_files.sum do |f|
112
- File.size(f)
113
- rescue Errno::ENOENT
114
- 0
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)) }
115
302
  end
116
- return if total_size < @max_size
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)
117
313
 
118
- sorted = profile_files.sort_by do |f|
119
- File.mtime(f)
120
- rescue Errno::ENOENT
121
- Time.now
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)
122
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
123
342
 
124
- sorted.each do |file|
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
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 => e
349
+ # Unreadable: kept in the count, so that it is evicted in turn; listed by no one.
350
+ Profiler.log_error_once(:file_store_entry, "FileStore: could not read profile #{token} to index it", e)
351
+ stat = begin
352
+ File.lstat(profile_file_path(token))
353
+ rescue SystemCallError
130
354
  nil
131
355
  end
356
+ Entry.new(token, stat ? stat.mtime.to_f : 0.0, nil, nil, stat ? stat.size : 0, nil)
357
+ end
358
+
359
+ def modified_at(token)
360
+ File.lstat(profile_file_path(token)).mtime.to_f
361
+ rescue SystemCallError
362
+ 0.0
363
+ end
364
+
365
+ def remove_stale_temporary(path)
366
+ File.delete(path) if File.lstat(path).mtime < Time.now - STALE_TEMPORARY_AGE
367
+ rescue SystemCallError
368
+ nil
369
+ end
370
+
371
+ # In the order they were saved, not started: a job saved when it ends is a new profile. The
372
+ # entries keep the order of the index lines, the order of the saves.
373
+ def evict_first_saved(keep)
374
+ count_target = @max_profiles && [(@max_profiles * LOW_WATER).floor, 1].max
375
+ bytes_target = @bytes >= @max_size ? @max_size * LOW_WATER : nil
376
+ evicted = []
377
+ @entries.values.each do |entry|
378
+ break unless (count_target && @entries.size > count_target) || (bytes_target && @bytes >= bytes_target)
379
+ next if entry.token == keep
380
+
381
+ remove_entry(entry.token)
382
+ evicted << entry.token
383
+ end
384
+ evicted
385
+ end
386
+
387
+ def rewrite_index
388
+ generation = SecureRandom.hex(8)
389
+ lines = [JSON.generate("profiler_index" => INDEX_VERSION, "generation" => generation,
390
+ "compacted_at" => Time.now.to_f)]
391
+ @entries.each_value do |entry|
392
+ lines << JSON.generate("op" => "put", "token" => entry.token, "at" => entry.at, "type" => entry.type,
393
+ "parent" => entry.parent, "bytes" => entry.bytes, "summary" => entry.summary)
394
+ end
395
+ data = "#{lines.join("\n")}\n"
396
+ PrivateFiles.write(@index_path, data)
397
+ @generation = generation
398
+ @offset = data.bytesize
399
+ @records = @entries.size
400
+ @damaged = false
401
+ end
402
+
403
+ # flock on a file of its own, opened for each use: a lock taken through a descriptor shared
404
+ # with a forked process would not exclude it. The mutex excludes the threads of this one.
405
+ def with_lock(mode)
406
+ PrivateFiles.open(@lock_path) do |file|
407
+ next unless file.flock(mode)
408
+
409
+ yield
410
+ end
132
411
  end
133
412
  end
134
413
  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
- cleanup_if_needed
17
- @profiles[token] = serialize_profile(profile)
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
- def list(limit: 50, offset: 0)
31
- @profiles.values
32
- .map { |data| deserialize_profile(data) }
33
- .sort_by { |p| p.started_at }
34
- .reverse
35
- .drop(offset)
36
- .take(limit)
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
- cutoff_time = Time.now - older_than
41
- @profiles.delete_if do |_token, data|
42
- profile = deserialize_profile(data)
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.values
51
- .map { |data| deserialize_profile(data) }
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
- @profiles.delete(token)
76
+ @mutex.synchronize { remove(token) }
60
77
  end
61
78
 
62
79
  def clear(type: nil)
63
- if type.nil?
64
- @profiles.clear
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
- sorted_tokens = @profiles.map do |token, data|
82
- profile = deserialize_profile(data)
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
- sorted_tokens.take(profiles_to_remove).each do |token|
87
- @profiles.delete(token)
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)
@@ -112,9 +125,9 @@ module Profiler
112
125
  first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
113
126
  return unless first
114
127
 
115
- warn "[Profiler] tmp_path #{key} belongs to another user or is writable by group or others " \
116
- "(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
117
- "config.tmp_path to a directory of your own, or run chmod 700 on it."
128
+ Profiler.log_warn("tmp_path #{key} belongs to another user or is writable by group or others " \
129
+ "(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
130
+ "config.tmp_path to a directory of your own, or run chmod 700 on it.")
118
131
  rescue SystemCallError
119
132
  nil
120
133
  end