bitfab 0.51.9 → 0.51.10
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/README.md +3 -3
- data/exe/bitfab-seed +13 -0
- data/lib/bitfab/assertion_categories.rb +42 -0
- data/lib/bitfab/assertions.rb +35 -0
- data/lib/bitfab/baml.rb +208 -0
- data/lib/bitfab/client.rb +95 -31
- data/lib/bitfab/db_snapshot.rb +18 -5
- data/lib/bitfab/detached_trace.rb +46 -0
- data/lib/bitfab/graders.rb +23 -0
- data/lib/bitfab/http_client.rb +106 -35
- data/lib/bitfab/labels.rb +80 -0
- data/lib/bitfab/replay.rb +92 -23
- data/lib/bitfab/replay_cli.rb +7 -1
- data/lib/bitfab/replay_concurrency.rb +44 -0
- data/lib/bitfab/replay_memory.rb +111 -0
- data/lib/bitfab/replay_processes.rb +218 -0
- data/lib/bitfab/replay_registry.rb +100 -15
- data/lib/bitfab/seed.rb +128 -0
- data/lib/bitfab/seed_cli.rb +67 -0
- data/lib/bitfab/seed_context.rb +21 -0
- data/lib/bitfab/simulation_plan.rb +248 -0
- data/lib/bitfab/span_context.rb +9 -4
- data/lib/bitfab/span_origin.rb +13 -0
- data/lib/bitfab/subtree.rb +144 -5
- data/lib/bitfab/thread_propagation.rb +111 -0
- data/lib/bitfab/traceable.rb +70 -155
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +9 -2
- metadata +32 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitfab
|
|
4
|
+
class ReplayConcurrency
|
|
5
|
+
UNSET = Object.new.freeze
|
|
6
|
+
attr_reader :attempts, :max_concurrency, :primitive, :memory_throttle, :on_item_finish_in_child_process, :child_timeout
|
|
7
|
+
|
|
8
|
+
def initialize(attempts: 1, max_concurrency: UNSET, primitive: "async", memory_throttle: UNSET, on_item_finish_in_child_process: nil, child_timeout: 2400)
|
|
9
|
+
raise ArgumentError, "primitive must be async or process" unless %w[async process].include?(primitive)
|
|
10
|
+
raise ArgumentError, "attempts must be an integer from 1 to 100" unless attempts.is_a?(Integer) && attempts.between?(1, 100)
|
|
11
|
+
max_concurrency = (primitive == "process") ? 4 : 10 if max_concurrency.equal?(UNSET)
|
|
12
|
+
valid_max = max_concurrency.nil? || (max_concurrency.is_a?(Integer) && max_concurrency.positive?)
|
|
13
|
+
unless valid_max
|
|
14
|
+
raise ArgumentError, "max_concurrency must be a positive integer or nil"
|
|
15
|
+
end
|
|
16
|
+
raise ArgumentError, "process concurrency must be bounded" if primitive == "process" && max_concurrency.nil?
|
|
17
|
+
memory_throttle = primitive == "process" if memory_throttle.equal?(UNSET)
|
|
18
|
+
raise ArgumentError, "memory_throttle must be true or false" unless [true, false].include?(memory_throttle)
|
|
19
|
+
if primitive != "process" && (memory_throttle || on_item_finish_in_child_process)
|
|
20
|
+
raise ArgumentError, "memory_throttle and on_item_finish_in_child_process require primitive: process"
|
|
21
|
+
end
|
|
22
|
+
if on_item_finish_in_child_process && !on_item_finish_in_child_process.respond_to?(:call)
|
|
23
|
+
raise ArgumentError, "on_item_finish_in_child_process must be callable"
|
|
24
|
+
end
|
|
25
|
+
raise ArgumentError, "child_timeout must be a positive finite number" unless child_timeout.is_a?(Numeric) && child_timeout.finite? && child_timeout.positive?
|
|
26
|
+
@child_timeout = child_timeout
|
|
27
|
+
@attempts, @max_concurrency, @primitive = attempts, max_concurrency, primitive
|
|
28
|
+
@memory_throttle, @on_item_finish_in_child_process = memory_throttle, on_item_finish_in_child_process
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.resolve(concurrency, attempts, max_concurrency)
|
|
33
|
+
if concurrency
|
|
34
|
+
raise ArgumentError, "concurrency must be a ReplayConcurrency" unless concurrency.is_a?(self)
|
|
35
|
+
unless attempts.equal?(UNSET) && max_concurrency.equal?(UNSET)
|
|
36
|
+
raise ArgumentError, "concurrency cannot be combined with attempts or max_concurrency"
|
|
37
|
+
end
|
|
38
|
+
concurrency
|
|
39
|
+
else
|
|
40
|
+
new(attempts: attempts.equal?(UNSET) ? 1 : attempts, max_concurrency:)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Bitfab
|
|
6
|
+
class ReplayMemoryThrottle
|
|
7
|
+
MIB = 1024 * 1024
|
|
8
|
+
attr_reader :condition
|
|
9
|
+
|
|
10
|
+
def initialize(enabled: true)
|
|
11
|
+
@enabled = enabled && !%w[0 off false no].include?(ENV.fetch("BITFAB_REPLAY_MEMORY_THROTTLE", "").strip.downcase)
|
|
12
|
+
@initial_budget = env_bytes("BITFAB_REPLAY_CHILD_MEMORY_MB", 2048 * MIB)
|
|
13
|
+
@floor = env_bytes("BITFAB_REPLAY_MEMORY_FLOOR_MB", 2048 * MIB)
|
|
14
|
+
@closed = false
|
|
15
|
+
@peak = 0
|
|
16
|
+
@resident = {}
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@condition = ConditionVariable.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def admit(index)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@condition.wait(@mutex, 2) while !@closed && @enabled && !headroom?
|
|
24
|
+
raise "Replay process launcher closed" if @closed
|
|
25
|
+
@resident[index] = 0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def observe(index, pid)
|
|
30
|
+
return unless @enabled
|
|
31
|
+
value = resident_bytes(pid)
|
|
32
|
+
return unless value
|
|
33
|
+
@mutex.synchronize { @resident[index] = [@resident.fetch(index, 0), value].max }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def release(index)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@peak = [@peak, @resident.delete(index).to_i].max
|
|
39
|
+
@condition.broadcast
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@closed = true
|
|
46
|
+
@condition.broadcast
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def headroom?
|
|
51
|
+
return true if @resident.empty?
|
|
52
|
+
snapshot = memory_snapshot
|
|
53
|
+
return false if snapshot[:swap_used_ratio] && snapshot[:swap_used_ratio] >= 0.85
|
|
54
|
+
return true unless snapshot[:available]
|
|
55
|
+
budget = [@peak.positive? ? @peak : @initial_budget, *@resident.values].max
|
|
56
|
+
unrealized = @resident.values.sum { |rss| [budget - rss, 0].max }
|
|
57
|
+
snapshot[:available] - unrealized - budget >= @floor
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def memory_snapshot
|
|
61
|
+
if File.file?("/proc/meminfo")
|
|
62
|
+
fields = File.read("/proc/meminfo").scan(/^(\w+):\s+(\d+)/).to_h.transform_values { |value| value.to_i * 1024 }
|
|
63
|
+
total = fields["SwapTotal"].to_i
|
|
64
|
+
{available: fields["MemAvailable"], swap_used_ratio: total.positive? ? 1.0 - fields["SwapFree"].to_f / total : 0.0}
|
|
65
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
66
|
+
total = Integer(read_command("sysctl", "-n", "hw.memsize").to_s, exception: false)
|
|
67
|
+
percent = Integer(read_command("sysctl", "-n", "kern.memorystatus_level").to_s, exception: false)
|
|
68
|
+
swap = read_command("sysctl", "-n", "vm.swapusage").to_s
|
|
69
|
+
total_swap = swap[/total = ([\d.]+)/, 1].to_f
|
|
70
|
+
used_swap = swap[/used = ([\d.]+)/, 1].to_f
|
|
71
|
+
{available: (percent && total&.positive?) ? total * percent.to_i.clamp(0, 100) / 100 : nil, swap_used_ratio: total_swap.positive? ? used_swap / total_swap : 0.0}
|
|
72
|
+
else
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
rescue
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resident_bytes(pid)
|
|
80
|
+
value = if File.file?("/proc/#{pid}/status")
|
|
81
|
+
File.read("/proc/#{pid}/status")[/^VmRSS:\s+(\d+)/, 1]
|
|
82
|
+
else
|
|
83
|
+
read_command("ps", "-o", "rss=", "-p", pid.to_s)
|
|
84
|
+
end
|
|
85
|
+
value && value.to_i * 1024
|
|
86
|
+
rescue
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def env_bytes(key, fallback)
|
|
93
|
+
value = Integer(ENV.fetch(key, "0"), exception: false)
|
|
94
|
+
value&.positive? ? value * MIB : fallback
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_command(*argv)
|
|
98
|
+
Open3.popen3(*argv) do |input, output, _errors, process|
|
|
99
|
+
input.close
|
|
100
|
+
unless process.join(5)
|
|
101
|
+
Process.kill("KILL", process.pid)
|
|
102
|
+
process.join
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
process.value.success? ? output.read.strip : nil
|
|
106
|
+
end
|
|
107
|
+
rescue
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require_relative "replay_concurrency"
|
|
7
|
+
require_relative "replay_memory"
|
|
8
|
+
|
|
9
|
+
module Bitfab
|
|
10
|
+
module ReplayProcesses
|
|
11
|
+
ORIGINAL_ENV = ENV.to_h.freeze
|
|
12
|
+
LAUNCHER_KEY = :__bitfab_process_launcher
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def with_registry(path, argv, stderr:)
|
|
17
|
+
launcher = Launcher.new(path, argv, stderr:)
|
|
18
|
+
previous = Thread.current.thread_variable_get(LAUNCHER_KEY)
|
|
19
|
+
Thread.current.thread_variable_set(LAUNCHER_KEY, launcher)
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
launcher&.close
|
|
23
|
+
Thread.current.thread_variable_set(LAUNCHER_KEY, previous)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current
|
|
27
|
+
Thread.current.thread_variable_get(LAUNCHER_KEY)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute_assignment(registry, path, stderr:)
|
|
31
|
+
assignment = JSON.parse(File.read(path))
|
|
32
|
+
registration = registry.fetch(assignment.fetch("pipeline"))
|
|
33
|
+
options = registration.options.dup
|
|
34
|
+
if registration.options_factory
|
|
35
|
+
options.merge!(registration.options_factory.call(ReplayRegistryContext.new(params: assignment.fetch("params"))))
|
|
36
|
+
end
|
|
37
|
+
client = registration.client
|
|
38
|
+
receiver, method_name = registration.receiver, registration.method_name
|
|
39
|
+
unless method_name && Traceable.trace_function_key_for(receiver, method_name)
|
|
40
|
+
receiver = ManagedCallable.new(client, registration.trace_function_key, method_name ? receiver.method(method_name) : receiver)
|
|
41
|
+
method_name = :call
|
|
42
|
+
end
|
|
43
|
+
http = client.instance_variable_get(:@http_client)
|
|
44
|
+
overrides = MockOverride.normalize(options[:mock_override]) + (client.instance_variable_get(:@mock_overrides) || [])
|
|
45
|
+
result = Replay.process_single_item(http, assignment.fetch("item"), receiver, method_name,
|
|
46
|
+
assignment.fetch("test_run_id"), assignment.fetch("mock"), options[:adapt_inputs],
|
|
47
|
+
assignment.fetch("include_db_branch_lease"), assignment["db_branch_settings"],
|
|
48
|
+
mock_overrides: overrides, dry_run: assignment.fetch("dry_run"))
|
|
49
|
+
unless assignment["dry_run"]
|
|
50
|
+
begin
|
|
51
|
+
mapped = Replay.wait_for_replay_persistence(http, assignment.fetch("test_run_id"), [result[:_sdk_trace_id]])
|
|
52
|
+
result[:trace_id] = mapped[result[:_sdk_trace_id]] || result[:trace_id]
|
|
53
|
+
if result[:_sdk_trace_id] && result[:trace_id].nil?
|
|
54
|
+
status = http.get_replay_status(assignment.fetch("test_run_id"), {result[:_sdk_trace_id] => 1})
|
|
55
|
+
result[:trace_id] = status.fetch("traceIds", {})[result[:_sdk_trace_id]]
|
|
56
|
+
end
|
|
57
|
+
raise "Child replay trace persistence could not be confirmed" if result[:error].nil? && result[:trace_id].nil?
|
|
58
|
+
rescue => error
|
|
59
|
+
result[:replay_error] = error
|
|
60
|
+
result[:error] = error.message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
write_result(assignment.fetch("result_path"), result)
|
|
64
|
+
hook = options[:concurrency]&.on_item_finish_in_child_process
|
|
65
|
+
if hook && !assignment["dry_run"]
|
|
66
|
+
begin
|
|
67
|
+
hook.call({test_run_id: assignment.fetch("test_run_id"), item: Replay.public_replay_items([result]).first})
|
|
68
|
+
rescue => error
|
|
69
|
+
stderr.puts "[replay] child grading hook failed for #{result[:original_trace_id]} attempt #{result[:attempt]}: #{error}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
client.close(timeout: 5)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_result(path, result)
|
|
77
|
+
encoded = begin
|
|
78
|
+
Marshal.dump(result)
|
|
79
|
+
rescue TypeError
|
|
80
|
+
safe = result.to_h do |key, value|
|
|
81
|
+
serializable = begin
|
|
82
|
+
Marshal.dump(value)
|
|
83
|
+
value
|
|
84
|
+
rescue TypeError
|
|
85
|
+
value.is_a?(Exception) ? Replay.json_safe(value) : Serialize.serialize_value(value)
|
|
86
|
+
end
|
|
87
|
+
[key, serializable]
|
|
88
|
+
end
|
|
89
|
+
Marshal.dump(safe)
|
|
90
|
+
end
|
|
91
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
92
|
+
File.binwrite(temporary, encoded)
|
|
93
|
+
File.rename(temporary, path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class Launcher
|
|
97
|
+
def initialize(registry_path, argv, stderr:)
|
|
98
|
+
@registry_path, @argv, @stderr = registry_path, argv, stderr
|
|
99
|
+
@directory = Dir.mktmpdir("bitfab-replay-process-")
|
|
100
|
+
@mutex = Mutex.new
|
|
101
|
+
@children = {}
|
|
102
|
+
@sequence = 0
|
|
103
|
+
@closed = false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def configure(registry, concurrency)
|
|
107
|
+
@args = ReplayCli.parse(registry, @argv.dup)
|
|
108
|
+
@params = ReplayCli.load_parameters(@args[:params], @args.fetch(:param, []))
|
|
109
|
+
@child_timeout = concurrency.child_timeout
|
|
110
|
+
@throttle = ReplayMemoryThrottle.new(enabled: concurrency.memory_throttle)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def call(item, test_run_id, mock, include_db_branch_lease, db_branch_settings, dry_run: false)
|
|
114
|
+
index = @mutex.synchronize { @sequence += 1 }
|
|
115
|
+
@throttle.admit(index)
|
|
116
|
+
result_path = File.join(@directory, "#{index}.result")
|
|
117
|
+
assignment_path = File.join(@directory, "#{index}.json")
|
|
118
|
+
stdout_path, stderr_path = File.join(@directory, "#{index}.out"), File.join(@directory, "#{index}.err")
|
|
119
|
+
assignment = {pipeline: @args.fetch(:pipeline), params: @params, item:, test_run_id:, mock:, include_db_branch_lease:, db_branch_settings:, dry_run:, result_path:}
|
|
120
|
+
File.write(assignment_path, JSON.generate(assignment), mode: "w", perm: 0o600)
|
|
121
|
+
executable = File.expand_path("../../exe/bitfab-replay", __dir__)
|
|
122
|
+
pid = @mutex.synchronize do
|
|
123
|
+
raise "Replay process launcher closed" if @closed
|
|
124
|
+
child = Process.spawn(ORIGINAL_ENV.merge("BITFAB_REPLAY_RESULT_PATH" => nil), RbConfig.ruby,
|
|
125
|
+
"-I", File.expand_path("..", __dir__), executable, "--registry", @registry_path,
|
|
126
|
+
"--execute-item", assignment_path, out: stdout_path, err: stderr_path,
|
|
127
|
+
unsetenv_others: true, **Gem.win_platform? ? {} : {pgroup: true})
|
|
128
|
+
@children[index] = child
|
|
129
|
+
child
|
|
130
|
+
end
|
|
131
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @child_timeout
|
|
132
|
+
status = nil
|
|
133
|
+
timed_out = false
|
|
134
|
+
terminated = false
|
|
135
|
+
next_observation = 0
|
|
136
|
+
loop do
|
|
137
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
if now >= next_observation
|
|
139
|
+
@throttle.observe(index, pid)
|
|
140
|
+
next_observation = now + 5
|
|
141
|
+
end
|
|
142
|
+
waited = Process.waitpid2(pid, Process::WNOHANG)
|
|
143
|
+
if waited
|
|
144
|
+
status = waited[1]
|
|
145
|
+
break
|
|
146
|
+
end
|
|
147
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
148
|
+
timed_out = true
|
|
149
|
+
terminate(pid)
|
|
150
|
+
terminated = true
|
|
151
|
+
break
|
|
152
|
+
end
|
|
153
|
+
sleep(0.1)
|
|
154
|
+
end
|
|
155
|
+
child_errors = read_tail(stderr_path)
|
|
156
|
+
@mutex.synchronize { @stderr.write(child_errors) } unless child_errors.empty?
|
|
157
|
+
unless File.file?(result_path)
|
|
158
|
+
output_tail = read_tail(stdout_path)
|
|
159
|
+
@mutex.synchronize { @stderr.write(output_tail) } unless output_tail.empty?
|
|
160
|
+
raise "Replay child timed out after #{@child_timeout} seconds" if timed_out
|
|
161
|
+
raise "Replay child #{pid} exited #{status.exitstatus || status.termsig} without a complete result"
|
|
162
|
+
end
|
|
163
|
+
@stderr.puts "[replay] child exceeded #{@child_timeout} seconds after writing its result" if timed_out
|
|
164
|
+
Marshal.load(File.binread(result_path))
|
|
165
|
+
rescue => error
|
|
166
|
+
{
|
|
167
|
+
attempt: item["attempt"] || 0, original_trace_id: Replay.original_trace_id_of(item), original_span_id: Replay.original_span_id_of(item),
|
|
168
|
+
source_trace_id: Replay.original_trace_id_of(item), source_span_id: Replay.original_span_id_of(item),
|
|
169
|
+
ingestion_type: item["ingestionType"], input: [], result: nil, original_output: nil, trace_id: nil,
|
|
170
|
+
error: error.message, replay_error: error, trace_error: nil
|
|
171
|
+
}
|
|
172
|
+
ensure
|
|
173
|
+
terminate(pid) if pid && status.nil? && !terminated
|
|
174
|
+
@mutex.synchronize { @children.delete(index) } if index
|
|
175
|
+
@throttle.release(index) if index
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def close
|
|
179
|
+
children = @mutex.synchronize do
|
|
180
|
+
@closed = true
|
|
181
|
+
@children.values.dup
|
|
182
|
+
end
|
|
183
|
+
@throttle&.close
|
|
184
|
+
children.each { |pid| terminate(pid) }
|
|
185
|
+
FileUtils.remove_entry(@directory) if @directory && File.directory?(@directory)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def read_tail(path)
|
|
191
|
+
File.open(path, "rb") do |file|
|
|
192
|
+
file.seek(-[file.size, 65_536].min, IO::SEEK_END)
|
|
193
|
+
file.read.lines.last(20).join
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def terminate(pid)
|
|
198
|
+
Process.kill("TERM", Gem.win_platform? ? pid : -pid)
|
|
199
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
200
|
+
loop do
|
|
201
|
+
return if Process.waitpid(pid, Process::WNOHANG)
|
|
202
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
203
|
+
sleep(0.05)
|
|
204
|
+
end
|
|
205
|
+
Process.kill("KILL", Gem.win_platform? ? pid : -pid)
|
|
206
|
+
Process.waitpid(pid)
|
|
207
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
208
|
+
nil
|
|
209
|
+
ensure
|
|
210
|
+
begin
|
|
211
|
+
Process.kill("KILL", Gem.win_platform? ? pid : -pid)
|
|
212
|
+
rescue Errno::ESRCH
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -19,9 +19,9 @@ module Bitfab
|
|
|
19
19
|
# Project-owned registry of production replay roots.
|
|
20
20
|
class ReplayRegistry
|
|
21
21
|
OPTION_NAMES = %i[
|
|
22
|
-
limit trace_ids name max_concurrency code_change_description
|
|
22
|
+
limit trace_ids name concurrency max_concurrency code_change_description
|
|
23
23
|
code_change_files experiment_group_id dataset_id dataset_ids grader_ids mock
|
|
24
|
-
mock_override adapt_inputs db_branch
|
|
24
|
+
mock_override adapt_inputs db_branch attempts only_with_assertions dry_run on_item_finish
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
27
|
def initialize
|
|
@@ -39,14 +39,14 @@ module Bitfab
|
|
|
39
39
|
# +trace_function_key:+ explicitly. Pass executable per-function replay
|
|
40
40
|
# behavior such as +mock_override:+ and +adapt_inputs:+ as keyword options;
|
|
41
41
|
# the installed command forwards them to +Bitfab::Client#replay+.
|
|
42
|
-
def register(name, receiver, method_name, client: nil, trace_function_key: nil, options_factory: nil, **options)
|
|
42
|
+
def register(name, receiver, method_name = nil, client: nil, trace_function_key: nil, options_factory: nil, **options)
|
|
43
43
|
raise ArgumentError, "Replay registry names cannot be empty." if name.to_s.empty?
|
|
44
44
|
raise ArgumentError, "Replay registry already contains '#{name}'." if @entries.key?(name.to_s)
|
|
45
45
|
if options_factory && !options_factory.respond_to?(:call)
|
|
46
46
|
raise ArgumentError, "Replay registry options_factory must be callable."
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
key = trace_function_key || Traceable.trace_function_key_for(receiver, method_name)
|
|
49
|
+
key = trace_function_key || (method_name && Traceable.trace_function_key_for(receiver, method_name))
|
|
50
50
|
unless key
|
|
51
51
|
raise ArgumentError,
|
|
52
52
|
"Replay registry entry uses a plain method. Set trace_function_key: " \
|
|
@@ -57,7 +57,7 @@ module Bitfab
|
|
|
57
57
|
@entries[name.to_s] = ReplayRegistration.new(
|
|
58
58
|
client: client || Bitfab.client,
|
|
59
59
|
receiver:,
|
|
60
|
-
method_name: method_name
|
|
60
|
+
method_name: method_name&.to_sym,
|
|
61
61
|
trace_function_key: key,
|
|
62
62
|
options: options.freeze,
|
|
63
63
|
options_factory:
|
|
@@ -70,6 +70,12 @@ module Bitfab
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def validate_options(options)
|
|
73
|
+
if options[:concurrency]
|
|
74
|
+
ReplayConcurrency.resolve(options[:concurrency], options.fetch(:attempts, ReplayConcurrency::UNSET), options.fetch(:max_concurrency, ReplayConcurrency::UNSET))
|
|
75
|
+
end
|
|
76
|
+
if options[:on_item_finish] && !options[:on_item_finish].respond_to?(:call)
|
|
77
|
+
raise ArgumentError, "Replay registry on_item_finish must be callable."
|
|
78
|
+
end
|
|
73
79
|
unknown = options.keys - OPTION_NAMES
|
|
74
80
|
return if unknown.empty?
|
|
75
81
|
|
|
@@ -85,7 +91,27 @@ module Bitfab
|
|
|
85
91
|
|
|
86
92
|
module_function
|
|
87
93
|
|
|
88
|
-
def
|
|
94
|
+
def bound_trace_ids(client, trace_ids, limit, only_with_assertions)
|
|
95
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
96
|
+
raise ArgumentError, "Replay limit must be a positive integer."
|
|
97
|
+
end
|
|
98
|
+
return trace_ids.first(limit) if !only_with_assertions || limit >= trace_ids.length
|
|
99
|
+
|
|
100
|
+
selected = []
|
|
101
|
+
trace_ids.each do |trace_id|
|
|
102
|
+
result = client.traces.get_assertions(trace_id)
|
|
103
|
+
if result.fetch("inheritedFrom").nil? && !result.fetch("assertions").empty?
|
|
104
|
+
selected << trace_id
|
|
105
|
+
break if selected.length == limit
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
if selected.empty?
|
|
109
|
+
raise ArgumentError, "No traces with active assertions matched this replay selection."
|
|
110
|
+
end
|
|
111
|
+
selected
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pinned_dataset_members(client, dataset_ids, limit, only_with_assertions = false)
|
|
89
115
|
members = dataset_ids.flat_map { |dataset_id|
|
|
90
116
|
client.datasets.list_traces(dataset_id).fetch("traceIds")
|
|
91
117
|
}.uniq.sort
|
|
@@ -98,11 +124,13 @@ module Bitfab
|
|
|
98
124
|
"every selected dataset in full."
|
|
99
125
|
end
|
|
100
126
|
|
|
101
|
-
members
|
|
127
|
+
bound_trace_ids(client, members, limit, only_with_assertions)
|
|
102
128
|
end
|
|
103
129
|
|
|
104
130
|
def run(registry, argv: ARGV, stdout: $stdout, stderr: $stderr)
|
|
105
131
|
args = parse(registry, argv.dup)
|
|
132
|
+
return SeedCli.run(registry, argv:, stdout:, stderr:) if args[:seed] || args[:from_trace]
|
|
133
|
+
|
|
106
134
|
registration = registry.fetch(args.fetch(:pipeline))
|
|
107
135
|
options = registration.options.dup
|
|
108
136
|
params = load_parameters(args[:params], args.fetch(:param, []))
|
|
@@ -132,22 +160,25 @@ module Bitfab
|
|
|
132
160
|
options.delete(:trace_ids)
|
|
133
161
|
end
|
|
134
162
|
|
|
163
|
+
only_with_assertions = args[:only_with_assertions] || options[:only_with_assertions]
|
|
135
164
|
bound = args[:limit] || options[:limit]
|
|
136
165
|
options.delete(:limit)
|
|
137
166
|
bounded_dataset_ids = args[:dataset_ids] || options[:dataset_ids]
|
|
138
167
|
|
|
139
168
|
if options[:trace_ids]
|
|
140
|
-
|
|
169
|
+
if bound
|
|
170
|
+
options[:trace_ids] = bound_trace_ids(registration.client, options[:trace_ids], bound, only_with_assertions)
|
|
171
|
+
end
|
|
141
172
|
elsif bounded_dataset_ids
|
|
142
173
|
if bound
|
|
143
|
-
pinned = pinned_dataset_members(registration.client, bounded_dataset_ids, bound)
|
|
174
|
+
pinned = pinned_dataset_members(registration.client, bounded_dataset_ids, bound, only_with_assertions)
|
|
144
175
|
options[:trace_ids] = pinned if pinned
|
|
145
176
|
end
|
|
146
177
|
else
|
|
147
178
|
options[:limit] = bound || 10
|
|
148
179
|
end
|
|
149
180
|
|
|
150
|
-
%i[name max_concurrency experiment_group_id dataset_ids grader_ids mock].each do |key|
|
|
181
|
+
%i[name max_concurrency experiment_group_id dataset_ids grader_ids mock attempts only_with_assertions dry_run].each do |key|
|
|
151
182
|
options[key] = args[key] unless args[key].nil?
|
|
152
183
|
end
|
|
153
184
|
|
|
@@ -169,9 +200,34 @@ module Bitfab
|
|
|
169
200
|
options[:code_change_files] = nil
|
|
170
201
|
end
|
|
171
202
|
|
|
203
|
+
if options[:concurrency] || args[:primitive] || args.key?(:memory_throttle)
|
|
204
|
+
configured = options[:concurrency]
|
|
205
|
+
options[:concurrency] = ReplayConcurrency.new(
|
|
206
|
+
primitive: args[:primitive] || configured&.primitive || "async",
|
|
207
|
+
attempts: options.delete(:attempts) || configured&.attempts || 1,
|
|
208
|
+
max_concurrency: if options.key?(:max_concurrency)
|
|
209
|
+
options.delete(:max_concurrency)
|
|
210
|
+
else
|
|
211
|
+
(configured ? configured.max_concurrency : ReplayConcurrency::UNSET)
|
|
212
|
+
end,
|
|
213
|
+
memory_throttle: args.fetch(:memory_throttle, configured ? configured.memory_throttle : ReplayConcurrency::UNSET),
|
|
214
|
+
on_item_finish_in_child_process: configured&.on_item_finish_in_child_process,
|
|
215
|
+
child_timeout: configured&.child_timeout || 2400
|
|
216
|
+
)
|
|
217
|
+
ReplayProcesses.current&.configure(registry, options[:concurrency]) if options[:concurrency].primitive == "process"
|
|
218
|
+
end
|
|
219
|
+
|
|
172
220
|
reporter = Bitfab.method(:report_replay_progress)
|
|
173
221
|
options[:on_item_start] = reporter
|
|
174
|
-
options[:on_item_finish]
|
|
222
|
+
registry_callback = options[:on_item_finish] unless options[:dry_run]
|
|
223
|
+
options[:on_item_finish] = lambda do |progress|
|
|
224
|
+
reporter.call(progress)
|
|
225
|
+
begin
|
|
226
|
+
registry_callback&.call(progress)
|
|
227
|
+
rescue => error
|
|
228
|
+
stderr.puts "[replay] on_item_finish failed for #{progress.dig(:item, :original_trace_id)}: #{error}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
175
231
|
|
|
176
232
|
stderr.puts "[replay] Replaying #{describe_selection(options)} from \"#{registration.trace_function_key}\"..."
|
|
177
233
|
|
|
@@ -190,7 +246,13 @@ module Bitfab
|
|
|
190
246
|
raise
|
|
191
247
|
end
|
|
192
248
|
|
|
193
|
-
|
|
249
|
+
raise ArgumentError, "No traces matched this replay selection." if result[:items].empty?
|
|
250
|
+
|
|
251
|
+
if options[:dry_run]
|
|
252
|
+
result[:items].each { |item| stderr.puts "[dry-run] #{item[:original_trace_id]} attempt #{item[:attempt]} inputs=#{JSON.generate(item[:input])}" }
|
|
253
|
+
else
|
|
254
|
+
render_summary(args.fetch(:pipeline), result, stderr)
|
|
255
|
+
end
|
|
194
256
|
stdout.puts Bitfab.serialize_replay_result(result)
|
|
195
257
|
result
|
|
196
258
|
end
|
|
@@ -207,6 +269,9 @@ module Bitfab
|
|
|
207
269
|
args[:limit] = positive_integer("--limit", value)
|
|
208
270
|
end
|
|
209
271
|
options.on("--trace-ids IDS") { |value| args[:trace_ids] = comma_separated("--trace-ids", value) }
|
|
272
|
+
options.on("--seed PATH", "--cases PATH") { |value| args[:seed] = value }
|
|
273
|
+
options.on("--run") { args[:run] = true }
|
|
274
|
+
options.on("--from-trace IDS") { |value| args[:from_trace] = comma_separated("--from-trace", value) }
|
|
210
275
|
options.on("--name NAME") { |value| args[:name] = value }
|
|
211
276
|
options.on("--concurrency N", Integer) do |value|
|
|
212
277
|
args[:max_concurrency] = positive_integer("--concurrency", value)
|
|
@@ -218,6 +283,11 @@ module Bitfab
|
|
|
218
283
|
options.on("--experiment-group-id UUID") { |value| args[:experiment_group_id] = value }
|
|
219
284
|
options.on("--dataset-ids IDS", "--dataset-id IDS") { |value| args[:dataset_ids] = comma_separated("--dataset-ids", value) }
|
|
220
285
|
options.on("--grader-ids IDS") { |value| args[:grader_ids] = comma_separated("--grader-ids", value) }
|
|
286
|
+
options.on("--attempts N", Integer) { |value| args[:attempts] = positive_integer("--attempts", value) }
|
|
287
|
+
options.on("--only-with-assertions") { args[:only_with_assertions] = true }
|
|
288
|
+
options.on("--dry-run") { args[:dry_run] = true }
|
|
289
|
+
options.on("--primitive TYPE", %w[async process]) { |value| args[:primitive] = value }
|
|
290
|
+
options.on("--[no-]memory-throttle") { |value| args[:memory_throttle] = value }
|
|
221
291
|
options.on("--mock STRATEGY", %w[none all marked]) { |value| args[:mock] = value }
|
|
222
292
|
options.on("--db-branch") { args[:db_branch] = true }
|
|
223
293
|
options.on("--no-db-branch") { args[:db_branch] = false }
|
|
@@ -317,11 +387,19 @@ module Bitfab
|
|
|
317
387
|
def render_summary(pipeline, result, stderr)
|
|
318
388
|
same = 0
|
|
319
389
|
changed = 0
|
|
390
|
+
matched = 0
|
|
391
|
+
missed = 0
|
|
320
392
|
errors = 0
|
|
321
393
|
result[:items].each do |item|
|
|
322
394
|
if item[:error]
|
|
323
395
|
errors += 1
|
|
324
|
-
elsif item[:
|
|
396
|
+
elsif item[:ingestion_type] == "seeded"
|
|
397
|
+
if Serialize.serialize_value(item[:result]) == Serialize.serialize_value(item[:original_output])
|
|
398
|
+
matched += 1
|
|
399
|
+
else
|
|
400
|
+
missed += 1
|
|
401
|
+
end
|
|
402
|
+
elsif Serialize.serialize_value(item[:result]) == Serialize.serialize_value(item[:original_output])
|
|
325
403
|
same += 1
|
|
326
404
|
else
|
|
327
405
|
changed += 1
|
|
@@ -331,8 +409,15 @@ module Bitfab
|
|
|
331
409
|
stderr.puts "\n─── Summary ───"
|
|
332
410
|
stderr.puts " Pipeline: #{pipeline}"
|
|
333
411
|
stderr.puts " Replayed: #{result[:items].length}"
|
|
334
|
-
stderr.puts "
|
|
335
|
-
|
|
412
|
+
stderr.puts " Attempts: #{result[:attempts]}" if result[:attempts].to_i > 1
|
|
413
|
+
if same.positive? || changed.positive? || matched + missed == 0
|
|
414
|
+
stderr.puts " Same: #{same}"
|
|
415
|
+
stderr.puts " Changed: #{changed}"
|
|
416
|
+
end
|
|
417
|
+
if matched.positive? || missed.positive?
|
|
418
|
+
stderr.puts " Matched expected: #{matched}"
|
|
419
|
+
stderr.puts " Missed expected: #{missed}"
|
|
420
|
+
end
|
|
336
421
|
stderr.puts " Errors: #{errors}" if errors > 0
|
|
337
422
|
stderr.puts "\n #{result[:test_run_url]}"
|
|
338
423
|
end
|