From 9e8e697d806aa0f293332cfd1177a1217f919c70 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Fri, 23 Jan 2026 21:16:27 +0000 Subject: [PATCH 1/3] chore: Support running FDv2 contract tests --- .github/actions/check/action.yml | 13 +- Makefile | 2 +- contract-tests/client_entity.rb | 170 ++++++++++++++------ lib/ldclient-rb/impl/data_system/polling.rb | 4 +- 4 files changed, 139 insertions(+), 50 deletions(-) diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index 806b8c7b..2c386452 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -35,9 +35,20 @@ runs: shell: bash run: make start-contract-test-service-bg - - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.2.0 + - name: Run contract tests v2 if: ${{ inputs.flaky != 'true' }} + uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1 with: test_service_port: 9000 enable_persistence_tests: true token: ${{ inputs.token }} + stop_service: 'false' + + - name: Run contract tests v3 + if: ${{ inputs.flaky != 'true' }} + uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1 + with: + test_service_port: 9000 + enable_persistence_tests: true + token: ${{ inputs.token }} + version: v3.0.0-alpha.1 \ No newline at end of file diff --git a/Makefile b/Makefile index 2dd2279e..af9bb6bb 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ start-contract-test-service-bg: run-contract-tests: @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \ - | VERSION=v2 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + | VERSION=v3.0.0-alpha.1 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index ad2eb76c..f4085f87 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -14,17 +14,86 @@ def initialize(log, config) opts[:logger] = log - if config[:streaming] + data_system_config = config[:dataSystem] + if data_system_config + data_system = LaunchDarkly::DataSystem.custom + + init_configs = data_system_config[:initializers] + if init_configs + initializers = [] + init_configs.each do |init_config| + polling = init_config[:polling] + next unless polling + + opts[:base_uri] = polling[:baseUri] if polling[:baseUri] + set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval) + initializers << LaunchDarkly::DataSystem.polling_ds_builder + end + data_system.initializers(initializers) + end + + sync_config = data_system_config[:synchronizers] + if sync_config + primary = sync_config[:primary] + secondary = sync_config[:secondary] + + primary_builder = nil + secondary_builder = nil + + if primary + streaming = primary[:streaming] + if streaming + opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri] + set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay) + primary_builder = LaunchDarkly::DataSystem.streaming_ds_builder + elsif primary[:polling] + polling = primary[:polling] + opts[:base_uri] = polling[:baseUri] if polling[:baseUri] + set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval) + primary_builder = LaunchDarkly::DataSystem.polling_ds_builder + end + end + + if secondary + streaming = secondary[:streaming] + if streaming + opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri] + set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay) + secondary_builder = LaunchDarkly::DataSystem.streaming_ds_builder + elsif secondary[:polling] + polling = secondary[:polling] + opts[:base_uri] = polling[:baseUri] if polling[:baseUri] + set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval) + secondary_builder = LaunchDarkly::DataSystem.polling_ds_builder + end + end + + data_system.synchronizers(primary_builder, secondary_builder) if primary_builder + + # Always configure FDv1 fallback when synchronizers are present + # The fallback is triggered by the LD-FD-Fallback header from the server + if primary_builder || secondary_builder + fallback_builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder + data_system.fdv1_compatible_synchronizer(fallback_builder) + end + end + + if data_system_config[:payloadFilter] + opts[:payload_filter_key] = data_system_config[:payloadFilter] + end + + opts[:data_system_config] = data_system.build + elsif config[:streaming] streaming = config[:streaming] opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil? opts[:payload_filter_key] = streaming[:filter] unless streaming[:filter].nil? - opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 unless streaming[:initialRetryDelayMs].nil? + set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay) elsif config[:polling] polling = config[:polling] opts[:stream] = false opts[:base_uri] = polling[:baseUri] unless polling[:baseUri].nil? opts[:payload_filter_key] = polling[:filter] unless polling[:filter].nil? - opts[:poll_interval] = polling[:pollIntervalMs] / 1_000.0 unless polling[:pollIntervalMs].nil? + set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval) else opts[:use_ldd] = true end @@ -72,7 +141,7 @@ def initialize(log, config) opts[:diagnostic_opt_out] = !events[:enableDiagnostics] opts[:all_attributes_private] = !!events[:allAttributesPrivate] opts[:private_attributes] = events[:globalPrivateAttributes] - opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) unless events[:flushIntervalMs].nil? + set_optional_time_prop(events, :flushIntervalMs, opts, :flush_interval) opts[:omit_anonymous_contexts] = !!events[:omitAnonymousContexts] opts[:compress_events] = !!events[:enableGzip] else @@ -81,19 +150,14 @@ def initialize(log, config) if config[:bigSegments] big_segments = config[:bigSegments] + big_config = { store: BigSegmentStoreFixture.new(big_segments[:callbackUri]) } - store = BigSegmentStoreFixture.new(config[:bigSegments][:callbackUri]) - context_cache_time = big_segments[:userCacheTimeMs].nil? ? nil : big_segments[:userCacheTimeMs] / 1_000 - status_poll_interval_ms = big_segments[:statusPollIntervalMs].nil? ? nil : big_segments[:statusPollIntervalMs] / 1_000 - stale_after_ms = big_segments[:staleAfterMs].nil? ? nil : big_segments[:staleAfterMs] / 1_000 - - opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new( - store: store, - context_cache_size: big_segments[:userCacheSize], - context_cache_time: context_cache_time, - status_poll_interval: status_poll_interval_ms, - stale_after: stale_after_ms - ) + big_config[:context_cache_size] = big_segments[:userCacheSize] if big_segments[:userCacheSize] + set_optional_time_prop(big_segments, :userCacheTimeMs, big_config, :context_cache_time) + set_optional_time_prop(big_segments, :statusPollIntervalMs, big_config, :status_poll_interval) + set_optional_time_prop(big_segments, :staleAfterMs, big_config, :stale_after) + + opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(**big_config) end if config[:tags] @@ -198,6 +262,50 @@ def context_comparison(params) context1 == context2 end + def secure_mode_hash(params) + @client.secure_mode_hash(params[:context]) + end + + def track(params) + @client.track(params[:eventKey], params[:context], params[:data], params[:metricValue]) + end + + def identify(params) + @client.identify(params[:context]) + end + + def flush_events + @client.flush + end + + def get_big_segment_store_status + status = @client.big_segment_store_status_provider.status + { available: status.available, stale: status.stale } + end + + def log + @log + end + + def close + @client.close + @log.info("Test ended") + end + + # + # Helper to convert millisecond time properties to seconds. + # Only sets the output if the input value is present. + # + # @param params_in [Hash] Input parameters hash + # @param name_in [Symbol] Key name in input hash (e.g., :pollIntervalMs) + # @param params_out [Hash] Output parameters hash + # @param name_out [Symbol] Key name in output hash (e.g., :poll_interval) + # + private def set_optional_time_prop(params_in, name_in, params_out, name_out) + value = params_in[name_in] + params_out[name_out] = value / 1_000.0 if value + end + private def build_context_from_params(params) return build_single_context_from_attribute_definitions(params[:single]) unless params[:single].nil? @@ -229,34 +337,4 @@ def context_comparison(params) LaunchDarkly::LDContext.create(context) end - - def secure_mode_hash(params) - @client.secure_mode_hash(params[:context]) - end - - def track(params) - @client.track(params[:eventKey], params[:context], params[:data], params[:metricValue]) - end - - def identify(params) - @client.identify(params[:context]) - end - - def flush_events - @client.flush - end - - def get_big_segment_store_status - status = @client.big_segment_store_status_provider.status - { available: status.available, stale: status.stale } - end - - def log - @log - end - - def close - @client.close - @log.info("Test ended") - end end diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index b1ead5ea..d85ff217 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -17,8 +17,8 @@ module DataSystem FDV2_POLLING_ENDPOINT = "/sdk/poll" FDV1_POLLING_ENDPOINT = "/sdk/latest-all" - LD_ENVID_HEADER = "x-launchdarkly-env-id" - LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback" + LD_ENVID_HEADER = "X-LD-EnvID" + LD_FD_FALLBACK_HEADER = "X-LD-FD-Fallback" # # Requester protocol for polling data source From b42f0056e2885367a702632a9bb2246744d1c6ff Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Fri, 23 Jan 2026 23:09:34 +0000 Subject: [PATCH 2/3] add support for persistent data stores --- contract-tests/client_entity.rb | 87 ++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index f4085f87..27f60186 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -18,6 +18,11 @@ def initialize(log, config) if data_system_config data_system = LaunchDarkly::DataSystem.custom + if config[:persistentDataStore] + store, store_mode = build_persistent_store(config[:persistentDataStore]) + data_system.data_store(store, store_mode) + end + init_configs = data_system_config[:initializers] if init_configs initializers = [] @@ -70,8 +75,6 @@ def initialize(log, config) data_system.synchronizers(primary_builder, secondary_builder) if primary_builder - # Always configure FDv1 fallback when synchronizers are present - # The fallback is triggered by the LD-FD-Fallback header from the server if primary_builder || secondary_builder fallback_builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder data_system.fdv1_compatible_synchronizer(fallback_builder) @@ -99,39 +102,8 @@ def initialize(log, config) end if config[:persistentDataStore] - store_config = {} - store_config[:prefix] = config[:persistentDataStore][:store][:prefix] if config[:persistentDataStore][:store][:prefix] - - case config[:persistentDataStore][:cache][:mode] - when 'off' - store_config[:expiration] = 0 - when 'infinite' - # NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default - # 15 second expiration time in the SDK, which is long enough to pass any test. - store_config[:expiration] = nil - when 'ttl' - store_config[:expiration] = config[:persistentDataStore][:cache][:ttl] - end - - case config[:persistentDataStore][:store][:type] - when 'redis' - store_config[:redis_url] = config[:persistentDataStore][:store][:dsn] - store = LaunchDarkly::Integrations::Redis.new_feature_store(store_config) - opts[:feature_store] = store - when 'consul' - store_config[:url] = config[:persistentDataStore][:store][:url] - store = LaunchDarkly::Integrations::Consul.new_feature_store(store_config) - opts[:feature_store] = store - when 'dynamodb' - client = Aws::DynamoDB::Client.new( - region: 'us-east-1', - credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'), - endpoint: config[:persistentDataStore][:store][:dsn] - ) - store_config[:existing_client] = client - store = LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config) - opts[:feature_store] = store - end + store, store_mode = build_persistent_store(config[:persistentDataStore]) + opts[:feature_store] = store end if config[:events] @@ -337,4 +309,49 @@ def close LaunchDarkly::LDContext.create(context) end + + # + # Builds a persistent data store from the contract test configuration. + # + # @param persistent_store_config [Hash] The persistentDataStore configuration + # @return [Array] Returns [store, store_mode] + # + private def build_persistent_store(persistent_store_config) + store_config = {} + store_config[:prefix] = persistent_store_config[:store][:prefix] if persistent_store_config[:store][:prefix] + + case persistent_store_config[:cache][:mode] + when 'off' + store_config[:expiration] = 0 + when 'infinite' + # NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default + # 15 second expiration time in the SDK, which is long enough to pass any test. + store_config[:expiration] = nil + when 'ttl' + store_config[:expiration] = persistent_store_config[:cache][:ttl] + end + + store = case persistent_store_config[:store][:type] + when 'redis' + store_config[:redis_url] = persistent_store_config[:store][:dsn] + LaunchDarkly::Integrations::Redis.new_feature_store(store_config) + when 'consul' + store_config[:url] = persistent_store_config[:store][:url] + LaunchDarkly::Integrations::Consul.new_feature_store(store_config) + when 'dynamodb' + client = Aws::DynamoDB::Client.new( + region: 'us-east-1', + credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'), + endpoint: persistent_store_config[:store][:dsn] + ) + store_config[:existing_client] = client + LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config) + end + + # Determine store mode based on whether it's read-write or read-only + # For contract tests with data sources (streaming/polling), stores are read-write + store_mode = LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE + + [store, store_mode] + end end From b4a104c5ba300560c90db0cbbe04f9c85c415bdc Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Fri, 23 Jan 2026 23:29:48 +0000 Subject: [PATCH 3/3] don't double configure --- contract-tests/client_entity.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 27f60186..0b0d9143 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -101,7 +101,8 @@ def initialize(log, config) opts[:use_ldd] = true end - if config[:persistentDataStore] + # Configure persistent data store for legacy (non-dataSystem) configurations + if !data_system_config && config[:persistentDataStore] store, store_mode = build_persistent_store(config[:persistentDataStore]) opts[:feature_store] = store end