bitfab 0.57.7 → 0.57.8

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: b92a57be2a0c05885f0f84f757ede302ab14aed53ad422c85ee2781591a39786
4
- data.tar.gz: ddb9b050e2f66e9591c17be161c2ebd25d395b625704baee80e396801f5a9e25
3
+ metadata.gz: 187464e848086662f6d74efe91dfe95476bdae7284d7ba5668f49b2a4af4c606
4
+ data.tar.gz: 16fce40beaf7dc062e5d6d31624d6393730847b1eaee578b60459b5f0d27897a
5
5
  SHA512:
6
- metadata.gz: 0542a3b4dcb64f183ce35f7c9f16ead26e858fa1fefb7889115842e552b4855842ba175bea19893912ec37547708403d115259a201b2e02a1e6cb5ffa84f79f6
7
- data.tar.gz: a530cd08bc1cdbc42467ea640c8f64e3035569c6723032626284bd6397744a21f5149ef0bc2d32af50aab1b1bbb856e8ba7510d1eb3640824de4f6f4e4472c43
6
+ metadata.gz: 7a5f7f9cac1900dca07228819c1c40965cf020deb5adba9f9827c36c76265911e79c5ce3aca16db5e486c354a436f63e9ac487896af9c2cf522581886424887d
7
+ data.tar.gz: 560d27de6a0766f9625b82bcf33053ebfffd3792dbaf2a21cd59ddd7a129742d265965d086d68138566dee51a5ba02893fdcf0bf67a95ebc8d24bc4d37ecf6ff
data/README.md CHANGED
@@ -235,7 +235,7 @@ class OrderPipeline
235
235
  end
236
236
  ```
237
237
 
238
- Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting a span limit stops new capture but allows already-open spans in every Fiber to finish. `max_depth:` (default `30`), `max_captured_subtree_spans:` (default `512`), `max_spans:` (default `2048`), `exclude:`, and `include_wrappers:` control the subtree. `max_spans:` is a strict cap on the total number of spans one trace root sends. It counts the root span itself, discovered calls, `bitfab_node` calls, nested trace roots, and spans whose capture is on or off, including spans sent without inputs and outputs because the sim plan could not be read. Nothing past it is sent. It must be at least 1, and a lower value raises `ArgumentError`. `max_captured_subtree_spans:` caps discovered calls sent with their inputs and outputs. It does not count `bitfab_node` calls, nested trace roots, or spans the loaded sim plan turned capture off for, so those keep recording past it up to `max_spans:`. Spans sent without inputs and outputs only because the sim plan could not be read still count toward it. Past it, further discovered calls with capture on record nothing. It is never higher than `max_spans:`. Once the plan has loaded, the SDK never builds the inputs or outputs of a span whose capture it turned off. The client starts reading the sim plan when it is built. A `bitfab_trace` root that starts while that first read is still running waits for it, for at most five seconds. Once the first read has finished, whether it succeeded or failed, no later root waits. Nested roots and the calls beneath a root never wait, and nothing waits when the plan is disabled. If that first read fails, spans other than each trace's root span are sent without inputs and outputs until a read succeeds, and they still count toward both limits. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_node` when replay must substitute it. The configured node records once in each enclosing trace. Mixing explicit spans with subtree tracing raises `Bitfab::MixedTracingError`.
238
+ Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting a span limit stops new capture but allows already-open spans in every Fiber to finish. `max_depth:` (default `30`), `max_captured_subtree_spans:` (default `500`), `max_spans:` (default `10000`), `exclude:`, and `include_wrappers:` control the subtree. `max_spans:` is a strict cap on the total number of spans one trace root sends. It counts the root span itself, discovered calls, `bitfab_node` calls, nested trace roots, and spans whose capture is on or off, including spans sent without inputs and outputs because the sim plan could not be read. Nothing past it is sent. It must be at least 1, and a lower value raises `ArgumentError`. `max_captured_subtree_spans:` caps discovered calls sent with their inputs and outputs. It does not count `bitfab_node` calls, nested trace roots, or spans the loaded sim plan turned capture off for, so those keep recording past it up to `max_spans:`. Spans sent without inputs and outputs only because the sim plan could not be read still count toward it. Past it, further discovered calls with capture on record nothing. It is never higher than `max_spans:`. Once the plan has loaded, the SDK never builds the inputs or outputs of a span whose capture it turned off. The client starts reading the sim plan when it is built. A `bitfab_trace` root that starts while that first read is still running waits for it, for at most five seconds. Once the first read has finished, whether it succeeded or failed, no later root waits. Nested roots and the calls beneath a root never wait, and nothing waits when the plan is disabled. If that first read fails, spans other than each trace's root span are sent without inputs and outputs until a read succeeds, and they still count toward both limits. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_node` when replay must substitute it. The configured node records once in each enclosing trace. Mixing explicit spans with subtree tracing raises `Bitfab::MixedTracingError`.
239
239
 
