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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/lib/flagkit/client.rb +443 -0
- data/lib/flagkit/core/cache.rb +162 -0
- data/lib/flagkit/core/encrypted_cache.rb +227 -0
- data/lib/flagkit/core/event_persistence.rb +513 -0
- data/lib/flagkit/core/event_queue.rb +190 -0
- data/lib/flagkit/core/polling_manager.rb +112 -0
- data/lib/flagkit/core/streaming_manager.rb +469 -0
- data/lib/flagkit/error/error_code.rb +98 -0
- data/lib/flagkit/error/error_sanitizer.rb +48 -0
- data/lib/flagkit/error/flagkit_error.rb +95 -0
- data/lib/flagkit/http/circuit_breaker.rb +145 -0
- data/lib/flagkit/http/http_client.rb +312 -0
- data/lib/flagkit/options.rb +222 -0
- data/lib/flagkit/types/evaluation_context.rb +121 -0
- data/lib/flagkit/types/evaluation_reason.rb +22 -0
- data/lib/flagkit/types/evaluation_result.rb +77 -0
- data/lib/flagkit/types/flag_state.rb +100 -0
- data/lib/flagkit/types/flag_type.rb +46 -0
- data/lib/flagkit/utils/security.rb +528 -0
- data/lib/flagkit/utils/version.rb +116 -0
- data/lib/flagkit/version.rb +5 -0
- data/lib/flagkit.rb +166 -0
- metadata +200 -0
|
@@ -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
|