featurehub-sdk 2.0.1 → 2.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.
@@ -10,22 +10,31 @@
10
10
  <orderEntry type="library" scope="PROVIDED" name="concurrent-ruby (v1.3.6, rbenv: 3.3.10) [gem]" level="application" />
11
11
  <orderEntry type="library" scope="PROVIDED" name="connection_pool (v3.0.2, rbenv: 3.3.10) [gem]" level="application" />
12
12
  <orderEntry type="library" scope="PROVIDED" name="daemons (v1.4.1, rbenv: 3.3.10) [gem]" level="application" />
13
+ <orderEntry type="library" scope="PROVIDED" name="dalli (v4.3.3, rbenv: 3.3.10) [gem]" level="application" />
13
14
  <orderEntry type="library" scope="PROVIDED" name="domain_name (v0.6.20240107, rbenv: 3.3.10) [gem]" level="application" />
14
15
  <orderEntry type="library" scope="PROVIDED" name="eventmachine (v1.2.7, rbenv: 3.3.10) [gem]" level="application" />
15
- <orderEntry type="library" scope="PROVIDED" name="faraday (v2.14.1, rbenv: 3.3.10) [gem]" level="application" />
16
- <orderEntry type="library" scope="PROVIDED" name="faraday-net_http (v3.4.2, rbenv: 3.3.10) [gem]" level="application" />
16
+ <orderEntry type="library" scope="PROVIDED" name="faraday (v1.10.5, rbenv: 3.3.10) [gem]" level="application" />
17
+ <orderEntry type="library" scope="PROVIDED" name="faraday-em_http (v1.0.0, rbenv: 3.3.10) [gem]" level="application" />
18
+ <orderEntry type="library" scope="PROVIDED" name="faraday-em_synchrony (v1.0.1, rbenv: 3.3.10) [gem]" level="application" />
19
+ <orderEntry type="library" scope="PROVIDED" name="faraday-excon (v1.1.0, rbenv: 3.3.10) [gem]" level="application" />
20
+ <orderEntry type="library" scope="PROVIDED" name="faraday-httpclient (v1.0.1, rbenv: 3.3.10) [gem]" level="application" />
21
+ <orderEntry type="library" scope="PROVIDED" name="faraday-multipart (v1.2.0, rbenv: 3.3.10) [gem]" level="application" />
22
+ <orderEntry type="library" scope="PROVIDED" name="faraday-net_http (v1.0.2, rbenv: 3.3.10) [gem]" level="application" />
23
+ <orderEntry type="library" scope="PROVIDED" name="faraday-net_http_persistent (v1.2.0, rbenv: 3.3.10) [gem]" level="application" />
24
+ <orderEntry type="library" scope="PROVIDED" name="faraday-patron (v1.0.0, rbenv: 3.3.10) [gem]" level="application" />
25
+ <orderEntry type="library" scope="PROVIDED" name="faraday-rack (v1.0.0, rbenv: 3.3.10) [gem]" level="application" />
26
+ <orderEntry type="library" scope="PROVIDED" name="faraday-retry (v1.0.4, rbenv: 3.3.10) [gem]" level="application" />
17
27
  <orderEntry type="library" scope="PROVIDED" name="ffi (v1.17.3, rbenv: 3.3.10) [gem]" level="application" />
18
28
  <orderEntry type="library" scope="PROVIDED" name="ffi-compiler (v1.3.2, rbenv: 3.3.10) [gem]" level="application" />
19
29
  <orderEntry type="library" scope="PROVIDED" name="http (v5.3.1, rbenv: 3.3.10) [gem]" level="application" />
20
30
  <orderEntry type="library" scope="PROVIDED" name="http-cookie (v1.1.0, rbenv: 3.3.10) [gem]" level="application" />
21
31
  <orderEntry type="library" scope="PROVIDED" name="http-form_data (v2.3.0, rbenv: 3.3.10) [gem]" level="application" />
22
- <orderEntry type="library" scope="PROVIDED" name="json (v2.19.2, rbenv: 3.3.10) [gem]" level="application" />
23
32
  <orderEntry type="library" scope="PROVIDED" name="ld-eventsource (v2.5.1, rbenv: 3.3.10) [gem]" level="application" />
24
33
  <orderEntry type="library" scope="PROVIDED" name="llhttp-ffi (v0.5.1, rbenv: 3.3.10) [gem]" level="application" />
