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.
@@ -310,10 +310,11 @@ module Legion
310
310
  end
311
311
 
312
312
  def memcached_hash_merge(full_key, new_fields)
313
- current = memcached_hash_load(full_key) || {}
314
- merged = current.merge(new_fields.transform_keys(&:to_s))
315
- Legion::Cache.set(full_key, Legion::JSON.dump(merged), ttl: cache_default_ttl, async: false)
316
- true
313
+ result = Legion::Cache.cas(full_key, ttl: cache_default_ttl) do |raw|
314
+ current = raw.nil? ? {} : Legion::JSON.load(raw).transform_keys(&:to_s)
315
+ Legion::JSON.dump(current.merge(new_fields.transform_keys(&:to_s)))
316
+ end
317
+ !!result
317
318
  end
318
319
 
319
320
  def memcached_hash_load(full_key)
@@ -329,14 +330,15 @@ module Legion
329
330
  end
330
331
 
331
332
  def memcached_hash_delete_fields(full_key, fields)
332
- current = memcached_hash_load(full_key)
333
- return 0 if current.nil?
334
-
335
- str_fields = fields.map(&:to_s)
336
- removed = str_fields.count { |f| current.key?(f) }
337
- str_fields.each { |f| current.delete(f) }
338
- Legion::Cache.set(full_key, Legion::JSON.dump(current), ttl: cache_default_ttl, async: false)
339
- removed
333
+ removed = 0
334
+ result = Legion::Cache.cas(full_key, ttl: cache_default_ttl) do |raw|
335
+ current = raw.nil? ? {} : Legion::JSON.load(raw).transform_keys(&:to_s)
336
+ str_fields = fields.map(&:to_s).uniq
337
+ removed = str_fields.count { |field| current.key?(field) }
338
+ str_fields.each { |field| current.delete(field) }
339
+ Legion::JSON.dump(current)
340
+ end
341
+ result ? removed : 0
340
342
  end
341
343
 
342
344
  def raise_sorted_set_unsupported(method)
@@ -3,55 +3,58 @@
3
3
  require 'concurrent'
4
4
  require 'legion/logging/helper'
5
5
  require 'legion/cache/settings'
6
+ require 'legion/cache/async_writer'
7
+ require 'legion/cache/pool'
6
8
 
7
9
  module Legion
8
10
  module Cache
9
11
  module Local
10
12
  @connected = Concurrent::AtomicBoolean.new(false)
13
+ @async_writer = Legion::Cache::AsyncWriter.new(settings_key: :cache_local)
11
14
 
12
15
  class << self
13
16
  include Legion::Logging::Helper
17
+ include Legion::Cache::Pool
14
18
 
15
- def setup(**)
16
- return unless enabled?
17
- return if connected?
18
-
19
- settings = local_settings
20
- return unless settings[:enabled]
19
+ def setup(**opts)
20
+ return false unless enabled?
21
+ return true if connected?
21
22
 
23
+ @connection_options = Legion::Cache::Settings.connection_options(@connection_options || {}, opts)
24
+ settings = Legion::Cache::Settings.connection_options(local_settings, @connection_options)
22
25
  driver_name = settings[:driver] || Legion::Cache::Settings.driver
23
26
  @driver_name = Legion::Cache::Settings.normalize_driver(driver_name)
24
27
  @driver = build_driver(driver_name)
25
- @driver.client(**settings, logger: log, **)
26
- @connected.make_true
27
- servers = Array(settings[:servers]).join(', ')
28
- log.info "Legion::Cache::Local connected (#{driver_name}) to #{servers}"
28
+ @driver.instance_variable_set(:@cache_settings, settings)
29
+ @driver.instance_variable_set(:@cache_failure_callback, ->(error) { cache_backend_failed(error) })
30
+ @driver.client(**settings, logger: log)
31
+ @async_writer.start
32
+ cache_mark_connected(true)
33
+ true
29
34
  rescue StandardError => e
35
+ @driver&.close
36
+ @driver = nil
37
+ cache_mark_connected(false)
30
38
  handle_exception(e, level: :warn, handled: true, operation: :cache_local_setup, driver: driver_name)
