redis-read-write-locks 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '02872b14abf3f2ab995c0d0ccf405cbfbab1fe88c7ccb7c59453a11da4bb3e33'
4
+ data.tar.gz: 3f647988f91d9b003dcbd242920af95d6942b94be79b2845b54e2a0d4af893e2
5
+ SHA512:
6
+ metadata.gz: 771040f529b8d93a4acd3d1770c11413573fe37be918a1d91ea0e0dd0376ae1d184a4616fca159b914dec0a89da71c5d4657f7ced036b4be66634b996ad8a4a2
7
+ data.tar.gz: 4b15e4c18f97a0b76959df16bc821d1816efe437a82061c0dfd610806cd098e2de0b4253d00ca35215956bbd00ba428c0ff5a7a7d6d56621f4dcd47d9e39fd84
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # redis-read-write-locks
2
+
3
+ Distributed read-write locks for Ruby backed by Redis.
4
+
5
+ - Multiple readers can hold the lock simultaneously
6
+ - Writers get exclusive access (blocks all readers and other writers)
7
+ - Locks expire automatically via TTL — no deadlock on crash
8
+ - Atomic operations via Lua scripts
9
+
10
+ ## Installation
11
+
12
+ ```ruby
13
+ gem "redis-read-write-locks"
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require "redis"
20
+ require "redis_read_write_locks"
21
+
22
+ client = RedisReadWriteLocks::Client.new(Redis.new(url: "redis://localhost:6379/0"))
23
+
24
+ # Block form — acquires, yields, releases
25
+ client.read_lock("my_resource") { read_data }
26
+ client.write_lock("my_resource") { write_data }
27
+
28
+ # Manual acquire/release
29
+ lock = client.read_lock("my_resource")
30
+ lock.acquire # => true / false (non-blocking)
31
+ lock.release
32
+
33
+ # Block on contention — retries for up to N seconds
34
+ lock.acquire(timeout: 5) # raises LockTimeoutError if timeout exceeded
35
+ lock.synchronize(timeout: 5) { } # acquire + yield + release
36
+
37
+ # Per-lock TTL override
38
+ client.write_lock("my_resource", ttl: 60) { long_operation }
39
+ ```
40
+
41
+ ### Client options
42
+
43
+ ```ruby
44
+ client = RedisReadWriteLocks::Client.new(redis, default_ttl: 60)
45
+ ```
46
+
47
+ ### Options
48
+
49
+ | Option | Default | Description |
50
+ |--------|---------|-------------|
51
+ | `ttl` | `30` | Lock TTL in seconds. Lock auto-expires if holder crashes. |
52
+
53
+ ### Errors
54
+
55
+ | Error | When |
56
+ |-------|------|
57
+ | `LockNotAcquiredError` | `synchronize` (no timeout) called when lock is contended |
58
+ | `LockTimeoutError` | `acquire(timeout:)` or `synchronize(timeout:)` exceeded timeout |
59
+
60
+ ## Redis key structure
61
+
62
+ ```
63
+ rw_lock:writer:<name> # String, holds owner token, TTL = lock TTL
64
+ rw_lock:readers:<name> # Sorted set, member = token, score = expiry timestamp
65
+ ```
66
+
67
+ ## Requirements
68
+
69
+ - Ruby >= 2.7
70
+ - Redis >= 5.0
71
+ - `redis` gem >= 4.0
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,67 @@
1
+ require "securerandom"
2
+
3
+ module RedisReadWriteLocks
4
+ class BaseLock
5
+ DEFAULT_TTL = 30
6
+ RETRY_INTERVAL = 0.01
7
+
8
+ attr_reader :name, :token
9
+
10
+ def initialize(redis:, name:, ttl: DEFAULT_TTL)
11
+ @redis = redis
12
+ @name = name
13
+ @ttl = ttl
14
+ @token = SecureRandom.uuid
15
+ @acquired = false
16
+ end
17
+
18
+ def acquired?
19
+ @acquired
20
+ end
21
+
22
+ # Non-blocking: returns true/false.
23
+ # With timeout: retries for timeout seconds, returns true or raises LockTimeoutError.
24
+ def acquire(timeout: nil)
25
+ return try_acquire if timeout.nil?
26
+
27
+ deadline = Time.now.to_f + timeout
28
+ loop do
29
+ return true if try_acquire
30
+ raise LockTimeoutError, "Timeout acquiring #{lock_type} lock '#{@name}'" if Time.now.to_f >= deadline
31
+ sleep RETRY_INTERVAL
32
+ end
33
+ end
34
+
35
+ # Acquires lock, yields, releases. Raises LockNotAcquiredError if non-blocking acquire fails.
36
+ def synchronize(timeout: nil, &block)
37
+ if timeout
38
+ acquire(timeout: timeout)
39
+ else
40
+ acquire || raise(LockNotAcquiredError, "Could not acquire #{lock_type} lock '#{@name}'")
41
+ end
42
+ begin
43
+ block.call
44
+ ensure
45
+ release
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def writer_key
52
+ "rw_lock:writer:#{@name}"
53
+ end
54
+
55
+ def readers_key
56
+ "rw_lock:readers:#{@name}"
57
+ end
58
+
59
+ def lock_type
60
+ self.class.name.split("::").last.sub("Lock", "").downcase
61
+ end
62
+
63
+ def eval_script(script, keys:, argv:)
64
+ @redis.eval(script, keys: keys, argv: argv)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ module RedisReadWriteLocks
2
+ class Client
3
+ def initialize(redis, default_ttl: BaseLock::DEFAULT_TTL)
4
+ @redis = redis
5
+ @default_ttl = default_ttl
6
+ end
7
+
8
+ def read_lock(name, ttl: @default_ttl, &block)
9
+ lock = ReadLock.new(redis: @redis, name: name, ttl: ttl)
10
+ block ? lock.synchronize(&block) : lock
11
+ end
12
+
13
+ def write_lock(name, ttl: @default_ttl, &block)
14
+ lock = WriteLock.new(redis: @redis, name: name, ttl: ttl)
15
+ block ? lock.synchronize(&block) : lock
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module RedisReadWriteLocks
2
+ Error = Class.new(StandardError)
3
+ LockNotAcquiredError = Class.new(Error)
4
+ LockTimeoutError = Class.new(Error)
5
+ end
@@ -0,0 +1,71 @@
1
+ module RedisReadWriteLocks
2
+ module LockScripts
3
+ # KEYS[1] = writer_key, KEYS[2] = readers_key
4
+ # ARGV[1] = token, ARGV[2] = expiry (unix ts), ARGV[3] = now (unix ts)
5
+ # Returns 1 = acquired, 0 = blocked
6
+ ACQUIRE_READ = <<~LUA.freeze
7
+ local writer_key = KEYS[1]
8
+ local readers_key = KEYS[2]
9
+ local token = ARGV[1]
10
+ local expiry = tonumber(ARGV[2])
11
+ local now = tonumber(ARGV[3])
12
+
13
+ redis.call('ZREMRANGEBYSCORE', readers_key, '-inf', now)
14
+
15
+ if redis.call('EXISTS', writer_key) == 1 then
16
+ return 0
17
+ end
18
+
19
+ redis.call('ZADD', readers_key, expiry, token)
20
+
21
+ local current_ttl = redis.call('TTL', readers_key)
22
+ if current_ttl == -1 or (current_ttl > 0 and (now + current_ttl) < expiry) then
23
+ redis.call('EXPIREAT', readers_key, expiry + 1)
24
+ end
25
+
26
+ return 1
27
+ LUA
28
+
29
+ # KEYS[1] = readers_key
30
+ # ARGV[1] = token
31
+ RELEASE_READ = <<~LUA.freeze
32
+ redis.call('ZREM', KEYS[1], ARGV[1])
33
+ return 1
34
+ LUA
35
+
36
+ # KEYS[1] = writer_key, KEYS[2] = readers_key
37
+ # ARGV[1] = token, ARGV[2] = ttl (seconds), ARGV[3] = now (unix ts)
38
+ # Returns 1 = acquired, 0 = blocked
39
+ ACQUIRE_WRITE = <<~LUA.freeze
40
+ local writer_key = KEYS[1]
41
+ local readers_key = KEYS[2]
42
+ local token = ARGV[1]
43
+ local ttl = tonumber(ARGV[2])
44
+ local now = tonumber(ARGV[3])
45
+
46
+ redis.call('ZREMRANGEBYSCORE', readers_key, '-inf', now)
47
+
48
+ if redis.call('ZCARD', readers_key) > 0 then
49
+ return 0
50
+ end
51
+
52
+ if redis.call('EXISTS', writer_key) == 1 then
53
+ return 0
54
+ end
55
+
56
+ redis.call('SET', writer_key, token, 'EX', ttl)
57
+ return 1
58
+ LUA
59
+
60
+ # KEYS[1] = writer_key
61
+ # ARGV[1] = token
62
+ # Returns 1 = released, 0 = not owner
63
+ RELEASE_WRITE = <<~LUA.freeze
64
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
65
+ redis.call('DEL', KEYS[1])
66
+ return 1
67
+ end
68
+ return 0
69
+ LUA
70
+ end
71
+ end
@@ -0,0 +1,26 @@
1
+ module RedisReadWriteLocks
2
+ class ReadLock < BaseLock
3
+ def release
4
+ return false unless @acquired
5
+
6
+ eval_script(LockScripts::RELEASE_READ, keys: [readers_key], argv: [@token])
7
+ @acquired = false
8
+ true
9
+ end
10
+
11
+ private
12
+
13
+ def try_acquire
14
+ now = Time.now.to_i
15
+ expiry = now + @ttl
16
+
17
+ result = eval_script(
18
+ LockScripts::ACQUIRE_READ,
19
+ keys: [writer_key, readers_key],
20
+ argv: [@token, expiry, now],
21
+ )
22
+
23
+ @acquired = result == 1
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module RedisReadWriteLocks
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,23 @@
1
+ module RedisReadWriteLocks
2
+ class WriteLock < BaseLock
3
+ def release
4
+ return false unless @acquired
5
+
6
+ eval_script(LockScripts::RELEASE_WRITE, keys: [writer_key], argv: [@token])
7
+ @acquired = false
8
+ true
9
+ end
10
+
11
+ private
12
+
13
+ def try_acquire
14
+ result = eval_script(
15
+ LockScripts::ACQUIRE_WRITE,
16
+ keys: [writer_key, readers_key],
17
+ argv: [@token, @ttl, Time.now.to_i],
18
+ )
19
+
20
+ @acquired = result == 1
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "redis_read_write_locks/version"
2
+ require_relative "redis_read_write_locks/errors"
3
+ require_relative "redis_read_write_locks/lock_scripts"
4
+ require_relative "redis_read_write_locks/base_lock"
5
+ require_relative "redis_read_write_locks/read_lock"
6
+ require_relative "redis_read_write_locks/write_lock"
7
+ require_relative "redis_read_write_locks/client"
@@ -0,0 +1,21 @@
1
+ require_relative "lib/redis_read_write_locks/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "redis-read-write-locks"
5
+ spec.version = RedisReadWriteLocks::VERSION
6
+ spec.authors = ["Umbrellio"]
7
+ spec.email = ["oss@umbrellio.biz"]
8
+ spec.summary = "Distributed read-write locks using Redis"
9
+ spec.description = "Redis-backed distributed read-write locks. Multiple concurrent readers, exclusive writers."
10
+ spec.homepage = "https://github.com/umbrellio/redis-read-write-locks"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = ">= 3.2"
13
+
14
+ spec.files = Dir["lib/**/*.rb", "*.gemspec", "LICENSE", "README.md"]
15
+ spec.require_paths = ["lib"]
16
+
17
+ spec.add_dependency "redis", ">= 4.0"
18
+
19
+ spec.add_development_dependency "rspec", "~> 3.0"
20
+ spec.add_development_dependency "rake", "~> 13.0"
21
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redis-read-write-locks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Umbrellio
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: redis
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: Redis-backed distributed read-write locks. Multiple concurrent readers,
55
+ exclusive writers.
56
+ email:
57
+ - oss@umbrellio.biz
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - README.md
63
+ - lib/redis_read_write_locks.rb
64
+ - lib/redis_read_write_locks/base_lock.rb
65
+ - lib/redis_read_write_locks/client.rb
66
+ - lib/redis_read_write_locks/errors.rb
67
+ - lib/redis_read_write_locks/lock_scripts.rb
68
+ - lib/redis_read_write_locks/read_lock.rb
69
+ - lib/redis_read_write_locks/version.rb
70
+ - lib/redis_read_write_locks/write_lock.rb
71
+ - redis-read-write-locks.gemspec
72
+ homepage: https://github.com/umbrellio/redis-read-write-locks
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.2'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: Distributed read-write locks using Redis
93
+ test_files: []