legion-cache 1.3.22 → 1.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9beae1671201b0badb0e624397d49fc7bd87ad72b7b934da511de49cb73b4ca
4
- data.tar.gz: 774613e524ac32ebf1f418162972d4adcef79520117516034c7e034ad2dc9ffa
3
+ metadata.gz: 0fe912c13fe0c2a7f697df703795eaadde5865c91aa96f112b89720fa4f6d4ee
4
+ data.tar.gz: 81053355f02e0763186aa3bf4c66f8bbc3fe5fd5592a4349164709e567055d28
5
5
  SHA512:
6
- metadata.gz: 20adce165ec6becfb16dde7b68f2c597f045e61834ee6251bc175c6219da630d72a5d8a09ed74eeee62680eb309db526b992193f8fdaf1f613369914df8061ea
7
- data.tar.gz: 2e54304b1ebb1ccb2bba53f465e314825ddee6fd1099248590b0664ca09e3727a655ce5d1f320095fd612c37d26ba9de5bb14a79d7c98848c3fe1f98cb97b231
6
+ metadata.gz: 58018a8630317f2f20ad6487e44dc072965e5d5c4125a003227f1bc54d5e97a6a8eeb71c7ab12f0a014646b2ba662492019960b25e6b66d52049215f9eade7c0
7
+ data.tar.gz: e1cfa791e292d2448ac3ad4026c847150dc62437a1bcb3d7bf25000138b65f7e33ae82d76b5f539fe19f7ee645ef40e4ea05f2933e8ead15dc18154de1cf0967
data/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.1] - 2026-04-06
6
+
7
+ ### Fixed
8
+ - AsyncWriter TOCTOU race condition in enqueue (capture local executor reference)
9
+ - Reconnector deadlock on stop (release mutex before thread.join)
10
+ - Reconnector NoMethodError on successful reconnect (AtomicFixnum reset)
11
+ - Missing `require 'concurrent'` in reconnector.rb
12
+ - Redis cluster flush now passes auth/TLS credentials to per-node connections
13
+ - Async writer drains before pool close on shutdown
14
+ - Serialization applied to mget/mset_sync (was only on set_sync/get)
15
+ - Binary encoding forced before serialization prefix checks
16
+
17
+ ### Added
18
+ - Automatic failback to Local tier when shared cache is disabled or disconnected (configurable via `failback_to_local: true`)
19
+ - `mget`/`mset` methods on Memory adapter for interface consistency
20
+ - Public `pool` accessor on `Legion::Cache` (replaces direct `@client` access)
21
+ - `failed_count` counter in AsyncWriter stats (`async_failed` in stats hash)
22
+ - `reconnect_shared!` raising method for reconnector connect_block
23
+ - End-to-end lifecycle integration test (shared fail -> local failback -> reconnect)
24
+
25
+ ### Changed
26
+ - Helper and Cacheable use `async: false` for read-after-write consistency
27
+ - AsyncWriter and Reconnector are tier-aware (`settings_key:` parameter, `:cache_local` for local tier)
28
+ - Redis driver `pool_size` resolved from settings (was hardcoded to 20)
29
+ - Pool checkout timeout separated from operation timeout (new `pool_checkout_timeout` setting)
30
+ - Reconnector starts on any shared failure (even when local fallback succeeds)
31
+ - `setup` guarded by `enabled?` check
32
+ - State flags (`@connected`, `@using_local`, `@using_memory`) refactored to `Concurrent::AtomicBoolean`
33
+ - Reconnector `@stop_signal` refactored to `Concurrent::AtomicBoolean`
34
+ - `RedisHash` uses public `pool` accessor instead of `instance_variable_get(:@client)`
35
+
36
+ ## [1.4.0] - 2026-04-06
37
+
38
+ ### Added
39
+ - Async write support via `Legion::Cache::AsyncWriter` backed by `concurrent-ruby` ThreadPoolExecutor
40
+ - `set`, `delete`, and `mset` now accept `async:` keyword (default `true`) for non-blocking writes
41
+ - `Legion::Cache::Reconnector` with exponential backoff (1s to 60s) for background reconnection
42
+ - Reconnector auto-starts when both shared and local cache are unavailable at setup
43
+ - `enabled?` guard on both shared and local tiers
44
+ - `stats` method returning frozen Hash with driver, connection, pool, async, and reconnect metrics
45
+ - `set_sync`, `delete_sync`, `mset_sync` explicit synchronous write methods on all tiers
46
+ - Async and reconnect default settings in `Legion::Cache::Settings`
47
+ - Transparent JSON serialization for Redis driver (prefix-byte protocol, backward-compatible with legacy data)
48
+
49
+ ### Changed
50
+ - All cache drivers now use keyword TTL (`ttl:`) instead of positional arguments
51
+ - `flush` takes no arguments across all drivers (was `flush(delay = 0)`)
52
+ - `Helper` module updated: `FALLBACK_TTL` changed from 60 to 3600, all delegations use keyword signatures, `cache_set`/`cache_delete`/`cache_mset` accept `async:` keyword
53
+ - `Cacheable` module updated: `cache_write` and `local_cache_write` use keyword TTL
54
+ - Default TTL changed from 60 to 3600 (shared) and 21600 (local)
55
+ - Version bump from 1.3.22 to 1.4.0
56
+
57
+ ### Fixed
58
+ - Unified exception handling model: reads return nil (handled), sync writes re-raise, lifecycle handles internally
59
+
5
60
  ## [1.3.22] - 2026-04-03
