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.
@@ -2,67 +2,110 @@
2
2
 
3
3
  require "json"
4
4
  require "concurrent-ruby"
5
+ require_relative "session_store_helpers"
5
6
 
6
7
  module FeatureHub
7
8
  module Sdk
9
+ # Optional configuration for RedisSessionStore.
10
+ class RedisSessionStoreOptions
11
+ attr_reader :prefix, :backoff_timeout, :retry_update_count, :refresh_timeout, :logger, :db
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
+ @db = opts[:db] || 0
21
+ end
22
+ end
23
+
8
24
  # Persists feature values from a FeatureHubRepository in Redis so they survive
9
25
  # process restarts and are shared across multiple processes.
10
26
  #
11
- # WARNING: Do not use with server-evaluated features. Each server-evaluated
12
- # context sends different resolved values; storing them in a shared Redis key
13
- # will cause processes to overwrite each other's feature states.
27
+ # Uses SHA256-based change detection and Redis WATCH/MULTI/EXEC for multi-process safety.
14
28
  #
15
- # On initialization the store checks Redis for previously saved features and
16
- # replays them into the repository. It then listens for live updates via
17
- # RawUpdateFeatureListener and writes newer versions back to Redis. A periodic
18
- # timer re-reads all features from Redis so that updates published by other
19
- # processes are picked up automatically.
29
+ # WARNING: Do not use with server-evaluated features. Each server-evaluated context
30
+ # sends different resolved values; storing them in a shared Redis key will cause
31
+ # processes to overwrite each other's feature states.
20
32
  #
21
- # Options (symbol keys):
22
- # :namespace - Redis db index (default: 0)
23
- # :prefix - key prefix for all Redis keys (default: "featurehub")
24
- # :timeout - seconds between periodic reloads (default: 30)
33
+ # On initialization the store reads any previously saved features from Redis and
34
+ # replays them into the repository. A periodic timer re-reads the SHA key so that
35
+ # updates published by other processes are picked up automatically.
25
36
  class RedisSessionStore < RawUpdateFeatureListener
37
+ include SessionStoreHelpers
38
+
26
39
  SOURCE = "redis-store"
27
40
 
28
- def initialize(connection_string, repository, opts = nil)
41
+ # @param connection_or_client [String, Array, Object] Redis URL, list of cluster URLs,
42
+ # or an existing Redis client
43
+ # @param config [FeatureHubConfig] SDK config (provides repository and environment_id)
44
+ # @param opts [RedisSessionStoreOptions, Hash, nil] optional configuration
45
+ def initialize(connection_or_client, config, opts = nil)
29
46
  super()
30
47
 
31
- opts ||= {}
32
- @repository = repository
33
- @prefix = opts[:prefix] || "featurehub"
34
- @timeout = opts[:timeout] || 30
35
- @namespace = opts[:namespace] || 0
36
- @password = opts[:password]
37
- @logger = opts[:logger]
48
+ options = opts.is_a?(RedisSessionStoreOptions) ? opts : RedisSessionStoreOptions.new(opts)
49
+
50
+ @repository = config.repository
51
+ @environment_id = config.environment_id
52
+ @prefix = options.prefix
53
+ @backoff_timeout = options.backoff_timeout
54
+ @retry_update_count = options.retry_update_count
55
+ @refresh_timeout = options.refresh_timeout
56
+ @internal_sha = nil
57
+ @mutex = Mutex.new
38
58
  @task = nil
59
+ @logger = options.logger
39
60
 
40
61
  return unless redis_available?
41
62
 
42
- redis_opts = { url: connection_string, db: @namespace }
43
- redis_opts[:password] = @password if @password
44
- @redis = Redis.new(**redis_opts)
45
- load_from_redis
63
+ @redis = if connection_or_client.is_a?(String)
64
+ Redis.new(url: connection_or_client, db: options.db)
65
+ else
66
+ connection_or_client
67
+ end
68
+
69
+ config.register_raw_update_listener(self)
70
+
71
+ @logger&.debug("[featurehubsdk] started redis store")
72
+ Concurrent::Future.execute { load_from_redis }
46
73
  start_timer
47
74
  end
48
75
 
49
76
  def process_updates(features, source)
50
77
  return if source == SOURCE || !redis_available?
51
78
 
52
- features.each { |f| store_feature(f) }
79
+ incoming_sha = calculate_sha(features)
80
+ return if incoming_sha == @redis.get(sha_key)
81
+
82
+ perform_store_with_retry do |redis_features|
83
+ has_newer = features.any? do |f|
84
+ existing = redis_features.find { |rf| rf["id"] == f["id"] }
85
+ existing.nil? || version_of(f) > version_of(existing)
86
+ end
87
+ has_newer ? merge_features(redis_features, features) : nil
88
+ end
53
89
  end
54
90
 
55
91
  def process_update(feature, source)
56
92
  return if source == SOURCE || !redis_available?
57
93
 
