launchdarkly-server-sdk 8.11.3-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.
@@ -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
@@ -192,9 +192,10 @@ module LaunchDarkly
192
192
  builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES)
193
193
 
194
194
  # Add the updated flag
195
+ flag_key = flag_data[:key].to_sym
195
196
  builder.add_put(
196
197
  LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
197
- flag_data[:key],
198
+ flag_key,
198
199
  flag_data[:version] || 1,
199
200
  flag_data
200
201
  )
@@ -247,9 +248,10 @@ module LaunchDarkly
247
248
  builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES)
248
249
 
249
250
  # Add the updated segment
251
+ segment_key = segment_data[:key].to_sym
250
252
  builder.add_put(
251
253
  LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT,
252
- segment_data[:key],
254
+ segment_key,
253
255
  segment_data[:version] || 1,
254
256
  segment_data
255
257
  )
@@ -35,7 +35,7 @@ module LaunchDarkly
35
35
  items_out = {}
36
36
  until remaining_items.empty?
37
37
  # pick a random item that hasn't been updated yet
38
- key, item = remaining_items.first
38
+ _, item = remaining_items.first
39
39
  self.add_with_dependencies_first(item, dependency_fn, remaining_items, items_out)
40
40
  end
41
41
  items_out
@@ -118,12 +118,18 @@ module LaunchDarkly
118
118
  end
119
119
  end
120
120
 
121
- def self.new_http_client(uri_s, config)
121
+ #
122
+ # Creates a new persistent HTTP client with the given configuration.
123
+ #
124
+ # @param http_config [LaunchDarkly::Impl::DataSystem::HttpConfigOptions] HTTP connection settings
125
+ # @return [HTTP::Client]
126
+ #
127
+ def self.new_http_client(http_config)
122
128
  http_client_options = {}
123
- if config.socket_factory
124
- http_client_options["socket_class"] = config.socket_factory
129
+ if http_config.socket_factory
130
+ http_client_options["socket_class"] = http_config.socket_factory
125
131
  end
126
- proxy = URI.parse(uri_s).find_proxy
132
+ proxy = URI.parse(http_config.base_uri).find_proxy
127
133
  unless proxy.nil?
128
134
  http_client_options["proxy"] = {
129
135
  proxy_address: proxy.host,
@@ -134,10 +140,10 @@ module LaunchDarkly
134
140
  end
135
141
  HTTP::Client.new(http_client_options)
136
142
  .timeout({
137
- read: config.read_timeout,
138
- connect: config.connect_timeout,
143
+ read: http_config.read_timeout,
144
+ connect: http_config.connect_timeout,
139
145
  })
140
- .persistent(uri_s)
146
+ .persistent(http_config.base_uri)
141
147
  end
142
148
 
143
149
  def self.log_exception(logger, message, exc)
@@ -1,4 +1,5 @@
1
1
  require 'ldclient-rb/impl/integrations/file_data_source'
2
+ require 'ldclient-rb/impl/integrations/file_data_source_v2'
2
3
 
3
4
  module LaunchDarkly
4
5
  module Integrations
@@ -103,6 +104,72 @@ module LaunchDarkly
103
104
  lambda { |sdk_key, config|
104
105
  Impl::Integrations::FileDataSourceImpl.new(config.feature_store, config.data_source_update_sink, config.logger, options) }
105
106
  end
107
+
108
+ #
109
+ # Returns a builder for the FDv2-compatible file data source.
110
+ #
111
+ # This method is not stable, and not subject to any backwards compatibility guarantees or semantic versioning.
112
+ # It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode
113
+ #
114
+ # This method returns a builder proc that can be used with the FDv2 data system
115
+ # configuration as both an Initializer and a Synchronizer. When used as an Initializer
116
+ # (via `fetch`), it reads files once. When used as a Synchronizer (via `sync`), it
117
+ # watches for file changes and automatically updates when files are modified.
118
+ #
119
+ # @param options [Hash] the configuration options
120
+ # @option options [Array<String>, String] :paths The paths of the source files for loading flag data. These
121
+ # may be absolute paths or relative to the current working directory. (Required)
122
+ # @option options [Float] :poll_interval The minimum interval, in seconds, between checks for
123
+ # file modifications - used only if the native file-watching mechanism from 'listen' is not
124
+ # being used. The default value is 1 second.
125
+ # @return [FileDataSourceV2Builder] a builder that can be used as an FDv2 initializer or synchronizer
126
+ #
127
+ # @example Using as an initializer
128
+ # file_source = LaunchDarkly::Integrations::FileData.data_source_v2(paths: ['flags.json'])
129
+ # data_system_config = LaunchDarkly::DataSystemConfig.new(
130
+ # initializers: [file_source]
131
+ # )
132
+ # config = LaunchDarkly::Config.new(data_system: data_system_config)
133
+ #
134
+ # @example Using as a synchronizer
135
+ # file_source = LaunchDarkly::Integrations::FileData.data_source_v2(paths: ['flags.json'])
136
+ # data_system_config = LaunchDarkly::DataSystemConfig.new(
137
+ # synchronizers: [file_source]
138
+ # )
139
+ # config = LaunchDarkly::Config.new(data_system: data_system_config)
140
+ #
141
+ # @example Using as both initializer and synchronizer
142
+ # file_source = LaunchDarkly::Integrations::FileData.data_source_v2(paths: ['flags.json'])
143
+ # data_system_config = LaunchDarkly::DataSystemConfig.new(
144
+ # initializers: [file_source],
145
+ # synchronizers: [file_source]
146
+ # )
147
+ # config = LaunchDarkly::Config.new(data_system: data_system_config)
148
+ #
149
+ def self.data_source_v2(options = {})
150
+ paths = options[:paths] || []
151
+ poll_interval = options[:poll_interval] || 1
152
+
153
+ FileDataSourceV2Builder.new(paths, poll_interval)
154
+ end
155
+ end
156
+
157
+ #
158
+ # Builder for FileDataSourceV2.
159
+ #
160
+ class FileDataSourceV2Builder
161
+ def initialize(paths, poll_interval)
162
+ @paths = paths
163
+ @poll_interval = poll_interval
164
+ end
165
+
166
+ def build(_sdk_key, config)
167
+ Impl::Integrations::FileDataSourceV2.new(
168
+ config.logger,
169
+ paths: @paths,
170
+ poll_interval: @poll_interval
171
+ )
172
+ end
106
173
  end
107
174
  end
108
175
  end