Skip to content

Commit 2ffe4e9

Browse files
authored
chore: Support running FDv2 contract tests (#359)
1 parent bfa571e commit 2ffe4e9

File tree

5 files changed

+196
-83
lines changed

5 files changed

+196
-83
lines changed

.github/actions/check/action.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,20 @@ runs:
3535
shell: bash
3636
run: make start-contract-test-service-bg
3737

38-
- uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.2.0
38+
- name: Run contract tests v2
3939
if: ${{ inputs.flaky != 'true' }}
40+
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
4041
with:
4142
test_service_port: 9000
4243
enable_persistence_tests: true
4344
token: ${{ inputs.token }}
45+
stop_service: 'false'
46+
47+
- name: Run contract tests v3
48+
if: ${{ inputs.flaky != 'true' }}
49+
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
50+
with:
51+
test_service_port: 9000
52+
enable_persistence_tests: true
53+
token: ${{ inputs.token }}
54+
version: v3.0.0-alpha.2

contract-tests/client_entity.rb

Lines changed: 175 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -14,55 +14,99 @@ def initialize(log, config)
1414

1515
opts[:logger] = log
1616

17-
if config[:streaming]
17+
data_system_config = config[:dataSystem]
18+
if data_system_config
19+
data_system = LaunchDarkly::DataSystem.custom
20+
21+
# For FDv2, persistent store config is nested inside dataSystem.store
22+
persistent_store_config = data_system_config.dig(:store, :persistentDataStore)
23+
if persistent_store_config
24+
store, store_mode = build_persistent_store(persistent_store_config)
25+
data_system.data_store(store, store_mode)
26+
end
27+
28+
init_configs = data_system_config[:initializers]
29+
if init_configs
30+
initializers = []
31+
init_configs.each do |init_config|
32+
polling = init_config[:polling]
33+
next unless polling
34+
35+
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
36+
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
37+
initializers << LaunchDarkly::DataSystem.polling_ds_builder
38+
end
39+
data_system.initializers(initializers)
40+
end
41+
42+
sync_config = data_system_config[:synchronizers]
43+
if sync_config
44+
primary = sync_config[:primary]
45+
secondary = sync_config[:secondary]
46+
47+
primary_builder = nil
48+
secondary_builder = nil
49+
50+
if primary
51+
streaming = primary[:streaming]
52+
if streaming
53+
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
54+
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
55+
primary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
56+
elsif primary[:polling]
57+
polling = primary[:polling]
58+
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
59+
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
60+
primary_builder = LaunchDarkly::DataSystem.polling_ds_builder
61+
end
62+
end
63+
64+
if secondary
65+
streaming = secondary[:streaming]
66+
if streaming
67+
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
68+
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
69+
secondary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
70+
elsif secondary[:polling]
71+
polling = secondary[:polling]
72+
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
73+
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
74+
secondary_builder = LaunchDarkly::DataSystem.polling_ds_builder
75+
end
76+
end
77+
78+
data_system.synchronizers(primary_builder, secondary_builder) if primary_builder
79+
80+
if primary_builder || secondary_builder
81+
fallback_builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder
82+
data_system.fdv1_compatible_synchronizer(fallback_builder)
83+
end
84+
end
85+
86+
if data_system_config[:payloadFilter]
87+
opts[:payload_filter_key] = data_system_config[:payloadFilter]
88+
end
89+
90+
opts[:data_system_config] = data_system.build
91+
elsif config[:streaming]
1892
streaming = config[:streaming]
1993
opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil?
2094
opts[:payload_filter_key] = streaming[:filter] unless streaming[:filter].nil?
21-
opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 unless streaming[:initialRetryDelayMs].nil?
95+
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
2296
elsif config[:polling]
2397
polling = config[:polling]
2498
opts[:stream] = false
2599
opts[:base_uri] = polling[:baseUri] unless polling[:baseUri].nil?
26100
opts[:payload_filter_key] = polling[:filter] unless polling[:filter].nil?
27-
opts[:poll_interval] = polling[:pollIntervalMs] / 1_000.0 unless polling[:pollIntervalMs].nil?
101+
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
28102
else
29103
opts[:use_ldd] = true
30104
end
31105

32-
if config[:persistentDataStore]
33-
store_config = {}
34-
store_config[:prefix] = config[:persistentDataStore][:store][:prefix] if config[:persistentDataStore][:store][:prefix]
35-
36-
case config[:persistentDataStore][:cache][:mode]
37-
when 'off'
38-
store_config[:expiration] = 0
39-
when 'infinite'
40-
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
41-
# 15 second expiration time in the SDK, which is long enough to pass any test.
42-
store_config[:expiration] = nil
43-
when 'ttl'
44-
store_config[:expiration] = config[:persistentDataStore][:cache][:ttl]
45-
end
46-
47-
case config[:persistentDataStore][:store][:type]
48-
when 'redis'
49-
store_config[:redis_url] = config[:persistentDataStore][:store][:dsn]
50-
store = LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
51-
opts[:feature_store] = store
52-
when 'consul'
53-
store_config[:url] = config[:persistentDataStore][:store][:url]
54-
store = LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
55-
opts[:feature_store] = store
56-
when 'dynamodb'
57-
client = Aws::DynamoDB::Client.new(
58-
region: 'us-east-1',
59-
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
60-
endpoint: config[:persistentDataStore][:store][:dsn]
61-
)
62-
store_config[:existing_client] = client
63-
store = LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
64-
opts[:feature_store] = store
65-
end
106+
# Configure persistent data store for legacy (non-dataSystem) configurations
107+
if !data_system_config && config[:persistentDataStore]
108+
store, _store_mode = build_persistent_store(config[:persistentDataStore])
109+
opts[:feature_store] = store
66110
end
67111

68112
if config[:events]
@@ -72,7 +116,7 @@ def initialize(log, config)
72116
opts[:diagnostic_opt_out] = !events[:enableDiagnostics]
73117
opts[:all_attributes_private] = !!events[:allAttributesPrivate]
74118
opts[:private_attributes] = events[:globalPrivateAttributes]
75-
opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) unless events[:flushIntervalMs].nil?
119+
set_optional_time_prop(events, :flushIntervalMs, opts, :flush_interval)
76120
opts[:omit_anonymous_contexts] = !!events[:omitAnonymousContexts]
77121
opts[:compress_events] = !!events[:enableGzip]
78122
else
@@ -81,19 +125,14 @@ def initialize(log, config)
81125

