redis_queued_locks 1.16.2 → 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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +281 -0
  3. data/.claude/rules/acquirer.md +81 -0
  4. data/.claude/rules/arguments.md +60 -0
  5. data/.claude/rules/git.md +46 -0
  6. data/.claude/rules/logic.md +63 -0
  7. data/.claude/rules/swarm.md +120 -0
  8. data/.claude/rules/tests.md +48 -0
  9. data/.claude/rules/type-checking.md +50 -0
  10. data/.claude/rules/visitors.md +69 -0
  11. data/.rubocop.rbs.yml +31 -0
  12. data/.rubocop.yml +3 -3
  13. data/.ruby-version +1 -1
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +36 -0
  16. data/README.md +367 -82
  17. data/Rakefile +31 -2
  18. data/github_ci/.keep +0 -0
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/delay_execution.rb +2 -2
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +6 -3
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +9 -7
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +20 -10
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +34 -16
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +240 -28
  25. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +398 -132
  26. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +12 -6
  27. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +33 -9
  28. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +73 -24
  29. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +9 -1
  30. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +182 -3
  31. data/lib/redis_queued_locks/acquirer/is_locked.rb +19 -2
  32. data/lib/redis_queued_locks/acquirer/is_queued.rb +6 -2
  33. data/lib/redis_queued_locks/acquirer/lock_info.rb +96 -6
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +8 -4
  35. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -7
  36. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +126 -29
  37. data/lib/redis_queued_locks/acquirer/locks.rb +103 -6
  38. data/lib/redis_queued_locks/acquirer/queue_info.rb +38 -9
  39. data/lib/redis_queued_locks/acquirer/queues.rb +28 -4
  40. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +35 -1
  41. data/lib/redis_queued_locks/acquirer/release_lock.rb +40 -9
  42. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +43 -3
  43. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +143 -0
  44. data/lib/redis_queued_locks/acquirer.rb +1 -0
  45. data/lib/redis_queued_locks/client.rb +274 -158
  46. data/lib/redis_queued_locks/config/dsl.rb +4 -10
  47. data/lib/redis_queued_locks/resource.rb +117 -9
  48. data/lib/redis_queued_locks/swarm/flush_zombies.rb +46 -6
  49. data/lib/redis_queued_locks/swarm/supervisor.rb +1 -1
  50. data/lib/redis_queued_locks/swarm/swarm_element/isolated.rb +166 -44
  51. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +45 -47
  52. data/lib/redis_queued_locks/swarm/zombie_info.rb +42 -0
  53. data/lib/redis_queued_locks/version.rb +2 -2
  54. data/rbs_collection.lock.yaml +10 -2
  55. data/rbs_collection.yaml +0 -2
  56. data/redis_queued_locks.gemspec +1 -1
  57. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -1
  58. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +0 -1
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +5 -0
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +12 -6
  61. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +74 -7
  62. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +4 -2
  63. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -0
  64. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_with_expire/log_visitor.rbs +4 -2
  65. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +19 -1
  66. data/sig/redis_queued_locks/acquirer/lock_info.rbs +9 -1
  67. data/sig/redis_queued_locks/acquirer/locks.rbs +10 -1
  68. data/sig/redis_queued_locks/acquirer/queue_info.rbs +2 -4
  69. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  70. data/sig/redis_queued_locks/acquirer/release_lock.rbs +8 -1
  71. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +44 -0
  72. data/sig/redis_queued_locks/client.rbs +22 -2
  73. data/sig/redis_queued_locks/resource.rbs +9 -2
  74. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +1 -1
  75. data/sig/redis_queued_locks/swarm/swarm_element/isolated.rbs +13 -5
  76. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +6 -6
  77. data/sig/redis_queued_locks/swarm/zombie_info.rbs +5 -0
  78. metadata +17 -5
  79. data/github_ci/ruby3.3.gemfile +0 -17
  80. data/github_ci/ruby3.3.gemfile.lock +0 -216
