launchdarkly-server-sdk 7.3.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ require 'thread'
2
+
3
+ module LaunchDarkly
4
+ module Impl
5
+ module Migrations
6
+
7
+ #
8
+ # A migration config stores references to callable methods which execute customer defined read or write
9
+ # operations on old or new origins of information. For read operations, an optional comparison function also be
10
+ # defined.
11
+ #
12
+ class MigrationConfig
13
+ #
14
+ # @param old [#call] Refer to {#old}
15
+ # @param new [#call] Refer to {#new}
16
+ # @param comparison [#call, nil] Refer to {#comparison}
17
+ #
18
+ def initialize(old, new, comparison)
19
+ @old = old
20
+ @new = new
21
+ @comparison = comparison
22
+ end
23
+
24
+ #
25
+ # Callable which receives a nullable payload parameter and returns an {LaunchDarkly::Result}.
26
+ #
27
+ # This function call should affect the old migration origin when called.
28
+ #
29
+ # @return [#call]
30
+ #
31
+ attr_reader :old
32
+
33
+ #
34
+ # Callable which receives a nullable payload parameter and returns an {LaunchDarkly::Result}.
35
+ #
36
+ # This function call should affect the new migration origin when called.
37
+ #
38
+ # @return [#call]
39
+ #
40
+ attr_reader :new
41
+
42
+ #
43
+ # Optional callable which receives two objects of any kind and returns a boolean representing equality.
44
+ #
45
+ # The result of this comparison can be sent upstream to LaunchDarkly to enhance migration observability.
46
+ #
47
+ # @return [#call, nil]
48
+ #
49
+ attr_reader :comparison
50
+ end
51
+
52
+ #
53
+ # An implementation of the [LaunchDarkly::Interfaces::Migrations::Migrator] interface, capable of supporting
54
+ # feature-flag backed technology migrations.
55
+ #
56
+ class Migrator
57
+ include LaunchDarkly::Interfaces::Migrations::Migrator
58
+
59
+ #
60
+ # @param client [LaunchDarkly::LDClient]
61
+ # @param read_execution_order [Symbol]
62
+ # @param read_config [MigrationConfig]
63
+ # @param write_config [MigrationConfig]
64
+ # @param measure_latency [Boolean]
65
+ # @param measure_errors [Boolean]
66
+ #
67
+ def initialize(client, read_execution_order, read_config, write_config, measure_latency, measure_errors)
68
+ @client = client
69
+ @read_execution_order = read_execution_order
70
+ @read_config = read_config
71
+ @write_config = write_config
72
+ @measure_latency = measure_latency
73
+ @measure_errors = measure_errors
74
+ @sampler = LaunchDarkly::Impl::Sampler.new(Random.new)
75
+ end
76
+
77
+ #
78
+ # Perform the configured read operations against the appropriate old and/or new origins.
79
+ #
80
+ # @param key [String] The migration-based flag key to use for determining migration stages
81
+ # @param context [LaunchDarkly::LDContext] The context to use for evaluating the migration flag
82
+ # @param default_stage [Symbol] The stage to fallback to if one could not be determined for the requested flag
83
+ # @param payload [String] An optional payload to pass through to the configured read operations.
84
+ #
85
+ # @return [LaunchDarkly::Migrations::OperationResult]
86
+ #
87
+ def read(key, context, default_stage, payload = nil)
88
+ stage, tracker = @client.migration_variation(key, context, default_stage)
89
+ tracker.operation(LaunchDarkly::Migrations::OP_READ)
90
+
91
+ old = Executor.new(@client.logger, LaunchDarkly::Migrations::ORIGIN_OLD, @read_config.old, tracker, @measure_latency, @measure_errors, payload)
92
+ new = Executor.new(@client.logger, LaunchDarkly::Migrations::ORIGIN_NEW, @read_config.new, tracker, @measure_latency, @measure_errors, payload)
93
+
94
+ case stage
95
+ when LaunchDarkly::Migrations::STAGE_OFF
96
+ result = old.run
97
+ when LaunchDarkly::Migrations::STAGE_DUALWRITE
98
+ result = old.run
99
+ when LaunchDarkly::Migrations::STAGE_SHADOW
100
+ result = read_both(old, new, @read_config.comparison, @read_execution_order, tracker)
101
+ when LaunchDarkly::Migrations::STAGE_LIVE
102
+ result = read_both(new, old, @read_config.comparison, @read_execution_order, tracker)
103
+ when LaunchDarkly::Migrations::STAGE_RAMPDOWN
104
+ result = new.run
105
+ when LaunchDarkly::Migrations::STAGE_COMPLETE
106
+ result = new.run
107
+ else
108
+ result = LaunchDarkly::Migrations::OperationResult.new(
109
+ LaunchDarkly::Migrations::ORIGIN_OLD,
110
+ LaunchDarkly::Result.fail("invalid stage #{stage}; cannot execute read")
111
+ )
112
+ end
113
+
114
+ @client.track_migration_op(tracker)
115
+
116
+ result
117
+ end
118
+
119
+ #
120
+ # Perform the configured write operations against the appropriate old and/or new origins.
121
+ #
122
+ # @param key [String] The migration-based flag key to use for determining migration stages
123
+ # @param context [LaunchDarkly::LDContext] The context to use for evaluating the migration flag
124
+ # @param default_stage [Symbol] The stage to fallback to if one could not be determined for the requested flag
125
+ # @param payload [String] An optional payload to pass through to the configured write operations.
126
+ #
127
+ # @return [LaunchDarkly::Migrations::WriteResult]
128
+ #
129
+ def write(key, context, default_stage, payload = nil)
130
+ stage, tracker = @client.migration_variation(key, context, default_stage)
131
+ tracker.operation(LaunchDarkly::Migrations::OP_WRITE)
132
+
133
+ old = Executor.new(@client.logger, LaunchDarkly::Migrations::ORIGIN_OLD, @write_config.old, tracker, @measure_latency, @measure_errors, payload)
134
+ new = Executor.new(@client.logger, LaunchDarkly::Migrations::ORIGIN_NEW, @write_config.new, tracker, @measure_latency, @measure_errors, payload)
135
+
136
+ case stage
137
+ when LaunchDarkly::Migrations::STAGE_OFF
138
+ result = old.run()
139
+ write_result = LaunchDarkly::Migrations::WriteResult.new(result)
140
+ when LaunchDarkly::Migrations::STAGE_DUALWRITE
141
+ authoritative_result, nonauthoritative_result = write_both(old, new, tracker)
142
+ write_result = LaunchDarkly::Migrations::WriteResult.new(authoritative_result, nonauthoritative_result)
143
+ when LaunchDarkly::Migrations::STAGE_SHADOW
144
+ authoritative_result, nonauthoritative_result = write_both(old, new, tracker)
145
+ write_result = LaunchDarkly::Migrations::WriteResult.new(authoritative_result, nonauthoritative_result)
146
+ when LaunchDarkly::Migrations::STAGE_LIVE
147
+ authoritative_result, nonauthoritative_result = write_both(new, old, tracker)
148
+ write_result = LaunchDarkly::Migrations::WriteResult.new(authoritative_result, nonauthoritative_result)
149
+ when LaunchDarkly::Migrations::STAGE_RAMPDOWN
150
+ authoritative_result, nonauthoritative_result = write_both(new, old, tracker)
151
+ write_result = LaunchDarkly::Migrations::WriteResult.new(authoritative_result, nonauthoritative_result)
152
+ when LaunchDarkly::Migrations::STAGE_COMPLETE
153
+ result = new.run()
154
+ write_result = LaunchDarkly::Migrations::WriteResult.new(result)
155
+ else
156
+ result = LaunchDarkly::Migrations::OperationResult.fail(
157
+ LaunchDarkly::Migrations::ORIGIN_OLD,
158
+ LaunchDarkly::Result.fail("invalid stage #{stage}; cannot execute write")
159
+ )
160
+ write_result = LaunchDarkly::Migrations::WriteResult.new(result)
161
+ end
162
+
163
+ @client.track_migration_op(tracker)
164
+
165
+ write_result
166
+ end
167
+
168
+ #
169
+ # Execute both read methods in accordance with the requested execution order.
170
+ #
171
+ # This method always returns the {LaunchDarkly::Migrations::OperationResult} from running the authoritative read operation. The
172
+ # non-authoritative executor may fail but it will not affect the return value.
173
+ #
174
+ # @param authoritative [Executor]
175
+ # @param nonauthoritative [Executor]
176
+ # @param comparison [#call]
177
+ # @param execution_order [Symbol]
178
+ # @param tracker [LaunchDarkly::Interfaces::Migrations::OpTracker]
179
+ #
180
+ # @return [LaunchDarkly::Migrations::OperationResult]
181
+ #
182
+ private def read_both(authoritative, nonauthoritative, comparison, execution_order, tracker)
183
+ authoritative_result = nil
184
+ nonauthoritative_result = nil
185
+
186
+ case execution_order
187
+ when LaunchDarkly::Migrations::MigratorBuilder::EXECUTION_PARALLEL
188
+ auth_handler = Thread.new { authoritative_result = authoritative.run }
189
+ nonauth_handler = Thread.new { nonauthoritative_result = nonauthoritative.run }
190
+
191
+ auth_handler.join()
192
+ nonauth_handler.join()
193
+ when LaunchDarkly::Migrations::MigratorBuilder::EXECUTION_RANDOM && @sampler.sample(2)
194
+ nonauthoritative_result = nonauthoritative.run
195
+ authoritative_result = authoritative.run
196
+ else
197
+ authoritative_result = authoritative.run
198
+ nonauthoritative_result = nonauthoritative.run
199
+ end
200
+
201
+ return authoritative_result if comparison.nil?
202
+
203
+ if authoritative_result.success? && nonauthoritative_result.success?
204
+ tracker.consistent(->{ comparison.call(authoritative_result.value, nonauthoritative_result.value) })
205
+ end
206
+
207
+ authoritative_result
208
+ end
209
+
210
+ #
211
+ # Execute both operations sequentially.
212
+ #
213
+ # If the authoritative executor fails, do not run the non-authoritative one. As a result, this method will
214
+ # always return an authoritative {LaunchDarkly::Migrations::OperationResult} as the first value, and optionally the non-authoritative
215
+ # {LaunchDarkly::Migrations::OperationResult} as the second value.
216
+ #
217
+ # @param authoritative [Executor]
218
+ # @param nonauthoritative [Executor]
219
+ # @param tracker [LaunchDarkly::Interfaces::Migrations::OpTracker]
220
+ #
221
+ # @return [Array<LaunchDarkly::Migrations::OperationResult, [LaunchDarkly::Migrations::OperationResult, nil]>]
222
+ #
223
+ private def write_both(authoritative, nonauthoritative, tracker)
224
+ authoritative_result = authoritative.run()
225
+ tracker.invoked(authoritative.origin)
226
+
227
+ return authoritative_result, nil unless authoritative_result.success?
228
+
229
+ nonauthoritative_result = nonauthoritative.run()
230
+ tracker.invoked(nonauthoritative.origin)
231
+
232
+ [authoritative_result, nonauthoritative_result]
233
+ end
234
+ end
235
+
236
+ #
237
+ # Utility class for executing migration operations while also tracking our built-in migration measurements.
238
+ #
239
+ class Executor
240
+ #
241
+ # @return [Symbol]
242
+ #
243
+ attr_reader :origin
244
+
245
+ #
246
+ # @param origin [Symbol]
247
+ # @param fn [#call]
248
+ # @param tracker [LaunchDarkly::Interfaces::Migrations::OpTracker]
249
+ # @param measure_latency [Boolean]
250
+ # @param measure_errors [Boolean]
251
+ # @param payload [Object, nil]
252
+ #
253
+ def initialize(logger, origin, fn, tracker, measure_latency, measure_errors, payload)
254
+ @logger = logger
255
+ @origin = origin
256
+ @fn = fn
257
+ @tracker = tracker
258
+ @measure_latency = measure_latency
259
+ @measure_errors = measure_errors
260
+ @payload = payload
261
+ end
262
+
263
+ #
264
+ # Execute the configured operation and track any available measurements.
265
+ #
266
+ # @return [LaunchDarkly::Migrations::OperationResult]
267
+ #
268
+ def run()
269
+ start = Time.now
270
+
271
+ begin
272
+ result = @fn.call(@payload)
273
+ rescue => e
274
+ LaunchDarkly::Util.log_exception(@logger, "Unexpected error running method for '#{origin}' origin", e)
275
+ result = LaunchDarkly::Result.fail("'#{origin}' operation raised an exception", e)
276
+ end
277
+
278
+ @tracker.latency(@origin, (Time.now - start) * 1_000) if @measure_latency
279
+ @tracker.error(@origin) if @measure_errors && !result.success?
280
+ @tracker.invoked(@origin)
281
+
282
+ LaunchDarkly::Migrations::OperationResult.new(@origin, result)
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,136 @@
1
+ require "set"
2
+ require "ldclient-rb/impl/sampler"
3
+ require "logger"
4
+
5
+ module LaunchDarkly
6
+ module Impl
7
+ module Migrations
8
+ class OpTracker
9
+ include LaunchDarkly::Interfaces::Migrations::OpTracker
10
+
11
+ #
12
+ # @param logger [Logger] logger
13
+ # @param key [string] key
14
+ # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] flag
15
+ # @param context [LaunchDarkly::LDContext] context
16
+ # @param detail [LaunchDarkly::EvaluationDetail] detail
17
+ # @param default_stage [Symbol] default_stage
18
+ #
19
+ def initialize(logger, key, flag, context, detail, default_stage)
20
+ @logger = logger
21
+ @key = key
22
+ @flag = flag
23
+ @context = context
24
+ @detail = detail
25
+ @default_stage = default_stage
26
+ @sampler = LaunchDarkly::Impl::Sampler.new(Random.new)
27
+
28
+ @mutex = Mutex.new
29
+
30
+ # @type [Symbol, nil]
31
+ @operation = nil
32
+
33
+ # @type [Set<Symbol>]
34
+ @invoked = Set.new
35
+ # @type [Boolean, nil]
36
+ @consistent = nil
37
+
38
+ # @type [Int]
39
+ @consistent_ratio = @flag&.migration_settings&.check_ratio
40
+ @consistent_ratio = 1 if @consistent_ratio.nil?
41
+
42
+ # @type [Set<Symbol>]
43
+ @errors = Set.new
44
+ # @type [Hash<Symbol, Float>]
45
+ @latencies = Hash.new
46
+ end
47
+
48
+ def operation(operation)
49
+ return unless LaunchDarkly::Migrations::VALID_OPERATIONS.include? operation
50
+
51
+ @mutex.synchronize do
52
+ @operation = operation
53
+ end
54
+ end
55
+
56
+ def invoked(origin)
57
+ return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin
58
+
59
+ @mutex.synchronize do
60
+ @invoked.add(origin)
61
+ end
62
+ end
63
+
64
+ def consistent(is_consistent)
65
+ @mutex.synchronize do
66
+ if @sampler.sample(@consistent_ratio)
67
+ begin
68
+ @consistent = is_consistent.call
69
+ rescue => e
70
+ LaunchDarkly::Util.log_exception(@logger, "Exception raised during consistency check; failed to record measurement", e)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def error(origin)
77
+ return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin
78
+
79
+ @mutex.synchronize do
80
+ @errors.add(origin)
81
+ end
82
+ end
83
+
84
+ def latency(origin, duration)
85
+ return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin
86
+ return unless duration.is_a? Numeric
87
+ return if duration < 0
88
+
89
+ @mutex.synchronize do
90
+ @latencies[origin] = duration
91
+ end
92
+ end
93
+
94
+ def build
95
+ @mutex.synchronize do
96
+ return "operation cannot contain an empty key" if @key.empty?
97
+ return "operation not provided" if @operation.nil?
98
+ return "no origins were invoked" if @invoked.empty?
99
+ return "provided context was invalid" unless @context.valid?
100
+
101
+ result = check_invoked_consistency
102
+ return result unless result == true
103
+
104
+ LaunchDarkly::Impl::MigrationOpEvent.new(
105
+ LaunchDarkly::Impl::Util.current_time_millis,
106
+ @context,
107
+ @key,
108
+ @flag,
109
+ @operation,
110
+ @default_stage,
111
+ @detail,
112
+ @invoked,
113
+ @consistent,
114
+ @consistent_ratio,
115
+ @errors,
116
+ @latencies
117
+ )
118
+ end
119
+ end
120
+
121
+ private def check_invoked_consistency
122
+ LaunchDarkly::Migrations::VALID_ORIGINS.each do |origin|
123
+ next if @invoked.include? origin
124
+
125
+ return "provided latency for origin '#{origin}' without recording invocation" if @latencies.include? origin
126
+ return "provided error for origin '#{origin}' without recording invocation" if @errors.include? origin
127
+ end
128
+
129
+ return "provided consistency without recording both invocations" if !@consistent.nil? && @invoked.size != 2
130
+
131
+ true
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -26,6 +26,10 @@ module LaunchDarkly
26
26
  @version = data[:version]
