redis_queued_locks 1.17.0 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +86 -10
  3. data/.claude/rules/acquirer.md +31 -11
  4. data/.claude/rules/arguments.md +2 -1
  5. data/.claude/rules/git.md +46 -0
  6. data/.claude/rules/logic.md +9 -3
  7. data/.claude/rules/swarm.md +4 -3
  8. data/.claude/rules/tests.md +9 -3
  9. data/.claude/rules/type-checking.md +10 -2
  10. data/.claude/rules/visitors.md +12 -7
  11. data/CHANGELOG.md +62 -0
  12. data/CLAUDE.md +7 -2
  13. data/README.md +348 -79
  14. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +6 -3
  15. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +9 -7
  16. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +20 -10
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +34 -16
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +240 -28
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +398 -129
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +12 -6
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +32 -8
  22. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +73 -20
  23. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +9 -1
  24. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +182 -3
  25. data/lib/redis_queued_locks/acquirer/is_locked.rb +19 -2
  26. data/lib/redis_queued_locks/acquirer/is_queued.rb +6 -2
  27. data/lib/redis_queued_locks/acquirer/lock_info.rb +96 -6
  28. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +8 -2
  29. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -6
  30. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +126 -27
  31. data/lib/redis_queued_locks/acquirer/locks.rb +103 -6
  32. data/lib/redis_queued_locks/acquirer/queue_info.rb +38 -9
  33. data/lib/redis_queued_locks/acquirer/queues.rb +28 -4
  34. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +35 -1
  35. data/lib/redis_queued_locks/acquirer/release_lock.rb +40 -9
  36. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +43 -3
  37. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +143 -0
  38. data/lib/redis_queued_locks/acquirer.rb +1 -0
  39. data/lib/redis_queued_locks/client.rb +129 -14
  40. data/lib/redis_queued_locks/resource.rb +117 -9
  41. data/lib/redis_queued_locks/swarm/flush_zombies.rb +38 -0
  42. data/lib/redis_queued_locks/swarm/zombie_info.rb +42 -0
  43. data/lib/redis_queued_locks/version.rb +1 -1
  44. data/rbs_collection.lock.yaml +10 -2
  45. data/rbs_collection.yaml +0 -2
  46. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -1
  47. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +0 -1
  48. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +5 -0
  49. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +12 -6
  50. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +74 -7
  51. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +4 -2
  52. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -0
  53. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_with_expire/log_visitor.rbs +4 -2
  54. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +19 -1
  55. data/sig/redis_queued_locks/acquirer/lock_info.rbs +9 -1
  56. data/sig/redis_queued_locks/acquirer/locks.rbs +10 -1
  57. data/sig/redis_queued_locks/acquirer/queue_info.rbs +2 -4
  58. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  59. data/sig/redis_queued_locks/acquirer/release_lock.rbs +8 -1
  60. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +44 -0
  61. data/sig/redis_queued_locks/client.rbs +22 -2
  62. data/sig/redis_queued_locks/resource.rbs +9 -2
  63. data/sig/redis_queued_locks/swarm/zombie_info.rbs +5 -0
  64. metadata +4 -1
@@ -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,66 +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
259
  # @type var spc_processed_timestamp: Float
165
260
  LogVisitor.reentrant_lock__extend_and_work_through(
166
261
  logger, log_sampled, log_lock_try, lock_key,
167
- queue_ttl, acquirer_id, host_id, access_strategy,
262
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
168
263
  sp_conflict_status, ttl, spc_processed_timestamp
169
264
  )
170
265
  # SP-Conflict-Step X2: switch to dead lock logic or not
171
266
  elsif sp_conflict_status == :conflict_work_through
172
267
  inter_result = :conflict_work_through
173
268
 
174
- # SPCF Step X: (lock meta-data)
175
- # - increment the conflcit counter in order to remember
176
- # how many times dead lock happened;
177
- # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
178
- # - for non-existent key starts from 0
179
- transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
180
- # SPCF Step 4: (lock meta-data)
181
- # - remember the last ext-timestamp and the last ext-initial ttl;
182
- # - [REDIS RESULT]: for normal cases should return the number of fields
183
- # were added/changed;
184
- transact.call(
185
- 'HSET',
186
- lock_key,
187
- 'l_spc_ts', spc_processed_timestamp = Time.now.to_f
188
- )
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
189
304
 
190
305
  # @type var spc_processed_timestamp: Float
191
306
  LogVisitor.reentrant_lock__work_through(
192
307
  logger, log_sampled, log_lock_try, lock_key,
193
- queue_ttl, acquirer_id, host_id, access_strategy,
308
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
194
309
  sp_conflict_status, spc_processed_timestamp
195
310
  )
196
311
  # SP-Conflict-Step X2: switch to dead lock logic or not
@@ -201,16 +316,36 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
201
316
  # @type var spc_processed_timestamp: Float
