bitfab 0.16.1 → 0.17.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: 0dfedcfce509695e2bb260c885fcfa460aaab8d9c89421379cd7572cd280eb95
4
- data.tar.gz: e8aaba21b08b4a53c9f89319e7c7a9d9f0a911f0be657058d80fc84add6e4f6e
3
+ metadata.gz: 60fa4998513b5fa9f67950bf4abbbc253b324955732e3726531ca47856544ddb
4
+ data.tar.gz: ad45f27ff4c50a4a75329f48e6ba06f252eb74e425b1d19686b55f6c780ae6a0
5
5
  SHA512:
6
- metadata.gz: 89851a89aae7ad24ac24d03335e3641b2715c5a841dfd76d302effd8ca07723d41cf0de6103884795fa96337c79671ed9ca5776a5b71c1aa805c3a87621a59b5
7
- data.tar.gz: 3fb0c600868f499787e25dc424ce7643ee383f8da1e72fb99ec06a394949fc5ca5f8180e9f40b45791da8f2d4d1b562422bc4921d98643dbefb8c9d03e754893
6
+ metadata.gz: 1dd29ae11022f8cc0ad80b3d59e37d809843b85ab9cb48e0623afd01ee1a2d6b4e99e8ba91c996b7cad1acb589757ccf4a0291673a28d7a54eccffa098d5239e
7
+ data.tar.gz: efaf095342fee4995aa8d45c69773c7d268f5eb23e358606292c7dd3ba11a3958da7416606025f18c8c11b43c295b62ef0bcd80baf753c0070055ae0d3dcc0f3
data/lib/bitfab/client.rb CHANGED
@@ -60,11 +60,11 @@ module Bitfab
60
60
  # @return [Hash] with :items, :test_run_id, :test_run_url
61
61
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
62
62
  code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none",
63
- adapt_inputs: nil)
63
+ adapt_inputs: nil, environment: nil)
64
64
  Replay.run(
65
65
  self, receiver, method_name,
66
66
  trace_function_key:, limit:, trace_ids:, max_concurrency:,
67
- code_change_description:, code_change_files:, experiment_group_id:, mock:, adapt_inputs:
67
+ code_change_description:, code_change_files:, experiment_group_id:, mock:, adapt_inputs:, environment:
68
68
  )
69
69
  end
70
70
 
@@ -315,6 +315,9 @@ module Bitfab
315
315
  if trace_state&.dig(:input_source_trace_id)
316
316
  raw_trace["input_source_trace_id"] = trace_state[:input_source_trace_id]
317
317
  end
318
+ if trace_state&.dig(:db_snapshot_ref)
319
+ raw_trace["db_snapshot_ref"] = trace_state[:db_snapshot_ref]
320
+ end
318
321
 
319
322
  payload = {
320
323
  "type" => "sdk-function",
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Per-trace database snapshot ref capture.
5
+ #
6
+ # Every root trace carries a snapshot ref that pins the DB state at trace
7
+ # open by wall-clock timestamp. Capturing the timestamp is free (no IO) and
8
+ # harmless, so it happens on every trace regardless of configuration: that
9
+ # lets any trace be replayed against a historical branch later. The provider
10
+ # is resolved at replay time. Mirrors the TypeScript and Python SDKs'
11
+ # +DbSnapshotRef+ wire shape (camelCase key, since the server speaks the same
12
+ # protocol for every SDK).
13
+ module DbSnapshot
14
+ module_function
15
+
16
+ # Build a snapshot ref for one trace. Synchronous, no IO.
17
+ #
18
+ # Stores only the wall clock the SDK observed immediately before invoking
19
+ # the wrapped function; the server-side resolver uses that as the snapshot
20
+ # timestamp. No provider is captured (it is resolved at replay time).
21
+ #
22
+ # @param sdk_wall_clock_before_fn [String] ISO wall-clock timestamp the SDK
23
+ # observed immediately before invoking the wrapped function.
24
+ # @return [Hash] snapshot ref with a single camelCase +sdkWallClockBeforeFn+ key.
25
+ def build_snapshot_ref(sdk_wall_clock_before_fn)
26
+ {"sdkWallClockBeforeFn" => sdk_wall_clock_before_fn}
27
+ end
28
+ end
29
+ end
@@ -106,7 +106,7 @@ module Bitfab
106
106
  # @param experiment_group_id [String, nil] optional UUID grouping multiple
107
107
  # replay runs into a single experiment batch
108
108
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
109
- code_change_files: nil, experiment_group_id: nil)
109
+ code_change_files: nil, experiment_group_id: nil, include_db_branch_lease: false)
110
110
  payload = {
111
111
  "traceFunctionKey" => trace_function_key
112
112
  }
