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.
@@ -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.to_sym,
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 pinned_dataset_members(client, dataset_ids, limit)
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.first(limit)
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
- options[:trace_ids] = options[:trace_ids].first(bound) if bound
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] = reporter
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
- render_summary(args.fetch(:pipeline), result, stderr)
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[:result] == item[:original_output]
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 " Same: #{same}"
335
- stderr.puts " Changed: #{changed}"
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