82126
if config[:bigSegments]
83127
big_segments = config[:bigSegments]
128+
big_config = { store: BigSegmentStoreFixture.new(big_segments[:callbackUri]) }
84129

85-
store = BigSegmentStoreFixture.new(config[:bigSegments][:callbackUri])
86-
context_cache_time = big_segments[:userCacheTimeMs].nil? ? nil : big_segments[:userCacheTimeMs] / 1_000
87-
status_poll_interval_ms = big_segments[:statusPollIntervalMs].nil? ? nil : big_segments[:statusPollIntervalMs] / 1_000
88-
stale_after_ms = big_segments[:staleAfterMs].nil? ? nil : big_segments[:staleAfterMs] / 1_000
89-
90-
opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(
91-
store: store,
92-
context_cache_size: big_segments[:userCacheSize],
93-
context_cache_time: context_cache_time,
94-
status_poll_interval: status_poll_interval_ms,
95-
stale_after: stale_after_ms
96-
)
130+
big_config[:context_cache_size] = big_segments[:userCacheSize] if big_segments[:userCacheSize]
131+
set_optional_time_prop(big_segments, :userCacheTimeMs, big_config, :context_cache_time)
132+
set_optional_time_prop(big_segments, :statusPollIntervalMs, big_config, :status_poll_interval)
133+
set_optional_time_prop(big_segments, :staleAfterMs, big_config, :stale_after)
134+
135+
opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(**big_config)
97136
end
98137

99138
if config[:tags]
@@ -198,6 +237,50 @@ def context_comparison(params)
198237
context1 == context2
199238
end
200239

240+
def secure_mode_hash(params)
241+
@client.secure_mode_hash(params[:context])
242+
end
243+
244+
def track(params)
245+
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
246+
end
247+
248+
def identify(params)
249+
@client.identify(params[:context])
250+
end
251+
252+
def flush_events
253+
@client.flush
254+
end
255+
256+
def get_big_segment_store_status
257+
status = @client.big_segment_store_status_provider.status
258+
{ available: status.available, stale: status.stale }
259+
end
260+
261+
def log
262+
@log
263+
end
264+
265+
def close
266+
@client.close
267+
@log.info("Test ended")
268+
end
269+
270+
#
271+
# Helper to convert millisecond time properties to seconds.
272+
# Only sets the output if the input value is present.
273+
#
274+
# @param params_in [Hash] Input parameters hash
275+
# @param name_in [Symbol] Key name in input hash (e.g., :pollIntervalMs)
276+
# @param params_out [Hash] Output parameters hash
277+
# @param name_out [Symbol] Key name in output hash (e.g., :poll_interval)
278+
#
279+
private def set_optional_time_prop(params_in, name_in, params_out, name_out)
280+
value = params_in[name_in]
281+
params_out[name_out] = value / 1_000.0 if value
282+
end
283+
201284
private def build_context_from_params(params)
202285
return build_single_context_from_attribute_definitions(params[:single]) unless params[:single].nil?
203286

