redis_queued_locks 1.2.0 → 1.3.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.
@@ -7,6 +7,15 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
7
7
  # @since 1.0.0
8
8
  extend RedisQueuedLocks::Utilities
9
9
 
10
+ # @return [String]
11
+ #
12
+ # @api private
13
+ # @since 1.3.0
14
+ EXTEND_LOCK_PTTL = <<~LUA_SCRIPT.strip.tr("\n", '').freeze
15
+ local new_lock_pttl = redis.call("PTTL", KEYS[1]) + ARGV[1];
16
+ return redis.call("PEXPIRE", KEYS[1], new_lock_pttl);
17
+ LUA_SCRIPT
18
+
10
19
  # @param redis [RedisClient]
11
20
  # @param logger [::Logger,#debug]
12
21
  # @param log_lock_try [Boolean]
@@ -17,11 +26,13 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
17
26
  # @param ttl [Integer]
18
27
  # @param queue_ttl [Integer]
19
28
  # @param fail_fast [Boolean]
29
+ # @param conflict_strategy [Symbol]
20
30
  # @param meta [NilClass,Hash<String|Symbol,Any>]
21
31
  # @return [Hash<Symbol,Any>] Format: { ok: true/false, result: Symbol|Hash<Symbol,Any> }
22
32
  #
23
33
  # @api private
24
34
  # @since 1.0.0
35
+ # @version 1.3.0
25
36
  # rubocop:disable Metrics/MethodLength
26
37
  def try_to_lock(
27
38
  redis,
@@ -34,11 +45,13 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
34
45
  ttl,
35
46
  queue_ttl,
36
47
  fail_fast,
48
+ conflict_strategy,
37
49
  meta
38
50
  )
39
51
  # Step X: intermediate invocation results
40
52
  inter_result = nil
41
53
  timestamp = nil
54
+ spc_processed_timestamp = nil
42
55
 
43
56
  if log_lock_try
44
57
  run_non_critical do
@@ -68,9 +81,162 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
68
81
  # watch the lock key changes (and discard acquirement if lock is already
69
82
  # obtained by another acquier during the current lock acquiremntt)
70
83
  rconn.multi(watch: [lock_key]) do |transact|