27
27
  @deleted = !!data[:deleted]
28
28
  return if @deleted
29
+ migration_settings = data[:migration] || {}
30
+ @migration_settings = MigrationSettings.new(migration_settings[:checkRatio])
31
+ @sampling_ratio = data[:samplingRatio]
32
+ @exclude_from_summaries = !!data[:excludeFromSummaries]
29
33
  @variations = data[:variations] || []
30
34
  @on = !!data[:on]
31
35
  fallthrough = data[:fallthrough] || {}
@@ -63,6 +67,12 @@ module LaunchDarkly
63
67
  attr_reader :version
64
68
  # @return [Boolean]
65
69
  attr_reader :deleted
70
+ # @return [MigrationSettings, nil]
71
+ attr_reader :migration_settings
72
+ # @return [Integer, nil]
73
+ attr_reader :sampling_ratio
74
+ # @return [Boolean, nil]
75
+ attr_reader :exclude_from_summaries
66
76
  # @return [Array]
67
77
  attr_reader :variations
68
78
  # @return [Boolean]
@@ -173,6 +183,19 @@ module LaunchDarkly
173
183
  attr_reader :variation_or_rollout
174
184
  end
175
185
 
186
+
187
+ class MigrationSettings
188
+ #
189
+ # @param check_ratio [Int, nil]
190
+ #
191
+ def initialize(check_ratio)
192
+ @check_ratio = check_ratio
193
+ end
194
+
195
+ # @return [Integer, nil]
196
+ attr_reader :check_ratio
197
+ end
198
+
176
199
  class VariationOrRollout
