flagkit 1.0.1

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,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+
7
+ module FlagKit
8
+ module Core
9
+ # Crash-resilient event persistence using write-ahead logging (WAL).
10
+ # Events are persisted to disk before being queued for sending to prevent
11
+ # data loss during unexpected process termination.
12
+ class EventPersistence
13
+ # Event status constants
14
+ STATUS_PENDING = "pending"
15
+ STATUS_SENDING = "sending"
16
+ STATUS_SENT = "sent"
17
+ STATUS_FAILED = "failed"
18
+
19
+ # Default buffer size before flushing to disk
20
+ DEFAULT_BUFFER_SIZE = 100
21
+
22
+ # Default retention period for sent events (24 hours in seconds)
23
+ DEFAULT_RETENTION_PERIOD = 86_400
24
+
25
+ attr_reader :storage_path, :max_events, :flush_interval
26
+
27
+ # @param storage_path [String] Directory for event storage
28
+ # @param max_events [Integer] Maximum events to persist
29
+ # @param flush_interval [Integer] Milliseconds between disk writes
30
+ # @param logger [Object, nil] Logger instance
31
+ def initialize(storage_path:, max_events:, flush_interval:, logger: nil)
32
+ @storage_path = storage_path
33
+ @max_events = max_events
34
+ @flush_interval = flush_interval
35
+ @logger = logger
36
+ @buffer = []
37
+ @buffer_mutex = Mutex.new
38
+ @file_mutex = Mutex.new
39
+ @closed = false
40
+ @flush_thread = nil
41
+ @buffer_size = DEFAULT_BUFFER_SIZE
42
+
43
+ ensure_storage_directory
44
+ start_flush_thread
45
+ end
46
+
47
+ # Persists an event to the buffer.
48
+ # Events are written to disk when the buffer is full or on flush interval.
49
+ #
50
+ # @param event [Hash] The event to persist
51
+ # @return [String] The event ID
52
+ def persist(event)
53
+ return nil if @closed
54
+
55
+ event_id = event[:id] || event["id"] || generate_event_id
56
+ persisted_event = {
57
+ id: event_id,
58
+ type: event[:type] || event["type"],
59
+ data: event[:data] || event["data"],
60
+ timestamp: event[:timestamp] || event["timestamp"] || (Time.now.to_f * 1000).to_i,
61
+ status: STATUS_PENDING
62
+ }
63
+
64
+ should_flush = false
65
+
66
+ @buffer_mutex.synchronize do
67
+ @buffer << persisted_event
68
+ should_flush = @buffer.size >= @buffer_size
69
+ end
70
+
71
+ flush if should_flush
72
+
73
+ event_id
74
+ end
75
+
76
+ # Flushes buffered events to disk with file locking.
77
+ #
78
+ # @return [Boolean] Whether the flush was successful
79
+ def flush
80
+ events_to_write = nil
81
+
82
+ @buffer_mutex.synchronize do
83
+ return true if @buffer.empty?
84
+
85
+ events_to_write = @buffer.dup
86
+ @buffer.clear
87
+ end
88
+
89
+ write_events_to_disk(events_to_write)
90
+ rescue StandardError => e
91
+ log(:error, "Failed to flush events to disk: #{e.message}")
92
+ # Re-add events to buffer on failure
93
+ @buffer_mutex.synchronize do
94
+ @buffer = events_to_write + @buffer
95
+ end
96
+ false
97
+ end
98
+
99
+ # Marks events as sent after successful batch send.
100
+ #
101
+ # @param event_ids [Array<String>] IDs of events to mark as sent
102
+ # @return [Boolean] Whether the operation was successful
103
+ def mark_sent(event_ids)
104
+ return true if event_ids.empty?
105
+
106
+ update_event_status(event_ids, STATUS_SENT, sent_at: (Time.now.to_f * 1000).to_i)
107
+ end
108
+
109
+ # Marks events as sending (in-flight).
110
+ #
111
+ # @param event_ids [Array<String>] IDs of events to mark as sending
112
+ # @return [Boolean] Whether the operation was successful
113
+ def mark_sending(event_ids)
114
+ return true if event_ids.empty?
115
+
116
+ update_event_status(event_ids, STATUS_SENDING)
117
+ end
118
+
119
+ # Marks events as pending (e.g., after failed send).
120
+ #
121
+ # @param event_ids [Array<String>] IDs of events to mark as pending
122
+ # @return [Boolean] Whether the operation was successful
123
+ def mark_pending(event_ids)
124
+ return true if event_ids.empty?
125
+
126
+ update_event_status(event_ids, STATUS_PENDING)
127
+ end
128
+
129
+ # Recovers pending and sending events on startup.
130
+ # Events marked as "sending" are treated as crashed mid-send and recovered.
131
+ #
132
+ # @return [Array<Hash>] Recovered events
133
+ def recover
134
+ recovered_events = []
135
+
136
+ @file_mutex.synchronize do
137
+ event_files.each do |file_path|
138
+ events_by_id = {}
139
+
140
+ with_file_lock(file_path, File::LOCK_SH) do |file|
141
+ file.each_line do |line|
142
+ next if line.strip.empty?
143
+
144
+ begin
145
+ event = JSON.parse(line.strip, symbolize_names: true)
146
+
147
+ # Handle status update entries
148
+ if event[:status] && event[:id] && !event[:type]
149
+ if events_by_id[event[:id]]
150
+ events_by_id[event[:id]][:status] = event[:status]
151
+ end
152
+ else
153
+ events_by_id[event[:id]] = event
154
+ end
155
+ rescue JSON::ParserError => e
156
+ log(:warn, "Skipping corrupted event line: #{e.message}")
157
+ end
158
+ end
159
+ end
160
+
161
+ # Collect pending and sending (crashed mid-send) events
162
+ events_by_id.each_value do |event|
163
+ if [STATUS_PENDING, STATUS_SENDING].include?(event[:status])
164
+ recovered_events << event
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ log(:info, "Recovered #{recovered_events.size} pending events") unless recovered_events.empty?
171
+ recovered_events
172
+ rescue StandardError => e
173
+ log(:error, "Failed to recover events: #{e.message}")
174
+ []
175
+ end
176
+
177
+ # Cleans up old sent events and compacts files.
178
+ #
179
+ # @param retention_period [Integer] Seconds to keep sent events (default: 24 hours)
180
+ # @return [Integer] Number of events cleaned up
181
+ def cleanup(retention_period: DEFAULT_RETENTION_PERIOD)
182
+ cleaned_count = 0
183
+ cutoff_time = (Time.now.to_f * 1000).to_i - (retention_period * 1000)
184
+
185
+ @file_mutex.synchronize do
186
+ event_files.each do |file_path|
187
+ events_to_keep = []
188
+ events_cleaned = 0
189
+
190
+ with_file_lock(file_path, File::LOCK_EX) do |file|
191
+ events_by_id = {}
192
+
193
+ file.each_line do |line|
194
+ next if line.strip.empty?
195
+
196
+ begin
197
+ event = JSON.parse(line.strip, symbolize_names: true)
198
+
199
+ if event[:status] && event[:id] && !event[:type]
200
+ # Status update
201
+ if events_by_id[event[:id]]
202
+ events_by_id[event[:id]][:status] = event[:status]
203
+ events_by_id[event[:id]][:sent_at] = event[:sent_at] if event[:sent_at]
204
+ end
205
+ else
206
+ events_by_id[event[:id]] = event
207
+ end
208
+ rescue JSON::ParserError
209
+ next
210
+ end
211
+ end
212
+
213
+ # Keep events that are not sent or were sent recently
214
+ events_by_id.each_value do |event|
215
+ if event[:status] == STATUS_SENT
216
+ # Clean up if: no sent_at (legacy), retention_period is 0, or sent_at is before cutoff
217
+ if !event[:sent_at] || retention_period == 0 || event[:sent_at] <= cutoff_time
218
+ events_cleaned += 1
219
+ else
220
+ events_to_keep << event
221
+ end
222
+ elsif event[:status] == STATUS_FAILED
223
+ events_cleaned += 1
224
+ else
225
+ events_to_keep << event
226
+ end
227
+ end
228
+ end
229
+
230
+ # Rewrite file with kept events only
231
+ if events_cleaned > 0
232
+ File.open(file_path, "w") do |file|
233
+ file.flock(File::LOCK_EX)
234
+ events_to_keep.each do |event|
235
+ file.puts(JSON.generate(event))
236
+ end
237
+ file.flush
238
+ file.fsync
239
+ file.flock(File::LOCK_UN)
240
+ end
241
+ cleaned_count += events_cleaned
242
+ end
243
+
244
+ # Delete empty files
245
+ File.delete(file_path) if File.exist?(file_path) && File.size(file_path) == 0
246
+ end
247
+ end
248
+
249
+ log(:debug, "Cleaned up #{cleaned_count} old events") if cleaned_count > 0
250
+ cleaned_count
251
+ rescue StandardError => e
252
+ log(:error, "Failed to cleanup events: #{e.message}")
253
+ 0
254
+ end
255
+
256
+ # Flushes remaining events and cleans up resources.
257
+ def close
258
+ return if @closed
259
+
260
+ @closed = true
261
+ stop_flush_thread
262
+ flush
263
+ cleanup
264
+ end
265
+
266
+ # Returns the total count of pending events (in buffer and on disk).
267
+ #
268
+ # @return [Integer]
269
+ def pending_count
270
+ buffer_count = @buffer_mutex.synchronize { @buffer.size }
271
+ disk_count = count_events_by_status([STATUS_PENDING, STATUS_SENDING])
272
+ buffer_count + disk_count
273
+ end
274
+
275
+ private
276
+
277
+ def ensure_storage_directory
278
+ FileUtils.mkdir_p(@storage_path) unless File.directory?(@storage_path)
279
+ # Set restrictive permissions (owner only)
280
+ File.chmod(0o700, @storage_path)
281
+ rescue StandardError => e
282
+ log(:error, "Failed to create storage directory: #{e.message}")
283
+ raise
284
+ end
285
+
286
+ def start_flush_thread
287
+ return if @flush_interval <= 0
288
+
289
+ @flush_thread = Thread.new do
290
+ interval_seconds = @flush_interval / 1000.0
291
+
292
+ until @closed
293
+ sleep(interval_seconds)
294
+ break if @closed
295
+
296
+ flush
297
+ end
298
+ end
299
+ end
300
+
301
+ def stop_flush_thread
302
+ @flush_thread&.kill
303
+ @flush_thread = nil
304
+ end
305
+
306
+ def generate_event_id
307
+ "evt_#{SecureRandom.hex(12)}"
308
+ end
309
+
310
+ def current_log_file
311
+ # Use a single file per day to avoid too many small files
312
+ date_str = Time.now.strftime("%Y%m%d")
313
+ File.join(@storage_path, "flagkit-events-#{date_str}.jsonl")
314
+ end
315
+
316
+ def event_files
317
+ Dir.glob(File.join(@storage_path, "flagkit-events-*.jsonl")).sort
318
+ end
319
+
320
+ def lock_file_path
321
+ File.join(@storage_path, "flagkit-events.lock")
322
+ end
323
+
324
+ def write_events_to_disk(events)
325
+ return true if events.empty?
326
+
327
+ # Check if we would exceed max events
328
+ current_count = count_events_by_status([STATUS_PENDING, STATUS_SENDING])
329
+ if current_count + events.size > @max_events
330
+ # Remove oldest events to make room
331
+ excess = (current_count + events.size) - @max_events
332
+ log(:warn, "Dropping #{excess} oldest events to stay within max_events limit")
333
+ remove_oldest_events(excess)
334
+ end
335
+
336
+ file_path = current_log_file
337
+
338
+ @file_mutex.synchronize do
339
+ with_file_lock(file_path, File::LOCK_EX, create: true) do |file|
340
+ events.each do |event|
341
+ file.puts(JSON.generate(event))
342
+ end
343
+ file.flush
344
+ file.fsync
345
+ end
346
+ end
347
+
348
+ true
349
+ rescue StandardError => e
350
+ log(:error, "Failed to write events to disk: #{e.message}")
351
+ false
352
+ end
353
+
354
+ def update_event_status(event_ids, new_status, extra_fields = {})
355
+ event_ids_set = event_ids.to_set
356
+ timestamp = (Time.now.to_f * 1000).to_i
357
+
358
+ @file_mutex.synchronize do
359
+ event_files.each do |file_path|
360
+ updates_needed = []
361
+
362
+ # First pass: find events that need updating
363
+ with_file_lock(file_path, File::LOCK_SH) do |file|
364
+ file.each_line do |line|
365
+ next if line.strip.empty?
366
+
367
+ begin
368
+ event = JSON.parse(line.strip, symbolize_names: true)
369
+ if event_ids_set.include?(event[:id]) && event[:type]
370
+ updates_needed << event[:id]
371
+ end
372
+ rescue JSON::ParserError
373
+ next
374
+ end
375
+ end
376
+ end
377
+
378
+ # Second pass: append status updates
379
+ next if updates_needed.empty?
380
+
381
+ with_file_lock(file_path, File::LOCK_EX) do |file|
382
+ file.seek(0, IO::SEEK_END)
383
+ updates_needed.each do |event_id|
384
+ status_update = { id: event_id, status: new_status }.merge(extra_fields)
385
+ file.puts(JSON.generate(status_update))
386
+ end
387
+ file.flush
388
+ file.fsync
389
+ end
390
+ end
391
+ end
392
+
393
+ true
394
+ rescue StandardError => e
395
+ log(:error, "Failed to update event status: #{e.message}")
396
+ false
397
+ end
398
+
399
+ def count_events_by_status(statuses)
400
+ count = 0
401
+ statuses_set = statuses.to_set
402
+
403
+ event_files.each do |file_path|
404
+ events_by_id = {}
405
+
406
+ with_file_lock(file_path, File::LOCK_SH) do |file|
407
+ file.each_line do |line|
408
+ next if line.strip.empty?
409
+
410
+ begin
411
+ event = JSON.parse(line.strip, symbolize_names: true)
412
+
413
+ if event[:status] && event[:id] && !event[:type]
414
+ events_by_id[event[:id]][:status] = event[:status] if events_by_id[event[:id]]
415
+ else
416
+ events_by_id[event[:id]] = event
417
+ end
418
+ rescue JSON::ParserError
419
+ next
420
+ end
421
+ end
422
+ end
423
+
424
+ events_by_id.each_value do |event|
425
+ count += 1 if statuses_set.include?(event[:status])
426
+ end
427
+ end
428
+
429
+ count
430
+ rescue StandardError
431
+ 0
432
+ end
433
+
434
+ def remove_oldest_events(count)
435
+ removed = 0
436
+ sorted_files = event_files.sort # Oldest first by filename
437
+
438
+ sorted_files.each do |file_path|
439
+ break if removed >= count
440
+
441
+ events_to_keep = []
442
+
443
+ with_file_lock(file_path, File::LOCK_EX) do |file|
444
+ events = []
445
+
446
+ file.each_line do |line|
447
+ next if line.strip.empty?
448
+
449
+ begin
450
+ event = JSON.parse(line.strip, symbolize_names: true)
451
+ if event[:type] && [STATUS_PENDING, STATUS_SENDING].include?(event[:status])
452
+ events << event
453
+ end
454
+ rescue JSON::ParserError
455
+ next
456
+ end
457
+ end
458
+
459
+ # Sort by timestamp and remove oldest
460
+ events.sort_by! { |e| e[:timestamp] || 0 }
461
+ to_remove = [count - removed, events.size].min
462
+ removed += to_remove
463
+ events_to_keep = events.drop(to_remove)
464
+ end
465
+
466
+ # Rewrite file
467
+ File.open(file_path, "w") do |file|
468
+ file.flock(File::LOCK_EX)
469
+ events_to_keep.each do |event|
470
+ file.puts(JSON.generate(event))
471
+ end
472
+ file.flush
473
+ file.fsync
474
+ file.flock(File::LOCK_UN)
475
+ end
476
+ end
477
+
478
+ removed
479
+ end
480
+
481
+ def with_file_lock(file_path, lock_mode, create: false)
482
+ mode = create ? "a+" : "r+"
483
+
484
+ # Create file if it doesn't exist when in create mode
485
+ if create && !File.exist?(file_path)
486
+ FileUtils.touch(file_path)
487
+ File.chmod(0o600, file_path)
488
+ end
489
+
490
+ return unless File.exist?(file_path)
491
+
492
+ File.open(file_path, mode) do |file|
493
+ file.flock(lock_mode)
494
+ begin
495
+ file.seek(0) if lock_mode == File::LOCK_SH || mode == "r+"
496
+ yield file
497
+ ensure
498
+ file.flock(File::LOCK_UN)
499
+ end
500
+ end
501
+ end
502
+
503
+ def log(level, message)
504
+ return unless @logger
505
+
506
+ @logger.send(level, "[FlagKit::EventPersistence] #{message}")
507
+ end
508
+ end
509
+ end
510
+
511
+ # Alias for backward compatibility
512
+ EventPersistence = Core::EventPersistence
513
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ module Core
5
+ # Batches and sends analytics events.
6
+ class EventQueue
7
+ attr_reader :batch_size, :flush_interval, :running, :persistence
8
+
9
+ # @param batch_size [Integer] Maximum events per batch
10
+ # @param flush_interval [Integer] Seconds between flushes
11
+ # @param on_flush [Proc] Callback to send events
12
+ # @param logger [Object, nil] Logger instance
13
+ # @param persist_events [Boolean] Enable crash-resilient event persistence
14
+ # @param event_storage_path [String, nil] Directory for event storage
15
+ # @param max_persisted_events [Integer] Maximum events to persist
16
+ # @param persistence_flush_interval [Integer] Milliseconds between disk writes
17
+ def initialize(
18
+ batch_size:,
19
+ flush_interval:,
20
+ on_flush:,
21
+ logger: nil,
22
+ persist_events: false,
23
+ event_storage_path: nil,
24
+ max_persisted_events: 10_000,
25
+ persistence_flush_interval: 1000
26
+ )
27
+ @batch_size = batch_size
28
+ @flush_interval = flush_interval
29
+ @on_flush = on_flush
30
+ @logger = logger
31
+ @queue = []
32
+ @mutex = Mutex.new
33
+ @running = false
34
+ @thread = nil
35
+ @persist_events = persist_events
36
+ @persistence = nil
37
+
38
+ setup_persistence(event_storage_path, max_persisted_events, persistence_flush_interval) if persist_events
39
+ end
40
+
41
+ # Starts the background flush thread.
42
+ def start
43
+ @mutex.synchronize do
44
+ return if @running
45
+
46
+ @running = true
47
+ @thread = Thread.new { flush_loop }
48
+ end
49
+
50
+ # Recover persisted events on start
51
+ recover_persisted_events if @persist_events && @persistence
52
+ end
53
+
54
+ # Stops the background flush thread.
55
+ def stop
56
+ @mutex.synchronize do
57
+ @running = false
58
+ end
59
+ @thread&.join(5)
60
+ @thread = nil
61
+ flush
62
+ @persistence&.close
63
+ end
64
+
65
+ # Adds an event to the queue.
66
+ #
67
+ # @param event [Hash] The event to queue
68
+ def enqueue(event)
69
+ # Persist event BEFORE queuing (crash-safe)
70
+ if @persist_events && @persistence
71
+ event_id = @persistence.persist(event)
72
+ event = event.merge(id: event_id) if event_id
73
+ end
74
+
75
+ should_flush = false
76
+
77
+ @mutex.synchronize do
78
+ @queue << event
79
+ should_flush = @queue.size >= batch_size
80
+ end
81
+
82
+ flush if should_flush
83
+ end
84
+
85
+ # Flushes all pending events.
86
+ def flush
87
+ events = nil
88
+
89
+ @mutex.synchronize do
90
+ return if @queue.empty?
91
+
92
+ events = @queue.dup
93
+ @queue.clear
94
+ end
95
+
96
+ send_events(events)
97
+ end
98
+
99
+ # Returns the number of pending events.
100
+ #
101
+ # @return [Integer]
102
+ def size
103
+ @mutex.synchronize { @queue.size }
104
+ end
105
+
106
+ # Checks if the queue is running.
107
+ #
108
+ # @return [Boolean]
109
+ def running?
110
+ @mutex.synchronize { @running }
111
+ end
112
+
113
+ private
114
+
115
+ def flush_loop
116
+ while running?
117
+ sleep(flush_interval)
118
+ break unless running?
119
+
120
+ flush
121
+ end
122
+ end
123
+
124
+ def send_events(events)
125
+ return if events.nil? || events.empty?
126
+
127
+ # Mark events as sending if persistence is enabled
128
+ event_ids = events.map { |e| e[:id] || e["id"] }.compact
129
+ @persistence&.mark_sending(event_ids) if @persist_events && event_ids.any?
130
+
131
+ begin
132
+ @on_flush.call(events)
133
+
134
+ # Mark events as sent on success
135
+ @persistence&.mark_sent(event_ids) if @persist_events && event_ids.any?
136
+ rescue StandardError => e
137
+ log(:error, "Failed to send events: #{e.message}")
138
+
139
+ # Mark events as pending on failure (will retry)
140
+ @persistence&.mark_pending(event_ids) if @persist_events && event_ids.any?
141
+
142
+ # Re-queue events on failure
143
+ @mutex.synchronize do
144
+ @queue = events + @queue
145
+ end
146
+ end
147
+ end
148
+
149
+ def log(level, message)
150
+ return unless @logger
151
+
152
+ @logger.send(level, "[FlagKit::EventQueue] #{message}")
153
+ end
154
+
155
+ def setup_persistence(storage_path, max_events, flush_interval)
156
+ return unless storage_path
157
+
158
+ @persistence = EventPersistence.new(
159
+ storage_path: storage_path,
160
+ max_events: max_events,
161
+ flush_interval: flush_interval,
162
+ logger: @logger
163
+ )
164
+ rescue StandardError => e
165
+ log(:error, "Failed to setup event persistence: #{e.message}")
166
+ @persist_events = false
167
+ @persistence = nil
168
+ end
169
+
170
+ def recover_persisted_events
171
+ return unless @persistence
172
+
173
+ recovered = @persistence.recover
174
+ return if recovered.empty?
175
+
176
+ log(:info, "Recovering #{recovered.size} persisted events")
177
+
178
+ # Add recovered events to the front of the queue with priority
179
+ @mutex.synchronize do
180
+ @queue = recovered + @queue
181
+ end
182
+ rescue StandardError => e
183
+ log(:error, "Failed to recover persisted events: #{e.message}")
184
+ end
185
+ end
186
+ end
187
+
188
+ # Alias for backward compatibility
189
+ EventQueue = Core::EventQueue
190
+ end