legion-cache 1.4.2 → 1.4.3

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.
@@ -5,16 +5,181 @@ require 'legion/logging/helper'
5
5
 
6
6
  module Legion
7
7
  module Cache
8
+ # Shared bounded storage for Memory and Cacheable's process-local fallback.
9
+ # Cleanup exists only while expiring entries exist; clear/close joins it.
10
+ class ExpiringStore
11
+ DEFAULT_MAX_ENTRIES = 10_000
12
+
13
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
14
+ @entries = {}
15
+ @expiry = {}
16
+ @mutex = Mutex.new
17
+ @condition = ConditionVariable.new
18
+ self.max_entries = max_entries
19
+ end
20
+
21
+ def max_entries=(limit)
22
+ raise ArgumentError, 'max_entries must be a positive integer' unless limit.is_a?(Integer) && limit.positive?
23
+
24
+ @mutex.synchronize do
25
+ @max_entries = limit
26
+ evict_oldest while @entries.size > limit
27
+ end
28
+ end
29
+
30
+ def get(key)
31
+ @mutex.synchronize do
32
+ expire_key(key)
33
+ @entries[key]
34
+ end
35
+ end
36
+
37
+ def set(key, value, ttl: nil, unless_exist: false)
38
+ deadline = expiry_deadline(ttl)
39
+ @mutex.synchronize do
40
+ expire_key(key)
41
+ return false if unless_exist && @entries.key?(key)
42
+
43
+ write_entry(key, value, deadline)
44
+ end
45
+ true
46
+ end
47
+
48
+ def cas(key, ttl: nil)
49
+ @mutex.synchronize do
50
+ expire_key(key)
51
+ value = yield @entries[key]
52
+ write_entry(key, value, expiry_deadline(ttl))
53
+ value
54
+ end
55
+ end
56
+
57
+ def mget(keys)
58
+ @mutex.synchronize do
59
+ keys.to_h do |key|
60
+ expire_key(key)
61
+ [key, @entries[key]]
62
+ end
63
+ end
64
+ end
65
+
66
+ def mset(values, ttl: nil)
67
+ deadline = expiry_deadline(ttl)
68
+ @mutex.synchronize { values.each { |key, value| write_entry(key, value, deadline) } }
69
+ true
70
+ end
71
+
72
+ def delete(key)
73
+ @mutex.synchronize do
74
+ expire_key(key)
75
+ present = @entries.key?(key)
76
+ @entries.delete(key)
77
+ @expiry.delete(key)
78
+ @condition.signal
79
+ present
80
+ end
81
+ end
82
+
83
+ def clear
84
+ worker = @mutex.synchronize do
85
+ @entries.clear
86
+ @expiry.clear
87
+ @next_expiry = nil
88
+ thread = @cleanup_thread
89
+ @cleanup_thread = nil
90
+ @condition.broadcast
91
+ thread
92
+ end
93
+ worker&.join
94
+ true
95
+ end
96
+
97
+ def close
98
+ clear
99
+ end
100
+
101
+ # Does not opportunistically expire entries: cleanup reclaims inactive keys.
102
+ def size
103
+ @mutex.synchronize { @entries.size }
104
+ end
105
+
106
+ private
107
+
108
+ def expiry_deadline(ttl)
109
+ return if ttl.nil? || ttl.zero?
110
+ raise ArgumentError, 'ttl must be a finite nonnegative number' unless ttl.is_a?(Numeric) && ttl.real? && ttl.finite? && ttl.positive?
111
+
112
+ monotonic_now + ttl
113
+ end
114
+
115
+ def monotonic_now
116
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
117
+ end
118
+
119
+ def expire_key(key)
120
+ return unless @expiry.key?(key) && @expiry[key] <= monotonic_now
121
+
122
+ @entries.delete(key)
123
+ @expiry.delete(key)
124
+ @condition.signal
125
+ end
126
+
127
+ def write_entry(key, value, deadline)
128
+ if !@entries.key?(key) && @entries.size >= @max_entries
129
+ purge_expired if @next_expiry && @next_expiry <= monotonic_now
130
+ evict_oldest if @entries.size >= @max_entries
131
+ end
132
+ @entries[key] = value
133
+ if deadline
134
+ @expiry[key] = deadline
135
+ @next_expiry = deadline if @next_expiry.nil? || deadline < @next_expiry
136
+ @cleanup_thread ||= Thread.new { cleanup_loop }
137
+ else
138
+ @expiry.delete(key)
139
+ end
140
+ @condition.signal
141
+ end
142
+
143
+ def evict_oldest
144
+ key, = @entries.shift
145
+ @expiry.delete(key)
146
+ @condition.signal
147
+ end
148
+
149
+ def purge_expired
150
+ now = monotonic_now
151
+ @expiry.delete_if do |key, deadline|
152
+ next false if deadline > now
153
+
154
+ @entries.delete(key)
155
+ true
156
+ end
157
+ @next_expiry = @expiry.each_value.min
158
+ end
159
+
160
+ def cleanup_loop
161
+ @mutex.synchronize do
162
+ while @cleanup_thread.equal?(Thread.current)
163
+ purge_expired if @next_expiry && @next_expiry <= monotonic_now
164
+ break if @expiry.empty?
165
+
166
+ delay = @next_expiry - monotonic_now
167
+ @condition.wait(@mutex, delay) if delay.positive?
168
+ end
169
+ @cleanup_thread = nil if @cleanup_thread.equal?(Thread.current)
170
+ end
171
+ end
172
+ end
173
+
8
174
  module Memory
