redis_queued_locks 1.16.2 → 1.18.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +281 -0
  3. data/.claude/rules/acquirer.md +81 -0
  4. data/.claude/rules/arguments.md +60 -0
  5. data/.claude/rules/git.md +46 -0
  6. data/.claude/rules/logic.md +63 -0
  7. data/.claude/rules/swarm.md +120 -0
  8. data/.claude/rules/tests.md +48 -0
  9. data/.claude/rules/type-checking.md +50 -0
  10. data/.claude/rules/visitors.md +69 -0
  11. data/.rubocop.rbs.yml +31 -0
  12. data/.rubocop.yml +3 -3
  13. data/.ruby-version +1 -1
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +36 -0
  16. data/README.md +367 -82
  17. data/Rakefile +31 -2
  18. data/github_ci/.keep +0 -0
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/delay_execution.rb +2 -2
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +6 -3
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +9 -7
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +20 -10
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +34 -16
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +240 -28
  25. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +398 -132
  26. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +12 -6
  27. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +33 -9
  28. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +73 -24
  29. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +9 -1
  30. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +182 -3
  31. data/lib/redis_queued_locks/acquirer/is_locked.rb +19 -2
  32. data/lib/redis_queued_locks/acquirer/is_queued.rb +6 -2
  33. data/lib/redis_queued_locks/acquirer/lock_info.rb +96 -6
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +8 -4
  35. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -7
  36. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +126 -29
  37. data/lib/redis_queued_locks/acquirer/locks.rb +103 -6
  38. data/lib/redis_queued_locks/acquirer/queue_info.rb +38 -9
  39. data/lib/redis_queued_locks/acquirer/queues.rb +28 -4
  40. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +35 -1
  41. data/lib/redis_queued_locks/acquirer/release_lock.rb +40 -9
  42. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +43 -3
  43. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +143 -0
  44. data/lib/redis_queued_locks/acquirer.rb +1 -0
  45. data/lib/redis_queued_locks/client.rb +274 -158
  46. data/lib/redis_queued_locks/config/dsl.rb +4 -10
  47. data/lib/redis_queued_locks/resource.rb +117 -9
  48. data/lib/redis_queued_locks/swarm/flush_zombies.rb +46 -6
  49. data/lib/redis_queued_locks/swarm/supervisor.rb +1 -1
  50. data/lib/redis_queued_locks/swarm/swarm_element/isolated.rb +166 -44
  51. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +45 -47
  52. data/lib/redis_queued_locks/swarm/zombie_info.rb +42 -0
  53. data/lib/redis_queued_locks/version.rb +2 -2
  54. data/rbs_collection.lock.yaml +10 -2
  55. data/rbs_collection.yaml +0 -2
  56. data/redis_queued_locks.gemspec +1 -1
  57. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -1
  58. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +0 -1
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +5 -0
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +12 -6
  61. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +74 -7
  62. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +4 -2
  63. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -0
  64. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_with_expire/log_visitor.rbs +4 -2
  65. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +19 -1
  66. data/sig/redis_queued_locks/acquirer/lock_info.rbs +9 -1
  67. data/sig/redis_queued_locks/acquirer/locks.rbs +10 -1
  68. data/sig/redis_queued_locks/acquirer/queue_info.rbs +2 -4
  69. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  70. data/sig/redis_queued_locks/acquirer/release_lock.rbs +8 -1
  71. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +44 -0
  72. data/sig/redis_queued_locks/client.rbs +22 -2
  73. data/sig/redis_queued_locks/resource.rbs +9 -2
  74. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +1 -1
  75. data/sig/redis_queued_locks/swarm/swarm_element/isolated.rbs +13 -5
  76. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +6 -6
  77. data/sig/redis_queued_locks/swarm/zombie_info.rbs +5 -0
  78. metadata +17 -5
  79. data/github_ci/ruby3.3.gemfile +0 -17
  80. data/github_ci/ruby3.3.gemfile.lock +0 -216
@@ -1,8 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Read/Write locks (`read_write_mode`):
4
+ # - `:write` lock is exclusive: it is obtained when there is no write lock and no live read lock;
5
+ # - `:read` lock is shared: it is obtained when there is no write lock;
6
+ # - read locks are stored in the readers registry: a sorted set where the member is an acquirer
7
+ # id and the score is the read lock expiration time (redis server time, in milliseconds);
8
+ # - each read lock has its own data hash (the same structure as the write lock: acquirer, host,
9
+ # timestamp, ttl, meta, reentrant lock data) with the read lock ttl; the readers registry is
10
+ # the only source of truth for the read lock existence (the data hash is a data carrier only);
11
+ # - mutual exclusion relies on the optimistic transactions only (WATCH/MULTI):
12
+ # - readers WATCH the write lock key: a new write lock invalidates the reader's transaction;
13
+ # - writers WATCH the write lock key and the readers registry: a new read lock invalidates
14
+ # the writer's transaction;
15
+ # - readers do not WATCH the readers registry so they never invalidate each other;
16
+ # - lock queues are responsible for the order only (`:queued` access strategy,
17
+ # FIFO between modes):
18
+ # - write lock request should be the first in the write lock queue (the classic lock queue)
19
+ # and should wait for all earlier read lock requests;
20
+ # - read lock request should wait for all earlier write lock requests;
21
+ # - `:random` access strategy ignores both queues (no ordering between modes, so
22
+ # a continuous flow of readers can starve writers);
23
+ #
3
24
  # @api private
