debugbundle 2.0.1 → 2.1.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +127 -0
  3. data/lib/debugbundle/before_send.rb +58 -30
  4. data/lib/debugbundle/bounded_http_response.rb +24 -0
  5. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  6. data/lib/debugbundle/client.rb +41 -67
  7. data/lib/debugbundle/client_event_support.rb +11 -12
  8. data/lib/debugbundle/client_evidence_support.rb +169 -0
  9. data/lib/debugbundle/client_exception_support.rb +54 -0
  10. data/lib/debugbundle/client_process_support.rb +5 -12
  11. data/lib/debugbundle/client_queue_support.rb +59 -4
  12. data/lib/debugbundle/config.rb +15 -2
  13. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  14. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  15. data/lib/debugbundle/event_batch.rb +48 -0
  16. data/lib/debugbundle/evidence_delivery.rb +50 -0
  17. data/lib/debugbundle/evidence_graph.rb +73 -0
  18. data/lib/debugbundle/evidence_json.rb +55 -0
  19. data/lib/debugbundle/evidence_resources.rb +60 -0
  20. data/lib/debugbundle/evidence_value.rb +107 -0
  21. data/lib/debugbundle/evidence_wire.rb +193 -0
  22. data/lib/debugbundle/exception_evidence.rb +179 -0
  23. data/lib/debugbundle/framework_operation.rb +13 -0
  24. data/lib/debugbundle/operation_context.rb +90 -0
  25. data/lib/debugbundle/rack/middleware.rb +24 -21
  26. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  27. data/lib/debugbundle/rails/railtie.rb +4 -1
  28. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  29. data/lib/debugbundle/relay/forwarder.rb +83 -0
  30. data/lib/debugbundle/relay/handler.rb +83 -19
  31. data/lib/debugbundle/remote_config.rb +5 -0
  32. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  33. data/lib/debugbundle/telemetry_privacy.rb +9 -1
  34. data/lib/debugbundle/transport.rb +43 -17
  35. data/lib/debugbundle/version.rb +1 -1
  36. data/lib/debugbundle.rb +11 -1
  37. metadata +19 -2
@@ -9,11 +9,12 @@ module DebugBundle
9
9
 
10
10
  def capture_enabled?
11
11
  ensure_current_process!
12
- config.enabled? && config.configured?
12
+ !@closed && config.enabled? && config.configured?
13
13
  end
14
14
 
15
15
  def merge_context(context)
16
- merged = @context.merge(stringify_hash(context || {}))
16
+ scoped = @operations.current&.fetch(:correlation) || {}
17
+ merged = @context.merge(scoped).merge(stringify_hash(context || {}))
17
18
  @redactor.redact_value(merged)
18
19
  end
19
20
 
@@ -46,7 +47,13 @@ module DebugBundle
46
47
  }
47
48
  end
48
49
 
49
- def runtime_payload = Runtime.payload
50
+ def runtime_payload
51
+ snapshot = Runtime.payload
52
+ # Engine metadata belongs in the existing extensible runtime block;
53
+ # unknown top-level runtime keys are rejected by installed acceptors.
54
+ extras = snapshot.slice('engine', 'engine_version')
55
+ snapshot.except('engine', 'engine_version').merge('framework_extras' => extras)
56
+ end
50
57
 
51
58
  def exception_causes(error)
52
59
  causes = []
@@ -144,15 +151,7 @@ module DebugBundle
144
151
  { 'value' => '[REDACTED]' }
145
152
  end
146
153
 
147
- def apply_before_send(event)
148
- protected = protect_event(event)
149
- return nil unless protected
150
-
151
- prepared = BeforeSend.apply(protected, config.before_send)
152
- prepared && protect_event(prepared)
153
- end
154
-
155
- def protect_event(event)
154
+ def protect_event_fields(event)
156
155
  return nil unless TelemetryPrivacy.safe_event_identity?(event, additional_fields: config.redact_fields)
157
156
 
