Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Run benchmark tests
run: bundle exec ruby benchmark.rb
env:
WORKFLOW_PORT: 30001
REDIS_PORT: 30001

- name: Stop redis cluster
run: bin/cluster stop
Expand Down
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ RSpec/MultipleExpectations:
# Example has too many lines. [8/5]
RSpec/ExampleLength:
Max: 20

# Rubygems 2FA can't be used with github publish workflow
Gemspec/RequireMFA:
Enabled: false
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,37 @@
## [Unreleased]
## [0.1.3] - 2025-02-08

## What's Changed

**Full Changelog**: https://github.com/lifeBCE/redis-single-file/compare/v0.1.2...v0.1.3

---
<br />

## [0.1.2] - 2025-02-08

## What's Changed
* publish workflow by @lifeBCE in https://github.com/lifeBCE/redis-single-file/pull/3
* IGNORE_VERSION: "true" by @lifeBCE in https://github.com/lifeBCE/redis-single-file/pull/4
* cluster support - v0.1.2 by @lifeBCE in https://github.com/lifeBCE/redis-single-file/pull/5

**Full Changelog**: https://github.com/lifeBCE/redis-single-file/compare/v0.1.1...v0.1.2

---
<br />

## [0.1.1] - 2025-02-03

## What's Changed
* github workflows by @lifeBCE in https://github.com/lifeBCE/redis-single-file/pull/1
* badges by @lifeBCE in https://github.com/lifeBCE/redis-single-file/pull/2

## New Contributors
* @lifeBCE made their first contribution in https://github.com/lifeBCE/redis-single-file/pull/1

**Full Changelog**: https://github.com/lifeBCE/redis-single-file/commits/v0.1.1

---
<br />

## [0.1.0] - 2025-01-31

Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
redis-single-file (0.1.2)
redis-single-file (0.1.3)
redis (~> 5.3.0)
redis-clustering (~> 5.3.0)