4
25
  # @since 1.0.0
5
- # @version 1.7.0
26
+ # @version 1.18.0
6
27
  # rubocop:disable Metrics/ModuleLength
7
28
  module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
8
29
  require_relative 'try_to_lock/log_visitor'
@@ -20,10 +41,11 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
20
41
  # @param logger [::Logger,#debug]
21
42
  # @param log_lock_try [Boolean]
22
43
  # @param lock_key [String]
23
- # @param read_write_mode [Symbol]
24
- # @param lock_key_queue [String]
25
- # @param read_lock_key_queue [String]
26
- # @param write_lock_key_queue [String]
44
+ # @param read_write_mode [Symbol] `:read` or `:write`
45
+ # @param lock_key_queue [String] Queue of write lock requests.
46
+ # @param read_lock_key_queue [String] Queue of read lock requests.
47
+ # @param lock_readers_key [String] Registry of read lock holders.
48
+ # @param read_lock_key [String] Read lock data of the current acquirer.
27
49
  # @param acquirer_id [String]
28
50
  # @param host_id [String]
29
51
  # @param acquirer_position [Numeric]
@@ -39,7 +61,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
39
61
  #
40
62
  # @api private
41
63
  # @since 1.0.0
42
- # @version 1.13.0
64
+ # @version 1.18.0
43
65
  # rubocop:disable Metrics/MethodLength