202
317
  LogVisitor.single_process_lock_conflict__dead_lock(
203
318
  logger, log_sampled, log_lock_try, lock_key,
204
- 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,
205
331
  sp_conflict_status, spc_processed_timestamp
206
332
  )
207
333
  # Reached the SP-Non-Conflict Mode (NOTE):
208
334
  # - in other sp-conflict cases we are in <wait_for_lock> (non-conflict) status and should
209
335
  # continue to work in classic way (next lines of code):
210
- 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
+ )
211
342
  # Fast-Step X1: lock is already obtained. fail fast leads to "no try".
212
343
  inter_result = :fail_fast_no_try
213
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
+
214
349
  # Step 1: add an acquirer to the lock acquirement queue
215
350
  # NOTE:
216
351
  # 'NX' means "Only add new elements. Don't update already existing elements."
@@ -218,97 +353,185 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
218
353
  # 1. (enqueue) <<if you are already in the queue - do nothing and wait your time>>
219
354
  # 2. (requeue) or <<add to the right pre-calculated position if you are
220
355
  # not in the queue now>>;
221
- rconn.call('ZADD', lock_key_queue, 'NX', acquirer_position, acquirer_id)
356
+ rconn.call('ZADD', request_queue, 'NX', acquirer_position, acquirer_id)
222
357
 
223
358
  LogVisitor.acq_added_to_queue(
224
359
  logger, log_sampled, log_lock_try, lock_key,
225
- queue_ttl, acquirer_id, host_id, access_strategy
360
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
226
361
  )
227
362
 
228
- # Step 2.1: drop expired acquirers from the lock queue
229
- rconn.call(
230
- 'ZREMRANGEBYSCORE',
231
- lock_key_queue,
232
- '-inf',
233
- RedisQueuedLocks::Resource.acquirer_dead_score(queue_ttl)
234
- )
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
235
371
 
236
372
  LogVisitor.remove_expired_acqs(
237
373
  logger, log_sampled, log_lock_try, lock_key,
238
- queue_ttl, acquirer_id, host_id, access_strategy
374
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
239
375
  )
240
376
 
241
377
  # Step 3: get the actual acquirer waiting in the queue
242
- waiting_acquirer = Array(rconn.call('ZRANGE', lock_key_queue, '0', '0')).first #: String?
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
243
386
 
244
387
  LogVisitor.get_first_from_queue(
245
388
  logger, log_sampled, log_lock_try, lock_key,
246
- queue_ttl, acquirer_id, host_id, access_strategy, waiting_acquirer
389
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode, waiting_acquirer
247
390
  )
248
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
+
249
404
  # Step PRE-4.x: check if the request time limit is reached
250
405
  # (when the current try self-removes itself from queue (queue ttl has come))
251
406
  if waiting_acquirer == nil
252
407
  LogVisitor.exit__queue_ttl_reached(
253
408
  logger, log_sampled, log_lock_try, lock_key,
254
- queue_ttl, acquirer_id, host_id, access_strategy
409
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode
255
410
  )
256
411
 
257
412
  inter_result = :dead_score_reached
258
413
  # Step STRATEGY: check the stragegy and corresponding preventing factor
259
414
  # Step STRATEGY (queued): check the actual acquirer: is it ours? are we aready to lock?
260
- 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
261
418
  # Step ROLLBACK 1.1: our time hasn't come yet. retry!
262
419
 
263
420
  LogVisitor.exit__no_first(
264
421
  logger, log_sampled, log_lock_try, lock_key,
265
- queue_ttl, acquirer_id, host_id, access_strategy, waiting_acquirer,
266
- 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 : {})
267
425
  )
268
426
  inter_result = :acquirer_is_not_first_in_queue
269
- # Step STRAGEY: successfull (:queued OR :random)
270
- elsif (access_strategy == :queued && waiting_acquirer == acquirer_id) ||
271
- (access_strategy == :random)
272
- # NOTE: our time has come! let's try to acquire the lock!
273
-
274
- # Step 5: find the lock -> check if the our lock is already acquired
275
- locked_by_acquirer = rconn.call('HGET', lock_key, 'acq_id')
276
-
277
- if locked_by_acquirer
278
- # Step ROLLBACK 2: required lock is stil acquired. retry!
279
-
280
- 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(
281
431
  logger, log_sampled, log_lock_try, lock_key,
282
- queue_ttl, acquirer_id, host_id, access_strategy,
283
- waiting_acquirer, locked_by_acquirer,
284
- rconn.call('HGETALL', lock_key).to_h
432
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
433
+ opposite_request_ahead
285
434
  )
286
- inter_result = :lock_is_still_acquired
435
+ inter_result = :write_request_is_ahead
287
436
  else
