bitfab 0.30.0 → 0.31.0

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: 1711bd0806a246ec36f15e2bedd0b31d8fe8c9df0aacc922d7ad15a20badf629
4
- data.tar.gz: 2e619cf976e2909bdf1bf629cb350e6b761115d4ec9d8372beaacfe5b46adbbf
3
+ metadata.gz: 8d85c35718e6138d5095d4d20278fdac5f3c6f31858684622cb3a68bad6b5416
4
+ data.tar.gz: 1de8519b600a8ea4a29593a82f938669d8aa9e9ec616c95102c0abe6d06045c6
5
5
  SHA512:
6
- metadata.gz: 88d118025460c8aed53e48d4ca16815133c67aa04986688bf1ed84ed506a16c41f11e76fd012cb9c18f6ee06a44f1b42e7de811e78834da737b9e4b260d5a024
7
- data.tar.gz: 6c0e746e23dfdf5995c502e3a40cc12c03888c5e783b924049d2cd4152660c8a226b9ce852532440687630449fe84f027a75082602f175619fbe28e38121e9ba
6
+ metadata.gz: 0c6d45ca5bbaedfd2a162e2d05c4bef6da21b31c482db91d5539fb46b79be936aa4600e2ea5d0875ecf0e608907ee92a028660edbcd08dc367ce942003658538
7
+ data.tar.gz: db3e762b1ce6b7cb60f4996f4642d6e2a3d5e6a7e6b886a14c319b98ae198f0f210e7af857fda35c53d4e2a49339283ae929f6338d1bae3f64f9be6b97023acc
data/lib/bitfab/client.rb CHANGED
@@ -114,12 +114,12 @@ module Bitfab
114
114
  # @return [Hash] with :items, :test_run_id, :test_run_url
115
115
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
116
116
  name: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked",
117
- adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
117
+ adapt_inputs: nil, mock_override: nil, environment: nil, db_branch: nil, on_progress: nil)
118
118
  Replay.run(
119
119
  self, receiver, method_name,
120
120
  trace_function_key:, limit:, trace_ids:, name:, max_concurrency:,
121
121
  code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, grader_ids:, mock:, adapt_inputs:,
122
- mock_override:, environment:,
122
+ mock_override:, environment:, db_branch:,
123
123
  on_progress:
124
124
  )
125
125
  end
@@ -118,7 +118,7 @@ module Bitfab
118
118
  # same org and trace function or the server rejects the replay
119
119
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
120
120
  code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_id: nil,
121
- grader_ids: nil)
121
+ grader_ids: nil, db_branch_settings: nil)
122
122
  payload = {
123
123
  "traceFunctionKey" => trace_function_key
124
124
  }
@@ -133,6 +133,7 @@ module Bitfab
133
133
  payload["includeDbBranchLease"] = true if include_db_branch_lease
134
134
  payload["datasetId"] = dataset_id unless dataset_id.nil?
135
135
  payload["graderIds"] = grader_ids unless grader_ids.nil?
136
+ payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
136
137
 
137
138
  # When DB branching is on, the server resolves a Neon preview branch per
138
139
  # item (snapshot + restore + poll), which can run several seconds each.
data/lib/bitfab/replay.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "open3"
6
+ require "timeout"
5
7
 
6
8
  require_relative "constants"
7
9
  require_relative "mock_override"
@@ -134,7 +136,7 @@ module Bitfab
134
136
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
135
137
  max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil,
136
138
  dataset_id: nil, grader_ids: nil, mock: "marked",
137
- adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
139
+ adapt_inputs: nil, mock_override: nil, environment: nil, db_branch: nil, on_progress: nil)
138
140
  unless MOCK_STRATEGIES.include?(mock.to_s)
139
141
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
140
142
  end
@@ -179,6 +181,19 @@ module Bitfab
179
181
 
180
182
  include_db_branch_lease = !environment.nil?
181
183
 
