star_gates 0.0.0 → 0.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +19 -0
  3. data/.pre-commit-config.yaml +20 -0
  4. data/CHANGELOG.md +52 -3
  5. data/LICENSE.txt +23 -16
  6. data/README.md +175 -16
  7. data/Rakefile +14 -4
  8. data/lib/star_gates/adapters/base_adapter.rb +65 -0
  9. data/lib/star_gates/adapters/memory_adapter.rb +93 -0
  10. data/lib/star_gates/adapters/redis_adapter.rb +119 -0
  11. data/lib/star_gates/application_star_gate.rb +398 -0
  12. data/lib/star_gates/circuit.rb +171 -0
  13. data/lib/star_gates/configuration.rb +172 -0
  14. data/lib/star_gates/dsl.rb +71 -2
  15. data/lib/star_gates/errors.rb +8 -0
  16. data/lib/star_gates/event_store/event_store.rb +79 -0
  17. data/lib/star_gates/event_store/memory_adapter.rb +78 -0
  18. data/lib/star_gates/events/base_event.rb +46 -0
  19. data/lib/star_gates/events/feature_events.rb +97 -0
  20. data/lib/star_gates/feature.rb +446 -0
  21. data/lib/star_gates/flags.rb +153 -0
  22. data/lib/star_gates/gate.rb +100 -0
  23. data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
  24. data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
  25. data/lib/star_gates/hexagonal/feature_service.rb +121 -0
  26. data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
  27. data/lib/star_gates/projections/feature_projection.rb +224 -0
  28. data/lib/star_gates/registry.rb +58 -0
  29. data/lib/star_gates/telemetry/opentelemetry_adapter.rb +254 -0
  30. data/lib/star_gates/telemetry/prometheus_adapter.rb +184 -0
  31. data/lib/star_gates/telemetry/telemetry_adapter.rb +174 -0
  32. data/lib/star_gates/version.rb +1 -1
  33. data/lib/star_gates.rb +308 -8
  34. metadata +95 -14
  35. data/sig/star_gates.rbs +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91627ddc25a266e8584724a1e1973799dc693e883f4b5d01dfe33cb1ccbd6fbf
4
- data.tar.gz: be8eaf8ff34249e3cf355c3fa8fd1159625aee1e23be009ce9664d644f837a6d
3
+ metadata.gz: 2747943c57b6c5a1d42a415ec8321bcab9420438f3425424432a35ada2571c97
4
+ data.tar.gz: 375a4a1f9f7d27ec78d35386bb973f37c5723a2ff7a37eb580d5cd9d80f9f686
5
5
  SHA512:
6
- metadata.gz: 1ff36a75a319ed7646b3eab22a9b256078044f5144c2ab7eb62f8b052504a800565ab13c3e9c5a7010428df585cbc974f9d4b0c5eb1d8fefa20049cc6b61462b
7
- data.tar.gz: df5408c183aba188434a0f74b0b330036b1951786fa3abd78b80b4eda7e7151b1270e17eaba39fb27f8422efeb25392ec0f575805c18f627053ed981056f8184
6
+ metadata.gz: db442d9496bc8ecaaae6a329c0b2bae1ac6f99a8a4375b57ec3e950941e7257c4a45045d1eb66494c2e74fc3c3075429ddf193173446ae09189b11b4dbfe8d2a
7
+ data.tar.gz: 9954bf6fccad1f849cd5fb411c3b79f2516ea46dc7b99da86ee4a373d3b4eb06e68cc14ba07a43c8c960bc4a0e5cf69086b40c5807495b79f3d4d250ce6fd7d6
data/.dictate.toml ADDED
@@ -0,0 +1,19 @@
1
+ [decree.supreme]
2
+ trailing_whitespace = "deny"
3
+ tabs_vs_spaces = "spaces"
4
+ tab_width = 2
5
+ final_newline = "require"
6
+ line_endings = "lf"
7
+ blank_line_whitespace = "deny"
8
+
9
+ [decree.supreme.ignore.tab-character]
10
+ filenames = ["Makefile", "GNUmakefile", "makefile"]
11
+ extensions = ["md", "mdx", "tsv"]
12
+
13
+ [decree.ruby]
14
+ max_line_length = 120
15
+ max_lines = 400
16
+ ignore_comments = true
17
+ ignore_blank_lines = true
18
+ method_visibility_order = ["public", "protected", "private"]
19
+ comment_spacing = true
@@ -0,0 +1,20 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-added-large-files
9
+ - id: check-merge-conflict
10
+ - id: check-json
11
+ - id: check-case-conflict
12
+
13
+ - repo: local
14
+ hooks:
15
+ - id: dictator
16
+ name: dictator
17
+ entry: dictator lint
18
+ language: system
19
+ types: [text]
20
+ require_serial: true
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2025-07-08
3
+ ## Unreleased
4
4
 