177
200
  def initialize(variation, rollout_data, flag = nil, errors_out = nil, description = nil)
178
201
  @variation = variation
@@ -0,0 +1,25 @@
1
+ module LaunchDarkly
2
+ module Impl
3
+ class Sampler
4
+ #
5
+ # @param random [Random]
6
+ #
7
+ def initialize(random)
8
+ @random = random
9
+ end
10
+
11
+ #
12
+ # @param ratio [Int]
13
+ #
14
+ # @return [Boolean]
15
+ #
16
+ def sample(ratio)
17
+ return false unless ratio.is_a? Integer
18
+ return false if ratio <= 0
19
+ return true if ratio == 1
20
+
21
+ @random.rand(1.0) < 1.0 / ratio
22
+ end
23
+ end
24
+ end
25
+ end
@@ -43,6 +43,47 @@ module LaunchDarkly
43
43
  self
44
44
  end
45
45
 
46
+ #
47
+ # Set the migration related settings for this feature flag.
48
+ #
49
+ # The settings hash should be built using the {FlagMigrationSettingsBuilder}.
50
+ #
51
+ # @param settings [Hash]
52
+ # @return [FlagBuilder] the builder
53
+ #
54
+ def migration_settings(settings)
55
+ @migration_settings = settings
56
+ self
57
+ end
58
+
59
+ #
60
+ # Set the sampling ratio for this flag. This ratio is used to control the emission rate of feature, debug, and
61
+ # migration op events.
62
+ #
63
+ # General usage should not require interacting with this method.
64
+ #
65
+ # @param ratio [Integer]
66
+ # @return [FlagBuilder]
67
+ #
68
+ def sampling_ratio(ratio)
69
+ @sampling_ratio = ratio
70
+ self
71
+ end
72
+
73
+ #
74
+ # Set the option to exclude this flag from summary events. This is used to control the size of the summary event
75
+ # in the event certain flag payloads are large.
76
+ #
77
+ # General usage should not require interacting with this method.
78
+ #
79
+ # @param exclude [Boolean]
80
+ # @return [FlagBuilder]
81
+ #
82
+ def exclude_from_summaries(exclude)
83
+ @exclude_from_summaries = exclude
84
+ self
85
+ end
86
+
46
87
  #
