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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9150966ca03ffe47ce938c669d38ab3291e25b6b7aaea9560e89099ec255a65d
4
- data.tar.gz: 0454b43be544e3c50026811f5d44ec3a28903c4ff824fcf3b855aaf70a733aba
3
+ metadata.gz: 58b1b72d0deb4eb946b9c7c799f24ddd621051c2e7d611a570fbce998d33cc8f
4
+ data.tar.gz: 6e0cdeb3572cd4a939749c2020eb80140ed33e8b9586e5c106bcff5cd65ba47e
5
5
  SHA512:
6
- metadata.gz: 4d7802feb0b5ebc3aefcd585224cfd5dda9f95e9e8f23ee5653a087f5326d14764ce7e6a06d16ca01cafd60570325c910cb776464b2abce14b2ef05b0a202407
7
- data.tar.gz: 3e57ca2506afe07616e9c884f1f74228c2f380601a0ca4779b311a422aa0c215d228835e9160b0689db744ec788252b752f5133703992063a06c6c7db87254d7
6
+ metadata.gz: 67de3f5d4afb0ed6ea05b889c273aa0d4ea6693bef9d6be51cc8aef84f56f62116fd54cd818fa7de9f8ad2ca09a4cf410ef903ba3d060a5da7da81ebe5c159b5
7
+ data.tar.gz: 17bcc70a0e898e2d1bb63f6abdca7fc80b5b70e9d03422a2ae552d7d631be1800ad281a0e7a6624df2e5914ef0b8e26a3c8d8a388fba8057f3c80666361bbcac
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.1"
3
+ }
data/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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
- Runtime transitions reuse `StarGates::Feature`; changes and metadata are process-local,
50
- not persisted or synchronized across workers. `reset!` clears both runtime state and
51
- metadata, not environment/configuration. `StarGates.reset!` resets gates separately.
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
 
@@ -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
- @mutex.synchronize { @runtime.delete(normalize(name)) }
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
- names = (environment_flags.keys + configured.keys + runtime_names).uniq
49
- names.to_h { |name| [name, resolution(name, configured).slice(:enabled, :source)] }
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: runtime_names.map(&:to_sym),
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
- resolution(normalize(name), configuration_flags)
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
- metadata = @mutex.synchronize { @runtime[normalized]&.fetch(:metadata)&.dup || {} }
73
- { name: normalized&.to_sym, enabled: details[:enabled], source: details[:source], metadata: metadata }
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 runtime_names
105
- @mutex.synchronize { @runtime.keys }
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}"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StarGates
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.1'
5
5
  end
@@ -0,0 +1,11 @@
1
+ {
2
+ "bootstrap-sha": "144ff79fe22abe175830d0c765ddc249c178ddc7",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "star_gates",
7
+ "bump-minor-pre-major": true,
8
+ "include-component-in-tag": false
9
+ }
10
+ }
11
+ }
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.2.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.10.1
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.10.1
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/main/CHANGELOG.md
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: