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.
@@ -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