44
66
  def try_to_lock(
45
67
  redis,
@@ -49,7 +71,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
49
71
  read_write_mode,
50
72
  lock_key_queue,
51
73
  read_lock_key_queue,
52
- write_lock_key_queue,
74
+ lock_readers_key,
75
+ read_lock_key,
53
76
  acquirer_id,
54
77
  host_id,
55
78
  acquirer_position,
@@ -69,44 +92,89 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
69
92
  timestamp = nil
70
93
  # @type var spc_processed_timestamp: Float?
71
94
  spc_processed_timestamp = nil
95
+ # Step RW: the mode of the lock that is really held by the acquirer after the attempt
96
+ # (the requested mode or the mode of the already obtained lock for same-process conflicts);
97
+ # @type var held_rw_mode: Symbol
98
+ held_rw_mode = read_write_mode
99
+ # Step RW: read lock request flag
100
+ read_mode = (read_write_mode == :read)
72
101
 
73
102
  LogVisitor.start(
74
103
  logger, log_sampled, log_lock_try, lock_key,
75
- queue_ttl, acquirer_id, host_id, access_strategy
104
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
76
105
  )
77
106
 
78
107
  # Step X: start to work with lock acquiring
79
108
  result = redis.with do |rconn|
80
109
  LogVisitor.rconn_fetched(
81
110
  logger, log_sampled, log_lock_try, lock_key,
82
- queue_ttl, acquirer_id, host_id, access_strategy
111
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
83
112
  )
84
113
 
85
114
  # Step 0:
86
115
  # watch the lock key changes (and discard acquirement if lock is already
87
116
  # obtained by another acquirer during the current lock acquiremntt)
88
- rconn.multi(watch: [lock_key]) do |transact|
89
- # SP-Conflict status PREPARING: get the current lock obtainer
90
- current_lock_obtainer = rconn.call('HGET', lock_key, 'acq_id')
117
+ # Step 0 (RW):
118
+ # - write lock request watches the readers registry too (and discards acquirement if
119
+ # a read lock is obtained during the current lock acquirement);
120
+ # - read lock request does not watch the readers registry (readers should not invalidate
121
+ # transactions of each other);
122
+ rconn.multi(watch: (read_mode ? [lock_key] : [lock_key, lock_readers_key])) do |transact|
123
+ # Step 0.1: fetch the current lock state in one round trip:
124
+ # - current write lock obtainer;
125
+ # - redis server time (read lock expiration is calculated in redis time);
126
+ # - read lock expiration of the current acquirer;
127
+ # - the longest read lock (the last one in the readers registry);
128
+ # @type var lock_state: [String?, Array[String], Float?, Array[[String, Float]]]
129
+ lock_state = rconn.pipelined do |pipeline|
130
+ pipeline.call('HGET', lock_key, 'acq_id')
131
+ pipeline.call('TIME')
132
+ pipeline.call('ZSCORE', lock_readers_key, acquirer_id)
133
+ pipeline.call('ZRANGE', lock_readers_key, '-1', '-1', 'WITHSCORES')
134
+ end
135
+ current_lock_obtainer = lock_state[0]
136
+ redis_time = RedisQueuedLocks::Resource.redis_time_ms(lock_state[1])
137
+ own_read_lock_expiration = lock_state[2]
138
+ longest_read_lock = lock_state[3].first
139
+ # NOTE:
140
+ # - expired read locks are ignored (they can live in the registry till the cleanup);
141
+ # - nil expiration (no read lock) is converted to 0.0 (always expired);
142
+ read_locked_by_acquirer = own_read_lock_expiration.to_f > redis_time
143
+ read_locked = longest_read_lock != nil && longest_read_lock[1] > redis_time
144
+
145
+ # SP-Conflict status PREPARING: the mode of the lock (already obtained by the current
146
+ # acquirer) that the current lock request conflicts with;
147
+ sp_conflict_rw_mode =
148
+ if current_lock_obtainer != nil && acquirer_id == current_lock_obtainer
149
+ :write
150
+ elsif read_locked_by_acquirer
151
+ :read
152
+ end
91
153
  # SP-Conflict status PREPARING: status flag variable
92
154
  sp_conflict_status = nil
93
155
 
94
156
  # SP-Conflict Step X1: calculate the current deadlock status
95
- if current_lock_obtainer != nil && acquirer_id == current_lock_obtainer
157
+ if sp_conflict_rw_mode != nil
96
158
  LogVisitor.same_process_conflict_detected(
97
159
  logger, log_sampled, log_lock_try, lock_key,
98
- queue_ttl, acquirer_id, host_id, access_strategy
160
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
99
161
  )
100
162
 
163
+ # NOTE:
164
+ # read-to-write lock upgrade can not "work through": the read lock is not exclusive
165
+ # so the "work through" logic will work without an exclusive lock;
166
+ lock_upgrade = !read_mode && sp_conflict_rw_mode == :read
167
+
101
168
  # SP-Conflict Step X2: self-process dead lock moment started.
102
169
  # SP-Conflict CHECK (Step CHECK): check chosen strategy and flag the current status
103
170
  case conflict_strategy
104
171
  when :work_through
105
172
  # <SP-Conflict Moment>: work through => exit
106
- sp_conflict_status = :conflict_work_through
173
+ sp_conflict_status = lock_upgrade ? :conflict_lock_upgrade : :conflict_work_through
107
174
  when :extendable_work_through
108
175
  # <SP-Conflict Moment>: extendable_work_through => extend the lock pttl and exit
109
- sp_conflict_status = :extendable_conflict_work_through
176
+ sp_conflict_status =
177
+ lock_upgrade ? :conflict_lock_upgrade : :extendable_conflict_work_through
110
178
  when :wait_for_lock
111
179
  # <SP-Conflict Moment>: wait_for_lock => obtain a lock in classic way
112
180
  sp_conflict_status = :conflict_wait_for_lock
@@ -122,7 +190,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
122
190
  end
123
191
  LogVisitor.same_process_conflict_analyzed(
124
192
  logger, log_sampled, log_lock_try, lock_key,
125
- queue_ttl, acquirer_id, host_id, access_strategy, sp_conflict_status
193
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode, sp_conflict_status
126
194
  )
127
195
  end
128
196
 
@@ -131,68 +199,113 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
131
199
  # SP-Conflict-Step FINAL (SPCF): extend the lock and work through
132
200
  # - extend the lock ttl;
133
201
  # - store extensions in lock metadata;
134
- # SPCF Step 1: extend the lock pttl
135
- # - [REDIS RESULT]: in normal cases should return the last script command value
136
- # - for the current script should return:
137
- # <1> => timeout was set;
138
- # <0> => timeount was not set;
139
- transact.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, ttl)
140
- # SPCF Step <Meta>: store conflict-state additionals in lock metadata:
141
- # SPCF Step 2: (lock meta-data)
142
- # - add the added ttl to reflect the real lock TTL in info;
143
- # - [REDIS RESULT]: in normal cases should return the value of <ttl> key
144
- # - for non-existent key value starts from <0> (zero)
145
- transact.call('HINCRBY', lock_key, 'spc_ext_ttl', ttl)
146
- # SPCF Step 3: (lock meta-data)
147
- # - increment the conflcit counter in order to remember
148
- # how many times dead lock happened;
149
- # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
150
- # - for non-existent key starts from 0
151
- transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
152
- # SPCF Step 4: (lock meta-data)
153
- # - remember the last ext-timestamp and the last ext-initial ttl;
154
- # - [REDIS RESULT]: for normal cases should return the number of fields
155
- # were added/changed;
156
- transact.call(
157
- 'HSET',
158
- lock_key,
159
- 'l_spc_ext_ts', spc_processed_timestamp = Time.now.to_f,
160
- 'l_spc_ext_ini_ttl', ttl
161
- )
202
+ if sp_conflict_rw_mode == :write
203
+ held_rw_mode = :write
204
+
205
+ # SPCF Step 1: extend the lock pttl
206
+ # - [REDIS RESULT]: in normal cases should return the last script command value
207
+ # - for the current script should return:
208
+ # <1> => timeout was set;
209
+ # <0> => timeount was not set;
210
+ transact.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, ttl)
211
+ # SPCF Step <Meta>: store conflict-state additionals in lock metadata:
212
+ # SPCF Step 2: (lock meta-data)
213
+ # - add the added ttl to reflect the real lock TTL in info;
214
+ # - [REDIS RESULT]: in normal cases should return the value of <ttl> key
215
+ # - for non-existent key value starts from <0> (zero)
216
+ transact.call('HINCRBY', lock_key, 'spc_ext_ttl', ttl)
217
+ # SPCF Step 3: (lock meta-data)
218
+ # - increment the conflcit counter in order to remember
219
+ # how many times dead lock happened;
220
+ # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
221
+ # - for non-existent key starts from 0
222
+ transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
223
+ # SPCF Step 4: (lock meta-data)
224
+ # - remember the last ext-timestamp and the last ext-initial ttl;
225
+ # - [REDIS RESULT]: for normal cases should return the number of fields
226
+ # were added/changed;
227
+ transact.call(
228
+ 'HSET',
229
+ lock_key,
230
+ 'l_spc_ext_ts', spc_processed_timestamp = Time.now.to_f,
231
+ 'l_spc_ext_ini_ttl', ttl
232
+ )
233
+ else
234
+ held_rw_mode = :read
235
+ # SPCF Step 1 (read lock): extend the read lock of the current acquirer
236
+ # - [REDIS RESULT]: the new read lock expiration time
237
+ # (or nil if the read lock has been removed from the registry);
238
+ transact.call('ZADD', lock_readers_key, 'XX', 'INCR', ttl, acquirer_id)
239
+ # SPCF Step 2 (read lock): the registry should live as long as the longest read lock
240
+ # - NX + GT => sets the registry TTL to max(current TTL, extended read lock TTL);
241
+ extended_read_lock_ttl = (own_read_lock_expiration.to_f + ttl.to_i - redis_time).ceil
242
+ transact.call('PEXPIRE', lock_readers_key, extended_read_lock_ttl, 'NX')
243
+ transact.call('PEXPIRE', lock_readers_key, extended_read_lock_ttl, 'GT')
244
+ # SPCF Step 3 (read lock data): the same reentrant lock data as for the write lock
245
+ transact.call('HINCRBY', read_lock_key, 'spc_ext_ttl', ttl)
246
+ transact.call('HINCRBY', read_lock_key, 'spc_cnt', 1)
247
+ transact.call(
248
+ 'HSET',
249
+ read_lock_key,
250
+ 'l_spc_ext_ts', spc_processed_timestamp = Time.now.to_f,
251
+ 'l_spc_ext_ini_ttl', ttl
252
+ )
253
+ # SPCF Step 4 (read lock data): the read lock data lives as long as the read lock
254
+ transact.call('PEXPIRE', read_lock_key, extended_read_lock_ttl, 'NX')
255
+ transact.call('PEXPIRE', read_lock_key, extended_read_lock_ttl, 'GT')
256
+ end
162
257
  inter_result = :extendable_conflict_work_through
