logbrew-sdk 0.1.1 → 0.1.2
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 +4 -4
- data/README.md +211 -3
- data/examples/Makefile +13 -1
- data/examples/automatic_delivery.rb +32 -0
- data/examples/persistent_worker_delivery.rb +33 -0
- data/examples/sidekiq_tracing.rb +22 -0
- data/lib/logbrew/automatic_delivery.rb +493 -0
- data/lib/logbrew/bounded_event_queue.rb +196 -0
- data/lib/logbrew/event_batcher.rb +67 -0
- data/lib/logbrew/faraday_tracing.rb +55 -0
- data/lib/logbrew/http_client_tracing.rb +282 -0
- data/lib/logbrew/operation_tracing.rb +16 -1
- data/lib/logbrew/persistent_event_store.rb +403 -0
- data/lib/logbrew/sidekiq.rb +466 -0
- data/lib/logbrew/span_events.rb +34 -0
- data/lib/logbrew/worker_lifecycle.rb +211 -0
- data/lib/logbrew.rb +357 -33
- metadata +14 -2
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
class DeliveryHealth
|
|
5
|
+
FIELDS = %i[
|
|
6
|
+
state queued_events queued_bytes dropped_events in_flight last_outcome
|
|
7
|
+
consecutive_failures pause_reason successful_flushes failed_flushes retry_delay_ms
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader(*FIELDS)
|
|
11
|
+
|
|
12
|
+
def initialize(**values)
|
|
13
|
+
FIELDS.each { |field| instance_variable_set("@#{field}", values.fetch(field)) }
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
FIELDS.each_with_object({}) do |field, result|
|
|
19
|
+
result[field.to_s] = public_send(field)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class AutomaticDelivery
|
|
25
|
+
COUNTER_MAX = (2**63) - 1
|
|
26
|
+
STATES = %w[idle running retrying paused stopped closing closed].freeze
|
|
27
|
+
OUTCOMES = %w[none empty accepted retryable_failure terminal_failure stopped].freeze
|
|
28
|
+
PAUSE_REASONS = %w[authentication quota validation nonretryable].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :transport
|
|
31
|
+
|
|
32
|
+
def self.validate!(
|
|
33
|
+
transport:,
|
|
34
|
+
flush_interval:,
|
|
35
|
+
flush_threshold:,
|
|
36
|
+
retry_base_delay:,
|
|
37
|
+
retry_max_delay:,
|
|
38
|
+
max_queue_size:
|
|
39
|
+
)
|
|
40
|
+
unless transport.respond_to?(:send)
|
|
41
|
+
raise SdkError.new("validation_error", "automatic transport must respond to send")
|
|
42
|
+
end
|
|
43
|
+
validate_positive_number("flush_interval", flush_interval)
|
|
44
|
+
validate_positive_integer("flush_threshold", flush_threshold)
|
|
45
|
+
if flush_threshold > max_queue_size
|
|
46
|
+
raise SdkError.new("validation_error", "flush_threshold must not exceed max_queue_size")
|
|
47
|
+
end
|
|
48
|
+
validate_positive_number("retry_base_delay", retry_base_delay)
|
|
49
|
+
validate_positive_number("retry_max_delay", retry_max_delay)
|
|
50
|
+
if retry_base_delay > retry_max_delay
|
|
51
|
+
raise SdkError.new("validation_error", "retry_base_delay must not exceed retry_max_delay")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.validate_positive_number(label, value)
|
|
56
|
+
valid = (value.is_a?(Integer) || value.is_a?(Float)) && value.positive?
|
|
57
|
+
valid &&= !value.is_a?(Float) || (!value.nan? && !value.infinite?)
|
|
58
|
+
raise SdkError.new("validation_error", "#{label} must be a positive finite number") unless valid
|
|
59
|
+
end
|
|
60
|
+
private_class_method :validate_positive_number
|
|
61
|
+
|
|
62
|
+
def self.validate_positive_integer(label, value)
|
|
63
|
+
return if value.is_a?(Integer) && value.positive?
|
|
64
|
+
|
|
65
|
+
raise SdkError.new("validation_error", "#{label} must be a positive integer")
|
|
66
|
+
end
|
|
67
|
+
private_class_method :validate_positive_integer
|
|
68
|
+
|
|
69
|
+
def initialize(
|
|
70
|
+
client:,
|
|
71
|
+
transport:,
|
|
72
|
+
flush_interval:,
|
|
73
|
+
flush_threshold:,
|
|
74
|
+
retry_base_delay:,
|
|
75
|
+
retry_max_delay:
|
|
76
|
+
)
|
|
77
|
+
@client = client
|
|
78
|
+
@transport = transport
|
|
79
|
+
@flush_interval = flush_interval.to_f
|
|
80
|
+
@flush_threshold = flush_threshold
|
|
81
|
+
@retry_base_delay = retry_base_delay.to_f
|
|
82
|
+
@retry_max_delay = retry_max_delay.to_f
|
|
83
|
+
@owner_process_id = current_process_id
|
|
84
|
+
@mutex = Mutex.new
|
|
85
|
+
@condition = ConditionVariable.new
|
|
86
|
+
@worker = nil
|
|
87
|
+
@stop_requested = false
|
|
88
|
+
@generation = 0
|
|
89
|
+
@state = "idle"
|
|
90
|
+
@last_outcome = "none"
|
|
91
|
+
@pause_reason = nil
|
|
92
|
+
@in_flight = false
|
|
93
|
+
@consecutive_failures = 0
|
|
94
|
+
@successful_flushes = 0
|
|
95
|
+
@failed_flushes = 0
|
|
96
|
+
@retry_delay = nil
|
|
97
|
+
@retry_deadline = nil
|
|
98
|
+
@next_interval_deadline = nil
|
|
99
|
+
@wake_requested = false
|
|
100
|
+
@observed_queue_count = live_queue_metrics.fetch(:queued_events)
|
|
101
|
+
activate_recovered_work if @observed_queue_count.positive?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def assert_process_ownership!
|
|
105
|
+
return if current_process_id == @owner_process_id
|
|
106
|
+
|
|
107
|
+
raise SdkError.new(
|
|
108
|
+
"process_ownership_error",
|
|
109
|
+
"automatic delivery client must be created in the current process"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def notify_capture
|
|
114
|
+
assert_process_ownership!
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
update_queue_count(live_queue_metrics)
|
|
117
|
+
return if @stop_requested || @state == "paused" || @state == "closed"
|
|
118
|
+
|
|
119
|
+
return unless ensure_worker
|
|
120
|
+
return if @state == "retrying"
|
|
121
|
+
|
|
122
|
+
@next_interval_deadline ||= monotonic_time + @flush_interval
|
|
123
|
+
@wake_requested = true if @observed_queue_count >= @flush_threshold
|
|
124
|
+
@condition.signal
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def queue_changed
|
|
129
|
+
assert_process_ownership!
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
update_queue_count(live_queue_metrics)
|
|
132
|
+
if @observed_queue_count.zero?
|
|
133
|
+
@wake_requested = false
|
|
134
|
+
@next_interval_deadline = nil
|
|
135
|
+
@retry_deadline = nil
|
|
136
|
+
@retry_delay = nil
|
|
137
|
+
end
|
|
138
|
+
@condition.signal
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def begin_explicit_flush
|
|
143
|
+
assert_process_ownership!
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@generation += 1
|
|
146
|
+
@retry_deadline = nil
|
|
147
|
+
@retry_delay = nil
|
|
148
|
+
previous = [@state, @pause_reason, @generation]
|
|
149
|
+
unless @state == "stopped" || @state == "closed"
|
|
150
|
+
@state = "running"
|
|
151
|
+
@in_flight = true
|
|
152
|
+
end
|
|
153
|
+
previous
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def complete_explicit_flush(previous, response)
|
|
158
|
+
@mutex.synchronize do
|
|
159
|
+
@in_flight = false
|
|
160
|
+
update_queue_count(live_queue_metrics)
|
|
161
|
+
return unless previous.fetch(2) == @generation
|
|
162
|
+
|
|
163
|
+
if previous.fetch(0) == "stopped"
|
|
164
|
+
@state = "stopped"
|
|
165
|
+
else
|
|
166
|
+
record_success(response)
|
|
167
|
+
return if @observed_queue_count.positive? && !ensure_worker
|
|
168
|
+
|
|
169
|
+
schedule_pending_work
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def fail_explicit_flush(previous, error)
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
@in_flight = false
|
|
177
|
+
update_queue_count(live_queue_metrics)
|
|
178
|
+
return unless previous.fetch(2) == @generation
|
|
179
|
+
|
|
180
|
+
@failed_flushes = increment(@failed_flushes)
|
|
181
|
+
@last_outcome = retryable_failure?(error) ? "retryable_failure" : "terminal_failure"
|
|
182
|
+
if previous.fetch(0) == "paused"
|
|
183
|
+
@state = "paused"
|
|
184
|
+
@pause_reason = previous.fetch(1)
|
|
185
|
+
@wake_requested = false
|
|
186
|
+
@next_interval_deadline = nil
|
|
187
|
+
@retry_delay = nil
|
|
188
|
+
@retry_deadline = nil
|
|
189
|
+
elsif previous.fetch(0) == "stopped"
|
|
190
|
+
@state = "stopped"
|
|
191
|
+
@wake_requested = false
|
|
192
|
+
else
|
|
193
|
+
record_failure(error)
|
|
194
|
+
ensure_worker if retryable_failure?(error) && @observed_queue_count.positive?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def complete_automatic_flush(generation, response)
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
@in_flight = false
|
|
202
|
+
update_queue_count(live_queue_metrics)
|
|
203
|
+
return unless generation == @generation
|
|
204
|
+
return if @stop_requested || %w[closing closed stopped].include?(@state)
|
|
205
|
+
|
|
206
|
+
record_success(response)
|
|
207
|
+
schedule_pending_work
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fail_automatic_flush(generation, error)
|
|
212
|
+
@mutex.synchronize do
|
|
213
|
+
@in_flight = false
|
|
214
|
+
update_queue_count(live_queue_metrics)
|
|
215
|
+
return unless generation == @generation
|
|
216
|
+
return if @stop_requested || %w[closing closed stopped].include?(@state)
|
|
217
|
+
|
|
218
|
+
@failed_flushes = increment(@failed_flushes)
|
|
219
|
+
@last_outcome = retryable_failure?(error) ? "retryable_failure" : "terminal_failure"
|
|
220
|
+
record_failure(error)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def prepare_shutdown
|
|
225
|
+
assert_process_ownership!
|
|
226
|
+
worker = @mutex.synchronize do
|
|
227
|
+
if @state == "closing" || @state == "closed"
|
|
228
|
+
raise SdkError.new("shutdown_error", "automatic delivery is already shutting down")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@generation += 1
|
|
232
|
+
@state = "closing"
|
|
233
|
+
@stop_requested = true
|
|
234
|
+
@wake_requested = false
|
|
235
|
+
@retry_deadline = nil
|
|
236
|
+
@retry_delay = nil
|
|
237
|
+
@condition.broadcast
|
|
238
|
+
@worker
|
|
239
|
+
end
|
|
240
|
+
join_worker(worker)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def complete_shutdown(response)
|
|
244
|
+
@mutex.synchronize do
|
|
245
|
+
@in_flight = false
|
|
246
|
+
queue_metrics = live_queue_metrics
|
|
247
|
+
update_queue_count(queue_metrics)
|
|
248
|
+
record_success(response)
|
|
249
|
+
@state = "closed"
|
|
250
|
+
@worker = nil
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def resume_after_failed_shutdown(error)
|
|
255
|
+
@mutex.synchronize do
|
|
256
|
+
queue_metrics = live_queue_metrics
|
|
257
|
+
update_queue_count(queue_metrics)
|
|
258
|
+
@stop_requested = false
|
|
259
|
+
@failed_flushes = increment(@failed_flushes)
|
|
260
|
+
@consecutive_failures = increment(@consecutive_failures)
|
|
261
|
+
@worker = nil
|
|
262
|
+
if retryable_failure?(error)
|
|
263
|
+
@state = "retrying"
|
|
264
|
+
@last_outcome = "retryable_failure"
|
|
265
|
+
@pause_reason = nil
|
|
266
|
+
@retry_delay = bounded_retry_delay
|
|
267
|
+
@retry_deadline = monotonic_time + @retry_delay
|
|
268
|
+
ensure_worker if @observed_queue_count.positive?
|
|
269
|
+
@condition.signal
|
|
270
|
+
else
|
|
271
|
+
@state = "paused"
|
|
272
|
+
@last_outcome = "terminal_failure"
|
|
273
|
+
@pause_reason = pause_reason(error)
|
|
274
|
+
@retry_delay = nil
|
|
275
|
+
@retry_deadline = nil
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def stop
|
|
281
|
+
assert_process_ownership!
|
|
282
|
+
worker = @mutex.synchronize do
|
|
283
|
+
return if @state == "stopped" || @state == "closed"
|
|
284
|
+
|
|
285
|
+
@generation += 1
|
|
286
|
+
@state = "stopped"
|
|
287
|
+
@last_outcome = "stopped"
|
|
288
|
+
@stop_requested = true
|
|
289
|
+
@wake_requested = false
|
|
290
|
+
@retry_deadline = nil
|
|
291
|
+
@retry_delay = nil
|
|
292
|
+
@condition.broadcast
|
|
293
|
+
@worker
|
|
294
|
+
end
|
|
295
|
+
join_worker(worker)
|
|
296
|
+
@mutex.synchronize do
|
|
297
|
+
@worker = nil
|
|
298
|
+
@in_flight = false
|
|
299
|
+
end
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def snapshot
|
|
304
|
+
assert_process_ownership!
|
|
305
|
+
@mutex.synchronize do
|
|
306
|
+
queue_metrics = live_queue_metrics
|
|
307
|
+
DeliveryHealth.new(
|
|
308
|
+
state: @state,
|
|
309
|
+
queued_events: queue_metrics.fetch(:queued_events),
|
|
310
|
+
queued_bytes: queue_metrics.fetch(:queued_bytes),
|
|
311
|
+
dropped_events: queue_metrics.fetch(:dropped_events),
|
|
312
|
+
in_flight: @in_flight,
|
|
313
|
+
last_outcome: @last_outcome,
|
|
314
|
+
consecutive_failures: @consecutive_failures,
|
|
315
|
+
pause_reason: @pause_reason,
|
|
316
|
+
successful_flushes: @successful_flushes,
|
|
317
|
+
failed_flushes: @failed_flushes,
|
|
318
|
+
retry_delay_ms: @retry_delay.nil? ? 0 : bounded_milliseconds(@retry_delay)
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def activate_recovered_work
|
|
326
|
+
@mutex.synchronize do
|
|
327
|
+
return unless ensure_worker
|
|
328
|
+
|
|
329
|
+
@wake_requested = true
|
|
330
|
+
@condition.signal
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def ensure_worker
|
|
335
|
+
return false if @stop_requested || @state == "paused" || @state == "closed"
|
|
336
|
+
return true if @worker&.alive?
|
|
337
|
+
|
|
338
|
+
@worker = Thread.new { run }
|
|
339
|
+
@worker.name = "logbrew-delivery" if @worker.respond_to?(:name=)
|
|
340
|
+
@worker.report_on_exception = false if @worker.respond_to?(:report_on_exception=)
|
|
341
|
+
true
|
|
342
|
+
rescue ThreadError
|
|
343
|
+
@worker = nil
|
|
344
|
+
@state = "stopped"
|
|
345
|
+
@last_outcome = "terminal_failure"
|
|
346
|
+
@pause_reason = "nonretryable"
|
|
347
|
+
@stop_requested = true
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def run
|
|
352
|
+
loop do
|
|
353
|
+
generation = wait_for_wake
|
|
354
|
+
return if generation.nil?
|
|
355
|
+
|
|
356
|
+
@client.send(:perform_automatic_flush, self, generation, @transport)
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError
|
|
359
|
+
@mutex.synchronize do
|
|
360
|
+
@in_flight = false
|
|
361
|
+
@state = "stopped" unless @state == "closed"
|
|
362
|
+
@last_outcome = "terminal_failure"
|
|
363
|
+
@pause_reason = "nonretryable"
|
|
364
|
+
@stop_requested = true
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def wait_for_wake
|
|
369
|
+
@mutex.synchronize do
|
|
370
|
+
loop do
|
|
371
|
+
return nil if @stop_requested
|
|
372
|
+
|
|
373
|
+
now = monotonic_time
|
|
374
|
+
retry_ready = !@retry_deadline.nil? && now >= @retry_deadline
|
|
375
|
+
interval_ready = !@next_interval_deadline.nil? && now >= @next_interval_deadline
|
|
376
|
+
if @state != "paused" && (@wake_requested || retry_ready || interval_ready)
|
|
377
|
+
@wake_requested = false
|
|
378
|
+
@retry_deadline = nil
|
|
379
|
+
@retry_delay = nil if retry_ready
|
|
380
|
+
@next_interval_deadline = nil
|
|
381
|
+
@state = retry_ready ? "retrying" : "running"
|
|
382
|
+
@in_flight = true
|
|
383
|
+
return @generation
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
deadline = [@retry_deadline, @next_interval_deadline].compact.min
|
|
387
|
+
timeout = deadline.nil? ? nil : [deadline - now, 0].max
|
|
388
|
+
@condition.wait(@mutex, timeout)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def record_success(response)
|
|
394
|
+
@consecutive_failures = 0
|
|
395
|
+
@pause_reason = nil
|
|
396
|
+
@retry_delay = nil
|
|
397
|
+
@retry_deadline = nil
|
|
398
|
+
@last_outcome = response.batches.zero? ? "empty" : "accepted"
|
|
399
|
+
@successful_flushes = increment(@successful_flushes) if response.batches.positive?
|
|
400
|
+
@state = "idle"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def record_failure(error)
|
|
404
|
+
@consecutive_failures = increment(@consecutive_failures)
|
|
405
|
+
@wake_requested = false
|
|
406
|
+
@next_interval_deadline = nil
|
|
407
|
+
if retryable_failure?(error)
|
|
408
|
+
@pause_reason = nil
|
|
409
|
+
@retry_delay = bounded_retry_delay
|
|
410
|
+
@retry_deadline = monotonic_time + @retry_delay
|
|
411
|
+
@state = "retrying"
|
|
412
|
+
@condition.signal
|
|
413
|
+
else
|
|
414
|
+
@pause_reason = pause_reason(error)
|
|
415
|
+
@retry_delay = nil
|
|
416
|
+
@retry_deadline = nil
|
|
417
|
+
@state = "paused"
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def schedule_pending_work
|
|
422
|
+
if @observed_queue_count.zero?
|
|
423
|
+
@wake_requested = false
|
|
424
|
+
@next_interval_deadline = nil
|
|
425
|
+
elsif @observed_queue_count >= @flush_threshold
|
|
426
|
+
@wake_requested = true
|
|
427
|
+
@next_interval_deadline = nil
|
|
428
|
+
@condition.signal
|
|
429
|
+
else
|
|
430
|
+
@wake_requested = false
|
|
431
|
+
@next_interval_deadline = monotonic_time + @flush_interval
|
|
432
|
+
@condition.signal
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def update_queue_count(queue_metrics)
|
|
437
|
+
@observed_queue_count = queue_metrics.fetch(:queued_events)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def live_queue_metrics
|
|
441
|
+
@client.send(:queue_metrics)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def retryable_failure?(error)
|
|
445
|
+
error.respond_to?(:automatic_retryable?) && error.automatic_retryable?
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def pause_reason(error)
|
|
449
|
+
reason = error.respond_to?(:automatic_pause_reason) ? error.automatic_pause_reason : nil
|
|
450
|
+
return reason if PAUSE_REASONS.include?(reason)
|
|
451
|
+
return "authentication" if error.is_a?(SdkError) && error.code == "unauthenticated"
|
|
452
|
+
return "quota" if error.is_a?(SdkError) && error.code == "rate_limited"
|
|
453
|
+
return "validation" if error.is_a?(SdkError) && error.code == "validation_error"
|
|
454
|
+
|
|
455
|
+
"nonretryable"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def bounded_retry_delay
|
|
459
|
+
exponent = [[@consecutive_failures - 1, 0].max, 30].min
|
|
460
|
+
cap = [@retry_base_delay * (2**exponent), @retry_max_delay].min
|
|
461
|
+
half = cap / 2.0
|
|
462
|
+
half + (Random.rand * half)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def bounded_milliseconds(seconds)
|
|
466
|
+
[[(seconds * 1000).ceil, 0].max, (COUNTER_MAX)].min
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def increment(value)
|
|
470
|
+
value >= COUNTER_MAX ? COUNTER_MAX : value + 1
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def join_worker(worker)
|
|
474
|
+
return if worker.nil? || worker == Thread.current
|
|
475
|
+
|
|
476
|
+
worker.join
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def current_process_id
|
|
480
|
+
process_id = Process.pid
|
|
481
|
+
unless process_id.is_a?(Integer) && process_id.positive?
|
|
482
|
+
raise SdkError.new("process_ownership_error", "automatic delivery process identity is unavailable")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
process_id
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def monotonic_time
|
|
489
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
private_constant :AutomaticDelivery
|
|
493
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
class DroppedEvent
|
|
5
|
+
attr_reader :event_id, :event_type, :reason, :dropped_events,
|
|
6
|
+
:pending_events, :pending_event_bytes
|
|
7
|
+
|
|
8
|
+
def initialize(event_id:, event_type:, reason:, dropped_events:, pending_events:, pending_event_bytes:)
|
|
9
|
+
@event_id = event_id.dup.freeze
|
|
10
|
+
@event_type = event_type.dup.freeze
|
|
11
|
+
@reason = reason.dup.freeze
|
|
12
|
+
@dropped_events = dropped_events
|
|
13
|
+
@pending_events = pending_events
|
|
14
|
+
@pending_event_bytes = pending_event_bytes
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class BoundedEventQueue
|
|
20
|
+
DEFAULT_MAX_SIZE = 1_000
|
|
21
|
+
DEFAULT_MAX_BYTES = 4_194_304
|
|
22
|
+
CALLBACK_GUARD_KEY = :logbrew_drop_callback_guards
|
|
23
|
+
|
|
24
|
+
EventRecord = Struct.new(:json, :bytes, :storage_record)
|
|
25
|
+
Snapshot = Struct.new(:records, :event_count, :event_bytes) do
|
|
26
|
+
def events
|
|
27
|
+
records.map { |record| JSON.parse(record.json) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serialized_events
|
|
31
|
+
records.map(&:json)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
private_constant :EventRecord
|
|
35
|
+
private_constant :Snapshot
|
|
36
|
+
|
|
37
|
+
def initialize(max_size:, max_bytes:, max_event_bytes: max_bytes, on_event_dropped:, event_store: nil)
|
|
38
|
+
validate_positive_integer("max_queue_size", max_size)
|
|
39
|
+
validate_positive_integer("max_queue_bytes", max_bytes)
|
|
40
|
+
validate_positive_integer("max_event_bytes", max_event_bytes)
|
|
41
|
+
unless on_event_dropped.nil? || on_event_dropped.respond_to?(:call)
|
|
42
|
+
raise SdkError.new("validation_error", "on_event_dropped must respond to call")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@max_size = max_size
|
|
46
|
+
@max_bytes = max_bytes
|
|
47
|
+
@max_event_bytes = max_event_bytes
|
|
48
|
+
@on_event_dropped = on_event_dropped
|
|
49
|
+
@event_store = event_store
|
|
50
|
+
stored_records = event_store.nil? ? [] : event_store.records
|
|
51
|
+
@records = stored_records.map do |record|
|
|
52
|
+
EventRecord.new(record.json, record.bytes, record).freeze
|
|
53
|
+
end
|
|
54
|
+
@pending_bytes = @records.sum(&:bytes)
|
|
55
|
+
if @records.length > @max_size || @pending_bytes > @max_bytes || @records.any? { |record| record.bytes > @max_event_bytes }
|
|
56
|
+
raise SdkError.new("persistent_queue_error", "persistent queue exceeds configured bounds")
|
|
57
|
+
end
|
|
58
|
+
@dropped_events = 0
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def enqueue(event_id:, event_type:, event:)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return record_drop(event_id, event_type, "queue_overflow") if @records.length >= @max_size
|
|
65
|
+
|
|
66
|
+
serialized = serialize_event(event)
|
|
67
|
+
event_bytes = serialized.bytesize
|
|
68
|
+
return record_drop(event_id, event_type, "event_too_large") if event_bytes > @max_bytes || event_bytes > @max_event_bytes
|
|
69
|
+
return record_drop(event_id, event_type, "queue_overflow") if @pending_bytes + event_bytes > @max_bytes
|
|
70
|
+
|
|
71
|
+
storage_record = begin
|
|
72
|
+
@event_store&.append(serialized)
|
|
73
|
+
rescue SdkError => error
|
|
74
|
+
raise error if %w[process_ownership_error persistence_commit_error].include?(error.code)
|
|
75
|
+
|
|
76
|
+
return record_drop(event_id, event_type, "persistence_failure")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@records << EventRecord.new(serialized, event_bytes, storage_record).freeze
|
|
80
|
+
@pending_bytes += event_bytes
|
|
81
|
+
unless storage_record.nil? || storage_record.durable
|
|
82
|
+
raise SdkError.new("persistence_commit_error", "persistent queue admission durability is unconfirmed")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def notify_drop(notice)
|
|
90
|
+
return if notice.nil? || @on_event_dropped.nil?
|
|
91
|
+
|
|
92
|
+
guards = Thread.current.thread_variable_get(CALLBACK_GUARD_KEY)
|
|
93
|
+
unless guards
|
|
94
|
+
guards = {}
|
|
95
|
+
Thread.current.thread_variable_set(CALLBACK_GUARD_KEY, guards)
|
|
96
|
+
end
|
|
97
|
+
return if guards[object_id]
|
|
98
|
+
|
|
99
|
+
guards[object_id] = true
|
|
100
|
+
begin
|
|
101
|
+
@on_event_dropped.call(notice)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
ensure
|
|
105
|
+
guards.delete(object_id)
|
|
106
|
+
Thread.current.thread_variable_set(CALLBACK_GUARD_KEY, nil) if guards.empty?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def length
|
|
111
|
+
@mutex.synchronize { @records.length }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pending_bytes
|
|
115
|
+
@mutex.synchronize { @pending_bytes }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def dropped_events
|
|
119
|
+
@mutex.synchronize { @dropped_events }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def metrics
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
{
|
|
125
|
+
queued_events: @records.length,
|
|
126
|
+
queued_bytes: @pending_bytes,
|
|
127
|
+
dropped_events: @dropped_events
|
|
128
|
+
}.freeze
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def snapshot
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
@event_store&.prepare_delivery unless @records.empty?
|
|
135
|
+
Snapshot.new(@records.dup.freeze, @records.length, @pending_bytes).freeze
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def acknowledge(snapshot)
|
|
140
|
+
return nil if snapshot.event_count.zero?
|
|
141
|
+
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
removed = @records.first(snapshot.event_count)
|
|
144
|
+
compaction_error = @event_store&.acknowledge(removed.map(&:storage_record))
|
|
145
|
+
@records.shift(snapshot.event_count)
|
|
146
|
+
@pending_bytes -= removed.sum(&:bytes)
|
|
147
|
+
compaction_error
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def purge
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
return 0 if @records.empty?
|
|
154
|
+
|
|
155
|
+
removed = @records.dup
|
|
156
|
+
compaction_error = @event_store&.acknowledge(removed.map(&:storage_record))
|
|
157
|
+
@records.clear
|
|
158
|
+
@pending_bytes = 0
|
|
159
|
+
raise compaction_error unless compaction_error.nil?
|
|
160
|
+
|
|
161
|
+
removed.length
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def close
|
|
166
|
+
@event_store&.close
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def validate_positive_integer(label, value)
|
|
172
|
+
return if value.is_a?(Integer) && value.positive?
|
|
173
|
+
|
|
174
|
+
raise SdkError.new("validation_error", "#{label} must be a positive integer")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def serialize_event(event)
|
|
178
|
+
JSON.generate(event).freeze
|
|
179
|
+
rescue JSON::GeneratorError, EncodingError
|
|
180
|
+
raise SdkError.new("validation_error", "event must be JSON serializable")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def record_drop(event_id, event_type, reason)
|
|
184
|
+
@dropped_events += 1
|
|
185
|
+
DroppedEvent.new(
|
|
186
|
+
event_id: event_id,
|
|
187
|
+
event_type: event_type,
|
|
188
|
+
reason: reason,
|
|
189
|
+
dropped_events: @dropped_events,
|
|
190
|
+
pending_events: @records.length,
|
|
191
|
+
pending_event_bytes: @pending_bytes
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
private_constant :BoundedEventQueue
|
|
196
|
+
end
|