47
88
  # Specifies the fallthrough variation. The fallthrough is the value
48
89
  # that is returned if targeting is on and the context was not matched by a more specific
@@ -128,11 +169,6 @@ module LaunchDarkly
128
169
  end
129
170
  end
130
171
 
131
- #
132
- # @deprecated Backwards compatibility alias for #variation_for_all
133
- #
134
- alias_method :variation_for_all_users, :variation_for_all
135
-
136
172
  #
137
173
  # Sets the flag to always return the specified variation value for all context.
138
174
  #
@@ -148,11 +184,6 @@ module LaunchDarkly
148
184
  variations(value).variation_for_all(0)
149
185
  end
150
186
 
151
- #
152
- # @deprecated Backwards compatibility alias for #value_for_all
153
- #
154
- alias_method :value_for_all_users, :value_for_all
155
-
156
187
  #
157
188
  # Sets the flag to return the specified variation for a specific context key when targeting
158
189
  # is on.
@@ -315,11 +346,6 @@ module LaunchDarkly
315
346
  self
316
347
  end
317
348
 
318
- #
319
- # @deprecated Backwards compatibility alias for #clear_targets
320
- #
321
- alias_method :clear_user_targets, :clear_targets
322
-
323
349
  #
324
350
  # Removes any existing rules from the flag.