163
258
 
164
- # @type var sp_conflict_status: Symbol
165
259
  # @type var spc_processed_timestamp: Float
166
260
  LogVisitor.reentrant_lock__extend_and_work_through(
167
261
  logger, log_sampled, log_lock_try, lock_key,
168
- queue_ttl, acquirer_id, host_id, access_strategy,
262
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
169
263
  sp_conflict_status, ttl, spc_processed_timestamp
170
264
  )
171
265
  # SP-Conflict-Step X2: switch to dead lock logic or not
172
266
  elsif sp_conflict_status == :conflict_work_through
173
267
  inter_result = :conflict_work_through
174
268
 
175
- # SPCF Step X: (lock meta-data)
176
- # - increment the conflcit counter in order to remember
177
- # how many times dead lock happened;
178
- # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
179
- # - for non-existent key starts from 0
180
- transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
181
- # SPCF Step 4: (lock meta-data)
182
- # - remember the last ext-timestamp and the last ext-initial ttl;
183
- # - [REDIS RESULT]: for normal cases should return the number of fields
184
- # were added/changed;
185
- transact.call(
186
- 'HSET',
187
- lock_key,
188
- 'l_spc_ts', spc_processed_timestamp = Time.now.to_f
189
- )
269
+ if sp_conflict_rw_mode == :write
270
+ held_rw_mode = :write
271
+
272
+ # SPCF Step X: (lock meta-data)
273
+ # - increment the conflcit counter in order to remember
274
+ # how many times dead lock happened;
275
+ # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
276
+ # - for non-existent key starts from 0
277
+ transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
278
+ # SPCF Step 4: (lock meta-data)
279
+ # - remember the last ext-timestamp and the last ext-initial ttl;
280
+ # - [REDIS RESULT]: for normal cases should return the number of fields
281
+ # were added/changed;
282
+ transact.call(
283
+ 'HSET',
284
+ lock_key,
285
+ 'l_spc_ts', spc_processed_timestamp = Time.now.to_f
286
+ )
287
+ else
288
+ held_rw_mode = :read
289
+
290
+ # SPCF Step X (read lock data): the same reentrant lock data as for the write lock
291
+ transact.call('HINCRBY', read_lock_key, 'spc_cnt', 1)
292
+ transact.call(
293
+ 'HSET', read_lock_key, 'l_spc_ts', spc_processed_timestamp = Time.now.to_f
294
+ )
295
+ # SPCF Step X (read lock data): (NX) the read lock data should not outlive the read lock
296
+ # (it is the case when the read lock data is dropped during the read lock life);
297
+ transact.call(
298
+ 'PEXPIRE',
299
+ read_lock_key,
300
+ (own_read_lock_expiration.to_f - redis_time).ceil,
301
+ 'NX'
302
+ )
303
+ end
190
304
 
191
- # @type var sp_conflict_status: Symbol
192
305
  # @type var spc_processed_timestamp: Float
193
306
  LogVisitor.reentrant_lock__work_through(
194
307
  logger, log_sampled, log_lock_try, lock_key,
195
- queue_ttl, acquirer_id, host_id, access_strategy,
308
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
196
309
  sp_conflict_status, spc_processed_timestamp
197
310
  )
198
311
  # SP-Conflict-Step X2: switch to dead lock logic or not