58
- store_feature(feature)
94
+ perform_store_with_retry do |redis_features|
95
+ existing = redis_features.find { |f| f["id"] == feature["id"] }
96
+ next nil if existing && version_of(existing) >= version_of(feature)
97
+
98
+ merge_features(redis_features, [feature])
99
+ end
59
100
  end
60
101
 
61
102
  def delete_feature(feature, source)
62
103
  return if source == SOURCE || !redis_available? || !feature["id"]
63
104
 
64
- @redis.srem(ids_key, feature["id"])
65
- @redis.del(feature_key(feature["id"]))
105
+ perform_store_with_retry do |redis_features|
106
+ updated = redis_features.reject { |f| f["id"] == feature["id"] }
107
+ updated.length < redis_features.length ? updated : nil
108
+ end
66
109
  end
67
110
 
68
111
  def close
@@ -84,14 +127,10 @@ module FeatureHub
84
127
  end
85
128
 
86
129
  def load_from_redis
87
- ids = @redis.smembers(ids_key)
88
- return if ids.empty?
89
-
90
- features = ids.filter_map do |id|
91
- json = @redis.get(feature_key(id))
92
- JSON.parse(json) if json
93
- end
130
+ sha = @redis.get(sha_key)
131
+ @mutex.synchronize { @internal_sha = sha }
94
132
 
133
+ features = read_features_from_redis
95
134
  return if features.empty?
96
135
 
97
136
  @logger&.debug("[featurehubsdk] loading #{features.size} feature(s) from redis")
@@ -99,31 +138,88 @@ module FeatureHub
99
138
  end
100
139
 
101
140
  def start_timer
102
- @task = Concurrent::TimerTask.new(execution_interval: @timeout, run_now: false) do
103
- load_from_redis
141
+ @task = Concurrent::TimerTask.new(execution_interval: @refresh_timeout, run_now: false) do
142
+ check_for_updates
104
143
  end
105
144
  @task.execute
106
145
  end
107
146
 
108
- def store_feature(feature)
109
- return unless feature && feature["id"] && feature["key"]
147
+ def check_for_updates
148
+ return unless redis_available?
149
+
150
+ current_sha = @redis.get(sha_key)
151
+ stored_sha = @mutex.synchronize { @internal_sha }
152
+ return if current_sha == stored_sha
153
+
154
+ features = read_features_from_redis
155
+ @logger&.debug("[featurehubsdk] detected redis change, reloading #{features.size} feature(s)")
156
+ @repository.notify("features", features, SOURCE)
157
+ @mutex.synchronize { @internal_sha = current_sha }
158
+ end
110
159
 
111
- existing_json = @redis.get(feature_key(feature["id"]))
112
- if existing_json
113
- existing = JSON.parse(existing_json)
114
- return if existing["version"].to_i >= feature["version"].to_i
160
+ # Computes what to write by yielding the current Redis features to the block.
161
+ # The block returns the new features array to store, or nil to abort.
162
+ # Uses WATCH/MULTI/EXEC with retry to handle multi-process contention.
163
+ def perform_store_with_retry
164
+ attempt = 0
165
+ while attempt < @retry_update_count
166
+ redis_features = read_features_from_redis
167
+ new_features = yield(redis_features)
168
+ return if new_features.nil?
169
+
170
+ new_sha = calculate_sha(new_features)
171
+ current_internal = @mutex.synchronize { @internal_sha }
172
+ return if new_sha == current_internal
173
+
174
+ current_sha = @redis.get(sha_key)
175
+
176
+ if current_sha != current_internal
177
+ # Another process updated Redis — reload and recheck on next attempt
178
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
179
+ attempt += 1
180
+ next
181
+ end
182
+
183
+ stored = attempt_atomic_write(current_internal, new_sha, new_features)
184
+
185
+ if stored
186
+ @mutex.synchronize { @internal_sha = new_sha }
187
+ return
188
+ end
189
+
190
+ sleep(@backoff_timeout / 1000.0) unless attempt == @retry_update_count - 1
191
+ attempt += 1
115
192
  end
116
193
 
117
- @redis.sadd(ids_key, feature["id"])
118
- @redis.set(feature_key(feature["id"]), feature.to_json)
194
+ @logger&.warn("[featurehubsdk] failed to update redis after #{@retry_update_count} retries")
119
195
  end
120
196
 
121
- def ids_key
122
- "#{@prefix}_ids"
197
+ # Uses WATCH + MULTI/EXEC to atomically update both keys only if sha_key has not
198
+ # been modified since we read it. Returns true if the transaction committed.
199
+ def attempt_atomic_write(current_internal, new_sha, new_features)
200
+ @redis.watch(sha_key)
201
+ current = @redis.get(sha_key)
202
+
203
+ unless current == current_internal
204
+ @redis.unwatch
205
+ return false
206
+ end
207
+
208
+ result = @redis.multi do |tx|
209
+ tx.set(features_key, new_features.to_json)
210
+ tx.set(sha_key, new_sha)
211
+ end
212
+
213
+ result.is_a?(Array)
123
214
  end
124
215
 