@@ -117,8 +117,14 @@ module Bitfab
117
117
  payload["codeChangeDescription"] = code_change_description unless code_change_description.nil?
118
118
  payload["codeChangeFiles"] = normalize_code_change_files(code_change_files) unless code_change_files.nil?
119
119
  payload["experimentGroupId"] = experiment_group_id unless experiment_group_id.nil?
120
-
121
- request("/api/sdk/replay/start", payload, timeout: 30)
120
+ payload["includeDbBranchLease"] = true if include_db_branch_lease
121
+
122
+ # When DB branching is on, the server resolves a Neon preview branch per
123
+ # item (snapshot + restore + poll), which can run several seconds each.
124
+ # Use a generous timeout so the SDK doesn't give up before a healthy
125
+ # server finishes.
126
+ timeout = include_db_branch_lease ? 180 : 30
127
+ request("/api/sdk/replay/start", payload, timeout:)
122
128
  end
123
129
 
124
130
  # Fetch an external span by ID. Blocking GET request.
@@ -142,6 +148,13 @@ module Bitfab
142
148
  request("/api/sdk/replay/complete", {"testRunId" => test_run_id}, timeout: 30)
143
149
  end
144
150
 
151
+ # Release a previously-resolved DB branch by deleting its Neon branch.
152
+ # Blocking call. Idempotent server-side (a missing branch is treated as
153
+ # already released).
154
+ def release_db_branch_lease(neon_branch_id)
155
+ request("/api/sdk/replay/releaseDbBranchLease", {"neonBranchId" => neon_branch_id}, timeout: 30)
156
+ end
157
+
145
158
  # Send an external trace (fire-and-forget in background thread).
146
159
  def send_external_trace(payload)
147
160
  merged = payload.merge("sdkVersion" => VERSION)
data/lib/bitfab/replay.rb CHANGED
@@ -27,7 +27,8 @@ module Bitfab
27
27
  # threads (span uploads + trace completion) so the replay runner can join
28
28
  # them before complete_replay builds the trace-ID mapping.
29
29
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
30
- mock_tree: nil, mock_strategy: nil, pending_persistence: nil)
30
+ mock_tree: nil, mock_strategy: nil, pending_persistence: nil, db_branch_lease: nil,
31
+ source_bitfab_trace_id: nil)
31
32
  previous = Thread.current[REPLAY_CONTEXT_KEY]
32
33
  ctx = {
33
34
  test_run_id:,
@@ -41,6 +42,11 @@ module Bitfab
41
42
  ctx[:mock_strategy] = mock_strategy || "none"
42
43
  ctx[:call_counters] = {}
43
44
  end
45
+ # The per-trace DB branch (resolved server-side) and the Bitfab trace ID
46
+ # it belongs to ride on the context so ReplayEnvironment can read them
47
+ # inside the replayed method.
48
+ ctx[:db_branch_lease] = db_branch_lease if db_branch_lease
49
+ ctx[:source_bitfab_trace_id] = source_bitfab_trace_id if source_bitfab_trace_id
44
50
  Thread.current[REPLAY_CONTEXT_KEY] = ctx
45
51
  yield
46
52
  ensure
@@ -84,7 +90,7 @@ module Bitfab
84
90
  # @return [Hash] with :items, :test_run_id, :test_run_url
85
91
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
86
92
  code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none",
87
- adapt_inputs: nil)
93
+ adapt_inputs: nil, environment: nil)
88
94
  unless MOCK_STRATEGIES.include?(mock.to_s)