31
- @connected.make_false
39
+ raise unless cache_connection_error?(e)
40
+
41
+ false
32
42
  end
33
43
 
34
44
  def shutdown
35
- return unless @connected
36
-
37
- log.info 'Shutting down Legion::Cache::Local'
45
+ @async_writer.stop
38
46
  @driver&.close
39
47
  @driver = nil
40
48
  @driver_name = nil
41
- @connected.make_false
49
+ cache_mark_connected(false)
42
50
  end
43
51
 
44
52
  def enabled?
45
- return true unless defined?(Legion::Settings)
46
-
47
- Legion::Settings.dig(:cache_local, :enabled) != false
48
- rescue StandardError => e
49
- handle_exception(e, level: :warn, handled: true, operation: :cache_local_enabled)
50
- true
53
+ !defined?(Legion::Settings) || Legion::Settings.dig(:cache_local, :enabled) != false
51
54
  end
52
55
 
53
56
  def connected?
54
- @connected&.true? || false
57
+ enabled? && @connected.value
55
58
  end
56
59
 
57
60
  def driver_name
@@ -59,154 +62,160 @@ module Legion
59
62
  end
60
63
 
61
64
  def get(key)
62
- result = @driver.get(key)
63
- log.debug { "[cache:local] GET #{key} hit=#{!result.nil?}" }
64
- result
65
- rescue StandardError => e
66
- handle_exception(e, level: :warn, handled: true, operation: :cache_local_get, key: key)
67
- nil
65
+ local_operation(:get, key, failure: nil)
68
66
  end
69
67
 
70
- def set(key, value, ttl: nil, **)
71
- set_sync(key, value, ttl: ttl, **)
68
+ def set(key, value, ttl: nil, async: true, phi: false, **) # rubocop:disable Metrics/ParameterLists
69
+ return false unless enabled? && connected?
70
+
71
+ ttl = local_write_ttl(ttl, phi: phi)
72
+ if async
73
+ @async_writer.enqueue(key: key) { set_sync(key, value, ttl: ttl) }
74
+ else
75
+ set_sync(key, value, ttl: ttl)
76
+ end
72
77
  end
73
78
 
74
- def set_sync(key, value, ttl: nil, **)
75
- effective_ttl = ttl || local_default_ttl
76
- result = @driver.set_sync(key, value, ttl: effective_ttl)
77
- log.debug { "[cache:local] SET #{key} ttl=#{effective_ttl} success=#{result}" }
78
- result
79
- rescue StandardError => e
80
- handle_exception(e, level: :error, handled: false, operation: :cache_local_set_sync, key: key, ttl: effective_ttl)
81
- raise
79
+ def set_sync(key, value, ttl: nil, phi: false, **)
80
+ local_operation(:set_sync, key, value, ttl: local_write_ttl(ttl, phi: phi), failure: false)
82
81
  end
83
82
 
84
- def fetch(key, ttl: nil, &)
85
- result = @driver.fetch(key, ttl: ttl, &)
86
- log.debug { "[cache:local] FETCH #{key} hit=#{!result.nil?}" }
87
- result
88
- rescue StandardError => e
89
- handle_exception(e, level: :warn, handled: true, operation: :cache_local_fetch, key: key, ttl: ttl)
90
- nil
83
+ def set_nx(key, value, ttl: nil, phi: false)
84
+ local_operation(:set_nx, key, value, ttl: local_write_ttl(ttl, phi: phi), failure: false)
85
+ end
86
+
87
+ def cas(key, ttl: nil, phi: false, &)
88
+ local_operation(:cas, key, ttl: local_write_ttl(ttl, phi: phi), failure: false, &)
89
+ end
90
+
91
+ def fetch(key, ttl: nil)
92
+ cached = get(key)
93
+ return cached unless cached.nil?
94
+ return nil unless block_given?
95
+
96
+ value = yield
97
+ set_sync(key, value, ttl: ttl)
98
+ value
91
99
  end
92
100
 