184
+ # An explicit code change always wins; only when the caller passed neither
185
+ # field do we fall back to the payload the replay wrapper captured from git
186
+ # and handed over via BITFAB_CODE_CHANGE_PATH. This is what makes an
187
+ # experiment show its diff even when the run happened outside the
188
+ # assistant's make-change rails.
189
+ if code_change_description.nil? && code_change_files.nil?
190
+ captured = resolve_auto_code_change(name)
191
+ unless captured.nil?
192
+ code_change_description = captured[0]
193
+ code_change_files = captured[1]
194
+ end
195
+ end
196
+
182
197
  replay_data = http_client.start_replay(
183
198
  trace_function_key,
184
199
  effective_limit,
@@ -189,7 +204,8 @@ module Bitfab
189
204
  experiment_group_id:,
190
205
  include_db_branch_lease:,
191
206
  dataset_id:,
192
- grader_ids:
207
+ grader_ids:,
208
+ db_branch_settings: db_branch_settings(db_branch)
193
209
  )
194
210
  test_run_id = replay_data["testRunId"]
195
211
  test_run_url = replay_data["testRunUrl"]
@@ -295,6 +311,202 @@ module Bitfab
295
311
  result
296
312
  end
297
313
 
314
+ # Read the code-change payload the replay wrapper captured and pointed us at
315
+ # via BITFAB_CODE_CHANGE_PATH. Returns [description, files] or nil.
316
+ # Best-effort: any error (no env var, missing file, bad JSON) yields nil so
317
+ # the replay proceeds with no code change rather than raising.
318
+ TRUNK_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"].freeze
319
+ CC_MAX_FILES = 60
320
+ CC_MAX_FILE_BYTES = 500_000
321
+ CC_MAX_TOTAL_BYTES = 2_000_000
322
+
323
+ # Convert the caller's +db_branch+ options to the wire shape, or nil when
324
+ # nothing was set. Dropping the key entirely keeps the request identical to
325
+ # what older SDKs send.
326
+ #
327
+ # Keys: :min_cu and :max_cu are the branch compute's autoscaling floor and
328
+ # ceiling in Neon Compute Units (equal values pin the size, keeping items
329
+ # comparable); :warmup_sql is appended to the branch's readiness check so it
330
+ # warms the cache before the replayed function sees the lease.
331
+ def db_branch_settings(db_branch)
332
+ return nil if db_branch.nil? || db_branch.empty?
333
+
334
+ # Symbol or string keys, matching normalize_code_change_files: a hash
335
+ # loaded from JSON or YAML would otherwise be read as empty, leaving the
336
+ # branch on the mirror's defaults with no error to notice.
337
+ min_cu = db_branch[:min_cu] || db_branch["min_cu"]
338
+ max_cu = db_branch[:max_cu] || db_branch["max_cu"]
339
+ warmup_sql = db_branch[:warmup_sql] || db_branch["warmup_sql"]
340
+
341
+ settings = {}
342
+ settings["minCu"] = min_cu unless min_cu.nil?
343
+ settings["maxCu"] = max_cu unless max_cu.nil?
344
+ settings["warmupSql"] = warmup_sql unless warmup_sql.nil?
345
+ settings.empty? ? nil : settings
346
+ end
347
+
348
+ # Auto-capture the code change to attach when the caller passed none. Lives
349
+ # in replay() (the one call guaranteed to run on every replay) so it works no
350
+ # matter how the replay was launched. Precedence: a BITFAB_CODE_CHANGE_PATH
351
+ # override, else the git diff vs trunk. Best-effort: any failure yields nil.
352
+ # An explicit code_change_files always wins over this.
353
+ def resolve_auto_code_change(label = nil)
354
+ return nil if ENV["BITFAB_DISABLE_CODE_CHANGE_CAPTURE"]
355
+
356
+ from_env = read_code_change_file
357
+ return from_env unless from_env.nil?
358
+
359
+ capture_code_change_from_git(Dir.pwd, label)
360
+ end
361
+
362
+ def read_code_change_file
363
+ path = ENV["BITFAB_CODE_CHANGE_PATH"]
364
+ return nil if path.nil? || path.empty?
365
+
366
+ begin
367
+ parsed = JSON.parse(File.read(path))
368
+ files = parsed["files"]
369
+ # A malformed payload (non-array, or entries that aren't hashes) must
370
+ # yield no code change, never crash normalize_code_change_files.
371
+ files = nil unless files.is_a?(Array) && files.all?(Hash)
372
+ description = parsed["description"]
373
+ description = nil unless description.is_a?(String)
374
+ return nil if files.nil? && description.nil?
375
+
376
+ [description, files]
377
+ rescue
378
+ nil
379
+ end
380
+ end
381
+
382
+ def git(cwd, args)
383
+ # capture3 so git's stderr (e.g. "not a git repository") is swallowed, not
384
+ # leaked to the host process's console.
385
+ out, _err, status = Timeout.timeout(30) do
386
+ Open3.capture3("git", *args, chdir: cwd)
387
+ end
388
+ return nil unless status.success?
389
+
390
+ # Tag as UTF-8 and scrub invalid bytes so blob contents compare cleanly
391
+ # against the UTF-8 working-file reads and never break JSON.generate later
392
+ # (a non-UTF-8 file that slips past the null-byte check would otherwise
393
+ # abort the whole start-replay request).
394
+ out.force_encoding("UTF-8").scrub
395
+ rescue
396
+ nil
397
+ end
398
+
399
+ def cc_ref_exists?(root, ref)
400
+ !git(root, ["rev-parse", "--verify", "#{ref}^{object}"]).nil?
401
+ end
402
+
403
+ # Returns [base, from_trunk]; from_trunk is false on the HEAD fallback.
404
+ def cc_resolve_base(root)
405
+ forced = ENV["BITFAB_CODE_CHANGE_BASE"]
406
+ if forced && cc_ref_exists?(root, forced)
407
+ mb = git(root, ["merge-base", "HEAD", forced])
408
+ return [mb.strip, true] if mb && !mb.strip.empty?
409
+
410
+ rp = git(root, ["rev-parse", "--verify", forced])
411
+ return (rp && !rp.strip.empty?) ? [rp.strip, true] : nil
412
+ end
413
+ TRUNK_CANDIDATES.each do |candidate|
414
+ next unless cc_ref_exists?(root, candidate)
415
+
416
+ mb = git(root, ["merge-base", "HEAD", candidate])
417
+ return [mb.strip, true] if mb && !mb.strip.empty?
418
+ end
419
+ cc_ref_exists?(root, "HEAD") ? ["HEAD", false] : nil
420
+ end
421
+
422
+ def cc_blob_bytes(root, base, path)
423
+ out = git(root, ["cat-file", "-s", "#{base}:#{path}"])
424
+ (out && !out.strip.empty?) ? out.strip.to_i : 0
425
+ rescue
426
+ 0
427
+ end
428
+
429
+ def cc_working_bytes(root, path)
430
+ File.size(File.join(root, path))
431
+ rescue
432
+ 0
433
+ end
434
+
435
+ def capture_code_change_from_git(cwd, label = nil)
436
+ root = git(cwd, ["rev-parse", "--show-toplevel"])
437
+ return nil if root.nil? || root.strip.empty?
438
+
439
+ root = root.strip
440
+ resolved = cc_resolve_base(root)
441
+ return nil if resolved.nil?
442
+
443
+ base, from_trunk = resolved
444
+
445
+ tracked = git(root, ["diff", "--name-status", "--no-renames", "-z", base, "--", ":!.bitfab"]) || ""
446
+ untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":!.bitfab"]) || ""
447
+
448
+ entries = cc_parse_name_status_z(tracked)
449
+ untracked.split("\0").reject(&:empty?).each { |p| entries << ["A", p] }
450
+ return nil if entries.empty?
451
+
452
+ files = []
453
+ total = 0
454
+ entries.each do |status, path|
455
+ break if files.length >= CC_MAX_FILES
456
+
457
+ # Skip oversized files by size BEFORE reading their full contents, so a
458
+ # huge changed file can't OOM/stall replay just to be discarded.
459
+ before_bytes = (status == "A") ? 0 : cc_blob_bytes(root, base, path)
460
+ after_bytes = (status == "D") ? 0 : cc_working_bytes(root, path)
461
+ next if before_bytes > CC_MAX_FILE_BYTES || after_bytes > CC_MAX_FILE_BYTES
462
+
463
+ # Normalize CRLF -> LF on both sides: git's blob is already LF-normalized
464
+ # (autocrlf clean), so a raw CRLF working file would otherwise skew every
465
+ # line as changed. Comparing/storing LF keeps the diff meaningful.
466
+ before = (status == "A") ? "" : (git(root, ["show", "#{base}:#{path}"]) || "").gsub("\r\n", "\n")
467
+ after = (status == "D") ? "" : cc_read_working_file(root, path).gsub("\r\n", "\n")
468
+ next if before == after
469
+
470
+ size = before.bytesize + after.bytesize
471
+ next if total + size > CC_MAX_TOTAL_BYTES ||
472
+ cc_looks_binary?(before) || cc_looks_binary?(after)
473
+
474
+ total += size
475
+ files << {"path" => path, "before" => before, "after" => after}
476
+ end
477
+ return nil if files.empty?
478
+
479
+ subject = git(root, ["log", "-1", "--format=%s", "HEAD"])
480
+ subject = subject ? subject.strip : ""
481
+ head = ((label && !label.strip.empty?) ? label.strip : nil) || (subject.empty? ? nil : subject) || "Working-tree change"
482
+ word = (files.length == 1) ? "file" : "files"
483
+ against = from_trunk ? "vs trunk" : "uncommitted (vs HEAD)"
484
+ ["#{head} (#{files.length} #{word} changed #{against})", files]
485
+ rescue
486
+ nil
487
+ end
488
+
489
+ def cc_parse_name_status_z(raw)
490
+ parts = raw.split("\0").reject(&:empty?)
491
+ out = []
492
+ i = 0
493
+ while i + 1 < parts.length
494
+ out << [parts[i][0], parts[i + 1]]
495
+ i += 2
496
+ end
497
+ out
498
+ end
499
+
500
+ def cc_read_working_file(root, path)
501
+ File.read(File.join(root, path), encoding: "UTF-8")
502
+ rescue
503
+ ""
504
+ end
505
+
506
+ def cc_looks_binary?(str)
507
+ str[0, 8000].include?("\0")
508
+ end
509
+
298
510
  def write_replay_result_file(result)