240
240
  When capture ends, already-open automatic calls in suspended Fibers are emitted once with a nil output and `Subtree capture ended before the call returned` as the error, unless a real error was already observed. Their inputs and parenting are retained. The SDK does not resume Fibers, and later resumption cannot add spans to the ended capture. If hot reload moves the root to an unresolved source, the last valid first-party boundary and its exclusions remain in use.
241
241
 
data/lib/bitfab/otel.rb CHANGED
@@ -24,7 +24,7 @@ module Bitfab
24
24
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
25
25
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
26
26
  MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
27
- MAX_QUEUE_SIZE = 8_192
27
+ MAX_QUEUE_SIZE = 16_384
28
28
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
29
29
  DIRECT_MAX_REQUEST_BATCH_SIZE = 128
30
30
  DEFAULT_EXPORT_CONCURRENCY = 32
@@ -331,17 +331,17 @@ module Bitfab
331
331
  if e.oversized
332
332
  warn_oversized(batch.spans.size)
333
333
  else
334
- Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
334
+ Bitfab.warn_delivery_problem("failed to export an OpenTelemetry span batch: #{e.message}")
335
335
  end
336
336
  false
337
337
  rescue => e
338
- Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
338
+ Bitfab.warn_delivery_problem("failed to export an OpenTelemetry span batch: #{e.message}")
339
339
  false
340
340
  end
341
341
 
342
342
  def warn_oversized(span_count)
343
343
  subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
344
- Bitfab.warn_always("#{subject} exceeded the ingestion request limit and could not be exported")
344
+ Bitfab.warn_delivery_problem("#{subject} exceeded the ingestion request limit and could not be exported")
345
345
  end
346
346
 
347
347
  def send_with_retries(request)
data/lib/bitfab/replay.rb CHANGED
@@ -1179,7 +1179,8 @@ module Bitfab
1179
1179
  # repeat itself. Acks cannot settle a request that timed out client-side
1180
1180
  # after the server committed, so anything short of fully delivered falls
1181
1181
  # through to the server poll, which stays the authority there.
1182
- def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids)
1182
+ def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids, timeout: nil)
1183
+ timeout ||= PERSISTENCE_TIMEOUT_SECONDS
1183
1184
  trace_ids = sdk_trace_ids.compact
1184
1185
 
1185
1186
  # Checked before flushing: a run whose traces never closed submitted
@@ -1194,7 +1195,7 @@ module Bitfab
1194
1195
  # timeout can fire while requests are still in flight and the server goes
1195
1196
  # on to persist every one of them. Failing here failed runs whose data had
1196
1197
  # fully landed.
1197
- flushed = Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS)
1198
+ flushed = Bitfab.flush_traces(timeout:)
1198
1199
 
1199
1200
  raw_deliveries = http_client.take_trace_deliveries(trace_ids)
1200
1201
  # Server-assigned traces.id per replay trace, read back off the ingest
@@ -1206,7 +1207,7 @@ module Bitfab
1206
1207
  expected_span_counts = deliveries.transform_values(&:span_count)
1207
1208
  return read_back_trace_ids if expected_span_counts.empty? || deliveries.each_value.all?(&:delivered)
1208
1209
 
1209
- deadline = Otel.monotonic_now + PERSISTENCE_TIMEOUT_SECONDS
1210
+ deadline = Otel.monotonic_now + timeout
1210
1211
  missing = expected_span_counts.keys
1211
1212
 
1212
1213
  while missing.any?
@@ -3,9 +3,9 @@
3
3
  module Bitfab
4
4
  class ReplayConcurrency
5
5
  UNSET = Object.new.freeze
6
- attr_reader :attempts, :max_concurrency, :primitive, :memory_throttle, :on_item_finish_in_child_process, :child_timeout
6
+ attr_reader :attempts, :max_concurrency, :primitive, :memory_throttle, :on_item_finish_in_child_process, :child_timeout, :child_delivery_timeout
7
7
 
8
- def initialize(attempts: 1, max_concurrency: UNSET, primitive: "async", memory_throttle: UNSET, on_item_finish_in_child_process: nil, child_timeout: 2400)
8
+ def initialize(attempts: 1, max_concurrency: UNSET, primitive: "async", memory_throttle: UNSET, on_item_finish_in_child_process: nil, child_timeout: 2400, child_delivery_timeout: UNSET)
9
9
  raise ArgumentError, "primitive must be async or process" unless %w[async process].include?(primitive)
10
10
  raise ArgumentError, "attempts must be an integer from 1 to 100" unless attempts.is_a?(Integer) && attempts.between?(1, 100)
