Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@ jobs:
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run tests
run: bundle exec rspec
- name: Run core tests
run: bundle exec rspec spec
- name: Setup Redis adapter gems
uses: ruby/setup-ruby@v1
with:
working-directory: rollout-redis
bundler-cache: true
- name: Run Redis adapter tests
working-directory: rollout-redis
run: bundle exec rspec
Comment on lines +30 to +37
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
Expand Down
35 changes: 31 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,32 @@ on:


jobs:
test:
core:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Run core tests
run: |
mkdir -p test_results
bundle exec rspec spec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: core-test-results-${{ matrix.ruby-version }}
path: test_results/rspec.xml
retention-days: 30

redis:
runs-on: ubuntu-latest
strategy:
matrix:
Expand All @@ -31,15 +56,17 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
working-directory: rollout-redis
bundler-cache: true
- name: Run tests
- name: Run Redis adapter tests
working-directory: rollout-redis
run: |
mkdir -p test_results
bundle exec rspec --format progress --format RspecJunitFormatter --out test_results/rspec.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.ruby-version }}
path: test_results/rspec.xml
name: redis-test-results-${{ matrix.ruby-version }}
path: rollout-redis/test_results/rspec.xml
retention-days: 30
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rollout

Fast feature flags based on Redis.
Fast feature flags.

[![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
[![CI](https://github.com/fetlife/rollout/actions/workflows/test.yml/badge.svg)](https://github.com/fetlife/rollout/actions/workflows/test.yml)
Expand All @@ -11,24 +11,25 @@ Fast feature flags based on Redis.

```bash
gem install rollout
gem install rollout-redis
```

```ruby
gem "rollout"
gem "rollout-redis"
```

## How it works

Initialize a rollout object. I assign it to a global var.

```ruby
require 'redis'
require "redis"
require "rollout"
require "rollout/redis"

$redis = Redis.new
$rollout = Rollout.new($redis)
```

or even simpler

```ruby
require 'redis'
$rollout = Rollout.new($redis) # Will use REDIS_URL env var or default redis url
$rollout = Rollout.new(backend: Rollout::Redis::Backend.new($redis))
```


Expand Down Expand Up @@ -128,7 +129,10 @@ In some cases you might want to have a feature activated for a random set of
users. It can come specially handy when using Rollout for split tests.

```ruby
$rollout = Rollout.new($redis, randomize_percentage: true)
$rollout = Rollout.new(
backend: Rollout::Redis::Backend.new($redis),
randomize_percentage: true,
)
```

When on `randomize_percentage` will make sure that 50% of users for feature A
Expand Down Expand Up @@ -181,7 +185,7 @@ environments by using the

```ruby
$ns = Redis::Namespace.new(Rails.env, redis: $redis)
$rollout = Rollout.new($ns)
$rollout = Rollout.new(backend: Rollout::Redis::Backend.new($ns))
$rollout.activate_group(:chat, :all)
```

Expand Down Expand Up @@ -209,12 +213,18 @@ This example would use the "development:feature:chat:groups" key.

## Testing

The suite flushes Redis database 7 before every example. Use a disposable
Core tests do not need Redis:

```bash
bundle exec rake spec
```

Redis adapter tests flush database 7 before every example. Use a disposable
instance, not a shared or production Redis.

```bash
docker run --rm -p 6379:6379 redis:7-alpine
bundle exec rspec
bundle exec rake spec:redis
```

Optional connection settings: `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`.
Expand Down
16 changes: 15 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)
RSpec::Core::RakeTask.new(:spec) do |task|
task.pattern = "spec/**/*_spec.rb"
end

namespace :spec do
desc "Run Redis adapter tests"
task :redis do
gemfile = File.expand_path("rollout-redis/Gemfile", __dir__)
Dir.chdir("rollout-redis") do
Bundler.with_unbundled_env do
sh({ "BUNDLE_GEMFILE" => gemfile }, "bundle exec rspec")
end
end
end
end

task default: :spec

Expand Down
100 changes: 44 additions & 56 deletions lib/rollout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

require 'rollout/feature'
require 'rollout/logging'
require 'rollout/redis_codec'
require 'rollout/version'
require 'zlib'
require 'set'
Expand All @@ -14,14 +13,14 @@ class Rollout

RAND_BASE = (2**32 - 1) / 100.0

attr_reader :options, :storage
attr_reader :options, :backend

def initialize(storage, opts = {})
@storage = storage
@options = opts
def initialize(backend:, **options)
@backend = backend
@options = options
@groups = { all: ->(_user) { true } }

extend(Logging) if opts[:logging]
extend(Logging) if options[:logging]
end

def groups
Expand All @@ -39,10 +38,7 @@ def deactivate(feature)
end

def delete(feature)
features = (@storage.get(features_key) || '').split(',')
features.delete(feature.to_s)
@storage.set(features_key, features.join(','))
@storage.del(key(feature))
@backend.delete_feature(feature)

if respond_to?(:logging)
logging.delete(feature)
Expand Down Expand Up @@ -138,9 +134,8 @@ def active_in_group?(group, user)
end

def get(feature)
payload = @storage.get(key(feature))
Feature.new(
state: RedisCodec.decode(feature, payload),
state: @backend.fetch_feature(feature),
rollout: self,
options: @options,
name: feature,
Expand All @@ -162,23 +157,13 @@ def clear_feature_data(feature)
def multi_get(*features)
return [] if features.empty?

feature_keys = features.map { |feature| key(feature) }

@storage
.mget(*feature_keys)
.map
.with_index do |payload, index|
Feature.new(
state: RedisCodec.decode(features[index], payload),
rollout: self,
options: @options,
name: features[index],
)
end
@backend.fetch_features(features).zip(features).map do |state, name|
Feature.new(state: state, rollout: self, options: @options, name: name)
end
end

def features
(@storage.get(features_key) || '').split(',').map(&:to_sym)
@backend.feature_names.map(&:to_sym)
end

def feature_states(user = nil)
Expand All @@ -196,49 +181,52 @@ def active_features(user = nil)
def clear!
features.each do |feature|
with_feature(feature, &:clear)
@storage.del(key(feature))
@backend.delete_feature(feature)
end

@storage.del(features_key)
@backend.clear_features
end

def exists?(feature)
# since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
# https://github.com/redis/redis-rb/pull/918
if @storage.respond_to?(:exists?)
@storage.exists?(key(feature))
else
@storage.exists(key(feature))
end
@backend.feature_exists?(feature)
end

def with_feature(feature)
f = get(feature)
mutated = nil
before = nil
capture_logging = logging_capture?
notify = count_observers > 0
snapshot = notify || capture_logging

@backend.mutate_feature(feature) do |current_state|
mutated = Feature.new(
state: current_state,
rollout: self,
options: @options,
name: feature,
)
before = mutated.deep_clone if snapshot
yield mutated

event = capture_logging ? logging.event_for(before, mutated) : nil
result = { state: mutated.to_feature_state, event: event }
if event
result[:history_length] = logging.history_length
result[:global] = logging.global
end
result
end

if count_observers > 0
before = f.deep_clone
yield(f)
save(f)
if notify
changed
notify_observers(:update, before, f)
else
yield(f)
save(f)
notify_observers(:update, before, mutated)
end
end

private

def key(name)
"feature:#{name}"
mutated
end

def features_key
'feature:__features__'
end
private

def save(feature)
@storage.set(key(feature.name), RedisCodec.encode(feature.to_feature_state))
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
def logging_capture?
respond_to?(:logging) && logging.logging_enabled?
end
end
Loading
Loading