@@ -200,20 +313,39 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
200
313
  inter_result = :conflict_dead_lock
201
314
  spc_processed_timestamp = Time.now.to_f
202
315
 
203
- # @type var sp_conflict_status: Symbol
204
316
  # @type var spc_processed_timestamp: Float
205
317
  LogVisitor.single_process_lock_conflict__dead_lock(
206
318
  logger, log_sampled, log_lock_try, lock_key,
207
- queue_ttl, acquirer_id, host_id, access_strategy,
319
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
320
+ sp_conflict_status, spc_processed_timestamp
321
+ )
322
+ # SP-Conflict-Step X2 (RW): read-to-write lock upgrade is not supported => exit and fail
323
+ elsif sp_conflict_status == :conflict_lock_upgrade
324
+ inter_result = :conflict_lock_upgrade
325
+ spc_processed_timestamp = Time.now.to_f
326
+
327
+ # @type var spc_processed_timestamp: Float
328
+ LogVisitor.single_process_lock_conflict__lock_upgrade(
329
+ logger, log_sampled, log_lock_try, lock_key,
330
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
208
331
  sp_conflict_status, spc_processed_timestamp
209
332
  )
210
333
  # Reached the SP-Non-Conflict Mode (NOTE):
211
334
  # - in other sp-conflict cases we are in <wait_for_lock> (non-conflict) status and should
212
335
  # continue to work in classic way (next lines of code):
213
- elsif fail_fast && current_lock_obtainer != nil # Fast-Step X0: fail-fast check
336
+ # Fast-Step X0: fail-fast check
337
+ # - write lock request: the lock is obtained by a writer or by readers;
338
+ # - read lock request: the lock is obtained by a writer (or by the current acquirer);
339
+ elsif fail_fast && (
340
+ current_lock_obtainer != nil || (read_mode ? read_locked_by_acquirer : read_locked)
341
+ )
214
342
  # Fast-Step X1: lock is already obtained. fail fast leads to "no try".
215
343
  inter_result = :fail_fast_no_try
216
344
  else
345
+ # Step RW: request queue of the current mode and the queue of the opposite mode
346
+ request_queue = read_mode ? read_lock_key_queue : lock_key_queue
347
+ opposite_request_queue = read_mode ? lock_key_queue : read_lock_key_queue
348
+
217
349
  # Step 1: add an acquirer to the lock acquirement queue
218
350
  # NOTE:
219
351
  # 'NX' means "Only add new elements. Don't update already existing elements."
@@ -221,97 +353,185 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
221
353
  # 1. (enqueue) <<if you are already in the queue - do nothing and wait your time>>
222
354
  # 2. (requeue) or <<add to the right pre-calculated position if you are
223
355
  # not in the queue now>>;
224
- rconn.call('ZADD', lock_key_queue, 'NX', acquirer_position, acquirer_id)
356
+ rconn.call('ZADD', request_queue, 'NX', acquirer_position, acquirer_id)
225
357
 
226
358
  LogVisitor.acq_added_to_queue(
227
359
  logger, log_sampled, log_lock_try, lock_key,
228
- queue_ttl, acquirer_id, host_id, access_strategy
360
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
229
361
  )
230
362
 
231
- # Step 2.1: drop expired acquirers from the lock queue
232
- rconn.call(
233
- 'ZREMRANGEBYSCORE',
234
- lock_key_queue,
235
- '-inf',
236
- RedisQueuedLocks::Resource.acquirer_dead_score(queue_ttl)
237
- )
363
+ # Step 2.1: drop expired acquirers from the lock queues
364
+ # - (RW) both queues are cleared: an expired request of the opposite mode should not
365
+ # block the current request forever;
366
+ acquirer_dead_score = RedisQueuedLocks::Resource.acquirer_dead_score(queue_ttl)
367
+ rconn.pipelined do |pipeline|
368
+ pipeline.call('ZREMRANGEBYSCORE', request_queue, '-inf', acquirer_dead_score)
369
+ pipeline.call('ZREMRANGEBYSCORE', opposite_request_queue, '-inf', acquirer_dead_score)
370
+ end
238
371
 
239
372
  LogVisitor.remove_expired_acqs(
240
373
  logger, log_sampled, log_lock_try, lock_key,
241
- queue_ttl, acquirer_id, host_id, access_strategy
374
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
242
375
  )
243
376
 
244
377
  # Step 3: get the actual acquirer waiting in the queue
245
- waiting_acquirer = Array(rconn.call('ZRANGE', lock_key_queue, '0', '0')).first
378
+ # - (RW) and the first request of the opposite mode;
379
+ # @type var queue_heads: [Array[String], Array[[String, Float]]]
380
+ queue_heads = rconn.pipelined do |pipeline|
381
+ pipeline.call('ZRANGE', request_queue, '0', '0')
382
+ pipeline.call('ZRANGE', opposite_request_queue, '0', '0', 'WITHSCORES')
383
+ end
384
+ waiting_acquirer = queue_heads[0].first
385
+ opposite_waiting_request = queue_heads[1].first
246
386
 
247
387
  LogVisitor.get_first_from_queue(
248
388
  logger, log_sampled, log_lock_try, lock_key,
249
- queue_ttl, acquirer_id, host_id, access_strategy, waiting_acquirer
389
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode, waiting_acquirer
250
390
  )
251
391
 