6
61
 
7
62
  ### Fixed
data/legion-cache.gemspec CHANGED
@@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
26
26
  'rubygems_mfa_required' => 'true'
27
27
  }
28
28
 
29
+ spec.add_dependency 'concurrent-ruby', '>= 1.2'
29
30
  spec.add_dependency 'connection_pool', '>= 2.4'
30
31
  spec.add_dependency 'dalli', '>= 3.0'
31
32
  spec.add_dependency 'legion-logging', '>= 1.5.0'
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'legion/logging/helper'
5
+
6
+ module Legion
7
+ module Cache
8
+ class AsyncWriter
9
+ include Legion::Logging::Helper
10
+
11
+ DEFAULT_POOL_SIZE = 4
12
+ DEFAULT_QUEUE_SIZE = 1000
13
+ DEFAULT_SHUTDOWN_TIMEOUT = 5
14
+
15
+ def initialize(pool_size: nil, queue_size: nil, shutdown_timeout: nil, settings_key: :cache)
16
+ @settings_key = settings_key
17
+ @config_pool_size = pool_size
18
+ @config_queue_size = queue_size
19
+ @config_shutdown_timeout = shutdown_timeout
20
+ @processed = Concurrent::AtomicFixnum.new(0)
21
+ @failed = Concurrent::AtomicFixnum.new(0)
22
+ @executor = nil
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def start(pool_size: nil, queue_size: nil, **)
27
+ @mutex.synchronize do
28
+ return if running?
29
+
30
+ ps = pool_size || @config_pool_size || configured_pool_size
31
+ qs = queue_size || @config_queue_size || configured_queue_size
32
+
33
+ @executor = Concurrent::ThreadPoolExecutor.new(
34
+ min_threads: 1,
35
+ max_threads: ps,
36
+ max_queue: qs,
37
+ fallback_policy: :caller_runs
38
+ )
39
+ log.info "Legion::Cache::AsyncWriter started pool_size=#{ps} queue_size=#{qs}"
40
+ end
41
+ end
42
+
43
+ def stop(timeout: nil)
44
+ @mutex.synchronize do
45
+ return unless @executor
46
+
47
+ to = timeout || @config_shutdown_timeout || configured_shutdown_timeout
48
+ @executor.shutdown
49
+ unless @executor.wait_for_termination(to)
50
+ @executor.kill
51
+ log.warn "Legion::Cache::AsyncWriter force-killed after #{to}s timeout"
52
+ end
53
+ log.info "Legion::Cache::AsyncWriter stopped processed=#{@processed.value}"
54
+ @executor = nil
55
+ end
56
+ end
57
+
58
+ def enqueue(&block)
59
+ executor = @executor
60
+ if executor&.running?
61
+ executor.post do
62
+ block.call
63
+ @processed.increment
64
+ rescue StandardError => e
65
+ handle_exception(e, level: :warn, handled: true, operation: :async_writer_job)
66
+ @failed.increment
67
+ end
68
+ else
69
+ begin
70
+ block.call
71
+ @processed.increment
72
+ rescue StandardError => e
73
+ handle_exception(e, level: :warn, handled: true, operation: :async_writer_sync_fallback)
74
+ @failed.increment
75
+ end
76
+ end
77
+ end
78
+
79
+ def running?
80
+ @executor&.running? == true
81
+ end
82
+
83
+ def pool_size
84
+ @executor&.max_length || 0
85
+ end
86
+
87
+ def queue_depth
88
+ @executor&.queue_length || 0
89
+ end
90
+
91
+ def processed_count
92
+ @processed.value
93
+ end
94
+
95
+ def failed_count
96
+ @failed.value
97
+ end
98
+
99
+ private
100
+
101
+ def configured_pool_size
102
+ return DEFAULT_POOL_SIZE unless defined?(Legion::Settings)
103
+
104
+ Legion::Settings.dig(@settings_key, :async, :pool_size) || DEFAULT_POOL_SIZE
105
+ rescue StandardError
106
+ DEFAULT_POOL_SIZE
107
+ end
108
+
109
+ def configured_queue_size
110
+ return DEFAULT_QUEUE_SIZE unless defined?(Legion::Settings)
111
+
112
+ Legion::Settings.dig(@settings_key, :async, :queue_size) || DEFAULT_QUEUE_SIZE
113
+ rescue StandardError
114
+ DEFAULT_QUEUE_SIZE
115
+ end
116
+
117
+ def configured_shutdown_timeout
118
+ return DEFAULT_SHUTDOWN_TIMEOUT unless defined?(Legion::Settings)
119
+
120
+ Legion::Settings.dig(@settings_key, :async, :shutdown_timeout) || DEFAULT_SHUTDOWN_TIMEOUT
121
+ rescue StandardError
122
+ DEFAULT_SHUTDOWN_TIMEOUT
123
+ end
124
+ end
125
+ end
126
+ end
@@ -72,13 +72,13 @@ module Legion
72
72
  case scope
73
73
  when :global
74
74
  if global_cache_available?
75
- Legion::Cache.set(key, value, ttl)
75
+ Legion::Cache.set(key, value, ttl: ttl, async: false)
76
76
  else
77
77
  memory_write(key, value, ttl)
78
78
  end
79
79
  else
80
80
  if local_cache_available?
81
- result = local_cache_write(key, value, ttl)
81
+ result = local_cache_write(key, value, ttl: ttl)
82
82
  memory_write(key, value, ttl) unless result
83
83
  else
84
84
  memory_write(key, value, ttl)
@@ -104,10 +104,10 @@ module Legion
104
104
  LOCAL_CACHE_MISS
105
105
  end
106
106
 
107
- def self.local_cache_write(key, value, ttl)
107
+ def self.local_cache_write(key, value, ttl:)
108
108
  return unless local_cache_available?
109
109
 
110
- Legion::Cache::Local.set(key, value, ttl)
110
+ Legion::Cache::Local.set(key, value, ttl: ttl, async: false)
111
111
  rescue StandardError => e
112
112
  handle_exception(e, level: :warn, operation: :local_cache_write, key: key, ttl: ttl)
113
113
  nil
@@ -7,7 +7,7 @@ module Legion
7
7
  module Helper
8
8
  include Legion::Logging::Helper
9
9
 
10
- FALLBACK_TTL = 60
10
+ FALLBACK_TTL = 3600
11
11
 
12
12
  # --- TTL Resolution ---
13
13
  # Override in your LEX to set a custom default TTL for the extension.
@@ -40,7 +40,7 @@ module Legion
40
40
 
41
41
  def cache_set(key, value, ttl: nil, phi: false)
42
42
  effective_ttl = ttl || cache_default_ttl
43
- Legion::Cache.set(cache_namespace + key, value, effective_ttl, phi: phi)
43
+ Legion::Cache.set(cache_namespace + key, value, ttl: effective_ttl, async: false, phi: phi)
44
44
  end
45
45
 
46
46
  def cache_get(key)
@@ -48,12 +48,12 @@ module Legion
48
48
  end
49
49
 
50
50
  def cache_delete(key)
51
- Legion::Cache.delete(cache_namespace + key)
51
+ Legion::Cache.delete(cache_namespace + key, async: false)
52
52
  end
53
53
 
54
54
  def cache_fetch(key, ttl: nil, &)
55
55
  effective_ttl = ttl || cache_default_ttl
56
- Legion::Cache.fetch(cache_namespace + key, effective_ttl, &)
56
+ Legion::Cache.fetch(cache_namespace + key, ttl: effective_ttl, &)
57
57
  end
58
58
 
59
59
  def cache_exist?(key)
@@ -90,7 +90,7 @@ module Legion
90
90
 
91
91
  effective_ttl = ttl || cache_default_ttl
92
92
 
93
- hash.each { |k, v| Legion::Cache.set(cache_namespace + k, v, effective_ttl) }
93
+ hash.each { |k, v| Legion::Cache.set(cache_namespace + k, v, ttl: effective_ttl, async: false) }
94
94
  true
95
95
  rescue StandardError => e
96
96
  log_cache_error('cache_mset', e)
@@ -114,7 +114,7 @@ module Legion
114
114
 
115
115
  effective_ttl = ttl || local_cache_default_ttl
116
116
 
117
- hash.each { |k, v| Legion::Cache::Local.set(cache_namespace + k, v, effective_ttl) }
117
+ hash.each { |k, v| Legion::Cache::Local.set(cache_namespace + k, v, ttl: effective_ttl, async: false) }
118
118
  true
119
119
  rescue StandardError => e
120
120
  log_cache_error('local_cache_mset', e)
@@ -205,7 +205,7 @@ module Legion
205
205
  def local_cache_set(key, value, ttl: nil, phi: false)
206
206
  effective_ttl = ttl || local_cache_default_ttl
207
207
  effective_ttl = Legion::Cache.enforce_phi_ttl(effective_ttl, phi: phi)
208
- Legion::Cache::Local.set(cache_namespace + key, value, effective_ttl)
208
+ Legion::Cache::Local.set(cache_namespace + key, value, ttl: effective_ttl, async: false)
209
209
  end
210
210
 
211
211
  def local_cache_get(key)
@@ -213,12 +213,12 @@ module Legion
213
213
  end
214
214
 
215
215
  def local_cache_delete(key)
216
- Legion::Cache::Local.delete(cache_namespace + key)
216
+ Legion::Cache::Local.delete(cache_namespace + key, async: false)
217
217
  end
218
218
 
219
219
  def local_cache_fetch(key, ttl: nil, &)
220
220
  effective_ttl = ttl || local_cache_default_ttl
221
- Legion::Cache::Local.fetch(cache_namespace + key, effective_ttl, &)
221
+ Legion::Cache::Local.fetch(cache_namespace + key, ttl: effective_ttl, &)
222
222
  end
223
223
 
224
224
  def local_cache_exist?(key)
@@ -312,7 +312,7 @@ module Legion
312
312
  def memcached_hash_merge(full_key, new_fields)
313
313
  current = memcached_hash_load(full_key) || {}
314
314
  merged = current.merge(new_fields.transform_keys(&:to_s))
