redis-read-write-locks 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c9401651786ff69991b9b975ebbc62cf0442fd3f2639c79fad803fab00f335e
4
- data.tar.gz: 724513d599b3a614e43a3be6b634ad52c1d64035c63ddfb1566e2c93d4a837a6
3
+ metadata.gz: c9e310b7b077bb24539719d830b79839dde4f00dc0634d4a2ccbb9e91809105a
4
+ data.tar.gz: fffb72914195bd66e69ebb800fb0a2fe5bb0a39b79b3d73e912335e4fc968ce3
5
5
  SHA512:
6
- metadata.gz: be039491564babb54c6e1b5ce87e880bc643f4f761efa24faadbc9c2a890e019c48daac13ebbd83409b0db60885b21e23b53fa7fc2d83a879662690fafab2b2a
7
- data.tar.gz: 765be840a9d1abba171df6cee27dc3c775eac4ad8edfdf38eebbc32c21a09e51912713ae7006d6863fc9ff8d6e6299dd1820be503231443169ebb36db62530b0
6
+ metadata.gz: 1d1f9430aa04d71be97759004e36786de7f99aa845b6379d776718fc9adac65c486765a2ce6096ff4a22d4c273f14c5d40b4c128b782071b0a80c4ed4fc1fae3
7
+ data.tar.gz: 03a01ad0f6fa2906d2934fcdc23ae12bef0b77448936de949d7422c61d6ec98d004b58648e31517e1edf5466ed6a33633958b92e6bb9ab6c28e885a2011f053e
data/README.md CHANGED
@@ -49,6 +49,7 @@ client = RedisReadWriteLocks::Client.new(redis, default_ttl: 60_000)
49
49
  | Option | Default | Description |
50
50
  |--------|---------|-------------|
51
51
  | `ttl` | `30` | Lock TTL in seconds. Lock auto-expires if holder crashes. |
52
+ | `prefer_writer` | `false` | Write lock only. When `true`, a writer blocked on acquire registers its intent so new readers are refused until it acquires or gives up. Only takes effect when `retry_count` is set — a non-retrying writer isn't waiting, so it registers and immediately clears its intent, making the flag a no-op. |
52
53
 
53
54
  ### Errors
54
55
 
@@ -60,8 +61,10 @@ client = RedisReadWriteLocks::Client.new(redis, default_ttl: 60_000)
60
61
  ## Redis key structure
61
62
 
62
63
  ```
63
- rw_lock:writer:<name> # String, holds owner token, TTL = lock TTL
64
- rw_lock:readers:<name> # Sorted set, member = token, score = expiry timestamp
64
+ rw_lock:writer:<name> # String, holds owner token, TTL = lock TTL
65
+ rw_lock:readers:<name> # Sorted set, member = token, score = expiry timestamp
66
+ rw_lock:pending_writers:<name> # Sorted set, member = token, score = pending-intent expiry timestamp.
67
+ # Populated only by writers using `prefer_writer: true`.
65
68
  ```
66
69
 
67
70
  ## Requirements
@@ -1 +1,3 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "redis_read_write_locks"
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "securerandom"
2
4
 
3
5
  module RedisReadWriteLocks
4
6
  class BaseLock
5
7
  DEFAULT_TTL = 30_000
6
- RETRY_INTERVAL = 0.01
8
+ DEFAULT_RETRY_DELAY = 100
9
+ PENDING_WRITER_TTL = 30_000
7
10
 
8
11
  attr_reader :name, :token
9
12
 
@@ -19,35 +22,73 @@ module RedisReadWriteLocks
19
22
  @acquired
20
23
  end
21
24
 
22
- # Non-blocking: returns true/false.
23
- # With timeout: retries for timeout milliseconds, returns true or raises LockTimeoutError.
24
- def acquire(timeout: nil)
25
- return try_acquire if timeout.nil?
25
+ def acquire(retry_count: nil, retry_delay: DEFAULT_RETRY_DELAY)
26
+ @retry_delay = retry_count.nil? ? 0 : retry_delay
27
+
28
+ return try_acquire if retry_count.nil?
29
+
30
+ return true if try_acquire
26
31
 
