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,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
class PersistentEventStore
|
|
5
|
+
EVENT_FILE = /\A(\d{20})\.event\z/.freeze
|
|
6
|
+
TEMP_FILE = /\A\.tmp-[0-9a-f]{32}\z/.freeze
|
|
7
|
+
LOCK_FILE = ".lock"
|
|
8
|
+
ACK_FILE = ".ack"
|
|
9
|
+
|
|
10
|
+
StoredRecord = Struct.new(:sequence, :json, :bytes, :durable) do
|
|
11
|
+
def initialize(sequence, json, bytes, durable = true)
|
|
12
|
+
super(sequence, json.dup.freeze, bytes, durable)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.open(path:)
|
|
18
|
+
validate_path(path)
|
|
19
|
+
new(path)
|
|
20
|
+
rescue SdkError
|
|
21
|
+
raise
|
|
22
|
+
rescue StandardError
|
|
23
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not be opened")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_path(path)
|
|
27
|
+
unless path.is_a?(String) && !path.empty? && File.absolute_path(path) == path && File.expand_path(path) == path
|
|
28
|
+
raise SdkError.new("validation_error", "persistent_queue_path must be a normalized absolute path")
|
|
29
|
+
end
|
|
30
|
+
rescue ArgumentError
|
|
31
|
+
raise SdkError.new("validation_error", "persistent_queue_path must be a normalized absolute path")
|
|
32
|
+
end
|
|
33
|
+
private_class_method :validate_path
|
|
34
|
+
|
|
35
|
+
def initialize(path)
|
|
36
|
+
@path = path.dup.freeze
|
|
37
|
+
@owner_process_id = current_process_id
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@closed = false
|
|
40
|
+
@failed = false
|
|
41
|
+
@directory_sync_pending = false
|
|
42
|
+
@records = []
|
|
43
|
+
@acknowledged_sequence = 0
|
|
44
|
+
@next_sequence = 1
|
|
45
|
+
@directory_io = nil
|
|
46
|
+
@directory_identity = nil
|
|
47
|
+
@lock_io = nil
|
|
48
|
+
|
|
49
|
+
prepare_directory
|
|
50
|
+
open_directory
|
|
51
|
+
acquire_lock
|
|
52
|
+
recover
|
|
53
|
+
rescue StandardError
|
|
54
|
+
close_resources
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
private_class_method :new
|
|
58
|
+
|
|
59
|
+
def records
|
|
60
|
+
assert_process_ownership
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
ensure_usable
|
|
63
|
+
assert_directory_identity
|
|
64
|
+
@records.dup.freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepare_delivery
|
|
69
|
+
assert_process_ownership
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
ensure_usable
|
|
72
|
+
assert_directory_identity
|
|
73
|
+
confirm_directory_sync
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def append(json)
|
|
79
|
+
assert_process_ownership
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
ensure_usable
|
|
82
|
+
assert_directory_identity
|
|
83
|
+
confirm_directory_sync
|
|
84
|
+
validate_event_json(json)
|
|
85
|
+
sequence = @next_sequence
|
|
86
|
+
directory_synced = write_atomic(event_file_name(sequence), json)
|
|
87
|
+
record = StoredRecord.new(sequence, json, json.bytesize, directory_synced)
|
|
88
|
+
@records << record
|
|
89
|
+
@next_sequence += 1
|
|
90
|
+
record
|
|
91
|
+
rescue SdkError => error
|
|
92
|
+
@failed = true unless error.code == "persistence_commit_error"
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError
|
|
95
|
+
@failed = true
|
|
96
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not store an event")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns a content-free compaction error only after the accepted marker is durable.
|
|
101
|
+
def acknowledge(records)
|
|
102
|
+
assert_process_ownership
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
ensure_usable
|
|
105
|
+
assert_directory_identity
|
|
106
|
+
return nil if records.empty?
|
|
107
|
+
|
|
108
|
+
confirm_directory_sync
|
|
109
|
+
validate_prefix(records)
|
|
110
|
+
accepted_sequence = records.last.sequence
|
|
111
|
+
marker_synced = write_atomic(ACK_FILE, "#{accepted_sequence}\n")
|
|
112
|
+
unless marker_synced
|
|
113
|
+
raise SdkError.new("persistent_queue_error", "persistent queue acknowledgement durability is incomplete")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@acknowledged_sequence = accepted_sequence
|
|
117
|
+
@records.shift(records.length)
|
|
118
|
+
compact_records(records)
|
|
119
|
+
rescue SdkError
|
|
120
|
+
raise
|
|
121
|
+
rescue StandardError
|
|
122
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not acknowledge events")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def close
|
|
127
|
+
assert_process_ownership
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
return if @closed
|
|
130
|
+
|
|
131
|
+
@closed = true
|
|
132
|
+
close_resources
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def prepare_directory
|
|
140
|
+
if File.exist?(@path) || File.symlink?(@path)
|
|
141
|
+
validate_directory(File.lstat(@path))
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
parent = File.dirname(@path)
|
|
146
|
+
parent_stat = File.lstat(parent)
|
|
147
|
+
unless parent_stat.directory?
|
|
148
|
+
raise SdkError.new("validation_error", "persistent queue parent directory must exist")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Dir.mkdir(@path, 0o700)
|
|
152
|
+
validate_directory(File.lstat(@path))
|
|
153
|
+
rescue Errno::ENOENT
|
|
154
|
+
raise SdkError.new("validation_error", "persistent queue parent directory must exist")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_directory(stat)
|
|
158
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.uid && (stat.mode & 0o777) == 0o700
|
|
159
|
+
raise SdkError.new("persistent_queue_error", "persistent queue must use an owner-only dedicated directory")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def open_directory
|
|
164
|
+
@directory_io = File.open(@path, File::RDONLY | File::NOFOLLOW)
|
|
165
|
+
directory_stat = @directory_io.stat
|
|
166
|
+
validate_directory(directory_stat)
|
|
167
|
+
@directory_identity = [directory_stat.dev, directory_stat.ino].freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def acquire_lock
|
|
171
|
+
assert_directory_identity
|
|
172
|
+
lock_path = File.join(@path, LOCK_FILE)
|
|
173
|
+
@lock_io = File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
174
|
+
validate_private_file(@lock_io.stat)
|
|
175
|
+
unless @lock_io.flock(File::LOCK_EX | File::LOCK_NB)
|
|
176
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is already in use")
|
|
177
|
+
end
|
|
178
|
+
rescue Errno::EWOULDBLOCK, Errno::EAGAIN
|
|
179
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is already in use")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def recover
|
|
183
|
+
assert_directory_identity
|
|
184
|
+
entries = Dir.children(@path)
|
|
185
|
+
unexpected = entries.reject do |entry|
|
|
186
|
+
entry == LOCK_FILE || entry == ACK_FILE || EVENT_FILE.match?(entry) || TEMP_FILE.match?(entry)
|
|
187
|
+
end
|
|
188
|
+
unless unexpected.empty?
|
|
189
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unexpected entries")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
entries.each { |entry| validate_known_entry(entry) }
|
|
193
|
+
@acknowledged_sequence = read_acknowledged_sequence
|
|
194
|
+
|
|
195
|
+
cleaned = false
|
|
196
|
+
entries.grep(TEMP_FILE).each do |entry|
|
|
197
|
+
File.unlink(File.join(@path, entry))
|
|
198
|
+
cleaned = true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
event_entries = entries.grep(EVENT_FILE).sort
|
|
202
|
+
recovered = []
|
|
203
|
+
event_entries.each do |entry|
|
|
204
|
+
sequence = Integer(EVENT_FILE.match(entry)[1], 10)
|
|
205
|
+
if sequence <= @acknowledged_sequence
|
|
206
|
+
File.unlink(File.join(@path, entry))
|
|
207
|
+
cleaned = true
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
json = read_event(entry)
|
|
212
|
+
recovered << StoredRecord.new(sequence, json, json.bytesize)
|
|
213
|
+
end
|
|
214
|
+
sync_directory if cleaned
|
|
215
|
+
|
|
216
|
+
@records = recovered
|
|
217
|
+
latest_sequence = [@acknowledged_sequence, recovered.empty? ? 0 : recovered.last.sequence].max
|
|
218
|
+
@next_sequence = latest_sequence + 1
|
|
219
|
+
rescue SdkError
|
|
220
|
+
raise
|
|
221
|
+
rescue StandardError
|
|
222
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_known_entry(entry)
|
|
226
|
+
path = File.join(@path, entry)
|
|
227
|
+
stat = File.lstat(path)
|
|
228
|
+
validate_private_file(stat)
|
|
229
|
+
rescue Errno::ENOENT
|
|
230
|
+
raise SdkError.new("persistent_queue_error", "persistent queue changed during recovery")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_private_file(stat)
|
|
234
|
+
unless stat.file? && !stat.symlink? && stat.uid == Process.uid && (stat.mode & 0o077).zero?
|
|
235
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unsafe files")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def read_acknowledged_sequence
|
|
240
|
+
path = File.join(@path, ACK_FILE)
|
|
241
|
+
return 0 unless File.exist?(path)
|
|
242
|
+
|
|
243
|
+
content = read_private_file(path)
|
|
244
|
+
unless /\A\d{1,20}\n?\z/.match?(content)
|
|
245
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
Integer(content, 10)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def read_event(entry)
|
|
252
|
+
json = read_private_file(File.join(@path, entry))
|
|
253
|
+
json.force_encoding(Encoding::UTF_8)
|
|
254
|
+
validate_event_json(json)
|
|
255
|
+
json.freeze
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def read_private_file(path)
|
|
259
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
260
|
+
validate_private_file(file.stat)
|
|
261
|
+
file.read
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_event_json(json)
|
|
266
|
+
unless json.is_a?(String) && json.encoding == Encoding::UTF_8 && json.valid_encoding? && !json.empty?
|
|
267
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
event = JSON.parse(json)
|
|
271
|
+
valid = event.is_a?(Hash) && event["type"].is_a?(String) && !event["type"].empty? &&
|
|
272
|
+
event["timestamp"].is_a?(String) && !event["timestamp"].empty? &&
|
|
273
|
+
event["id"].is_a?(String) && !event["id"].empty? && event["attributes"].is_a?(Hash)
|
|
274
|
+
unless valid && JSON.generate(event) == json
|
|
275
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
276
|
+
end
|
|
277
|
+
rescue JSON::ParserError, JSON::GeneratorError, EncodingError
|
|
278
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def validate_prefix(records)
|
|
282
|
+
expected = @records.first(records.length)
|
|
283
|
+
unless expected.length == records.length && expected.map(&:sequence) == records.map(&:sequence)
|
|
284
|
+
raise SdkError.new("persistent_queue_error", "persistent queue acknowledgement is stale")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def compact_records(records)
|
|
289
|
+
compaction_failed = false
|
|
290
|
+
records.each do |record|
|
|
291
|
+
begin
|
|
292
|
+
File.unlink(File.join(@path, event_file_name(record.sequence)))
|
|
293
|
+
rescue Errno::ENOENT
|
|
294
|
+
nil
|
|
295
|
+
rescue StandardError
|
|
296
|
+
compaction_failed = true
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
sync_directory
|
|
302
|
+
rescue StandardError
|
|
303
|
+
compaction_failed = true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if compaction_failed
|
|
307
|
+
SdkError.new("persistent_queue_error", "persistent queue accepted-prefix compaction is incomplete")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def write_atomic(destination_name, content)
|
|
312
|
+
assert_directory_identity
|
|
313
|
+
temp_name = ".tmp-#{SecureRandom.hex(16)}"
|
|
314
|
+
temp_path = File.join(@path, temp_name)
|
|
315
|
+
destination_path = File.join(@path, destination_name)
|
|
316
|
+
renamed = false
|
|
317
|
+
|
|
318
|
+
begin
|
|
319
|
+
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
320
|
+
file.write(content)
|
|
321
|
+
file.flush
|
|
322
|
+
file.fsync
|
|
323
|
+
end
|
|
324
|
+
assert_directory_identity
|
|
325
|
+
File.rename(temp_path, destination_path)
|
|
326
|
+
renamed = true
|
|
327
|
+
begin
|
|
328
|
+
sync_directory
|
|
329
|
+
true
|
|
330
|
+
rescue StandardError
|
|
331
|
+
@directory_sync_pending = true
|
|
332
|
+
false
|
|
333
|
+
end
|
|
334
|
+
ensure
|
|
335
|
+
File.unlink(temp_path) if !renamed && File.exist?(temp_path)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def sync_directory
|
|
340
|
+
@directory_io.fsync
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def confirm_directory_sync
|
|
344
|
+
return unless @directory_sync_pending
|
|
345
|
+
|
|
346
|
+
sync_directory
|
|
347
|
+
@directory_sync_pending = false
|
|
348
|
+
rescue StandardError
|
|
349
|
+
raise SdkError.new("persistence_commit_error", "persistent queue durability is unconfirmed")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def assert_directory_identity
|
|
353
|
+
stat = File.lstat(@path)
|
|
354
|
+
current_identity = [stat.dev, stat.ino]
|
|
355
|
+
unless current_identity == @directory_identity
|
|
356
|
+
raise SdkError.new("persistent_queue_error", "persistent queue directory changed while in use")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
validate_directory(stat)
|
|
360
|
+
rescue SdkError
|
|
361
|
+
raise
|
|
362
|
+
rescue StandardError
|
|
363
|
+
raise SdkError.new("persistent_queue_error", "persistent queue directory changed while in use")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def event_file_name(sequence)
|
|
367
|
+
format("%020d.event", sequence)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def ensure_usable
|
|
371
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is closed") if @closed
|
|
372
|
+
raise SdkError.new("persistent_queue_error", "persistent queue requires recovery") if @failed
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def assert_process_ownership
|
|
376
|
+
return if current_process_id == @owner_process_id
|
|
377
|
+
|
|
378
|
+
raise SdkError.new("process_ownership_error", "persistent queue must be opened in the current process")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def current_process_id
|
|
382
|
+
process_id = Process.pid
|
|
383
|
+
unless process_id.is_a?(Integer) && process_id.positive?
|
|
384
|
+
raise SdkError.new("process_ownership_error", "persistent queue process identity is unavailable")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
process_id
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def close_resources
|
|
391
|
+
if @lock_io
|
|
392
|
+
@lock_io.flock(File::LOCK_UN) rescue nil
|
|
393
|
+
@lock_io.close rescue nil
|
|
394
|
+
@lock_io = nil
|
|
395
|
+
end
|
|
396
|
+
if @directory_io
|
|
397
|
+
@directory_io.close rescue nil
|
|
398
|
+
@directory_io = nil
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
private_constant :PersistentEventStore
|
|
403
|
+
end
|