@@ -6,29 +6,23 @@ module RedisQueuedLocks::Config::DSL
6
6
  # @api private
7
7
  # @since 1.13.0
8
8
  module ClassMethods
9
- # NOTE:
10
- # 1. Style/DefWithParentheses rubocop's cop incorrectly drops `()` from method definition
11
- # and breaks ruby code syntax. So this cop is disabled here;
12
- # 2. attr_reader is not used cuz `steep` can't understand it form inside the class where
9
+ # NOTE: attr_reader is not used cuz `steep` can't understand it form inside the class where
13
10
  # the current module is mixed;
14
11
  #
15
12
  # @return [Hash<String,Block>]
16
13
  #
17
14
  # @api private
18
15
  # @since 1.13.0
19
- def config_setters()= @config_setters
16
+ def config_setters = @config_setters
20
17
 
21
- # NOTE:
22
- # 1. Style/DefWithParentheses rubocop's cop incorrectly drops `()` from method definition
23
- # and breaks ruby code syntax. So this cop is disabled here;
24
- # 2. attr_reader is not used cuz `steep` can't understand it form inside the class where
18
+ # NOTE: attr_reader is not used cuz `steep` can't understand it form inside the class where
25
19
  # the current module is mixed;
26
20
  #
27
21
  # @return [Hash<String,Block>]
28
22
  #
29
23
  # @api private
30
24
  # @since 1.13.0
31
- def config_validators()= @config_validators
25
+ def config_validators = @config_validators
32
26
 
33
27
  # @param config_key [String]
34
28
  # @param validator [Block]
@@ -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
 
@@ -100,19 +137,21 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
100
137
  rql_client.config['swarm.flush_zombies.enabled_for_swarm']
101
138
  end
102
139
 
103
- # @return [void]
140
+ # @param swarm_element_results_port [Ractor::Port] Results port of the main Ractor.
141
+ # @return [Ractor]
104
142
  #
105
143
  # @api private
106
- # @since 1.9.0
107
- def swarm!
108
- @swarm_element = Ractor.new(
144
+ # @since 1.17.0
145
+ def spawn_swarm_element!(swarm_element_results_port)
146
+ Ractor.new(
147
+ swarm_element_results_port,
109
148
  rql_client.config.slice('swarm.flush_zombies.redis_config'),
110
149
  rql_client.config['swarm.flush_zombies.zombie_ttl'],
111
150
  rql_client.config['swarm.flush_zombies.zombie_lock_scan_size'],
112
151
  rql_client.config['swarm.flush_zombies.zombie_queue_scan_size'],
113
152
  rql_client.config['swarm.flush_zombies.zombie_flush_period']
114
- ) do |rc, z_ttl, z_lss, z_qss, z_fl_prd|
115
- RedisQueuedLocks::Swarm::FlushZombies.swarm_loop do
153
+ ) do |r_res_p, rc, z_ttl, z_lss, z_qss, z_fl_prd|
154
+ RedisQueuedLocks::Swarm::FlushZombies.swarm_loop(r_res_p) do
116
155
  Thread.new do
