launchdarkly-server-sdk 8.11.2-java → 8.12.0-java

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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ldclient-rb/config.rb +69 -9
  3. data/lib/ldclient-rb/context.rb +1 -1
  4. data/lib/ldclient-rb/data_system.rb +227 -0
  5. data/lib/ldclient-rb/events.rb +34 -19
  6. data/lib/ldclient-rb/flags_state.rb +1 -1
  7. data/lib/ldclient-rb/impl/big_segments.rb +4 -4
  8. data/lib/ldclient-rb/impl/cache_store.rb +44 -0
  9. data/lib/ldclient-rb/impl/data_source/polling.rb +108 -0
  10. data/lib/ldclient-rb/impl/data_source/requestor.rb +113 -0
  11. data/lib/ldclient-rb/impl/data_source/status_provider.rb +83 -0
  12. data/lib/ldclient-rb/impl/data_source/stream.rb +198 -0
  13. data/lib/ldclient-rb/impl/data_source.rb +3 -3
  14. data/lib/ldclient-rb/impl/data_store/data_kind.rb +108 -0
  15. data/lib/ldclient-rb/impl/data_store/feature_store_client_wrapper.rb +187 -0
  16. data/lib/ldclient-rb/impl/data_store/in_memory_feature_store.rb +130 -0
  17. data/lib/ldclient-rb/impl/data_store/status_provider.rb +76 -0
  18. data/lib/ldclient-rb/impl/data_store/store.rb +371 -0
  19. data/lib/ldclient-rb/impl/data_store.rb +11 -97
  20. data/lib/ldclient-rb/impl/data_system/data_source_builder_common.rb +77 -0
  21. data/lib/ldclient-rb/impl/data_system/fdv1.rb +20 -7
  22. data/lib/ldclient-rb/impl/data_system/fdv2.rb +472 -0
  23. data/lib/ldclient-rb/impl/data_system/http_config_options.rb +32 -0
  24. data/lib/ldclient-rb/impl/data_system/polling.rb +628 -0
  25. data/lib/ldclient-rb/impl/data_system/protocolv2.rb +264 -0
  26. data/lib/ldclient-rb/impl/data_system/streaming.rb +401 -0
  27. data/lib/ldclient-rb/impl/dependency_tracker.rb +21 -9
  28. data/lib/ldclient-rb/impl/evaluator.rb +3 -2
  29. data/lib/ldclient-rb/impl/event_sender.rb +14 -6
  30. data/lib/ldclient-rb/impl/expiring_cache.rb +79 -0
  31. data/lib/ldclient-rb/impl/integrations/file_data_source.rb +8 -8
  32. data/lib/ldclient-rb/impl/integrations/file_data_source_v2.rb +460 -0
  33. data/lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb +290 -0
  34. data/lib/ldclient-rb/impl/memoized_value.rb +34 -0
  35. data/lib/ldclient-rb/impl/migrations/migrator.rb +2 -1
  36. data/lib/ldclient-rb/impl/migrations/tracker.rb +2 -1
  37. data/lib/ldclient-rb/impl/model/serialization.rb +6 -6
  38. data/lib/ldclient-rb/impl/non_blocking_thread_pool.rb +48 -0
  39. data/lib/ldclient-rb/impl/repeating_task.rb +2 -2
  40. data/lib/ldclient-rb/impl/simple_lru_cache.rb +27 -0
  41. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +1 -1
  42. data/lib/ldclient-rb/impl/util.rb +71 -0
  43. data/lib/ldclient-rb/impl.rb +1 -2
  44. data/lib/ldclient-rb/in_memory_store.rb +1 -18
  45. data/lib/ldclient-rb/integrations/file_data.rb +67 -0
  46. data/lib/ldclient-rb/integrations/test_data/flag_builder.rb +9 -9
  47. data/lib/ldclient-rb/integrations/test_data.rb +11 -11
  48. data/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb +582 -0
  49. data/lib/ldclient-rb/integrations/test_data_v2.rb +254 -0
  50. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +3 -2
  51. data/lib/ldclient-rb/interfaces/data_system.rb +704 -0
  52. data/lib/ldclient-rb/interfaces/feature_store.rb +5 -2
  53. data/lib/ldclient-rb/ldclient.rb +66 -132
  54. data/lib/ldclient-rb/util.rb +11 -70
  55. data/lib/ldclient-rb/version.rb +1 -1
  56. data/lib/ldclient-rb.rb +9 -17
  57. metadata +41 -19
  58. data/lib/ldclient-rb/cache_store.rb +0 -45
  59. data/lib/ldclient-rb/expiring_cache.rb +0 -77
  60. data/lib/ldclient-rb/memoized_value.rb +0 -32
  61. data/lib/ldclient-rb/non_blocking_thread_pool.rb +0 -46
  62. data/lib/ldclient-rb/polling.rb +0 -102
  63. data/lib/ldclient-rb/requestor.rb +0 -102
  64. data/lib/ldclient-rb/simple_lru_cache.rb +0 -25
  65. data/lib/ldclient-rb/stream.rb +0 -197
