featurehub-sdk 1.3.0 → 2.0.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 +85 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +18 -1
- data/Gemfile +1 -1
- data/Gemfile.lock +20 -8
- data/README.md +306 -119
- data/examples/rails_example/.ruby-version +1 -1
- data/examples/rails_example/Dockerfile +1 -1
- data/examples/sinatra/.dockerignore +7 -0
- data/examples/sinatra/.ruby-version +1 -1
- data/examples/sinatra/Dockerfile +14 -25
- data/examples/sinatra/Gemfile +5 -4
- data/examples/sinatra/Gemfile.lock +40 -32
- data/examples/sinatra/app/application.rb +21 -9
- data/examples/sinatra/docker-compose.yaml +24 -0
- data/examples/sinatra/feature-flags.yaml +6 -0
- data/examples/sinatra/sinatra.iml +35 -14
- data/examples/sinatra/start.sh +2 -0
- data/featurehub-sdk.gemspec +4 -1
- data/lib/feature_hub/sdk/context.rb +28 -7
- data/lib/feature_hub/sdk/feature_hub_config.rb +68 -12
- data/lib/feature_hub/sdk/feature_repository.rb +52 -13
- data/lib/feature_hub/sdk/{feature_state.rb → feature_state_holder.rb} +13 -9
- data/lib/feature_hub/sdk/interceptors.rb +10 -6
- data/lib/feature_hub/sdk/internal_feature_repository.rb +7 -3
- data/lib/feature_hub/sdk/local_yaml_interceptor.rb +99 -0
- data/lib/feature_hub/sdk/local_yaml_store.rb +71 -0
- data/lib/feature_hub/sdk/poll_edge_service.rb +10 -15
- data/lib/feature_hub/sdk/raw_update_feature_listener.rb +19 -0
- data/lib/feature_hub/sdk/redis_session_store.rb +130 -0
- data/lib/feature_hub/sdk/strategy_attributes.rb +7 -0
- data/lib/feature_hub/sdk/streaming_edge_service.rb +5 -7
- data/lib/feature_hub/sdk/version.rb +1 -10
- data/lib/featurehub-sdk.rb +5 -1
- data/sig/feature_hub/featurehub.rbs +127 -28
- metadata +27 -5
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module FeatureHub
|
|
4
4
|
module Sdk
|
|
5
5
|
# represents internal state of a feature
|
|
6
|
-
class
|
|
6
|
+
class FeatureStateHolder
|
|
7
7
|
attr_reader :key, :internal_feature_state, :encoded_strategies
|
|
8
8
|
|
|
9
9
|
def initialize(key, repo, feature_state = nil, parent_state = nil, ctx = nil)
|
|
@@ -30,6 +30,10 @@ module FeatureHub
|
|
|
30
30
|
!(fs.empty? || fs["l"].nil?)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def present?
|
|
34
|
+
exists?
|
|
35
|
+
end
|
|
36
|
+
|
|
33
37
|
def id
|
|
34
38
|
exists? ? @internal_feature_state["id"] : nil
|
|
35
39
|
end
|
|
@@ -40,7 +44,7 @@ module FeatureHub
|
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def with_context(ctx)
|
|
43
|
-
|
|
47
|
+
FeatureStateHolder.new(@key, @repo, nil, self, ctx)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def update_feature_state(feature_state)
|
|
@@ -61,19 +65,19 @@ module FeatureHub
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def string
|
|
64
|
-
get_value(
|
|
68
|
+
get_value(FeatureValueType::STRING)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
def number
|
|
68
|
-
get_value(
|
|
72
|
+
get_value(FeatureValueType::NUMBER)
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def raw_json
|
|
72
|
-
get_value(
|
|
76
|
+
get_value(FeatureValueType::JSON)
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def boolean
|
|
76
|
-
get_value(
|
|
80
|
+
get_value(FeatureValueType::BOOLEAN)
|
|
77
81
|
end
|
|
78
82
|
|
|
79
83
|
def flag
|
|
@@ -108,9 +112,9 @@ module FeatureHub
|
|
|
108
112
|
|
|
109
113
|
def get_value(feature_type)
|
|
110
114
|
unless locked?
|
|
111
|
-
|
|
115
|
+
matched, intercept_value = @repo.find_interceptor(@key, top_feature_state.feature_state)
|
|
112
116
|
|
|
113
|
-
return
|
|
117
|
+
return intercept_value if matched
|
|
114
118
|
end
|
|
115
119
|
|
|
116
120
|
fs = top_feature_state
|
|
@@ -124,7 +128,7 @@ module FeatureHub
|
|
|
124
128
|
if @ctx
|
|
125
129
|
matched = @repo.apply(fs.encoded_strategies, @key, fs.id, @ctx)
|
|
126
130
|
|
|
127
|
-
return
|
|
131
|
+
return matched.value if matched.matched
|
|
128
132
|
end
|
|
129
133
|
|
|
130
134
|
state["value"]
|
|
@@ -12,9 +12,9 @@ module FeatureHub
|
|
|
12
12
|
return @val if expected_type.nil? || @val.nil?
|
|
13
13
|
|
|
14
14
|
case expected_type
|
|
15
|
-
when
|
|
15
|
+
when FeatureValueType::BOOLEAN
|
|
16
16
|
@val.to_s.downcase.strip == "true"
|
|
17
|
-
when
|
|
17
|
+
when FeatureValueType::NUMBER
|
|
18
18
|
@val.to_s.to_f
|
|
19
19
|
else
|
|
20
20
|
@val.to_s
|
|
@@ -25,7 +25,11 @@ module FeatureHub
|
|
|
25
25
|
# Holds the pattern for a value based interceptor, which could come from a file, or whatever
|
|
26
26
|
# they are not typed
|
|
27
27
|
class ValueInterceptor
|
|
28
|
-
def intercepted_value(
|
|
28
|
+
def intercepted_value(_feature_key, _repository, _feature_state)
|
|
29
|
+
[false, nil]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close; end
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
# An example of a value interceptor that uses environment variables
|
|
@@ -35,13 +39,13 @@ module FeatureHub
|
|
|
35
39
|
@enabled = ENV.fetch("FEATUREHUB_OVERRIDE_FEATURES", "false") == "true"
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
def intercepted_value(feature_key)
|
|
42
|
+
def intercepted_value(feature_key, _repository, _feature_state)
|
|
39
43
|
if @enabled
|
|
40
44
|
found = ENV.fetch("FEATUREHUB_#{sanitize_feature_name(feature_key.to_s)}", nil)
|
|
41
|
-
return
|
|
45
|
+
return [true, found] unless found.nil?
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
nil
|
|
48
|
+
[false, nil]
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
private
|
|
@@ -8,8 +8,12 @@ module FeatureHub
|
|
|
8
8
|
nil
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def
|
|
12
|
-
|
|
11
|
+
def value(_key, default_value = nil, _attrs = nil)
|
|
12
|
+
default_value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find_interceptor(_feature_key, _feature_state = nil)
|
|
16
|
+
[false, nil]
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def ready?
|
|
@@ -22,7 +26,7 @@ module FeatureHub
|
|
|
22
26
|
Applied.new(false, nil)
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
def notify(status, data); end
|
|
29
|
+
def notify(status, data, source = "unknown"); end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "concurrent-ruby"
|
|
6
|
+
|
|
7
|
+
module FeatureHub
|
|
8
|
+
module Sdk
|
|
9
|
+
# Reads feature flag overrides from a local YAML file.
|
|
10
|
+
# The file path is read from FEATUREHUB_LOCAL_YAML or defaults to featurehub-features.yaml.
|
|
11
|
+
# Pass watch: true to reload the file automatically when it changes.
|
|
12
|
+
# Expected format:
|
|
13
|
+
# flagValues:
|
|
14
|
+
# MY_FLAG: true
|
|
15
|
+
# MY_STRING: "hello"
|
|
16
|
+
class LocalYamlValueInterceptor < ValueInterceptor
|
|
17
|
+
def initialize(opts = nil)
|
|
18
|
+
super()
|
|
19
|
+
opts ||= {}
|
|
20
|
+
@yaml_file = opts[:filename] || ENV.fetch("FEATUREHUB_LOCAL_YAML", "featurehub-features.yaml")
|
|
21
|
+
@logger = opts[:logger]
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@flag_values = load_flag_values(@yaml_file)
|
|
24
|
+
@logger&.debug("[featurehubsdk] loaded #{@flag_values.size} feature override(s) from #{@yaml_file}")
|
|
25
|
+
@watcher = nil
|
|
26
|
+
|
|
27
|
+
return unless opts[:watch]
|
|
28
|
+
|
|
29
|
+
@last_mtime = File.exist?(@yaml_file) ? File.mtime(@yaml_file) : nil
|
|
30
|
+
watch_interval = opts[:watch_interval] || 5
|
|
31
|
+
@watcher = Concurrent::TimerTask.new(execution_interval: watch_interval, run_now: false) do
|
|
32
|
+
reload_if_changed
|
|
33
|
+
end
|
|
34
|
+
@watcher.execute
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def intercepted_value(feature_key, _repository, feature_state)
|
|
38
|
+
key = feature_key.to_s
|
|
39
|
+
flag_values = @mutex.synchronize { @flag_values }
|
|
40
|
+
return [false, nil] unless flag_values.key?(key)
|
|
41
|
+
|
|
42
|
+
value = flag_values[key]
|
|
43
|
+
|
|
44
|
+
return [false, nil] if feature_state && yaml_value_type(value) != feature_state["type"]
|
|
45
|
+
|
|
46
|
+
[true, cast_value(value)]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
return if @watcher.nil?
|
|
51
|
+
|
|
52
|
+
@watcher.shutdown
|
|
53
|
+
@watcher = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def reload_if_changed
|
|
59
|
+
return unless File.exist?(@yaml_file)
|
|
60
|
+
|
|
61
|
+
current_mtime = File.mtime(@yaml_file)
|
|
62
|
+
return if current_mtime == @last_mtime
|
|
63
|
+
|
|
64
|
+
@last_mtime = current_mtime
|
|
65
|
+
new_values = load_flag_values(@yaml_file)
|
|
66
|
+
@logger&.debug("[featurehubsdk] reloaded #{new_values.size} feature override(s) from #{@yaml_file}")
|
|
67
|
+
@mutex.synchronize { @flag_values = new_values }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def load_flag_values(yaml_file)
|
|
71
|
+
return {} unless File.exist?(yaml_file)
|
|
72
|
+
|
|
73
|
+
data = YAML.safe_load(File.read(yaml_file))
|
|
74
|
+
data&.fetch("flagValues", {}) || {}
|
|
75
|
+
rescue StandardError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def yaml_value_type(value)
|
|
80
|
+
case value
|
|
81
|
+
when true, false
|
|
82
|
+
FeatureValueType::BOOLEAN
|
|
83
|
+
when Integer, Float
|
|
84
|
+
FeatureValueType::NUMBER
|
|
85
|
+
when String
|
|
86
|
+
FeatureValueType::STRING
|
|
87
|
+
else
|
|
88
|
+
FeatureValueType::JSON
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cast_value(value)
|
|
93
|
+
return value.to_f if value.is_a?(Integer) || value.is_a?(Float)
|
|
94
|
+
|
|
95
|
+
value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module FeatureHub
|
|
8
|
+
module Sdk
|
|
9
|
+
# Reads feature flags from a local YAML file and loads them into a FeatureHubRepository,
|
|
10
|
+
# allowing the SDK to operate without a FeatureHub Edge server.
|
|
11
|
+
# Implements RawUpdateFeatureListener but silently ignores all incoming update callbacks —
|
|
12
|
+
# the file is the single source of truth and is read exactly once at initialization.
|
|
13
|
+
#
|
|
14
|
+
# Expected YAML format (same as LocalYamlValueInterceptor):
|
|
15
|
+
# flagValues:
|
|
16
|
+
# MY_FLAG: true
|
|
17
|
+
# MY_STRING: "hello"
|
|
18
|
+
# MY_NUMBER: 42
|
|
19
|
+
class LocalYamlStore < RawUpdateFeatureListener
|
|
20
|
+
SOURCE = "local-yaml"
|
|
21
|
+
|
|
22
|
+
def initialize(repository, filename = nil)
|
|
23
|
+
super()
|
|
24
|
+
@environment_id = SecureRandom.uuid
|
|
25
|
+
yaml_file = filename || ENV.fetch("FEATUREHUB_LOCAL_YAML", "featurehub-features.yaml")
|
|
26
|
+
features = load_features(yaml_file)
|
|
27
|
+
repository.notify("features", features, SOURCE) if features
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def load_features(yaml_file)
|
|
33
|
+
return nil unless File.exist?(yaml_file)
|
|
34
|
+
|
|
35
|
+
data = YAML.safe_load(File.read(yaml_file))
|
|
36
|
+
flag_values = data&.fetch("flagValues", {}) || {}
|
|
37
|
+
flag_values.map { |key, value| build_feature_state(key.to_s, value) }
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_feature_state(key, value)
|
|
43
|
+
{
|
|
44
|
+
"id" => SecureRandom.uuid,
|
|
45
|
+
"key" => key,
|
|
46
|
+
"l" => false,
|
|
47
|
+
"version" => 1,
|
|
48
|
+
"type" => feature_type(value),
|
|
49
|
+
"value" => cast_value(value),
|
|
50
|
+
"environmentId" => @environment_id
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def feature_type(value)
|
|
55
|
+
case value
|
|
56
|
+
when true, false then FeatureValueType::BOOLEAN
|
|
57
|
+
when Integer, Float then FeatureValueType::NUMBER
|
|
58
|
+
when String then FeatureValueType::STRING
|
|
59
|
+
else FeatureValueType::JSON
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def cast_value(value)
|
|
64
|
+
return value.to_f if value.is_a?(Integer) || value.is_a?(Float)
|
|
65
|
+
return value.to_json if value.is_a?(Hash) || value.is_a?(Array)
|
|
66
|
+
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -10,18 +10,13 @@ module FeatureHub
|
|
|
10
10
|
module Sdk
|
|
11
11
|
# uses a periodic polling mechanism to get updates
|
|
12
12
|
class PollingEdgeService < EdgeService
|
|
13
|
-
attr_reader :
|
|
13
|
+
attr_reader :api_keys, :edge_url, :interval, :stopped, :etag, :cancel, :sha_context
|
|
14
14
|
|
|
15
15
|
def initialize(repository, api_keys, edge_url, interval, logger = nil)
|
|
16
|
-
super(repository, api_keys, edge_url)
|
|
16
|
+
super(repository, api_keys, edge_url, logger)
|
|
17
17
|
|
|
18
|
-
@repository = repository
|
|
19
|
-
@api_keys = api_keys
|
|
20
|
-
@edge_url = edge_url
|
|
21
18
|
@interval = interval
|
|
22
19
|
|
|
23
|
-
@logger = logger || FeatureHub::Sdk.default_logger
|
|
24
|
-
|
|
25
20
|
@task = nil
|
|
26
21
|
@cancel = false
|
|
27
22
|
@context = nil
|
|
@@ -73,7 +68,7 @@ module FeatureHub
|
|
|
73
68
|
def poll_with_interval
|
|
74
69
|
return if @cancel || !@task.nil? || @stopped
|
|
75
70
|
|
|
76
|
-
@logger
|
|
71
|
+
@logger&.debug("starting polling for #{determine_request_url}")
|
|
77
72
|
@task = Concurrent::TimerTask.new(execution_interval: @interval, run_now: false) do
|
|
78
73
|
get_updates
|
|
79
74
|
end
|
|
@@ -112,7 +107,7 @@ module FeatureHub
|
|
|
112
107
|
headers["x-featurehub"] = @context unless @context.nil?
|
|
113
108
|
headers["if-none-match"] = @etag unless @etag.nil?
|
|
114
109
|
|
|
115
|
-
@logger
|
|
110
|
+
@logger&.debug("polling for #{url}")
|
|
116
111
|
resp = @conn.get url, {}, headers
|
|
117
112
|
case resp.status
|
|
118
113
|
when 200
|
|
@@ -120,14 +115,14 @@ module FeatureHub
|
|
|
120
115
|
when 236
|
|
121
116
|
stopped_task
|
|
122
117
|
success(resp)
|
|
123
|
-
when 404 # no such key
|
|
124
|
-
@repository.notify("failed", nil)
|
|
118
|
+
when 404, 400 # no such key
|
|
119
|
+
@repository.notify("failed", nil, "polling")
|
|
125
120
|
cancel_task
|
|
126
|
-
@logger
|
|
121
|
+
@logger&.error("featurehub: key does not exist, stopping polling")
|
|
127
122
|
when 503 # dacha busy
|
|
128
|
-
@logger
|
|
123
|
+
@logger&.debug("featurehub: dacha is busy, trying again")
|
|
129
124
|
else
|
|
130
|
-
@logger
|
|
125
|
+
@logger&.debug("featurehub: unknown error #{resp.status}") if resp.status != 304
|
|
131
126
|
end
|
|
132
127
|
end
|
|
133
128
|
|
|
@@ -143,7 +138,7 @@ module FeatureHub
|
|
|
143
138
|
|
|
144
139
|
def process_results(data)
|
|
145
140
|
data.each do |environment|
|
|
146
|
-
@repository.notify("features", environment["features"]) if environment
|
|
141
|
+
@repository.notify("features", environment["features"], "polling") if environment
|
|
147
142
|
end
|
|
148
143
|
end
|
|
149
144
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureHub
|
|
4
|
+
module Sdk
|
|
5
|
+
# Base class for listening to raw feature update events from edge services.
|
|
6
|
+
# Subclass and override the methods you care about.
|
|
7
|
+
class RawUpdateFeatureListener
|
|
8
|
+
def delete_feature(_feature, _source); end
|
|
9
|
+
|
|
10
|
+
def process_updates(_features, _source); end
|
|
11
|
+
|
|
12
|
+
def process_update(_feature, _source); end
|
|
13
|
+
|
|
14
|
+
def close; end
|
|
15
|
+
|
|
16
|
+
def config_changed; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "concurrent-ruby"
|
|
5
|
+
|
|
6
|
+
module FeatureHub
|
|
7
|
+
module Sdk
|
|
8
|
+
# Persists feature values from a FeatureHubRepository in Redis so they survive
|
|
9
|
+
# process restarts and are shared across multiple processes.
|
|
10
|
+
#
|
|
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.
|
|
14
|
+
#
|
|
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.
|
|
20
|
+
#
|
|
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)
|
|
25
|
+
class RedisSessionStore < RawUpdateFeatureListener
|
|
26
|
+
SOURCE = "redis-store"
|
|
27
|
+
|
|
28
|
+
def initialize(connection_string, repository, opts = nil)
|
|
29
|
+
super()
|
|
30
|
+
|
|
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]
|
|
38
|
+
@task = nil
|
|
39
|
+
|
|
40
|
+
return unless redis_available?
|
|
41
|
+
|
|
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
|
|
46
|
+
start_timer
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def process_updates(features, source)
|
|
50
|
+
return if source == SOURCE || !redis_available?
|
|
51
|
+
|
|
52
|
+
features.each { |f| store_feature(f) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process_update(feature, source)
|
|
56
|
+
return if source == SOURCE || !redis_available?
|
|
57
|
+
|
|
58
|
+
store_feature(feature)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete_feature(feature, source)
|
|
62
|
+
return if source == SOURCE || !redis_available? || !feature["id"]
|
|
63
|
+
|
|
64
|
+
@redis.srem(ids_key, feature["id"])
|
|
65
|
+
@redis.del(feature_key(feature["id"]))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def close
|
|
69
|
+
return if @task.nil?
|
|
70
|
+
|
|
71
|
+
@task.shutdown
|
|
72
|
+
@task = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def redis_available?
|
|
78
|
+
@redis_available ||= begin
|
|
79
|
+
require "redis"
|
|
80
|
+
true
|
|
81
|
+
rescue LoadError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
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
|
|
94
|
+
|
|
95
|
+
return if features.empty?
|
|
96
|
+
|
|
97
|
+
@logger&.debug("[featurehubsdk] loading #{features.size} feature(s) from redis")
|
|
98
|
+
@repository.notify("features", features, SOURCE)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def start_timer
|
|
102
|
+
@task = Concurrent::TimerTask.new(execution_interval: @timeout, run_now: false) do
|
|
103
|
+
load_from_redis
|
|
104
|
+
end
|
|
105
|
+
@task.execute
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def store_feature(feature)
|
|
109
|
+
return unless feature && feature["id"] && feature["key"]
|
|
110
|
+
|
|
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
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@redis.sadd(ids_key, feature["id"])
|
|
118
|
+
@redis.set(feature_key(feature["id"]), feature.to_json)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ids_key
|
|
122
|
+
"#{@prefix}_ids"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def feature_key(id)
|
|
126
|
+
"#{@prefix}_#{id}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -213,6 +213,13 @@ module FeatureHub
|
|
|
213
213
|
Embedded = "embedded"
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
module FeatureValueType
|
|
217
|
+
BOOLEAN = "BOOLEAN"
|
|
218
|
+
JSON = "JSON"
|
|
219
|
+
STRING = "STRING"
|
|
220
|
+
NUMBER = "NUMBER"
|
|
221
|
+
end
|
|
222
|
+
|
|
216
223
|
module StrategyAttributePlatformName
|
|
217
224
|
Linux = "linux"
|
|
218
225
|
Windows = "windows"
|
|
@@ -7,16 +7,14 @@ module FeatureHub
|
|
|
7
7
|
module Sdk
|
|
8
8
|
# provides a streaming service
|
|
9
9
|
class StreamingEdgeService < FeatureHub::Sdk::EdgeService
|
|
10
|
-
attr_reader :
|
|
10
|
+
attr_reader :sse_client, :url, :stopped
|
|
11
11
|
|
|
12
12
|
def initialize(repository, api_keys, edge_url, logger = nil)
|
|
13
|
-
super(repository, api_keys, edge_url)
|
|
13
|
+
super(repository, api_keys, edge_url, logger)
|
|
14
14
|
|
|
15
15
|
@url = "#{edge_url}features/#{api_keys[0]}"
|
|
16
|
-
@repository = repository
|
|
17
16
|
@sse_client = nil
|
|
18
17
|
@context = nil
|
|
19
|
-
@logger = logger || FeatureHub::Sdk.default_logger
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def closed
|
|
@@ -58,7 +56,7 @@ module FeatureHub
|
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
def start_streaming
|
|
61
|
-
@logger
|
|
59
|
+
@logger&.debug("streaming from #{@url}")
|
|
62
60
|
# we can get an error before returning the new() function and get a race condition on the close
|
|
63
61
|
must_close = false
|
|
64
62
|
@sse_client = SSE::Client.new(@url) do |client|
|
|
@@ -68,12 +66,12 @@ module FeatureHub
|
|
|
68
66
|
if event.type == "config"
|
|
69
67
|
process_config(json_data)
|
|
70
68
|
else
|
|
71
|
-
@repository.notify(event.type, json_data)
|
|
69
|
+
@repository.notify(event.type, json_data, "streaming")
|
|
72
70
|
end
|
|
73
71
|
end
|
|
74
72
|
client.on_error do |error|
|
|
75
73
|
if error.is_a?(SSE::Errors::HTTPStatusError) && (error.status == 404)
|
|
76
|
-
@repository.notify("failure", nil)
|
|
74
|
+
@repository.notify("failure", nil, "streaming")
|
|
77
75
|
close
|
|
78
76
|
must_close = true
|
|
79
77
|
end
|
|
@@ -3,15 +3,6 @@
|
|
|
3
3
|
module FeatureHub
|
|
4
4
|
# already documented elsewhere
|
|
5
5
|
module Sdk
|
|
6
|
-
VERSION = "
|
|
7
|
-
|
|
8
|
-
def default_logger
|
|
9
|
-
log = ::Logger.new($stdout)
|
|
10
|
-
log.level = ::Logger::WARN
|
|
11
|
-
log.progname = "featurehub-sdk"
|
|
12
|
-
log
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
module_function :default_logger
|
|
6
|
+
VERSION = "2.0.1"
|
|
16
7
|
end
|
|
17
8
|
end
|
data/lib/featurehub-sdk.rb
CHANGED
|
@@ -9,9 +9,13 @@ require_relative "feature_hub/sdk/context"
|
|
|
9
9
|
require_relative "feature_hub/sdk/feature_hub_config"
|
|
10
10
|
require_relative "feature_hub/sdk/internal_feature_repository"
|
|
11
11
|
require_relative "feature_hub/sdk/feature_repository"
|
|
12
|
-
require_relative "feature_hub/sdk/
|
|
12
|
+
require_relative "feature_hub/sdk/feature_state_holder"
|
|
13
13
|
require_relative "feature_hub/sdk/interceptors"
|
|
14
14
|
require_relative "feature_hub/sdk/strategy_attributes"
|
|
15
|
+
require_relative "feature_hub/sdk/local_yaml_interceptor"
|
|
16
|
+
require_relative "feature_hub/sdk/raw_update_feature_listener"
|
|
17
|
+
require_relative "feature_hub/sdk/local_yaml_store"
|
|
18
|
+
require_relative "feature_hub/sdk/redis_session_store"
|
|
15
19
|
require_relative "feature_hub/sdk/impl/strategy_wrappers"
|
|
16
20
|
require_relative "feature_hub/sdk/poll_edge_service"
|
|
17
21
|
require_relative "feature_hub/sdk/streaming_edge_service"
|