315
- Legion::Cache.set(full_key, Legion::JSON.dump(merged), cache_default_ttl)
315
+ Legion::Cache.set(full_key, Legion::JSON.dump(merged), ttl: cache_default_ttl, async: false)
316
316
  true
317
317
  end
318
318
 
@@ -335,7 +335,7 @@ module Legion
335
335
  str_fields = fields.map(&:to_s)
336
336
  removed = str_fields.count { |f| current.key?(f) }
337
337
  str_fields.each { |f| current.delete(f) }
338
- Legion::Cache.set(full_key, Legion::JSON.dump(current), cache_default_ttl)
338
+ Legion::Cache.set(full_key, Legion::JSON.dump(current), ttl: cache_default_ttl, async: false)
339
339
  removed
340
340
  end
341
341
 
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
3
4
  require 'legion/logging/helper'
4
5
  require 'legion/cache/settings'
5
6
 
6
7
  module Legion
7
8
  module Cache
8
9
  module Local
10
+ @connected = Concurrent::AtomicBoolean.new(false)
11
+
9
12
  class << self
10
13
  include Legion::Logging::Helper
11
14
 
12
15
  def setup(**)
13
- return if @connected
16
+ return unless enabled?
17
+ return if connected?
14
18
 
15
19
  settings = local_settings
16
20
  return unless settings[:enabled]
@@ -19,12 +23,12 @@ module Legion
19
23
  @driver_name = Legion::Cache::Settings.normalize_driver(driver_name)
20
24
  @driver = build_driver(driver_name)
21
25
  @driver.client(**settings, logger: log, **)
22
- @connected = true
26
+ @connected.make_true
23
27
  servers = Array(settings[:servers]).join(', ')
24
28
  log.info "Legion::Cache::Local connected (#{driver_name}) to #{servers}"
25
29
  rescue StandardError => e
26
30
  handle_exception(e, level: :warn, handled: true, operation: :cache_local_setup, driver: driver_name)
27
- @connected = false
31
+ @connected.make_false
28
32
  end
29
33
 
30
34
  def shutdown
@@ -34,11 +38,20 @@ module Legion
34
38
  @driver&.close
35
39
  @driver = nil
36
40
  @driver_name = nil
37
- @connected = false
41
+ @connected.make_false
42
+ end
43
+
44
+ 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
38
51
  end
39
52
 
40
53
  def connected?
41
- @connected == true
54
+ @connected&.true? || false
42
55
  end
43
56
 
44
57
  def driver_name
@@ -47,47 +60,82 @@ module Legion
47
60
 
48
61
  def get(key)
49
62
  result = @driver.get(key)
50
- log.debug "[cache:local] GET #{key} hit=#{!result.nil?}"
63
+ log.debug { "[cache:local] GET #{key} hit=#{!result.nil?}" }
51
64
  result
52
65
  rescue StandardError => e
53
- handle_exception(e, level: :warn, handled: false, operation: :cache_local_get, key: key)
54
- raise
66
+ handle_exception(e, level: :warn, handled: true, operation: :cache_local_get, key: key)
67
+ nil
55
68
  end
56
69
 
57
- def set(key, value, ttl = 180)
58
- result = @driver.set(key, value, ttl)
59
- log.debug "[cache:local] SET #{key} ttl=#{ttl} success=#{result}"
70
+ def set(key, value, ttl: nil, **)
71
+ set_sync(key, value, ttl: ttl, **)
72
+ end
73
+
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}" }
60
78
  result
61
79
  rescue StandardError => e
62
- handle_exception(e, level: :warn, handled: false, operation: :cache_local_set, key: key, ttl: ttl)
80
+ handle_exception(e, level: :error, handled: false, operation: :cache_local_set_sync, key: key, ttl: effective_ttl)
63
81
  raise
64
82
  end
65
83
 