9
175
  extend self
10
176
  extend Legion::Logging::Helper
11
177
 
12
- @store = {}
13
- @expiry = {}
14
- @mutex = Mutex.new
178
+ @store = ExpiringStore.new
15
179
  @connected = Concurrent::AtomicBoolean.new(false)
16
180
 
17
- def setup(**)
181
+ def setup(max_entries: nil, **)
182
+ @store.max_entries = max_entries unless max_entries.nil?
18
183
  @connected.make_true
19
184
  log.info 'Legion::Cache::Memory connected'
20
185
  true
@@ -32,7 +197,7 @@ module Legion
32
197
 
33
198
  def restart(**)
34
199
  shutdown
35
- setup
200
+ setup(**)
36
201
  rescue StandardError => e
37
202
  @connected.make_false
38
203
  handle_exception(e, level: :warn, handled: true, operation: :memory_restart)
@@ -40,12 +205,9 @@ module Legion
40
205
  end
41
206
 
42
207
  def get(key)
43
- @mutex.synchronize do
44
- expire_if_needed(key)
45
- result = @store[key]
46
- log.debug { "[cache:memory] GET #{key} hit=#{!result.nil?}" }
47
- result
48
- end
208
+ result = @store.get(key)
209
+ log.debug { "[cache:memory] GET #{key} hit=#{!result.nil?}" }
210
+ result
49
211
  rescue StandardError => e
50
212
  handle_exception(e, level: :warn, handled: true, operation: :memory_get)
51
213
  nil
@@ -55,50 +217,40 @@ module Legion
55
217
  set_sync(key, value, ttl: ttl, phi: phi)
56
218
  end
57
219
 
58
- def set_nx(key, value, ttl: nil)
59
- @mutex.synchronize do
60
- expire_if_needed(key)
61
- return false if @store.key?(key)
62
-
63
- @store[key] = value
64
- if ttl&.positive?
65
- @expiry[key] = Time.now + ttl
66
- else
67
- @expiry.delete(key)
68
- end
69
- log.debug { "[cache:memory] SET_NX #{key} ttl=#{ttl.inspect} result=true" }
70
- true
71
- end
72
- rescue StandardError => e
73
- handle_exception(e, level: :warn, handled: true, operation: :memory_set_nx)
74
- false
220
+ def set_nx(key, value, ttl: nil, phi: false)
221
+ ttl = enforce_phi_ttl(ttl) if phi
222
+ result = @store.set(key, value, ttl: ttl, unless_exist: true)
223
+ log.debug { "[cache:memory] SET_NX #{key} ttl=#{ttl.inspect} result=#{result}" }
224
+ result
75
225
  end
76
226
 
77
227
  def set_sync(key, value, ttl: nil, phi: false)
78
- ttl = enforce_phi_ttl(ttl, phi: phi) if phi
79
- @mutex.synchronize do
80
- @store[key] = value
81
- if ttl&.positive?
82
- @expiry[key] = Time.now + ttl
83
- else
84
- @expiry.delete(key)
85
- end
86
- log.debug { "[cache:memory] SET #{key} ttl=#{ttl.inspect}" }
87
- true
88
- end
228
+ ttl = enforce_phi_ttl(ttl) if phi
229
+ @store.set(key, value, ttl: ttl)
230
+ log.debug { "[cache:memory] SET #{key} ttl=#{ttl.inspect}" }
231
+ true
232
+ end
233
+
234
+ def cas(key, ttl: nil, phi: false, &)
235
+ ttl = enforce_phi_ttl(ttl) if phi
236
+ value = @store.cas(key, ttl: ttl, &)
237
+ log.debug { "[cache:memory] CAS #{key} ttl=#{ttl.inspect}" }
238
+ value
89
239
  end
90
240
 
91
- def fetch(key, ttl: nil, &block)
241
+ def fetch(key, ttl: nil, phi: false, &block)
242
+ ttl = enforce_phi_ttl(ttl) if phi
92
243
  val = get(key)
93
244
  return val unless val.nil?
94
245
 
95
246
  log.debug { "[cache:memory] FETCH #{key} miss=true" }
96
247
  val = block&.call
97
- set(key, val, ttl: ttl)
248
+ begin
249
+ set(key, val, ttl: ttl)
250
+ rescue StandardError => e
251
+ handle_exception(e, level: :warn, handled: true, operation: :memory_fetch_write)
252
+ end
98
253
  val
99
- rescue StandardError => e
100
- handle_exception(e, level: :warn, handled: true, operation: :memory_fetch)
101
- nil
102
254
  end
103
255
 
104
256
  def delete(key, async: true) # rubocop:disable Lint/UnusedMethodArgument
@@ -106,59 +258,29 @@ module Legion
106
258
  end
107
259
 
108
260
  def delete_sync(key)
109
- @mutex.synchronize do
110
- removed = @store.delete(key)
111
- @expiry.delete(key)
112
- log.debug { "[cache:memory] DELETE #{key} success=#{!removed.nil?}" }
113
- !removed.nil?
114
- end
261
+ removed = @store.delete(key)
262
+ log.debug { "[cache:memory] DELETE #{key} success=#{removed}" }
263
+ removed
115
264
  end
116
265
 
117
266
  def mget(*keys)
118
- keys = keys.flatten
119
- return {} if keys.empty?
120
-
121
- @mutex.synchronize do
122
- keys.each { |k| expire_if_needed(k) }
123
- keys.to_h { |k| [k, @store[k]] }
124
- end
267
+ @store.mget(keys.flatten)
125
268
  rescue StandardError => e
126
269
  handle_exception(e, level: :warn, handled: true, operation: :memory_mget)
127
270
  {}
128
271
  end
129
272
 
130
- def mset(hash, ttl: nil, async: true)
131
- return true if hash.empty?
132
-
133
- hash.each { |k, v| set(k, v, ttl: ttl, async: async) }
134
- true
135
- rescue StandardError => e
136
- handle_exception(e, level: :warn, handled: true, operation: :memory_mset)
137
- true
273
+ def mset(hash, ttl: nil, async: true, phi: false) # rubocop:disable Lint/UnusedMethodArgument
274
+ mset_sync(hash, ttl: ttl, phi: phi)
138
275
  end
139
276
 
140
277
  def mset_sync(hash, ttl: nil, phi: false)
141
- return true if hash.empty?
142
-
143
- @mutex.synchronize do
144
- hash.each do |key, value|
145
- effective_ttl = phi ? enforce_phi_ttl(ttl, phi: true) : ttl
146
- @store[key] = value
147
- if effective_ttl&.positive?
148
- @expiry[key] = Time.now + effective_ttl
149
- else
150
- @expiry.delete(key)
151
- end
152
- end
153
- true
154
- end
278
+ ttl = enforce_phi_ttl(ttl) if phi
279
+ @store.mset(hash, ttl: ttl)
155
280
  end
156
281
 
157
282
  def flush
158
- @mutex.synchronize do
159
- @store.clear
160
- @expiry.clear
161
- end
283
+ @store.clear
162
284
  log.info 'Legion::Cache::Memory flushed'
163
285
  true
164
286
  rescue StandardError => e
@@ -166,10 +288,12 @@ module Legion
166
288
  false
167
289
  end
168
290
 
169
- def close = nil
291
+ def close
292
+ shutdown
293
+ end
170
294
 
171
295
  def shutdown
172
- flush
296
+ @store.close
173
297
  @connected.make_false
174
298
  log.info 'Legion::Cache::Memory shut down'
175
299
  false
@@ -180,11 +304,9 @@ module Legion
180
304
  end
181
305
 
182
306
  def reset!
183
- @mutex.synchronize do
184
- @connected.make_false
185
- @store.clear
186
- @expiry.clear
187
- end
307
+ @store.clear
308
+ @store.max_entries = ExpiringStore::DEFAULT_MAX_ENTRIES
309
+ @connected.make_false
188
310
  log.info 'Legion::Cache::Memory state reset'
189
311
  false
190
312
  end
@@ -194,24 +316,18 @@ module Legion
194
316
 
195
317
  private
196
318
 
197
- def expire_if_needed(key)
198
- return unless @expiry.key?(key) && Time.now > @expiry[key]
199
-
200
- @store.delete(key)
201
- @expiry.delete(key)
202
- log.debug { "[cache:memory] EXPIRE #{key}" }
203
- end
204
-
205
- def enforce_phi_ttl(ttl, phi: false)
206
- return ttl unless phi
207
-
319
+ def enforce_phi_ttl(ttl)
208
320
  max = if defined?(Legion::Settings)
209
- Legion::Settings.dig(:cache, :compliance, :phi_max_ttl) || 3600
321
+ Legion::Settings.dig(:cache, :compliance, :phi_max_ttl)
210
322
  else
211
323
  3600
212
324
  end
213
- result = ttl.nil? ? max : [ttl, max].min
214
- [result, 1].max
325
+ max = 3600 if max.nil?
326
+ raise ArgumentError, 'phi_max_ttl must be a finite positive number' unless max.is_a?(Numeric) && max.real? && max.finite? && max.positive?
327
+ return max if ttl.nil?
328
+ raise ArgumentError, 'ttl must be a finite number' unless ttl.is_a?(Numeric) && ttl.real? && ttl.finite?
329
+
330
+ ttl.positive? ? [ttl, max].min : max
215
331
  end
216
332
  end
217
333
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'connection_pool'
4
+ require 'concurrent'
5
+ require 'timeout'
6
+ require 'socket'
4
7
  require 'legion/logging/helper'
5
8
 
6
9
  module Legion
@@ -10,10 +13,7 @@ module Legion
10
13
  extend Legion::Logging::Helper
11
14
 
12
15
  def connected?
13
- return false unless defined?(@connected)
14
- return @connected.true? if @connected.respond_to?(:true?)
15
-
16
- @connected == true
16
+ @connected&.value == true
17
17
  end
18
18
 
19
19
  def size
@@ -33,27 +33,44 @@ module Legion
33
33
  end
34
34
 
35
35
  def close
36
- return unless @client
37
-
38
- @client.shutdown(&:close)
36
+ pool = @client
39
37
  @client = nil
40
- @connected = false
38
+ cache_mark_connected(false)
39
+ pool&.shutdown(&:close)
41
40
  log.info "#{pool_log_name} pool closed"
42
41
  end
43
42
 
44
- def restart(**opts)
43
+ def restart(**)
45
44
  close
46
- @client = nil
47
- client_hash = opts
48
- client_hash[:pool_size] = opts[:pool_size] if opts.key? :pool_size
49
- client_hash[:timeout] = opts[:timeout] if opts.key? :timeout
50
- client(**client_hash)
51
- @connected = true
45
+ client(**)
52
46
  log.info "#{pool_log_name} pool restarted"
53
47
  end
54
48
 
55
49
  private
56
50
 
51
+ def cache_mark_connected(value)
52
+ @connected ||= Concurrent::AtomicBoolean.new(false)
53
+ @connected.value = value
54
+ end
55
+
56
+ def cache_backend_failed(error)
57
+ return unless cache_connection_error?(error)
58
+
59
+ cache_mark_connected(false)
60
+ @cache_failure_callback&.call(error)
61
+ end
62
+
63
+ def cache_connection_error?(error)
64
+ return true if error.is_a?(IOError) || error.is_a?(SystemCallError) || error.is_a?(Timeout::Error) || error.is_a?(SocketError)
65
+ return true if error.is_a?(ConnectionPool::TimeoutError)
66
+ return error.errors.values.any? { |nested| cache_connection_error?(nested) } if error.instance_of?(::Redis::Cluster::CommandErrorCollection)
67
+
68
+ error.class.ancestors.any? do |type|
69
+ %w[Dalli::NetworkError Dalli::RingError Redis::BaseConnectionError RedisClient::ConnectionError
70
+ Redis::Cluster::InitialSetupError Redis::Cluster::NodeMightBeDown].include?(type.name)
71
+ end
72
+ end
73
+
57
74
  def pool_log_name
