star_gates 0.2.0 → 0.3.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/.release-please-manifest.json +3 -0
- data/CHANGELOG.md +13 -1
- data/README.md +21 -3
- data/lib/star_gates/flags.rb +149 -14
- data/lib/star_gates/version.rb +1 -1
- data/release-please-config.json +11 -0
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58b1b72d0deb4eb946b9c7c799f24ddd621051c2e7d611a570fbce998d33cc8f
|
|
4
|
+
data.tar.gz: 6e0cdeb3572cd4a939749c2020eb80140ed33e8b9586e5c106bcff5cd65ba47e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67de3f5d4afb0ed6ea05b889c273aa0d4ea6693bef9d6be51cc8aef84f56f62116fd54cd818fa7de9f8ad2ca09a4cf410ef903ba3d060a5da7da81ebe5c159b5
|
|
7
|
+
data.tar.gz: 17bcc70a0e898e2d1bb63f6abdca7fc80b5b70e9d03422a2ae552d7d631be1800ad281a0e7a6624df2e5914ef0b8e26a3c8d8a388fba8057f3c80666361bbcac
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [0.3.1](https://github.com/seuros/star_gates/compare/v0.3.0...v0.3.1) (2026-10-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **deps:** require breaker_machines 0.22.1 ([34c4f16](https://github.com/seuros/star_gates/commit/34c4f16849e89646e38b73fb846049d1fcffb762))
|
|
9
|
+
|
|
10
|
+
## [0.3.0](https://github.com/seuros/star_gates/compare/v0.2.0...v0.3.0) (2026-10-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* share runtime flags through an ActiveSupport::Cache store ([edb39ba](https://github.com/seuros/star_gates/commit/edb39ba43f03278a6e3d70770a14671ccc4eb539))
|
|
4
16
|
|
|
5
17
|
## 0.2.0 (2026-10-28)
|
|
6
18
|
|
data/README.md
CHANGED
|
@@ -46,9 +46,27 @@ StarGates.flags.configuration = -> { Rails.application.config.x.feature_flags }
|
|
|
46
46
|
|
|
47
47
|
`StarGates::Flags.new(env: {}, configuration: {})` creates an isolated registry.
|
|
48
48
|
`all`, `enabled_flags`, `summary`, and `flag_status` expose values and their sources.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
By default, runtime transitions reuse `StarGates::Feature`, and changes and metadata are
|
|
50
|
+
process-local. To share them across workers, give the registry an `ActiveSupport::Cache`
|
|
51
|
+
store (Solid Cache, Redis, Memcached):
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
StarGates.flags.store = Rails.cache
|
|
55
|
+
# or: StarGates::Flags.new(store: Rails.cache, prefix: 'myapp:flags')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Store-backed flags read the store on every check, so a change in one worker is visible
|
|
59
|
+
in the others right away. Each flag's value and metadata are separate keys written
|
|
60
|
+
blindly; `prefix` (default `star_gates:flags`) namespaces them. A failed write raises
|
|
61
|
+
`StarGates::Error`, and a value the store cannot return counts as no runtime override.
|
|
62
|
+
Listing (`all`, `enabled_flags`, `summary`) and `reset!` use a shared name index. Each new
|
|
63
|
+
name takes its own slot from an atomic `increment`, so workers adding flags at the same
|
|
64
|
+
moment never drop each other's names. Stores without `increment` fall back to a
|
|
65
|
+
read-modify-write list, where a concurrent addition can drop a name from listings until
|
|
66
|
+
that flag next changes; lookups by name are unaffected either way. `reset!` and `remove`
|
|
67
|
+
clear runtime state and metadata, not environment/configuration. Switching `store` drops
|
|
68
|
+
process-local runtime state.
|
|
69
|
+
`StarGates.reset!` resets gates separately.
|
|
52
70
|
|
|
53
71
|
## Gates and rollouts
|
|
54
72
|
|
data/lib/star_gates/flags.rb
CHANGED
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module StarGates
|
|
4
4
|
# Boolean flags with environment > configuration > runtime > false precedence.
|
|
5
|
-
# Runtime state uses Feature's lifecycle and is local to this process
|
|
5
|
+
# Runtime state uses Feature's lifecycle and is local to this process unless
|
|
6
|
+
# a shared store is configured.
|
|
6
7
|
class Flags
|
|
7
8
|
attr_accessor :configuration
|
|
9
|
+
attr_reader :store
|
|
8
10
|
|
|
9
|
-
def initialize(env: ENV, configuration: {})
|
|
11
|
+
def initialize(env: ENV, configuration: {}, store: nil, prefix: 'star_gates:flags')
|
|
10
12
|
@env = env
|
|
11
13
|
@configuration = configuration
|
|
14
|
+
@store = store
|
|
15
|
+
@prefix = prefix
|
|
12
16
|
@runtime = {}
|
|
13
17
|
@mutex = Mutex.new
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
def store=(store)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@runtime.clear
|
|
23
|
+
@store = store
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
def enabled?(name)
|
|
17
28
|
debug(name)[:enabled]
|
|
18
29
|
end
|
|
@@ -34,19 +45,25 @@ module StarGates
|
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
def remove(name)
|
|
37
|
-
|
|
48
|
+
normalized = normalize(name)
|
|
49
|
+
if @store
|
|
50
|
+
store_remove(normalized) if normalized
|
|
51
|
+
else
|
|
52
|
+
@mutex.synchronize { @runtime.delete(normalized) }
|
|
53
|
+
end
|
|
38
54
|
nil
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
def reset!
|
|
42
|
-
@mutex.synchronize { @runtime.clear }
|
|
58
|
+
@store ? store_reset : @mutex.synchronize { @runtime.clear }
|
|
43
59
|
nil
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
def all
|
|
47
63
|
configured = configuration_flags
|
|
48
|
-
|
|
49
|
-
names
|
|
64
|
+
runtime = runtime_values
|
|
65
|
+
names = (environment_flags.keys + configured.keys + runtime.keys).uniq
|
|
66
|
+
names.to_h { |name| [name, resolution(name, configured, runtime[name]).slice(:enabled, :source)] }
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
def enabled_flags
|
|
@@ -55,7 +72,7 @@ module StarGates
|
|
|
55
72
|
|
|
56
73
|
def summary
|
|
57
74
|
{
|
|
58
|
-
runtime_flags:
|
|
75
|
+
runtime_flags: runtime_values.keys.map(&:to_sym),
|
|
59
76
|
environment_flags: environment_flags.keys.map(&:to_sym),
|
|
60
77
|
configuration_flags: configuration_flags.keys.map(&:to_sym),
|
|
61
78
|
all_enabled: enabled_flags
|
|
@@ -63,14 +80,15 @@ module StarGates
|
|
|
63
80
|
end
|
|
64
81
|
|
|
65
82
|
def debug(name)
|
|
66
|
-
|
|
83
|
+
normalized = normalize(name)
|
|
84
|
+
resolution(normalized, configuration_flags, runtime_value(normalized))
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
def flag_status(name)
|
|
70
88
|
normalized = normalize(name)
|
|
71
89
|
details = debug(normalized)
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
{ name: normalized&.to_sym, enabled: details[:enabled], source: details[:source],
|
|
91
|
+
metadata: runtime_metadata(normalized) }
|
|
74
92
|
end
|
|
75
93
|
|
|
76
94
|
private
|
|
@@ -91,6 +109,8 @@ module StarGates
|
|
|
91
109
|
raise ArgumentError, 'flag name must be a nonempty string or symbol' unless normalized
|
|
92
110
|
|
|
93
111
|
enabled = boolean(value)
|
|
112
|
+
return store_update(normalized, enabled, metadata) if @store
|
|
113
|
+
|
|
94
114
|
@mutex.synchronize do
|
|
95
115
|
entry = @runtime[normalized] ||= { feature: Feature.new, metadata: {} }
|
|
96
116
|
feature = entry.fetch(:feature)
|
|
@@ -101,8 +121,124 @@ module StarGates
|
|
|
101
121
|
enabled
|
|
102
122
|
end
|
|
103
123
|
|
|
104
|
-
def
|
|
105
|
-
|
|
124
|
+
def runtime_value(name)
|
|
125
|
+
return unless name
|
|
126
|
+
return store_values([name])[name] if @store
|
|
127
|
+
|
|
128
|
+
@mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def runtime_values
|
|
132
|
+
return store_values(store_names) if @store
|
|
133
|
+
|
|
134
|
+
@mutex.synchronize { @runtime.transform_values { |entry| entry.fetch(:feature).enabled_for? } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def runtime_metadata(name)
|
|
138
|
+
return {} unless name
|
|
139
|
+
|
|
140
|
+
metadata = if @store
|
|
141
|
+
@store.read(metadata_key(name))
|
|
142
|
+
else
|
|
143
|
+
@mutex.synchronize { @runtime[name]&.fetch(:metadata) }
|
|
144
|
+
end
|
|
145
|
+
metadata.is_a?(Hash) ? metadata.dup : {}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def store_update(name, enabled, metadata)
|
|
149
|
+
index_add(name)
|
|
150
|
+
store_write(flag_key(name), enabled)
|
|
151
|
+
store_write(metadata_key(name), metadata.dup) if metadata
|
|
152
|
+
enabled
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def store_remove(name)
|
|
156
|
+
store_delete([flag_key(name), metadata_key(name)])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def store_reset
|
|
160
|
+
slots = index_slot_keys
|
|
161
|
+
names = slots ? store_read(slots).values.uniq : legacy_index
|
|
162
|
+
keys = names.flat_map { |name| [flag_key(name), metadata_key(name)] }
|
|
163
|
+
store_delete(keys + (slots ? slots + [index_sequence_key] : [index_key]))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def index_add(name)
|
|
167
|
+
return unless @store.read(flag_key(name)).nil?
|
|
168
|
+
return @mutex.synchronize { legacy_index_add(name) } unless @store.respond_to?(:increment)
|
|
169
|
+
|
|
170
|
+
slot = @store.increment(index_sequence_key)
|
|
171
|
+
store_write(index_slot_key(slot), name) if slot
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def store_names
|
|
175
|
+
slots = index_slot_keys
|
|
176
|
+
slots ? store_read(slots).values.uniq : legacy_index
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def index_slot_keys
|
|
180
|
+
return unless @store.respond_to?(:increment)
|
|
181
|
+
|
|
182
|
+
Array.new(@store.read(index_sequence_key, raw: true).to_i) { |slot| index_slot_key(slot + 1) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def legacy_index_add(name)
|
|
186
|
+
names = legacy_index
|
|
187
|
+
store_write(index_key, names + [name]) unless names.include?(name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def legacy_index
|
|
191
|
+
names = @store.read(index_key)
|
|
192
|
+
names.is_a?(Array) ? names : []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def store_values(names)
|
|
196
|
+
keys = names.to_h { |name| [flag_key(name), name] }
|
|
197
|
+
store_read(keys.keys).each_with_object({}) do |(key, value), values|
|
|
198
|
+
values[keys.fetch(key)] = value if [true, false].include?(value)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def store_read(keys)
|
|
203
|
+
return {} if keys.empty?
|
|
204
|
+
return @store.read_multi(*keys) if @store.respond_to?(:read_multi)
|
|
205
|
+
|
|
206
|
+
keys.each_with_object({}) do |key, values|
|
|
207
|
+
value = @store.read(key)
|
|
208
|
+
values[key] = value unless value.nil?
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def store_write(key, value)
|
|
213
|
+
return if @store.write(key, value)
|
|
214
|
+
|
|
215
|
+
raise Error, "#{@store.class} did not persist #{key}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def store_delete(keys)
|
|
219
|
+
return @store.delete_multi(keys) if @store.respond_to?(:delete_multi)
|
|
220
|
+
|
|
221
|
+
keys.each { |key| @store.delete(key) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def flag_key(name)
|
|
225
|
+
"#{@prefix}:flag:#{name}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def metadata_key(name)
|
|
229
|
+
"#{@prefix}:metadata:#{name}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def index_key
|
|
233
|
+
"#{@prefix}:names"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def index_sequence_key
|
|
237
|
+
"#{@prefix}:name_sequence"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def index_slot_key(slot)
|
|
241
|
+
"#{@prefix}:name:#{slot}"
|
|
106
242
|
end
|
|
107
243
|
|
|
108
244
|
def configuration_flags
|
|
@@ -124,10 +260,9 @@ module StarGates
|
|
|
124
260
|
end
|
|
125
261
|
end
|
|
126
262
|
|
|
127
|
-
def resolution(name, configured)
|
|
263
|
+
def resolution(name, configured, runtime_value)
|
|
128
264
|
env_key = "FEATURE_#{name&.upcase}"
|
|
129
265
|
environment_value = name && @env[env_key]
|
|
130
|
-
runtime_value = @mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
|
|
131
266
|
|
|
132
267
|
value, source = if name && !environment_value.to_s.strip.empty?
|
|
133
268
|
[boolean(environment_value), "environment:#{env_key}"]
|
data/lib/star_gates/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: star_gates
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - ">="
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.
|
|
74
|
+
version: 0.22.1
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - ">="
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.
|
|
81
|
+
version: 0.22.1
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: chrono_machines
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -103,6 +103,7 @@ extra_rdoc_files: []
|
|
|
103
103
|
files:
|
|
104
104
|
- ".dictate.toml"
|
|
105
105
|
- ".pre-commit-config.yaml"
|
|
106
|
+
- ".release-please-manifest.json"
|
|
106
107
|
- CHANGELOG.md
|
|
107
108
|
- LICENSE.txt
|
|
108
109
|
- README.md
|
|
@@ -133,14 +134,14 @@ files:
|
|
|
133
134
|
- lib/star_gates/telemetry/prometheus_adapter.rb
|
|
134
135
|
- lib/star_gates/telemetry/telemetry_adapter.rb
|
|
135
136
|
- lib/star_gates/version.rb
|
|
137
|
+
- release-please-config.json
|
|
136
138
|
homepage: https://github.com/seuros/star_gates
|
|
137
139
|
licenses:
|
|
138
140
|
- BSD-3-Clause
|
|
139
141
|
metadata:
|
|
140
|
-
allowed_push_host: https://rubygems.org
|
|
141
142
|
homepage_uri: https://github.com/seuros/star_gates
|
|
142
143
|
source_code_uri: https://github.com/seuros/star_gates
|
|
143
|
-
changelog_uri: https://github.com/seuros/star_gates/blob/
|
|
144
|
+
changelog_uri: https://github.com/seuros/star_gates/blob/master/CHANGELOG.md
|
|
144
145
|
rubygems_mfa_required: 'true'
|
|
145
146
|
rdoc_options: []
|
|
146
147
|
require_paths:
|