27
- deadline = Time.now.to_f + timeout / 1000.0
28
- loop do
32
+ retry_count.times do
33
+ sleep retry_delay / 1000.0
29
34
  return true if try_acquire
30
- raise LockTimeoutError, "Timeout acquiring #{lock_type} lock '#{@name}'" if Time.now.to_f >= deadline
31
- sleep RETRY_INTERVAL
35
+ end
36
+ raise LockTimeoutError, "Could not acquire #{lock_type} lock '#{@name}' after #{retry_count} retries"
37
+ ensure
38
+ begin
39
+ abandon_pending unless acquired?
40
+ rescue StandardError
41
+ nil
32
42
  end
33
43
  end
34
44
 
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
45
+ WATCHDOG_REFRESH_INTERVAL = 10
46
+ WATCHDOG_SLEEP_INTERVAL = 0.5
47
+
48
+ def synchronize(retry_count: nil, retry_delay: DEFAULT_RETRY_DELAY, &block)
49
+ acquire_or_raise(retry_count: retry_count, retry_delay: retry_delay)
50
+ stopped = false
51
+ watchdog = start_watchdog(Thread.current, -> { stopped })
42
52
  begin
43
53
  block.call
44
54
  ensure
55
+ stopped = true
56
+ watchdog.join
45
57
  release
46
58
  end
47
59
  end
48
60
 
49
61
  private
50
62
 
63
+ def acquire_or_raise(retry_count:, retry_delay:)
64
+ return acquire(retry_count: retry_count, retry_delay: retry_delay) if retry_count
65
+
66
+ acquire || raise(LockNotAcquiredError, "Could not acquire #{lock_type} lock '#{@name}'")
67
+ end
68
+
69
+ def start_watchdog(main_thread, stopped)
70
+ Thread.new do
71
+ elapsed = 0.0
72
+ until stopped.call
73
+ sleep WATCHDOG_SLEEP_INTERVAL
74
+ elapsed += WATCHDOG_SLEEP_INTERVAL
75
+ next if stopped.call
76
+ next unless elapsed >= WATCHDOG_REFRESH_INTERVAL
77
+
78
+ elapsed = 0.0
79
+ begin
80
+ unless refresh
81
+ main_thread.raise(LockRefreshError, "Could not refresh #{lock_type} lock '#{@name}'")
82
+ break
83
+ end
84
+ rescue StandardError => e
85
+ main_thread.raise(e)
86
+ break
87
+ end
88
+ end
89
+ end
90
+ end
91
+
51
92
  def writer_key
52
93
  "rw_lock:writer:#{@name}"
53
94
  end
@@ -56,6 +97,14 @@ module RedisReadWriteLocks
56
97
  "rw_lock:readers:#{@name}"
57
98
  end
58
99
 
100
+ def pending_writers_key
101
+ "rw_lock:pending_writers:#{@name}"
102
+ end
103
+
104
+ def abandon_pending
105
+ nil
106
+ end
107
+
59
108
  def lock_type
60
109
  self.class.name.split("::").last.sub("Lock", "").downcase
61
110
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
4
  class Client
3
5
  def initialize(redis, default_ttl: BaseLock::DEFAULT_TTL)
@@ -5,14 +7,15 @@ module RedisReadWriteLocks
5
7
  @default_ttl = default_ttl
6
8
  end
7
9
 
8
- def read_lock(name, ttl: @default_ttl, timeout: nil, &block)
10
+ def read_lock(name, ttl: @default_ttl, retry_count: nil, retry_delay: BaseLock::DEFAULT_RETRY_DELAY, &block)
9
11
  lock = ReadLock.new(redis: @redis, name: name, ttl: ttl)
10
- block ? lock.synchronize(timeout: timeout, &block) : lock
12
+ block ? lock.synchronize(retry_count: retry_count, retry_delay: retry_delay, &block) : lock
11
13
  end