58
75
  if respond_to?(:name)
59
76
  label = name.to_s
@@ -10,6 +10,7 @@ module Legion
10
10
 
11
11
  DEFAULT_INITIAL_DELAY = 1
12
12
  DEFAULT_MAX_DELAY = 60
13
+ STOP_TIMEOUT = 1
13
14
 
14
15
  def initialize(tier:, connect_block:, enabled_block:, settings_key: :cache)
15
16
  @tier = tier
@@ -19,13 +20,17 @@ module Legion
19
20
  @attempts = Concurrent::AtomicFixnum.new(0)
20
21
  @thread = nil
21
22
  @mutex = Mutex.new
23
+ @wake = ConditionVariable.new
24
+ @stopping = false
22
25
  @stop_signal = Concurrent::AtomicBoolean.new(false)
23
26
  @next_retry_at = nil
24
27
  end
25
28
 
26
29
  def start
30
+ return unless configured_enabled?
31
+
27
32
  @mutex.synchronize do
28
- return if running?
33
+ return if @stopping || running?
29
34
 
30
35
  @stop_signal.make_false
31
36
  @thread = Thread.new { reconnect_loop }
@@ -34,13 +39,25 @@ module Legion
34
39
  end
35
40
 
36
41
  def stop
37
- thread_to_join = nil
38
- @mutex.synchronize do
42
+ thread_to_join = @mutex.synchronize do
43
+ @stopping = true unless @thread == Thread.current
39
44
  @stop_signal.make_true
40
- thread_to_join = @thread
41
- @thread = nil
45
+ @wake.broadcast
46
+ @thread
47
+ end
48
+ Thread.exit if thread_to_join == Thread.current
49
+
50
+ if thread_to_join && !thread_to_join.join(STOP_TIMEOUT)
51
+ thread_to_join.kill
52
+ raise ThreadError, "Legion::Cache::Reconnector[#{@tier}] could not stop its connection callback" unless thread_to_join.join(STOP_TIMEOUT)
53
+ end
54
+
55
+ @mutex.synchronize do
56
+ if @thread == thread_to_join
57
+ @thread = nil
58
+ @stopping = false
59
+ end
42
60
  end
43
- thread_to_join&.join(5)
44
61
  log.info "Legion::Cache::Reconnector[#{@tier}] stopped"
45
62
  end
46
63
 
@@ -60,19 +77,19 @@ module Legion
60
77
  delay = configured_initial_delay
61
78
 
62
79
  until @stop_signal.true?
63
- unless @enabled_block.call
64
- sleep 1
80
+ unless configured_enabled? && @enabled_block.call
81
+ wait_for_retry(1)
65
82
  next
66
83
  end
67
84
 
68
85
  begin
69
86
  @next_retry_at = Time.now + delay
70
- sleep delay
71
- return if @stop_signal.true?
87
+ return unless wait_for_retry(delay)
88
+ next unless configured_enabled? && @enabled_block.call
72
89
 
73
90
  @connect_block.call
74
91
  count = @attempts.value
75
- @attempts = Concurrent::AtomicFixnum.new(0)
92
+ @attempts.value = 0
76
93
  @next_retry_at = nil
77
94
  log.info "Legion::Cache::Reconnector[#{@tier}] reconnected after #{count} attempts"
78
95
  return
@@ -86,6 +103,28 @@ module Legion
86
103
  end
87
104
  rescue StandardError => e
88
105
  handle_exception(e, level: :error, handled: true, operation: :"reconnector_#{@tier}_loop")
106
+ ensure
107
+ @next_retry_at = nil
108
+ end
109
+
110
+ def wait_for_retry(delay)
111
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
112
+ @mutex.synchronize do
113
+ until @stop_signal.true?
114
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
115
+ return true unless remaining.positive?
116
+
117
+ @wake.wait(@mutex, remaining)
118
+ end
119
+ end
120
+ false
121
+ end
122
+
123
+ def configured_enabled?
124
+ return true unless defined?(Legion::Settings)
125
+
126
+ Legion::Settings.dig(@settings_key, :enabled) != false &&
127
+ Legion::Settings.dig(@settings_key, :reconnect, :enabled) != false
89
128
  end
90
129
 
91
130
  def configured_initial_delay