rails-profiler 0.31.2 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/app/assets/builds/profiler.js +20 -4
  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 +20 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +2 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -16
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/slave_proxy.rb +7 -5
  13. data/lib/profiler/collectors/base_collector.rb +8 -3
  14. data/lib/profiler/collectors/cache_collector.rb +3 -3
  15. data/lib/profiler/collectors/database_collector.rb +128 -10
  16. data/lib/profiler/collectors/env_collector.rb +12 -4
  17. data/lib/profiler/collectors/exception_collector.rb +7 -9
  18. data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
  19. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  20. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  21. data/lib/profiler/collectors/routes_collector.rb +7 -50
  22. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  23. data/lib/profiler/collectors/view_collector.rb +2 -2
  24. data/lib/profiler/configuration.rb +20 -5
  25. data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
  26. data/lib/profiler/instrumentation/request_context.rb +77 -0
  27. data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
  28. data/lib/profiler/job_profiler.rb +4 -2
  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 +8 -2
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_detail.rb +8 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
  38. data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
  39. data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
  40. data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
  41. data/lib/profiler/mcp/tools/run_tests.rb +1 -1
  42. data/lib/profiler/middleware/profiler_middleware.rb +4 -1
  43. data/lib/profiler/process_snapshot.rb +157 -0
  44. data/lib/profiler/railtie.rb +3 -0
  45. data/lib/profiler/storage/file_store.rb +332 -58
  46. data/lib/profiler/storage/memory_store.rb +60 -31
  47. data/lib/profiler/storage/private_files.rb +13 -0
  48. data/lib/profiler/storage/redis_store.rb +251 -32
  49. data/lib/profiler/storage/sqlite_store.rb +57 -9
  50. data/lib/profiler/storage/summary.rb +50 -0
  51. data/lib/profiler/storage/unavailable.rb +58 -0
  52. data/lib/profiler/test_helpers/reporter.rb +1 -1
  53. data/lib/profiler/version.rb +1 -1
  54. data/lib/profiler.rb +17 -1
  55. metadata +8 -1
@@ -114,7 +114,7 @@ module Profiler
114
114
  end
115
115
 
116
116
  def self.collect_run_profiles(since)
117
- Profiler.storage.list(limit: 500).select do |p|
117
+ Profiler.storage.list(limit: 500, type: "test").select do |p|
118
118
  p.profile_type == "test" && p.started_at && p.started_at >= since
119
119
  end.map(&:token)
120
120
  rescue
@@ -104,7 +104,10 @@ module Profiler
104
104
  record_response(env, profile, request_body, status, headers, content, content.bytesize, true)
105
105
  collect_all(profile, collectors)
106
106
 
107
- Profiler.storage.save(profile.token, profile)
107
+ storage = Profiler.storage
108
+ storage.save(profile.token, profile)
109
+ # Not saved: no token to send, no toolbar to inject for it.
110
+ return [status, headers, body] if storage.is_a?(Profiler::Storage::Unavailable)
108
111
 
109
112
  set_header(headers, TOKEN_HEADER, profile.token)
