bitfab 0.38.0 → 0.38.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4bb59de5ab0ec989d85e8be14ee08ad935ada5145e01826b34657fc8c9e6623
4
- data.tar.gz: 78b0255c5a3062470f4149baf632c94a5b18b430567485a1ba58f5b9cf9b9534
3
+ metadata.gz: 2816ce4e3bcd11bb1335ba7dac6b36376277d17075cf853d26c9befa231a758b
4
+ data.tar.gz: 7e923c2efb8dfd38edbdd592972a8f236a3fa81cdfabcbd46b8303ba4160b01e
5
5
  SHA512:
6
- metadata.gz: e127f198a39372bebaff5a0cf395b4b6b00f2ede4bf9768b8e3a4556ee00271ddf44fe4a40f16fcffac72d3def4d3c83c3f756801ba156edbe58ecbfd189f2e5
7
- data.tar.gz: ca803b3df3783f6bb01d5eb5a518300334d0ccd4f31d43f3870c981779cd69710d076a1ded3f058c3b9e8aa65f643b3872dd60f99ac8e4394cd5f97855ec1970
6
+ metadata.gz: 15a2df82260f61733b90da3c110b195c92373c187c3eddce5faa041f44499bb008ec5a14cd27dab22b63e2973540010cc2e2f10fcc2dcd75481405d018a107f6
7
+ data.tar.gz: 13968f6a9f8d3c88ea2845d0d59e0cc1d31ffadc1054311addeedc1e4ba92d94aea504f0f5d11e9891fd0a233e92d285e635e5d8e85e46ebfdf553cdedc45005
data/exe/bitfab-replay ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bitfab"
5
+
6
+ exit Bitfab::ReplayCommand.main
data/lib/bitfab/client.rb CHANGED
@@ -91,7 +91,8 @@ module Bitfab
91
91
  # maximum: 5,000). Ignored when trace_ids or dataset_id is passed because
92
92
  # either source already determines how many traces replay. Supplying
93
93
  # trace_ids also emits a warning.
94
- # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
94
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100),
95
+ # mutually exclusive with dataset_id
95
96
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
96
97
  # @param code_change_description [String, nil] optional rationale for the
97
98
  # code change being tested in this replay (stored on the experiment).
@@ -105,7 +106,8 @@ module Bitfab
105
106
  # @param experiment_group_id [String, nil] optional UUID grouping multiple
106
107
  # replay runs into a single experiment batch
107
108
  # @param dataset_id [String, nil] optional UUID of the dataset this replay
108
- # runs against, stored on the resulting experiment for durable attribution
109
+ # runs against, mutually exclusive with trace_ids and stored on the
110
+ # resulting experiment for durable attribution
109
111
  # @param grader_ids [Array<String>, nil] optional UUIDs of graders attached
110
112
  # directly to this experiment, graded as the union with the dataset's
111
113
  # runnable graders at completion; each must be an active/live grader in the
data/lib/bitfab/replay.rb CHANGED
@@ -260,11 +260,14 @@ module Bitfab
260
260
  raise ArgumentError, "trace_ids supports at most 100 trace IDs per replay (got #{trace_ids.length})."
261
261
  end
262
262
  end
263
+ if trace_ids && dataset_id
264
+ raise ArgumentError,
265
+ "trace_ids and dataset_id select different replay sources and cannot be used together."
266
+ end
263
267
  if limit && trace_ids
264
268
  warn "Bitfab: limit is ignored when trace_ids is passed: the explicit trace ID list already " \
265
269
  "determines how many traces replay."
266
270
  end
267
-
268
271
  # Reject a trace_function_key that contradicts the method's declared key.
269
272
  # replay() fetches historical traces by trace_function_key but records the
