redis_queued_locks 1.17.0 → 1.19.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 +87 -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 +68 -0
  12. data/CLAUDE.md +7 -2
  13. data/README.md +427 -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 +143 -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 +2 -2
  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 +23 -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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api private
4
+ # @since 1.18.0
5
+ module RedisQueuedLocks::Acquirer::ReleaseReadLock
6
+ # @since 1.18.0
7
+ extend RedisQueuedLocks::Utilities
8
+
9
+ class << self
10
+ # Release the read lock of the concrete acquirer (other read locks, the write lock and
11
+ # lock requests are not affected): drop the acquirer from the readers registry and drop
12
+ # its read lock data.
13
+ #
14
+ # @param redis [RedisClient]
15
+ # Redis connection client.
16
+ # @param lock_name [String]
17
+ # The lock name whose read lock should be released.
18
+ # @param acquirer_id [String]
19
+ # The read lock acquirer.
20
+ # @param instrumenter [#notify]
21
+ # See RedisQueuedLocks::Instrument::ActiveSupport for example.
22
+ # @param logger [::Logger,#debug]
23
+ # - Logger object used from `configuration` layer (see config['logger']);
24
+ # - See RedisQueuedLocks::Logging::VoidLogger for example;
25
+ # @param instrument [NilClass,Any]
26
+ # - Custom instrumentation data wich will be passed to the instrumenter's payload
27
+ # with :instrument key;
28
+ # @param log_sampling_enabled [Boolean]
29
+ # @param log_sampling_percent [Integer]
30
+ # @param log_sampler [#sampling_happened?,Module<RedisQueuedLocks::Logging::Sampler>]
31
+ # @param log_sample_this [Boolean]
32
+ # @param instr_sampling_enabled [Boolean]
33
+ # - enables <instrumentaion sampling>: only the configured percent
34
+ # of RQL cases will be instrumented;
35
+ # @param instr_sampling_percent [Integer]
36
+ # - the percent of cases that should be instrumented;
37
+ # @param instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
38
+ # - percent-based sampler that decides should be RQL case instrumented or not;
39
+ # @param instr_sample_this [Boolean]
40
+ # - marks the method that everything should be instrumneted
41
+ # despite the enabled instrumentation sampling;
42
+ # @return [Hash<Symbol,Boolean<Hash<Symbol,Numeric|String|Symbol>>]
43
+ # Format: {
44
+ # ok: true,
45
+ # result: {
46
+ # rel_time: Numeric, # <milliseconds>
47
+ # rel_key: String, # lock key
48
+ # rel_acq_id: String, # read lock acquirer
49
+ # lock_res: Symbol # :released or :nothing_to_release
50
+ # }
51
+ # }
52
+ #
53
+ # @api private
54
+ # @since 1.18.0
55
+ # rubocop:disable Metrics/MethodLength
56
+ def release_read_lock(
57
+ redis,
58
+ lock_name,
59
+ acquirer_id,
60
+ instrumenter,
61
+ logger,
62
+ instrument,
63
+ log_sampling_enabled,
64
+ log_sampling_percent,
65
+ log_sampler,
66
+ log_sample_this,
67
+ instr_sampling_enabled,
68
+ instr_sampling_percent,
69
+ instr_sampler,
70
+ instr_sample_this
71
+ )
72
+ lock_key = RedisQueuedLocks::Resource.prepare_lock_key(lock_name)
73
+ lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
74
+ read_lock_key = RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
75
+
76
+ rel_start_time = clock_gettime
77
+ # steep:ignore:start
78
+ fully_release_read_lock(
79
+ redis, lock_readers_key, read_lock_key, acquirer_id
80
+ ) => { ok:, result: }
81
+ # steep:ignore:end
82
+
83
+ # @type var ok: bool
84
+ # @type var result: Hash[Symbol,Symbol]
85
+
86
+ time_at = Time.now.to_f
87
+ rel_end_time = clock_gettime
88
+ rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)
89
+
90
+ instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(
91
+ instr_sampling_enabled,
92
+ instr_sample_this,
93
+ instr_sampling_percent,
94
+ instr_sampler
95
+ )
96
+
97
+ run_non_critical do
98
+ instrumenter.notify('redis_queued_locks.explicit_read_lock_release', {
99
+ lock_key: lock_key,
100
+ acq_id: acquirer_id,
101
+ lock_res: result[:lock],
102
+ rel_time: rel_time,
103
+ at: time_at,
104
+ instrument: instrument
105
+ })
106
+ end if instr_sampled
107
+
108
+ {
109
+ ok: true,
110
+ result: {
111
+ rel_time: rel_time,
112
+ rel_key: lock_key,
113
+ rel_acq_id: acquirer_id,
114
+ lock_res: result[:lock]
115
+ }
116
+ }
117
+ end
118
+ # rubocop:enable Metrics/MethodLength
119
+
120
+ private
121
+
122
+ # @param redis [RedisClient]
123
+ # @param lock_readers_key [String]
124
+ # @param read_lock_key [String]
125
+ # @param acquirer_id [String]
126
+ # @return [Hash<Symbol,Boolean|Hash<Symbol,Symbol>>]
127
+ # Format: { ok: true, result: { lock: :released/:nothing_to_release } }
128
+ #
129
+ # @api private
130
+ # @since 1.18.0
131
+ def fully_release_read_lock(redis, lock_readers_key, read_lock_key, acquirer_id)
132
+ # @type var result: [Integer,Integer]
133
+ result = redis.with do |rconn|
134
+ rconn.multi do |transact|
135
+ transact.call('ZREM', lock_readers_key, acquirer_id)
136
+ transact.call('DEL', read_lock_key)
137
+ end
138
+ end
139
+
140
+ { ok: true, result: { lock: (result[0] != 0) ? :released : :nothing_to_release } }
141
+ end
142
+ end
143
+ end
@@ -6,6 +6,7 @@ module RedisQueuedLocks::Acquirer
6
6
  require_relative 'acquirer/acquire_lock'
