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
@@ -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,112 @@ class RedisQueuedLocks::Client
654
677
  end
655
678
  alias_method :release_lock, :unlock
656
679
 
680
+ # Releases the read lock of the current acquirer only: other read locks, the write lock and
681
+ # lock requests are not affected (`#unlock` releases the lock entirely). It is suitable for
682
+ # read locks obtained without a block of code.
683
+ #
684
+ # @param lock_name [String] The lock name whose read lock should be released.
685
+ # @option identity [String]
686
+ # Unique acquirer identity (the same as for `#lock`) that is used to calculate the current
687
+ # acquirer id (see `#current_acquirer_id`).
688
+ # @option logger [::Logger,#debug]
689
+ # @option instrumenter [#notify]
690
+ # @option instrument [NilClass,Any]
691
+ # @option log_sampling_enabled [Boolean]
692
+ # @option log_sampling_percent [Integer]
693
+ # @option log_sampler [#sampling_happened?,Module<RedisQueuedLocks::Logging::Sampler>]
694
+ # @option log_sample_this [Boolean]
695
+ # @option instr_sampling_enabled [Boolean]
696
+ # @option instr_sampling_percent [Integer]
697
+ # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
698
+ # @option instr_sample_this [Boolean]
699
+ # @return [Hash<Symbol,Any>]
700
+ # Format: {
701
+ # ok: true,
702
+ # result: {
703
+ # rel_time: Numeric, # <milliseconds>
704
+ # rel_key: String, # lock key
705
+ # rel_acq_id: String, # read lock acquirer
706
+ # lock_res: Symbol # :released or :nothing_to_release
707
+ # }
708
+ # }
709
+ #
710
+ # @api public
711
+ # @since 1.18.0
712
+ def unlock_read(
713
+ lock_name,
714
+ identity: uniq_identity,
715
+ logger: config['logger'],
716
+ instrumenter: config['instrumenter'],
717
+ instrument: nil,
718
+ log_sampling_enabled: config['log_sampling_enabled'],
719
+ log_sampling_percent: config['log_sampling_percent'],
720
+ log_sampler: config['log_sampler'],
721
+ log_sample_this: false,
722
+ instr_sampling_enabled: config['instr_sampling_enabled'],
723
+ instr_sampling_percent: config['instr_sampling_percent'],
724
+ instr_sampler: config['instr_sampler'],
725
+ instr_sample_this: false
726
+ )
727
+ RedisQueuedLocks::Acquirer::ReleaseReadLock.release_read_lock(
728
+ redis_client,
729
+ lock_name,
730
+ current_acquirer_id(identity:),
731
+ instrumenter,
732
+ logger,
733
+ instrument,
734
+ log_sampling_enabled,
735
+ log_sampling_percent,
736
+ log_sampler,
737
+ log_sample_this,
738
+ instr_sampling_enabled,
739
+ instr_sampling_percent,
740
+ instr_sampler,
741
+ instr_sample_this
742
+ )
743
+ end
744
+ alias_method :release_read_lock, :unlock_read
745
+
657
746
  # @param lock_name [String]
658
- # @return [Boolean]
747
+ # @return [Boolean] Is the lock obtained by a writer or by any reader.
659
748
  #
660
749
  # @api public
661
750
  # @since 1.0.0
751
+ # @version 1.18.0
662
752
  def locked?(lock_name)
663
753
  RedisQueuedLocks::Acquirer::IsLocked.locked?(redis_client, lock_name)
664
754
  end
665
755
 
666
756
  # @param lock_name [String]
667
- # @return [Boolean]
757
+ # @return [Boolean] Are there any write or read lock requests.
668
758
  #
669
759
  # @api public
670
760
  # @since 1.0.0
761
+ # @version 1.18.0
671
762
  def queued?(lock_name)
672
763
  RedisQueuedLocks::Acquirer::IsQueued.queued?(redis_client, lock_name)
673
764
  end
674
765
 
675
766
  # @param lock_name [String]
676
- # @return [Hash<String,String|Numeric>,NilClass]
767
+ # @return [Hash<String,String|Numeric|Array<Hash<String,String|Numeric>>>,NilClass]
768
+ # - write lock info or read locks info (when there is no write lock);
769
+ # - see `RedisQueuedLocks::Acquirer::LockInfo.lock_info` for details;
677
770
  #