12
14
 
13
- def write_lock(name, ttl: @default_ttl, timeout: nil, &block)
14
- lock = WriteLock.new(redis: @redis, name: name, ttl: ttl)
15
- block ? lock.synchronize(timeout: timeout, &block) : lock
15
+ def write_lock(name, ttl: @default_ttl, retry_count: nil, retry_delay: BaseLock::DEFAULT_RETRY_DELAY,
16
+ prefer_writer: false, &block)
17
+ lock = WriteLock.new(redis: @redis, name: name, ttl: ttl, prefer_writer: prefer_writer)
18
+ block ? lock.synchronize(retry_count: retry_count, retry_delay: retry_delay, &block) : lock
16
19
  end
17
20
  end
18
21
  end
@@ -1,5 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
- Error = Class.new(StandardError)
3
- LockNotAcquiredError = Class.new(Error)
4
- LockTimeoutError = Class.new(Error)
4
+ class Error < StandardError
5
+ end
6
+
7
+ class LockNotAcquiredError < Error
8
+ end
9
+
10
+ class LockTimeoutError < Error
11
+ end
12
+
13
+ class LockRefreshError < Error
14
+ end
5
15
  end
@@ -1,26 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
4
  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
+ # KEYS[1] = writer_key, KEYS[2] = readers_key, KEYS[3] = pending_writers_key
6
+ # ARGV[1] = token, ARGV[2] = expiry (unix ts), ARGV[3] = now (unix ts), ARGV[4] = ttl (ms)
5
7
  # Returns 1 = acquired, 0 = blocked
6
- ACQUIRE_READ = <<~LUA.freeze
8
+ ACQUIRE_READ = <<~LUA
7
9
  local writer_key = KEYS[1]
8
10
  local readers_key = KEYS[2]
11
+ local pending_writers_key = KEYS[3]
9
12
  local token = ARGV[1]
10
13
  local expiry = tonumber(ARGV[2])
11
14
  local now = tonumber(ARGV[3])
15
+ local ttl_ms = tonumber(ARGV[4])
12
16
 
13
17
  redis.call('ZREMRANGEBYSCORE', readers_key, '-inf', now)
18
+ redis.call('ZREMRANGEBYSCORE', pending_writers_key, '-inf', now)
14
19
 
15
20
  if redis.call('EXISTS', writer_key) == 1 then
16
21
  return 0
17
22
  end
18
23
 
24
+ if redis.call('ZCARD', pending_writers_key) > 0 then
25
+ return 0
26
+ end
27
+
19
28
  redis.call('ZADD', readers_key, expiry, token)
20
29
 
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)
30
+ local current_pttl = redis.call('PTTL', readers_key)
31
+ if current_pttl == -1 or current_pttl < ttl_ms then
32
+ redis.call('PEXPIRE', readers_key, ttl_ms)
24
33
  end
25
34
 
26
35
  return 1
@@ -28,44 +37,100 @@ module RedisReadWriteLocks
28
37
 
29
38
  # KEYS[1] = readers_key
30
39
  # ARGV[1] = token
31
- RELEASE_READ = <<~LUA.freeze
40
+ RELEASE_READ = <<~LUA
32
41
  redis.call('ZREM', KEYS[1], ARGV[1])
33
42
  return 1
34
43
  LUA
35
44
 
36
- # KEYS[1] = writer_key, KEYS[2] = readers_key
37
- # ARGV[1] = token, ARGV[2] = ttl (seconds), ARGV[3] = now (unix ts)
45
+ # KEYS[1] = writer_key, KEYS[2] = readers_key, KEYS[3] = pending_writers_key
46
+ # ARGV[1] = token, ARGV[2] = ttl (ms), ARGV[3] = now (unix ts),
47
+ # ARGV[4] = prefer (1 = writer preference on), ARGV[5] = pending expiry (unix ts),
48
+ # ARGV[6] = pending ttl (ms)
38
49
  # Returns 1 = acquired, 0 = blocked