11
11
  max_concurrency = (primitive == "process") ? 4 : 10 if max_concurrency.equal?(UNSET)
@@ -24,11 +24,31 @@ module Bitfab
24
24
  end
25
25
  raise ArgumentError, "child_timeout must be a positive finite number" unless child_timeout.is_a?(Numeric) && child_timeout.finite? && child_timeout.positive?
26
26
  @child_timeout = child_timeout
27
+ @child_delivery_timeout = resolve_child_delivery_timeout(child_delivery_timeout, primitive)
27
28
  @attempts, @max_concurrency, @primitive = attempts, max_concurrency, primitive
28
29
  @memory_throttle, @on_item_finish_in_child_process = memory_throttle, on_item_finish_in_child_process
29
30
  freeze
30
31
  end
31
32
 
33
+ private
34
+
35
+ def resolve_child_delivery_timeout(timeout, primitive)
36
+ if timeout.equal?(UNSET)
37
+ return (primitive == "process") ? Replay::PERSISTENCE_TIMEOUT_SECONDS : nil
38
+ end
39
+ return nil if timeout.nil? && primitive != "process"
40
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
41
+ raise ArgumentError, "child_delivery_timeout must be a positive number of seconds (got #{timeout.inspect})"
42
+ end
43
+ if primitive != "process"
44
+ raise ArgumentError, "child_delivery_timeout bounds how long a child process waits for its spans to reach Bitfab, " \
45
+ "and only primitive: process creates one. Under primitive: async the run's own delivery wait applies."
46
+ end
47
+ timeout
48
+ end
49
+
50
+ public
51
+
32
52
  def self.resolve(concurrency, attempts, max_concurrency)
33
53
  if concurrency
34
54
  raise ArgumentError, "concurrency must be a ReplayConcurrency" unless concurrency.is_a?(self)
@@ -42,37 +42,57 @@ module Bitfab
42
42
  end
43
43
  http = client.instance_variable_get(:@http_client)
44
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]]
45
+ delivery_timeout = options[:concurrency]&.child_delivery_timeout || Replay::PERSISTENCE_TIMEOUT_SECONDS
46
+ result = nil
47
+ Bitfab.collect_delivery_problems do
48
+ result = Replay.process_single_item(http, assignment.fetch("item"), receiver, method_name,
49
+ assignment.fetch("test_run_id"), assignment.fetch("mock"), options[:adapt_inputs],
50
+ assignment.fetch("include_db_branch_lease"), assignment["db_branch_settings"],
51
+ mock_overrides: overrides, dry_run: assignment.fetch("dry_run"))
52
+ unless assignment["dry_run"]
53
+ begin
54
+ mapped = Replay.wait_for_replay_persistence(http, assignment.fetch("test_run_id"), [result[:_sdk_trace_id]], timeout: delivery_timeout)
55
+ result[:trace_id] = mapped[result[:_sdk_trace_id]] || result[:trace_id]
56
+ if result[:_sdk_trace_id] && result[:trace_id].nil?
57
+ status = http.get_replay_status(assignment.fetch("test_run_id"), {result[:_sdk_trace_id] => 1})
58
+ result[:trace_id] = status.fetch("traceIds", {})[result[:_sdk_trace_id]]
59
+ end
60
+ raise "Child replay trace persistence could not be confirmed" if result[:error].nil? && result[:trace_id].nil?
61
+ rescue => error
62
+ result[:replay_error] = error
63
+ result[:error] = error.message
56
64
  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
65
  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}"
66
+ write_result(assignment.fetch("result_path"), result)
67
+ hook = options[:concurrency]&.on_item_finish_in_child_process
68
+ if hook && !assignment["dry_run"]
69
+ begin
70
+ hook.call({test_run_id: assignment.fetch("test_run_id"), item: Replay.public_replay_items([result]).first})
71
+ rescue => error
72
+ stderr.puts "[replay] child grading hook failed for #{result[:original_trace_id]} attempt #{result[:attempt]}: #{error}"
73
+ end
70
74
  end
75
+ unless client.close(timeout: delivery_timeout) || assignment["dry_run"]
76
+ Bitfab.count_delivery_problem("delivery was not confirmed within child_delivery_timeout (#{delivery_timeout}s). " \
77
+ "Spans that failed or were still sending when the child exited may never reach Bitfab.")
78
+ end
79
+ write_delivery_problems(assignment["delivery_problems_path"], result, Bitfab.delivery_problem_counts, stderr)
71
80
  end
72
- client.close(timeout: 5)
73
81
  result
74
82
  end
75
83
 