270
273
  # replayed spans under the method's own bitfab_span key (via send below),
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Bitfab
6
+ # Installed command that loads a project-owned registry module.
7
+ module ReplayCommand
8
+ USAGE = "Usage: bitfab-replay --registry <path> <pipeline> [replay options]"
9
+ HELP = "#{USAGE}\nRun bitfab-replay --registry <path> --help to list replay options."
10
+
11
+ module_function
12
+
13
+ def run(argv: ARGV, stdout: $stdout, stderr: $stderr, registry_loader: nil)
14
+ registry_path, replay_argv = parse(argv)
15
+ loader = registry_loader || method(:load_registry)
16
+ registry = loader.call(registry_path)
17
+ ReplayCli.run(registry, argv: replay_argv, stdout:, stderr:)
18
+ end
19
+
20
+ def main(argv: ARGV, stdout: $stdout, stderr: $stderr)
21
+ if (argv.include?("--help") || argv.include?("-h")) && !argv.include?("--registry")
22
+ stdout.puts HELP
23
+ return 0
24
+ end
25
+ run(argv:, stdout:, stderr:)
26
+ 0
27
+ rescue => error
28
+ stderr.puts error.message
29
+ 1
30
+ end
31
+
32
+ def parse(argv)
33
+ values = argv.dup
34
+ registry_index = values.index("--registry")
35
+ registry_path = values[registry_index + 1] if registry_index
36
+ unless registry_path && !registry_path.start_with?("--")
37
+ raise OptionParser::MissingArgument, USAGE
38
+ end
39
+
40
+ values.slice!(registry_index, 2)
41
+ raise OptionParser::MissingArgument, USAGE if values.empty?
42
+
43
+ [File.expand_path(registry_path), values]
44
+ end
45
+
46
+ def load_registry(path)
47
+ container = Module.new
48
+ container.module_eval(File.read(path), path)
49
+ registry = container.const_get(:REGISTRY, false) if container.const_defined?(:REGISTRY, false)
50
+ unless registry.is_a?(ReplayRegistry)
51
+ raise ArgumentError,
52
+ "Replay registry '#{path}' must define REGISTRY as a Bitfab::ReplayRegistry."
53
+ end
54
+
55
+ registry
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module Bitfab
7
+ # One project function exposed through the SDK-owned replay command.
8
+ ReplayRegistration = Data.define(
9
+ :client,
10
+ :receiver,
11
+ :method_name,
12
+ :trace_function_key,
13
+ :options,
14
+ :options_factory
15
+ )
16
+
17
+ ReplayRegistryContext = Data.define(:params)
18
+
19
+ # Project-owned registry of production replay roots.
20
+ class ReplayRegistry
21
+ OPTION_NAMES = %i[
22
+ limit trace_ids name max_concurrency code_change_description
23
+ code_change_files experiment_group_id dataset_id grader_ids mock
24
+ mock_override adapt_inputs db_branch
25
+ ].freeze
26
+
27
+ def initialize
28
+ @entries = {}
29
+ end
30
+
31
+ def names
32
+ @entries.keys.freeze
33
+ end
34
+
35
+ # Register the exact traced receiver method production invokes.
36
+ #
37
+ # A +bitfab_span+ method carries its trace function key automatically.
38
+ # Handler-instrumented or otherwise plain methods must pass
39
+ # +trace_function_key:+ explicitly. Pass executable per-function replay
40
+ # behavior such as +mock_override:+ and +adapt_inputs:+ as keyword options;
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)
43
+ raise ArgumentError, "Replay registry names cannot be empty." if name.to_s.empty?
44
+ raise ArgumentError, "Replay registry already contains '#{name}'." if @entries.key?(name.to_s)
45
+ if options_factory && !options_factory.respond_to?(:call)
46
+ raise ArgumentError, "Replay registry options_factory must be callable."
47
+ end
48
+
49
+ key = trace_function_key || Traceable.trace_function_key_for(receiver, method_name)
50
+ unless key
51
+ raise ArgumentError,
52
+ "Replay registry entry uses a plain method. Set trace_function_key: " \
53
+ "to the key its production handler records."
54
+ end
55
+ validate_options(options)
56
+
57
+ @entries[name.to_s] = ReplayRegistration.new(
58
+ client: client || Bitfab.client,
59
+ receiver:,
60
+ method_name: method_name.to_sym,
61
+ trace_function_key: key,
62
+ options: options.freeze,
63
+ options_factory:
64
+ )
65
+ self
66
+ end
67
+
68
+ def fetch(name)
69
+ @entries.fetch(name)
70
+ end
71
+
72
+ def validate_options(options)
73
+ unknown = options.keys - OPTION_NAMES
74
+ return if unknown.empty?
75
+
76
+ raise ArgumentError,
77
+ "Unknown replay registry option(s): #{unknown.sort.join(", ")}. " \
78
+ "Allowed options: #{OPTION_NAMES.sort.join(", ")}."
79
+ end
80
+ end
81
+
82
+ # Argument parsing and output for the SDK-owned replay command.
83
+ module ReplayCli
84
+ module_function
85
+
86
+ def run(registry, argv: ARGV, stdout: $stdout, stderr: $stderr)
87
+ args = parse(registry, argv.dup)
88
+ registration = registry.fetch(args.fetch(:pipeline))
89
+ options = registration.options.dup
90
+ params = load_parameters(args[:params], args.fetch(:param, []))
91
+ if registration.options_factory
92
+ dynamic_options = registration.options_factory.call(ReplayRegistryContext.new(params:))
93
+ unless dynamic_options.is_a?(Hash)
94
+ raise ArgumentError, "Replay registry options_factory must return a Hash."
95
+ end
96
+ registry.validate_options(dynamic_options)
97
+ options.merge!(dynamic_options)
98
+ end
99
+
100
+ if options[:trace_ids] && options[:dataset_id]
101
+ raise ArgumentError,
102
+ "Replay registry options trace_ids and dataset_id select different sources and cannot be used together."
103
+ end
104
+
105
+ if args[:trace_ids]
106
+ options.delete(:limit)
107
+ options.delete(:dataset_id)
108
+ options[:trace_ids] = args[:trace_ids]
109
+ elsif args[:dataset_id]
110
+ options.delete(:trace_ids)
111
+ options.delete(:limit)
112
+ elsif args[:limit]
113
+ options.delete(:trace_ids)
114
+ options.delete(:dataset_id)
115
+ options[:limit] = args[:limit]
116
+ elsif options[:trace_ids] || options[:dataset_id]
117
+ options.delete(:limit)
118
+ else
119
+ options[:limit] = options.fetch(:limit, 10)
120
+ end
121
+
122
+ %i[name max_concurrency experiment_group_id dataset_id grader_ids mock].each do |key|
123
+ options[key] = args[key] unless args[key].nil?
124
+ end
125
+
126
+ unless args[:db_branch].nil?
127
+ if args[:db_branch]
128
+ configured = options[:db_branch]
129
+ options[:db_branch] = (configured.nil? || configured == false) ? true : configured
130
+ else
131
+ options[:db_branch] = false
132
+ end
133
+ end
134
+
135
+ if args[:code_change]
136
+ code_change = load_code_change(args[:code_change])
137
+ options[:code_change_description] = code_change.fetch("description")
138
+ options[:code_change_files] = code_change.fetch("files")
139
+ elsif args[:no_code_change]
140
+ options[:code_change_description] = nil
141
+ options[:code_change_files] = nil
142
+ end
143
+
144
+ reporter = Bitfab.method(:report_replay_progress)
145
+ options[:on_item_start] = reporter
146
+ options[:on_item_finish] = reporter
147
+
148
+ count = options[:trace_ids]&.length || options[:limit] || "dataset"
149
+ stderr.puts "[replay] Replaying #{count} traces from \"#{registration.trace_function_key}\"..."
150
+
151
+ result = begin
152
+ registration.client.replay(
153
+ registration.receiver,
154
+ registration.method_name,
155
+ trace_function_key: registration.trace_function_key,
156
+ **options
157
+ )
158
+ rescue ReplayError => error
159
+ error.items.each do |item|
160
+ item_error = item[:trace_error] || item[:replay_error] || item[:error]
161
+ stderr.puts "#{item[:original_trace_id]}: #{item_error}"
162
+ end
163
+ raise
164
+ end
165
+
166
+ render_summary(args.fetch(:pipeline), result, stderr)
167
+ stdout.puts Bitfab.serialize_replay_result(result)
168
+ result
169
+ end
170
+
171
+ def parse(registry, argv)
172
+ if argv.include?("--db-branch") && argv.include?("--no-db-branch")
173
+ raise OptionParser::InvalidArgument, "--db-branch and --no-db-branch cannot be used together"
174
+ end
175
+ args = {}
176
+ parser = OptionParser.new do |options|
177
+ options.banner = "Usage: bitfab-replay <#{registry.names.join("|")}> [options]"
178
+ options.on("--limit N", Integer) { |value| args[:limit] = positive_integer("--limit", value) }
179
+ options.on("--trace-ids IDS") { |value| args[:trace_ids] = comma_separated("--trace-ids", value) }
180
+ options.on("--name NAME") { |value| args[:name] = value }
181
+ options.on("--concurrency N", Integer) do |value|
182
+ args[:max_concurrency] = positive_integer("--concurrency", value)
183
+ end
184
+ options.on("--max-concurrency N", Integer) do |value|
185
+ args[:max_concurrency] = positive_integer("--max-concurrency", value)
186
+ end
187
+ options.on("--code-change PATH") { |value| args[:code_change] = value }
188
+ options.on("--experiment-group-id UUID") { |value| args[:experiment_group_id] = value }
189
+ options.on("--dataset-id UUID") { |value| args[:dataset_id] = value }
190
+ options.on("--grader-ids IDS") { |value| args[:grader_ids] = comma_separated("--grader-ids", value) }
191
+ options.on("--mock STRATEGY", %w[none all marked]) { |value| args[:mock] = value }
192
+ options.on("--db-branch") { args[:db_branch] = true }
193
+ options.on("--no-db-branch") { args[:db_branch] = false }
194
+ options.on("--no-code-change") { args[:no_code_change] = true }
195
+ options.on("--params PATH") { |value| args[:params] = value }
196
+ options.on("--param NAME=VALUE") { |value| (args[:param] ||= []) << value }
197
+ end
198
+ parser.parse!(argv)
199
+ if args[:trace_ids] && args[:dataset_id]
200
+ raise OptionParser::InvalidArgument,
201
+ "--trace-ids and --dataset-id select different replay sources and cannot be used together"
202
+ end
203
+ if args[:code_change] && args[:no_code_change]
204
+ raise OptionParser::InvalidArgument, "--code-change and --no-code-change cannot be used together"
205
+ end
206
+ pipeline = argv.shift
207
+ raise OptionParser::MissingArgument, parser.banner unless pipeline && registry.names.include?(pipeline)
208
+ raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
209
+
210
+ args.merge(pipeline:)
211
+ end
212
+
213
+ def positive_integer(flag, value)
214
+ raise OptionParser::InvalidArgument, "#{flag} must be a positive integer" if value < 1
215
+
216
+ value
217
+ end
218
+
219
+ def comma_separated(flag, value)
220
+ values = value.split(",").map(&:strip).reject(&:empty?)
221
+ raise OptionParser::InvalidArgument, "#{flag} must contain at least one value" if values.empty?
222
+
223
+ values
224
+ end
225
+
226
+ def load_code_change(path)
227
+ value = JSON.parse(File.read(path))
228
+ unless value.is_a?(Hash) && value["description"].is_a?(String) && value["files"].is_a?(Array)
229
+ raise ArgumentError, "Invalid --code-change file '#{path}': expected { description, files }."
230
+ end
231
+
232
+ value
233
+ end
234
+
235
+ def parse_parameter(raw)
236
+ key, separator, value = raw.partition("=")
237
+ key = key.strip
238
+ if separator.empty? || key.empty?
239
+ raise OptionParser::InvalidArgument,
240
+ "Invalid --param '#{raw}': expected a non-empty name=value pair"
241
+ end
242
+
243
+ parsed = begin
244
+ JSON.parse(value)
245
+ rescue JSON::ParserError
246
+ value
247
+ end
248
+ [key, parsed]
249
+ end
250
+
251
+ def load_parameters(path, raw_parameters)
252
+ params = {}
253
+ if path
254
+ value = JSON.parse(File.read(path))
255
+ unless value.is_a?(Hash)
256
+ raise ArgumentError, "Invalid --params file '#{path}': expected a JSON object."
257
+ end
258
+ params.merge!(value)
259
+ end
260
+ raw_parameters.each do |raw|
261
+ key, value = parse_parameter(raw)
262
+ params[key] = value
263
+ end
264
+ params.freeze
265
+ end
266
+
267
+ def render_summary(pipeline, result, stderr)
268
+ same = 0
269
+ changed = 0
270
+ errors = 0
271
+ result[:items].each do |item|
272
+ if item[:error]
273
+ errors += 1
274
+ elsif item[:result] == item[:original_output]
275
+ same += 1
276
+ else
277
+ changed += 1
278
+ end
279
+ end
280
+
281
+ stderr.puts "\n─── Summary ───"
282
+ stderr.puts " Pipeline: #{pipeline}"
283
+ stderr.puts " Replayed: #{result[:items].length}"
284
+ stderr.puts " Same: #{same}"
285
+ stderr.puts " Changed: #{changed}"
286
+ stderr.puts " Errors: #{errors}" if errors > 0
287
+ stderr.puts "\n #{result[:test_run_url]}"
288
+ end
289
+ end
290
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.38.0"
4
+ VERSION = "0.38.1"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -15,6 +15,8 @@ require_relative "bitfab/replay"
15
15
  require_relative "bitfab/replay_branch"