93
- def delete(key, **)
94
- delete_sync(key)
101
+ def delete(key, async: true, **)
102
+ return false unless enabled? && connected?
103
+
104
+ if async
105
+ @async_writer.enqueue(key: key) { delete_sync(key) }
106
+ else
107
+ delete_sync(key)
108
+ end
95
109
  end
96
110
 
97
111
  def delete_sync(key)
98
- result = @driver.delete_sync(key)
99
- log.debug { "[cache:local] DELETE #{key} success=#{result}" }
100
- result
101
- rescue StandardError => e
102
- handle_exception(e, level: :error, handled: false, operation: :cache_local_delete_sync, key: key)
103
- raise
112
+ local_operation(:delete_sync, key, failure: false)
104
113
  end
105
114
 
106
115
  def flush
107
- result = @driver.flush
108
- log.debug { '[cache:local] FLUSH completed' }
109
- result
110
- rescue StandardError => e
111
- handle_exception(e, level: :warn, handled: true, operation: :cache_local_flush)
112
- nil
116
+ local_operation(:flush, failure: false)
113
117
  end
114
118
 
115
119
  def mget(*keys)
116
- keys = keys.flatten
117
- return {} if keys.empty?
120
+ return {} unless enabled?
118
121
 
119
- keys.to_h { |key| [key, get(key)] }
122
+ keys.flatten.to_h { |key| [key, get(key)] }
120
123
  end
121
124
 
122
- def mset(hash, ttl: nil, **)
123
- return true if hash.empty?
125
+ def mset(hash, ttl: nil, async: true, phi: false, **)
126
+ return false unless enabled?
124
127
 
125
- hash.each { |key, value| set(key, value, ttl: ttl) }
126
- true
128
+ accepted = true
129
+ hash.each do |key, value|
130
+ result = set(key, value, ttl: ttl, async: async, phi: phi)
131
+ accepted = false unless result
132
+ end
133
+ accepted
134
+ end
135
+
136
+ def mset_sync(hash, ttl: nil, phi: false, **)
137
+ mset(hash, ttl: ttl, async: false, phi: phi)
127
138
  end
128
139
 
129
140
  def stats
130
- {
131
- driver: driver_name,
132
- servers: local_servers,
133
- enabled: enabled?,
134
- connected: connected?
135
- }.freeze
136
- rescue StandardError => e
137
- handle_exception(e, level: :warn, handled: true, operation: :cache_local_stats)
138
- { error: e.message }.freeze
141
+ { driver: driver_name, servers: Array(local_settings[:servers]), enabled: enabled?, connected: connected? }.freeze
139
142
  end
140
143
 
141
- def client
142
- @driver&.client
144
+ def client(**)
145
+ return nil unless enabled?
146
+
147
+ setup(**) unless @driver
148
+ @driver&.client if connected?
143
149
  end
144
150
 
145
151
  def close
146
- @driver&.close
147
- @driver = nil
148
- @driver_name = nil
149
- @connected.make_false
150
- log.info 'Legion::Cache::Local pool closed'
151
- @connected
152
+ shutdown
152
153
  end
153
154
 
154
- def restart(**opts)
155
- settings = local_settings
156
- @driver&.restart(**settings.merge(opts, logger: log))
157
- @connected.make_true
158
- log.info 'Legion::Cache::Local pool restarted'
159
- @connected
155
+ def restart(**)
156
+ shutdown
157
+ setup(**)
160
158
  end
161
159
 
162
160
  def size
163
- @driver.size
161
+ connected? ? @driver.size : 0
164
162
  end
165
163
 
166
164
  def available
167
- @driver.available
165
+ connected? ? @driver.available : 0
168
166
  end
169
167
 
170
168
  def pool_size
171
- @driver.pool_size
169
+ connected? ? @driver.pool_size : 0
172
170
  end
173
171
 
174
172
  def timeout
175
- @driver.timeout
173
+ connected? ? @driver.timeout : 0
176
174
  end
177
175
 
178
176
  def reset!
179
- @driver = nil
180
- @driver_name = nil
181
- @connected.make_false
182
- log.debug 'Legion::Cache::Local state reset'
183
- @connected
177
+ shutdown
178
+ @connection_options = nil
184
179
  end