84
+ def write_delivery_problems(path, result, problems, stderr)
85
+ return if problems.empty?
86
+ label = "#{result[:original_trace_id]}##{result[:attempt]}"
87
+ report = problems.map do |message, count|
88
+ "[replay] span delivery problem for #{label}: #{message}#{" (x#{count})" if count > 1}\n"
89
+ end.join
90
+ stderr.write(report)
91
+ File.write(path, report, mode: "w", perm: 0o600) if path
92
+ rescue SystemCallError
93
+ nil
94
+ end
95
+
76
96
  def write_result(path, result)
77
97
  encoded = begin
78
98
  Marshal.dump(result)
@@ -116,7 +136,8 @@ module Bitfab
116
136
  result_path = File.join(@directory, "#{index}.result")
117
137
  assignment_path = File.join(@directory, "#{index}.json")
118
138
  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:}
139
+ delivery_problems_path = File.join(@directory, "#{index}.delivery")
140
+ assignment = {pipeline: @args.fetch(:pipeline), params: @params, item:, test_run_id:, mock:, include_db_branch_lease:, db_branch_settings:, dry_run:, result_path:, delivery_problems_path:}
120
141
  File.write(assignment_path, JSON.generate(assignment), mode: "w", perm: 0o600)
121
142
  executable = File.expand_path("../../exe/bitfab-replay", __dir__)
122
143
  pid = @mutex.synchronize do
@@ -154,6 +175,8 @@ module Bitfab
154
175
  end
155
176
  child_errors = read_tail(stderr_path)
156
177
  @mutex.synchronize { @stderr.write(child_errors) } unless child_errors.empty?
178
+ delivery_problems = File.file?(delivery_problems_path) ? File.read(delivery_problems_path) : ""
179
+ @mutex.synchronize { @stderr.write(delivery_problems) } unless delivery_problems.empty? || child_errors.include?(delivery_problems)
157
180
  unless File.file?(result_path)
158
181
  output_tail = read_tail(stdout_path)
159
182
  @mutex.synchronize { @stderr.write(output_tail) } unless output_tail.empty?
@@ -220,7 +220,8 @@ module Bitfab
220
220
  end,
221
221
  memory_throttle: args.fetch(:memory_throttle, configured ? configured.memory_throttle : ReplayConcurrency::UNSET),
222
222
  on_item_finish_in_child_process: configured&.on_item_finish_in_child_process,
223
- child_timeout: configured&.child_timeout || 2400
223
+ child_timeout: configured&.child_timeout || 2400,
224
+ child_delivery_timeout: configured&.child_delivery_timeout || ReplayConcurrency::UNSET
224
225
  )
225
226
  ReplayProcesses.current&.configure(registry, options[:concurrency]) if options[:concurrency].primitive == "process"
226
227
  end
@@ -10,8 +10,8 @@ module Bitfab
10
10
 
11
11
  module Subtree
12
12
  DEFAULT_MAX_DEPTH = 30
13
- DEFAULT_MAX_SPANS = 2048
14
- DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 512
13
+ DEFAULT_MAX_SPANS = 10_000
14
+ DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 500
15
15
  LIMIT_ORDER = %i[max_depth max_spans max_captured_subtree_spans].freeze
16
16
  SESSION_KEY = :bitfab_subtree_session
17
17
  DISPATCHER_KEY = :bitfab_subtree_dispatcher
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.57.7"
4
+ VERSION = "0.57.8"
5
5
  end
@@ -12,6 +12,8 @@ module Bitfab
12
12
  # warns once, not just the first one seen.
13
13
  @warn_once_seen = Set.new
14
14
  @warn_once_mutex = Mutex.new
15
+ @delivery_problems = nil
16
+ @delivery_problems_mutex = Mutex.new
15
17
 
16
18
  class << self
17
19
  def warn_once(key, message)
@@ -37,6 +39,27 @@ module Bitfab
37
39
  # Logging must never crash the host app (e.g. a closed $stderr).
38
40
  end
39
41
 
42
+ def warn_delivery_problem(message)
43
+ count_delivery_problem(message)
44
+ warn_always(message)
45
+ end
46
+
47
+ def count_delivery_problem(message)
48
+ @delivery_problems_mutex.synchronize { @delivery_problems[message] += 1 if @delivery_problems }
49
+ end
50
+
51
+ def delivery_problem_counts
52
+ @delivery_problems_mutex.synchronize { @delivery_problems ? @delivery_problems.dup : {} }
53
+ end
54
+
55
+ def collect_delivery_problems
56
+ problems = Hash.new(0)
57
+ @delivery_problems_mutex.synchronize { @delivery_problems = problems }
58
+ yield problems
59
+ ensure
60
+ @delivery_problems_mutex.synchronize { @delivery_problems = nil }
61
+ end
62
+
40
63
  # Test-only: clear the dedup set so a warning can fire again.
41
64
  def _reset_warn_once
42
65
  @warn_once_mutex.synchronize { @warn_once_seen.clear }
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.57.7
4
+ version: 0.57.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team