71
- # Fast-Step X0: fail-fast check
72
- if fail_fast && rconn.call('HGET', lock_key, 'acq_id')
73
- # Fast-Step X1: is lock already obtained. fail fast leads to "no try".
84
+ # SP-Conflict status PREPARING: get the current lock obtainer
85
+ current_lock_obtainer = rconn.call('HGET', lock_key, 'acq_id')
86
+ # SP-Conflict status PREPARING: status flag variable
87
+ sp_conflict_status = nil
88
+
89
+ # SP-Conflict Step X1: calculate the current deadlock status
90
+ if current_lock_obtainer != nil && acquier_id == current_lock_obtainer
91
+ if log_lock_try
92
+ run_non_critical do
93
+ logger.debug do
94
+ "[redis_queued_locks.try_lock.same_process_conflict_detected] " \
95
+ "lock_key => '#{lock_key}' " \
96
+ "queue_ttl => #{queue_ttl} " \
97
+ "acq_id => '#{acquier_id}'"
98
+ end
99
+ end
100
+ end
101
+
102
+ # SP-Conflict Step X2: self-process dead lock moment started.
103
+ # SP-Conflict CHECK (Step CHECK): check chosen strategy and flag the current status
104
+ # rubocop:disable Lint/DuplicateBranch
105
+ case conflict_strategy
106
+ when :work_through
107
+ # <SP-Conflict Moment>: work through => exit
108
+ sp_conflict_status = :conflict_work_through
109
+ when :extendable_work_through
110
+ # <SP-Conflict Moment>: extendable_work_through => extend the lock pttl and exit
111
+ sp_conflict_status = :extendable_conflict_work_through
112
+ when :wait_for_lock
113
+ # <SP-Conflict Moment>: wait_for_lock => obtain a lock in classic way
114
+ sp_conflict_status = :conflict_wait_for_lock
115
+ when :dead_locking
116
+ # <SP-Conflict Moment>: dead_locking => exit and fail
117
+ sp_conflict_status = :conflict_dead_lock
118
+ else
119
+ # <SP-Conflict Moment>:
120
+ # - unknown status => work in classic way <wait_for_lock>
121
+ # - it is a case when the new status is added to the code base in the past
122
+ # but is forgotten to be added here;
123
+ sp_conflict_status = :conflict_wait_for_lock
124
+ end
125
+ # rubocop:enable Lint/DuplicateBranch
126
+
127
+ if log_lock_try
128
+ run_non_critical do
129
+ logger.debug do
130
+ "[redis_queued_locks.try_lock.same_process_conflict_analyzed] " \
131
+ "lock_key => '#{lock_key}' " \
132
+ "queue_ttl => #{queue_ttl} " \
133
+ "acq_id => '#{acquier_id}' " \
134
+ "spc_status => '#{sp_conflict_status}'"
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ # SP-Conflict-Step X2: switch to conflict-based logic or not
141
+ if sp_conflict_status == :extendable_conflict_work_through
142
+ # SP-Conflict-Step FINAL (SPCF): extend the lock and work through
143
+ # - extend the lock ttl;
144
+ # - store extensions in lock metadata;
145
+ # SPCF Step 1: extend the lock pttl
146
+ # - [REDIS RESULT]: in normal cases should return the last script command value
147
+ # - for the current script should return:
148
+ # <1> => timeout was set;
149
+ # <0> => timeount was not set;
150
+ transact.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, ttl)
151
+ # SPCF Step <Meta>: store conflict-state additionals in lock metadata:
152
+ # SPCF Step 2: (lock meta-data)
153
+ # - add the added ttl to reflect the real lock TTL in info;
154
+ # - [REDIS RESULT]: in normal cases should return the value of <ttl> key
155
+ # - for non-existent key value starts from <0> (zero)
156
+ transact.call('HINCRBY', lock_key, 'spc_ext_ttl', ttl)
157
+ # SPCF Step 3: (lock meta-data)
158
+ # - increment the conflcit counter in order to remember
159
+ # how many times dead lock happened;
160
+ # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
161
+ # - for non-existent key starts from 0
162
+ transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
163
+ # SPCF Step 4: (lock meta-data)
164
+ # - remember the last ext-timestamp and the last ext-initial ttl;
165
+ # - [REDIS RESULT]: for normal cases should return the number of fields
166
+ # were added/changed;
167
+ transact.call(
168
+ 'HSET',
169
+ lock_key,
170
+ 'l_spc_ext_ts', (spc_processed_timestamp = Time.now.to_f),
171
+ 'l_spc_ext_ini_ttl', ttl
172
+ )
173
+ inter_result = :extendable_conflict_work_through
174
+
175
+ if log_lock_try
176
+ run_non_critical do
177
+ logger.debug do
178
+ "[redis_queued_locks.try_lock.reentrant_lock__extend_and_work_through] " \
179
+ "lock_key => '#{lock_key}' " \
180
+ "queue_ttl => #{queue_ttl} " \
181
+ "acq_id => '#{acquier_id}'" \
182
+ "spc_status => '#{sp_conflict_status} '" \
183
+ "last_ext_ttl => '#{ttl}' " \
184
+ "last_ext_ts => '#{spc_processed_timestamp}'"
185
+ end
186
+ end
187
+ end
188
+ # SP-Conflict-Step X2: switch to dead lock logic or not
189
+ elsif sp_conflict_status == :conflict_work_through
190
+ inter_result = :conflict_work_through
191
+
192
+ # SPCF Step X: (lock meta-data)
193
+ # - increment the conflcit counter in order to remember
194
+ # how many times dead lock happened;
195
+ # - [REDIS RESULT]: in normal cases should return the value of <spc_cnt> key
196
+ # - for non-existent key starts from 0
197
+ transact.call('HINCRBY', lock_key, 'spc_cnt', 1)
198
+ # SPCF Step 4: (lock meta-data)
199
+ # - remember the last ext-timestamp and the last ext-initial ttl;
200
+ # - [REDIS RESULT]: for normal cases should return the number of fields
201
+ # were added/changed;
202
+ transact.call(
203
+ 'HSET',
204
+ lock_key,
205
+ 'l_spc_ts', (spc_processed_timestamp = Time.now.to_f)
206
+ )
207
+
208
+ if log_lock_try
209
+ run_non_critical do
210
+ logger.debug do
211
+ "[redis_queued_locks.try_lock.reentrant_lock__work_through] " \
212
+ "lock_key => '#{lock_key}' " \
213
+ "queue_ttl => #{queue_ttl} " \
214
+ "acq_id => '#{acquier_id}' " \
215
+ "spc_status => '#{sp_conflict_status} ' " \
216
+ "last_spc_ts => '#{spc_processed_timestamp}'"
217
+ end
218
+ end
219
+ end
220
+ # SP-Conflict-Step X2: switch to dead lock logic or not
221
+ elsif sp_conflict_status == :conflict_dead_lock
222
+ inter_result = :conflict_dead_lock
223
+ spc_processed_timestamp = Time.now.to_f
224
+
225
+ if log_lock_try
226
+ logger.debug do
227
+ "[redis_queued_locks.try_lock.single_process_lock_conflict__dead_lock] " \
228
+ "lock_key => '#{lock_key}' " \
229
+ "queue_ttl => #{queue_ttl} " \
230
+ "acq_id => '#{acquier_id}' " \
231
+ "spc_status => '#{sp_conflict_status}' " \
232
+ "last_spc_ts => '#{spc_processed_timestamp}'"
233
+ end
234
+ end
235
+ # Reached the SP-Non-Conflict Mode (NOTE):
236
+ # - in other sp-conflict cases we are in <wait_for_lock> (non-conflict) status and should
237
+ # continue to work in classic way (next lines of code):
238
+ elsif fail_fast && current_lock_obtainer != nil # Fast-Step X0: fail-fast check
239
+ # Fast-Step X1: lock is already obtained. fail fast leads to "no try".
74
240
  inter_result = :fail_fast_no_try