89
95
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
90
96
  end
@@ -105,13 +111,16 @@ module Bitfab
105
111
  # the count), so it's omitted from the request entirely.
106
112
  effective_limit = trace_ids ? nil : (limit || 5)
107
113
 
114
+ include_db_branch_lease = !environment.nil?
115
+
108
116
  replay_data = http_client.start_replay(
109
117
  trace_function_key,
110
118
  effective_limit,
111
119
  trace_ids:,
112
120
  code_change_description:,
113
121
  code_change_files:,
114
- experiment_group_id:
122
+ experiment_group_id:,
123
+ include_db_branch_lease:
115
124
  )
116
125
  test_run_id = replay_data["testRunId"]
117
126
  test_run_url = replay_data["testRunUrl"]
@@ -119,7 +128,7 @@ module Bitfab
119
128
 
120
129
  result_items = if server_items.any?
121
130
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
122
- adapt_inputs)
131
+ adapt_inputs, include_db_branch_lease)
123
132
  else
124
133
  []
125
134
  end
@@ -192,12 +201,13 @@ module Bitfab
192
201
 
193
202
  # Process all replay items, optionally in parallel using threads.
194
203
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
195
- adapt_inputs = nil)
204
+ adapt_inputs = nil, include_db_branch_lease = false)
196
205
  concurrency = max_concurrency || server_items.length
197
206
 
198
207
  if concurrency <= 1
199
208
  server_items.map do |item|
200
- process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs)
209
+ process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs,
210
+ include_db_branch_lease)
201
211
  end
202
212
  else
203
213
  results_mutex = Mutex.new
@@ -212,7 +222,7 @@ module Bitfab
212
222
  break unless item
213
223
 
214
224
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
215
- adapt_inputs)
225
+ adapt_inputs, include_db_branch_lease)
216
226
  results_mutex.synchronize { results[idx] = result }
217
227
  end
218
228
  end
@@ -230,8 +240,15 @@ module Bitfab
230
240
  # than propagated, so one bad trace never aborts the whole replay run
231
241
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
232
242
  def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy,
233
- adapt_inputs = nil)
243
+ adapt_inputs = nil, include_db_branch_lease = false)
234
244
  metrics = extract_server_item_metrics(server_item)
245
+ # The server resolves a Neon preview branch per item during /replay/start
246
+ # (only when include_db_branch_lease was sent). Release it in the +ensure+
247
+ # below so any raise — span fetch, mock-tree build, or the replayed
248
+ # method — frees the Neon resource. Items whose source trace had no
249
+ # snapshot ref, or whose resolve failed server-side, arrive without a
250
+ # lease (env.active? is false for those).
251
+ lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
235
252
 
236
253
  span = http_client.get_external_span(server_item["externalSpanId"])
237
254
  item_data = extract_span_data(span)
@@ -255,7 +272,10 @@ module Bitfab
255
272
  mock_strategy:,
256
273
  mock_tree:,
257
274
  adapt_inputs:,
258
- adapt_ctx:
275
+ adapt_ctx:,
276
+ db_branch_lease: lease,
277
+ source_bitfab_trace_id: server_item["traceId"],
278
+ db_snapshot_ref: server_item["dbSnapshotRef"]
259
279
  )
260
280
  rescue => e
261
281
  warn "Bitfab: replay item for span #{server_item["externalSpanId"]} failed before execution: #{e.message}"
@@ -267,8 +287,22 @@ module Bitfab
267
287
  duration_ms: metrics&.dig(:duration_ms),
268
288
  tokens: metrics&.dig(:tokens),
269
289
  model: metrics&.dig(:model),
270
- trace_id: nil
290
+ trace_id: nil,
291
+ db_snapshot_ref: server_item["dbSnapshotRef"]
271
292
  }