158
157
  fields = TelemetryPrivacy.protect(
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'debugbundle/diagnostic_budget'
4
+ require 'debugbundle/evidence_delivery'
5
+ require 'debugbundle/exception_evidence'
6
+ require 'debugbundle/operation_context'
7
+ require 'debugbundle/event_batch'
8
+
9
+ module DebugBundle
10
+ class Client
11
+ module EvidenceSupport
12
+ def with_operation_context(kind:, name: nil, limit_ms: nil, correlation: nil)
13
+ ensure_current_process!
14
+ unless @closed
15
+ lease = @operations.begin_scope(kind: kind, name: name, limit_ms: limit_ms, correlation: correlation)
16
+ end
17
+ yield
18
+ ensure
19
+ @operations.end_scope(lease)
20
+ end
21
+
22
+ def capture_recovery(observation = nil, context: nil, **options)
23
+ return unless capture_enabled? && recovery_allowed?
24
+
25
+ return if observation && !options.empty?
26
+
27
+ input = EvidenceValue.options(observation || options)
28
+ payload = EvidenceWire.recovery(input, @operations.scope)
29
+ return unless payload
30
+
31
+ event = base_event('diagnostic_context', payload, merge_context(context))
32
+ return unless EvidenceWire.valid_optional?(event)
33
+
34
+ enqueue_event(event)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ def apply_before_send(event)
42
+ protected = protect_event(event)
43
+ return nil unless protected
44
+
45
+ prepared = BeforeSend.apply(protected, config.before_send)
46
+ return nil unless prepared
47
+
48
+ result = protect_event(prepared)
49
+ return result if prepared.equal?(protected)
50
+
51
+ result if result && BeforeSend.valid?(result)
52
+ end
53
+
54
+ def protect_event(event)
55
+ protected = protect_event_fields(event)
56
+ return fit_protected_event(protected) if protected && EvidenceWire.valid_optional?(protected)
57
+
58
+ legacy = EvidenceWire.without_optional(event)
59
+ fit_protected_event(legacy && protect_event_fields(legacy))
60
+ rescue StandardError
61
+ legacy = EvidenceWire.without_optional(event)
62
+ fit_protected_event(legacy && protect_event_fields(legacy))
63
+ end
64
+
65
+ def fit_protected_event(event)
66
+ return event if @transport.is_a?(Transport::FileTransport)
67
+
68
+ EventBatch.fit(event)
69
+ end
70
+
71
+ def initialize_evidence
72
+ @evidence = EvidenceDelivery.new(config.evidence_capabilities)
73
+ @operations = OperationContext.new
74
+ @diagnostics = DiagnosticBudget.new
75
+ end
76
+
77
+ def recovery_allowed?
78
+ config.capture_recovery_observations && @evidence.supports?('recovery_v1') &&
79
+ @capture_policy.preset != 'minimal' && @capture_policy.capture_diagnostic_context != false
80
+ end
81
+
82
+ def evidence_context(context)
83
+ return context unless @evidence.supports?('recovery_v1') || @evidence.supports?('resource_observations_v1')
84
+
85
+ reference = EvidenceWire.capture_reference(context, @operations.scope)
86
+ reference ? context.except('failure_ref', 'evidence_scope').merge('evidence_reference' => reference) : context
87
+ end
88
+
89
+ def enrich_exception(event, retained, causes, fatal:)
90
+ return event unless config.capture_exception_evidence && @evidence.supports?('exception_evidence_v1')
91
+
92
+ metadata = {}
93
+ execution = @operations.snapshot
94
+ metadata['execution'] = execution if execution
95
+ reference = EvidenceWire.reference(event.dig('context', 'evidence_reference'))
96
+ metadata['evidence_reference'] = reference if reference
97
+ maximum = (fatal ? 4096 : 32_768) - EvidenceJson.bytes(metadata) - 32
98
+ graph = ExceptionEvidence.from_retained(root: retained, causes: causes, fatal: fatal, budget_bytes: maximum)
99
+ event['payload']['exception_evidence'] = graph if graph
100
+ event['payload']['execution'] = execution if execution
101
+ event
102
+ rescue StandardError
103
+ event
104
+ end
105
+
106
+ def filter_evidence(event)
107
+ features = @evidence.features
108
+ features -= ['exception_evidence_v1'] unless config.capture_exception_evidence
109
+ EvidenceWire.supported(event, features)
110
+ end
111
+
112
+ def reserve_diagnostic_locked(event, bytes)
113
+ event['event_type'] != 'diagnostic_context' ||
114
+ (recovery_allowed? && event.dig('payload', 'kind') == 'recovery' &&
115
+ @diagnostics.reserve(event['event_id'], event['payload'], bytes, now.to_f))
116
+ end
117
+
118
+ def reconcile_diagnostic(original, replacement)
119
+ @buffer_mutex.synchronize do
120
+ original_id = original['event_type'] == 'diagnostic_context' ? original['event_id'] : nil
121
+ if replacement['event_type'] == 'diagnostic_context'
122
+ return false unless recovery_allowed? && replacement.dig('payload', 'kind') == 'recovery'
123
+
124
+ payload = replacement['payload']
125
+ size = JSON.generate(replacement).bytesize
126
+ return original_id ? @diagnostics.replace(original_id, replacement['event_id'], payload, size,
127
+ now.to_f) : @diagnostics.reserve(replacement['event_id'],
128
+ payload, size, now.to_f)
129
+ end
130
+ @diagnostics.release([original_id]) if original_id
131
+ if %w[backend_exception frontend_exception].include?(replacement['event_type'])
132
+ reference = EvidenceWire.reference(replacement.dig('context', 'evidence_reference'))
133
+ if reference
134
+ @diagnostics.register_failure(reference, replacement['event_id'],
135
+ Time.iso8601(replacement['occurred_at']).to_f)
136
+ end
137
+ end
138
+ true
139
+ end
140
+ end
141
+
142
+ def release_diagnostics_locked(event, finalized = nil)
143
+ ids = []
144
+ ids << event['event_id'] if event['event_type'] == 'diagnostic_context'
145
+ ids << finalized['event_id'] if finalized && finalized['event_type'] == 'diagnostic_context'
146
+ @diagnostics.release(ids)
147
+ end
148
+
149
+ def downgrade_evidence_errors(batch, decision, lost)
150
+ return [] unless lost
151
+
152
+ decision.fetch(:terminal_errors).filter_map do |index, reason|
153
+ next unless reason == 'invalid_event'
154
+
155
+ original = batch.fetch(index)
156
+ finalized = @buffer_mutex.synchronize { @finalized_events[original['event_id']] || original }
157
+ next unless %w[backend_exception frontend_exception].include?(finalized['event_type'])
158
+
159
+ reduced = filter_evidence(finalized)
160
+ next if reduced == finalized
161
+
162
+ original if cache_finalized_event(original, reduced)
163
+ end
164
+ end
165
+ end
166
+
167
+ include EvidenceSupport
168
+ end
169
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ class Client
5
+ module ExceptionSupport
6
+ private
7
+
8
+ def capture_exception_internal(error, context:, handled:, run_before_send:)
9
+ return unless capture_enabled?
10
+ return unless preflight_capacity?(3, 'exception')
11
+
12
+ request_remote_config_poll_if_due
13
+
14
+ merged_context = evidence_context(merge_context(context))
15
+ payload = {
16
+ 'name' => safe_exception_name(error),
17
+ 'message' => safe_exception_message(error),
18
+ 'stack' => safe_exception_stack(error),
19
+ 'handled' => handled,
20
+ 'request' => request_payload(merged_context['request']),
21
+ 'response' => response_payload(merged_context['response']),
22
+ 'runtime' => runtime_payload
23
+ }
24
+
25
+ causes = exception_causes(error)
26
+ retained = payload.slice('name', 'message', 'stack')
27
+ payload['message'] = payload['name'] if payload['message'].strip.empty?
28
+ payload['stack'] = "#{payload['name']}: #{payload['message']}" if payload['stack'].empty?
29
+
30
+ probe_data = probe_snapshot
31
+ payload['probe_data'] = probe_data unless probe_data.empty?
32
+
33
+ extra_context = merged_context.dup
34
+ extra_context['causes'] = causes unless causes.empty?
35
+
36
+ event = base_event('backend_exception', payload, extra_context)
37
+
38
+ event_payload = event.fetch('payload')
39
+ suppression_key = [
40
+ event['event_type'],
41
+ event_payload['name'],
42
+ event_payload['message'],
43
+ event_payload['stack']
44
+ ].join(':')
45
+ return unless @suppression.should_capture(suppression_key, now: monotonic_now)
46
+
47
+ event = enrich_exception(event, retained, causes, fatal: !run_before_send)
48
+ enqueue_event(event, skip_before_send: !run_before_send)
49
+ end
50
+ end
51
+
52
+ include ExceptionSupport
53
+ end
54
+ end
@@ -13,19 +13,11 @@ module DebugBundle
13
13
  inherited_probes_enabled = @remote_config.probes_enabled
14
14
  # A fork inherits buffers, locks, and an unusable sender thread. Reset them
15
15
  # before touching any inherited synchronization object or captured event.
16
- @buffer = []
17
- @buffer_bytes = 0
18
- @buffer_priority_counts = [0, 0, 0, 0]
19
- @inflight_event_ids = Set.new
20
- @inflight_priority_counts = [0, 0, 0, 0]
21
- @event_sizes = {}
22
- @pressure_drops = {}
23
- @hook_bypass = Set.new
24
- @finalized_events = {}
25
- @buffer_mutex = Mutex.new
26
- @flush_mutex = Mutex.new
16
+ initialize_buffer_ownership
27
17
  @probe_buffers = {}
28
18
  @context = {}
19
+ @operations&.clear
20
+ initialize_evidence
29
21
  @suppression = Suppression::Tracker.new
30
22
  @last_event_at = nil
31
23
  @retry_at = nil
@@ -41,7 +33,8 @@ module DebugBundle
41
33
  @delivery_worker = nil
42
34
  @config_worker = nil
43
35
  @owner_pid = current_pid
44
- return unless config.enabled? && config.configured?
36
+ @closed = @retired
37
+ return unless !@closed && config.enabled? && config.configured?
45
38
 
46
39
  @delivery_worker = DeliveryWorker.new(
47
40
  interval: config.flush_interval,
@@ -5,6 +5,20 @@ module DebugBundle
5
5
  module QueueSupport
6
6
  private
7
7
 
8
+ def initialize_buffer_ownership
9
+ @buffer = []
10
+ @buffer_bytes = 0
11
+ @buffer_priority_counts = [0, 0, 0, 0]
12
+ @inflight_event_ids = Set.new
13
+ @inflight_priority_counts = [0, 0, 0, 0]
14
+ @event_sizes = {}
15
+ @pressure_drops = {}
16
+ @hook_bypass = Set.new
17
+ @finalized_events = {}
18
+ @buffer_mutex = Mutex.new
19
+ @flush_mutex = Mutex.new
20
+ end
21
+
8
22
  def preflight_capacity?(priority, kind)
9
23
  @buffer_mutex.synchronize do
10
24
  full = @buffer.length >= MAX_BUFFER_SIZE || @buffer_bytes >= MAX_BUFFER_BYTES
@@ -23,7 +37,10 @@ module DebugBundle
23
37
 
24
38
  event_size = JSON.generate(event).bytesize
25
39
  admitted, buffered = @buffer_mutex.synchronize do
40
+ next [false, @buffer.length] unless reserve_diagnostic_locked(event, event_size)
41
+
26
42
  unless admit_event_locked?(event, event_size)
43
+ release_diagnostics_locked(event)
27
44
  record_pressure_drop_locked(event) if record_pressure
28
45
  next [false, @buffer.length]
29
46
  end
@@ -118,7 +135,8 @@ module DebugBundle
118
135
  @buffer_priority_counts[event_priority(event)] -= 1
119
136
  @inflight_priority_counts[event_priority(event)] -= 1 if @inflight_event_ids.delete?(event_id)
120
137
  @hook_bypass.delete(event_id)
121
- @finalized_events.delete(event_id)
138
+ finalized = @finalized_events.delete(event_id)
139
+ release_diagnostics_locked(event, finalized)
122
140
  end
123
141
 
124
142
  def evictable_event_index(priority)
@@ -130,9 +148,13 @@ module DebugBundle
130
148
  def cache_finalized_event(event, finalized)
131
149
  # The original stays available for acknowledgement identity and retries, so
132
150
  # a distinct hook replacement owns additional bytes until removal.
151
+ finalized = event if finalized == event
133
152
  extra_bytes = finalized.equal?(event) ? 0 : JSON.generate(finalized).bytesize
134
153
  @buffer_mutex.synchronize do
135
- while @buffer_bytes + extra_bytes > MAX_BUFFER_BYTES
154
+ previous = @finalized_events[event['event_id']]
155
+ old_extra = previous && !previous.equal?(event) ? JSON.generate(previous).bytesize : 0
156
+ delta = extra_bytes - old_extra
157
+ while @buffer_bytes + delta > MAX_BUFFER_BYTES
136
158
  index = evictable_event_index(event_priority(event))
137
159
  unless index
138
160
  record_pressure_drop_locked(event)
@@ -142,8 +164,8 @@ module DebugBundle
142
164
  remove_event_ownership_locked(removed)
143
165
  record_pressure_drop_locked(removed)
144
166
  end
145
- @buffer_bytes += extra_bytes
146
- @event_sizes[event['event_id']] += extra_bytes
167
+ @buffer_bytes += delta
168
+ @event_sizes[event['event_id']] += delta
147
169
  @finalized_events[event['event_id']] = finalized
148
170
  end
149
171
  end
@@ -156,11 +178,44 @@ module DebugBundle
156
178
  end
157
179
  end
158
180
 
181
+ def prepare_wire_batch(candidates)
182
+ prepared = []
183
+ size = EventBatch::WRAPPER_BYTES
184
+ limit = @transport.is_a?(Transport::FileTransport) ? nil : EventBatch::MAX_BODY_BYTES
185
+ candidates.each do |event|
186
+ finalized = finalized_event_for(event)
187
+ unless finalized
188
+ remove_buffered_events([event])
189
+ next
190
+ end
191
+
192
+ cost = EventBatch.bytes(finalized) + (prepared.empty? ? 0 : 1)
193
+ break if limit && size + cost > limit
194
+
195
+ prepared << [event, finalized]
196
+ size += cost
197
+ end
198
+ prepared
199
+ end
200
+
159
201
  def release_buffered_batch
160
202
  @buffer_mutex.synchronize do
161
203
  @inflight_event_ids.clear
162
204
  @inflight_priority_counts = [0, 0, 0, 0]
205
+ discard_closed_buffer_locked if @closed
206
+ end
207
+ end
208
+
209
+ def discard_closed_buffer_locked
210
+ # A stopped sender discards queued work. An active send keeps ownership
211
+ # charged until its ensure block releases the last snapshot.
212
+ @buffer.reject! do |event|
213
+ next false if @inflight_event_ids.include?(event['event_id'])
214
+
215
+ remove_event_ownership_locked(event)
216
+ true
163
217
  end
218
+ @diagnostics = DiagnosticBudget.new if @buffer.empty?
164
219
  end
165
220
 
166
221
  def buffered_batch
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'debugbundle/evidence_wire'
4
+
3
5
  module DebugBundle
4
6
  class Config
5
7
  DEFAULT_ENDPOINT = 'https://api.debugbundle.com/v1/events'
@@ -40,7 +42,10 @@ module DebugBundle
40
42
  :max_probe_entries_per_label,
41
43
  :probe_flush_on_error,
42
44
  :probes_poll_interval,
43
- :before_send
45
+ :before_send,
46
+ :capture_exception_evidence,
47
+ :capture_recovery_observations,
48
+ :evidence_capabilities
44
49
 
45
50
  def initialize(
46
51
  project_token: nil,
@@ -63,7 +68,10 @@ module DebugBundle
63
68
  max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
64
69
  probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
65
70
  probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL,
66
- before_send: nil
71
+ before_send: nil,
72
+ capture_exception_evidence: true,
73
+ capture_recovery_observations: false,
74
+ evidence_capabilities: nil
67
75
  )
68
76
  @project_token = project_token
69
77
  @enabled = enabled
@@ -92,6 +100,11 @@ module DebugBundle
92
100
  @probe_flush_on_error = probe_flush_on_error
93
101
  @probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
94
102
  @before_send = before_send.respond_to?(:call) ? before_send : nil
103
+ @capture_exception_evidence = !false.equal?(capture_exception_evidence)
104
+ @capture_recovery_observations = true.equal?(capture_recovery_observations)
105
+ @evidence_capabilities = EvidenceWire.descriptor(EvidenceValue.options(evidence_capabilities))
106
+ @evidence_capabilities['features'].freeze
107
+ @evidence_capabilities.freeze
95
108
  freeze
96
109
  end
97
110
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module DebugBundle
6
+ # Caller holds the destination's buffer mutex. Metadata and pending serialized
7
+ # ownership share a 64 KiB budget; ACK/retry cannot renew an expired window.
8
+ class DiagnosticBudget
9
+ def initialize
10
+ @windows = {}
11
+ @pending = {}
12
+ @held = 0
13
+ end
14
+
15
+ def accounted_bytes = @held + @windows.values.sum { |window| window[:cost] }
16
+
17
+ def key(payload)
18
+ scope = payload.fetch('scope')
19
+ JSON.generate([payload.fetch('failure_ref'), scope.fetch('kind'), scope['operation_id'] || scope['page_id']])
20
+ end
21
+
22
+ def metadata_cost(scope, window) = JSON.generate([scope, window.except(:cost)]).bytesize
23
+
24
+ def prune(now)
25
+ @windows.delete_if { |_key, window| window[:pending].zero? && now >= window[:starts] + 300 }
26
+ end
27
+
28
+ def window(now)
29
+ { starts: now, failure_at: nil, failure_id: nil, attempts: [], observations: 0,
30
+ resource: false, ambiguous: false, pending: 0, cost: 0 }
31
+ end
32
+
33
+ def register_failure(payload, event_id, now)
34
+ return false unless now.is_a?(Numeric) && now.finite?
35
+
36
+ prune(now)
37
+ scope = key(payload)
38
+ previous = @windows[scope]
39
+ if previous && previous[:failure_id] && previous[:failure_id] != event_id
40
+ previous[:ambiguous] = true
41
+ previous[:cost] = metadata_cost(scope, previous)
42
+ return false
43
+ end
44
+ return false if previous.nil? && @windows.length >= 50
45
+
46
+ value = previous ? previous.dup : window(now)
47
+ value[:failure_id] = event_id
48
+ value[:failure_at] ||= now
49
+ store(scope, previous, value)
50
+ end
51
+
52
+ def store(scope, previous, value, extra = 0)
53
+ value[:cost] = metadata_cost(scope, value)
54
+ return false if accounted_bytes - (previous ? previous[:cost] : 0) + value[:cost] + extra > 65_536
55
+
56
+ @windows[scope] = value
57
+ !value[:ambiguous]
58
+ end
59
+
60
+ def reserve(event_id, payload, bytes, now)
61
+ return false unless valid_size?(bytes) && now.is_a?(Numeric) && now.finite? && !@pending.key?(event_id)
62
+
63
+ prune(now)
64
+ scope = key(payload)
65
+ previous = @windows[scope]
66
+ return false if (previous.nil? && @windows.length >= 50) || (previous && previous[:ambiguous])
67
+
68
+ value = previous ? previous.merge(attempts: previous[:attempts].dup) : window(now)
69
+ return false if now < value[:starts] || now > (value[:failure_at] || value[:starts]) + 300
70
+ return false if value[:observations] >= 20 || !admit_kind?(value, payload, now)
71
+
72
+ value[:pending] += 1
73
+ value[:observations] += 1
74
+ cost = bytes + event_id.bytesize + scope.bytesize + 64
75
+ return false unless store(scope, previous, value, cost)
76
+
77
+ @pending[event_id] =
78
+ { scope: scope, kind: payload['kind'], attempt: payload['attempt_id'], size: bytes, cost: cost }
79
+ @held += cost
80
+ true
81
+ end
82
+
83
+ def admit_kind?(value, payload, now)
84
+ if payload['kind'] == 'recovery'
85
+ attempt = payload['attempt_id']
86
+ return false if !value[:attempts].include?(attempt) && value[:attempts].length >= 5
87
+
88
+ value[:attempts] |= [attempt]
89
+ else
90
+ return false if value[:resource] || value[:failure_id] != payload['source_event_id']
91
+ return false unless value[:failure_at] && now <= value[:failure_at] + 30
92
+
93
+ value[:resource] = true
94
+ end
95
+ true
96
+ end
97
+
98
+ def replace(original_id, event_id, payload, bytes, now)
99
+ entry = @pending[original_id]
100
+ same_scope = entry && entry[:scope] == key(payload)
101
+ unless same_scope && entry[:kind] == payload['kind'] && entry[:attempt] == payload['attempt_id']
102
+ release([original_id])
103
+ return reserve(event_id, payload, bytes, now)
104
+ end
105
+ if event_id != original_id
106
+ if @pending.key?(event_id)
107
+ release([original_id])
108
+ return false
109
+ end
110
+ @pending[event_id] = @pending.delete(original_id)
111
+ delta = event_id.bytesize - original_id.bytesize
112
+ entry[:cost] += delta
113
+ @held += delta
114
+ end
115
+ if !adjust(event_id, bytes) || !eligible(event_id, payload)
116
+ release([event_id])
117
+ return false
118
+ end
119
+ true
120
+ end
121
+
122
+ def adjust(event_id, bytes)
123
+ entry = @pending[event_id]
124
+ return false unless entry && valid_size?(bytes) && accounted_bytes + bytes - entry[:size] <= 65_536
125
+
126
+ @held += bytes - entry[:size]
127
+ entry[:cost] += bytes - entry[:size]
128
+ entry[:size] = bytes
129
+ true
130
+ end
131
+
132
+ def eligible(event_id, payload)
133
+ entry = @pending[event_id]
134
+ value = @windows[key(payload)]
135
+ entry && entry[:scope] == key(payload) && value && !value[:ambiguous] &&
136
+ (payload['kind'] == 'recovery' || value[:failure_id] == payload['source_event_id'])
137
+ end
138
+
139
+ def release(event_ids)
140
+ event_ids.compact.uniq.each do |id|
141
+ entry = @pending.delete(id)
142
+ next unless entry
143
+
144
+ @held -= entry[:cost]
145
+ value = @windows[entry[:scope]]
146
+ next unless value
147
+
148
+ value[:pending] = [value[:pending] - 1, 0].max
149
+ value[:cost] = metadata_cost(entry[:scope], value)
150
+ end
151
+ end
152
+
153
+ def valid_size?(size) = size.is_a?(Integer) && size.between?(0, 65_536)
154
+ end
155
+ end