110
113
 
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redaction"
4
+
5
+ module Profiler
6
+ # What is the same for every request of a process, kept out of the profiles: the route table
7
+ # and ENV. A profile stores the route its request matched; the Routes and Env tabs, the
8
+ # toolbar and the MCP tools get the table and the variables from the process that displays the
9
+ # profile (#hydrate).
10
+ #
11
+ # The route table is built once and built again when the routes are reloaded, in development
12
+ # (reload_routes!, or a change picked up by the reloader): the routes are new objects then.
13
+ # ENV is read when the profile is displayed, masked as it always was: it can change while the
14
+ # process runs, through the profiler's own env overrides among others.
15
+ module ProcessSnapshot
16
+ @mutex = Mutex.new
17
+ @routes = nil # [fingerprint, table, routes by [controller, action]]
18
+
19
+ class << self
20
+ # The application's routes, the engine's and Rails' own left out, as the Routes tab lists
21
+ # them: { name:, pattern:, verb:, controller_action: }.
22
+ def route_table
23
+ routes_snapshot[1]
24
+ end
25
+
26
+ # The route of the table a request to +path+ with +method+ is dispatched to, or nil.
27
+ def matched_route(path, method)
28
+ return nil unless rails_routes?
29
+
30
+ recognized = Rails.application.routes.recognize_path(path, method: method)
31
+ candidates = routes_snapshot[2][[recognized[:controller], recognized[:action]]]
32
+ return nil unless candidates
33
+
34
+ # A PUT goes to the action of the PATCH route too: give the route of the request's verb.
35
+ verb = method.to_s.upcase == "HEAD" ? "GET" : method.to_s.upcase
36
+ candidates.find { |route| route[:verb].split("|").include?(verb) } ||
37
+ candidates.find { |route| route[:verb] == "ANY" } ||
38
+ candidates.first
39
+ rescue StandardError
40
+ nil
41
+ end
42
+
43
+ # ENV as the Env tab shows it.
44
+ def env_variables
45
+ Redaction.hide_credentials(Redaction.env_snapshot)
46
+ end
47
+
48
+ # Puts the route table and ENV back into +profile+'s data for display. A profile saved by
49
+ # an earlier version has its own table and variables, and is left as it is.
50
+ def hydrate(profile)
51
+ # A job, a console expression or a test ran in another process, whose ENV its profile keeps.
52
+ return profile unless profile && profile.profile_type.to_s == "http"
53
+
54
+ data = profile.collectors_data
55
+ if (routes = data["routes"]) && !key?(routes, :routes)
56
+ data["routes"] = with_route_table(routes)
57
+ end
58
+ if (env = data["env"]) && !key?(env, :variables)
59
+ variables = env_variables
60
+ data["env"] = merge(env, variables: variables, total: variables.size)
61
+ end
62
+ profile
63
+ rescue StandardError => e
64
+ warn "Profiler: could not add the routes and ENV to profile #{profile.token}: #{e.message}"
65
+ profile
66
+ end
67
+
68
+ private
69
+
70
+ def rails_routes?
71
+ defined?(Rails) && Rails.respond_to?(:application) && Rails.application
72
+ end
73
+
74
+ def routes_snapshot
75
+ return [nil, [].freeze, {}.freeze] unless rails_routes?
76
+
77
+ journey = Rails.application.routes.routes
78
+ list = journey.respond_to?(:routes) ? journey.routes : journey.to_a
79
+ fingerprint = [journey.object_id, list.size, list.first&.object_id, list.last&.object_id]
80
+ snapshot = @routes
81
+ return snapshot if snapshot && snapshot[0] == fingerprint
82
+
83
+ @mutex.synchronize do
84
+ @routes = build_snapshot(fingerprint, list) unless @routes && @routes[0] == fingerprint
85
+ @routes
86
+ end
87
+ rescue StandardError
88
+ [nil, [].freeze, {}.freeze]
89
+ end
90
+
91
+ def build_snapshot(fingerprint, list)
92
+ index = {}
93
+ table = list.filter_map do |route|
94
+ next if route.respond_to?(:internal) && route.internal
95
+
96
+ controller = route.defaults[:controller]
97
+ action = route.defaults[:action]
98
+ next if controller.nil?
99
+ next if controller.start_with?("rails/", "profiler/")
100
+
101
+ verb = route.verb
102
+ entry = {
103
+ name: route.name,
104
+ pattern: route.path.spec.to_s.sub(/\(\.:format\)$/, ""),
105
+ verb: (verb && !verb.empty?) ? verb : "ANY",
106
+ controller_action: controller_action(controller, action)
107
+ }.freeze
108
+ (index[[controller, action]] ||= []) << entry
109
+ entry
110
+ end
111
+ [fingerprint, table.freeze, index.each_value(&:freeze).freeze]
112
+ end
113
+
114
+ def controller_action(controller, action)
115
+ return nil unless action
116
+
117
+ "#{controller.split("/").map { |s| ActiveSupport::Inflector.camelize(s) }.join("::")}Controller##{action}"
118
+ end
119
+
120
+ def with_route_table(routes)
121
+ matched = fetch(routes, :matched)
122
+ table = route_table.map { |route| route.merge(matched: same_route?(route, matched)) }
123
+ merge(routes, routes: table, total: table.size)
124
+ end
125
+
126
+ # Every route of the matched action, as the table always marked them.
127
+ def same_route?(route, matched)
128
+ return false unless matched.is_a?(Hash)
129
+
130
+ action = fetch(matched, :controller_action)
131
+ action ? route[:controller_action] == action : route[:pattern] == fetch(matched, :pattern)
132
+ end
133
+
134
+ # Collector data has symbol keys as collected, string keys once read back from storage.
135
+ def key?(hash, key)
136
+ hash.key?(key) || hash.key?(key.to_s)
137
+ end
138
+
139
+ def fetch(hash, key)
140
+ hash.key?(key) ? hash[key] : hash[key.to_s]
141
+ end
142
+
143
+ def merge(hash, values)
144
+ strings = hash.keys.first.is_a?(String)
145
+ hash.merge(strings ? deep_stringify(values) : values)
146
+ end
147
+
148
+ def deep_stringify(value)
149
+ case value
150
+ when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
151
+ when Array then value.map { |v| deep_stringify(v) }
152
+ else value
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -15,6 +15,9 @@ module Profiler
15
15
  config.default(:enabled, development_or_test)
16
16
  config.default(:storage, development_or_test ? :file : :memory)
17
17
  config.default(:track_tests, Rails.env.test?)
18
+ # One test profile per example: no cap on the count in the test environment, so that the
19
+ # report at the end of a suite sees all of them.
20
+ config.default(:max_profiles, Rails.env.test? ? nil : 100)
18
21
 
19
22
  app.config.profiler.each do |key, value|
20
23
  if config.respond_to?("#{key}=")
@@ -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
 
@@ -45,55 +104,56 @@ module Profiler
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,31 +163,245 @@ 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
108
189
  end
109
190
 
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
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
- return if total_size < @max_size
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
- sorted = profile_files.sort_by do |f|
119
- File.mtime(f)
120
- rescue Errno::ENOENT
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
- 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
130
- nil
404
+ yield
131
405
  end
132
406
  end
133
407
  end