293
+ ensure
294
+ release_db_branch_lease(http_client, lease) if lease
295
+ end
296
+
297
+ # Delete the per-item Neon preview branch. Best-effort: a failure is warned
298
+ # but never raised — the server-side TTL janitor reaps orphans.
299
+ def release_db_branch_lease(http_client, lease)
300
+ neon_branch_id = lease["neonBranchId"]
301
+ return unless neon_branch_id
302
+
303
+ http_client.release_db_branch_lease(neon_branch_id)
304
+ rescue => e
305
+ warn "Bitfab: failed to release DB branch #{neon_branch_id} (TTL janitor will catch it): #{e.message}"
272
306
  end
273
307
 
274
308
  # Walk the children of a root span tree node depth-first and build a
@@ -346,7 +380,8 @@ module Bitfab
346
380
 
347
381
  # Execute a single replay item: deserialize inputs, call method with replay context.
348
382
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
349
- input_source_trace_id: nil, mock_strategy: "none", mock_tree: nil, adapt_inputs: nil, adapt_ctx: nil)
383
+ input_source_trace_id: nil, mock_strategy: "none", mock_tree: nil, adapt_inputs: nil, adapt_ctx: nil,
384
+ db_branch_lease: nil, source_bitfab_trace_id: nil, db_snapshot_ref: nil)
350
385
  args, kwargs = Serialize.deserialize_inputs(item)
351
386
 
352
387
  fn_result = nil
@@ -365,7 +400,9 @@ module Bitfab
365
400
  trace_id: sdk_trace_id,
366
401
  mock_tree:,
367
402
  mock_strategy:,
368
- pending_persistence:
403
+ pending_persistence:,
404
+ db_branch_lease:,
405
+ source_bitfab_trace_id:
369
406
  ) do
370
407
  # Reshape recorded inputs onto the current signature when an adapter is
371
408
  # supplied. Inside the rescue so a raising adapter surfaces on this
@@ -397,7 +434,8 @@ module Bitfab
397
434
  duration_ms: metrics[:duration_ms],
398
435
  tokens: metrics[:tokens],
399
436
  model: metrics[:model],
400
- trace_id: sdk_trace_id
437
+ trace_id: sdk_trace_id,
438
+ db_snapshot_ref:
401
439
  }
402
440
  end