75
241
  else
76
242
  # Step 1: add an acquier to the lock acquirement queue
@@ -216,6 +382,63 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
216
382
  # Step 7: Analyze the aquirement attempt:
217
383
  # rubocop:disable Lint/DuplicateBranch
218
384
  case
385
+ when inter_result == :extendable_conflict_work_through
386
+ # Step 7.same_process_conflict.A:
387
+ # - extendable_conflict_work_through case => yield <block> without lock realesing/extending;
388
+ # - lock is extended in logic above;
389
+ # - if <result == nil> => the lock was changed during an extention:
390
+ # it is the fail case => go retry.
391
+ # - else: let's go! :))
392
+ if result.is_a?(::Array) && result.size == 4 # NOTE: four commands should be processed
393
+ # TODO:
394
+ # => (!) analyze the command result and do actions with the depending on it
395
+ # 1. EVAL (extend lock pttl) (OK for != nil)
396
+ # 2. HINCRBY (ttl extension) (OK for != nil)
397
+ # 3. HINCRBY (increased spc count) (OK for != nil)
398
+ # 4. HSET (store the last spc time and ttl data) (OK for == 2 or != nil)
399
+ if result[0] != nil && result[1] != nil && result[2] != nil && result[3] != nil
400
+ RedisQueuedLocks::Data[ok: true, result: {
401
+ process: :extendable_conflict_work_through,
402
+ lock_key: lock_key,
403
+ acq_id: acquier_id,
404
+ ts: spc_processed_timestamp,
405
+ ttl: ttl
406
+ }]
407
+ elsif result[0] != nil
408
+ # NOTE: that is enough to the fact that the lock is extended but <TODO>
409
+ # TODO: add detalized overview (log? some in-line code clarifications?) of the result
410
+ RedisQueuedLocks::Data[ok: true, result: {
411
+ process: :extendable_conflict_work_through,
412
+ lock_key: lock_key,
413
+ acq_id: acquier_id,
414
+ ts: spc_processed_timestamp,
415
+ ttl: ttl
416
+ }]
417
+ else
418
+ # NOTE: unknown behaviour :thinking:
419
+ RedisQueuedLocks::Data[ok: false, result: :unknown]
420
+ end
421
+ elsif result == nil || (result.is_a?(::Array) && result.empty?)
422
+ # NOTE: the lock key was changed durign an SPC logic execution
423
+ RedisQueuedLocks::Data[ok: false, result: :lock_is_acquired_during_acquire_race]
424
+ else
425
+ # NOTE: unknown behaviour :thinking:. this part is not reachable at the moment.
426
+ RedisQueuedLocks::Data[ok: false, result: :unknown]
427
+ end
428
+ when inter_result == :conflict_work_through
429
+ # Step 7.same_process_conflict.B:
430
+ # - conflict_work_through case => yield <block> without lock realesing/extending
431
+ RedisQueuedLocks::Data[ok: true, result: {
432
+ process: :conflict_work_through,
433
+ lock_key: lock_key,
434
+ acq_id: acquier_id,
435
+ ts: spc_processed_timestamp,
436
+ ttl: ttl
437
+ }]
438
+ when inter_result == :conflict_dead_lock
439
+ # Step 7.same_process_conflict.C:
440
+ # - deadlock. should fail in acquirement logic;
441
+ RedisQueuedLocks::Data[ok: false, result: inter_result]
219
442
  when fail_fast && inter_result == :fail_fast_no_try