185
180
 
186
181
  private
187
182
 
188
- def local_servers
189
- Array(local_settings[:servers])
190
- rescue StandardError
191
- []
183
+ def local_operation(operation, *, failure:, **, &block)
184
+ return failure unless enabled? && connected? && @driver
185
+
186
+ user_error = nil
187
+ guarded = if block
188
+ proc do |value|
189
+ block.call(value)
190
+ rescue StandardError => e
191
+ user_error = e
192
+ raise
193
+ end
194
+ end
195
+ @driver.public_send(operation, *, **, &guarded)
196
+ rescue StandardError => e
197
+ raise if e.equal?(user_error) || !cache_connection_error?(e)
198
+
199
+ cache_backend_failed(e)
200
+ handle_exception(e, level: :warn, handled: true, operation: operation)
201
+ failure
192
202
  end
193
203
 
194
- def local_default_ttl
195
- return 21_600 unless defined?(Legion::Settings)
204
+ def local_write_ttl(ttl, phi:)
205
+ effective = ttl || local_settings[:default_ttl] || 21_600
206
+ return effective unless phi
196
207
 
197
- Legion::Settings.dig(:cache_local, :default_ttl) || 21_600
198
- rescue StandardError
199
- 21_600
208
+ Legion::Cache.enforce_phi_ttl(effective, phi: phi)
200
209
  end
201
210
 
202
211
  def build_driver(driver_name)
203
212
  case Legion::Cache::Settings.normalize_driver(driver_name)
204
213
  when 'redis'
205
214
  require 'legion/cache/redis'
206
- Legion::Cache::Redis.dup
215
+ Object.new.extend(Legion::Cache::Redis, Legion::Logging::Helper)
207
216
  else
208
217
  require 'legion/cache/memcached'
209
- Legion::Cache::Memcached.dup
218
+ Object.new.extend(Legion::Cache::Memcached, Legion::Logging::Helper)
210
219
  end
211
220
  end
212
221
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openssl'
4
+ require 'timeout'
4
5
  require 'dalli'
5
6
  require 'legion/logging/helper'
6
7
  require 'legion/cache/pool'
@@ -16,12 +17,17 @@ module Legion
16
17
  username: nil, password: nil, logger: nil, **opts)
17
18
  return @client unless @client.nil?
18
19
 
19
- settings = defined?(Legion::Settings) ? Legion::Settings[:cache] : {}
20
- servers ||= settings[:servers] || []
20
+ settings = @cache_settings || (defined?(Legion::Settings) ? Legion::Settings[:cache] : {})
21
+ if server.nil? && servers.nil?
22
+ server = settings[:server]
23
+ servers = settings[:servers]
24
+ end
21
25
  @component_logger = logger || log
22
26
 
23
27
  @pool_size = pool_size || settings[:pool_size] || 10
24
28
  @timeout = timeout || settings[:timeout] || 5
29
+ health_timeout = Float(@timeout)
30
+ raise ArgumentError, 'memcached timeout must be finite and positive' unless health_timeout.finite? && health_timeout.positive?
25
31
 