25
34
  <orderEntry type="library" scope="PROVIDED" name="logger (v1.7.0, rbenv: 3.3.10) [gem]" level="application" />
35
+ <orderEntry type="library" scope="PROVIDED" name="multipart-post (v2.4.1, rbenv: 3.3.10) [gem]" level="application" />
26
36
  <orderEntry type="library" scope="PROVIDED" name="murmurhash3 (v0.1.7, rbenv: 3.3.10) [gem]" level="application" />
27
37
  <orderEntry type="library" scope="PROVIDED" name="mustermann (v2.0.2, rbenv: 3.3.10) [gem]" level="application" />
28
- <orderEntry type="library" scope="PROVIDED" name="net-http (v0.9.1, rbenv: 3.3.10) [gem]" level="application" />
29
38
  <orderEntry type="library" scope="PROVIDED" name="public_suffix (v7.0.5, rbenv: 3.3.10) [gem]" level="application" />
30
39
  <orderEntry type="library" scope="PROVIDED" name="rack (v2.2.6.4, rbenv: 3.3.10) [gem]" level="application" />
31
40
  <orderEntry type="library" scope="PROVIDED" name="rack-protection (v2.2.3, rbenv: 3.3.10) [gem]" level="application" />
@@ -38,6 +47,5 @@
38
47
  <orderEntry type="library" scope="PROVIDED" name="sinatra (v2.2.3, rbenv: 3.3.10) [gem]" level="application" />
39
48
  <orderEntry type="library" scope="PROVIDED" name="thin (v1.8.1, rbenv: 3.3.10) [gem]" level="application" />
40
49
  <orderEntry type="library" scope="PROVIDED" name="tilt (v2.1.0, rbenv: 3.3.10) [gem]" level="application" />
41
- <orderEntry type="library" scope="PROVIDED" name="uri (v1.1.1, rbenv: 3.3.10) [gem]" level="application" />
42
50
  </component>
43
51
  </module>
@@ -1,5 +1,20 @@
1
- #!/bin/sh
1
+ #!/bin/bash
2
2
  RACK_ENV=development
3
- #export FEATUREHUB_REDIS_STORE=redis://localhost:6379
4
- export FEATUREHUB_LOCAL_YAML=feature-flags.yaml
5
- bundle exec thin -R thin.ru -a 0.0.0.0 -p 8099 start
3
+ if [[ "$1" == "redis" ]]; then
4
+ echo "detected redis"
5
+ export FEATUREHUB_REDIS_STORE=redis://localhost:6379
6
+ fi
7
+ if [[ "$1" == "memcache" ]]; then
8
+ echo "detected memcache"
9
+ export FEATUREHUB_MEMCACHE_STORE=localhost:11211
10
+ fi
11
+ if [[ "$1" == "local" ]]; then
12
+ echo "detected local yaml"
13
+ export FEATUREHUB_LOCAL_YAML=feature-flags.yaml
14
+ fi
15
+ PORT=8099
16
+ if [[ "$2" == "sec" ]]; then
17
+ PORT=8100
18
+ fi
19
+
20
+ bundle exec thin -R thin.ru -a 0.0.0.0 -p $PORT start
@@ -35,11 +35,12 @@ Gem::Specification.new do |spec|
35
35
  spec.require_paths = ["lib"]
36
36
 
37
37
  spec.add_dependency "concurrent-ruby", "~> 1.3"
38
- spec.add_dependency "faraday", "~> 2"
38
+ spec.add_dependency "faraday"
39
39
  spec.add_dependency "ld-eventsource", "~> 2.5.1"
40
40
  spec.add_dependency "murmurhash3", "~> 0.1.7"
41
41
  spec.add_dependency "sem_version", "~> 2.0.0"
42
42
 
43
43
  # we will dynamically determine if redis is available
44
+ spec.add_development_dependency "dalli", "~> 4"
44
45
  spec.add_development_dependency "redis", "~> 5"
45
46
  end
@@ -171,7 +171,7 @@ module FeatureHub
171
171
  end
172
172
 
173
173
  def build
174
- @edge.poll
174
+ @edge&.poll
175
175
  self
176
176
  end
177
177
 
@@ -190,6 +190,8 @@ module FeatureHub
190
190
  end
191
191
 
192
192
  def build
193
+ return self unless @edge
194
+
193
195
  new_header = @attributes.map { |k, v| "#{k}=#{URI.encode_www_form_component(v[0].to_s)}" } * "&"