16
16
  require_relative "bitfab/client"
17
17
  require_relative "bitfab/traceable"
18
+ require_relative "bitfab/replay_registry"
19
+ require_relative "bitfab/replay_cli"
18
20
 
19
21
  module Bitfab
20
22
  # No-op span handle returned when outside a span context.
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.38.0
4
+ version: 0.38.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -131,11 +131,13 @@ description: Client library for sending function execution spans to the Bitfab A
131
131
  Provides automatic tracing with nested span support.
132
132
  email:
133
133
  - team@goharvest.ai
134
- executables: []
134
+ executables:
135
+ - bitfab-replay
135
136
  extensions: []
136
137
  extra_rdoc_files: []
137
138
  files:
138
139
  - README.md
140
+ - exe/bitfab-replay
139
141
  - lib/bitfab.rb
140
142
  - lib/bitfab/client.rb
141
143
  - lib/bitfab/compress.rb
@@ -147,6 +149,8 @@ files:
147
149
  - lib/bitfab/payload_budget.rb
148
150
  - lib/bitfab/replay.rb
149
151
  - lib/bitfab/replay_branch.rb
152
+ - lib/bitfab/replay_cli.rb
153
+ - lib/bitfab/replay_registry.rb
150
154
  - lib/bitfab/serialize.rb
151
155
  - lib/bitfab/span_context.rb
152
156
  - lib/bitfab/traceable.rb