125
- def feature_key(id)
126
- "#{@prefix}_#{id}"
216
+ def read_features_from_redis
217
+ json = @redis.get(features_key)
218
+ return [] unless json
219
+
220
+ JSON.parse(json)
221
+ rescue JSON::ParserError
222
+ []
127
223
  end
128
224
  end
129
225
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module FeatureHub
6
+ module Sdk
7
+ # Shared pure helpers for MemcacheSessionStore and RedisSessionStore.
8
+ # Both stores rely on SHA256-based change detection and a two-key layout:
9
+ # ${prefix}_${environment_id} → JSON array of FeatureState objects
10
+ # ${prefix}_${environment_id}_sha → SHA256 of "id:version|..." for change detection
11
+ module SessionStoreHelpers
12
+ private
13
+
14
+ def calculate_sha(features)
15
+ parts = features.map { |f| "#{f["id"]}:#{version_of(f)}" }.join("|")
16
+ Digest::SHA256.hexdigest(parts)
17
+ end
18
+
19
+ def merge_features(base, updates)
20
+ result = base.dup
21
+ updates.each do |update|
22
+ idx = result.find_index { |f| f["id"] == update["id"] }
23
+ if idx
24
+ result[idx] = update if version_of(update) > version_of(result[idx])
25
+ else
26
+ result << update
27
+ end
28
+ end
29
+ result
30
+ end
31
+
32
+ def version_of(feature)
33
+ (feature["version"] || 0).to_i
34
+ end
35
+
36
+ def features_key
37
+ "#{@prefix}_#{@environment_id}"
38
+ end
39
+
40
+ def sha_key
41
+ "#{@prefix}_#{@environment_id}_sha"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,6 +3,6 @@
3
3
  module FeatureHub
4
4
  # already documented elsewhere
5
5
  module Sdk
6
- VERSION = "2.0.1"
6
+ VERSION = "2.1.0"
7
7
  end
8
8
  end
@@ -15,7 +15,9 @@ require_relative "feature_hub/sdk/strategy_attributes"
15
15
  require_relative "feature_hub/sdk/local_yaml_interceptor"
16
16
  require_relative "feature_hub/sdk/raw_update_feature_listener"
17
17
  require_relative "feature_hub/sdk/local_yaml_store"
18
+ require_relative "feature_hub/sdk/session_store_helpers"
18
19
  require_relative "feature_hub/sdk/redis_session_store"
20
+ require_relative "feature_hub/sdk/memcache_session_store"
19
21
  require_relative "feature_hub/sdk/impl/strategy_wrappers"
20
22
  require_relative "feature_hub/sdk/poll_edge_service"
21
23
  require_relative "feature_hub/sdk/streaming_edge_service"
@@ -94,6 +94,8 @@ module FeatureHub
94
94
 
95
95
  def set?: () -> bool?
96
96
 
97
+ def phantom?: () -> bool
98
+
97
99
  private
98
100
 
99
101
  def top_feature_state: -> FeatureStateHolder
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featurehub-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Vowles
@@ -28,16 +28,16 @@ dependencies:
28
28
  name: faraday
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '2'
33
+ version: '0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '2'
40
+ version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: ld-eventsource
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: 2.0.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: dalli
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '4'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '4'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: redis
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -102,6 +116,7 @@ extensions: []
102
116
  extra_rdoc_files: []
103
117
  files:
104
118
  - ".claude/CLAUDE.md"
119
+ - ".dockerignore"
105
120
  - ".rspec"
106
121
  - ".rubocop.yml"
107
122
  - ".ruby-version"
@@ -111,6 +126,7 @@ files:
111
126
  - Gemfile.lock
112
127
  - LICENSE
113
128
  - LICENSE.txt
129
+ - Makefile
114
130
  - README.md
115
131
  - Rakefile
116
132
  - examples/rails_example/.env.example
@@ -209,6 +225,7 @@ files:
209
225
  - examples/sinatra/build.sh
210
226
  - examples/sinatra/conf/nginx.conf
211
227
  - examples/sinatra/conf/nsswitch.conf
228
+ - examples/sinatra/conf/webapp.conf
212
229
  - examples/sinatra/config.ru
213
230
  - examples/sinatra/docker-compose.yaml
214
231
  - examples/sinatra/docker_start.sh
@@ -231,10 +248,12 @@ files:
231
248
  - lib/feature_hub/sdk/internal_feature_repository.rb
232
249
  - lib/feature_hub/sdk/local_yaml_interceptor.rb
233
250
  - lib/feature_hub/sdk/local_yaml_store.rb
251
+ - lib/feature_hub/sdk/memcache_session_store.rb
234
252
  - lib/feature_hub/sdk/percentage_calc.rb
235
253
  - lib/feature_hub/sdk/poll_edge_service.rb
236
254
  - lib/feature_hub/sdk/raw_update_feature_listener.rb
237
255
  - lib/feature_hub/sdk/redis_session_store.rb
256
+ - lib/feature_hub/sdk/session_store_helpers.rb
238
257
  - lib/feature_hub/sdk/strategy_attributes.rb
239
258
  - lib/feature_hub/sdk/streaming_edge_service.rb
240
259
  - lib/feature_hub/sdk/version.rb