5
- - Initial release
5
+ ### Changed
6
+
7
+ - Require Ruby 4.0 or newer.
8
+ - Require StateMachines 0.202.0 or newer.
9
+ - Change the license to BSD-3-Clause.
10
+ - Use ActiveSupport duration helpers without overriding Rails behavior.
11
+ - Preserve fractional seconds in circuit cooldowns and failure windows.
12
+ - Consolidate documentation and remove generated repository artifacts, keeping the space-fleet examples.
13
+ - Move demos into the Rails dummy app and rewrite ship systems as StarGates gates.
14
+ - Run gem and Rails integration tests separately, with explicit test discovery and Rails setup.
15
+ - Use the Dictator GitHub Action and a project decree for structural linting.
16
+ - Run both test suites with padded_cell and Minitest 6.
17
+
18
+ ### Fixed
19
+
20
+ - Correct dummy-app feature routing, persistent feature transitions, and circuit statistics.
21
+ - Make weather demo failures explicit instead of random.
22
+
23
+ ### Added
24
+
25
+ - Boolean flags with environment, configuration, and runtime overrides.
26
+ - Flag status, metadata, and debugging helpers.
27
+
28
+ ## 0.1.0 (2025-10-30)
29
+
30
+ ### Security
31
+
32
+ - Use safe YAML loading for configuration files.
33
+
34
+ ### Fixed
35
+
36
+ - Correct percentage-string parsing for feature rollouts.
37
+ - Remove dead time-parsing code.
38
+
39
+ ### Added
40
+
41
+ - ChronoMachines retry policies with exponential backoff and jitter.
42
+ - Circuit retry instrumentation.
43
+ - Feature state transitions, rollout targeting, and automatic circuit-triggered rollback.
44
+ - Transition history and introspection.
45
+ - Regression coverage for configuration security, rollouts, concurrency, and retries.
46
+
47
+ ### Changed
48
+
49
+ - Use immutable `Data.define` mock users instead of `OpenStruct`.
50
+ - Use concurrent-ruby primitives for thread-safe registries and transition history.
51
+
52
+ ## 0.0.0
53
+
54
+ - Internal development version.
data/LICENSE.txt CHANGED
@@ -1,21 +1,28 @@
1
- The MIT License (MIT)
1
+ BSD 3-Clause License
2
2
 
