redis_queued_locks 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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