288
- # NOTE: required lock is free and ready to be acquired! acquire!
289
-
290
- # Step 6.1: remove our acquirer from waiting queue
291
- transact.call('ZREM', lock_key_queue, acquirer_id)
292
-
293
- # Step 6.2: acquire a lock and store an info about the acquirer and host
294
- transact.call(
295
- 'HSET',
296
- lock_key,
297
- 'acq_id', acquirer_id,
298
- 'hst_id', host_id,
299
- 'ts', timestamp = Time.now.to_f,
300
- 'ini_ttl', ttl,
301
- *(meta.to_a if meta != nil) # steep:ignore
302
- )
303
-
304
- # Step 6.3: set the lock expiration time in order to prevent "infinite locks"
305
- transact.call('PEXPIRE', lock_key, ttl) # NOTE: in milliseconds
306
-
307
- LogVisitor.obtain__free_to_acquire(
437
+ LogVisitor.exit__read_request_ahead(
308
438
  logger, log_sampled, log_lock_try, lock_key,
309
- queue_ttl, acquirer_id, host_id, access_strategy
439
+ queue_ttl, acquirer_id, host_id, access_strategy, read_write_mode,
440
+ opposite_request_ahead
310
441
  )
442
+ inter_result = :read_request_is_ahead
311
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
+ )
312
535
  end
313
536
  end
314
537
  end
@@ -324,7 +547,29 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
324
547
  # - if <result == nil> => the lock was changed during an extention:
325
548
  # it is the fail case => go retry.
326
549
  # - else: let's go! :))
327
- 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
328
573
  # TODO:
329
574
  # => (!) analyze the command result and do actions with the depending on it
330
575
  # 1. EVAL (extend lock pttl) (OK for != nil)
@@ -340,7 +585,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
340
585
  acq_id: acquirer_id,
341
586
  hst_id: host_id,
342
587
  ts: spc_processed_timestamp,
343
- ttl: ttl
588
+ ttl: ttl,
589
+ rw_mode: held_rw_mode
344
590
  }
345
591
  }
346
592
  elsif result[0] != nil
@@ -354,7 +600,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
354
600
  acq_id: acquirer_id,
355
601
  hst_id: host_id,
356
602
  ts: spc_processed_timestamp,
357
- ttl: ttl
603
+ ttl: ttl,
604
+ rw_mode: held_rw_mode
358
605
  }
359
606
  }
360
607
  else
@@ -379,13 +626,18 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
379
626
  acq_id: acquirer_id,
380
627
  hst_id: host_id,
381
628
  ts: spc_processed_timestamp,
382
- ttl: ttl
629
+ ttl: ttl,
630
+ rw_mode: held_rw_mode
383
631
  }
384
632
  }
385
633
  when inter_result == :conflict_dead_lock
386
634
  # Step 7.same_process_conflict.C:
387
635
  # - deadlock. should fail in acquirement logic;
388
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 }
389
641
  when fail_fast && inter_result == :fail_fast_no_try
390
642
  # Step 7.a: lock is still acquired and we should exit from the logic as soon as possible
391
643
  { ok: false, result: :fail_fast_no_try }
@@ -394,23 +646,39 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
394
646
  when inter_result == :lock_is_still_acquired
395
647
  # Step 7.b: lock is still acquired by another process => failed to acquire
396
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 }
397
652
  when inter_result == :acquirer_is_not_first_in_queue
398
653
  # Step 7.c: lock is still acquired by another process => failed to acquire
399
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 }
400
661
  when result == nil || (result.is_a?(::Array) && result.empty?)
401
662
  # Step 7.d: lock is already acquired durign the acquire race => failed to acquire
402
663
  { ok: false, result: :lock_is_acquired_during_acquire_race }
403
- 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)
404
665
  # TODO:
405
666
  # => (!) analyze the command result and do actions with the depending on it;
406
667
  # => (*) at this moment we accept that all comamnds are completed successfully;
407
668
  # => (!) need to analyze:
408
- # 1. zrem shoud return ? (?)
409
- # 2. hset should return 3 as minimum
410
- # (lock key is added to the redis as a hashmap with 3 fields as minimum)
411
- # 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)
412
680
 
413
- # Step 7.d: locked! :) let's go! => successfully acquired
681
+ # Step 7.e: locked! :) let's go! => successfully acquired
414
682
  {
415
683
  ok: true,
416
684
  result: {
@@ -419,7 +687,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
419
687
  acq_id: acquirer_id,
420
688
  hst_id: host_id,
421
689
  ts: timestamp,
422
- ttl: ttl
690
+ ttl: ttl,
691
+ rw_mode: held_rw_mode
423
692
  }
424
693
  }
425
694
  else
@@ -428,6 +697,6 @@ module RedisQueuedLocks::Acquirer::AcquireLock::TryToLock
428
697
  end
429
698
  # rubocop:enable Lint/DuplicateBranch
430
699
  end
431
- # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
700
+ # rubocop:enable Metrics/MethodLength
432
701
  end
433
702
  # rubocop:enable Metrics/ModuleLength