392
+ # Step 3.1 (RW): the opposite mode request that should obtain the lock earlier
393
+ # - FIFO between modes (`:queued` access strategy only);
394
+ # - equal positions are ordered by acquirer ids;
395
+ # @type var opposite_request_ahead: String?
396
+ opposite_request_ahead =
397
+ if access_strategy == :queued && opposite_waiting_request != nil &&
398
+ (opposite_waiting_request[1] < acquirer_position ||
399
+ (opposite_waiting_request[1] == acquirer_position &&
400
+ opposite_waiting_request[0] < acquirer_id))
401
+ opposite_waiting_request[0]
402
+ end
403
+
252
404
  # Step PRE-4.x: check if the request time limit is reached
253
405
  # (when the current try self-removes itself from queue (queue ttl has come))
254
406
  if waiting_acquirer == nil
255
407
  LogVisitor.exit__queue_ttl_reached(
256
408
  logger, log_sampled, log_lock_try, lock_key,
257
- queue_ttl, acquirer_id, host_id, access_strategy
409
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
258
410
  )
259
411
 
260
412
  inter_result = :dead_score_reached
261
413
  # Step STRATEGY: check the stragegy and corresponding preventing factor
262
414
  # Step STRATEGY (queued): check the actual acquirer: is it ours? are we aready to lock?
263
- elsif access_strategy == :queued && waiting_acquirer != acquirer_id
415
+ # - (RW) write lock requests are processed one by one;
416
+ # - (RW) read lock requests are processed in parallel;
417
+ elsif !read_mode && access_strategy == :queued && waiting_acquirer != acquirer_id
264
418
  # Step ROLLBACK 1.1: our time hasn't come yet. retry!
265
419
 
266
420
  LogVisitor.exit__no_first(
267
421
  logger, log_sampled, log_lock_try, lock_key,
268
- queue_ttl, acquirer_id, host_id, access_strategy, waiting_acquirer,
269
- rconn.call('HGETALL', lock_key).to_h
422
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode, waiting_acquirer,
423
+ # NOTE: lock data is extracted for logs only
424
+ ((log_sampled && log_lock_try) ? rconn.call('HGETALL', lock_key).to_h : {})
270
425
  )
271
426
  inter_result = :acquirer_is_not_first_in_queue