7
7
  require_relative 'acquirer/lock_series_poc'
8
8
  require_relative 'acquirer/release_lock'
9
+ require_relative 'acquirer/release_read_lock'
9
10
  require_relative 'acquirer/release_all_locks'
10
11
  require_relative 'acquirer/release_locks_of'
11
12
  require_relative 'acquirer/is_locked'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api public
4
4
  # @since 1.0.0
5
- # @version 1.16.0
5
+ # @version 1.18.0
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class RedisQueuedLocks::Client
8
8
  # @return [RedisClient]
@@ -207,7 +207,28 @@ class RedisQueuedLocks::Client
207
207
  # (with timeouts, retry delays, retry limits, etc - in classic way :));
208
208
  # - `:dead_locking` - fail with deadlock exception;
209
209
  # @option read_write_mode [Symbol]
210
- # - ?
210
+ # - The lock mode (read/write lock semantics);
211
+ # - `:write` mode is used by default;
212
+ # - Supports:
213
+ # - `:write` - exclusive lock: waits for the write lock and for all read locks;
214
+ # - `:read` - shared lock: waits for the write lock only (read locks of different
215
+ # acquirers do not block each other);
216
+ # - `:queued` access strategy orders read and write lock requests in FIFO between modes
217
+ # (read lock request waits for earlier write lock requests, write lock request waits for
218
+ # earlier read and write lock requests) so writers are not starved by readers;
219
+ # - `:random` access strategy ignores lock queues (a continuous flow of readers can starve
220
+ # writers);
221
+ # - read lock specifics:
222
+ # - read lock is released (with a block of code) or expired (by `ttl`) independently
223
+ # of other read locks; `#unlock` releases all read locks and the write lock;
224
+ # - each read lock has its own data (acquirer, host, timestamp, ttl, `:meta`, reentrant
225
+ # lock data) that is returned by `#lock_info` for each reader;
226
+ # - same-process conflicts (`conflict_strategy`) work for both modes:
227
+ # - read lock request under the obtained write lock (or read lock) works as a reentrant
228
+ # lock of the obtained lock;
229
+ # - write lock request under the obtained read lock (read-to-write lock upgrade) fails with
230
+ # `RedisQueuedLocks::ConflictLockObtainError` for any conflict strategy except
231
+ # `:wait_for_lock` (it waits for the read lock expiration);
211
232
  # @option access_strategy [Symbol]
212
233
  # - The way in which the lock will be obtained;
213
234
  # - By default it uses `:queued` strategy;
@@ -310,7 +331,7 @@ class RedisQueuedLocks::Client
310
331
  #
311
332
  # @api public
312
333
  # @since 1.0.0
313
- # @version 1.13.0
334
+ # @version 1.18.0
314
335
  # rubocop:disable Metrics/MethodLength