Expand Down
130 changes: 110 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
[![Build Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/build.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/build.yml)
[![RSpec Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/rspec.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/rspec.yml)
[![CodeQL Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/codeql.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/codeql.yml)
[![Rubocop Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/rubocop.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/rubocop.yml)
[![Benchmark Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/benchmark.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/benchmark.yml)
[![CodeQL Status](https://github.com/lifeBCE/redis-single-file/actions/workflows/codeql.yml/badge.svg)](https://github.com/lifeBCE/redis-single-file/actions/workflows/codeql.yml)

# Redis Single File - Distributed Execution Synchronization

Expand Down Expand Up @@ -79,27 +78,29 @@ end

### Distributed Queue Design

The redis `blpop` command will attempt to pop (delete and return) a value from
a queue but will block when no values are present in the queue. A timeout can
be provided to prevent deadlock situations.

To unblock (unlock) an instance, add/push an item to the queue. This is done
one at a time to controll the serialization of the distrubuted execution. Redis
selects the instance waiting the longest each time a new token is added.
> [!IMPORTANT]
> The redis `blpop` command will attempt to pop (delete and return) a value from
> a queue but will block when no values are present in the queue. A timeout can
> be provided to prevent deadlock situations.
>
> To unblock (unlock) an instance, add/push an item to the queue. This is done
> one at a time to controll the serialization of the distrubuted execution. Redis
> selects the instance waiting the longest each time a new token is added.

### Auto Expiration

All redis keys are expired and automatically removed after a certain period
but will be recreated again on the next use. Each new client should face one
of two scenarios when entering synchronization.

1. The mutex key is not set causing the client to create the keys and prime
the queue with its first token unlocking it for the first execution.

2. The mutex key is already set so the client will skip the priming and enter
directly into the queue where it should immediately find a token left by
the last client upon completion or block waiting for the current client to
finish execution.
> [!NOTE]
> All redis keys are expired and automatically removed after a certain period
> but will be recreated again on the next use. Each new client should face one
> of two scenarios when entering synchronization.
>
> 1. The mutex key is not set causing the client to create the keys and prime
> the queue with its first token unlocking it for the first execution.
>
> 2. The mutex key is already set so the client will skip the priming and enter
> directly into the queue where it should immediately find a token left by
> the last client upon completion or block waiting for the current client to
> finish execution.

### Considerations over redlock approach

Expand Down Expand Up @@ -178,6 +179,95 @@ Comparison:
forked (10x): 56.6 i/s - 76.90x slower
```

## Cluster Management

After installing redis locally, you can use the provided `bin/cluster` script to manage a local cluster. To customize your local cluster, edit the `bin/cluster` script to provide your own values for the following script variables.

```bash
#
# configurable settings
#
HOST=127.0.0.1
PORT=30000
MASTERS=3 # min 3 for cluster
REPLICAS=2 # replicas per master
TIMEOUT=2000
PROTECTED_MODE=yes
ADDITIONAL_OPTIONS=""
```

<details>
<summary><strong>Start cluster nodes</strong></summary>

$ bin/cluster start

```console
Starting 30001
Starting 30002
Starting 30003
Starting 30004
Starting 30005
Starting 30006
Starting 30007
Starting 30008
Starting 30009
```
</details>

<details>
<summary><strong>Create cluster configuration</strong></summary>

$ bin/cluster create -f

```console
>>> Performing hash slots allocation on 9 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:30005 to 127.0.0.1:30001
Adding replica 127.0.0.1:30006 to 127.0.0.1:30001
Adding replica 127.0.0.1:30007 to 127.0.0.1:30002
Adding replica 127.0.0.1:30008 to 127.0.0.1:30002
Adding replica 127.0.0.1:30009 to 127.0.0.1:30003
Adding replica 127.0.0.1:30004 to 127.0.0.1:30003
```
</details>

<details>
<summary><strong>Stop cluster nodes</strong></summary>

$ bin/cluster stop

```console
Stopping 30001
Stopping 30002
Stopping 30003
Stopping 30004
Stopping 30005
Stopping 30006
Stopping 30007
Stopping 30008
Stopping 30009
```
</details>

<details>
<summary><strong>Clean local cluster files</strong></summary>

$ bin/cluster clean

```console
Cleaning *.log
Cleaning appendonlydir-*
Cleaning dump-*.rdb
Cleaning nodes-*.conf
```
</details>

After the cluster is running and configured, you can direct the `test.rb` and `benchmark.rb` scripts at the cluster by setting the port on execution.

$ REDIS_PORT=30001 bundle exec ruby benchmark.rb

## Disclaimer

> [!WARNING]
Expand Down
2 changes: 1 addition & 1 deletion benchmark.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require 'benchmark/ips'
require 'redis_single_file'

PORT = ENV['WORKFLOW_PORT'] || 6379
PORT = ENV['REDIS_PORT'] || 6379

scenario_1_semaphore = RedisSingleFile.new(name: :scenario1, port: PORT)
scenario_2_semaphore = RedisSingleFile.new(name: :scenario2, port: PORT)
Expand Down
8 changes: 8 additions & 0 deletions bin/cluster
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
# bin/cluster stop
# ...
#
# ---
#
# This shell script is an adaptation of the original script from the redis
# gem that can be found at the link below. Appreciate the head start!
#
# Redis create-cluster script:
# github.com/redis/redis/blob/unstable/utils/create-cluster/create-cluster
#

#
# local run env settings
Expand Down
44 changes: 22 additions & 22 deletions lib/redis_single_file/cluster_client_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class << self
#
# Delegates class method calls to instance method
#
# @param [...] params passes directly to constructor
# @param [...] params passed directly to constructor
# @return [Redis::Cluster] redis cluster instance
def call(...) = new(...).call
end
Expand All @@ -40,26 +40,40 @@ def initialize(redis:)
def call
raise ClusterDisabledError, 'cluster not detected' unless cluster_enabled?

# use extracted client options with parsed nodes
Redis::Cluster.new(**client_options, nodes:)
# use extracted client options with parsed master nodes
Redis::Cluster.new(**client_options, nodes: master_nodes)
end

private # ==================================================================

attr_reader :redis

def nodes
def cluster_enabled?
redis.info('cluster')['cluster_enabled'] == '1'
rescue Redis::CommandError
false # assume cluster mode is disabled
end

def master_nodes
cluster_nodes.filter_map do |node|
next unless node[:flags].include?('master')

"redis://#{node[:address].split('@').first}"
end
end

def cluster_enabled?
redis.info('cluster')['cluster_enabled'] == '1'
rescue Redis::CommandError
false # assume cluster mode is disabled
def client_options
config = redis._client.config
params = %i[
db ssl host port path custom username password protocol
ssl_params read_timeout write_timeout connect_timeout
]

params_hash = params.each.with_object({}) do |key, memo|
memo[key] = config.public_send(key)
end

params_hash.merge(url: config.server_url)
end

def cluster_nodes
Expand All @@ -82,19 +96,5 @@ def cluster_nodes
}
end
end

def client_options
config = redis._client.config
params = %i[
db ssl host port path custom username password protocol
ssl_params read_timeout write_timeout connect_timeout
]

params_hash = params.each.with_object({}) do |key, memo|
memo[key] = config.public_send(key)
end

params_hash.merge(url: config.server_url)
end
end
end
2 changes: 1 addition & 1 deletion lib/redis_single_file/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module RedisSingleFile
VERSION = '0.1.2'
VERSION = '0.1.3'
end
51 changes: 30 additions & 21 deletions redis-single-file.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,50 @@
require_relative 'lib/redis_single_file/version'

Gem::Specification.new do |spec|
spec.name = 'redis-single-file'
spec.name = 'redis-single-file'
spec.version = RedisSingleFile::VERSION
spec.authors = ['LifeBCE']
spec.email = ['eric06@gmail.com']
spec.email = ['eric06@gmail.com']

spec.summary = 'Distributed semaphore implementation with redis.'
spec.summary = 'Distributed semaphore implementation with redis.'
spec.description = 'Synchronize execution across numerous instances.'
spec.homepage = 'https://github.com/lifeBCE/redis-single-file'
spec.license = 'MIT'
spec.required_ruby_version = '>= 3.2.0'
spec.homepage = 'https://github.com/lifeBCE/redis-single-file'
spec.license = 'MIT'

# spec.metadata['allowed_push_host'] = "TODO: Set to your gem server 'https://example.com'"
# Build Requiremenrs
spec.require_paths = ['lib']
spec.required_ruby_version = '>= 3.2.0'

spec.metadata['homepage_uri'] = spec.homepage
# spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."

# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
gemspec = File.basename(__FILE__)
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
ls.readlines("\x0", chomp: true).reject do |f|
(f == gemspec) ||
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
spec.metadata['changelog_uri'] = 'https://github.com/lifeBCE/redis-single-file/blob/main/CHANGELOG.md'

# Local variables used when setting spec.files below
gemspec = File.basename(__FILE__)
rejected = %w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]

# NOTE: Grabs list of files to include in gem from git. New files/directories
# will not be picked up until running 'git add' prior to building gem.
#
spec.files =
IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
# reject self and rejected paths defined above
ls.readlines("\x0", chomp: true).reject do |f|
(f == gemspec) || f.start_with?(*rejected)
end
end
end

# Identify Gem Exectuables
spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

# Uncomment to register a new dependency of your gem
# Redis Single File Dependencies
spec.add_dependency 'redis', '~> 5.3.0'
spec.add_dependency 'redis-clustering', '~> 5.3.0'

# Disable MFA Requirement - github publishing can't support
spec.metadata['rubygems_mfa_required'] = 'false'

# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
spec.metadata['rubygems_mfa_required'] = 'true'
end
Loading
Loading