272
- # Step STRAGEY: successfull (:queued OR :random)
273
- elsif (access_strategy == :queued && waiting_acquirer == acquirer_id) ||
274
- (access_strategy == :random)
275
- # NOTE: our time has come! let's try to acquire the lock!
276
-
277
- # Step 5: find the lock -> check if the our lock is already acquired
278
- locked_by_acquirer = rconn.call('HGET', lock_key, 'acq_id')
279
-
280
- if locked_by_acquirer
281
- # Step ROLLBACK 2: required lock is stil acquired. retry!
282
-
283
- LogVisitor.exit__lock_still_obtained(
427
+ elsif opposite_request_ahead
428
+ # Step ROLLBACK 1.2 (RW): an earlier request of the opposite mode goes first. retry!
429
+ if read_mode
430
+ LogVisitor.exit__write_request_ahead(
284
431
  logger, log_sampled, log_lock_try, lock_key,
285
- queue_ttl, acquirer_id, host_id, access_strategy,
286
- waiting_acquirer, locked_by_acquirer,
287
- rconn.call('HGETALL', lock_key).to_h
432
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
433
+ opposite_request_ahead
288
434
  )
289
- inter_result = :lock_is_still_acquired
435
+ inter_result = :write_request_is_ahead
290
436
  else
291
- # NOTE: required lock is free and ready to be acquired! acquire!
292
-
293
- # Step 6.1: remove our acquirer from waiting queue
294
- transact.call('ZREM', lock_key_queue, acquirer_id)
295
-
296
- # Step 6.2: acquire a lock and store an info about the acquirer and host
297
- transact.call(
298
- 'HSET',
299
- lock_key,
300
- 'acq_id', acquirer_id,
301
- 'hst_id', host_id,
302
- 'ts', timestamp = Time.now.to_f,
303
- 'ini_ttl', ttl,
304
- *(meta.to_a if meta != nil) # steep:ignore
305
- )
306
-
307
- # Step 6.3: set the lock expiration time in order to prevent "infinite locks"
308
- transact.call('PEXPIRE', lock_key, ttl) # NOTE: in milliseconds
309
-
310
- LogVisitor.obtain__free_to_acquire(
437
+ LogVisitor.exit__read_request_ahead(
311
438
  logger, log_sampled, log_lock_try, lock_key,
312
- queue_ttl, acquirer_id, host_id, access_strategy
439
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
440
+ opposite_request_ahead
313
441
  )
442
+ inter_result = :read_request_is_ahead
314
443
  end
444
+ # NOTE: our time has come! let's try to acquire the lock!
445
+ # Step 5: find the lock -> check if the our lock is already acquired
446
+ # - NOTE: the lock state is fetched after WATCH (step 0.1) so any lock state change
447
+ # invalidates the transaction;
448
+ elsif current_lock_obtainer
449
+ # Step ROLLBACK 2: required lock is stil acquired. retry!
450
+
451
+ LogVisitor.exit__lock_still_obtained(
452
+ logger, log_sampled, log_lock_try, lock_key,
453
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
454
+ waiting_acquirer, current_lock_obtainer,
455
+ # NOTE: lock data is extracted for logs only
456
+ ((log_sampled && log_lock_try) ? rconn.call('HGETALL', lock_key).to_h : {})
457
+ )
458
+ inter_result = :lock_is_still_acquired
459
+ elsif read_mode ? read_locked_by_acquirer : read_locked
460
+ # Step ROLLBACK 3 (RW): the lock is still obtained by readers. retry!
461
+ # - write lock request waits for all readers;
462
+ # - read lock request waits for its own read lock
463
+ # (`:wait_for_lock` conflict strategy);
464
+
465
+ LogVisitor.exit__read_lock_still_obtained(
466
+ logger, log_sampled, log_lock_try, lock_key,
467
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode, waiting_acquirer
468
+ )
469
+ inter_result = :read_lock_is_still_acquired
470
+ elsif read_mode
471
+ # NOTE: required lock is free and ready to be acquired! acquire! (read lock)
472
+
473
+ # Step 6.1: remove our acquirer from waiting queue
474
+ transact.call('ZREM', request_queue, acquirer_id)
475
+
476
+ # Step 6.2 (RW): drop expired read locks from the readers registry
477
+ transact.call('ZREMRANGEBYSCORE', lock_readers_key, '-inf', "(#{redis_time}")
478
+
479
+ # Step 6.3 (RW): register the read lock (expiration time in redis time)
480
+ transact.call('ZADD', lock_readers_key, redis_time + ttl.to_i, acquirer_id)
481
+
482
+ # Step 6.4 (RW): the registry should live as long as the longest read lock
483
+ # - NX + GT => sets the registry TTL to max(current TTL, read lock TTL);
484
+ # - in order to prevent "infinite registries" after process crashes;
485
+ transact.call('PEXPIRE', lock_readers_key, ttl, 'NX')
486
+ transact.call('PEXPIRE', lock_readers_key, ttl, 'GT')
487
+
488
+ # Step 6.5 (RW): store the read lock data (the same structure as the write lock data)
489
+ # - drop the data of the expired read lock of the current acquirer (if any);
490
+ # - the read lock data lives as long as the read lock;
491
+ transact.call('DEL', read_lock_key)
492
+ transact.call(
493
+ 'HSET',
494
+ read_lock_key,
495
+ 'acq_id', acquirer_id,
496
+ 'hst_id', host_id,
497
+ 'ts', timestamp = Time.now.to_f,
498
+ 'ini_ttl', ttl,
499
+ *(meta.to_a if meta != nil) # steep:ignore
500
+ )
501
+ transact.call('PEXPIRE', read_lock_key, ttl)
502
+
503
+ inter_result = :lock_obtaining
504
+
505
+ LogVisitor.obtain__free_to_acquire(
506
+ logger, log_sampled, log_lock_try, lock_key,
507
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
508
+ )
509
+ else
510
+ # NOTE: required lock is free and ready to be acquired! acquire! (write lock)
511
+
512
+ # Step 6.1: remove our acquirer from waiting queue
513
+ transact.call('ZREM', request_queue, acquirer_id)
514
+
515
+ # Step 6.2: acquire a lock and store an info about the acquirer and host
516
+ transact.call(
517
+ 'HSET',
518
+ lock_key,
519
+ 'acq_id', acquirer_id,
520
+ 'hst_id', host_id,
521
+ 'ts', timestamp = Time.now.to_f,
522
+ 'ini_ttl', ttl,
523
+ *(meta.to_a if meta != nil) # steep:ignore
524
+ )
525
+
526
+ # Step 6.3: set the lock expiration time in order to prevent "infinite locks"
527
+ transact.call('PEXPIRE', lock_key, ttl) # NOTE: in milliseconds
528
+
529
+ inter_result = :lock_obtaining
530
+
531
+ LogVisitor.obtain__free_to_acquire(
532
+ logger, log_sampled, log_lock_try, lock_key,
533
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
534
+ )
315
535
  end
316
536
  end
317
537
  end
@@ -327,7 +547,29 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
327
547
  # - if <result == nil> => the lock was changed during an extention:
328
548
  # it is the fail case => go retry.
329
549
  # - else: let's go! :))
330
- if result.is_a?(::Array) && result.size == 4 # NOTE: four commands should be processed
550
+ if result.is_a?(::Array) && !result.empty? && held_rw_mode == :read
551
+ # NOTE: read lock extension commands:
552
+ # 1. ZADD XX INCR (new read lock expiration) (OK for != nil)
553
+ # 2. PEXPIRE NX/GT (registry TTL)
554
+ # 3. HINCRBY/HSET/PEXPIRE (read lock data)
555
+ if result[0] != nil
556
+ {
557
+ ok: true,
558
+ result: {
559
+ process: :extendable_conflict_work_through,
560
+ lock_key: lock_key,
561
+ acq_id: acquirer_id,
562
+ hst_id: host_id,
563
+ ts: spc_processed_timestamp,
564
+ ttl: ttl,
565
+ rw_mode: held_rw_mode
566
+ }
567
+ }
568
+ else
569
+ # NOTE: the read lock is expired and removed from the registry during the extension
570
+ { ok: false, result: :read_lock_is_expired_during_extension }
571
+ end
572
+ elsif result.is_a?(::Array) && result.size == 4 # NOTE: four commands should be processed
331
573
  # TODO:
332
574
  # => (!) analyze the command result and do actions with the depending on it
333
575
  # 1. EVAL (extend lock pttl) (OK for != nil)
@@ -343,7 +585,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
343
585
  acq_id: acquirer_id,
344
586
  hst_id: host_id,
345
587
  ts: spc_processed_timestamp,
346
- ttl: ttl
588
+ ttl: ttl,
589
+ rw_mode: held_rw_mode
347
590
  }
348
591
  }
