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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/app/assets/builds/profiler.js +20 -4
- 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 +20 -28
- data/app/controllers/profiler/api/tests_controller.rb +1 -11
- data/app/controllers/profiler/api/toolbar_controller.rb +2 -20
- data/app/controllers/profiler/application_controller.rb +55 -15
- data/app/controllers/profiler/profiles_controller.rb +2 -16
- data/lib/profiler/ajax_data.rb +40 -0
- data/lib/profiler/cluster/slave_proxy.rb +7 -5
- data/lib/profiler/collectors/base_collector.rb +8 -3
- data/lib/profiler/collectors/cache_collector.rb +3 -3
- data/lib/profiler/collectors/database_collector.rb +128 -10
- data/lib/profiler/collectors/env_collector.rb +12 -4
- data/lib/profiler/collectors/exception_collector.rb +7 -9
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
- data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
- data/lib/profiler/collectors/mailer_collector.rb +2 -8
- data/lib/profiler/collectors/routes_collector.rb +7 -50
- data/lib/profiler/collectors/scoped_notifications.rb +229 -0
- data/lib/profiler/collectors/view_collector.rb +2 -2
- data/lib/profiler/configuration.rb +20 -5
- data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
- data/lib/profiler/instrumentation/request_context.rb +77 -0
- data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
- data/lib/profiler/job_profiler.rb +4 -2
- 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_profile_detail.rb +8 -1
- 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/process_snapshot.rb +157 -0
- 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 +17 -1
- 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
|
|
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
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -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]
|
|
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
|