403
441
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Per-trace environment exposed to customer code during replay.
5
+ #
6
+ # The customer instantiates one +ReplayEnvironment+ and passes it to
7
+ # +client.replay(environment: ...)+. Inside the replayed method they read
8
+ # +env.database_url+ (and friends) to pick up the per-trace branch URL the
9
+ # Bitfab service resolved from the source trace's snapshot reference.
10
+ #
11
+ # Outside replay, reading +env.database_url+ raises. Customer code uses the
12
+ # env only on the replay path; live request code keeps reading
13
+ # +ENV["DATABASE_URL"]+ the normal way.
14
+ #
15
+ # Concurrency-safe: the readers resolve through the thread-local replay
16
+ # context, so each in-flight replay item sees its own per-trace values even
17
+ # when the SDK runs items across worker threads.
18
+ #
19
+ # Internally the resolved per-item state is a DB branch lease (the SDK <->
20
+ # server protocol term). We expose its useful fields directly here so
21
+ # customer code never sees the word.
22
+ class ReplayEnvironment
23
+ # The per-trace branch URL for the item currently being replayed.
24
+ # Raises if read outside a replay item.
25
+ def database_url
26
+ require_snapshot.fetch(:database_url)
27
+ end
28
+
29
+ # When the per-trace branch URL stops being valid. ISO-8601.
30
+ def expires_at
31
+ require_snapshot.fetch(:expires_at)
32
+ end
33
+
34
+ # Deep link to the branch in the provider console, if available.
35
+ def provider_console_url
36
+ require_snapshot[:provider_console_url]
37
+ end
38
+
39
+ # True if the branch is read-only. Customer code can use this to skip write
40
+ # operations during replay when the provider returned a read-only lease.
41
+ def read_only
42
+ require_snapshot[:read_only]
43
+ end
44
+
45
+ # The historical trace ID that produced the input for this replay item.
46
+ def trace_id
47
+ require_snapshot.fetch(:trace_id)
48
+ end
49
+
50
+ # True when read inside a replay item that has a resolved branch.
51
+ def active?
52
+ !read_snapshot.nil?
53
+ end
54
+
55
+ # Non-raising variant for callers that handle the inactive case. Returns a
56
+ # symbol-keyed hash or nil.
57
+ def snapshot
58
+ read_snapshot
59
+ end
60
+
61
+ private
62
+
63
+ def read_snapshot
64
+ ctx = ReplayContext.current
65
+ return nil unless ctx
66
+
67
+ lease = ctx[:db_branch_lease]
68
+ return nil unless lease
69
+
70
+ # Surface the Bitfab trace ID (what the customer sees in the dashboard),
71
+ # falling back to the external trace ID only if the Bitfab ID is somehow
72
+ # absent — keeps replays from external sources working until the
73
+ # source-system path is fully wired.
74
+ trace_id = ctx[:source_bitfab_trace_id] || ctx[:input_source_trace_id]
75
+ return nil unless trace_id
76
+
77
+ {
78
+ database_url: lease["databaseUrl"],
79
+ expires_at: lease["expiresAt"],
80
+ provider_console_url: lease["providerConsoleUrl"],
81
+ read_only: lease["readOnly"],
82
+ trace_id:
83
+ }
84
+ end
85
+
86
+ def require_snapshot
87
+ snap = read_snapshot
88
+ return snap if snap
89
+
90
+ raise "ReplayEnvironment accessed outside of a replay item. Pass it to " \
91
+ "client.replay(environment: ...) and only read it inside the replayed method."
92
+ end
93
+ end
94
+ end
@@ -133,12 +133,20 @@ module Bitfab
133
133
 
134
134
  def create(trace_id, test_run_id: nil, input_source_trace_id: nil)
135
135
  @states_mutex.synchronize do
136
- @states[trace_id] ||= {
137
- trace_id:,
138
- started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
139
- test_run_id:,
140
- input_source_trace_id:
141
- }.compact
136
+ @states[trace_id] ||= begin
137
+ started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
138
+ {
139
+ trace_id:,
140
+ started_at:,
141
+ test_run_id:,
142
+ input_source_trace_id:,
143
+ # Capture the wall clock now, before the wrapped function runs.
144
+ # Stored on every trace (no IO, harmless) so any trace can later be
145
+ # replayed against a historical branch; the provider is resolved at
146
+ # replay time.
147
+ db_snapshot_ref: DbSnapshot.build_snapshot_ref(started_at)
148
+ }.compact
149
+ end
142
150
  end
143
151
  end
144
152
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.16.1"
4
+ VERSION = "0.17.0"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -3,9 +3,11 @@
3
3
  require_relative "bitfab/version"
4
4
  require_relative "bitfab/constants"
5
5
  require_relative "bitfab/serialize"
6
+ require_relative "bitfab/db_snapshot"
6
7
  require_relative "bitfab/span_context"
7
8
  require_relative "bitfab/http_client"
8
9
  require_relative "bitfab/replay"
10
+ require_relative "bitfab/replay_environment"
9
11
  require_relative "bitfab/client"
10
12
  require_relative "bitfab/traceable"
11
13
 
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.16.1
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -119,8 +119,10 @@ files:
119
119
  - lib/bitfab.rb
120
120
  - lib/bitfab/client.rb
121
121
  - lib/bitfab/constants.rb
122
+ - lib/bitfab/db_snapshot.rb
122
123
  - lib/bitfab/http_client.rb
123
124
  - lib/bitfab/replay.rb
125
+ - lib/bitfab/replay_environment.rb
124
126
  - lib/bitfab/serialize.rb
125
127
  - lib/bitfab/span_context.rb
126
128
  - lib/bitfab/traceable.rb