315
336
  def lock(
316
337
  lock_name,
@@ -531,7 +552,7 @@ class RedisQueuedLocks::Client
531
552
  #
532
553
  # @api public
533
554
  # @since 1.0.0
534
- # @version 1.13.0
555
+ # @version 1.18.0
535
556
  def lock!(
536
557
  lock_name,
537
558
  ttl: config['default_lock_ttl'],
@@ -596,6 +617,8 @@ class RedisQueuedLocks::Client
596
617
  end
597
618
  # rubocop:enable Metrics/MethodLength
598
619
 
620
+ # Releases the lock: the write lock, all read locks and all lock requests (write and read).
621
+ #
599
622
  # @param lock_name [String] The lock name that should be released.
600
623
  # @option logger [::Logger,#debug]
601
624
  # @option instrumenter [#notify]
@@ -615,14 +638,14 @@ class RedisQueuedLocks::Client
615
638
  # rel_time: Integer, # <millisecnds>
616
639
  # rel_key: String, # lock key
617
640
  # rel_queue: String, # lock queue
618
- # queue_res: Symbol, # :released or :nothing_to_release
619
- # lock_res: Symbol # :released or :nothing_to_release
641
+ # queue_res: Symbol, # :released or :nothing_to_release (write or read lock requests)
642
+ # lock_res: Symbol # :released or :nothing_to_release (write lock or read locks)
620
643
  # }
621
644
  # }
622
645
  #
623
646
  # @api public
624
647
  # @since 1.0.0
625
- # @version 1.6.0
648
+ # @version 1.18.0
626
649
  def unlock(
627
650
  lock_name,
628
651
  logger: config['logger'],
@@ -654,38 +677,126 @@ class RedisQueuedLocks::Client
654
677
  end
655
678
  alias_method :release_lock, :unlock
656
679
 
680
+ # Releases the read lock of the current acquirer (or of the custom acquirer passed via
681
+ # `acquirer_id:`) only: other read locks, the write lock and lock requests are not affected
682
+ # (`#unlock` releases the lock entirely). It is suitable for read locks obtained without
683
+ # a block of code.
684
+ #
685
+ # @param lock_name [String] The lock name whose read lock should be released.
686
+ # @option acquirer_id [String,NilClass]
687
+ # - The acquirer whose read lock should be released (`nil` by default);
688
+ # - `nil` => the current acquirer is used (calculated from `identity:`, see `#current_acquirer_id`);
689
+ # - can be extracted from the lock data (`#lock_info`, `#locks_info`, `#queue_info`, `#queues_info`);
690
+ # @option identity [String]
691
+ # Unique acquirer identity (the same as for `#lock`) that is used to calculate the current
692
+ # acquirer id (see `#current_acquirer_id`). Ignored when `acquirer_id:` is passed: the acquirer id
693
+ # already contains its own identity.
694
+ # @option logger [::Logger,#debug]
695
+ # @option instrumenter [#notify]
696
+ # @option instrument [NilClass,Any]
697
+ # @option log_sampling_enabled [Boolean]
698
+ # @option log_sampling_percent [Integer]
699
+ # @option log_sampler [#sampling_happened?,Module<RedisQueuedLocks::Logging::Sampler>]
700
+ # @option log_sample_this [Boolean]
701
+ # @option instr_sampling_enabled [Boolean]
702
+ # @option instr_sampling_percent [Integer]
703
+ # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
704
+ # @option instr_sample_this [Boolean]
705
+ # @return [Hash<Symbol,Any>]
706
+ # Format: {
707
+ # ok: true,
708
+ # result: {
709
+ # rel_time: Numeric, # <milliseconds>
710
+ # rel_key: String, # lock key
711
+ # rel_acq_id: String, # read lock acquirer
712
+ # lock_res: Symbol # :released or :nothing_to_release
713
+ # }
714
+ # }
715
+ #
716
+ # @example Release the read lock of the current acquirer:
717
+ # client.unlock_read('report')
718
+ #
719
+ # @example Release the read lock of a different acquirer:
720
+ # client.unlock_read('report', acquirer_id: "rql:acq:62681/2016/2024/2032/b30ec5e4bea10512")
721
+ #
722
+ # @api public
723
+ # @since 1.18.0
724
+ # @version 1.19.0
725
+ def unlock_read(
726
+ lock_name,
727
+ acquirer_id: nil,
728
+ identity: uniq_identity,
729
+ logger: config['logger'],
730
+ instrumenter: config['instrumenter'],
731
+ instrument: nil,
732
+ log_sampling_enabled: config['log_sampling_enabled'],
733
+ log_sampling_percent: config['log_sampling_percent'],
734
+ log_sampler: config['log_sampler'],
735
+ log_sample_this: false,
736
+ instr_sampling_enabled: config['instr_sampling_enabled'],
737
+ instr_sampling_percent: config['instr_sampling_percent'],
738
+ instr_sampler: config['instr_sampler'],
739
+ instr_sample_this: false
740
+ )
741
+ RedisQueuedLocks::Acquirer::ReleaseReadLock.release_read_lock(
742
+ redis_client,
743
+ lock_name,
744
+ acquirer_id || current_acquirer_id(identity:),
745
+ instrumenter,
746
+ logger,
747
+ instrument,
748
+ log_sampling_enabled,
749
+ log_sampling_percent,
750
+ log_sampler,
751
+ log_sample_this,
752
+ instr_sampling_enabled,
753
+ instr_sampling_percent,
754
+ instr_sampler,
755
+ instr_sample_this
756
+ )
757
+ end
758
+ alias_method :release_read_lock, :unlock_read
759
+
657
760
  # @param lock_name [String]
658
- # @return [Boolean]
761
+ # @return [Boolean] Is the lock obtained by a writer or by any reader.
659
762
  #
660
763
  # @api public
661
764
  # @since 1.0.0
765
+ # @version 1.18.0
662
766
  def locked?(lock_name)
663
767
  RedisQueuedLocks::Acquirer::IsLocked.locked?(redis_client, lock_name)
664
768
  end
665
769
 
666
770
  # @param lock_name [String]
667
- # @return [Boolean]
771
+ # @return [Boolean] Are there any write or read lock requests.
668
772
  #
669
773
  # @api public
670
774
  # @since 1.0.0
775
+ # @version 1.18.0
671
776
  def queued?(lock_name)
672
777
  RedisQueuedLocks::Acquirer::IsQueued.queued?(redis_client, lock_name)
673
778
  end
674
779
 
675
780
  # @param lock_name [String]
676
- # @return [Hash<String,String|Numeric>,NilClass]
781
+ # @return [Hash<String,String|Numeric|Array<Hash<String,String|Numeric>>>,NilClass]
782
+ # - write lock info or read locks info (when there is no write lock);
783
+ # - see `RedisQueuedLocks::Acquirer::LockInfo.lock_info` for details;
677
784
  #
678
785
  # @api public
679
786
  # @since 1.0.0
787
+ # @version 1.18.0
680
788
  def lock_info(lock_name)
681
789
  RedisQueuedLocks::Acquirer::LockInfo.lock_info(redis_client, lock_name)
682
790
  end
683
791
 
684
792
  # @param lock_name [String]
685
793
  # @return [Hash<String|Array<Hash<String,String|Numeric>>,NilClass]
794
+ # - write lock requests and read lock requests (when they exist);
795
+ # - see `RedisQueuedLocks::Acquirer::QueueInfo.queue_info` for details;
686
796
  #
687
797
  # @api public
688
798
  # @since 1.0.0
799
+ # @version 1.18.0
689
800
  def queue_info(lock_name)
690
801
  RedisQueuedLocks::Acquirer::QueueInfo.queue_info(redis_client, lock_name)
691
802
  end
@@ -783,6 +894,16 @@ class RedisQueuedLocks::Client
783
894
  #
784
895
  # @param lock_name [String]
785
896
  # @param milliseconds [Integer] How many milliseconds should be added.
897
+ # @option read_write_mode [Symbol]
898
+ # - `:write` (default) - extends the write lock (the method description above);
899
+ # - `:read` - extends the read lock of the current acquirer only (an expired read lock or
900
+ # a read lock replaced by a writer is not extended);
901
+ # @option all_read_locks [Boolean]
902
+ # `:read` mode only (ignored for `:write` mode): extends all live read locks of the lock
903
+ # instead of the read lock of the current acquirer (`false` by default).
904
+ # @option identity [String]
905
+ # Unique acquirer identity (the same as for `#lock`) that is used to calculate the current
906
+ # acquirer id for `:read` mode (see `#current_acquirer_id`).
786
907
  # @option logger [::Logger,#debug]
787
908
  # @option instrumenter [#notify] See `config['instrumenter']` docs for details.
788
909
  # @option instrument [NilClass,Any]
@@ -794,16 +915,21 @@ class RedisQueuedLocks::Client
794
915
  # @option instr_sampling_percent [Integer]
795
916
  # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
796
917
  # @option instr_sample_this [Boolean]
797
- # @return [Hash<Symbol,Boolean|Symbol>]
798
- # - { ok: true, result: :ttl_extended }
918
+ # @return [Hash<Symbol,Boolean|Symbol|Hash<Symbol,Integer>>]
919
+ # - { ok: true, result: { extended_locks_count: Integer } } - the number of extended locks:
920
+ # `1` for the write lock and for the read lock of the current acquirer,
921
+ # the number of extended read locks for `all_read_locks: true`;
799
922
  # - { ok: false, result: :async_expire_or_no_lock }
800
923
  #
801
924
  # @api public
802
925
  # @since 1.0.0
803
- # @version 1.6.0
926
+ # @version 1.18.0
804
927
  def extend_lock_ttl(
805
928
  lock_name,
806
929
  milliseconds,
930
+ read_write_mode: :write,
931
+ all_read_locks: false,
932
+ identity: uniq_identity,
807
933
  logger: config['logger'],
808
934
  instrumenter: config['instrumenter'],
809
935
  instrument: nil,
@@ -820,6 +946,9 @@ class RedisQueuedLocks::Client
820
946
  redis_client,
821
947
  lock_name,
822
948
  milliseconds,
949
+ read_write_mode,
950
+ all_read_locks,
951
+ current_acquirer_id(identity:),
823
952
  logger,
824
953
  instrumenter,
825
954
  instrument,
@@ -928,7 +1057,7 @@ class RedisQueuedLocks::Client
928
1057
  # @example Release locks of a different host/acquirer:
929
1058
  # client.clear_locks_of(
930
1059
  # host_id: "rql:hst:62681/2016/2032/b30ec5e4bea10512",
931
- # acquirer_id: "ral:acq:62681/2016/2024/2032/b30ec5e4bea10512"
1060
+ # acquirer_id: "rql:acq:62681/2016/2024/2032/b30ec5e4bea10512"
932
1061
  # )
933
1062
  #
934
1063
  # @see #clear_current_locks
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
+ # rubocop:disable Metrics/ModuleLength
5
6
  module RedisQueuedLocks::Resource
6
7
  # @return [String]
7
8
  #
@@ -21,17 +22,30 @@ module RedisQueuedLocks::Resource
21
22
  # @since 1.0.0
22
23
  LOCK_QUEUE_PATTERN = 'rql:lock_queue:*'
23
24
 
25
+ # NOTE:
26
+ # - read/write keys use their own prefixes (not suffixes of the lock name) cuz the lock name
27
+ # is an arbitrary string: `rql:lock_queue:<name>:read` collides with the queue of a lock
28
+ # named "<name>:read";
29
+ # - new prefixes are not matched by LOCK_PATTERN/LOCK_QUEUE_PATTERN
30
+ # (`rql:lock_` vs `rql:lock:`) but are matched by KEY_PATTERN;
31
+ # - write lock requests use the classic lock queue (LOCK_QUEUE_PATTERN);
24
32
  # @return [String]
25
33
  #
26
34
  # @api private
27
- # @since ?.?.?
28
- READ_LOCK_QUEUE_PATTERN = 'rql:lock_queue:*:read'
35
+ # @since 1.18.0
36
+ READ_LOCK_QUEUE_PATTERN = 'rql:lock_read_queue:*'
29
37
 
30
38
  # @return [String]
31
39
  #
32
40
  # @api private
33
- # @since ?.?.?
34
- WRITE_LOCK_QUEUE_PATTERN = 'rql:lock_queue:*:write'
41
+ # @since 1.18.0
42
+ LOCK_READERS_PATTERN = 'rql:lock_readers:*'
43
+
44
+ # @return [String]
45
+ #
46
+ # @api private
47
+ # @since 1.18.0
48
+ READ_LOCK_PATTERN = 'rql:lock_reader:*'
35
49
 
36
50
  # @return [String]
37
51
  #
@@ -105,22 +119,115 @@ module RedisQueuedLocks::Resource
105
119
  "rql:lock_queue:#{lock_name}"
106
120
  end
107
121
 
122
+ # Queue of read lock requests (write lock requests use the classic lock queue).
123
+ #
108
124
  # @param lock_name [String]
109
125
  # @return [String]
110
126
  #
111
127
  # @api private
112
- # @api ?.?.?
128
+ # @since 1.18.0
113
129
  def prepare_read_lock_queue(lock_name)
114
- "rql:lock_queue:#{lock_name}:read"
130
+ "rql:lock_read_queue:#{lock_name}"
115
131
  end
116
132
 
133
+ # Registry of read lock holders: a sorted set where the member is an acquirer id and
134
+ # the score is the read lock expiration time (redis server time, epoch milliseconds).
135
+ #
117
136
  # @param lock_name [String]
118
137
  # @return [String]
119
138
  #
120
139
  # @api private
121
- # @api ?.?.?
122
- def prepare_write_lock_queue(lock_name)
123
- "rql:lock_queue:#{lock_name}:write"
140
+ # @since 1.18.0
141
+ def prepare_lock_readers(lock_name)
142
+ "rql:lock_readers:#{lock_name}"
143
+ end
144
+
145
+ # Read lock data of the concrete acquirer: a hash with the same structure as the write lock
146
+ # (acquirer, host, timestamp, ttl, meta, reentrant lock data). It is a data carrier only:
147
+ # the readers registry is the source of truth for the read lock existence.
148
+ #
149
+ # @param lock_name [String]
150
+ # @param acquirer_id [String]
151
+ # @return [String]
152
+ #
153
+ # @api private
154
+ # @since 1.18.0
155
+ def prepare_read_lock_key(lock_name, acquirer_id)
156
+ "rql:lock_reader:#{lock_name}:#{acquirer_id}"
157
+ end
158
+
159
+ # Mode of the lock requests stored in the given lock queue.
160
+ #
161
+ # @param lock_queue [String]
162
+ # Write lock queue (`rql:lock_queue:*`) or read lock queue (`rql:lock_read_queue:*`).
163
+ # @return [String,NilClass]
164
+ # - `'write'` for the write lock queue, `'read'` for the read lock queue;
165
+ # - `nil` for any other key (unreachable: lock queues are always passed consistently);
166
+ #
167
+ # @api private
168
+ # @since 1.18.0
169
+ def lock_queue_rw_mode(lock_queue)
170
+ # NOTE:
171
+ # - `start_with?` instead of regexp `when`-matchers: no MatchData allocations;
172
+ # - write lock queues are checked first: they are met more often than read lock queues;
173
+ case
174
+ when lock_queue.start_with?('rql:lock_queue:') then 'write'
175
+ when lock_queue.start_with?('rql:lock_read_queue:') then 'read'
176
+ else
177
+ # NOTE: unreachable: the logic always passes a consistent lock queue (read or write)
178
+ nil
179
+ end
180
+ end
181
+
182
+ # @param lock_readers_key [String]
183
+ # @return [String]
184
+ #
185
+ # @api private
186
+ # @since 1.18.0
187
+ def lock_name_from_readers(lock_readers_key)
188
+ lock_readers_key.delete_prefix('rql:lock_readers:')
189
+ end
190
+
191
+ # @param lock_readers_key [String]
192
+ # @return [String]
193
+ #
194
+ # @api private
195
+ # @since 1.18.0
196
+ def lock_key_from_readers(lock_readers_key)
197
+ "rql:lock:#{lock_readers_key.delete_prefix('rql:lock_readers:')}"
198
+ end
199
+
200
+ # Host identifier of the given acquirer
201
+ # (acquirer and host identifiers are built from the same process/thread/ractor/identity data):
202
+ # - acquirer id: `rql:acq:<pid>/<thread>/<fiber>/<ractor>/<identity>`;
203
+ # - host id: `rql:hst:<pid>/<thread>/<ractor>/<identity>` (the same as `host_identifier`);
204
+ #
205
+ # @param acquirer_id [String]
206
+ # @return [String]
207
+ #
208
+ # @api private
209
+ # @since 1.18.0
210
+ def host_identifier_from_acquirer(acquirer_id)
211
+ # NOTE:
212
+ # - the host id is the acquirer id with another prefix and without the fiber part, so it is
213
+ # built in place from the acquirer id copy (one allocation: no intermediate strings and
214
+ # arrays of `delete_prefix`/`split`, no substrings);
215
+ # - 8 is the size of the `rql:acq:` prefix;
216
+ # - byte positions are used cuz identity can contain any (multibyte) symbols;
217
+ thread_end = acquirer_id.byteindex('/', acquirer_id.byteindex('/', 8).to_i + 1).to_i
218
+ fiber_end = acquirer_id.byteindex('/', thread_end + 1).to_i
219
+ host_id = acquirer_id.dup
220
+ host_id.bytesplice(thread_end, fiber_end - thread_end, '') # NOTE: drop "/<fiber>"
221
+ host_id.bytesplice(0, 8, 'rql:hst:')
222
+ end
223
+
224
+ # @param redis_time [Array<String>] Result of the redis `TIME` command (seconds, microseconds).
225
+ # @return [Integer] Redis server time (epoch, in milliseconds).
226
+ #
227
+ # @api private
228
+ # @since 1.18.0
229
+ def redis_time_ms(redis_time)
230
+ (Integer(redis_time[0]) * 1_000) + (Integer(redis_time[1]) / 1_000)
124
231
  end
125
232
 
126
233
  # @return [Float] Redis's <Set> score that is calculated from the time (epoch) as a float.
@@ -241,3 +348,4 @@ module RedisQueuedLocks::Resource
241
348
  end
242
349
  end
243
350
  end
351
+ # rubocop:enable Metrics/ModuleLength
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.9.0
5
+ # rubocop:disable Metrics/ClassLength
5
6
  class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElement::Isolated
6
7
  class << self
7
8
  # @param redis_client [RedisClient]
@@ -18,6 +19,7 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
18
19
  #
19
20
  # @api private
20
21
  # @since 1.9.0
22
+ # @version 1.18.0
21
23
  # rubocop:disable Metrics/MethodLength
22
24
  def flush_zombies(
23
25
  redis_client,
@@ -65,6 +67,32 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
65
67
  # NOTE: (steep) steep can't use <Set>s for splats
66
68
  rconn.call('DEL', *zombie_locks) if zombie_locks.any? # steep:ignore
67
69
 
70
+ # Step 3 (RW): find zombie read locks (read locks of zombie hosts) and drop them
71
+ # - read locks store acquirer ids only (the host is a part of the acquirer id);
72
+ # - readers registry is reported as a zombie lock if any of its read locks is dropped;
73
+ rconn.scan(
74
+ 'MATCH', RedisQueuedLocks::Resource::LOCK_READERS_PATTERN, count: lock_scan_size
75
+ ) do |lock_readers_key|
76
+ # @type var read_lock_acquirers: Array[String]
77
+ read_lock_acquirers = rconn.call('ZRANGE', lock_readers_key, '0', '-1')
78
+ zombie_readers = read_lock_acquirers.select do |acquirer_id|
79
+ zombie_hosts.include?(RedisQueuedLocks::Resource.host_identifier_from_acquirer(acquirer_id))
80
+ end
81
+ next if zombie_readers.empty?
82
+
83
+ rconn.call('ZREM', lock_readers_key, *zombie_readers)
84
+ # NOTE: drop the zombie read lock data
85
+ lock_name = RedisQueuedLocks::Resource.lock_name_from_readers(lock_readers_key)
86
+ rconn.call(
87
+ 'DEL',
88
+ *zombie_readers.map do |acquirer_id|
89
+ RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
90
+ end
91
+ )
92
+ zombie_locks << lock_readers_key
93
+ zombie_acquirers.merge(zombie_readers)
94
+ end
95
+
68
96
  # Step 4: find zombie requests => and drop them
69
97
  # TODO: indexing (in order to prevent full database scan);
70
98
  # NOTE: original redis does not support indexing so we need to use
@@ -77,6 +105,15 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
77
105
  end
78
106
  end
79
107
 
108
+ # Step 4 (RW): drop zombie requests from the queues of read lock requests
109
+ rconn.scan(
110
+ 'MATCH', RedisQueuedLocks::Resource::READ_LOCK_QUEUE_PATTERN, count: queue_scan_size
111
+ ) do |read_lock_queue|
112
+ zombie_acquirers.each do |zombie_acquirer|
113
+ rconn.call('ZREM', read_lock_queue, zombie_acquirer)
114
+ end
115
+ end
116
+
80
117
  # Step 5: drop zombies from the swarm
81
118
  rconn.call('HDEL', RedisQueuedLocks::Resource::SWARM_KEY, *zombie_hosts)
82
119
 
@@ -134,3 +171,4 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
134
171
  end
135
172
  end
136
173
  end
174
+ # rubocop:enable Metrics/ClassLength