@@ -0,0 +1,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ldclient-rb/impl/util'
4
+ require 'ldclient-rb/interfaces/data_system'
5
+ require 'ldclient-rb/util'
6
+
7
+ require 'concurrent/atomics'
8
+ require 'json'
9
+ require 'yaml'
10
+ require 'pathname'
11
+ require 'thread'
12
+
13
+ module LaunchDarkly
14
+ module Impl
15
+ module Integrations
16
+ #
17
+ # Internal implementation of both Initializer and Synchronizer protocols for file-based data.
18
+ #
19
+ # This component reads feature flag and segment data from local files and provides them
20
+ # via the FDv2 protocol interfaces. Each instance implements both Initializer and Synchronizer
21
+ # protocols:
22
+ # - As an Initializer: reads files once and returns initial data
23
+ # - As a Synchronizer: watches for file changes and yields updates
24
+ #
25
+ # The files use the same format as the v1 file data source, supporting flags, flagValues,
26
+ # and segments in JSON or YAML format.
27
+ #
28
+ class FileDataSourceV2
29
+ include LaunchDarkly::Interfaces::DataSystem::Initializer
30
+ include LaunchDarkly::Interfaces::DataSystem::Synchronizer
31
+
32
+ # To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
33
+ # file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
34
+ # gem has been provided by the host app.
35
+ @@have_listen = false
36
+ begin
37
+ require 'listen'
38
+ @@have_listen = true
39
+ rescue LoadError
40
+ # Ignored
41
+ end
42
+
43
+ #
44
+ # Initialize the file data source.
45
+ #
46
+ # @param logger [Logger] the logger
47
+ # @param paths [Array<String>, String] file paths to load (or a single path string)
48
+ # @param poll_interval [Float] seconds between polling checks when watching files (default: 1)
49
+ #
50
+ def initialize(logger, paths:, poll_interval: 1)
51
+ @logger = logger
52
+ @paths = paths.is_a?(Array) ? paths : [paths]
53
+ @poll_interval = poll_interval
54
+
55
+ @closed = false
56
+ @update_queue = Queue.new
57
+ @lock = Mutex.new
58
+ @listener = nil
59
+ end
60
+
61
+ #
62
+ # Return the name of this data source.
63
+ #
64
+ # @return [String]
65
+ #
66
+ def name
67
+ 'FileDataV2'
68
+ end
69
+
70
+ #
71
+ # Implementation of the Initializer.fetch method.
72
+ #
73
+ # Reads all configured files once and returns their contents as a Basis.
74
+ #
75
+ # @param selector_store [LaunchDarkly::Interfaces::DataSystem::SelectorStore] Provides the Selector (unused for file data)
76
+ # @return [LaunchDarkly::Result] A Result containing either a Basis or an error message
77
+ #
78
+ def fetch(selector_store)
79
+ @lock.synchronize do
80
+ if @closed
81
+ return LaunchDarkly::Result.fail('FileDataV2 source has been closed')
82
+ end
83
+
84
+ result = load_all_to_changeset
85
+ return result unless result.success?
86
+
87
+ change_set = result.value
88
+ basis = LaunchDarkly::Interfaces::DataSystem::Basis.new(
89
+ change_set: change_set,
90
+ persist: false,
91
+ environment_id: nil
92
+ )
93
+
94
+ LaunchDarkly::Result.success(basis)
95
+ end
96
+ rescue => e
97
+ @logger.error { "[LDClient] Error fetching file data: #{e.message}" }
98
+ LaunchDarkly::Result.fail("Error fetching file data: #{e.message}", e)
99
+ end
100
+
101
+ #
102
+ # Implementation of the Synchronizer.sync method.
103
+ #
104
+ # Yields initial data from files, then continues to watch for file changes
105
+ # and yields updates when files are modified.
106
+ #
107
+ # @param selector_store [LaunchDarkly::Interfaces::DataSystem::SelectorStore] Provides the Selector (unused for file data)
108
+ # @yield [LaunchDarkly::Interfaces::DataSystem::Update] Yields Update objects as synchronization progresses
109
+ # @return [void]
110
+ #
111
+ def sync(selector_store)
112
+ # First yield initial data
113
+ initial_result = fetch(selector_store)
114
+ unless initial_result.success?
115
+ yield LaunchDarkly::Interfaces::DataSystem::Update.new(
116
+ state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
117
+ error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
118
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA,
119
+ 0,
120
+ initial_result.error,
121
+ Time.now
122
+ )
123
+ )
124
+ return
125
+ end
126
+
127
+ yield LaunchDarkly::Interfaces::DataSystem::Update.new(
128
+ state: LaunchDarkly::Interfaces::DataSource::Status::VALID,
129
+ change_set: initial_result.value.change_set
130
+ )
131
+
132
+ # Start watching for file changes
133
+ @lock.synchronize do
134
+ @listener = start_listener unless @closed
135
+ end
136
+
137
+ until @closed
138
+ begin
139
+ update = @update_queue.pop
140
+
141
+ # stop() pushes nil to wake us up when shutting down
142
+ break if update.nil?
143
+
144
+ yield update
145
+ rescue => e
146
+ yield LaunchDarkly::Interfaces::DataSystem::Update.new(
147
+ state: LaunchDarkly::Interfaces::DataSource::Status::OFF,
148
+ error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
149
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN,
150
+ 0,
151
+ "Error in file data synchronizer: #{e.message}",
152
+ Time.now
153
+ )
154
+ )
155
+ break
156
+ end
157
+ end
158
+ end
159
+
160
+ #
161
+ # Stop the data source and clean up resources.
162
+ #
163
+ # @return [void]
164
+ #
165
+ def stop
166
+ @lock.synchronize do
167
+ return if @closed
168
+ @closed = true
169
+
170
+ listener = @listener
171
+ @listener = nil
172
+
173
+ listener&.stop
174
+ end
175
+
176
+ # Signal shutdown to sync generator
177
+ @update_queue.push(nil)
178
+ end
179
+
180
+ #
181
+ # Load all files and build a changeset.
182
+ #
183
+ # @return [LaunchDarkly::Result] A Result containing either a ChangeSet or an error message
184
+ #
185
+ private def load_all_to_changeset
186
+ flags_dict = {}
187
+ segments_dict = {}
188
+
189
+ @paths.each do |path|
190
+ begin
191
+ load_file(path, flags_dict, segments_dict)
192
+ rescue => e
193
+ Impl::Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", e)
194
+ return LaunchDarkly::Result.fail("Unable to load flag data from \"#{path}\": #{e.message}", e)
195
+ end
196
+ end
197
+
198
+ builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new
199
+ builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL)
200
+
201
+ flags_dict.each do |key, flag_data|
202
+ builder.add_put(
203
+ LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
204
+ key,
205
+ flag_data[:version] || 1,
206
+ flag_data
207
+ )
208
+ end
209
+
210
+ segments_dict.each do |key, segment_data|
211
+ builder.add_put(
212
+ LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT,
213
+ key,
214
+ segment_data[:version] || 1,
215
+ segment_data
216
+ )
217
+ end
218
+
219
+ # Use no_selector since we don't have versioning information from files
220
+ change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)
221
+
222
+ LaunchDarkly::Result.success(change_set)
223
+ end
224
+
225
+ #
226
+ # Load a single file and add its contents to the provided dictionaries.
227
+ #
228
+ # @param path [String] path to the file
229
+ # @param flags_dict [Hash] dictionary to add flags to
230
+ # @param segments_dict [Hash] dictionary to add segments to
231
+ #
232
+ private def load_file(path, flags_dict, segments_dict)
233
+ parsed = parse_content(File.read(path))
234
+
235
+ (parsed[:flags] || {}).each do |key, flag|
236
+ flag[:version] ||= 1
237
+ add_item(flags_dict, 'flags', flag)
238
+ end
239
+
240
+ (parsed[:flagValues] || {}).each do |key, value|
241
+ add_item(flags_dict, 'flags', make_flag_with_value(key.to_s, value))
242
+ end
243
+
244
+ (parsed[:segments] || {}).each do |key, segment|
245
+ segment[:version] ||= 1
246
+ add_item(segments_dict, 'segments', segment)
247
+ end
248
+ end
249
+
250
+ #
251
+ # Parse file content as JSON or YAML.
252
+ #
253
+ # @param content [String] file content string
254
+ # @return [Hash] parsed dictionary with symbolized keys
255
+ #
256
+ private def parse_content(content)
257
+ # Ruby's YAML parser correctly handles JSON as well
258
+ symbolize_all_keys(YAML.safe_load(content)) || {}
259
+ end
260
+
261
+ #
262
+ # Recursively symbolize all keys in a hash or array.
263
+ #
264
+ # @param value [Object] the value to symbolize
265
+ # @return [Object] the value with all keys symbolized
266
+ private def symbolize_all_keys(value)
267
+ if value.is_a?(Hash)
268
+ value.map { |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h
269
+ elsif value.is_a?(Array)
270
+ value.map { |v| symbolize_all_keys(v) }
271
+ else
272
+ value
273
+ end
274
+ end
275
+
276
+ #
277
+ # Add an item to a dictionary, checking for duplicates.
278
+ #
279
+ # @param items_dict [Hash] dictionary to add to
280
+ # @param kind_name [String] name of the kind (for error messages)
281
+ # @param item [Hash] item to add
282
+ #
283
+ private def add_item(items_dict, kind_name, item)
284
+ key = item[:key].to_sym
285
+ if items_dict[key].nil?
286
+ items_dict[key] = item
287
+ else
288
+ raise ArgumentError, "In #{kind_name}, key \"#{item[:key]}\" was used more than once"
289
+ end
290
+ end
291
+
292
+ #
293
+ # Create a simple flag configuration from a key-value pair.
294
+ #
295
+ # @param key [String] flag key
296
+ # @param value [Object] flag value
297
+ # @return [Hash] flag dictionary
298
+ #
299
+ private def make_flag_with_value(key, value)
300
+ {
301
+ key: key,
302
+ on: true,
303
+ version: 1,
304
+ fallthrough: { variation: 0 },
305
+ variations: [value],
306
+ }
307
+ end
308
+
309
+ #
310
+ # Callback invoked when files change.
311
+ #
312
+ # Reloads all files and queues an update.
313
+ #
314
+ private def on_file_change
315
+ @lock.synchronize do
316
+ return if @closed
317
+
318
+ begin
319
+ result = load_all_to_changeset
320
+
321
+ if result.success?
322
+ update = LaunchDarkly::Interfaces::DataSystem::Update.new(
323
+ state: LaunchDarkly::Interfaces::DataSource::Status::VALID,
324
+ change_set: result.value
325
+ )
326
+ @update_queue.push(update)
327
+ else
328
+ error_update = LaunchDarkly::Interfaces::DataSystem::Update.new(
329
+ state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
330
+ error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
331
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA,
332
+ 0,
333
+ result.error,
334
+ Time.now
335
+ )
336
+ )
337
+ @update_queue.push(error_update)
338
+ end
339
+ rescue => e
340
+ @logger.error { "[LDClient] Error processing file change: #{e.message}" }
341
+ error_update = LaunchDarkly::Interfaces::DataSystem::Update.new(
342
+ state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
343
+ error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
344
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN,
345
+ 0,
346
+ "Error processing file change: #{e.message}",
347
+ Time.now
348
+ )
349
+ )
350
+ @update_queue.push(error_update)
351
+ end
352
+ end
353
+ end
354
+
355
+ #
356
+ # Start watching files for changes.
357
+ #
358
+ # @return [Object] auto-updater instance
359
+ #
360
+ private def start_listener
361
+ resolved_paths = @paths.map do |p|
362
+ begin
363
+ Pathname.new(File.absolute_path(p)).realpath.to_s
364
+ rescue
365
+ @logger.warn { "[LDClient] Cannot watch for changes to data file \"#{p}\" because it is an invalid path" }
366
+ nil
367
+ end
368
+ end.compact
369
+
370
+ if @@have_listen
371
+ start_listener_with_listen_gem(resolved_paths)
372
+ else
373
+ FileDataSourcePollerV2.new(resolved_paths, @poll_interval, method(:on_file_change), @logger)
374
+ end
375
+ end
376
+
377
+ #
378
+ # Start listening for file changes using the listen gem.
379
+ #
380
+ # @param resolved_paths [Array<String>] resolved file paths to watch
381
+ # @return [Listen::Listener] the listener instance
382
+ #
383
+ private def start_listener_with_listen_gem(resolved_paths)
384
+ path_set = resolved_paths.to_set
385
+ dir_paths = resolved_paths.map { |p| File.dirname(p) }.uniq
386
+ opts = { latency: @poll_interval }
387
+ l = Listen.to(*dir_paths, **opts) do |modified, added, removed|
388
+ paths = modified + added + removed
389
+ if paths.any? { |p| path_set.include?(p) }
390
+ on_file_change
391
+ end
392
+ end
393
+ l.start
394
+ l
395
+ end
396
+ end
397
+
398
+ #
399
+ # Used internally by FileDataSourceV2 to track data file changes if the 'listen' gem is not available.
400
+ #
401
+ class FileDataSourcePollerV2
402
+ #
403
+ # Initialize the file data poller.
404
+ #
405
+ # @param resolved_paths [Array<String>] resolved file paths to watch
406
+ # @param interval [Float] polling interval in seconds
407
+ # @param on_change_callback [Proc] callback to invoke when files change
408
+ # @param logger [Logger] the logger
409
+ #
410
+ def initialize(resolved_paths, interval, on_change_callback, logger)
411
+ @stopped = Concurrent::AtomicBoolean.new(false)
412
+ @on_change = on_change_callback
413
+ @logger = logger
414
+
415
+ get_file_times = proc do
416
+ ret = {}
417
+ resolved_paths.each do |path|
418
+ begin
419
+ ret[path] = File.mtime(path)
420
+ rescue Errno::ENOENT
421
+ ret[path] = nil
422
+ end
423
+ end
424
+ ret
425
+ end
426
+
427
+ last_times = get_file_times.call
428
+ @thread = Thread.new do
429
+ loop do
430
+ sleep interval
431
+ break if @stopped.value
432
+
433
+ begin
434
+ new_times = get_file_times.call
435
+ changed = false
436
+ last_times.each do |path, old_time|
437
+ new_time = new_times[path]
438
+ if !new_time.nil? && new_time != old_time
439
+ changed = true
440
+ break
441
+ end
442
+ end
443
+ last_times = new_times
444
+ @on_change.call if changed
445
+ rescue => e
446
+ Impl::Util.log_exception(@logger, "Unexpected exception in FileDataSourcePollerV2", e)
447
+ end
448
+ end
449
+ end
450
+ @thread.name = "LD/FileDataSourceV2"
451
+ end
452
+
453
+ def stop
454
+ @stopped.make_true
455
+ @thread.run # wakes it up if it's sleeping
456
+ end
457
+ end
458
+ end
459
+ end
460
+ end