26
32
  resolved = Legion::Cache::Settings.resolve_servers(
27
33
  driver: 'memcached', server: server, servers: Array(servers)
@@ -33,22 +39,29 @@ module Legion
33
39
  cache_opts[:serializer] ||= Legion::JSON
34
40
  cache_opts[:username] = username unless username.nil?
35
41
  cache_opts[:password] = password unless password.nil?
42
+ cache_opts[:socket_timeout] ||= health_timeout
43
+ @memcached_settings = cache_opts
36
44
 
37
45
  tls_ctx = memcached_tls_context(port: resolved.first.split(':').last.to_i)
38
- cache_opts[:ssl_context] = tls_ctx if tls_ctx
46
+ cache_opts[:ssl_context] ||= tls_ctx if tls_ctx
39
47
 
40
48
  checkout_timeout = opts[:pool_checkout_timeout] || settings[:pool_checkout_timeout] || @timeout
41
- @client = ConnectionPool.new(size: @pool_size, timeout: checkout_timeout) do
49
+ pool = ConnectionPool.new(size: @pool_size, timeout: checkout_timeout) do
42
50
  Dalli::Client.new(resolved, cache_opts)
43
51
  end
44
52
 
45
- @connected = true
53
+ Timeout.timeout(health_timeout) { pool.with(&:alive!) }
54
+ @client = pool
55
+ cache_mark_connected(true)
46
56
  log.info "Memcached connected to #{resolved.join(', ')}"
47
57
  @client
48
58
  rescue StandardError => e
49
59
  handle_exception(e, level: :error, handled: false, operation: :memcached_client,
50
60
  server: server, servers: Array(servers))
51
- @connected = false
61
+ cache_mark_connected(false)
62
+ cache_backend_failed(e)
63
+ memcached_discard_pool(pool)
64
+ @client = nil
52
65
  raise
53
66
  end
54
67
 
@@ -57,31 +70,50 @@ module Legion
57
70
  log.debug { "[cache] GET #{key} hit=#{!result.nil?}" }
58
71
  result
59
72
  rescue StandardError => e
73
+ cache_backend_failed(e)
60
74
  handle_exception(e, level: :warn, handled: true, operation: :memcached_get, key: key)
61
75
  nil
62
76
  end
63
77
 
64
- def fetch(key, ttl: nil, &)
65
- result = client.with do |conn|
66
- if block_given?
67
- conn.fetch(key, ttl, &)
68
- else
69
- conn.fetch(key, ttl)
70
- end
78
+ def fetch(key, ttl: nil)
79
+ result = get(key)
80
+ return result unless result.nil? && block_given?
81
+
82
+ result = yield
83
+ begin
84
+ set_nx(key, result, ttl: ttl)
85
+ rescue StandardError => e
86
+ handle_exception(e, level: :warn, handled: true, operation: :memcached_fetch, key: key, ttl: ttl)
71
87
  end
72
- log.debug { "[cache] FETCH #{key} hit=#{!result.nil?}" }
73
88
  result
74
- rescue StandardError => e
75
- handle_exception(e, level: :warn, handled: true, operation: :memcached_fetch, key: key, ttl: ttl)
76
- nil
89
+ end
90
+
91
+ def cas(key, ttl: nil)
92
+ raise ArgumentError, 'a block is required' unless block_given?
93
+
94
+ effective_ttl = ttl || default_ttl
95
+ 20.times do
96
+ value, token = memcached_cas_request(key) { |conn| conn.get_cas(key) }
97
+ updated = yield value
98
+ committed = memcached_cas_request(key) do |conn|
99
+ if token.nil?
100
+ conn.add(key, updated, effective_ttl)
101
+ else
102
+ conn.set_cas(key, updated, token, effective_ttl)
103
+ end
104
+ end
105
+ return updated if committed
106
+ end
107
+ raise 'Memcached CAS contention exceeded the retry limit'
77
108
  end
78
109
 
79
110
  def set_nx(key, value, ttl: nil)
80
111
  effective_ttl = ttl || default_ttl
81
- result = client.with { |conn| conn.add(key, value, effective_ttl) == true }
112
+ result = client.with { |conn| conn.add(key, value, effective_ttl) }
82
113
  log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" }
83
- result
114
+ !!result
84
115
  rescue StandardError => e
116
+ cache_backend_failed(e)
85
117
  handle_exception(e, level: :error, handled: false, operation: :memcached_set_nx, key: key, ttl: effective_ttl)
86
118
  raise
87
119
  end
@@ -92,10 +124,11 @@ module Legion
92
124
 
93
125
  def set_sync(key, value, ttl: nil, **)
94
126
  effective_ttl = ttl || default_ttl
95
- result = client.with { |conn| conn.set(key, value, effective_ttl).positive? }
127
+ result = client.with { |conn| conn.set(key, value, effective_ttl) }
96
128
  log.debug { "[cache] SET #{key} ttl=#{effective_ttl} success=#{result} value_class=#{value.class}" }
97
- result
129
+ !!result
98
130
  rescue StandardError => e
131
+ cache_backend_failed(e)
99
132
  handle_exception(e, level: :error, handled: false, operation: :memcached_set_sync, key: key, ttl: effective_ttl)
100
133
  raise
101
134
  end
@@ -109,6 +142,7 @@ module Legion
109
142
  log.debug { "[cache] DELETE #{key} success=#{result}" }
110
143
  result
111
144
  rescue StandardError => e
145
+ cache_backend_failed(e)
112
146
  handle_exception(e, level: :error, handled: false, operation: :memcached_delete_sync, key: key)
113
147
  raise
114
148
  end
@@ -118,6 +152,7 @@ module Legion
118
152
  log.debug { '[cache] FLUSH completed' }
119
153
  result
120
154
  rescue StandardError => e
155
+ cache_backend_failed(e)
121
156
  handle_exception(e, level: :warn, handled: true, operation: :memcached_flush)
122
157
  nil
123
158
  end
@@ -130,6 +165,7 @@ module Legion
130
165
  log.debug { "[cache] MGET keys=#{keys.size} hits=#{result.size}" }
131
166
  result
132
167
  rescue StandardError => e
168
+ cache_backend_failed(e)
133
169
  handle_exception(e, level: :warn, handled: true, operation: :memcached_mget, key_count: keys.size)
134
170
  {}
135
171
  end
@@ -138,24 +174,49 @@ module Legion
138
174
  mset_sync(hash, ttl: ttl)
139
175
  end
140
176
 
141
- def mset_sync(hash, ttl: nil, **) # rubocop:disable Lint/UnusedMethodArgument
177
+ def mset_sync(hash, ttl: nil, **)
142
178
  return true if hash.empty?
143
179
 
144
- client.with { |conn| conn.set_multi(hash) }
145
- log.debug { "[cache] MSET keys=#{hash.size}" }
146
- true
180
+ effective_ttl = ttl || default_ttl
181
+ # Dalli 3 has no set_multi; newer quiet batches do not acknowledge each write.
182
+ result = client.with do |conn|
183
+ success = true
184
+ hash.each { |key, value| success = false unless conn.set(key, value, effective_ttl) }
185
+ success
186
+ end
187
+ log.debug { "[cache] MSET keys=#{hash.size} success=#{result}" }
188
+ result
147
189
  rescue StandardError => e
190
+ cache_backend_failed(e)
148
191
  handle_exception(e, level: :error, handled: false, operation: :memcached_mset_sync, key_count: hash.size)
149
192
  raise
150
193
  end
151
194
 
152
195
  private
153
196
 
197
+ def memcached_discard_pool(pool)
198
+ pool&.shutdown(&:close)
199
+ rescue StandardError => e
200
+ handle_exception(e, level: :warn, handled: true, operation: :memcached_close_failed_pool)
201
+ end
202
+
203
+ def memcached_cas_request(key, &)
204
+ client.with(&)
205
+ rescue StandardError => e
206
+ cache_backend_failed(e)
207
+ handle_exception(e, level: :error, handled: false, operation: :memcached_cas, key: key)
208
+ raise
209
+ end
210
+
154
211
  def default_ttl
212
+ return @memcached_settings[:default_ttl] || 3600 if @memcached_settings
213
+ return @cache_settings[:default_ttl] || 3600 if @cache_settings
214
+
155
215
  return 3600 unless defined?(Legion::Settings)
156
216
 
157
217
  Legion::Settings.dig(:cache, :default_ttl) || 3600
158
- rescue StandardError
218
+ rescue StandardError => e
219
+ handle_exception(e, level: :warn, handled: true, operation: :memcached_default_ttl)
159
220
  3600
160
221
  end
161
222
 
@@ -172,6 +233,9 @@ module Legion
172
233
  end
173
234
 
174
235
  def memcached_tls_settings
236
+ return @memcached_settings[:tls] || {} if @memcached_settings
237
+ return @cache_settings[:tls] || {} if @cache_settings
238
+
175
239
  return {} unless defined?(Legion::Settings)
176
240
 
177
241
  Legion::Settings[:cache][:tls] || {}