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 +7 -0
- data/README.md +75 -0
- data/lib/redis_read_write_locks/base_lock.rb +67 -0
- data/lib/redis_read_write_locks/client.rb +18 -0
- data/lib/redis_read_write_locks/errors.rb +5 -0
- data/lib/redis_read_write_locks/lock_scripts.rb +71 -0
- data/lib/redis_read_write_locks/read_lock.rb +26 -0
- data/lib/redis_read_write_locks/version.rb +3 -0
- data/lib/redis_read_write_locks/write_lock.rb +23 -0
- data/lib/redis_read_write_locks.rb +7 -0
- data/redis-read-write-locks.gemspec +21 -0
- metadata +93 -0
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,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,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: []
|