66
- def fetch(key, ttl = nil, &)
67
- result = @driver.fetch(key, ttl, &)
68
- log.debug "[cache:local] FETCH #{key} hit=#{!result.nil?}"
84
+ def fetch(key, ttl: nil, &)
85
+ result = @driver.fetch(key, ttl: ttl, &)
86
+ log.debug { "[cache:local] FETCH #{key} hit=#{!result.nil?}" }
69
87
  result
70
88
  rescue StandardError => e
71
- handle_exception(e, level: :warn, handled: false, operation: :cache_local_fetch, key: key, ttl: ttl)
72
- raise
89
+ handle_exception(e, level: :warn, handled: true, operation: :cache_local_fetch, key: key, ttl: ttl)
90
+ nil
91
+ end
92
+
93
+ def delete(key, **)
94
+ delete_sync(key)
73
95
  end
74
96
 
75
- def delete(key)
76
- result = @driver.delete(key)
77
- log.debug "[cache:local] DELETE #{key} success=#{result}"
97
+ def delete_sync(key)
98
+ result = @driver.delete_sync(key)
99
+ log.debug { "[cache:local] DELETE #{key} success=#{result}" }
78
100
  result
79
101
  rescue StandardError => e
80
- handle_exception(e, level: :warn, handled: false, operation: :cache_local_delete, key: key)
102
+ handle_exception(e, level: :error, handled: false, operation: :cache_local_delete_sync, key: key)
81
103
  raise
82
104
  end
83
105
 
84
- def flush(delay = 0)
85
- result = @driver.flush(delay)
86
- log.debug '[cache:local] FLUSH completed'
106
+ def flush
107
+ result = @driver.flush
108
+ log.debug { '[cache:local] FLUSH completed' }
87
109
  result
88
110
  rescue StandardError => e
89
- handle_exception(e, level: :warn, handled: false, operation: :cache_local_flush, delay: delay)
90
- raise
111
+ handle_exception(e, level: :warn, handled: true, operation: :cache_local_flush)
112
+ nil
113
+ end
114
+
115
+ def mget(*keys)
116
+ keys = keys.flatten
117
+ return {} if keys.empty?
118
+
119
+ keys.to_h { |key| [key, get(key)] }
120
+ end
121
+
122
+ def mset(hash, ttl: nil, **)
123
+ return true if hash.empty?
124
+
125
+ hash.each { |key, value| set(key, value, ttl: ttl) }
126
+ true
127
+ end
128
+
129
+ 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
91
139
  end
92
140
 
93
141
  def client
@@ -98,7 +146,7 @@ module Legion
98
146
  @driver&.close
99
147
  @driver = nil
100
148
  @driver_name = nil
101
- @connected = false
149
+ @connected.make_false
102
150
  log.info 'Legion::Cache::Local pool closed'
103
151
  @connected
104
152
  end
@@ -106,7 +154,7 @@ module Legion
106
154
  def restart(**opts)
107
155
  settings = local_settings
108
156
  @driver&.restart(**settings.merge(opts, logger: log))
109
- @connected = true
157
+ @connected.make_true
110
158
  log.info 'Legion::Cache::Local pool restarted'
111
159
  @connected
112
160
  end
@@ -130,13 +178,27 @@ module Legion
130
178
  def reset!
131
179
  @driver = nil
132
180
  @driver_name = nil
133
- @connected = false
181
+ @connected.make_false
134
182
  log.debug 'Legion::Cache::Local state reset'
135
183
  @connected
136
184
  end
137
185
 
138
186
  private
139
187
 
188
+ def local_servers
189
+ Array(local_settings[:servers])
190
+ rescue StandardError
191
+ []
192
+ end
193
+
194
+ def local_default_ttl
195
+ return 21_600 unless defined?(Legion::Settings)
196
+
197
+ Legion::Settings.dig(:cache_local, :default_ttl) || 21_600
198
+ rescue StandardError
199
+ 21_600
200
+ end
201
+
140
202
  def build_driver(driver_name)
141
203
  case Legion::Cache::Settings.normalize_driver(driver_name)
142
204
  when 'redis'