220
443
  # Step 7.a: lock is still acquired and we should exit from the logic as soon as possible
221
444
  RedisQueuedLocks::Data[ok: false, result: inter_result]
@@ -238,10 +461,13 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
238
461
  # 3. pexpire should return 1 (expiration time is successfully applied)
239
462
 
240
463
  # Step 7.d: locked! :) let's go! => successfully acquired
241
- RedisQueuedLocks::Data[
242
- ok: true,
243
- result: { lock_key: lock_key, acq_id: acquier_id, ts: timestamp, ttl: ttl }
244
- ]
464
+ RedisQueuedLocks::Data[ok: true, result: {
465
+ process: :lock_obtaining,
466
+ lock_key: lock_key,
467
+ acq_id: acquier_id,
468
+ ts: timestamp,
469
+ ttl: ttl
470
+ }]
245
471
  else
246
472
  # Ste 7.3: unknown behaviour :thinking:
247
473
  RedisQueuedLocks::Data[ok: false, result: :unknown]
@@ -260,12 +486,13 @@ module RedisQueuedLocks::Acquier::AcquireLock::TryToLock
260
486
  #
261
487
  # @api private
262
488
  # @since 1.0.0
489
+ # @version 1.3.0
263
490
  def dequeue_from_lock_queue(redis, logger, lock_key, lock_key_queue, queue_ttl, acquier_id)
264
491
  result = redis.call('ZREM', lock_key_queue, acquier_id)
265
492
 
266
493
  run_non_critical do
267
494
  logger.debug do
268
- "[redis_queued_locks.fail_fast_or_limits_reached__dequeue] " \
495
+ "[redis_queued_locks.fail_fast_or_limits_reached_or_deadlock__dequeue] " \
269
496
  "lock_key => '#{lock_key}' " \
270
497
  "queue_ttl => '#{queue_ttl}' " \
