Skip to content

Commit 4bdc298

Browse files
committed
chore: Add the FDv2 data system protocol implementation
1 parent ff845d5 commit 4bdc298

File tree

10 files changed

+2608
-0
lines changed

10 files changed

+2608
-0
lines changed

lib/ldclient-rb/config.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class Config
4545
# @option opts [Hash] :application See {#application}
4646
# @option opts [String] :payload_filter_key See {#payload_filter_key}
4747
# @option opts [Boolean] :omit_anonymous_contexts See {#omit_anonymous_contexts}
48+
# @option opts [DataSystemConfig] :datasystem_config See {#datasystem_config}
4849
# @option hooks [Array<Interfaces::Hooks::Hook]
4950
# @option plugins [Array<Interfaces::Plugins::Plugin]
5051
#
@@ -83,6 +84,7 @@ def initialize(opts = {})
8384
@hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook }
8485
@plugins = (opts[:plugins] || []).keep_if { |plugin| plugin.is_a? Interfaces::Plugins::Plugin }
8586
@omit_anonymous_contexts = opts.has_key?(:omit_anonymous_contexts) && opts[:omit_anonymous_contexts]
87+
@datasystem_config = opts[:datasystem_config]
8688
@data_source_update_sink = nil
8789
@instance_id = nil
8890
end
@@ -431,6 +433,15 @@ def diagnostic_opt_out?
431433
#
432434
attr_reader :omit_anonymous_contexts
433435

436+
#
437+
# Configuration for the upcoming enhanced data system design. This is
438+
# experimental and should not be set without direction from LaunchDarkly
439+
# support.
440+
#
441+
# @return [DataSystemConfig, nil]
442+
#
443+
attr_reader :datasystem_config
444+
434445

435446
#
436447
# The default LaunchDarkly client configuration. This configuration sets
@@ -679,4 +690,55 @@ def initialize(store:, context_cache_size: nil, context_cache_time: nil, status_
679690
# @return [Float]
680691
attr_reader :stale_after
681692
end
693+
694+
#
695+
# Configuration for LaunchDarkly's data acquisition strategy.
696+
#
697+
# This is not stable and is not subject to any backwards compatibility guarantees
698+
# or semantic versioning. It is not suitable for production usage.
699+
#
700+
class DataSystemConfig
701+
#
702+
# @param initializers [Array<Proc(Config) => LaunchDarkly::Interfaces::DataSystem::Initializer>, nil] Array of builder procs that take Config and return an Initializer
703+
# @param primary_synchronizer [Proc(Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil] Builder proc that takes Config and returns the primary Synchronizer
704+
# @param secondary_synchronizer [Proc(Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil] Builder proc that takes Config and returns the secondary Synchronizer
705+
# @param data_store_mode [Symbol] The data store mode
706+
# @param data_store [LaunchDarkly::Interfaces::FeatureStore, nil] The (optional) data store
707+
# @param fdv1_fallback_synchronizer [Proc(Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil]
708+
# The (optional) builder proc for FDv1-compatible fallback synchronizer
709+
#
710+
def initialize(initializers:, primary_synchronizer:, secondary_synchronizer:,
711+
data_store_mode: LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil)
712+
@initializers = initializers
713+
@primary_synchronizer = primary_synchronizer
714+
@secondary_synchronizer = secondary_synchronizer
715+
@data_store_mode = data_store_mode
716+
@data_store = data_store
717+
@fdv1_fallback_synchronizer = fdv1_fallback_synchronizer
718+
end
719+
720+
# The initializers for the data system. Each proc takes sdk_key and Config and returns an Initializer.
721+
# @return [Array<Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Initializer>, nil]
722+
attr_reader :initializers
723+
724+
# The primary synchronizer builder. Takes sdk_key and Config and returns a Synchronizer.
725+
# @return [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil]
726+
attr_reader :primary_synchronizer
727+
728+
# The secondary synchronizer builder. Takes sdk_key and Config and returns a Synchronizer.
729+
# @return [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil]
730+
attr_reader :secondary_synchronizer
731+
732+
# The data store mode.
733+
# @return [Symbol]
734+
attr_reader :data_store_mode
735+
736+
# The data store.
737+
# @return [LaunchDarkly::Interfaces::FeatureStore, nil]
738+
attr_reader :data_store
739+
740+
# The FDv1-compatible fallback synchronizer builder. Takes sdk_key and Config and returns a Synchronizer.
741+
# @return [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil]
742+
attr_reader :fdv1_fallback_synchronizer
743+
end
682744
end

lib/ldclient-rb/datasystem.rb

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# frozen_string_literal: true
2+
3+
require 'ldclient-rb/interfaces/data_system'
4+
require 'ldclient-rb/config'
5+
6+
module LaunchDarkly
7+
#
8+
# Configuration for LaunchDarkly's data acquisition strategy.
9+
#
10+
# This module provides factory methods for creating data system configurations.
11+
#
12+
module DataSystem
13+
#
14+
# Builder for the data system configuration.
15+
#
16+
class ConfigBuilder
17+
def initialize
18+
@initializers = nil
19+
@primary_synchronizer = nil
20+
@secondary_synchronizer = nil
21+
@fdv1_fallback_synchronizer = nil
22+
@data_store_mode = LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY
23+
@data_store = nil
24+
end
25+
26+
#
27+
# Sets the initializers for the data system.
28+
#
29+
# @param initializers [Array<Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Initializer>]
30+
# Array of builder procs that take sdk_key and Config and return an Initializer
31+
# @return [ConfigBuilder] self for chaining
32+
#
33+
def initializers(initializers)
34+
@initializers = initializers
35+
self
36+
end
37+
38+
#
39+
# Sets the synchronizers for the data system.
40+
#
41+
# @param primary [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer] Builder proc that takes sdk_key and Config and returns the primary Synchronizer
42+
# @param secondary [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer, nil]
43+
# Builder proc that takes sdk_key and Config and returns the secondary Synchronizer
44+
# @return [ConfigBuilder] self for chaining
45+
#
46+
def synchronizers(primary, secondary = nil)
47+
@primary_synchronizer = primary
48+
@secondary_synchronizer = secondary
49+
self
50+
end
51+
52+
#
53+
# Configures the SDK with a fallback synchronizer that is compatible with
54+
# the Flag Delivery v1 API.
55+
#
56+
# @param fallback [Proc(String, Config) => LaunchDarkly::Interfaces::DataSystem::Synchronizer]
57+
# Builder proc that takes sdk_key and Config and returns the fallback Synchronizer
58+
# @return [ConfigBuilder] self for chaining
59+
#
60+
def fdv1_compatible_synchronizer(fallback)
61+
@fdv1_fallback_synchronizer = fallback
62+
self
63+
end
64+
65+
#
66+
# Sets the data store configuration for the data system.
67+
#
68+
# @param data_store [LaunchDarkly::Interfaces::FeatureStore] The data store
69+
# @param store_mode [Symbol] The store mode
70+
# @return [ConfigBuilder] self for chaining
71+
#
72+
def data_store(data_store, store_mode)
73+
@data_store = data_store
74+
@data_store_mode = store_mode
75+
self
76+
end
77+
78+
#
79+
# Builds the data system configuration.
80+
#
81+
# @return [DataSystemConfig]
82+
# @raise [ArgumentError] if configuration is invalid
83+
#
84+
def build
85+
if @secondary_synchronizer && @primary_synchronizer.nil?
86+
raise ArgumentError, "Primary synchronizer must be set if secondary is set"
87+
end
88+
89+
DataSystemConfig.new(
90+
initializers: @initializers,
91+
primary_synchronizer: @primary_synchronizer,
92+
secondary_synchronizer: @secondary_synchronizer,
93+
data_store_mode: @data_store_mode,
94+
data_store: @data_store,
95+
fdv1_fallback_synchronizer: @fdv1_fallback_synchronizer
96+
)
97+
end
98+
end
99+
100+
# @private
101+
def self.polling_ds_builder
102+
# TODO(fdv2): Implement polling data source builder
103+
lambda do |_sdk_key, _config|
104+
raise NotImplementedError, "Polling data source not yet implemented for FDv2"
105+
end
106+
end
107+
108+
# @private
109+
def self.fdv1_fallback_ds_builder
110+
# TODO(fdv2): Implement FDv1 fallback polling data source builder
111+
lambda do |_sdk_key, _config|
112+
raise NotImplementedError, "FDv1 fallback data source not yet implemented for FDv2"
113+
end
114+
end
115+
116+
# @private
117+
def self.streaming_ds_builder
118+
# TODO(fdv2): Implement streaming data source builder
119+
lambda do |_sdk_key, _config|
120+
raise NotImplementedError, "Streaming data source not yet implemented for FDv2"
121+
end
122+
end
123+
124+
#
125+
# Default is LaunchDarkly's recommended flag data acquisition strategy.
126+
#
127+
# Currently, it operates a two-phase method for obtaining data: first, it
128+
# requests data from LaunchDarkly's global CDN. Then, it initiates a
129+
# streaming connection to LaunchDarkly's Flag Delivery services to
130+
# receive real-time updates.
131+
#
132+
# If the streaming connection is interrupted for an extended period of
133+
# time, the SDK will automatically fall back to polling the global CDN
134+
# for updates.
135+
#
136+
# @return [ConfigBuilder]
137+
#
138+
def self.default
139+
polling_builder = polling_ds_builder
140+
streaming_builder = streaming_ds_builder
141+
fallback = fdv1_fallback_ds_builder
142+
143+
builder = ConfigBuilder.new
144+
builder.initializers([polling_builder])
145+
builder.synchronizers(streaming_builder, polling_builder)
146+
builder.fdv1_compatible_synchronizer(fallback)
147+
148+
builder
149+
end
150+
151+
#
152+
# Streaming configures the SDK to efficiently stream flag/segment data
153+
# in the background, allowing evaluations to operate on the latest data
154+
# with no additional latency.
155+
#
156+
# @return [ConfigBuilder]
157+
#
158+
def self.streaming
159+
streaming_builder = streaming_ds_builder
160+
fallback = fdv1_fallback_ds_builder
161+
162+
builder = ConfigBuilder.new
163+
builder.synchronizers(streaming_builder)
164+
builder.fdv1_compatible_synchronizer(fallback)
165+
166+
builder
167+
end
168+
169+
#
170+
# Polling configures the SDK to regularly poll an endpoint for
171+
# flag/segment data in the background. This is less efficient than
172+
# streaming, but may be necessary in some network environments.
173+
#
174+
# @return [ConfigBuilder]
175+
#
176+
def self.polling
177+
polling_builder = polling_ds_builder
178+
fallback = fdv1_fallback_ds_builder
179+
180+
builder = ConfigBuilder.new
181+
builder.synchronizers(polling_builder)
182+
builder.fdv1_compatible_synchronizer(fallback)
183+
184+
builder
185+
end
186+
187+
#
188+
# Custom returns a builder suitable for creating a custom data
189+
# acquisition strategy. You may configure how the SDK uses a Persistent
190+
# Store, how the SDK obtains an initial set of data, and how the SDK
191+
# keeps data up-to-date.
192+
#
193+
# @return [ConfigBuilder]
194+
#
195+
def self.custom
196+
ConfigBuilder.new
197+
end
198+
199+
#
200+
# Daemon configures the SDK to read from a persistent store integration
201+
# that is populated by Relay Proxy or other SDKs. The SDK will not connect
202+
# to LaunchDarkly. In this mode, the SDK never writes to the data store.
203+
#
204+
# @param store [Object] The persistent store
205+
# @return [ConfigBuilder]
206+
#
207+
def self.daemon(store)
208+
default.data_store(store, LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY)
209+
end
210+
211+
#
212+
# PersistentStore is similar to default, with the addition of a persistent
213+
# store integration. Before data has arrived from LaunchDarkly, the SDK is
214+
# able to evaluate flags using data from the persistent store. Once fresh
215+
# data is available, the SDK will no longer read from the persistent store,
216+
# although it will keep it up-to-date.
217+
#
218+
# @param store [Object] The persistent store
219+
# @return [ConfigBuilder]
220+
#
221+
def self.persistent_store(store)
222+
default.data_store(store, LaunchDarkly::Interfaces::DataStoreMode::READ_WRITE)
223+
end
224+
end
225+
end
226+
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
require "concurrent"
4+
require "forwardable"
5+
require "ldclient-rb/interfaces"
6+
7+
module LaunchDarkly
8+
module Impl
9+
module DataSource
10+
#
11+
# Provides status tracking and listener management for data sources.
12+
#
13+
# This class implements the {LaunchDarkly::Interfaces::DataSource::StatusProvider} interface.
14+
# It maintains the current status of the data source and broadcasts status changes to listeners.
15+
#
16+
class StatusProviderV2
17+
include LaunchDarkly::Interfaces::DataSource::StatusProvider
18+
19+
extend Forwardable
20+
def_delegators :@status_broadcaster, :add_listener, :remove_listener
21+
22+
#
23+
# Creates a new status provider.
24+
#
25+
# @param status_broadcaster [LaunchDarkly::Impl::Broadcaster] Broadcaster for status changes
26+
#
27+
def initialize(status_broadcaster)
28+
@status_broadcaster = status_broadcaster
29+
@status = LaunchDarkly::Interfaces::DataSource::Status.new(
30+
LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING,
31+
Time.now,
32+
nil
33+
)
34+
@lock = Concurrent::ReadWriteLock.new
35+
end
36+
37+
# (see LaunchDarkly::Interfaces::DataSource::StatusProvider#status)
38+
def status
39+
@lock.with_read_lock do
40+
@status
41+
end
42+
end
43+
44+
# (see LaunchDarkly::Interfaces::DataSource::UpdateSink#update_status)
45+
def update_status(new_state, new_error)
46+
status_to_broadcast = nil
47+
48+
@lock.with_write_lock do
49+
old_status = @status
50+
51+
# Special handling: INTERRUPTED during INITIALIZING stays INITIALIZING
52+
if new_state == LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED &&
53+
old_status.state == LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING
54+
new_state = LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING
55+
end
56+
57+
# No change if state is the same and no error
58+
return if new_state == old_status.state && new_error.nil?
59+
60+
new_since = new_state == old_status.state ? @status.state_since : Time.now
61+
new_error = @status.last_error if new_error.nil?
62+
63+
@status = LaunchDarkly::Interfaces::DataSource::Status.new(
64+
new_state,
65+
new_since,
66+
new_error
67+
)
68+
69+
status_to_broadcast = @status
70+
end
71+
72+
@status_broadcaster.broadcast(status_to_broadcast) if status_to_broadcast
73+
end
74+
end
75+
end
76+
end
77+
end
78+

0 commit comments

Comments
 (0)