678
771
  # @api public
679
772
  # @since 1.0.0
773
+ # @version 1.18.0
680
774
  def lock_info(lock_name)
681
775
  RedisQueuedLocks::Acquirer::LockInfo.lock_info(redis_client, lock_name)
682
776
  end
683
777
 
684
778
  # @param lock_name [String]
685
779
  # @return [Hash<String|Array<Hash<String,String|Numeric>>,NilClass]
780
+ # - write lock requests and read lock requests (when they exist);
781
+ # - see `RedisQueuedLocks::Acquirer::QueueInfo.queue_info` for details;
686
782
  #
687
783
  # @api public
688
784
  # @since 1.0.0
785
+ # @version 1.18.0
689
786
  def queue_info(lock_name)
690
787
  RedisQueuedLocks::Acquirer::QueueInfo.queue_info(redis_client, lock_name)
691
788
  end
@@ -783,6 +880,16 @@ class RedisQueuedLocks::Client
783
880
  #
784
881
  # @param lock_name [String]
785
882
  # @param milliseconds [Integer] How many milliseconds should be added.
883
+ # @option read_write_mode [Symbol]
884
+ # - `:write` (default) - extends the write lock (the method description above);
885
+ # - `:read` - extends the read lock of the current acquirer only (an expired read lock or
886
+ # a read lock replaced by a writer is not extended);
887
+ # @option all_read_locks [Boolean]
888
+ # `:read` mode only (ignored for `:write` mode): extends all live read locks of the lock
889
+ # instead of the read lock of the current acquirer (`false` by default).
890
+ # @option identity [String]
891
+ # Unique acquirer identity (the same as for `#lock`) that is used to calculate the current
892
+ # acquirer id for `:read` mode (see `#current_acquirer_id`).
786
893
  # @option logger [::Logger,#debug]
787
894
  # @option instrumenter [#notify] See `config['instrumenter']` docs for details.
788
895
  # @option instrument [NilClass,Any]
@@ -794,16 +901,21 @@ class RedisQueuedLocks::Client
794
901
  # @option instr_sampling_percent [Integer]
795
902
  # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
796
903
  # @option instr_sample_this [Boolean]
797
- # @return [Hash<Symbol,Boolean|Symbol>]
798
- # - { ok: true, result: :ttl_extended }
904
+ # @return [Hash<Symbol,Boolean|Symbol|Hash<Symbol,Integer>>]
905
+ # - { ok: true, result: { extended_locks_count: Integer } } - the number of extended locks:
906
+ # `1` for the write lock and for the read lock of the current acquirer,
907
+ # the number of extended read locks for `all_read_locks: true`;
799
908
  # - { ok: false, result: :async_expire_or_no_lock }
800
909
  #
801
910
  # @api public
802
911
  # @since 1.0.0
803
- # @version 1.6.0
912
+ # @version 1.18.0
804
913
  def extend_lock_ttl(
805
914
  lock_name,
806
915
  milliseconds,
916
+ read_write_mode: :write,
917
+ all_read_locks: false,
918
+ identity: uniq_identity,
807
919
  logger: config['logger'],
808
920
  instrumenter: config['instrumenter'],
809
921
  instrument: nil,
@@ -820,6 +932,9 @@ class RedisQueuedLocks::Client
820
932
  redis_client,
821
933
  lock_name,
822
934
  milliseconds,
935
+ read_write_mode,
936
+ all_read_locks,
937
+ current_acquirer_id(identity:),
823
938
  logger,
824
939
  instrumenter,
825
940
  instrument,
@@ -928,7 +1043,7 @@ class RedisQueuedLocks::Client
928
1043
  # @example Release locks of a different host/acquirer:
929
1044
  # client.clear_locks_of(
930
1045
  # host_id: "rql:hst:62681/2016/2032/b30ec5e4bea10512",
931
- # acquirer_id: "ral:acq:62681/2016/2024/2032/b30ec5e4bea10512"
1046
+ # acquirer_id: "rql:acq:62681/2016/2024/2032/b30ec5e4bea10512"
932
1047
  # )
933
1048
  #
934
1049
  # @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