349
592
  elsif result[0] != nil
@@ -357,7 +600,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
357
600
  acq_id: acquirer_id,
358
601
  hst_id: host_id,
359
602
  ts: spc_processed_timestamp,
360
- ttl: ttl
603
+ ttl: ttl,
604
+ rw_mode: held_rw_mode
361
605
  }
362
606
  }
363
607
  else
@@ -382,13 +626,18 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
382
626
  acq_id: acquirer_id,
383
627
  hst_id: host_id,
384
628
  ts: spc_processed_timestamp,
385
- ttl: ttl
629
+ ttl: ttl,
630
+ rw_mode: held_rw_mode
386
631
  }
387
632
  }
388
633
  when inter_result == :conflict_dead_lock
389
634
  # Step 7.same_process_conflict.C:
390
635
  # - deadlock. should fail in acquirement logic;
391
636
  { ok: false, result: :conflict_dead_lock }
637
+ when inter_result == :conflict_lock_upgrade
638
+ # Step 7.same_process_conflict.D (RW):
639
+ # - read-to-write lock upgrade. should fail in acquirement logic;
640
+ { ok: false, result: :conflict_lock_upgrade }
392
641
  when fail_fast && inter_result == :fail_fast_no_try
393
642
  # Step 7.a: lock is still acquired and we should exit from the logic as soon as possible
394
643
  { ok: false, result: :fail_fast_no_try }
@@ -397,23 +646,39 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
397
646
  when inter_result == :lock_is_still_acquired
398
647
  # Step 7.b: lock is still acquired by another process => failed to acquire
399
648
  { ok: false, result: :lock_is_still_acquired }
649
+ when inter_result == :read_lock_is_still_acquired
650
+ # Step 7.b (RW): lock is still acquired by readers => failed to acquire
651
+ { ok: false, result: :read_lock_is_still_acquired }
400
652
  when inter_result == :acquirer_is_not_first_in_queue
401
653
  # Step 7.c: lock is still acquired by another process => failed to acquire
402
654
  { ok: false, result: :acquirer_is_not_first_in_queue }
655
+ when inter_result == :write_request_is_ahead
656
+ # Step 7.c (RW): an earlier write lock request should obtain the lock first
657
+ { ok: false, result: :write_request_is_ahead }
658
+ when inter_result == :read_request_is_ahead
659
+ # Step 7.c (RW): an earlier read lock request should obtain the lock first
660
+ { ok: false, result: :read_request_is_ahead }
403
661
  when result == nil || (result.is_a?(::Array) && result.empty?)
404
662
  # Step 7.d: lock is already acquired durign the acquire race => failed to acquire
405
663
  { ok: false, result: :lock_is_acquired_during_acquire_race }
406
- when result.is_a?(::Array) && result.size == 3 # NOTE: 3 is a count of redis lock commands
664
+ when inter_result == :lock_obtaining && result.is_a?(::Array)
407
665
  # TODO:
408
666
  # => (!) analyze the command result and do actions with the depending on it;
409
667
  # => (*) at this moment we accept that all comamnds are completed successfully;
410
668
  # => (!) need to analyze:
411
- # 1. zrem shoud return ? (?)
412
- # 2. hset should return 3 as minimum
413
- # (lock key is added to the redis as a hashmap with 3 fields as minimum)
414
- # 3. pexpire should return 1 (expiration time is successfully applied)
669
+ # - (write lock):
670
+ # 1. zrem shoud return ? (?)
671
+ # 2. hset should return 3 as minimum
672
+ # (lock key is added to the redis as a hashmap with 3 fields as minimum)
673
+ # 3. pexpire should return 1 (expiration time is successfully applied)
674
+ # - (read lock):
675
+ # 1. zrem shoud return ? (?)
676
+ # 2. zremrangebyscore should return the number of dropped expired read locks
677
+ # 3. zadd should return 1 (new read lock) or 0 (expired read lock is re-obtained)
678
+ # 4. pexpire (NX + GT) should return 1 for one of them as minimum
679
+ # 5. del/hset/pexpire (read lock data)
415
680
 
416
- # Step 7.d: locked! :) let's go! => successfully acquired
681
+ # Step 7.e: locked! :) let's go! => successfully acquired
417
682
  {
418
683
  ok: true,
419
684
  result: {
@@ -422,7 +687,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
422
687
  acq_id: acquirer_id,
423
688
  hst_id: host_id,
424
689
  ts: timestamp,
425
- ttl: ttl
690
+ ttl: ttl,
691
+ rw_mode: held_rw_mode
426
692
  }
427
693
  }
428
694
  else
@@ -431,6 +697,6 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
431
697
  end
432
698
  # rubocop:enable Lint/DuplicateBranch
433
699
  end
434
- # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
700
+ # rubocop:enable Metrics/MethodLength
435
701
  end
436
702
  # rubocop:enable Metrics/ModuleLength