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.
- checksums.yaml +4 -4
- data/.claude/CLAUDE.md +1 -0
- data/.dockerignore +12 -0
- data/.rubocop.yml +0 -2
- data/CHANGELOG.md +9 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +8 -4
- data/Makefile +12 -0
- data/README.md +66 -13
- data/examples/sinatra/Dockerfile +16 -16
- data/examples/sinatra/Gemfile +4 -0
- data/examples/sinatra/Gemfile.lock +31 -12
- data/examples/sinatra/README.adoc +18 -2
- data/examples/sinatra/app/application.rb +31 -19
- data/examples/sinatra/conf/nginx.conf +3 -11
- data/examples/sinatra/conf/webapp.conf +16 -0
- data/examples/sinatra/docker-compose.yaml +32 -4
- data/examples/sinatra/sinatra.iml +13 -5
- data/examples/sinatra/start.sh +19 -4
- data/featurehub-sdk.gemspec +2 -1
- data/lib/feature_hub/sdk/context.rb +3 -1
- data/lib/feature_hub/sdk/feature_hub_config.rb +32 -8
- data/lib/feature_hub/sdk/feature_state_holder.rb +6 -0
- data/lib/feature_hub/sdk/memcache_session_store.rb +221 -0
- data/lib/feature_hub/sdk/redis_session_store.rb +145 -49
- data/lib/feature_hub/sdk/session_store_helpers.rb +45 -0
- data/lib/feature_hub/sdk/version.rb +1 -1
- data/lib/featurehub-sdk.rb +2 -0
- data/sig/feature_hub/featurehub.rbs +2 -0
- metadata +24 -5
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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: @
|
|
103
|
-
|
|
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
|
|
109
|
-
return unless
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
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
|
data/lib/featurehub-sdk.rb
CHANGED
|
@@ -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"
|
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
|
|
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: '
|
|
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: '
|
|
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
|