325
351
  # This undoes the effect of methods like {#if_match}
@@ -376,6 +402,18 @@ module LaunchDarkly
376
402
  res[:fallthrough] = { variation: @fallthrough_variation }
377
403
  end
378
404
 
405
+ unless @migration_settings.nil?
406
+ res[:migration] = @migration_settings
407
+ end
408
+
409
+ unless @sampling_ratio.nil? || @sampling_ratio == 1
410
+ res[:samplingRatio] = @sampling_ratio
411
+ end
412
+
413
+ unless @exclude_from_summaries.nil? || !@exclude_from_summaries
414
+ res[:excludeFromSummaries] = @exclude_from_summaries
415
+ end
416
+
379
417
  unless @targets.nil?
380
418
  targets = []
381
419
  context_targets = []
@@ -403,6 +441,37 @@ module LaunchDarkly
403
441
  res
404
442
  end
405
443
 
444
+ #
445
+ # A builder for feature flag migration settings to be used with {FlagBuilder}.
446
+ #
447
+ # In the LaunchDarkly model, a flag can be a standard feature flag, or it can be a migration-related flag, in
448
+ # which case it has migration-specified related settings. These settings control things like the rate at which
449
+ # reads are tested for consistency between origins.
450
+ #
451
+ class FlagMigrationSettingsBuilder
452
+ def initialize()
453
+ @check_ratio = nil
454
+ end
455
+
456
+ #
457
+ # @param ratio [Integer]
458
+ # @return [FlagMigrationSettingsBuilder]
459
+ #
460
+ def check_ratio(ratio)
461
+ return unless ratio.is_a? Integer
462
+ @check_ratio = ratio
463
+ self
464
+ end
465
+
466
+ def build
467
+ return nil if @check_ratio.nil? || @check_ratio == 1
468
+
469
+ {
470
+ "checkRatio": @check_ratio,
471
+ }
472
+ end
473
+ end
474
+
406
475
  #
407
476
  # A builder for feature flag rules to be used with {FlagBuilder}.
408
477
  #