toggly 0.6.0 → 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +28 -0
- data/lib/toggly/client/snapshot_support.rb +5 -39
- data/lib/toggly/client.rb +80 -80
- data/lib/toggly/config.rb +30 -34
- data/lib/toggly/definitions_provider.rb +0 -167
- data/lib/toggly/feature_definition.rb +20 -3
- data/lib/toggly/feature_variant.rb +188 -0
- data/lib/toggly/snapshot_providers/base.rb +0 -35
- data/lib/toggly/snapshot_providers/file.rb +0 -54
- data/lib/toggly/snapshot_providers/memory.rb +0 -29
- data/lib/toggly/variant_allocator.rb +161 -0
- data/lib/toggly/variant_result.rb +51 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +26 -1
- metadata +5 -3
- data/lib/toggly/evaluated_variant.rb +0 -88
|
@@ -17,12 +17,6 @@ module Toggly
|
|
|
17
17
|
# Result of one HTTP definitions fetch with cache telemetry outcome.
|
|
18
18
|
FetchResult = Struct.new(:definitions, :cache_outcome, keyword_init: true)
|
|
19
19
|
|
|
20
|
-
# Result of one HTTP evaluated-variants fetch with cache telemetry outcome.
|
|
21
|
-
VariantFetchResult = Struct.new(:variants, :cache_outcome, keyword_init: true)
|
|
22
|
-
|
|
23
|
-
# Maximum number of string claims sent on the evaluated-variants-signed wire.
|
|
24
|
-
MAX_VARIANT_CLAIMS = 20
|
|
25
|
-
|
|
26
20
|
# Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
|
|
27
21
|
FALLBACK_REFRESH_INTERVAL = 20 * 60
|
|
28
22
|
|
|
@@ -43,13 +37,6 @@ module Toggly
|
|
|
43
37
|
@last_modified = nil
|
|
44
38
|
@last_ts = 0
|
|
45
39
|
|
|
46
|
-
# Evaluated-variants rail state — separate ETag/Last-Modified/timestamp
|
|
47
|
-
# namespace from definitions so the two rails never cross-invalidate.
|
|
48
|
-
@variant_etag = nil
|
|
49
|
-
@variant_last_modified = nil
|
|
50
|
-
@variant_last_ts = 0
|
|
51
|
-
@variant_identity = config.variant_identity
|
|
52
|
-
|
|
53
40
|
# WebSocket state
|
|
54
41
|
@ws = nil
|
|
55
42
|
@ws_connected = false
|
|
@@ -90,56 +77,6 @@ module Toggly
|
|
|
90
77
|
@last_ts = 0
|
|
91
78
|
end
|
|
92
79
|
|
|
93
|
-
# Fetch server-evaluated variants from `evaluated-variants-signed`.
|
|
94
|
-
# Independent ETag/timestamp cache from {#fetch} (separate rail).
|
|
95
|
-
#
|
|
96
|
-
# @param force [Boolean] Force fetch even if cached
|
|
97
|
-
# @return [VariantFetchResult] variants (Hash or nil) plus :hit / :miss outcome
|
|
98
|
-
# @raise [NetworkError] On network failures
|
|
99
|
-
# @raise [DefinitionsError] On API errors
|
|
100
|
-
def fetch_variants(force: false)
|
|
101
|
-
return VariantFetchResult.new(variants: nil, cache_outcome: :hit) if @config.offline_mode?
|
|
102
|
-
|
|
103
|
-
uri = URI.parse(build_variant_request_url)
|
|
104
|
-
http = build_http(uri)
|
|
105
|
-
request = build_variant_request(uri, force)
|
|
106
|
-
|
|
107
|
-
response = http.request(request)
|
|
108
|
-
handle_variant_response(response)
|
|
109
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
110
|
-
raise NetworkError, "Request timeout: #{e.message}"
|
|
111
|
-
rescue SocketError, Errno::ECONNREFUSED => e
|
|
112
|
-
raise NetworkError, "Connection failed: #{e.message}"
|
|
113
|
-
rescue NetworkError, DefinitionsError
|
|
114
|
-
raise
|
|
115
|
-
rescue StandardError => e
|
|
116
|
-
raise NetworkError, "Request failed: #{e.message}"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Update the `userId` sent to `evaluated-variants-signed`. Distinct from
|
|
120
|
-
# any per-call `Context#identity` used for local rule evaluation. Named
|
|
121
|
-
# as an action (not `variant_identity=`) because it also invalidates the
|
|
122
|
-
# variants cache — it is not a passive attribute writer.
|
|
123
|
-
#
|
|
124
|
-
# @param identity [String, nil]
|
|
125
|
-
# @return [Boolean] true if the identity changed (cache invalidated)
|
|
126
|
-
# rubocop:disable-next Naming/AccessorMethodName
|
|
127
|
-
def set_variant_identity(identity)
|
|
128
|
-
normalized = identity&.to_s
|
|
129
|
-
return false if normalized == @variant_identity
|
|
130
|
-
|
|
131
|
-
@variant_identity = normalized
|
|
132
|
-
reset_variant_cache
|
|
133
|
-
true
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Reset cached variant headers/timestamp (force full fetch next time)
|
|
137
|
-
def reset_variant_cache
|
|
138
|
-
@variant_etag = nil
|
|
139
|
-
@variant_last_modified = nil
|
|
140
|
-
@variant_last_ts = 0
|
|
141
|
-
end
|
|
142
|
-
|
|
143
80
|
# Check whether the periodic refresh should be skipped because the
|
|
144
81
|
# WebSocket connection is active and the fallback interval has not elapsed.
|
|
145
82
|
#
|
|
@@ -299,110 +236,6 @@ module Toggly
|
|
|
299
236
|
@last_modified = last_modified if last_modified && !last_modified.empty?
|
|
300
237
|
end
|
|
301
238
|
|
|
302
|
-
def build_variant_request_url
|
|
303
|
-
pairs = variant_query_pairs
|
|
304
|
-
base = @config.variants_endpoint
|
|
305
|
-
pairs.empty? ? base : "#{base}?#{URI.encode_www_form(pairs)}"
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def variant_query_pairs
|
|
309
|
-
pairs = []
|
|
310
|
-
pairs << ["userId", @variant_identity] if @variant_identity && !@variant_identity.empty?
|
|
311
|
-
|
|
312
|
-
Array(@config.variant_groups).each do |group|
|
|
313
|
-
next unless group.is_a?(String)
|
|
314
|
-
|
|
315
|
-
trimmed = group.strip
|
|
316
|
-
pairs << ["g", trimmed] unless trimmed.empty?
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
normalized_claims = {}
|
|
320
|
-
(@config.variant_claims || {}).each do |key, value|
|
|
321
|
-
next unless key.is_a?(String) && value.is_a?(String)
|
|
322
|
-
next if key.empty? || value.empty?
|
|
323
|
-
|
|
324
|
-
normalized_claims[key] = value
|
|
325
|
-
end
|
|
326
|
-
normalized_claims.keys.sort.first(MAX_VARIANT_CLAIMS).each do |key|
|
|
327
|
-
pairs << ["claim.#{key}", normalized_claims[key]]
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
pairs
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def build_variant_request(uri, force)
|
|
334
|
-
request = Net::HTTP::Get.new(uri)
|
|
335
|
-
request["Accept"] = "application/json"
|
|
336
|
-
request["User-Agent"] = "toggly-ruby/#{Toggly::VERSION}"
|
|
337
|
-
request["X-App-Version"] = @config.app_version if @config.app_version
|
|
338
|
-
request["X-Instance-Name"] = @config.instance_name if @config.instance_name
|
|
339
|
-
|
|
340
|
-
unless force
|
|
341
|
-
request["If-None-Match"] = @variant_etag if @variant_etag
|
|
342
|
-
request["If-Modified-Since"] = @variant_last_modified if @variant_last_modified
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
request
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def handle_variant_response(response)
|
|
349
|
-
status = response.code.to_i
|
|
350
|
-
response_etag = response["ETag"]
|
|
351
|
-
response_lm = response["Last-Modified"]
|
|
352
|
-
kind = DefinitionCache.classify_http(
|
|
353
|
-
status,
|
|
354
|
-
@variant_etag,
|
|
355
|
-
response_etag,
|
|
356
|
-
existing_last_modified: @variant_last_modified,
|
|
357
|
-
response_last_modified: response_lm
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
case kind
|
|
361
|
-
when :not_modified
|
|
362
|
-
log_debug("Evaluated variants not modified")
|
|
363
|
-
VariantFetchResult.new(variants: nil, cache_outcome: :hit)
|
|
364
|
-
when :same_revision
|
|
365
|
-
log_debug("Evaluated variants revision matches existing (ETag or Last-Modified)")
|
|
366
|
-
store_variant_revision_headers(response_etag, response_lm)
|
|
367
|
-
VariantFetchResult.new(variants: nil, cache_outcome: :hit)
|
|
368
|
-
when :new_content
|
|
369
|
-
handle_new_variant_content(response, response_etag, response_lm)
|
|
370
|
-
when :error_status
|
|
371
|
-
handle_error_status(status, response, resource: "evaluated variants")
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def handle_new_variant_content(response, response_etag, response_lm)
|
|
376
|
-
data = JSON.parse(response.body)
|
|
377
|
-
signed_ts = extract_signed_timestamp(data)
|
|
378
|
-
if DefinitionCache.cached_signed_timestamp?(@variant_last_ts, signed_ts)
|
|
379
|
-
log_debug("Evaluated variants signed timestamp is not newer than cached revision")
|
|
380
|
-
store_variant_revision_headers(response_etag, response_lm)
|
|
381
|
-
return VariantFetchResult.new(variants: nil, cache_outcome: :hit)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
variants = parse_variant_defs(data)
|
|
385
|
-
store_variant_revision_headers(response_etag, response_lm)
|
|
386
|
-
@variant_last_ts = signed_ts if signed_ts&.positive?
|
|
387
|
-
VariantFetchResult.new(variants: variants, cache_outcome: :miss)
|
|
388
|
-
rescue JSON::ParserError => e
|
|
389
|
-
raise DefinitionsError, "Failed to parse evaluated variants: #{e.message}"
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def store_variant_revision_headers(etag, last_modified)
|
|
393
|
-
@variant_etag = etag if etag && !etag.empty?
|
|
394
|
-
@variant_last_modified = last_modified if last_modified && !last_modified.empty?
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
def parse_variant_defs(data)
|
|
398
|
-
raw = data.is_a?(Hash) ? data["defs"] : nil
|
|
399
|
-
return {} unless raw.is_a?(Hash)
|
|
400
|
-
|
|
401
|
-
raw.each_with_object({}) do |(key, value), hash|
|
|
402
|
-
hash[key] = EvaluatedVariantDef.from_hash(value) if value.is_a?(Hash)
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
239
|
def extract_signed_timestamp(data)
|
|
407
240
|
return nil unless data.is_a?(Hash)
|
|
408
241
|
|
|
@@ -35,6 +35,12 @@ module Toggly
|
|
|
35
35
|
# @return [String, nil] Feature description
|
|
36
36
|
attr_reader :description
|
|
37
37
|
|
|
38
|
+
# @return [Array<FeatureVariant>] Named variants for MF-parity assignment
|
|
39
|
+
attr_reader :variants
|
|
40
|
+
|
|
41
|
+
# @return [FeatureVariantAllocation, nil] Allocation rules for variant assignment
|
|
42
|
+
attr_reader :allocation
|
|
43
|
+
|
|
38
44
|
# Feature types
|
|
39
45
|
TYPES = %w[Release Experiment Ops Permission].freeze
|
|
40
46
|
|
|
@@ -50,7 +56,9 @@ module Toggly
|
|
|
50
56
|
updated_at: nil,
|
|
51
57
|
requirement_type: "Any",
|
|
52
58
|
context_kind: nil,
|
|
53
|
-
context_requirement_type: nil
|
|
59
|
+
context_requirement_type: nil,
|
|
60
|
+
variants: [],
|
|
61
|
+
allocation: nil
|
|
54
62
|
)
|
|
55
63
|
@feature_key = feature_key.to_s
|
|
56
64
|
@feature_type = validate_type(feature_type)
|
|
@@ -63,6 +71,8 @@ module Toggly
|
|
|
63
71
|
@requirement_type = requirement_type || "Any"
|
|
64
72
|
@context_kind = context_kind
|
|
65
73
|
@context_requirement_type = context_requirement_type
|
|
74
|
+
@variants = Array(variants)
|
|
75
|
+
@allocation = allocation
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
# Create from a hash (e.g., from JSON)
|
|
@@ -83,6 +93,9 @@ module Toggly
|
|
|
83
93
|
!rules.empty?
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
variants = Array(hash[:variants]).map { |v| FeatureVariant.from_hash(v) }
|
|
97
|
+
allocation = FeatureVariantAllocation.from_hash(hash[:allocation])
|
|
98
|
+
|
|
86
99
|
new(
|
|
87
100
|
feature_key: hash[:featureKey] || hash[:feature_key],
|
|
88
101
|
feature_type: hash[:featureType] || hash[:feature_type] || "Release",
|
|
@@ -94,7 +107,9 @@ module Toggly
|
|
|
94
107
|
updated_at: hash[:updatedAt] || hash[:updated_at],
|
|
95
108
|
requirement_type: hash[:requirementType] || hash[:requirement_type] || "Any",
|
|
96
109
|
context_kind: hash[:contextKind] || hash[:context_kind],
|
|
97
|
-
context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type]
|
|
110
|
+
context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type],
|
|
111
|
+
variants: variants,
|
|
112
|
+
allocation: allocation
|
|
98
113
|
)
|
|
99
114
|
end
|
|
100
115
|
|
|
@@ -110,7 +125,9 @@ module Toggly
|
|
|
110
125
|
metadata: @metadata,
|
|
111
126
|
description: @description,
|
|
112
127
|
created_at: @created_at&.iso8601,
|
|
113
|
-
updated_at: @updated_at&.iso8601
|
|
128
|
+
updated_at: @updated_at&.iso8601,
|
|
129
|
+
variants: @variants.map(&:to_h),
|
|
130
|
+
allocation: @allocation&.to_h
|
|
114
131
|
}
|
|
115
132
|
end
|
|
116
133
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# A single named variant of a feature flag, as sent on the catalog
|
|
5
|
+
# (`definitions` / `definitions-signed`) wire alongside `filters`.
|
|
6
|
+
#
|
|
7
|
+
# Matches the shape produced by `Microsoft.FeatureManagement`'s
|
|
8
|
+
# `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
|
|
9
|
+
class FeatureVariant
|
|
10
|
+
# Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
|
|
11
|
+
STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
|
|
12
|
+
|
|
13
|
+
# @return [String] Variant name
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# @return [Object, nil] Untyped configuration payload for this variant
|
|
17
|
+
attr_reader :configuration_value
|
|
18
|
+
|
|
19
|
+
# @return [String] "None" | "Enabled" | "Disabled"
|
|
20
|
+
attr_reader :status_override
|
|
21
|
+
|
|
22
|
+
def initialize(name:, configuration_value: nil, status_override: "None")
|
|
23
|
+
@name = name.to_s
|
|
24
|
+
@configuration_value = configuration_value
|
|
25
|
+
@status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
|
|
29
|
+
# @return [FeatureVariant]
|
|
30
|
+
def self.from_hash(hash)
|
|
31
|
+
hash = symbolize_keys(hash)
|
|
32
|
+
configuration_value = if hash.key?(:configurationValue)
|
|
33
|
+
hash[:configurationValue]
|
|
34
|
+
else
|
|
35
|
+
hash[:configuration_value]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
name: hash[:name],
|
|
40
|
+
configuration_value: configuration_value,
|
|
41
|
+
status_override: hash[:statusOverride] || hash[:status_override] || "None"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
46
|
+
# reported enabled regardless of the base filter-evaluated state.
|
|
47
|
+
def enabled_override?
|
|
48
|
+
@status_override == "Enabled"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
52
|
+
# reported disabled regardless of the base filter-evaluated state.
|
|
53
|
+
def disabled_override?
|
|
54
|
+
@status_override == "Disabled"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
name: @name,
|
|
61
|
+
configuration_value: @configuration_value,
|
|
62
|
+
status_override: @status_override
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(FeatureVariant) &&
|
|
68
|
+
@name == other.name &&
|
|
69
|
+
@configuration_value == other.configuration_value &&
|
|
70
|
+
@status_override == other.status_override
|
|
71
|
+
end
|
|
72
|
+
alias eql? ==
|
|
73
|
+
|
|
74
|
+
def hash
|
|
75
|
+
[@name, @configuration_value, @status_override].hash
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.symbolize_keys(hash)
|
|
79
|
+
return {} unless hash.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
82
|
+
end
|
|
83
|
+
private_class_method :symbolize_keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Allocation rules for assigning variants to users/groups/percentile
|
|
87
|
+
# buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
|
|
88
|
+
class FeatureVariantAllocation
|
|
89
|
+
# @return [String, nil] Variant assigned when the feature is enabled and
|
|
90
|
+
# no user/group/percentile allocation matched.
|
|
91
|
+
attr_reader :default_when_enabled
|
|
92
|
+
|
|
93
|
+
# @return [String, nil] Variant assigned when the feature is disabled.
|
|
94
|
+
attr_reader :default_when_disabled
|
|
95
|
+
|
|
96
|
+
# @return [String, nil] Seed for the percentile hash; falls back to
|
|
97
|
+
# "allocation\n{featureName}" when nil.
|
|
98
|
+
attr_reader :seed
|
|
99
|
+
|
|
100
|
+
# @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
|
|
101
|
+
attr_reader :user
|
|
102
|
+
|
|
103
|
+
# @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
|
|
104
|
+
attr_reader :group
|
|
105
|
+
|
|
106
|
+
# @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
|
|
107
|
+
attr_reader :percentile
|
|
108
|
+
|
|
109
|
+
def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
|
|
110
|
+
@default_when_enabled = default_when_enabled
|
|
111
|
+
@default_when_disabled = default_when_disabled
|
|
112
|
+
@seed = seed
|
|
113
|
+
@user = Array(user)
|
|
114
|
+
@group = Array(group)
|
|
115
|
+
@percentile = Array(percentile)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param hash [Hash, nil]
|
|
119
|
+
# @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
|
|
120
|
+
def self.from_hash(hash)
|
|
121
|
+
return nil unless hash.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
hash = symbolize_keys(hash)
|
|
124
|
+
new(
|
|
125
|
+
default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
|
|
126
|
+
default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
|
|
127
|
+
seed: hash[:seed],
|
|
128
|
+
user: parse_entries(hash[:user], :users),
|
|
129
|
+
group: parse_entries(hash[:group], :groups),
|
|
130
|
+
percentile: parse_percentile_entries(hash[:percentile])
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def to_h
|
|
136
|
+
{
|
|
137
|
+
default_when_enabled: @default_when_enabled,
|
|
138
|
+
default_when_disabled: @default_when_disabled,
|
|
139
|
+
seed: @seed,
|
|
140
|
+
user: @user,
|
|
141
|
+
group: @group,
|
|
142
|
+
percentile: @percentile
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ==(other)
|
|
147
|
+
other.is_a?(FeatureVariantAllocation) &&
|
|
148
|
+
@default_when_enabled == other.default_when_enabled &&
|
|
149
|
+
@default_when_disabled == other.default_when_disabled &&
|
|
150
|
+
@seed == other.seed &&
|
|
151
|
+
@user == other.user &&
|
|
152
|
+
@group == other.group &&
|
|
153
|
+
@percentile == other.percentile
|
|
154
|
+
end
|
|
155
|
+
alias eql? ==
|
|
156
|
+
|
|
157
|
+
def hash
|
|
158
|
+
[@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.parse_entries(raw, members_key)
|
|
162
|
+
Array(raw).filter_map do |entry|
|
|
163
|
+
next unless entry.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
entry = symbolize_keys(entry)
|
|
166
|
+
{ variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :parse_entries
|
|
170
|
+
|
|
171
|
+
def self.parse_percentile_entries(raw)
|
|
172
|
+
Array(raw).filter_map do |entry|
|
|
173
|
+
next unless entry.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
entry = symbolize_keys(entry)
|
|
176
|
+
{ variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
private_class_method :parse_percentile_entries
|
|
180
|
+
|
|
181
|
+
def self.symbolize_keys(hash)
|
|
182
|
+
return {} unless hash.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
185
|
+
end
|
|
186
|
+
private_class_method :symbolize_keys
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -40,21 +40,6 @@ module Toggly
|
|
|
40
40
|
false
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Save evaluated variants snapshot (dual-rail, used when
|
|
44
|
-
# `config.enable_variants` is true). Default: no-op. Override in
|
|
45
|
-
# subclasses to persist variants across restarts.
|
|
46
|
-
#
|
|
47
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants to save
|
|
48
|
-
# @param metadata [Hash] Optional metadata
|
|
49
|
-
def save_variants(_variants, _metadata = {}); end
|
|
50
|
-
|
|
51
|
-
# Load evaluated variants snapshot (dual-rail).
|
|
52
|
-
#
|
|
53
|
-
# @return [Hash, nil] Hash with :variants and :metadata, or nil if not available
|
|
54
|
-
def load_variants
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
|
|
58
43
|
protected
|
|
59
44
|
|
|
60
45
|
# Serialize definitions to a storable format
|
|
@@ -77,26 +62,6 @@ module Toggly
|
|
|
77
62
|
hash[definition.feature_key] = definition
|
|
78
63
|
end
|
|
79
64
|
end
|
|
80
|
-
|
|
81
|
-
# Serialize evaluated variants to a storable format
|
|
82
|
-
#
|
|
83
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
84
|
-
# @return [Hash<String, Hash>]
|
|
85
|
-
def serialize_variants(variants)
|
|
86
|
-
variants.transform_values(&:to_h)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Deserialize evaluated variants from stored format
|
|
90
|
-
#
|
|
91
|
-
# @param data [Hash<String, Hash>] Serialized variants
|
|
92
|
-
# @return [Hash<String, EvaluatedVariantDef>]
|
|
93
|
-
def deserialize_variants(data)
|
|
94
|
-
return {} unless data.is_a?(Hash)
|
|
95
|
-
|
|
96
|
-
data.each_with_object({}) do |(key, value), hash|
|
|
97
|
-
hash[key.to_s] = EvaluatedVariantDef.from_hash(value)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
65
|
end
|
|
101
66
|
end
|
|
102
67
|
end
|
|
@@ -12,14 +12,10 @@ module Toggly
|
|
|
12
12
|
# @return [String] Path to the snapshot file
|
|
13
13
|
attr_reader :path
|
|
14
14
|
|
|
15
|
-
# @return [String] Path to the evaluated-variants snapshot file (dual-rail)
|
|
16
|
-
attr_reader :variants_path
|
|
17
|
-
|
|
18
15
|
# @param path [String] Path to the snapshot file
|
|
19
16
|
def initialize(path:)
|
|
20
17
|
super()
|
|
21
18
|
@path = path
|
|
22
|
-
@variants_path = derive_variants_path(path)
|
|
23
19
|
@mutex = Mutex.new
|
|
24
20
|
end
|
|
25
21
|
|
|
@@ -70,7 +66,6 @@ module Toggly
|
|
|
70
66
|
def clear
|
|
71
67
|
@mutex.synchronize do
|
|
72
68
|
FileUtils.rm_f(@path)
|
|
73
|
-
FileUtils.rm_f(@variants_path)
|
|
74
69
|
end
|
|
75
70
|
rescue StandardError => e
|
|
76
71
|
raise SnapshotError, "Failed to clear snapshot: #{e.message}"
|
|
@@ -83,48 +78,6 @@ module Toggly
|
|
|
83
78
|
::File.exist?(@path)
|
|
84
79
|
end
|
|
85
80
|
|
|
86
|
-
# Save evaluated variants to file using atomic write (dual-rail)
|
|
87
|
-
#
|
|
88
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
89
|
-
# @param metadata [Hash] Optional metadata
|
|
90
|
-
def save_variants(variants, metadata = {})
|
|
91
|
-
@mutex.synchronize do
|
|
92
|
-
ensure_directory_exists
|
|
93
|
-
|
|
94
|
-
data = {
|
|
95
|
-
"variants" => serialize_variants(variants),
|
|
96
|
-
"metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
temp_path = "#{@variants_path}.tmp"
|
|
100
|
-
::File.write(temp_path, JSON.pretty_generate(data))
|
|
101
|
-
::File.rename(temp_path, @variants_path)
|
|
102
|
-
end
|
|
103
|
-
rescue StandardError => e
|
|
104
|
-
raise SnapshotError, "Failed to save variants snapshot: #{e.message}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Load evaluated variants from file (dual-rail)
|
|
108
|
-
#
|
|
109
|
-
# @return [Hash, nil] Hash with :variants and :metadata
|
|
110
|
-
def load_variants
|
|
111
|
-
@mutex.synchronize do
|
|
112
|
-
return nil unless ::File.exist?(@variants_path)
|
|
113
|
-
|
|
114
|
-
content = ::File.read(@variants_path)
|
|
115
|
-
data = JSON.parse(content)
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
variants: deserialize_variants(data["variants"]),
|
|
119
|
-
metadata: symbolize_keys(data["metadata"] || {})
|
|
120
|
-
}
|
|
121
|
-
end
|
|
122
|
-
rescue JSON::ParserError => e
|
|
123
|
-
raise SnapshotError, "Failed to parse variants snapshot: #{e.message}"
|
|
124
|
-
rescue StandardError => e
|
|
125
|
-
raise SnapshotError, "Failed to load variants snapshot: #{e.message}"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
81
|
private
|
|
129
82
|
|
|
130
83
|
def ensure_directory_exists
|
|
@@ -132,13 +85,6 @@ module Toggly
|
|
|
132
85
|
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
|
133
86
|
end
|
|
134
87
|
|
|
135
|
-
def derive_variants_path(path)
|
|
136
|
-
dir = ::File.dirname(path)
|
|
137
|
-
ext = ::File.extname(path)
|
|
138
|
-
base = ::File.basename(path, ext)
|
|
139
|
-
::File.join(dir, "#{base}_variants#{ext}")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
88
|
def symbolize_keys(hash)
|
|
143
89
|
return {} unless hash.is_a?(Hash)
|
|
144
90
|
|
|
@@ -9,7 +9,6 @@ module Toggly
|
|
|
9
9
|
def initialize
|
|
10
10
|
super
|
|
11
11
|
@data = nil
|
|
12
|
-
@variants_data = nil
|
|
13
12
|
@mutex = Mutex.new
|
|
14
13
|
end
|
|
15
14
|
|
|
@@ -44,7 +43,6 @@ module Toggly
|
|
|
44
43
|
def clear
|
|
45
44
|
@mutex.synchronize do
|
|
46
45
|
@data = nil
|
|
47
|
-
@variants_data = nil
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
|
|
@@ -56,33 +54,6 @@ module Toggly
|
|
|
56
54
|
!@data.nil?
|
|
57
55
|
end
|
|
58
56
|
end
|
|
59
|
-
|
|
60
|
-
# Save evaluated variants to memory (dual-rail)
|
|
61
|
-
#
|
|
62
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
63
|
-
# @param metadata [Hash] Optional metadata
|
|
64
|
-
def save_variants(variants, metadata = {})
|
|
65
|
-
@mutex.synchronize do
|
|
66
|
-
@variants_data = {
|
|
67
|
-
variants: serialize_variants(variants),
|
|
68
|
-
metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
|
|
69
|
-
}
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Load evaluated variants from memory (dual-rail)
|
|
74
|
-
#
|
|
75
|
-
# @return [Hash, nil] Hash with :variants and :metadata
|
|
76
|
-
def load_variants
|
|
77
|
-
@mutex.synchronize do
|
|
78
|
-
return nil unless @variants_data
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
variants: deserialize_variants(@variants_data[:variants]),
|
|
82
|
-
metadata: @variants_data[:metadata]
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
57
|
end
|
|
87
58
|
end
|
|
88
59
|
end
|