194
196
 
195
197
  if @old_header.nil? && new_header.empty?
@@ -25,12 +25,16 @@ module FeatureHub
25
25
 
26
26
  # central dispatch class for FeatureHub SDK
27
27
  class FeatureHubConfig
28
+ FALLBACK_ENVIRONMENT_ID = "569b0129-d53d-4516-a818-9154af601047"
29
+
28
30
  attr_reader :edge_url, :api_keys, :client_evaluated, :logger
29
31
 
30
32
  def initialize(edge_url = nil, api_keys = nil, repository = nil, edge_provider = nil, logger = nil) # rubocop:disable Metrics/ParameterLists
31
33
  @logger = logger
32
34
  @repository = repository || FeatureHub::Sdk::FeatureHubRepository.new(nil, @logger)
33
35
 
36
+ @closed = false
37
+
34
38
  resolved_url = resolve_edge_url(edge_url)
35
39
  resolved_keys = resolve_api_keys(api_keys)
36
40
 
@@ -84,6 +88,8 @@ module FeatureHub
84
88
 
85
89
  # rubocop:disable Naming/AccessorMethodName
86
90
  def get_or_create_edge_service
91
+ return nil if @closed
92
+
87
93
  @edge_service = create_edge_service if @edge_service.nil?
88
94
 
89
95
  @edge_service
@@ -91,6 +97,7 @@ module FeatureHub
91
97
  # rubocop:enable Naming/AccessorMethodName
92
98
 
93
99
  def edge_service_provider(edge_provider = nil)
100
+ return nil if @closed
94
101
  return @edge_service_provider if edge_provider.nil?
95
102
 
96
103
  @edge_service_provider = edge_provider
@@ -103,7 +110,8 @@ module FeatureHub
103
110
  edge_provider
104
111
  end
105
112
 
106
- def use_polling_edge_service(interval = ENV.fetch("FEATUREHUB_POLL_INTERVAL", "30").to_i)
113
+ def use_polling_edge_service(interval = ENV.fetch("FEATUREHUB_POLL_INTERVAL",
114
+ ENV.fetch("FEATUREHUB_POLLING_INTERVAL", "30")).to_i)
107
115
  @interval = interval
108
116
  @edge_service_provider = method(:create_polling_edge_provider)
109
117
  end
@@ -111,32 +119,43 @@ module FeatureHub
111
119
  def new_context
112
120
  get_or_create_edge_service
113
121
 
122
+ edge_service = @closed ? nil : @edge_service
123
+
114
124
  if @client_evaluated
115
- ClientEvalFeatureContext.new(@repository, @edge_service)
125
+ ClientEvalFeatureContext.new(@repository, edge_service)
116
126
  else
117
- ServerEvalFeatureContext.new(@repository, @edge_service)
127
+ ServerEvalFeatureContext.new(@repository, edge_service)
118
128
  end
119
129
  end
120
130
 
121
131
  def close
122
132
  unless @repository.nil?
133
+ @logger&.info("[featurehubsdk] repository now closed")
123
134
  @repository.close
124
135
  @repository = nil
125
136
  end
126
137
 
127
- return if @edge_service.nil?
128
-
129
- @edge_service.close
130
- @edge_service = nil
138
+ close_edge
131
139
  end
132
140
 
133
141
  def close_edge
142
+ return if @closed
143
+
144
+ @logger&.info("[featurehubsdk] edge now closed")
145
+ @closed = true
134
146
  return if @edge_service.nil?
135
147
 
136
148
  @edge_service.close
137
149
  @edge_service = nil
138
150
  end
139
151
 
152
+ def environment_id
153
+ return FALLBACK_ENVIRONMENT_ID if @api_keys.empty?
154
+
155
+ parts = @api_keys.first.split("/")
156
+ parts.length == 3 ? parts[1] : parts[0]
157
+ end
158
+
140
159
  private
141
160
 
142
161
  def create_edge_service
@@ -148,7 +167,12 @@ module FeatureHub
148
167
  end
149
168
 
150
169
  def create_default_provider(repo, api_keys, edge_url, logger)
151
- FeatureHub::Sdk::StreamingEdgeService.new(repo, api_keys, edge_url, logger)
170
+ if ENV["FEATUREHUB_POLL_INTERVAL"] || ENV["FEATUREHUB_POLLING_INTERVAL"]
171
+ use_polling_edge_service
172
+ create_polling_edge_provider(repo, api_keys, edge_url, logger)
173
+ else
174
+ FeatureHub::Sdk::StreamingEdgeService.new(repo, api_keys, edge_url, logger)
175
+ end
152
176
  end