271
498
  "acq_id => '#{acquier_id}'"
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api private
4
+ # @since 1.3.0
5
+ module RedisQueuedLocks::Acquier::AcquireLock::YieldExpire
6
+ # @since 1.3.0
7
+ extend RedisQueuedLocks::Utilities
8
+
9
+ # @return [String]
10
+ #
11
+ # @api private
12
+ # @since 1.3.0
13
+ DECREASE_LOCK_PTTL = <<~LUA_SCRIPT.strip.tr("\n", '').freeze
14
+ local new_lock_pttl = redis.call("PTTL", KEYS[1]) - ARGV[1];
15
+ return redis.call("PEXPIRE", KEYS[1], new_lock_pttl);
16
+ LUA_SCRIPT
17
+
18
+ # @param redis [RedisClient] Redis connection.
19
+ # @param logger [::Logger,#debug] Logger object.
20
+ # @param lock_key [String] Obtained lock key that should be expired.
21
+ # @param acquier_id [String] Acquier identifier.
22
+ # @param timed [Boolean] Should the lock be wrapped by Timeout with with lock's ttl
23
+ # @param ttl_shift [Float] Lock's TTL shifting. Should affect block's ttl. In millisecodns.
24
+ # @param ttl [Integer,NilClass] Lock's time to live (in ms). Nil means "without timeout".
25
+ # @param queue_ttl [Integer] Lock request lifetime.
26
+ # @param block [Block] Custom logic that should be invoked unter the obtained lock.
27
+ # @param should_expire [Boolean] Should the lock be expired after the block invocation.
28
+ # @param should_decrease [Boolean]
29
+ # - Should decrease the lock TTL after the lock invocation;
30
+ # - It is suitable for extendable reentrant locks;
31
+ # @return [Any,NilClass] nil is returned no block parametr is provided.
32
+ #
33
+ # @api private
34
+ # @since 1.3.0
35
+ # rubocop:disable Metrics/MethodLength
36
+ def yield_expire(
37
+ redis,
38
+ logger,
39
+ lock_key,
40
+ acquier_id,
41
+ timed,
42
+ ttl_shift,
43
+ ttl,
44
+ queue_ttl,
45
+ should_expire,
46
+ should_decrease,
47
+ &block
48
+ )
49
+ initial_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
50
+
51
+ if block_given?
52
+ timeout = ((ttl - ttl_shift) / 1000.0).yield_self do |time|
53
+ # NOTE: time in <seconds> cuz Ruby's Timeout requires <seconds>
54
+ (time < 0) ? 0.0 : time
55
+ end
56
+
57
+ if timed && ttl != nil
58
+ yield_with_timeout(timeout, lock_key, ttl, &block)
59
+ else
60
+ yield
61
+ end
62
+ end
63
+ ensure
64
+ if should_expire
65
+ run_non_critical do
66
+ logger.debug do
67
+ "[redis_queued_locks.expire_lock] " \
68
+ "lock_key => '#{lock_key}' " \
69
+ "queue_ttl => #{queue_ttl} " \
70
+ "acq_id => '#{acquier_id}'"
71
+ end
72
+ end
73
+ redis.call('EXPIRE', lock_key, '0')
74
+ elsif should_decrease
75
+ finish_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
76
+ spent_time = (finish_time - initial_time)
77
+ decreased_ttl = ttl - spent_time - RedisQueuedLocks::Resource::REDIS_TIMESHIFT_ERROR
78
+ if decreased_ttl > 0
79
+ run_non_critical do
80
+ logger.debug do
81
+ "[redis_queued_locks.decrease_lock] " \
82
+ "lock_key => '#{lock_key}' " \
83
+ "decreased_ttl => '#{decreased_ttl} " \
84
+ "queue_ttl => #{queue_ttl} " \
85
+ "acq_id => '#{acquier_id}' " \
86
+ end
87
+ end
88
+ # NOTE:# NOTE: EVAL signature -> <lua script>, (number of keys), *(keys), *(arguments)
89
+ redis.call('EVAL', DECREASE_LOCK_PTTL, 1, lock_key, decreased_ttl)
90
+ # TODO: upload scripts to the redis
91
+ end
92
+ end
93
+ end
94
+ # rubocop:enable Metrics/MethodLength
95
+
96
+ private
97
+
98
+ # @param timeout [Float]
99
+ # @parma lock_key [String]
100
+ # @param lock_ttl [Integer,NilClass]
101
+ # @param block [Blcok]
102
+ # @return [Any]
103
+ #
104
+ # @api private
105
+ # @since 1.3.0
106
+ def yield_with_timeout(timeout, lock_key, lock_ttl, &block)
107
+ ::Timeout.timeout(timeout, &block)
108
+ rescue ::Timeout::Error
109
+ raise(
110
+ RedisQueuedLocks::TimedLockTimeoutError,
111
+ "Passed <timed> block of code exceeded " \
112
+ "the lock TTL (lock: \"#{lock_key}\", ttl: #{lock_ttl})"
113
+ )
114
+ end
115
+ end