117
156
  redis_client = RedisQueuedLocks::Swarm::RedisClientBuilder.build(
118
157
  pooled: rc['pooled'],
@@ -132,3 +171,4 @@ class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElem
132
171
  end
133
172
  end
134
173
  end
174
+ # rubocop:enable Metrics/ClassLength
@@ -89,7 +89,7 @@ class RedisQueuedLocks::Swarm::Supervisor
89
89
 
90
90
  {
91
91
  running: running?,
92
- state: (visor == nil) ? 'non_initialized' : thread_state(visor), # steep:ignore
92
+ state: (visor == nil) ? 'non_initialized' : thread_state(visor),
93
93
  observable: (observable == nil) ? 'non_initialized' : 'initialized'
94
94
  }
95
95
  end
@@ -1,7 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Swarm element isolated inside its own Ractor. Communication works via `Ractor::Port`s:
4
+ # - `swarm_element_results_port`: created in the main Ractor (where the Swarm and its Supervisor
5
+ # live), receives element replies and the element ractor termination notice (`:exited`/`:aborted`,
6
+ # registered via `Ractor#monitor`);
7
+ # - `swarm_element_commands_port`: created inside the element ractor (only the creator ractor can
8
+ # receive from a port) and handed over to the main Ractor during the element startup;
9
+ # Each element instance owns its own pair of ports, so any number of isolated elements can work
10
+ # side by side.
11
+ #
3
12
  # @api private
4
13
  # @since 1.9.0
14
+ # @version 1.17.0
5
15
  # rubocop:disable Metrics/ClassLength
6
16
  class RedisQueuedLocks::Swarm::SwarmElement::Isolated
7
17
  # @since 1.9.0
@@ -19,6 +29,22 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
19
29
  # @since 1.9.0
20
30
  attr_reader :swarm_element
21
31
 
32
+ # Port of the element ractor that receives commands (created inside the element ractor).
33
+ #
34
+ # @return [Ractor::Port,NilClass]
35
+ #
36
+ # @api private
37
+ # @since 1.17.0
38
+ attr_reader :swarm_element_commands_port
39
+
40
+ # Port of the main Ractor that receives element replies and the element termination notice.
41
+ #
42
+ # @return [Ractor::Port,NilClass]
43
+ #
44
+ # @api private
45
+ # @since 1.17.0
46
+ attr_reader :swarm_element_results_port
47
+
22
48
  # @return [RedisQueuedLocks::Utilities::Lock]
23
49
  #
24
50
  # @api private
@@ -30,9 +56,12 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
30
56
  #
31
57
  # @api private
32
58
  # @since 1.9.0
59
+ # @version 1.17.0
33
60
  def initialize(rql_client)
34
61
  @rql_client = rql_client
35
62
  @swarm_element = nil
63
+ @swarm_element_commands_port = nil
64
+ @swarm_element_results_port = nil
36
65
  @sync = RedisQueuedLocks::Utilities::Lock.new
37
66
  end
38
67
 
@@ -100,19 +129,18 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
100
129
  #
101
130
  # @api private
102
131
  # @since 1.9.0
132
+ # @version 1.17.0
103
133
  def status
104
134
  sync.synchronize do
105
135
  ractor_running = swarmed__alive?
106
136
  ractor_state = swarmed? ? ractor_status(swarm_element) : 'non_initialized' # steep:ignore
107
137
 
108
- begin
109
- main_loop_running = swarmed__running?
110
- # steep:ignore:start
111
- main_loop_state =
112
- main_loop_running ? swarm_loop__status[:main_loop][:state] : 'non_initialized'
113
- # steep:ignore:end
114
- rescue Ractor::ClosedError
115
- # NOTE: it can happend when you running RedisQueuedLocks::Swarm#deswarm!
138
+ # NOTE: `nil` when the element ractor is not alive (or has died during the request);
139
+ loop_status = swarm_loop__status
140
+ if loop_status && loop_status[:alive]
141
+ main_loop_running = true
142
+ main_loop_state = loop_status[:state]
143
+ else
116
144
  main_loop_running = false
117
145
  main_loop_state = 'non_initialized'
118
146
  end
@@ -134,38 +162,79 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
134
162
  private
135
163
 
136
164
  # Swarm element lifecycle have the following scheme:
137
- # => 1) init (swarm!): create a ractor, main loop is not started;
138
- # => 2) start (swarm_loop__start!): run main lopp inside the ractor;
139
- # => 3) stop (swarm_loop__stop!): stop the main loop inside a ractor;
140
- # => 4) kill (swarm_loop__kill!): kill the main loop inside teh ractor and kill a ractor;
165
+ # => 1) init (swarm!): create a results port and a ractor (main loop is not started),
166
+ # receive the ractor's command port;
167
+ # => 2) start (swarm_loop__start): run the main loop inside the ractor;
168
+ # => 3) stop (swarm_loop__stop): stop the main loop inside the ractor;
169
+ # => 4) kill (swarm_loop__kill): kill the main loop inside the ractor and finish the ractor;
141
170
  #
142
171
  # @return [void]
143
172
  #
144
173
  # @api private
145
174
  # @since 1.9.0
175
+ # @version 1.17.0
146
176
  def swarm!
147
- # IMPORTANT №1: initialize @swarm_element here with Ractor;
148
- # IMPORTANT №2: your Ractor should invoke .swarm_loop inside (see below);
149
- # IMPORTANT №3: you should pass the main loop logic as a block to .swarm_loop;
177
+ # NOTE: the results port is created in the current (main) Ractor: only it can receive from it;
178
+ results = Ractor::Port.new
179
+ element = spawn_swarm_element!(results)
180
+ @swarm_element_results_port = results
181
+ @swarm_element = element
182
+ # NOTE: `:exited`/`:aborted` will be sent to the results port when the ractor is finished
183
+ # (immediately if it is already finished), so no request can wait for a reply forever;
184
+ element.monitor(results)
185
+ # NOTE: the first message from the element ractor is its command port (see .swarm_loop),
186
+ # any other message is the termination notice (the ractor has died during the startup);
187
+ handshake = results.receive
188
+ @swarm_element_commands_port = handshake.is_a?(Ractor::Port) ? handshake : nil
189
+ end
190
+
191
+ # @param swarm_element_results_port [Ractor::Port] Results port of the main Ractor.
192
+ # @return [Ractor]
193
+ #
194
+ # @api private
195
+ # @since 1.17.0
196
+ def spawn_swarm_element!(swarm_element_results_port)
197
+ # IMPORTANT №1: create and return a Ractor here (pass `swarm_element_results_port` and all
198
+ # required configs into `Ractor.new` as shareable/copyable values);
199
+ # IMPORTANT №2: your Ractor should invoke .swarm_loop(swarm_element_results_port) inside
200
+ # (see below);
201
+ # IMPORTANT №3: you should pass the main loop logic as a block to .swarm_loop
202
+ # (the block should return a Thread that wraps the looped logic);
203
+ raise NotImplementedError, "#{self.class}#spawn_swarm_element! is not implemented"
150
204
  end
151
205
 
206
+ # Internal protocol (bare scalars/primitives, no `{ ok:, result: }` wrapper):
207
+ # - handshake: the command port itself (the first message on the results port);
208
+ # - `:status` => `{ alive: <Boolean>, state: <String> }`;
209
+ # - `:is_active` => `true`/`false`;
210
+ # - `:start`, `:stop`, `:kill` => `true` (ack);
211
+ # - Symbol messages on the results port are reserved for the Ractor#monitor notice;
212
+ #
213
+ # @param swarm_element_results_port [Ractor::Port] Results port of the main Ractor.
152
214
  # @param main_loop_spawner [Block]
153
215
  # @return [void]
154
216
  #
155
217
  # @api private
156
218
  # @since 1.9.0
219
+ # @version 1.17.0
157
220
  # rubocop:disable Layout/ClassStructure, Lint/IneffectiveAccessModifier, Metrics/MethodLength
158
- def self.swarm_loop(&main_loop_spawner)
221
+ def self.swarm_loop(swarm_element_results_port, &main_loop_spawner)
159
222
  # NOTE:
160
223
  # This self.-related part of code is placed in the middle of class in order
161
224
  # to provide better code readability (it is placed next to the method inside
162
- # wich it should be called (see #swarm!)). That's why some rubocop cops are disabled.
225
+ # wich it should be called (see #spawn_swarm_element!)). That's why some rubocop
226
+ # cops are disabled.
227
+
228
+ # NOTE: the command port should be created inside the element ractor (only the creator
229
+ # ractor can receive from the port), so it is handed over to the main Ractor at startup;
230
+ swarm_element_commands_port = Ractor::Port.new
231
+ swarm_element_results_port << swarm_element_commands_port
163
232
 
164
233
  # @type var main_loop: Thread?
165
234
  main_loop = nil
166
235
 
167
236
  loop do
168
- command = Ractor.receive
237
+ command = swarm_element_commands_port.receive
169
238
 
170
239
  case command
171
240
  when :status
@@ -177,25 +246,46 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
177
246
  # @type var main_loop: Thread
178
247
  RedisQueuedLocks::Utilities.thread_state(main_loop)
179
248
  end
180
- # NOTE: (will be reworked with Ractor::Port in the next RQL release (~1.17))
181
- Ractor.yield({ # steep:ignore
182
- main_loop: { alive: main_loop_alive, state: main_loop_state }
183
- })
249
+ swarm_element_results_port << { alive: main_loop_alive, state: main_loop_state }
184
250
  when :is_active
185
- Ractor.yield(main_loop != nil && main_loop.alive?) # steep:ignore
251
+ swarm_element_results_port << (main_loop != nil && main_loop.alive?) # steep:ignore
186
252
  when :start
187
- main_loop.kill if main_loop != nil # steep:ignore
188
- main_loop = yield # REFERENCE: `main_loop_spawner.call`
253
+ terminate_thread(main_loop)
254
+ # REFERENCE: `main_loop_spawner.call`
255
+ main_loop = yield.tap { |thread| thread.abort_on_exception = false }
256
+ swarm_element_results_port << true
189
257
  when :stop
190
- main_loop.kill if main_loop != nil # steep:ignore
258
+ terminate_thread(main_loop)
259
+ swarm_element_results_port << true
191
260
  when :kill
192
- main_loop.kill if main_loop != nil # steep:ignore
193
- exit
261
+ # NOTE: terminate the main loop and all auxiliary threads it may have left behind (for
262
+ # example, socket connection helper threads when the loop is killed during connection):
263
+ # the ractor stays alive while it has unfinished threads;
264
+ (::Thread.list - [::Thread.current]).each { |thread| terminate_thread(thread) }
265
+ swarm_element_results_port << true
266
+ break
194
267
  end
195
268
  end
196
269
  end
197
270
  # rubocop:enable Layout/ClassStructure, Lint/IneffectiveAccessModifier, Metrics/MethodLength
198
271
 
272
+ # Kills the thread and waits for its termination: the ractor can not finish
273
+ # (and its status stays "running") while it has unfinished threads.
274
+ #
275
+ # @param thread [Thread,NilClass]
276
+ # @return [void]
277
+ #
278
+ # @api private
279
+ # @since 1.17.0
280
+ # rubocop:disable Lint/IneffectiveAccessModifier
281
+ def self.terminate_thread(thread)
282
+ return if thread == nil
283
+ thread.kill
284
+ # NOTE: join re-raises the exception that has failed the thread: it is not our case here;
285
+ thread.join rescue nil
286
+ end
287
+ # rubocop:enable Lint/IneffectiveAccessModifier
288
+
199
289
  # @return [Boolean]
200
290
  #
201
291
  # @api private
@@ -232,61 +322,93 @@ class RedisQueuedLocks::Swarm::SwarmElement::Isolated
232
322
  #
233
323
  # @api private
234
324
  # @since 1.9.0
325
+ # @version 1.17.0
235
326
  def swarmed__running?
236
- swarm_element != nil && ractor_alive?(swarm_element) && swarm_loop__is_active # steep:ignore
327
+ swarmed__alive? && swarm_loop__is_active == true
237
328
  end
238
329
 
239
330
  # @return [Boolean]
240
331
  #
241
332
  # @api private
242
333
  # @since 1.9.0
334
+ # @version 1.17.0
243
335
  def swarmed__stopped?
244
- swarm_element != nil && ractor_alive?(swarm_element) && !swarm_loop__is_active # steep:ignore
336
+ # NOTE: `nil` (the element ractor has died during the request) is neither active nor stopped;
337
+ swarmed__alive? && swarm_loop__is_active == false
245
338
  end
246
339
 
247
- # @return [Boolean]
340
+ # @return [Boolean,NilClass] Is the main loop alive (`nil` when the element is not available).
248
341
  #
249
342
  # @api private
250
343
  # @since 1.9.0
344
+ # @version 1.17.0
251
345
  def swarm_loop__is_active
252
- return false if idle? || swarmed__dead?
253
- sync.synchronize { swarm_element.send(:is_active).take }
346
+ swarm_loop__send_command(:is_active) #: bool?
254
347
  end
255
348
 
256
- # @return [Hash]
349
+ # @return [Hash<Symbol,Boolean|String>,NilClass]
350
+ # Format: `{ alive: <Boolean>, state: <String> }` (`nil` when the element is not available).
257
351
  #
258
352
  # @api private
259
353
  # @since 1.9.0
354
+ # @version 1.17.0
260
355
  def swarm_loop__status
261
- return if idle? || swarmed__dead?
262
- sync.synchronize { swarm_element.send(:status).take }
356
+ swarm_loop__send_command(:status) #: swarmLoopStatus?
263
357
  end
264
358
 
265
359
  # @return [void]
266
360
  #
267
361
  # @api private
268
362
  # @since 1.9.0
363
+ # @version 1.17.0
269
364
  def swarm_loop__start
270
- return if idle? || swarmed__dead?
271
- sync.synchronize { swarm_element.send(:start) }
365
+ swarm_loop__send_command(:start)
272
366
  end
273
367
 
274
368
  # @return [void]
275
369
  #
276
370
  # @api private
277
- # @since 1.9.0
278
- def swarm_loop__pause
279
- return if idle? || swarmed__dead?
280
- sync.synchronize { swarm_element.send(:stop) }
371
+ # @since 1.17.0
372
+ def swarm_loop__stop
373
+ swarm_loop__send_command(:stop)
281
374
  end
282
375
 
283
376
  # @return [void]
284
377
  #
285
378
  # @api private
286
379
  # @since 1.9.0
380
+ # @version 1.17.0
287
381
  def swarm_loop__kill
288
- return if idle? || swarmed__dead?
289
- sync.synchronize { swarm_element.send(:kill) }
382
+ sync.synchronize do
383
+ # NOTE: wait for the ractor finish only when it has confirmed the kill command;
384
+ killed = swarm_loop__send_command(:kill) == true
385
+ @swarm_element_commands_port = nil
386
+ swarm_element.join if killed # steep:ignore
387
+ end
388
+ rescue Ractor::RemoteError
389
+ # NOTE: the element ractor has been finished with an exception: it is dead anyway;
390
+ end
391
+
392
+ # Sends the command to the element ractor and waits for its reply. Replies are received
393
+ # strictly one by one under the lock, so each reply belongs to the sent command.
394
+ #
395
+ # @param command [Symbol]
396
+ # @return [Boolean,Hash<Symbol,Boolean|String>,NilClass]
397
+ # The bare reply value (see .swarm_loop) or `nil` if the element is dead.
398
+ #
399
+ # @api private
400
+ # @since 1.17.0
401
+ def swarm_loop__send_command(command)
402
+ return if idle? || swarmed__dead? || swarm_element_commands_port == nil
403
+ sync.synchronize do
404
+ swarm_element_commands_port << command
405
+ reply = swarm_element_results_port.receive # steep:ignore
406
+ # NOTE: Symbol replies are reserved for the Ractor#monitor notice (`:exited`/`:aborted`);
407
+ reply.is_a?(Symbol) ? nil : reply
408
+ end
409
+ rescue Ractor::ClosedError
410
+ # NOTE: the command port is closed together with the finished element ractor;
411
+ nil
290
412
  end
291
413
  end
292
414
  # rubocop:enable Metrics/ClassLength