299
511
  result_path = ENV["BITFAB_REPLAY_RESULT_PATH"]
300
512
  return if result_path.nil? || result_path.empty?
@@ -413,9 +625,19 @@ module Bitfab
413
625
  # (only when include_db_branch_lease was sent). Release it in the +ensure+
414
626
  # below so any raise (span fetch, mock-tree build, or the replayed
415
627
  # method) frees the Neon resource. Items whose source trace had no
416
- # snapshot ref, or whose resolve failed server-side, arrive without a
417
- # lease (env.active? is false for those).
628
+ # snapshot ref arrive without a lease (env.active? is false), and
629
+ # replaying those against the live database is correct.
418
630
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
631
+ # A resolve that was ATTEMPTED and failed is different: the caller asked
632
+ # for a branch, so running their method against live data would produce a
633
+ # result that looks valid and is not. Fail the item instead, loudly.
634
+ lease_error = include_db_branch_lease ? server_item["dbBranchLeaseError"] : nil
635
+ if lease_error
636
+ raise "Replay requested a database branch for trace #{original_trace_id} but it " \
637
+ "could not be resolved (#{lease_error["code"]}): #{lease_error["message"]}. " \
638
+ "The method was not run, because replaying it against the live database would " \
639
+ "produce a result that looks valid but did not use the historical data you asked for."
640
+ end
419
641
 
420
642
  span = http_client.get_external_span(original_span_id)
421
643
  item_data = extract_span_data(span)
@@ -44,6 +44,13 @@ module Bitfab
44
44
  require_snapshot[:read_only]
45
45
  end
46
46
 
47
+ # The branch's region, e.g. "aws-us-east-1". A compute runs in its
48
+ # project's region, so a replay runner elsewhere pays that round trip on
49
+ # every query.
50
+ def region
51
+ require_snapshot[:region]
52
+ end
53
+
47
54
  # The historical trace ID that produced the input for this replay item.
48
55
  def trace_id
49
56
  require_snapshot.fetch(:trace_id)
@@ -95,6 +102,7 @@ module Bitfab
95
102
  expires_at: lease["expiresAt"],
96
103
  provider_console_url: lease["providerConsoleUrl"],
97
104
  read_only: lease["readOnly"],
105
+ region: lease["region"],
98
106
  trace_id:
99
107
  }
100
108
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.30.0"
4
+ VERSION = "0.31.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team