featurehub-sdk 2.0.1 → 2.1.1
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 +14 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +38 -55
- 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 +33 -14
- 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/start.sh +19 -4
- data/featurehub-sdk.gemspec +3 -2
- 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 +11 -0
- data/lib/feature_hub/sdk/memcache_session_store.rb +221 -0
- data/lib/feature_hub/sdk/poll_edge_service.rb +21 -15
- 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 +4 -0
- metadata +32 -10
- data/examples/sinatra/sinatra.iml +0 -43
- data/featurehub-ruby-sdk.iml +0 -9
- data/featurehub-sdk.iml +0 -87
|
@@ -5,14 +5,21 @@ require "sinatra"
|
|
|
5
5
|
require "featurehub-sdk"
|
|
6
6
|
require "json"
|
|
7
7
|
|
|
8
|
-
def configure_featurehub
|
|
8
|
+
def configure_featurehub(logger)
|
|
9
9
|
puts "FeatureHub SDK Version is #{FeatureHub::Sdk::VERSION}"
|
|
10
10
|
|
|
11
|
-
config = FeatureHub::Sdk::FeatureHubConfig.new
|
|
11
|
+
config = FeatureHub::Sdk::FeatureHubConfig.new(nil, nil, nil, nil, logger)
|
|
12
12
|
repo = config.repository
|
|
13
13
|
|
|
14
14
|
if ENV["FEATUREHUB_REDIS_STORE"]
|
|
15
|
-
|
|
15
|
+
FeatureHub::Sdk::RedisSessionStore.new(ENV["FEATUREHUB_REDIS_STORE"], config,
|
|
16
|
+
{ logger: logger, refresh_timeout: 3 })
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if ENV["FEATUREHUB_MEMCACHE_STORE"]
|
|
20
|
+
FeatureHub::Sdk::MemcacheSessionStore.new(ENV["FEATUREHUB_MEMCACHE_STORE"],
|
|
21
|
+
config, { logger: logger,
|
|
22
|
+
refresh_timeout: 3 })
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
if ENV["FEATUREHUB_LOCAL_YAML"]
|
|
@@ -20,6 +27,7 @@ def configure_featurehub
|
|
|
20
27
|
config.register_interceptor(FeatureHub::Sdk::LocalYamlValueInterceptor.new(watch: true))
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
puts "connecting to FeatureHub"
|
|
23
31
|
# connect to edge service
|
|
24
32
|
config.init
|
|
25
33
|
end
|
|
@@ -34,7 +42,9 @@ class App < Sinatra::Base
|
|
|
34
42
|
rack = File.new("logs/rack.log", "a+")
|
|
35
43
|
use Rack::CommonLogger, rack
|
|
36
44
|
|
|
37
|
-
|
|
45
|
+
logger = Logger.new($stdout)
|
|
46
|
+
set :logger, logger
|
|
47
|
+
set :fh_config, configure_featurehub(logger)
|
|
38
48
|
set :users, {}
|
|
39
49
|
end
|
|
40
50
|
|
|
@@ -42,10 +52,6 @@ class App < Sinatra::Base
|
|
|
42
52
|
content_type "application/json"
|
|
43
53
|
end
|
|
44
54
|
|
|
45
|
-
get("/config/disconnect-edge") do
|
|
46
|
-
settings.fh_config.close_edge
|
|
47
|
-
end
|
|
48
|
-
|
|
49
55
|
# Routes
|
|
50
56
|
# "resolve" a specific todo for this user
|
|
51
57
|
put("/todo/:user/:id/resolve") do
|
|
@@ -83,6 +89,7 @@ class App < Sinatra::Base
|
|
|
83
89
|
if new_todo["title"].nil?
|
|
84
90
|
status(400)
|
|
85
91
|
else
|
|
92
|
+
settings.logger.debug("todo is #{new_todo}")
|
|
86
93
|
todos.push(Todo.new(new_todo["id"] || 1, new_todo["title"], new_todo["resolved"] || false))
|
|
87
94
|
todo_list(user)
|
|
88
95
|
end
|
|
@@ -94,13 +101,26 @@ class App < Sinatra::Base
|
|
|
94
101
|
end
|
|
95
102
|
|
|
96
103
|
get("/health/readiness") do
|
|
97
|
-
if
|
|
104
|
+
if settings.fh_config.repository.ready?
|
|
105
|
+
"ok"
|
|
106
|
+
else
|
|
107
|
+
status(500)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
get("/health/liveness") do
|
|
112
|
+
if settings.fh_config.repository.ready?
|
|
98
113
|
"ok"
|
|
99
114
|
else
|
|
100
115
|
status(500)
|
|
101
116
|
end
|
|
102
117
|
end
|
|
103
118
|
|
|
119
|
+
get("/health/disconnect") do
|
|
120
|
+
settings.fh_config.close_edge
|
|
121
|
+
status 200
|
|
122
|
+
end
|
|
123
|
+
|
|
104
124
|
private
|
|
105
125
|
|
|
106
126
|
def todo_list(user)
|
|
@@ -113,6 +133,7 @@ class App < Sinatra::Base
|
|
|
113
133
|
new_todos.push(Todo.new(todo.id, process_title(ctx, todo.title), todo.resolved).to_h)
|
|
114
134
|
end
|
|
115
135
|
|
|
136
|
+
settings.logger.debug("todos #{new_todos}")
|
|
116
137
|
new_todos.to_json
|
|
117
138
|
end
|
|
118
139
|
|
|
@@ -139,15 +160,6 @@ class App < Sinatra::Base
|
|
|
139
160
|
|
|
140
161
|
new_title = new_title.upcase if ctx.enabled?("FEATURE_TITLE_TO_UPPERCASE")
|
|
141
162
|
|
|
142
|
-
|
|
143
|
-
# puts("features via edge service: #{settings.fh_config.get_or_create_edge_service.repository}")
|
|
144
|
-
|
|
145
|
-
# puts("enabled? #{ctx.repo.features}")
|
|
146
|
-
puts(ctx.enabled?("FEATURE_TITLE_TO_UPPERCASE"))
|
|
147
|
-
puts(ctx.flag("FEATURE_TITLE_TO_UPPERCASE"))
|
|
148
|
-
puts(settings.fh_config.repository.feature("FEATURE_TITLE_TO_UPPERCASE").feature_type)
|
|
149
|
-
puts(settings.fh_config.repository.feature("FEATURE_TITLE_TO_UPPERCASE").flag)
|
|
150
|
-
|
|
151
|
-
new_title&.strip
|
|
163
|
+
new_title
|
|
152
164
|
end
|
|
153
165
|
end
|
|
@@ -11,15 +11,6 @@ events {
|
|
|
11
11
|
http {
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
passenger_user_switching on;
|
|
15
|
-
passenger_user root;
|
|
16
|
-
passenger_default_user root;
|
|
17
|
-
|
|
18
|
-
passenger_max_pool_size 6;
|
|
19
|
-
|
|
20
|
-
passenger_disable_security_update_check on;
|
|
21
|
-
passenger_disable_anonymous_telemetry on;
|
|
22
|
-
|
|
23
14
|
include /etc/nginx/conf/vhosts/*.conf;
|
|
24
15
|
include /etc/nginx/conf.d/*.conf;
|
|
25
16
|
|
|
@@ -33,8 +24,9 @@ http {
|
|
|
33
24
|
access_log /dev/stdout;
|
|
34
25
|
listen 8099 default_server;
|
|
35
26
|
server_name localhost;
|
|
36
|
-
root /app/featurehub;
|
|
37
|
-
passenger_app_root /app/featurehub;
|
|
27
|
+
root /app/featurehub/examples/sinatra;
|
|
28
|
+
passenger_app_root /app/featurehub/examples/sinatra;
|
|
29
|
+
passenger_preload_bundler on;
|
|
38
30
|
passenger_enabled on;
|
|
39
31
|
passenger_startup_file config.ru;
|
|
40
32
|
passenger_app_type rack;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
server {
|
|
2
|
+
listen 80;
|
|
3
|
+
server_name localhost;
|
|
4
|
+
root /app/featurehub/examples/sinatra;
|
|
5
|
+
|
|
6
|
+
passenger_enabled on;
|
|
7
|
+
passenger_user root;
|
|
8
|
+
|
|
9
|
+
# If this is a Ruby app, specify a Ruby version:
|
|
10
|
+
# For Ruby 3.3
|
|
11
|
+
passenger_ruby /usr/bin/ruby3.3;
|
|
12
|
+
|
|
13
|
+
# Nginx has a default limit of 1 MB for request bodies, which also applies
|
|
14
|
+
# to file uploads. The following line enables uploads of up to 50 MB:
|
|
15
|
+
#client_max_body_size 50M;
|
|
16
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
services:
|
|
2
2
|
party-server:
|
|
3
|
-
image: featurehub/party-server:
|
|
3
|
+
image: featurehub/party-server:latest
|
|
4
4
|
ports:
|
|
5
5
|
- 8085:8085
|
|
6
6
|
user: 999:999
|
|
@@ -12,13 +12,41 @@ services:
|
|
|
12
12
|
image: redis:latest
|
|
13
13
|
ports:
|
|
14
14
|
- 6379:6379
|
|
15
|
-
|
|
16
|
-
image:
|
|
15
|
+
memcache:
|
|
16
|
+
image: memcached:1.6-alpine
|
|
17
|
+
ports:
|
|
18
|
+
- 11211:11211
|
|
19
|
+
sinatra:
|
|
20
|
+
image: todo-server:e2e
|
|
21
|
+
ports:
|
|
22
|
+
- 8099:8099
|
|
23
|
+
environment:
|
|
24
|
+
RAILS_ENV: production
|
|
25
|
+
FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
|
|
26
|
+
FEATUREHUB_EDGE_URL: http://party-server:8085
|
|
27
|
+
FEATUREHUB_BASE_URL: http://party-server:8085
|
|
28
|
+
sinatra-redis:
|
|
29
|
+
image: todo-server:e2e
|
|
30
|
+
ports:
|
|
31
|
+
- 8099:8099
|
|
32
|
+
environment:
|
|
33
|
+
FEATUREHUB_POLLING_INTERVAL: "3"
|
|
34
|
+
FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
|
|
35
|
+
FEATUREHUB_EDGE_URL: http://party-server:8085
|
|
36
|
+
FEATUREHUB_BASE_URL: http://party-server:8085
|
|
37
|
+
FEATUREHUB_REDIS_STORE: redis://redis:6379
|
|
38
|
+
sinatra-memcache:
|
|
39
|
+
image: todo-server:e2e
|
|
17
40
|
ports:
|
|
18
41
|
- 8099:8099
|
|
19
42
|
environment:
|
|
43
|
+
RAILS_ENV: production
|
|
44
|
+
FEATUREHUB_POLLING_INTERVAL: "3"
|
|
45
|
+
FEATUREHUB_CLIENT_API_KEY: "a1286d73-a23b-4f5b-8fdf-04dd03668fb9/xPlhDYnWlKjjZ3maUPquhOUOmk50gn*Hh8Q5qY6JEURrxtEEjzk"
|
|
20
46
|
FEATUREHUB_EDGE_URL: http://party-server:8085
|
|
21
47
|
FEATUREHUB_BASE_URL: http://party-server:8085
|
|
22
|
-
|
|
48
|
+
FEATUREHUB_MEMCACHE_STORE: memcache
|
|
49
|
+
depends_on:
|
|
50
|
+
- memcache
|
|
23
51
|
volumes:
|
|
24
52
|
featurehub-h2-data:
|
data/examples/sinatra/start.sh
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
#!/bin/
|
|
1
|
+
#!/bin/bash
|
|
2
2
|
RACK_ENV=development
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
data/featurehub-sdk.gemspec
CHANGED
|
@@ -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"
|
|
39
|
-
spec.add_dependency "ld-eventsource", "
|
|
38
|
+
spec.add_dependency "faraday"
|
|
39
|
+
spec.add_dependency "ld-eventsource", ">= 2.5.1", "< 2.7.0"
|
|
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
|
|
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",
|
|
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,
|
|
125
|
+
ClientEvalFeatureContext.new(@repository, edge_service)
|
|
116
126
|
else
|
|
117
|
-
ServerEvalFeatureContext.new(@repository,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -43,6 +43,11 @@ module FeatureHub
|
|
|
43
43
|
exists?(fs) ? fs["type"] : nil
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def feature_properties
|
|
47
|
+
fs = feature_state
|
|
48
|
+
(exists?(fs) ? fs["fp"] : {}) || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
46
51
|
def with_context(ctx)
|
|
47
52
|
FeatureStateHolder.new(@key, @repo, nil, self, ctx)
|
|
48
53
|
end
|
|
@@ -92,6 +97,12 @@ module FeatureHub
|
|
|
92
97
|
!value.nil?
|
|
93
98
|
end
|
|
94
99
|
|
|
100
|
+
def phantom?
|
|
101
|
+
return @parent_state&.phantom? unless @parent_state.nil?
|
|
102
|
+
|
|
103
|
+
@internal_feature_state.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
95
106
|
def top_feature_state
|
|
96
107
|
return @parent_state&.top_feature_state if @parent_state
|
|
97
108
|
|
|
@@ -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
|
|
@@ -108,21 +108,27 @@ module FeatureHub
|
|
|
108
108
|
headers["if-none-match"] = @etag unless @etag.nil?
|
|
109
109
|
|
|
110
110
|
@logger&.debug("polling for #{url}")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
begin
|
|
112
|
+
resp = @conn.get url, {}, headers
|
|
113
|
+
case resp.status
|
|
114
|
+
when 200
|
|
115
|
+
success(resp)
|
|
116
|
+
when 236
|
|
117
|
+
stopped_task
|
|
118
|
+
success(resp)
|
|
119
|
+
when 404, 400 # no such key
|
|
120
|
+
@repository.notify("failed", nil, "polling")
|
|
121
|
+
cancel_task
|
|
122
|
+
@logger&.error("featurehub: key does not exist, stopping polling")
|
|
123
|
+
when 503 # dacha busy
|
|
124
|
+
@logger&.debug("featurehub: dacha is busy, trying again")
|
|
125
|
+
else
|
|
126
|
+
@logger&.debug("featurehub: unknown error #{resp.status}") if resp.status != 304
|
|
127
|
+
end
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
# we can get timeout errors for transient network failures, this should not prevent the
|
|
130
|
+
# next poll from happening however
|
|
131
|
+
@logger&.error("featurehub: failed to connect or similar error #{e&.message}")
|
|
126
132
|
end
|
|
127
133
|
end
|
|
128
134
|
|