153
177
 
154
178
  def resolve_edge_url(edge_url)
@@ -92,6 +92,12 @@ module FeatureHub
92
92
  !value.nil?
93
93
  end
94
94
 
95
+ def phantom?
96
+ return @parent_state&.phantom? unless @parent_state.nil?
97
+
98
+ @internal_feature_state.empty?
99
+ end
100
+
95
101
  def top_feature_state
96
102
  return @parent_state&.top_feature_state if @parent_state
97
103
 
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "concurrent-ruby"
5
+ require_relative "session_store_helpers"
6
+
7
+ module FeatureHub
8
+ module Sdk
9
+ # Optional configuration for MemcacheSessionStore.
10
+ class MemcacheSessionStoreOptions
11
+ attr_reader :prefix, :backoff_timeout, :retry_update_count, :refresh_timeout, :logger
12
+
13
+ def initialize(opts = nil)
14
+ opts ||= {}
15
+ @prefix = opts[:prefix] || "featurehub"
16
+ @backoff_timeout = opts[:backoff_timeout] || 500
17
+ @retry_update_count = opts[:retry_update_count] || 10
18
+ @refresh_timeout = opts[:refresh_timeout] || 300
19
+ @logger = opts[:logger]
20
+ end
21
+ end
22
+
23
+ # Persists feature values from a FeatureHubRepository in Memcache so they survive
24
+ # process restarts and are shared across multiple processes.
25
+ #
26
+ # Uses SHA256-based change detection and compare-and-set for multi-process safety.
27
+ #
28
+ # WARNING: Do not use with server-evaluated features. Each server-evaluated context
29
+ # sends different resolved values; storing them in a shared Memcache key will cause
30
+ # processes to overwrite each other's feature states.
31
+ #
32
+ # On initialization the store reads any previously saved features from Memcache and
33
+ # replays them into the repository. A periodic timer re-reads the SHA key so that
34
+ # updates published by other processes are picked up automatically.
35
+ class MemcacheSessionStore < RawUpdateFeatureListener
36
+ include SessionStoreHelpers
37
+
38
+ SOURCE = "memcache-store"
39
+
40
+ # @param connection_or_client [String, Dalli::Client] Memcache connection string or existing client
41
+ # @param config [FeatureHubConfig] SDK config (provides repository and environment_id)
42
+ # @param opts [MemcacheSessionStoreOptions, Hash, nil] optional configuration
43
+ def initialize(connection_or_client, config, opts = nil)
44
+ super()
45
+
46
+ options = opts.is_a?(MemcacheSessionStoreOptions) ? opts : MemcacheSessionStoreOptions.new(opts)
47
+
48
+ @repository = config.repository
49
+ @environment_id = config.environment_id
50
+ @prefix = options.prefix
51
+ @backoff_timeout = options.backoff_timeout
52
+ @retry_update_count = options.retry_update_count
53
+ @refresh_timeout = options.refresh_timeout
54
+ @internal_sha = nil
55
+ @mutex = Mutex.new
56
+ @task = nil
57
+ @logger = options.logger
58
+
59
+ return unless dalli_available?
60
+
61
+ @dalli = if connection_or_client.is_a?(String)
62
+ Dalli::Client.new(connection_or_client, serializer: JSON)
63
+ else
64
+ connection_or_client
65
+ end
66
+
67
+ @logger&.debug("[featurehubsdk] started memcache store")
68
+ Concurrent::Future.execute { load_from_memcache }
69
+ start_timer
70
+ end
71
+
72
+ def process_updates(features, source)
73
+ return if source == SOURCE || !dalli_available?
74
+
75
+ incoming_sha = calculate_sha(features)
76
+ return if incoming_sha == @dalli.get(sha_key)
77
+
78
+ perform_store_with_retry do |memcache_features|
79
+ has_newer = features.any? do |f|
80
+ existing = memcache_features.find { |mf| mf["id"] == f["id"] }
81
+ existing.nil? || version_of(f) > version_of(existing)
82
+ end
83
+ has_newer ? merge_features(memcache_features, features) : nil
84
+ end
85
+ end
86
+
87
+ def process_update(feature, source)
88
+ return if source == SOURCE || !dalli_available?
89
+
90
+ perform_store_with_retry do |memcache_features|
91
+ existing = memcache_features.find { |f| f["id"] == feature["id"] }
92
+ next nil if existing && version_of(existing) >= version_of(feature)
93
+
94
+ merge_features(memcache_features, [feature])
95
+ end
96
+ end
97
+
98
+ def delete_feature(feature, source)
99
+ return if source == SOURCE || !dalli_available? || !feature["id"]
100
+
101
+ perform_store_with_retry do |memcache_features|
102
+ updated = memcache_features.reject { |f| f["id"] == feature["id"] }
103
+ updated.length < memcache_features.length ? updated : nil
104
+ end
105
+ end
106
+
107
+ def close
108
+ return if @task.nil?
109
+
110
+ @task.shutdown
111
+ @task = nil
112
+ end
113
+
114
+ private
115
+
116
+ def dalli_available?
117
+ @dalli_available ||= begin
118
+ require "dalli"
119
+ true
120
+ rescue LoadError
121
+ false
122
+ end
123
+ end
124
+
125
+ def load_from_memcache
126
+ sha = @dalli.get(sha_key)
127
+ @mutex.synchronize { @internal_sha = sha }
128
+
129
+ features = read_features_from_memcache
130
+ return if features.empty?
131
+
132
+ @logger&.debug("[featurehubsdk] loading #{features.size} feature(s) from memcache")
133
+ @repository.notify("features", features, SOURCE)
134
+ end
135
+
136
+ def start_timer
137
+ @task = Concurrent::TimerTask.new(execution_interval: @refresh_timeout, run_now: false) do
138
+ check_for_updates
139
+ end
140
+ @task.execute
141
+ end
142
+
143
+ def check_for_updates
144
+ return unless dalli_available?
145
+
146
+ current_sha = @dalli.get(sha_key)
147
+ stored_sha = @mutex.synchronize { @internal_sha }
148
+ return if current_sha == stored_sha
149
+
150
+ features = read_features_from_memcache
151
+ @logger&.debug("[featurehubsdk] detected memcache change, reloading #{features.size} feature(s)")
152
+ @repository.notify("features", features, SOURCE)
153
+ @mutex.synchronize { @internal_sha = current_sha }
154
+ end
155
+
156
+ # Computes what to write by yielding the current memcache features to the block.
157
+ # The block returns the new features array to store, or nil to abort.
158
+ # Uses compare-and-set with retry to handle multi-process contention.
159
+ def perform_store_with_retry
160
+ attempt = 0
161
+ while attempt < @retry_update_count
162
+ memcache_features = read_features_from_memcache
163
+ new_features = yield(memcache_features)
164
+ return if new_features.nil?
165
+
166
+ new_sha = calculate_sha(new_features)
167
+ current_internal = @mutex.synchronize { @internal_sha }
168
+ return if new_sha == current_internal
169
+
170
+ current_sha = @dalli.get(sha_key)
171
+
172
+ if current_sha != current_internal
173
+ # Another process updated memcache — reload and recheck on next attempt
174
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
175
+ attempt += 1
176
+ next
177
+ end
178
+
179
+ stored = attempt_atomic_write(current_sha, new_sha, current_internal)
180
+
181
+ if stored
182
+ @dalli.set(features_key, new_features.to_json)
183
+ @mutex.synchronize { @internal_sha = new_sha }
184
+ return
185
+ end
186
+
187
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
188
+ attempt += 1
189
+ end
190
+
191
+ @logger&.warn("[featurehubsdk] failed to update memcache after #{@retry_update_count} retries")
192
+ end
193
+
194
+ def attempt_atomic_write(current_sha, new_sha, current_internal)
195
+ if current_sha.nil?
196
+ !!@dalli.add(sha_key, new_sha)
197
+ else
198
+ stored = false
199
+ @dalli.cas(sha_key) do |current|
200
+ if current == current_internal
201
+ stored = true
202
+ new_sha
203
+ else
204
+ current # write back same value — harmless no-op if CAS token still valid
205
+ end
206
+ end
207
+ stored
208
+ end
209
+ end
210
+
211
+ def read_features_from_memcache
212
+ json = @dalli.get(features_key)
213
+ return [] unless json
214
+
215
+ JSON.parse(json)
216
+ rescue JSON::ParserError
217
+ []
218
+ end
219
+ end
220
+ end
221
+ end