@@ -230,33 +313,48 @@ def context_comparison(params)
230313
LaunchDarkly::LDContext.create(context)
231314
end
232315

233-
def secure_mode_hash(params)
234-
@client.secure_mode_hash(params[:context])
235-
end
236-
237-
def track(params)
238-
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
239-
end
240-
241-
def identify(params)
242-
@client.identify(params[:context])
243-
end
244-
245-
def flush_events
246-
@client.flush
247-
end
248-
249-
def get_big_segment_store_status
250-
status = @client.big_segment_store_status_provider.status
251-
{ available: status.available, stale: status.stale }
252-
end
253-
254-
def log
255-
@log
256-
end
316+
#
317+
# Builds a persistent data store from the contract test configuration.
318+
#
319+
# @param persistent_store_config [Hash] The persistentDataStore configuration
320+
# @return [Array<Object, Symbol>] Returns [store, store_mode]
321+
#
322+
private def build_persistent_store(persistent_store_config)
323+
store_config = {}
324+
store_config[:prefix] = persistent_store_config[:store][:prefix] if persistent_store_config[:store][:prefix]
325+
326+
case persistent_store_config[:cache][:mode]
327+
when 'off'
328+
store_config[:expiration] = 0
329+
when 'infinite'
330+
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
331+
# 15 second expiration time in the SDK, which is long enough to pass any test.
332+
store_config[:expiration] = nil
333+
when 'ttl'
334+
store_config[:expiration] = persistent_store_config[:cache][:ttl]
335+
end
257336

258-
def close
259-
@client.close
260-
@log.info("Test ended")
337+
store = case persistent_store_config[:store][:type]
338+
when 'redis'
339+
store_config[:redis_url] = persistent_store_config[:store][:dsn]
340+
LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
341+
when 'consul'
342+
store_config[:url] = persistent_store_config[:store][:url]
343+
LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
344+
when 'dynamodb'
345+
client = Aws::DynamoDB::Client.new(
346+
region: 'us-east-1',
347+
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
348+
endpoint: persistent_store_config[:store][:dsn]
349+
)
350+
store_config[:existing_client] = client
351+
LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
352+
end
353+
354+
store_mode = persistent_store_config[:mode] == 'read' ?
355+
LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY :
356+
LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE
357+
358+
[store, store_mode]
261359
end
262360
end

contract-tests/service.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99
configure :development do
1010
disable :show_exceptions
11+
set :host_authorization, { permitted_hosts: [] }
1112
end
1213

1314
$log = Logger.new(STDOUT)
1415
$log.formatter = proc {|severity, datetime, progname, msg|
1516
"[GLOBAL] #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n"
1617
}
1718

19+
set :bind, '0.0.0.0'
1820
set :port, 9000
1921
set :logging, false
2022

lib/ldclient-rb/impl/data_system/polling.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ module DataSystem
1717
FDV2_POLLING_ENDPOINT = "/sdk/poll"
1818
FDV1_POLLING_ENDPOINT = "/sdk/latest-all"
1919

20-
LD_ENVID_HEADER = "x-launchdarkly-env-id"
21-
LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback"
20+
LD_ENVID_HEADER = "X-LD-EnvID"
21+
LD_FD_FALLBACK_HEADER = "X-LD-FD-Fallback"
2222

2323
#
2424
# Requester protocol for polling data source

lib/ldclient-rb/ldclient.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,9 @@ def secure_mode_hash(context)
264264
# @return [Boolean] true if the client has been initialized
265265
#
266266
def initialized?
267-
@data_system.data_availability == @data_system.target_availability
267+
return true if @config.offline? || @config.use_ldd?
268+
269+
Impl::DataSystem::DataAvailability.at_least?(@data_system.data_availability, Impl::DataSystem::DataAvailability::CACHED)
268270
end
269271

270272
#
@@ -692,8 +694,8 @@ def flag_tracker
692694
return detail, nil, context.error
693695
end
694696

695-
unless initialized?
696-
if @data_system.store.initialized?
697+
if @data_system.data_availability != Impl::DataSystem::DataAvailability::REFRESHED
698+
if @data_system.data_availability == Impl::DataSystem::DataAvailability::CACHED
697699
@config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" }
698700
else
699701
@config.logger.error { "[LDClient] Client has not finished initializing; feature store unavailable, returning default value" }

0 commit comments

Comments
 (0)