convert_sdk 1.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- metadata +93 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
# Feature resolution + typed-variable casting — the MAPPING + CASTING layer
|
|
7
|
+
# that turns the Epic 2 bucketing decisions (Story 2.11) into typed feature
|
|
8
|
+
# flags (FR24–FR27).
|
|
9
|
+
#
|
|
10
|
+
# == Features resolve THROUGH experiences (FR26)
|
|
11
|
+
#
|
|
12
|
+
# There is NO independent feature decision path. A feature is ENABLED exactly
|
|
13
|
+
# when the visitor is bucketed — via the ordered decision flow owned by
|
|
14
|
+
# {DataManager#get_bucketing} — into a variation that carries that feature. The
|
|
15
|
+
# carrying link lives in the variation's +changes+: a change with
|
|
16
|
+
# +type == "fullStackFeature"+ whose +data.feature_id+ matches a declared
|
|
17
|
+
# feature, and whose +data.variables_data+ holds the raw (string) variable
|
|
18
|
+
# values. This manager maps those bucketed variations onto declared features
|
|
19
|
+
# and casts the variable values; it NEVER re-evaluates rules (that would be a
|
|
20
|
+
# parity bug — the decision flow is owned in ONE place, the DataManager).
|
|
21
|
+
#
|
|
22
|
+
# == Typed variables (FR27) — the developer-experience core
|
|
23
|
+
#
|
|
24
|
+
# Each declared feature lists its variables as +{key, type}+; the bucketed
|
|
25
|
+
# variation supplies the raw values. {#cast_type} mirrors the JS
|
|
26
|
+
# +castType+ contract (javascript-sdk +packages/utils/src/types-utils.ts:13-54+)
|
|
27
|
+
# EXACTLY — five literal type strings:
|
|
28
|
+
#
|
|
29
|
+
# string -> String(value)
|
|
30
|
+
# boolean -> "true" -> true, "false" -> false, else truthiness
|
|
31
|
+
# integer -> true->1, false->0, else parseInt-style (leading digits)
|
|
32
|
+
# float -> true->1.0, false->0.0, else parseFloat-style (leading number)
|
|
33
|
+
# json -> already a Hash/Array? as-is; else JSON.parse, on FAILURE -> raw String
|
|
34
|
+
#
|
|
35
|
+
# There is NO +number+ type in the JS switch — none is added here. An unknown
|
|
36
|
+
# type returns the value unchanged (the JS +default+ branch). Casting is
|
|
37
|
+
# data-driven from the config's declared variable types — no per-feature cases.
|
|
38
|
+
#
|
|
39
|
+
# == Miss semantics (AC#5; feature-manager.ts:206-218)
|
|
40
|
+
#
|
|
41
|
+
# A miss is NEVER an exception. {#run_feature} returns a frozen
|
|
42
|
+
# {BucketedFeature} with +status == FeatureStatus::DISABLED+:
|
|
43
|
+
# * feature DECLARED but visitor not bucketed into a carrying variation ->
|
|
44
|
+
# +{id, name, key, status: DISABLED}+
|
|
45
|
+
# * feature NOT declared at all -> +{key, status: DISABLED}+
|
|
46
|
+
# Each miss is PAIRED with a +debug+ reason log (a Ruby observability addition;
|
|
47
|
+
# JS returns the disabled feature silently).
|
|
48
|
+
#
|
|
49
|
+
# == Sticky transitivity
|
|
50
|
+
#
|
|
51
|
+
# A returning visitor's stored bucketing (2.11) drives feature stability
|
|
52
|
+
# automatically — there is NO feature-level storage here.
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
class FeatureManager
|
|
56
|
+
# Variation-change type that carries a fullstack feature link. Wire value
|
|
57
|
+
# byte-identical to the JS enum (variation-change-type.ts:13). Held here
|
|
58
|
+
# (not inlined at the use site) so the wire string lives in ONE place.
|
|
59
|
+
FULLSTACK_FEATURE = "fullStackFeature"
|
|
60
|
+
|
|
61
|
+
# The values JS treats as falsey for the +!!value+ boolean cast (after the
|
|
62
|
+
# explicit "true"/"false" string checks): nil, false, "", and 0.
|
|
63
|
+
JS_FALSEY = [nil, false, "", 0].freeze
|
|
64
|
+
|
|
65
|
+
# @param data_manager [DataManager] the 2.11 decision-flow owner (config
|
|
66
|
+
# readers + +get_bucketing+).
|
|
67
|
+
# @param log_manager [LogManager, nil] optional debug/warn logger.
|
|
68
|
+
def initialize(data_manager:, log_manager: nil)
|
|
69
|
+
@data_manager = data_manager
|
|
70
|
+
@log_manager = log_manager
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve a SINGLE feature for a visitor (FR24).
|
|
74
|
+
#
|
|
75
|
+
# Mirrors JS +runFeature+ (feature-manager.ts:180-219): the feature is looked
|
|
76
|
+
# up by key; if declared, the bucketing flow runs FILTERED to this feature.
|
|
77
|
+
# On one carrying variation a single ENABLED {BucketedFeature} is returned; on
|
|
78
|
+
# several (the feature appears in multiple bucketed variations) an Array of
|
|
79
|
+
# ENABLED {BucketedFeature}s; on none the DISABLED fallback (+{id,name,key}+).
|
|
80
|
+
# An undeclared feature returns the +{key}+-only DISABLED fallback. Each miss
|
|
81
|
+
# is paired with a debug log; never raises.
|
|
82
|
+
#
|
|
83
|
+
# @param visitor_id [String] the visitor identifier.
|
|
84
|
+
# @param feature_key [String] the feature +key+ to resolve.
|
|
85
|
+
# @param attributes [Hash] bucketing attributes (+:visitor_properties+,
|
|
86
|
+
# +:location_properties+, +:environment+) — see {DataManager#get_bucketing}.
|
|
87
|
+
# @return [BucketedFeature, Array<BucketedFeature>] enabled feature(s) or a
|
|
88
|
+
# frozen DISABLED {BucketedFeature} on a miss.
|
|
89
|
+
def run_feature(visitor_id, feature_key, attributes = {})
|
|
90
|
+
declared = @data_manager.feature_by_key(feature_key)
|
|
91
|
+
unless declared
|
|
92
|
+
@log_manager&.debug("FeatureManager#run_feature: feature not declared key=#{feature_key}")
|
|
93
|
+
return disabled_feature(key: feature_key)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
enabled = run_features(visitor_id, attributes, features: [feature_key])
|
|
97
|
+
if enabled.empty?
|
|
98
|
+
@log_manager&.debug("FeatureManager#run_feature: not bucketed into a carrying variation key=#{feature_key}")
|
|
99
|
+
return disabled_from_declared(declared)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
enabled.length == 1 ? enabled.first : enabled
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Resolve ALL applicable features for a visitor (FR25).
|
|
106
|
+
#
|
|
107
|
+
# Mirrors JS +runFeatures+ (feature-manager.ts:327-463) under the Ruby
|
|
108
|
+
# across-all-experiences parity decision (Story 2.11 {ExperienceManager#select_variations}):
|
|
109
|
+
# misses are FILTERED OUT of the bucketed-variation set (sentinels never
|
|
110
|
+
# propagate), then every declared feature carried by a bucketed variation is
|
|
111
|
+
# collected as an ENABLED {BucketedFeature} (variables cast per declared type).
|
|
112
|
+
# When NO +features+ filter is supplied, every declared feature NOT already
|
|
113
|
+
# enabled is appended as a DISABLED {BucketedFeature} — so callers always see
|
|
114
|
+
# the full feature roster. With a +features+ filter, only enabled matches are
|
|
115
|
+
# returned (no DISABLED padding). Never raises.
|
|
116
|
+
#
|
|
117
|
+
# @param visitor_id [String] the visitor identifier.
|
|
118
|
+
# @param attributes [Hash] bucketing attributes (see {#run_feature}).
|
|
119
|
+
# @param experiences [Array<String>, nil] optional experience-key filter.
|
|
120
|
+
# @param features [Array<String>, nil] optional feature-key filter (suppresses
|
|
121
|
+
# the DISABLED padding).
|
|
122
|
+
# @return [Array<BucketedFeature>] the resolved features.
|
|
123
|
+
def run_features(visitor_id, attributes = {}, experiences: nil, features: nil)
|
|
124
|
+
declared_by_id = features_by_id
|
|
125
|
+
variations = bucketed_variations(visitor_id, attributes, experiences)
|
|
126
|
+
|
|
127
|
+
bucketed = collect_enabled(variations, declared_by_id, features)
|
|
128
|
+
|
|
129
|
+
# Pad with DISABLED features ONLY when no feature filter is supplied.
|
|
130
|
+
append_disabled(bucketed, declared_by_id) if features.nil?
|
|
131
|
+
bucketed
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Cast a raw variable value to its declared type. Mirrors JS +castType+
|
|
135
|
+
# (types-utils.ts:13-54) exactly — see the class doc for the truth table.
|
|
136
|
+
# Never raises: non-numeric integer/float inputs degrade to a leading-number
|
|
137
|
+
# parse (0 / 0.0 when there is no leading number), and a +json+ parse failure
|
|
138
|
+
# falls back to the raw String (JS +catch -> String(value)+).
|
|
139
|
+
#
|
|
140
|
+
# @param value [Object] the raw (typically String) variable value.
|
|
141
|
+
# @param type [String] the declared type: string/boolean/integer/float/json.
|
|
142
|
+
# @return [Object] the cast value.
|
|
143
|
+
def cast_type(value, type)
|
|
144
|
+
case type
|
|
145
|
+
when "string" then value.to_s
|
|
146
|
+
when "boolean" then cast_boolean(value)
|
|
147
|
+
when "integer" then cast_integer(value)
|
|
148
|
+
when "float" then cast_float(value)
|
|
149
|
+
when "json" then cast_json(value)
|
|
150
|
+
else value # JS default branch — unknown type passes through unchanged.
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Bucket the (optionally experience-filtered) experiences through the 2.11
|
|
157
|
+
# decision flow, keeping ONLY successful {BucketedVariation}s (sentinels and
|
|
158
|
+
# nils filtered — the Ruby across-all parity decision, Story 2.11).
|
|
159
|
+
def bucketed_variations(visitor_id, attributes, experience_keys)
|
|
160
|
+
experiences = target_experiences(experience_keys)
|
|
161
|
+
experiences.filter_map do |experience|
|
|
162
|
+
next unless experience.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
result = @data_manager.get_bucketing(visitor_id, experience["key"], attributes)
|
|
165
|
+
result if result.is_a?(BucketedVariation)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The experiences to decide: the whole configured list, or just those whose
|
|
170
|
+
# key is in +experience_keys+ when a filter is supplied.
|
|
171
|
+
def target_experiences(experience_keys)
|
|
172
|
+
all = @data_manager.experiences
|
|
173
|
+
return all if experience_keys.nil? || experience_keys.empty?
|
|
174
|
+
|
|
175
|
+
wanted = experience_keys.map(&:to_s)
|
|
176
|
+
all.select { |experience| experience.is_a?(Hash) && wanted.include?(experience["key"].to_s) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Walk every bucketed variation's +fullStackFeature+ changes, mapping each to
|
|
180
|
+
# its declared feature (by id), casting the variables, and building an ENABLED
|
|
181
|
+
# {BucketedFeature}. Honours the optional +feature_keys+ filter.
|
|
182
|
+
def collect_enabled(variations, declared_by_id, feature_keys)
|
|
183
|
+
bucketed = [] #: Array[BucketedFeature]
|
|
184
|
+
variations.each do |variation|
|
|
185
|
+
feature_changes(variation).each do |change|
|
|
186
|
+
feature = enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
|
|
187
|
+
bucketed << feature if feature
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
bucketed
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The +fullStackFeature+ changes carried by a bucketed variation (a warn is
|
|
194
|
+
# logged for any non-feature change, mirroring JS VARIATION_CHANGE_NOT_SUPPORTED).
|
|
195
|
+
def feature_changes(variation)
|
|
196
|
+
changes = variation.changes
|
|
197
|
+
return [] unless changes.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
changes.select do |change|
|
|
200
|
+
if change.is_a?(Hash) && change["type"] == FULLSTACK_FEATURE
|
|
201
|
+
true
|
|
202
|
+
else
|
|
203
|
+
@log_manager&.warn("FeatureManager#run_features: unsupported variation change type")
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build the ENABLED {BucketedFeature} for one feature change, or nil when the
|
|
210
|
+
# change has no feature_id, the feature is undeclared, or it is filtered out.
|
|
211
|
+
def enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
|
|
212
|
+
data = change["data"]
|
|
213
|
+
declared = declared_for_change(data, declared_by_id)
|
|
214
|
+
return nil if declared.nil?
|
|
215
|
+
return nil if filtered_out?(declared, feature_keys)
|
|
216
|
+
|
|
217
|
+
build_enabled(variation, declared, cast_variables(declared, data["variables_data"]))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The declared feature a feature-change maps to (by data.feature_id), or nil
|
|
221
|
+
# when the change carries no feature_id or the id is undeclared (each logged).
|
|
222
|
+
def declared_for_change(data, declared_by_id)
|
|
223
|
+
feature_id = data.is_a?(Hash) ? data["feature_id"] : nil
|
|
224
|
+
unless feature_id
|
|
225
|
+
@log_manager&.warn("FeatureManager#run_features: feature change without feature_id")
|
|
226
|
+
return nil
|
|
227
|
+
end
|
|
228
|
+
declared_by_id[feature_id.to_s]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# True when a feature filter is supplied and this declared feature's key is
|
|
232
|
+
# not in it.
|
|
233
|
+
def filtered_out?(declared, feature_keys)
|
|
234
|
+
return false if feature_keys.nil?
|
|
235
|
+
|
|
236
|
+
!feature_keys.map(&:to_s).include?(declared["key"].to_s)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Cast every supplied raw variable per its declared type (data-driven). A
|
|
240
|
+
# variable with no declared type passes through uncast (JS warns
|
|
241
|
+
# FEATURE_VARIABLES_TYPE_NOT_FOUND). Returns a fresh string-keyed Hash.
|
|
242
|
+
def cast_variables(declared, raw)
|
|
243
|
+
unless raw.is_a?(Hash)
|
|
244
|
+
@log_manager&.warn("FeatureManager#run_features: feature variables not found")
|
|
245
|
+
return {}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
definitions = declared["variables"]
|
|
249
|
+
cast = {} #: Hash[String, untyped]
|
|
250
|
+
raw.each do |name, value|
|
|
251
|
+
type = variable_type(definitions, name)
|
|
252
|
+
if type
|
|
253
|
+
cast[name.to_s] = cast_type(value, type)
|
|
254
|
+
else
|
|
255
|
+
@log_manager&.warn("FeatureManager#run_features: variable type not found name=#{name}")
|
|
256
|
+
cast[name.to_s] = value
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
cast
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The declared type for a variable name within a feature's +variables+ list.
|
|
263
|
+
def variable_type(definitions, name)
|
|
264
|
+
return nil unless definitions.is_a?(Array)
|
|
265
|
+
|
|
266
|
+
definition = definitions.find { |d| d.is_a?(Hash) && d["key"] == name }
|
|
267
|
+
definition && definition["type"]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A frozen ENABLED {BucketedFeature} with the experience provenance + the
|
|
271
|
+
# declared feature's id/name/key + the cast variables.
|
|
272
|
+
def build_enabled(variation, declared, variables)
|
|
273
|
+
BucketedFeature.new(
|
|
274
|
+
experience_id: variation.experience_id,
|
|
275
|
+
experience_key: variation.experience_key,
|
|
276
|
+
experience_name: variation.experience_name,
|
|
277
|
+
id: declared["id"],
|
|
278
|
+
key: declared["key"],
|
|
279
|
+
name: declared["name"],
|
|
280
|
+
status: FeatureStatus::ENABLED,
|
|
281
|
+
variables: variables
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Append a DISABLED {BucketedFeature} for every declared feature not already
|
|
286
|
+
# present (enabled) in +bucketed+ — JS feature-manager.ts:448-461.
|
|
287
|
+
def append_disabled(bucketed, declared_by_id)
|
|
288
|
+
enabled_ids = bucketed.map(&:id)
|
|
289
|
+
declared_by_id.each_value do |declared|
|
|
290
|
+
next if enabled_ids.include?(declared["id"])
|
|
291
|
+
|
|
292
|
+
bucketed << disabled_from_declared(declared)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A frozen DISABLED {BucketedFeature} for a DECLARED feature (id/name/key) —
|
|
297
|
+
# the "visitor not bucketed" miss shape (feature-manager.ts:206-211).
|
|
298
|
+
def disabled_from_declared(declared)
|
|
299
|
+
BucketedFeature.new(
|
|
300
|
+
id: declared["id"], name: declared["name"], key: declared["key"],
|
|
301
|
+
status: FeatureStatus::DISABLED
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A frozen DISABLED {BucketedFeature} for an UNDECLARED feature (key only) —
|
|
306
|
+
# the "feature not declared at all" miss shape (feature-manager.ts:214-217).
|
|
307
|
+
def disabled_feature(key:)
|
|
308
|
+
BucketedFeature.new(key: key, status: FeatureStatus::DISABLED)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Declared features keyed by id (String), for the change->feature mapping and
|
|
312
|
+
# the DISABLED padding. Mirrors JS getListAsObject('id').
|
|
313
|
+
def features_by_id
|
|
314
|
+
result = {} #: Hash[String, untyped]
|
|
315
|
+
@data_manager.features.each do |feature|
|
|
316
|
+
result[feature["id"].to_s] = feature if feature.is_a?(Hash) && feature["id"]
|
|
317
|
+
end
|
|
318
|
+
result
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# boolean: "true"->true, "false"->false, else Ruby truthiness of the value
|
|
322
|
+
# (JS !!value; "" and 0 are falsey in JS, so they map to false).
|
|
323
|
+
def cast_boolean(value)
|
|
324
|
+
return true if value == "true"
|
|
325
|
+
return false if value == "false"
|
|
326
|
+
|
|
327
|
+
!JS_FALSEY.include?(value)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# integer: true->1, false->0, else parseInt-style — leading integer digits of
|
|
331
|
+
# the string, 0 when there is no leading integer (JS parseInt returns NaN, but
|
|
332
|
+
# the never-crash contract degrades to 0).
|
|
333
|
+
def cast_integer(value)
|
|
334
|
+
return 1 if value == true
|
|
335
|
+
return 0 if value == false
|
|
336
|
+
return value if value.is_a?(Integer)
|
|
337
|
+
|
|
338
|
+
str = value.to_s.strip
|
|
339
|
+
match = str.match(/\A[+-]?\d+/)
|
|
340
|
+
match ? match[0].to_i : 0
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# float: true->1.0, false->0.0, else parseFloat-style — leading numeric prefix
|
|
344
|
+
# of the string, 0.0 when there is no leading number (degrade, never crash).
|
|
345
|
+
def cast_float(value)
|
|
346
|
+
return 1.0 if value == true
|
|
347
|
+
return 0.0 if value == false
|
|
348
|
+
return value + 0.0 if value.is_a?(Integer) || value.is_a?(Float)
|
|
349
|
+
|
|
350
|
+
str = value.to_s.strip
|
|
351
|
+
match = str.match(/\A[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?/)
|
|
352
|
+
match ? match[0].to_f : 0.0
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# json: an already-parsed Hash/Array passes through; otherwise JSON.parse,
|
|
356
|
+
# and on a parse failure fall back to the raw String (JS catch -> String(value)).
|
|
357
|
+
def cast_json(value)
|
|
358
|
+
return value if value.is_a?(Hash) || value.is_a?(Array)
|
|
359
|
+
|
|
360
|
+
begin
|
|
361
|
+
JSON.parse(value.to_s)
|
|
362
|
+
rescue JSON::ParserError, TypeError
|
|
363
|
+
value.to_s
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The SDK's single fork-detection authority and the ONLY +Process._fork+
|
|
5
|
+
# prepend in the gem — the SDK's only global mutation (NFR15, architecture
|
|
6
|
+
# Decision 6). It follows the Rails ForkTracker pattern: a module prepended
|
|
7
|
+
# onto +Process.singleton_class+ whose +_fork+ wraps +super+ and, when it
|
|
8
|
+
# returns +0+ (the child), runs the re-arm path. The prepend is installed once
|
|
9
|
+
# at SDK load (it must exist before any fork; installing it is cheap and
|
|
10
|
+
# thread-free, so it does not violate the NFR4 zero-threads-until-use rule —
|
|
11
|
+
# that rule concerns THREADS, not this hook).
|
|
12
|
+
#
|
|
13
|
+
# Fork detection elsewhere uses {.forked?} — a free integer comparison
|
|
14
|
+
# (+Process.pid != owner_pid+, the Datadog idiom) safe to call on every
|
|
15
|
+
# boundary, including JRuby (where it is always false).
|
|
16
|
+
#
|
|
17
|
+
# On JRuby (no +fork+, no +Process._fork+) the prepend is a no-op by
|
|
18
|
+
# construction: {.install!} skips it, so {.forked?} stays false forever.
|
|
19
|
+
#
|
|
20
|
+
# Consumers register their thread-owning timers via {.register_timer} and any
|
|
21
|
+
# child-side cleanup (e.g. ApiManager's queue-ownership clear in Story 4.2) via
|
|
22
|
+
# {.register_child_callback}, keeping ForkGuard decoupled from its callers.
|
|
23
|
+
# {.rearm!} is the shared re-arm path (also invoked by +Client#postfork+ in
|
|
24
|
+
# Epic 4): it marks every registered timer dead, then fires every registered
|
|
25
|
+
# child-callback in registration order, then resets +owner_pid+.
|
|
26
|
+
#
|
|
27
|
+
# The child hook path is LOCK-MINIMAL — mutexes held by other threads at the
|
|
28
|
+
# moment of fork are a classic deadlock source. It resets +owner_pid+ first,
|
|
29
|
+
# then iterates a SNAPSHOT of the timer registry and a SNAPSHOT of the
|
|
30
|
+
# child-callback registry taken under the registry mutex.
|
|
31
|
+
#
|
|
32
|
+
# @api private — not part of the public SDK surface.
|
|
33
|
+
module ForkGuard
|
|
34
|
+
# Thread safety: @registry_mutex guards @timers and @child_callbacks; the
|
|
35
|
+
# singleton-class prepend, @installed flag, @owner_pid, and @logger are
|
|
36
|
+
# module-level state mutated only at install / arm / wiring time.
|
|
37
|
+
@registry_mutex = Thread::Mutex.new
|
|
38
|
+
@timers = []
|
|
39
|
+
@child_callbacks = []
|
|
40
|
+
@installed = false
|
|
41
|
+
@owner_pid = Process.pid
|
|
42
|
+
@logger = nil
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# @return [Integer] the pid that currently owns the SDK's threads.
|
|
46
|
+
attr_reader :owner_pid
|
|
47
|
+
|
|
48
|
+
# Module-level logger, settable at wiring time (Client wires it in 2.7).
|
|
49
|
+
# nil-safe before wiring — the hook never assumes a logger is present.
|
|
50
|
+
# @return [ConvertSdk::LogManager, nil]
|
|
51
|
+
attr_accessor :logger
|
|
52
|
+
|
|
53
|
+
# Install the +Process._fork+ prepend. Idempotent (double-install guarded)
|
|
54
|
+
# and a no-op when fork is unsupported (JRuby) — the prepend never lands,
|
|
55
|
+
# so the hook is a no-op by construction. Safe to call repeatedly.
|
|
56
|
+
# @return [void]
|
|
57
|
+
def install!
|
|
58
|
+
return unless Process.respond_to?(:_fork) && Process.respond_to?(:fork)
|
|
59
|
+
return if @installed
|
|
60
|
+
|
|
61
|
+
Process.singleton_class.prepend(ForkHook)
|
|
62
|
+
@installed = true
|
|
63
|
+
@owner_pid = Process.pid
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] true iff the current process differs from the owner
|
|
67
|
+
# (i.e. we are in a forked child). A free comparison; false on JRuby.
|
|
68
|
+
def forked?
|
|
69
|
+
Process.pid != @owner_pid
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Register a thread-owning timer to be marked dead in a forked child.
|
|
73
|
+
# @param timer [#mark_dead]
|
|
74
|
+
# @return [void]
|
|
75
|
+
def register_timer(timer)
|
|
76
|
+
@registry_mutex.synchronize { @timers << timer }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Register a child-side callback fired after timers are marked dead (e.g.
|
|
80
|
+
# queue-ownership clear). Keeps ForkGuard decoupled from its callers.
|
|
81
|
+
# @param callable [#call]
|
|
82
|
+
# @return [void]
|
|
83
|
+
def register_child_callback(callable)
|
|
84
|
+
@registry_mutex.synchronize { @child_callbacks << callable }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The shared re-arm path: reset owner_pid, mark every registered timer
|
|
88
|
+
# dead, then fire every child-callback in registration order. Lock-minimal:
|
|
89
|
+
# owner_pid is reset first, then SNAPSHOTS of the registries are iterated
|
|
90
|
+
# outside the registry mutex (deadlock-safe in the fork hook).
|
|
91
|
+
# @return [void]
|
|
92
|
+
def rearm!
|
|
93
|
+
@owner_pid = Process.pid
|
|
94
|
+
timers, callbacks = @registry_mutex.synchronize { [@timers.dup, @child_callbacks.dup] }
|
|
95
|
+
@logger&.debug("ForkGuard#rearm!: fork detected, re-arming #{timers.size} timer(s) in pid #{Process.pid}")
|
|
96
|
+
timers.each(&:mark_dead)
|
|
97
|
+
callbacks.each(&:call)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Test-only reap that STOPS (signals exit + joins) every registered timer
|
|
101
|
+
# so NO BackgroundTimer thread can survive into the next example. Distinct
|
|
102
|
+
# from {.reset_for_tests!}, which only clears the registry (it leaves any
|
|
103
|
+
# live thread running). A leaked flush/refresh timer thread firing a real
|
|
104
|
+
# POST/GET after its example ends pollutes a later example's zero-HTTP
|
|
105
|
+
# assertion under WebMock (intermittent on JRuby's thread scheduling) — a
|
|
106
|
+
# global +after(:each)+ reap closes that window deterministically. Iterates
|
|
107
|
+
# a SNAPSHOT taken under the registry mutex; +#stop+ is idempotent so this
|
|
108
|
+
# is a cheap no-op for already-stopped timers.
|
|
109
|
+
# @api private
|
|
110
|
+
# @return [void]
|
|
111
|
+
def stop_all_timers!
|
|
112
|
+
timers = @registry_mutex.synchronize { @timers.dup }
|
|
113
|
+
timers.each(&:stop)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Test-only reset so the singleton-state module is order-independent under
|
|
117
|
+
# RSpec. Clears registries, resets owner_pid, drops the logger. Does NOT
|
|
118
|
+
# uninstall the prepend (it is harmless and global).
|
|
119
|
+
# @api private
|
|
120
|
+
# @return [void]
|
|
121
|
+
def reset_for_tests!
|
|
122
|
+
@registry_mutex.synchronize do
|
|
123
|
+
@timers = []
|
|
124
|
+
@child_callbacks = []
|
|
125
|
+
end
|
|
126
|
+
@owner_pid = Process.pid
|
|
127
|
+
@logger = nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The prepended +_fork+ wrapper (Rails ForkTracker pattern). In the child
|
|
132
|
+
# (+super+ returns 0) it runs the shared re-arm path; the parent path is a
|
|
133
|
+
# pass-through.
|
|
134
|
+
# @api private
|
|
135
|
+
module ForkHook
|
|
136
|
+
# @return [Integer] the pid returned by the real +_fork+.
|
|
137
|
+
def _fork
|
|
138
|
+
pid = super
|
|
139
|
+
ForkGuard.rearm! if pid.zero?
|
|
140
|
+
pid
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|