simple_lock 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e1bc79cf05fb49bb0c5bb534dd371f58f1128f2dace0d89ffe158a9554ef165b
4
+ data.tar.gz: 11d9f6d973acc1385195f21919e7aa27eb807bed2a7017cba326bd4f51c9e776
5
+ SHA512:
6
+ metadata.gz: b2512f7dea061d557af92adadc02d8eab15aa300bad6e3e5841ed4ebf859a909ab9641d9b2476fda3a1a4129f16f95e10c579508d237583c58f2d6349a55d2a8
7
+ data.tar.gz: 9b6a9439032b7309cb5b829778f939b0c4d875511e3bbd1e0bc3b13448d82a3ed2dd189137cd3d38e0979bb0390023c8497c7c20ec3720e93ebdc5cf318c43a4
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.7.0
3
+ NewCops: disable
4
+ SuggestExtensions: false
5
+ Include:
6
+ - lib/simple_lock.rb
7
+ - lib/simple_lock/*.rb
8
+ - spec/*.rb
9
+
10
+ Style/StringLiterals:
11
+ EnforcedStyle: double_quotes
12
+
13
+ Style/StringLiteralsInInterpolation:
14
+ EnforcedStyle: double_quotes
15
+
16
+ Metrics/BlockLength:
17
+ Exclude:
18
+ - spec/*.rb
19
+
20
+ Style/Documentation:
21
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-12-27
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 caiodsc
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:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
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.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+
2
+ # SimpleLock
3
+
4
+ **SimpleLock** is a simple implementation of distributed locking using Ruby and Redis, designed to prevent deadlocks and ensure that concurrent processes can be managed efficiently.
5
+
6
+ ## Installation
7
+
8
+ Add the gem to your `Gemfile`:
9
+
10
+ ```ruby
11
+ gem "simple_lock"
12
+ ```
13
+
14
+ Then run the following command:
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ Or, if you prefer installing it directly via terminal:
21
+
22
+ ```bash
23
+ gem install simple_lock
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Simple Locking
29
+
30
+ The main goal of **SimpleLock** is to provide a distributed locking mechanism. You can use the gem to ensure that only one process or thread can access a critical resource or operation at a time.
31
+
32
+ ### Basic Example
33
+
34
+ ```ruby
35
+ # Using the simple lock with a key and expiration time (TTL)
36
+ locked = SimpleLock.lock("my_unique_lock_key", 30)
37
+
38
+ if locked
39
+ # Execute the critical operation
40
+ puts "Lock acquired! Executing critical operation."
41
+ else
42
+ # Lock couldn't be acquired, another process already has it
43
+ puts "Failed to acquire lock, try again later."
44
+ end
45
+ ```
46
+
47
+ ### Using with Block
48
+
49
+ You can also use **SimpleLock** with a block, ensuring the lock will be automatically released when the block finishes, even if an exception occurs:
50
+
51
+ ```ruby
52
+ SimpleLock.lock("my_unique_lock_key", 30) do |locked|
53
+ if locked
54
+ # Critical operation
55
+ puts "Safe operation is running."
56
+ else
57
+ # Couldn't acquire the lock
58
+ puts "Failed to acquire lock."
59
+ end
60
+ end
61
+ ```
62
+
63
+ ### Unlocking
64
+
65
+ You can manually release the lock using the `unlock` method:
66
+
67
+ ```ruby
68
+ SimpleLock.unlock("my_unique_lock_key")
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ You can configure various behaviors of the gem, such as the key prefix, retry count, retry delay, and more.
74
+
75
+ ### Example Configuration:
76
+
77
+ ```ruby
78
+ SimpleLock.config.key_prefix = "simple_lock:"
79
+ SimpleLock.config.retry_count = 3
80
+ SimpleLock.config.retry_delay = 200 # in milliseconds
81
+ SimpleLock.config.retry_jitter = 50 # in milliseconds
82
+ SimpleLock.config.retry_proc = Proc.new { |attempt| attempt * 100 }
83
+ ```
84
+
85
+ ## Features
86
+
87
+ - **Distributed locking and unlocking** with Redis.
88
+ - **Automatic retry** with increasing delay and jitter, useful for high-concurrency systems.
89
+ - **Safe unlocking** even in case of failure.
90
+ - **Low latency** and easy integration.
91
+
92
+ ## Development
93
+
94
+ After cloning the repository, install dependencies:
95
+
96
+ ```bash
97
+ bin/setup
98
+ ```
99
+
100
+ Run tests:
101
+
102
+ ```bash
103
+ rake spec
104
+ ```
105
+
106
+ To interact with the code in the console:
107
+
108
+ ```bash
109
+ bin/console
110
+ ```
111
+
112
+ To install the gem locally:
113
+
114
+ ```bash
115
+ bundle exec rake install
116
+ ```
117
+
118
+ ### Releasing a New Version
119
+
120
+ 1. Update the version number in `lib/simple_lock/version.rb`.
121
+ 2. Run the command to release the version:
122
+
123
+ ```bash
124
+ bundle exec rake release
125
+ ```
126
+
127
+ This will create a Git tag, push the code, and upload the `.gem` file to RubyGems.
128
+
129
+ ## Contributing
130
+
131
+ Contributions are welcome! Open an issue or submit a pull request on the [GitHub repository](https://github.com/caiodsc/simple_lock).
132
+
133
+ 1. Fork the project.
134
+ 2. Create a new branch (`git checkout -b feature-branch`).
135
+ 3. Commit your changes (`git commit -am 'Add new feature'`).
136
+ 4. Push to the branch (`git push origin feature-branch`).
137
+ 5. Create a new Pull Request.
138
+
139
+ ## License
140
+
141
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SimpleLock
6
+ module Delegation
7
+ class DelegationError < NoMethodError; end
8
+
9
+ RUBY_RESERVED_KEYWORDS = %w[__ENCODING__ __LINE__ __FILE__ alias and BEGIN begin break
10
+ case class def defined? do else elsif END end ensure false for if in module next nil
11
+ not or redo rescue retry return self super then true undef unless until when while yield].freeze
12
+ DELEGATION_RESERVED_KEYWORDS = %w[_ arg args block].freeze
13
+ DELEGATION_RESERVED_METHOD_NAMES = Set.new(
14
+ RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS
15
+ ).freeze
16
+
17
+ def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil)
18
+ unless to
19
+ raise ArgumentError,
20
+ "Delegation needs a target. Supply a keyword argument 'to' (e.g. delegate :hello, to: :greeter)."
21
+ end
22
+
23
+ if prefix == true && /^[^a-z_]/.match?(to)
24
+ raise ArgumentError, "Can only automatically set the delegation prefix when delegating to a method."
25
+ end
26
+
27
+ method_prefix = \
28
+ if prefix
29
+ "#{prefix == true ? to : prefix}_"
30
+ else
31
+ ""
32
+ end
33
+
34
+ location = caller_locations(1, 1).first
35
+ file = location.path
36
+ line = location.lineno
37
+
38
+ receiver = to.to_s
39
+ receiver = "self.#{receiver}" if DELEGATION_RESERVED_METHOD_NAMES.include?(receiver)
40
+
41
+ method_def = []
42
+ method_names = []
43
+
44
+ method_def << "self.private" if private
45
+
46
+ methods.each do |method|
47
+ method_name = prefix ? "#{method_prefix}#{method}" : method
48
+ method_names << method_name.to_sym
49
+
50
+ definition = \
51
+ if /[^\]]=\z/.match?(method)
52
+ "arg"
53
+ else
54
+ method_object =
55
+ begin
56
+ if to.is_a?(Module)
57
+ to.method(method)
58
+ elsif receiver == "self.class"
59
+ method(method)
60
+ end
61
+ rescue NameError
62
+ # Do nothing. Fall back to `"..."`
63
+ end
64
+
65
+ if method_object
66
+ parameters = method_object.parameters
67
+
68
+ if (parameters.map(&:first) & %i[opt rest keyreq key keyrest]).any?
69
+ "..."
70
+ else
71
+ defn = parameters.filter_map { |type, arg| arg if type == :req }
72
+ defn << "&block"
73
+ defn.join(", ")
74
+ end
75
+ else
76
+ "..."
77
+ end
78
+ end
79
+
80
+ method = method.to_s
81
+ if allow_nil
82
+
83
+ method_def <<
84
+ "def #{method_name}(#{definition})" \
85
+ " _ = #{receiver}" \
86
+ " if !_.nil? || nil.respond_to?(:#{method})" \
87
+ " _.#{method}(#{definition})" \
88
+ " end" \
89
+ "end"
90
+ else
91
+ method_name = method_name.to_s
92
+
93
+ method_def <<
94
+ "def #{method_name}(#{definition})" \
95
+ " _ = #{receiver}" \
96
+ " _.#{method}(#{definition})" \
97
+ "rescue NoMethodError => e" \
98
+ " if _.nil? && e.name == :#{method}" <<
99
+ %( raise DelegationError, "#{self}##{method_name} delegated to #{receiver}.#{method}, but #{receiver} is nil: \#{self.inspect}") <<
100
+ " else" \
101
+ " raise" \
102
+ " end" \
103
+ "end"
104
+ end
105
+ end
106
+ module_eval(method_def.join(";"), file, line)
107
+ method_names
108
+ end
109
+
110
+ def delegate_missing_to(target, allow_nil: nil)
111
+ target = target.to_s
112
+ target = "self.#{target}" if DELEGATION_RESERVED_METHOD_NAMES.include?(target) || target == "__target"
113
+
114
+ if allow_nil
115
+ module_eval <<~RUBY, __FILE__, __LINE__ + 1
116
+ def respond_to_missing?(name, include_private = false)
117
+ # It may look like an oversight, but we deliberately do not pass
118
+ # +include_private+, because they do not get delegated.
119
+
120
+ return false if name == :marshal_dump || name == :_dump
121
+ #{target}.respond_to?(name) || super
122
+ end
123
+
124
+ def method_missing(method, *args, &block)
125
+ __target = #{target}
126
+ if __target.nil? && !nil.respond_to?(method)
127
+ nil
128
+ elsif __target.respond_to?(method)
129
+ __target.public_send(method, *args, &block)
130
+ else
131
+ super
132
+ end
133
+ end
134
+ ruby2_keywords(:method_missing)
135
+ RUBY
136
+ else
137
+ module_eval <<~RUBY, __FILE__, __LINE__ + 1
138
+ def respond_to_missing?(name, include_private = false)
139
+ # It may look like an oversight, but we deliberately do not pass
140
+ # +include_private+, because they do not get delegated.
141
+
142
+ return false if name == :marshal_dump || name == :_dump
143
+ #{target}.respond_to?(name) || super
144
+ end
145
+
146
+ def method_missing(method, *args, &block)
147
+ __target = #{target}
148
+ if __target.nil? && !nil.respond_to?(method)
149
+ raise DelegationError, "\#{method} delegated to #{target}, but #{target} is nil"
150
+ elsif __target.respond_to?(method)
151
+ __target.public_send(method, *args, &block)
152
+ else
153
+ super
154
+ end
155
+ end
156
+ ruby2_keywords(:method_missing)
157
+ RUBY
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleLock
4
+ module With
5
+ def with(**attributes)
6
+ old_values = {}
7
+ begin
8
+ attributes.each do |key, value|
9
+ old_values[key] = public_send(key)
10
+ public_send("#{key}=", value)
11
+ end
12
+ yield self
13
+ ensure
14
+ old_values.each do |key, old_value|
15
+ public_send("#{key}=", old_value)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module SimpleLock
6
+ class Config
7
+ extend SimpleLock::Delegation
8
+
9
+ DEFAULT_CONFIG = OpenStruct.new(
10
+ {
11
+ retry_count: 3,
12
+ retry_delay: 200,
13
+ retry_jitter: 50,
14
+ retry_proc: nil,
15
+ key_prefix: "simple_lock:"
16
+ }
17
+ )
18
+
19
+ def initialize
20
+ @config = DEFAULT_CONFIG
21
+ end
22
+
23
+ delegate_missing_to :@config, allow_nil: false
24
+
25
+ def respond_to_missing?(method_name, ...)
26
+ DEFAULT_CONFIG.table.keys.include?(method_name.to_s.delete_suffix("=").to_sym)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleLock
4
+ class Redis
5
+ extend SimpleLock::Delegation
6
+
7
+ def initialize(url: nil)
8
+ @redis = ::Redis.new(url: url)
9
+ end
10
+
11
+ delegate_missing_to :@redis, allow_nil: false
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SimpleLock
6
+ Script = Struct.new(:raw) do
7
+ def sha
8
+ @sha ||= Digest::SHA1.hexdigest(raw)
9
+ end
10
+ end
11
+
12
+ LOCK_VALUE = "1"
13
+
14
+ SCRIPTS = {
15
+ lock: Script.new("return redis.call('set', KEYS[1], #{LOCK_VALUE}, 'NX', 'PX', ARGV[1])"),
16
+ unlock: Script.new("redis.call('del', KEYS[1])")
17
+ }.freeze
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleLock
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ Dir["./lib/initializers/*.rb"].sort.each { |f| require(f) }
6
+ Dir["./lib/simple_lock/*.rb"].sort.each { |f| require(f) }
7
+
8
+ module SimpleLock
9
+ extend SimpleLock::With
10
+
11
+ class Error < StandardError; end
12
+
13
+ NOSCRIPT_MAX_RETRIES = 1
14
+
15
+ def self.client
16
+ @client ||= SimpleLock::Redis.new(url: nil)
17
+ end
18
+
19
+ def self.client=(client_or_url)
20
+ case client_or_url
21
+ when SimpleLock::Redis
22
+ @client = client
23
+ when String
24
+ @client = SimpleLock::Redis.new(url: client_or_url)
25
+ else
26
+ raise ArgumentError, "client must be an instance of SimpleLock::Redis or a String"
27
+ end
28
+ end
29
+
30
+ def self.config
31
+ @config ||= Config.new
32
+ end
33
+
34
+ # rubocop:disable Metrics/MethodLength
35
+ def self.lock(key, ttl)
36
+ key = "#{config.key_prefix}#{key}"
37
+
38
+ locked = (config.retry_count + 1).times.any? do |attempt|
39
+ sleep(backoff_for_attempt(attempt)) unless attempt.zero?
40
+
41
+ safe_exec_script(SCRIPTS[:lock], [key], [ttl]) == "OK"
42
+ end
43
+
44
+ return locked unless block_given?
45
+
46
+ begin
47
+ yield(locked)
48
+ ensure
49
+ unlock(key) if locked
50
+ end
51
+ end
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def self.unlock(key)
55
+ key = "#{config.key_prefix}#{key}"
56
+
57
+ safe_exec_script(SCRIPTS[:unlock], [key])
58
+ rescue StandardError
59
+ # Nothing to do, this is just a best-effort attempt.
60
+ end
61
+
62
+ def self.load_scripts
63
+ SCRIPTS.each_value do |script|
64
+ client.script("load", script.raw)
65
+ end
66
+ end
67
+
68
+ def self.safe_exec_script(script, ...)
69
+ retries = 0
70
+
71
+ begin
72
+ client.evalsha(script.sha, ...)
73
+ rescue ::Redis::CommandError => e
74
+ if e.message.include?("NOSCRIPT") && (retries += 1) <= NOSCRIPT_MAX_RETRIES
75
+ load_scripts
76
+ retry
77
+ end
78
+
79
+ raise Error, e.message
80
+ end
81
+ end
82
+
83
+ def self.backoff_for_attempt(attempt)
84
+ delay = config.retry_proc.respond_to?(:call) ? config.retry_proc.call(attempt) : config.retry_delay
85
+
86
+ (delay + rand(config.retry_jitter)).fdiv(1000)
87
+ end
88
+ end
@@ -0,0 +1,4 @@
1
+ module SimpleLock
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: simple_lock
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - caiodsc
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.21'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.21'
69
+ description:
70
+ email:
71
+ - caio.dscamara@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".rspec"
77
+ - ".rubocop.yml"
78
+ - CHANGELOG.md
79
+ - LICENSE.txt
80
+ - README.md
81
+ - Rakefile
82
+ - lib/initializers/delegation.rb
83
+ - lib/initializers/with.rb
84
+ - lib/simple_lock.rb
85
+ - lib/simple_lock/config.rb
86
+ - lib/simple_lock/redis.rb
87
+ - lib/simple_lock/scripts.rb
88
+ - lib/simple_lock/version.rb
89
+ - sig/simple_lock.rbs
90
+ homepage: https://github.com/caiodsc/simple_lock
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ allowed_push_host: https://rubygems.org
95
+ homepage_uri: https://github.com/caiodsc/simple_lock
96
+ source_code_uri: https://github.com/caiodsc/simple_lock
97
+ changelog_uri: https://github.com/caiodsc/simple_lock/CHANGELOG.md
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 2.7.0
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.2.33
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: Simple Deadlock implementation using Ruby.
117
+ test_files: []