From 23d63df9e54d5352eff095e10d3ddb701739cb63 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 19 Mar 2025 12:34:09 -0400 Subject: [PATCH] feat: Add `posthook` method to re-initialize after forking --- contract-tests/service.rb | 1 + lib/ldclient-rb/config.rb | 13 +++++++++ lib/ldclient-rb/impl/util.rb | 3 ++ lib/ldclient-rb/ldclient.rb | 51 ++++++++++++++++++++++++++++------ spec/impl/event_sender_spec.rb | 2 ++ spec/requestor_spec.rb | 2 ++ 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/contract-tests/service.rb b/contract-tests/service.rb index dcea5a8a..2a002308 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -41,6 +41,7 @@ 'context-comparison', 'polling-gzip', 'inline-context-all', + 'instance-id', 'anonymous-redaction', 'evaluation-hooks', 'omit-anonymous-contexts', diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 91c929d4..bca3db2c 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -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 # @@ -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. diff --git a/lib/ldclient-rb/impl/util.rb b/lib/ldclient-rb/impl/util.rb index 6768c601..9e7faacc 100644 --- a/lib/ldclient-rb/impl/util.rb +++ b/lib/ldclient-rb/impl/util.rb @@ -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 : "") diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index 139707a7..379ab71e 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -13,6 +13,7 @@ require "digest/sha1" require "forwardable" require "logger" +require "securerandom" require "benchmark" require "json" require "openssl" @@ -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 `. + # + # 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) } @@ -83,7 +116,7 @@ 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 !@config.offline? && @config.send_events && !@config.diagnostic_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 @@ -91,7 +124,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5) if @config.offline? || !@config.send_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? @@ -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 diff --git a/spec/impl/event_sender_spec.rb b/spec/impl/event_sender_spec.rb index e6a971bd..517d7985 100644 --- a/spec/impl/event_sender_spec.rb +++ b/spec/impl/event_sender_spec.rb @@ -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) @@ -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 [] diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index f2817d9e..5f60b337 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -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 @@ -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