3
3
  Copyright (c) 2025 Abdelkader Boudih
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
11
7
 
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
14
10
 
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its contributors
16
+ may be used to endorse or promote products derived from this software
17
+ without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md CHANGED
@@ -1,39 +1,198 @@
1
1
  # StarGates
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Gem Version](https://badge.fury.io/rb/star_gates.svg)](https://badge.fury.io/rb/star_gates)
4
+ [![CI](https://github.com/seuros/star_gates/actions/workflows/ci.yml/badge.svg)](https://github.com/seuros/star_gates/actions/workflows/ci.yml)
4
5
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/star_gates`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+ Feature flags, gradual rollouts, and circuit-protected execution for Ruby.
7
+ StarGates uses StateMachines for feature transitions, BreakerMachines for circuit
8
+ protection, and ChronoMachines for retries.
6
9
 
7
10
  ## Installation
8
11
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
12
+ Requires **Ruby 4.0+** and **ActiveSupport 8.1.1+**. Rails is optional.
10
13
 
11
- Install the gem and add to the application's Gemfile by executing:
14
+ ```ruby
15
+ gem 'star_gates'
16
+ ```
17
+
18
+ ```sh
19
+ bundle install
20
+ ```
21
+
22
+ ## Boolean flags
23
+
24
+ ```ruby
25
+ require 'star_gates'
26
+
27
+ StarGates.flags.configuration = { copilot: true }
28
+ StarGates.flags.enabled?(:copilot)
29
+ StarGates.flags.enable(:preview, metadata: { reason: 'testing' })
30
+ StarGates.flags.disable(:preview)
31
+ StarGates.flags.debug(:preview)
32
+ StarGates.flags.reset!
33
+ ```
34
+
35
+ Precedence: nonblank `FEATURE_<NAME>` environment variable, boolean configuration,
36
+ runtime override, then `false`. Names are case-insensitive; dashes become underscores.
37
+ Truthy strings are `true`, `1`, `yes`, and `on` (case-insensitive).
38
+
39
+ For Rails, keep defaults explicit and resolve them lazily:
40
+
41
+ ```ruby
42
+ # config/initializers/feature_flags.rb
43
+ Rails.application.config.x.feature_flags = { copilot: true }
44
+ StarGates.flags.configuration = -> { Rails.application.config.x.feature_flags }
45
+ ```
46
+
47
+ `StarGates::Flags.new(env: {}, configuration: {})` creates an isolated registry.
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.
52
+
53
+ ## Gates and rollouts
54
+
55
+ ```ruby
56
+ StarGates.define do
57
+ gate :payments do
58
+ feature rollout: 25.percent
59
+ circuit threshold: 3, within: 1.minute, reset_after: 30.seconds
60
+
61
+ run { |context| PaymentAPI.charge(context[:amount]) }
62
+ fallback { |context| LegacyPaymentAPI.charge(context[:amount]) }
63
+ end
64
+ end
65
+
66
+ gate = StarGates[:payments]
67
+ gate.feature.rollout_canary
68
+ gate.call(user: current_user, amount: 100)
69
+ ```
70
+
71
+ Features start disabled. Use `rollout_canary` for percentage/group targeting,
72
+ `enable` for full rollout, and `disable` to turn a feature off. Provide a user with
73
+ an `id` for consistent percentage targeting.
74
+
75
+ When a circuit opens, its feature rolls back from enabled to canary, or from canary
76
+ to disabled. Gates use their fallback when the feature is unavailable or execution
77
+ fails. Rails duration helpers retain their normal behavior, including fractional
78
+ seconds such as `0.5.seconds`.
79
+
80
+ ### Retries
81
+
82
+ Pass `retry_config` to a gate's `circuit` declaration:
83
+
84
+ ```ruby
85
+ circuit threshold: 5,
86
+ within: 1.minute,
87
+ retry_config: {
88
+ max_attempts: 3,
89
+ base_delay: 0.1,
90
+ multiplier: 2,
91
+ max_delay: 5
92
+ }
93
+ ```
12
94
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
95
+ Retries are opt-in and use ChronoMachines.
96
+
97
+ ## Rails
98
+
99
+ Define gates in an initializer and call them from controllers or services:
100
+
101
+ ```ruby
102
+ class PaymentsController < ApplicationController
103
+ def create
104
+ result = StarGates[:payments].call(user: current_user, amount: params[:amount])
105
+ render json: result
106
+ end
107
+ end
15
108
  ```
16
109
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
110
+ The Rails integration fixture lives in `test/dummy/` and uses the root Gemfile.
18
111
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
112
+ ## Instrumentation
113
+
114
+ StarGates emits events through `ActiveSupport::Notifications`:
115
+
116
+ ```ruby
117
+ ActiveSupport::Notifications.subscribe('star_gates.circuit.auto_rollback') do |*args|
118
+ event = ActiveSupport::Notifications::Event.new(*args)
119
+ Rails.logger.warn(event.payload)
120
+ end
21
121
  ```
22
122
 
23
- ## Usage
123
+ Circuit events include `circuit.auto_rollback`, `circuit.half_open`,
124
+ `circuit.recovered`, and `circuit.retry`, prefixed with `star_gates.`.
125
+
126
+ ## Examples
127
+
128
+ ### Space fleet
129
+
130
+ All demos live in `test/dummy/`. The fleet uses real `StarGates::Gate` instances
131
+ for feature rollout, circuit protection, automatic rollback, and fallbacks.
132
+ BreakerMachines is the underlying circuit engine, not a separate ship API.
133
+
134
+ - [Spaceship](test/dummy/app/models/spaceship.rb): warp drive, fusion reactor, and navigation circuits.
135
+ - [Spaceflight systems](test/dummy/app/models/spaceflight_systems.rb): simulated ship subsystems.
136
+ - [Ship hierarchy](test/dummy/app/models/base_ship.rb): shared systems for the fleet.
137
+ - [Corellian freighter](test/dummy/app/models/corellian_freighter.rb): specialized ship behavior.
138
+ - [RMNS Atlas Monkey](test/dummy/app/models/rmns_atlas_monkey.rb): the Universal Commentary Engine.
139
+
140
+ Run the fleet demo without starting Rails:
141
+
142
+ ```sh
143
+ bundle exec ruby test/dummy/demos/space_fleet.rb
144
+ ```
24
145
 
25
- TODO: Write usage instructions here
146
+ It demonstrates disabled-system fallback, pilot-only canary access, full rollout,
147
+ hardware failures opening a circuit, automatic feature rollback, and explicit
148
+ operator-approved recovery. Each ship's gates have independent state.
149
+
150
+ ```ruby
151
+ require_relative 'test/dummy/app/models/spaceship'
152
+
153
+ ship = Spaceship.new('SG Atlas', 'SG-1')
154
+ warp = ship.gate(:warp_drive) # A StarGates::Gate, with a feature and a circuit.
155
+ warp.feature.rollout_canary
156
+ pilot = StarGates::Feature::MockUser.new(id: 1, groups: [:pilots])
157
+ ship.engage_warp('Alpha Centauri', pilot: pilot)
158
+ ```
159
+
160
+ ### Application examples
161
+
162
+ - [Payment feature](test/dummy/demos/payment_feature.rb): state-specific feature behavior.
163
+ - [Transition history](test/dummy/demos/transition_demo.rb): recorded feature transitions.
164
+ - [Introspection](test/dummy/demos/introspection_demo.rb): status, analytics, and user simulation.
165
+ - [Application payment feature](test/dummy/demos/application_payment_feature.rb): application-level features.
166
+ - [JSON-RPC controller](test/dummy/demos/jsonrpc_rails_controller.rb): optional JSON-RPC integration.
167
+
168
+ Run standalone examples from the repository root:
169
+
170
+ ```sh
171
+ bundle exec ruby test/dummy/demos/payment_feature.rb
172
+ ```
26
173
 
27
174
  ## Development
28
175
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
176
+ ```sh
177
+ bin/setup
178
+ bundle exec rake test
179
+ bundle exec rake test:rails
180
+ dictator lint .
181
+ ```
30
182
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
183
+ Tests run with padded_cell and Minitest 6. Gem tests and Rails integration tests
184
+ run in separate processes. `bundle exec rake` runs both suites. To select gem tests
185
+ directly or pass runner options:
32
186
 
33
- ## Contributing
187
+ ```sh
188
+ bundle exec padded_cell test/gate_test.rb
189
+ bundle exec rake test TESTOPTS='-j 4 --seed 1234'
190
+ ```
34
191
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/star_gates.
192
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
36
193
 
37
194
  ## License
38
195
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
196
+ [BSD-3-Clause](LICENSE.txt).
197
+
198
+ > In space, nobody can hear your feature flags fail, but they'll notice when your fleet survives.
data/Rakefile CHANGED
@@ -1,8 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "minitest/test_task"
3
+ require 'bundler/gem_tasks'
4
+ require 'shellwords'
5
5
 
6
- Minitest::TestTask.create
6
+ desc 'Run gem tests with padded_cell'
7
+ task :test do
8
+ sh 'padded_cell', 'test/*_test.rb', *Shellwords.split(ENV.fetch('TESTOPTS', ''))
9
+ end
7
10
 
8
- task default: :test
11
+ namespace :test do
12
+ desc 'Run Rails integration tests with padded_cell'
13
+ task :rails do
14
+ sh 'padded_cell', 'test/dummy/test', *Shellwords.split(ENV.fetch('TESTOPTS', ''))
15
+ end
16
+ end
17
+
18
+ task default: [:test, 'test:rails']
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ module Adapters
5
+ class BaseAdapter
6
+ def initialize(config = {})
7
+ @config = config
8
+ end
9
+
10
+ # Persistence operations
11
+ def store(key, value)
12
+ raise NotImplementedError, 'Subclasses must implement #store'
13
+ end
14
+
15
+ def fetch(key)
16
+ raise NotImplementedError, 'Subclasses must implement #fetch'
17
+ end
18
+
19
+ def delete(key)
20
+ raise NotImplementedError, 'Subclasses must implement #delete'
21
+ end
22
+
23
+ def exists?(key)
24
+ raise NotImplementedError, 'Subclasses must implement #exists?'
25
+ end
26
+
27
+ # Caching operations
28
+ def cache_fetch(key, expires_in: nil, &block)
29
+ if exists?(key)
30
+ fetch(key)
31
+ elsif block_given?
32
+ value = block.call
33
+ store(key, value) if value
34
+ value
35
+ end
36
+ end
37
+
38
+ def cache_write(key, value, expires_in: nil)
39
+ store(key, value)
40
+ end
41
+
42
+ def cache_delete(key)
43
+ delete(key)
44
+ end
45
+
46
+ # Messaging operations
47
+ def publish(topic, message)
48
+ # Default no-op implementation
49
+ end
50
+
51
+ def subscribe(topic, &)
52
+ # Default no-op implementation
53
+ end
54
+
55
+ # Health check
56
+ def healthy?
57
+ true
58
+ end
59
+
60
+ protected
61
+
62
+ attr_reader :config
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+ require 'concurrent'
5
+
6
+ module StarGates
7
+ module Adapters
8
+ class MemoryAdapter < BaseAdapter
9
+ def initialize(config = {})
10
+ super
11
+ @storage = Concurrent::Hash.new
12
+ @subscribers = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def store(key, value)
17
+ @storage[key] = value
18
+ end
19
+
20
+ def fetch(key)
21
+ @storage[key]
22
+ end
23
+
24
+ def delete(key)
25
+ @storage.delete(key)
26
+ end
27
+
28
+ def exists?(key)
29
+ @storage.key?(key)
30
+ end
31
+
32
+ def cache_fetch(key, expires_in: nil, &block)
33
+ # Memory adapter doesn't support expiration, but we can implement basic caching
34
+ if @storage.key?(key)
35
+ @storage[key]
36
+ elsif block_given?
37
+ value = block.call
38
+ @storage[key] = value if value
39
+ value
40
+ end
41
+ end
42
+
43
+ def cache_write(key, value, expires_in: nil)
44
+ @storage[key] = value
45
+ end
46
+
47
+ def cache_delete(key)
48
+ @storage.delete(key)
49
+ end
50
+
51
+ def publish(topic, message)
52
+ subscribers = @subscribers[topic].to_a
53
+ return if subscribers.empty?
54
+
55
+ # Publish to subscribers in background fibers if available
56
+ if defined?(Fiber.scheduler) && Fiber.scheduler
57
+ subscribers.each do |subscriber|
58
+ Fiber.schedule { subscriber.call(message) }
59
+ end
60
+ else
61
+ subscribers.each { |subscriber| subscriber.call(message) }
62
+ end
63
+ end
64
+
65
+ def subscribe(topic, &block)
66
+ @subscribers[topic] << block
67
+ end
68
+
69
+ def clear!
70
+ @mutex.synchronize do
71
+ @storage.clear
72
+ @subscribers.clear
73
+ end
74
+ end
75
+
76
+ def size
77
+ @storage.size
78
+ end
79
+
80
+ def topics_count
81
+ @subscribers.size
82
+ end
83
+
84
+ def subscribers_count(topic)
85
+ @subscribers[topic].size
86
+ end
87
+
88
+ def healthy?
89
+ true
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+
5
+ module StarGates
6
+ module Adapters
7
+ class RedisAdapter < BaseAdapter
8
+ def initialize(config = {})
9
+ super
10
+ @redis = config[:redis] || redis_from_config
11
+ @key_prefix = config[:key_prefix] || 'star_gates'
12
+ @default_ttl = config[:default_ttl] || 3600 # 1 hour
13
+ end
14
+
15
+ def store(key, value)
16
+ serialized = serialize(value)
17
+ @redis.set(prefixed_key(key), serialized)
18
+ end
19
+
20
+ def fetch(key)
21
+ value = @redis.get(prefixed_key(key))
22
+ value ? deserialize(value) : nil
23
+ end
24
+
25
+ def delete(key)
26
+ @redis.del(prefixed_key(key))
27
+ end
28
+
29
+ def exists?(key)
30
+ @redis.exists?(prefixed_key(key)).positive?
31
+ end
32
+
33
+ def cache_fetch(key, expires_in: nil, &block)
34
+ prefixed = prefixed_key(key)
35
+ cached = @redis.get(prefixed)
36
+
37
+ if cached
38
+ deserialize(cached)
39
+ elsif block_given?
40
+ value = block.call
41
+ if value
42
+ serialized = serialize(value)
43
+ ttl = expires_in || @default_ttl
44
+ @redis.setex(prefixed, ttl, serialized)
45
+ end
46
+ value
47
+ end
48
+ end
49
+
50
+ def cache_write(key, value, expires_in: nil)
51
+ serialized = serialize(value)
52
+ ttl = expires_in || @default_ttl
53
+ @redis.setex(prefixed_key(key), ttl, serialized)
54
+ end
55
+
56
+ def cache_delete(key)
57
+ @redis.del(prefixed_key(key))
58
+ end
59
+
60
+ def publish(topic, message)
61
+ @redis.publish(topic_key(topic), serialize(message))
62
+ end
63
+
64
+ def subscribe(topic, &block)
65
+ # Redis pub/sub requires a dedicated connection
66
+ # This is a simplified version - in production, use a separate subscriber thread
67
+ @redis.subscribe(topic_key(topic)) do |on|
68
+ on.message do |_channel, message|
69
+ block.call(deserialize(message))
70
+ end
71
+ end
72
+ end
73
+
74
+ def healthy?
75
+ @redis.ping == 'PONG'
76
+ rescue StandardError
77
+ false
78
+ end
79
+
80
+ private
81
+
82
+ def prefixed_key(key)
83
+ "#{@key_prefix}:#{key}"
84
+ end
85
+
86
+ def topic_key(topic)
87
+ "#{@key_prefix}:topics:#{topic}"
88
+ end
89
+
90
+ def serialize(value)
91
+ case value
92
+ when String
93
+ value
94
+ when Symbol
95
+ value.to_s
96
+ else
97
+ JSON.generate(value)
98
+ end
99
+ end
100
+
101
+ def deserialize(value)
102
+ JSON.parse(value)
103
+ rescue JSON::ParserError
104
+ value
105
+ end
106
+
107
+ def redis_from_config
108
+ raise ArgumentError, "Redis gem not available. Install with: gem 'redis'" unless defined?(Redis)
109
+
110
+ # Try to use existing Redis configuration
111
+ if defined?(Rails) && Rails.application.config.respond_to?(:redis)
112
+ Redis.new(Rails.application.config.redis)
113
+ else
114
+ Redis.new
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end