39
- ACQUIRE_WRITE = <<~LUA.freeze
50
+ ACQUIRE_WRITE = <<~LUA
40
51
  local writer_key = KEYS[1]
41
52
  local readers_key = KEYS[2]
53
+ local pending_writers_key = KEYS[3]
42
54
  local token = ARGV[1]
43
55
  local ttl = tonumber(ARGV[2])
44
56
  local now = tonumber(ARGV[3])
57
+ local prefer = tonumber(ARGV[4])
58
+ local pending_expiry = tonumber(ARGV[5])
59
+ local pending_ttl_ms = tonumber(ARGV[6])
45
60
 
46
61
  redis.call('ZREMRANGEBYSCORE', readers_key, '-inf', now)
62
+ redis.call('ZREMRANGEBYSCORE', pending_writers_key, '-inf', now)
47
63
 
48
- if redis.call('ZCARD', readers_key) > 0 then
49
- return 0
50
- end
64
+ if redis.call('ZCARD', readers_key) > 0 or redis.call('EXISTS', writer_key) == 1 then
65
+ if prefer == 1 then
66
+ redis.call('ZADD', pending_writers_key, pending_expiry, token)
67
+
68
+ local current_pttl = redis.call('PTTL', pending_writers_key)
69
+ if current_pttl == -1 or current_pttl < pending_ttl_ms then
70
+ redis.call('PEXPIRE', pending_writers_key, pending_ttl_ms)
71
+ end
72
+ end
51
73
 
52
- if redis.call('EXISTS', writer_key) == 1 then
53
74
  return 0
54
75
  end
55
76
 
56
- redis.call('SET', writer_key, token, 'EX', ttl)
77
+ redis.call('SET', writer_key, token, 'PX', ttl)
78
+ redis.call('ZREM', pending_writers_key, token)
57
79
  return 1
58
80
  LUA
59
81
 
60
82
  # KEYS[1] = writer_key
61
83
  # ARGV[1] = token
62
84
  # Returns 1 = released, 0 = not owner
63
- RELEASE_WRITE = <<~LUA.freeze
85
+ RELEASE_WRITE = <<~LUA
64
86
  if redis.call('GET', KEYS[1]) == ARGV[1] then
65
87
  redis.call('DEL', KEYS[1])
66
88
  return 1
67
89
  end
68
90
  return 0
69
91
  LUA
92
+
93
+ # KEYS[1] = pending_writers_key
94
+ # ARGV[1] = token
95
+ # Returns 1 = cleared
96
+ CLEAR_PENDING_WRITE = <<~LUA
97
+ redis.call('ZREM', KEYS[1], ARGV[1])
98
+ return 1
99
+ LUA
100
+
101
+ # KEYS[1] = writer_key
102
+ # ARGV[1] = token, ARGV[2] = ttl (milliseconds)
103
+ # Returns 1 = refreshed, 0 = not owner
104
+ REFRESH_WRITE = <<~LUA
105
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
106
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
107
+ return 1
108
+ end
109
+ return 0
110
+ LUA
111
+
112
+ # KEYS[1] = readers_key
113
+ # ARGV[1] = token, ARGV[2] = expiry (unix ts), ARGV[3] = now (unix ts), ARGV[4] = ttl (ms)
114
+ # Returns 1 = refreshed, 0 = not in set
115
+ REFRESH_READ = <<~LUA
116
+ local readers_key = KEYS[1]
117
+ local token = ARGV[1]
118
+ local expiry = tonumber(ARGV[2])
119
+ local now = tonumber(ARGV[3])
120
+ local ttl_ms = tonumber(ARGV[4])
121
+
122
+ if redis.call('ZSCORE', readers_key, token) == false then
123
+ return 0
124
+ end
125
+
126
+ redis.call('ZADD', readers_key, expiry, token)
127
+
128
+ local current_pttl = redis.call('PTTL', readers_key)
129
+ if current_pttl == -1 or current_pttl < ttl_ms then
130
+ redis.call('PEXPIRE', readers_key, ttl_ms)
131
+ end
132
+
133
+ return 1
134
+ LUA
70
135
  end
71
136
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
4
  class ReadLock < BaseLock
3
5
  def release
@@ -8,16 +10,24 @@ module RedisReadWriteLocks
8
10
  true
9
11
  end
10
12
 
13
+ def refresh
14
+ return false unless @acquired
15
+
16
+ now = Time.now.to_i
17
+ expiry = now + (@ttl / 1000.0).ceil
18
+ eval_script(LockScripts::REFRESH_READ, keys: [readers_key], argv: [@token, expiry, now, @ttl]) == 1
19
+ end
20
+
11
21
  private
12
22
 
13
23
  def try_acquire
14
24
  now = Time.now.to_i
15
- expiry = now + @ttl / 1000
25
+ expiry = now + (@ttl / 1000.0).ceil
16
26
 
17
27
  result = eval_script(
18
28
  LockScripts::ACQUIRE_READ,
19
- keys: [writer_key, readers_key],
20
- argv: [@token, expiry, now],
29
+ keys: [writer_key, readers_key, pending_writers_key],
30
+ argv: [@token, expiry, now, @ttl]
21
31
  )
22
32
 
23
33
  @acquired = result == 1
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
3
5
  end
@@ -1,5 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RedisReadWriteLocks
2
4
  class WriteLock < BaseLock
5
+ def initialize(redis:, name:, ttl: DEFAULT_TTL, prefer_writer: false)
6
+ super(redis: redis, name: name, ttl: ttl)
7
+ @prefer_writer = prefer_writer
8
+ end
9
+
3
10
  def release
4
11
  return false unless @acquired
5
12
 
@@ -8,16 +15,34 @@ module RedisReadWriteLocks
8
15
  true
9
16
  end
10
17
 
18
+ def refresh
19
+ return false unless @acquired
20
+
21
+ eval_script(LockScripts::REFRESH_WRITE, keys: [writer_key], argv: [@token, @ttl]) == 1
22
+ end
23
+
11
24
  private
12
25
 
13
26
  def try_acquire
27
+ now = Time.now.to_i
28
+ pending_ttl_ms = [PENDING_WRITER_TTL, @retry_delay.to_i * 3].max
29
+ pending_expiry = now + (pending_ttl_ms / 1000.0).ceil
30
+
14
31
  result = eval_script(
15
32
  LockScripts::ACQUIRE_WRITE,
16
- keys: [writer_key, readers_key],
17
- argv: [@token, @ttl / 1000, Time.now.to_i],
33
+ keys: [writer_key, readers_key, pending_writers_key],
34
+ argv: [
35
+ @token, @ttl, now, @prefer_writer ? 1 : 0, pending_expiry, pending_ttl_ms
36
+ ]
18
37
  )
19
38
 
20
39
  @acquired = result == 1
21
40
  end
41
+
42
+ def abandon_pending
43
+ return unless @prefer_writer
44
+
45
+ eval_script(LockScripts::CLEAR_PENDING_WRITE, keys: [pending_writers_key], argv: [@token])
46
+ end
22
47
  end
23
48
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "redis_read_write_locks/version"
2
4
  require_relative "redis_read_write_locks/errors"
3
5
  require_relative "redis_read_write_locks/lock_scripts"
@@ -18,4 +18,6 @@ Gem::Specification.new do |spec|
18
18
  spec.add_development_dependency "redis-client"
19
19
  spec.add_development_dependency "rspec", "~> 3.0"
20
20
  spec.add_development_dependency "rake", "~> 13.0"
21
+ spec.add_development_dependency "rubocop", "~> 1.0"
22
+ spec.add_development_dependency "rubocop-rspec", "~> 3.0"
21
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-read-write-locks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Umbrellio
@@ -65,6 +65,34 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
68
96
  description: Redis-backed distributed read-write locks. Multiple concurrent readers,
69
97
  exclusive writers.
70
98
  email: