Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add postfork method to re-initialize after forking #319

Merged
merged 1 commit into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contract-tests/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
'context-comparison',
'polling-gzip',
'inline-context-all',
'instance-id',
'anonymous-redaction',
'evaluation-hooks',
'omit-anonymous-contexts',
Expand Down
13 changes: 13 additions & 0 deletions lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def initialize(opts = {})
@hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook }
@omit_anonymous_contexts = opts.has_key?(:omit_anonymous_contexts) && opts[:omit_anonymous_contexts]
@data_source_update_sink = nil
@instance_id = nil
end

#
Expand All @@ -97,6 +98,18 @@ def initialize(opts = {})
#
attr_accessor :data_source_update_sink


#
# Returns the unique identifier for this instance of the SDK.
#
# This property should only be set by the SDK. Long term access of this
# property is not supported; it is temporarily being exposed to maintain
# backwards compatibility while the SDK structure is updated.
#
# @private
#
attr_accessor :instance_id

#
# The base URL for the LaunchDarkly server. This is configurable mainly for testing
# purposes; most users should use the default value.
Expand Down
3 changes: 3 additions & 0 deletions lib/ldclient-rb/impl/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ def self.current_time_millis

def self.default_http_headers(sdk_key, config)
ret = { "Authorization" => sdk_key, "User-Agent" => "RubyClient/" + LaunchDarkly::VERSION }

ret["X-LaunchDarkly-Instance-Id"] = config.instance_id unless config.instance_id.nil?

if config.wrapper_name
ret["X-LaunchDarkly-Wrapper"] = config.wrapper_name +
(config.wrapper_version ? "/" + config.wrapper_version : "")
Expand Down
51 changes: 42 additions & 9 deletions lib/ldclient-rb/ldclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require "digest/sha1"
require "forwardable"
require "logger"
require "securerandom"
require "benchmark"
require "json"
require "openssl"
Expand Down Expand Up @@ -56,25 +57,57 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
end

@sdk_key = sdk_key
@hooks = Concurrent::Array.new(config.hooks)
config.instance_id = SecureRandom.uuid
@config = config

start_up(wait_for_sec)
end

#
# Re-initializes an existing client after a process fork.
#
# The SDK relies on multiple background threads to operate correctly. When a process forks, `these threads are not
# available to the child <https://apidock.com/ruby/Process/fork/class>`.
#
# As a result, the SDK will not function correctly in the child process until it is re-initialized.
#
# This method is effectively equivalent to instantiating a new client. Future iterations of the SDK will provide
# increasingly efficient re-initializing improvements.
#
# Note that any configuration provided to the SDK will need to survive the forking process independently. For this
# reason, it is recommended that any listener or hook integrations be added postfork unless you are certain it can
# survive the forking process.
#
# @param wait_for_sec [Float] maximum time (in seconds) to wait for initialization
#
def postfork(wait_for_sec = 5)
@data_source = nil
@event_processor = nil
@big_segment_store_manager = nil

start_up(wait_for_sec)
end

private def start_up(wait_for_sec)
@hooks = Concurrent::Array.new(@config.hooks)

@shared_executor = Concurrent::SingleThreadExecutor.new

data_store_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@shared_executor, config.logger)
data_store_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@shared_executor, @config.logger)
store_sink = LaunchDarkly::Impl::DataStore::UpdateSink.new(data_store_broadcaster)

# We need to wrap the feature store object with a FeatureStoreClientWrapper in order to add
# some necessary logic around updates. Unfortunately, we have code elsewhere that accesses
# the feature store through the Config object, so we need to make a new Config that uses
# the wrapped store.
@store = Impl::FeatureStoreClientWrapper.new(config.feature_store, store_sink, config.logger)
updated_config = config.clone
@store = Impl::FeatureStoreClientWrapper.new(@config.feature_store, store_sink, @config.logger)
updated_config = @config.clone
updated_config.instance_variable_set(:@feature_store, @store)
@config = updated_config

@data_store_status_provider = LaunchDarkly::Impl::DataStore::StatusProvider.new(@store, store_sink)

@big_segment_store_manager = Impl::BigSegmentStoreManager.new(config.big_segments, @config.logger)
@big_segment_store_manager = Impl::BigSegmentStoreManager.new(@config.big_segments, @config.logger)
@big_segment_store_status_provider = @big_segment_store_manager.status_provider

get_flag = lambda { |key| @store.get(FEATURES, key) }
Expand All @@ -83,15 +116,15 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
@evaluator = LaunchDarkly::Impl::Evaluator.new(get_flag, get_segment, get_big_segments_membership, @config.logger)

if [email protected]? && @config.send_events && [email protected]_opt_out?
diagnostic_accumulator = Impl::DiagnosticAccumulator.new(Impl::DiagnosticAccumulator.create_diagnostic_id(sdk_key))
diagnostic_accumulator = Impl::DiagnosticAccumulator.new(Impl::DiagnosticAccumulator.create_diagnostic_id(@sdk_key))
else
diagnostic_accumulator = nil
end

if @config.offline? || [email protected]_events
@event_processor = NullEventProcessor.new
else
@event_processor = EventProcessor.new(sdk_key, config, nil, diagnostic_accumulator)
@event_processor = EventProcessor.new(@sdk_key, @config, nil, diagnostic_accumulator)
end

if @config.use_ldd?
Expand All @@ -115,9 +148,9 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
# Currently, data source factories take two parameters unless they need to be aware of diagnostic_accumulator, in
# which case they take three parameters. This will be changed in the future to use a less awkware mechanism.
if data_source_or_factory.arity == 3
@data_source = data_source_or_factory.call(sdk_key, @config, diagnostic_accumulator)
@data_source = data_source_or_factory.call(@sdk_key, @config, diagnostic_accumulator)
else
@data_source = data_source_or_factory.call(sdk_key, @config)
@data_source = data_source_or_factory.call(@sdk_key, @config)
end
else
@data_source = data_source_or_factory
Expand Down
2 changes: 2 additions & 0 deletions spec/impl/event_sender_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def with_sender_and_server(config_options = {})
it "sends analytics event data without compression enabled" do
with_sender_and_server(compress_events: false) do |es, server|
server.setup_ok_response("/bulk", "")
es.instance_variable_get(:@config).instance_id = 'instance-id'

result = es.send_event_data(fake_data, "", false)

Expand All @@ -43,6 +44,7 @@ def with_sender_and_server(config_options = {})
"content-type" => [ "application/json" ],
"user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ],
"x-launchdarkly-event-schema" => [ "4" ],
"x-launchdarkly-instance-id" => [ "instance-id" ],
"connection" => [ "Keep-Alive" ],
})
expect(req.header['x-launchdarkly-payload-id']).not_to eq []
Expand Down
2 changes: 2 additions & 0 deletions spec/requestor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def with_requestor(base_uri, opts = {})
it "uses expected URI and headers" do
with_server do |server|
with_requestor(server.base_uri.to_s) do |requestor|
requestor.instance_variable_get(:@config).instance_id = 'instance-id'
server.setup_ok_response("/", "{}")
requestor.request_all_data
expect(server.requests.count).to eq 1
Expand All @@ -27,6 +28,7 @@ def with_requestor(base_uri, opts = {})
"authorization" => [ sdk_key ],
"user-agent" => [ "RubyClient/" + VERSION ],
"x-launchdarkly-tags" => [ "application-id/id application-version/version" ],
"x-launchdarkly-instance-id" => [ "instance-id" ],
})
end
end
Expand Down