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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 151b591709ad7af3c6012b1e701bef89a7516ee606bf8bd91385ed3da986f590
4
- data.tar.gz: '0593b44cbefdfa99cf2f30fc4e1601ac9d758ed404896ca90eb639ddcc2a7fbd'
3
+ metadata.gz: 468452176251521d3ab40135e1801f7081f738a2e5d6fd60af7710292a066ba7
4
+ data.tar.gz: 79361a0d3b3f4e5c0baf65deaaec789a4a1750079c298eef92cbc72d3f73bd0e
5
5
  SHA512:
6
- metadata.gz: 95d1f2d1b482514062f7711727571b7a168f830454d0faf516d527f86eb3d047b892ac77b8c4709c163161414e1f099f5d751bd57c3b9b29f8e20de3c4282740
7
- data.tar.gz: 8e7b5eb4667e70e23a91c75a65589210611d66865647ce9d0bb32edaff8fc323073c742a340594ea7dc395b9bd993d5c9493f38611eb211dcc39d8ee90f705b7
6
+ metadata.gz: 2e07439fc6f681b7664561eb1b60a4e592c9633a4b26c8c5445062193f8fd883d3118e09f85317051ee93dba81e8d80c584704688d6564ff48c32f7c550a1527
7
+ data.tar.gz: 7e61e51d9302b24aef31e86fc3ce39ef19c0ff13171968393c2ce0a8bc25931b7fbc1a80686f04ec34cc1f950ec447732fced3a8ae8c97f67e76fb4a581cc4eb
data/.gitignore CHANGED
@@ -17,3 +17,4 @@ legionio.key
17
17
  legion.log
18
18
  .DS_Store
19
19
  .worktrees/
20
+ *.gem
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.3] - 2026-10-29
6
+
7
+ ### Fixed
8
+ - Local cache `set`, `delete`, and `mset` now honor `async: true` using a dedicated, locally configured AsyncWriter.
9
+ - Drain Local async mutations before closing, restarting, or resetting the local pool; preserve synchronous facade fallback writes without double-enqueueing.
10
+ - Reject saturated/stopped async writes without running backend work on the caller; preserve admission order for each key while allowing independent keys to proceed.
11
+ - Interrupt reconnect backoff and stop/join in-flight workers before returning from shutdown; honor `reconnect.enabled`.
12
+ - Preserve atomic connection-state flags across setup, restart, backend failure, fallback, and recovery.
13
+ - Health-check Memcached and Redis before reporting connected; retry failed shared operations against healthy Local and reconnect shared in the background.
14
+ - Rebuild Local after close/reset, honor disabled-cache guards, and preserve explicit endpoint/auth/database configuration on lazy connections and restart.
15
+ - Release Memcached pool checkouts before executing fetch blocks; propagate application exceptions across every fetch path.
16
+ - Preserve computed method results when global-cache writes fail; isolate authenticated caller keys and anonymous method-cache owners.
17
+ - Normalize Memcached insert-if-absent results, preserve batch TTLs, and acknowledge each batch write.
18
+ - Use atomic, bounded CAS for emulated hash updates/deletes to prevent concurrent field loss.
19
+ - Enforce finite positive PHI retention across synchronous/asynchronous writes, batches, insert-if-absent, and CAS.
20
+ - Bound Memory and method-cache fallback storage and reclaim inactive expired entries with managed cleanup workers.
21
+ - Construct Redis Cluster with the supported clustering client; forward TLS and hostname verification to discovered nodes.
22
+ - Apply Redis namespaces to scalar, batch, hash, and sorted-set operations; namespace-only flush reports partial failures without global fallback.
23
+ - Correct README keyword-TTL examples and document admission, ordering, fallback, expiry, and namespace-upgrade behavior.
24
+ - Restore the intentional shared → Local → in-process fallback order for global misses, rejected writes, disabled shared cache, and missing batch entries.
25
+
26
+ ### Changed
27
+ - Require Redis/redis-clustering versions >= 5.4.1 and < 7.0; keep Dalli below 5.0 to retain binary-protocol SASL authentication.
28
+ - Redis namespace enforcement intentionally produces a cold cache for old unprefixed entries; no legacy-key fallback is used.
29
+ - Memory and method-cache fallback default to 10,000 entries with oldest-entry eviction; ordinary zero TTL means no expiration until eviction or clear.
30
+
5
31
  ## [1.4.1] - 2026-04-06
6
32
 
7
33
  ### Fixed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Caching wrapper for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides a consistent interface for Memcached (via `dalli`) and Redis (via `redis` gem) with connection pooling. Driver selection is config-driven.
4
4
 
5
- **Version**: 1.3.17
5
+ **Version**: 1.4.3
6
6
 
7
7
  ## Installation
8
8
 
@@ -24,22 +24,38 @@ require 'legion/cache'
24
24
  Legion::Cache.setup
25
25
  Legion::Cache.connected? # => true
26
26
 
27
- # Memcached driver (default) — TTL is a positional argument, default 180s
28
- Legion::Cache.set('foobar', 'testing', 10)
29
- Legion::Cache.get('foobar') # => 'testing'
30
- Legion::Cache.fetch('foobar') # => 'testing' (get with block support)
31
- Legion::Cache.delete('foobar') # => true
32
- Legion::Cache.flush # flush all keys
27
+ # Both drivers use keyword TTLs. Use synchronous writes for read-after-write.
28
+ Legion::Cache.set('foobar', 'testing', ttl: 10, async: false)
29
+ Legion::Cache.get('foobar') # => 'testing'
30
+ Legion::Cache.fetch('computed', ttl: 60) { 'value' } # computes only on a miss
31
+ Legion::Cache.set_nx('once', 'first', ttl: 60) # atomic insert-if-absent
32
+ Legion::Cache.mset({ 'one' => 1, 'two' => 2 }, ttl: 60, async: false)
33
+ Legion::Cache.mget('one', 'two')
34
+ Legion::Cache.delete('foobar', async: false)
33
35
 
34
- # Redis driver — TTL is the third positional argument
35
- Legion::Cache.set('foobar', 'testing', 10)
36
- Legion::Cache.get('foobar') # => 'testing'
37
- Legion::Cache.delete('foobar') # => true
38
- Legion::Cache.flush # flushdb
36
+ # Redis: deletes only this namespace. Memcached: flushes the entire server.
37
+ Legion::Cache.flush
39
38
 
40
39
  Legion::Cache.shutdown
41
40
  ```
42
41
 
42
+ ### Asynchronous Writes
43
+
44
+ `set`, `delete`, and `mset` default to `async: true`. A `true` return means the
45
+ mutation was admitted, not that the backend has committed it. A stopped or full
46
+ writer returns `false`; it never executes overflow work on the caller's thread.
47
+ For `mset`, a `false` result can mean partial admission.
48
+
49
+ Each tier has its own bounded writer. Accepted asynchronous mutations for the
50
+ same key execute in admission order; other keys can execute concurrently.
51
+ Capacity is `async.pool_size + async.queue_size` outstanding operations.
52
+ Synchronous writes bypass the queue and do not act as barriers for earlier
53
+ asynchronous work. Use one mode consistently when ordering matters.
54
+
55
+ Use `async: false`, `set_sync`, `delete_sync`, or `mset_sync` when the following
56
+ read must observe the write. Shutdown drains accepted work up to the configured
57
+ deadline, then terminates remaining workers before closing the backend.
58
+
43
59
  ## Lite Mode (No Infrastructure)
44
60
 
45
61
  When `LEGION_MODE=lite` is set, `Legion::Cache` activates the pure in-memory `Memory` adapter, bypassing Redis and Memcached entirely:
@@ -48,15 +64,30 @@ When `LEGION_MODE=lite` is set, `Legion::Cache` activates the pure in-memory `Me
48
64
  ENV['LEGION_MODE'] = 'lite'
49
65
  Legion::Cache.setup
50
66
  Legion::Cache.using_memory? # => true
51
- Legion::Cache.set('key', 'value', 60)
67
+ Legion::Cache.set('key', 'value', ttl: 60, async: false)
52
68
  Legion::Cache.get('key') # => 'value'
53
69
  ```
54
70
 
55
- The Memory adapter is thread-safe (Mutex), supports TTL expiry, and exposes the same `get`/`set`/`fetch`/`delete`/`flush` interface. Shutdown cleanly tears it down.
71
+ Memory and the method-cache fallback use bounded, mutex-protected storage:
72
+ 10,000 entries by default, with oldest-entry eviction. Expired entries are
73
+ reclaimed in the background even when never read again. Cleanup workers stop
74
+ when no expiring entries remain; clearing or closing storage wakes and joins
75
+ them. `Legion::Cache::Memory.setup(max_entries: 1000)` changes Memory's capacity.
56
76
 
57
77
  ## Two-Tier Cache
58
78
 
59
- Legion::Cache supports a two-tier architecture: a shared remote cluster and a local per-machine cache. If the shared cluster is unreachable at setup, all operations transparently fall back to local.
79
+ Legion::Cache supports a shared remote cache and an independent Local cache.
80
+ Setup performs a backend health check. Every shared miss checks healthy Local
81
+ before reporting a miss; `mget` checks Local only for missing keys. If shared
82
+ setup or a later operation fails with a connection error, the operation is
83
+ retried against Local when `failback_to_local` is enabled. Background
84
+ reconnection returns traffic to shared only after a successful health check.
85
+ Setting `reconnect.enabled` to `false` disables that worker.
86
+
87
+ Setting `cache.enabled` to `false` disables shared connectivity but leaves a
88
+ healthy, enabled Local cache available through the facade. Set
89
+ `cache_local.enabled` to `false`, or `failback_to_local` to `false`, to prevent
90
+ that fallback.
60
91
 
61
92
  ```ruby
62
93
  # Shared cache connects to remote cluster; Local connects to localhost
@@ -66,12 +97,25 @@ Legion::Cache.local # => Legion::Cache::Local
66
97
 
67
98
  # Use Local directly if needed
68
99
  Legion::Cache::Local.setup
69
- Legion::Cache::Local.set('key', 'value', 60)
100
+ Legion::Cache::Local.set('key', 'value', ttl: 60, async: false)
70
101
  Legion::Cache::Local.get('key') # => 'value'
71
102
  Legion::Cache::Local.shutdown
72
103
  ```
73
104
 
74
- Local uses a separate namespace (`legion_local`) and independent connection pool (pool_size: 5, timeout: 3) so it never collides with the shared tier.
105
+ Local uses its own configuration, namespace (`legion_local`), connection pool
106
+ (size 5, timeout 3 seconds), and default TTL (21,600 seconds). Redis scalar,
107
+ batch, hash, and sorted-set keys all include the selected tier's namespace.
108
+
109
+ Local `set`, `delete`, and `mset` accept `async:` (default `true`). After setup,
110
+ asynchronous mutations use a dedicated Local writer; pass `async: false` when a
111
+ following read must see the mutation immediately. `set_sync`, `delete_sync`, and
112
+ `mset_sync` are always synchronous. Shared-cache fallback does not enqueue the
113
+ same mutation a second time.
114
+
115
+ Local shutdown, close, restart, and reset drain pending mutations before
116
+ releasing the driver, up to the configured shutdown timeout. Restart rebuilds
117
+ the driver even after close or reset. Local uses the same overload rejection
118
+ and per-key ordering rules as the shared writer.
75
119
 
76
120
  ## Configuration
77
121
 
@@ -88,11 +132,27 @@ Local uses a separate namespace (`legion_local`) and independent connection pool
88
132
  "cache_nils": false,
89
133
  "pool_size": 10,
90
134
  "timeout": 5,
91
- "expires_in": 0
135
+ "pool_checkout_timeout": 5,
136
+ "default_ttl": 3600,
137
+ "expires_in": 0,
138
+ "failback_to_local": true,
139
+ "async": {
140
+ "pool_size": 4,
141
+ "queue_size": 1000,
142
+ "shutdown_timeout": 5
143
+ },
144
+ "reconnect": {
145
+ "enabled": true,
146
+ "initial_delay": 1,
147
+ "max_delay": 60
148
+ }
92
149
  }
93
150
  ```
94
151
 
95
- The driver is auto-detected at load time: prefers `dalli` (Memcached) if available, falls back to `redis`. Override with `"driver": "redis"` and update `servers` to point at your Redis instance.
152
+ These are the `Legion::Settings[:cache]` settings. Driver selection prefers
153
+ `dalli` when installed and otherwise uses `redis`; an explicit `driver` wins.
154
+ Shared pool size defaults to 10 for both drivers. TTLs are seconds; ordinary
155
+ `ttl: 0` disables expiration. Batch writes apply the same TTL as single writes.
96
156
 
97
157
  ### Driver Names
98
158
 
@@ -100,7 +160,15 @@ Supported driver names: `memcached` (or `dalli`), `redis`. All names are normali
100
160
 
101
161
  ### Server Resolution
102
162
 
103
- Both `server` (singular string) and `servers` (array) are accepted and merged. Default ports are injected per driver when omitted: 11211 for memcached, 6379 for redis. Duplicates are removed.
163
+ Both `server` (a string) and `servers` (an array) are accepted. Missing ports
164
+ default to 11211 for Memcached and 6379 for Redis; duplicates are removed.
165
+ Memcached uses the resolved server list. Standalone Redis uses its first
166
+ endpoint; use `cluster` for Redis Cluster. Redis URLs preserve credentials and
167
+ database selection.
168
+
169
+ An explicit `server:` or `servers:` passed to setup/restart replaces inherited
170
+ endpoints and clears an inherited cluster unless `cluster:` is also explicit.
171
+ Local settings do not inherit shared credentials, database, cluster, or TLS.
104
172
 
105
173
  ```json
106
174
  {
@@ -115,7 +183,46 @@ Both `server` (singular string) and `servers` (array) are accepted and merged. D
115
183
  ### Memcached notes
116
184
 
117
185
  - `value_max_bytes` defaults to **8MB**. Dalli enforces a 1MB client-side limit by default, which silently rejects large values. This default overrides that. Your Memcached server should also be started with `-I 8m` to match.
118
- - Redis default pool size is 20; Memcached default pool size is 10.
186
+ - Dalli 3.x and 4.x are supported. Dalli 5 is excluded because it removes the binary protocol and SASL authentication; configure `username` and `password` for SASL-capable servers.
187
+ - `fetch` releases its pooled connection before running application code. Application exceptions propagate unchanged.
188
+ - Hash helpers use bounded CAS retries instead of read/modify/write overwrites, preserving concurrent field updates.
189
+ - Memcached `flush` is server-wide, not namespace-scoped. Do not use it on a server whose other namespaces must be preserved.
190
+
191
+ ### Redis Cluster and TLS
192
+
193
+ Redis and `redis-clustering` support the 5.4.x and 6.x client families.
194
+ Cluster configuration uses the dedicated `Redis::Cluster` client:
195
+
196
+ ```ruby
197
+ Legion::Settings[:cache].merge!(
198
+ driver: 'redis',
199
+ cluster: ['redis://cache-1.example:6380', 'redis://cache-2.example:6380'],
200
+ namespace: 'legion',
201
+ tls: { enabled: true, verify: 'peer', ca: '/etc/ssl/cache-ca.pem' }
202
+ )
203
+ Legion::Cache.setup
204
+ ```
205
+
206
+ Configured TLS requires `Legion::Crypt::TLS` to be loaded. CA verification,
207
+ client certificates/keys, and optional `fixed_hostname` settings are forwarded
208
+ to cluster-discovered connections as well as startup nodes. Redis Cluster
209
+ supports database 0 only.
210
+
211
+ Redis `flush` scans and deletes only the configured namespace, including across
212
+ cluster nodes. Partial scan/deletion failures return failure rather than
213
+ reporting success or falling back to a global flush.
214
+
215
+ **Upgrade note:** Redis keys now consistently include the namespace. Previously
216
+ unprefixed entries are not migrated or read; expect a cold cache after upgrade.
217
+
218
+ ### PHI Retention
219
+
220
+ Pass `phi: true` to facade or Memory writes to enforce
221
+ `cache.compliance.phi_max_ttl` (default 3600 seconds). Zero or negative PHI TTL
222
+ requests use the cap; larger TTLs are clamped. Facades apply their tier's
223
+ default when TTL is omitted, bounded by the same cap. The cap itself must be
224
+ finite and positive. This applies to synchronous/asynchronous writes, batches,
225
+ `set_nx`, and `cas`, including Local writes.
119
226
 
120
227
  ### Local Cache Settings
121
228
 
@@ -125,7 +232,12 @@ Both `server` (singular string) and `servers` (array) are accepted and merged. D
125
232
  "servers": ["127.0.0.1:11211"],
126
233
  "namespace": "legion_local",
127
234
  "pool_size": 5,
128
- "timeout": 3
235
+ "timeout": 3,
236
+ "async": {
237
+ "pool_size": 4,
238
+ "queue_size": 1000,
239
+ "shutdown_timeout": 5
240
+ }
129
241
  }
130
242
  ```
131
243
 
@@ -139,24 +251,33 @@ Runner modules can use `cache_method` to transparently cache method results with
139
251
  module Runners::Presence
140
252
  extend Legion::Cache::Cacheable
141
253
 
142
- cache_method :get_presence, ttl: 300, exclude_from_key: [:token]
254
+ cache_method :get_presence, ttl: 300
143
255
 
144
- def get_presence(user_id: 'me', **)
145
- conn = graph_connection(**)
256
+ def get_presence(token:, user_id: 'me', **)
257
+ conn = graph_connection(token: token, **)
146
258
  response = conn.get("#{user_path(user_id)}/presence")
147
259
  { availability: response.body['availability'], activity: response.body['activity'] }
148
260
  end
149
261
  end
150
262
  ```
151
263
 
152
- Every caller of `get_presence` gets cached results for 5 minutes. Use `bypass_local_method_cache: true` to force-refresh:
264
+ Results are cached for five minutes per caller identity and arguments. Use `bypass_local_method_cache: true` to force-refresh:
153
265
 
154
266
  ```ruby
155
- runner.get_presence(user_id: 'me') # cached
156
- runner.get_presence(user_id: 'me', bypass_local_method_cache: true) # fresh
267
+ runner.get_presence(token: token, user_id: 'me') # cached
268
+ runner.get_presence(token: token, user_id: 'me', bypass_local_method_cache: true) # fresh
157
269
  ```
158
270
 
159
- Options: `ttl:` (seconds), `scope:` (`:local` or `:global`), `exclude_from_key:` (args to ignore in cache key). Falls back to in-memory store when no cache backend is available.
271
+ Options: `ttl:` (seconds), `scope:` (`:local` or `:global`), and
272
+ `exclude_from_key:` (arguments to ignore). Authentication `token` values always
273
+ contribute to the SHA-256 key digest, even if listed for exclusion; plaintext
274
+ tokens never appear in cache keys. Anonymous modules have distinct identities.
275
+
276
+ Global method-cache reads and writes use the same order: shared cache, then
277
+ healthy Local, then bounded in-process storage. Cache failures do not replace a
278
+ successfully computed method result with an exception; application exceptions
279
+ still propagate. Ordinary zero TTL means no expiration, subject to capacity
280
+ eviction.
160
281
 
161
282
  ## Pool API
162
283
 
@@ -171,7 +292,8 @@ Legion::Cache.shutdown # close pool and mark disconnected
171
292
  ## Requirements
172
293
 
173
294
  - Ruby >= 3.4
174
- - Memcached or Redis server
295
+ - Memcached or Redis server, unless using lite mode
296
+ - Dalli >= 3.0 and < 5.0; Redis and redis-clustering >= 5.4.1 and < 7.0
175
297
 
176
298
  ## License
177
299
 
data/legion-cache.gemspec CHANGED
@@ -28,8 +28,9 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.add_dependency 'concurrent-ruby', '>= 1.2'
30
30
  spec.add_dependency 'connection_pool', '>= 2.4'
31
- spec.add_dependency 'dalli', '>= 3.0'
31
+ spec.add_dependency 'dalli', '>= 3.0', '< 5.0'
32
32
  spec.add_dependency 'legion-logging', '>= 1.5.0'
33
33
  spec.add_dependency 'legion-settings', '>= 1.3.12'
34
- spec.add_dependency 'redis', '>= 5.0'
34
+ spec.add_dependency 'redis', '>= 5.4.1', '< 7.0'
35
+ spec.add_dependency 'redis-clustering', '>= 5.4.1', '< 7.0'
35
36
  end
@@ -19,73 +19,91 @@ module Legion
19
19
  @config_shutdown_timeout = shutdown_timeout
20
20
  @processed = Concurrent::AtomicFixnum.new(0)
21
21
  @failed = Concurrent::AtomicFixnum.new(0)
22
- @executor = nil
23
22
  @mutex = Mutex.new
23
+ @lifecycle_mutex = Mutex.new
24
+ @ready = ConditionVariable.new
25
+ @workers = []
26
+ @queue = []
27
+ @active_keys = {}
28
+ @outstanding = 0
29
+ @capacity = 0
30
+ @running = false
24
31
  end
25
32
 
26
33
  def start(pool_size: nil, queue_size: nil, **)
27
- @mutex.synchronize do
34
+ @lifecycle_mutex.synchronize do
28
35
  return if running?
29
36
 
30
37
  ps = pool_size || @config_pool_size || configured_pool_size
31
38
  qs = queue_size || @config_queue_size || configured_queue_size
39
+ raise ArgumentError, 'pool_size must be a positive integer' unless ps.is_a?(Integer) && ps.positive?
40
+ raise ArgumentError, 'queue_size must be a nonnegative integer' unless qs.is_a?(Integer) && qs >= 0
32
41
 
33
- @executor = Concurrent::ThreadPoolExecutor.new(
34
- min_threads: 1,
35
- max_threads: ps,
36
- max_queue: qs,
37
- fallback_policy: :caller_runs
38
- )
42
+ @mutex.synchronize do
43
+ @capacity = ps + qs
44
+ @running = true
45
+ @workers = Array.new(ps) { Thread.new { work } }
46
+ end
39
47
  log.info "Legion::Cache::AsyncWriter started pool_size=#{ps} queue_size=#{qs}"
40
48
  end
41
49
  end
42
50
 
43
51
  def stop(timeout: nil)
44
- @mutex.synchronize do
45
- return unless @executor
52
+ @lifecycle_mutex.synchronize do
53
+ workers = @mutex.synchronize do
54
+ return if @workers.empty?
46
55
 
56
+ @running = false
57
+ @ready.broadcast
58
+ @workers.dup
59
+ end
47
60
  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"
61
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + to
62
+ workers.each do |worker|
63
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
64
+ worker.join(remaining) if remaining.positive?
65
+ end
66
+ unfinished = workers.select(&:alive?)
67
+ unfinished.each(&:kill)
68
+ workers.each(&:join)
69
+ @mutex.synchronize do
70
+ @workers.clear
71
+ @queue.clear
72
+ @active_keys.clear
73
+ @outstanding = 0
74
+ @capacity = 0
52
75
  end
76
+ log.warn "Legion::Cache::AsyncWriter force-killed after #{to}s timeout" unless unfinished.empty?
53
77
  log.info "Legion::Cache::AsyncWriter stopped processed=#{@processed.value}"
54
- @executor = nil
55
78
  end
56
79
  end
57
80
 
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
81
+ # Admission includes running and waiting jobs, bounded by pool_size + queue_size.
82
+ # A nil key is independent work; equal non-nil keys execute in submission order.
83
+ def enqueue(key: nil, &block)
84
+ raise ArgumentError, 'a job block is required' unless block
85
+
86
+ @mutex.synchronize do
87
+ return false unless @running && @outstanding < @capacity
88
+
89
+ key = key.dup.freeze if key.is_a?(String)
90
+ @queue << [key, block]
91
+ @outstanding += 1
92
+ @ready.signal
93
+ true
76
94
  end
77
95
  end
78
96
 
79
97
  def running?
80
- @executor&.running? == true
98
+ @mutex.synchronize { @running }
81
99
  end
82
100
 
83
101
  def pool_size
84
- @executor&.max_length || 0
102
+ @mutex.synchronize { @workers.size }
85
103
  end
86
104
 
87
105
  def queue_depth
88
- @executor&.queue_length || 0
106
+ @mutex.synchronize { @queue.size }
89
107
  end
90
108
 
91
109
  def processed_count
@@ -98,6 +116,40 @@ module Legion
98
116
 
99
117
  private
100
118
 
119
+ def work
120
+ loop do
121
+ job = @mutex.synchronize do
122
+ loop do
123
+ index = @queue.index { |key, _block| key.nil? || !@active_keys.key?(key) }
124
+ if index
125
+ next_job = @queue.delete_at(index)
126
+ @active_keys[next_job.first] = true unless next_job.first.nil?
127
+ break next_job
128
+ end
129
+ return unless @running || !@queue.empty?
130
+
131
+ @ready.wait(@mutex)
132
+ end
133
+ end
134
+ perform_job(job)
135
+ end
136
+ end
137
+
138
+ def perform_job(job)
139
+ key, block = job
140
+ block.call
141
+ @processed.increment
142
+ rescue StandardError => e
143
+ @failed.increment
144
+ handle_exception(e, level: :warn, handled: true, operation: :async_writer_job)
145
+ ensure
146
+ @mutex.synchronize do
147
+ @active_keys.delete(key) unless key.nil?
148
+ @outstanding -= 1
149
+ @ready.broadcast
150
+ end
151
+ end
152
+
101
153
  def configured_pool_size
102
154
  return DEFAULT_POOL_SIZE unless defined?(Legion::Settings)
103
155
 
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'securerandom'
4
5
  require 'legion/logging/helper'
6
+ require 'legion/cache/memory'
5
7
 
6
8
  module Legion
7
9
  module Cache
@@ -10,8 +12,11 @@ module Legion
10
12
 
11
13
  LOCAL_CACHE_MISS = Object.new
12
14
 
15
+ @memory_store = Legion::Cache::ExpiringStore.new
16
+
13
17
  def self.extended(base)
14
18
  base.instance_variable_set(:@cached_methods, {})
19
+ base.instance_variable_set(:@cache_identity, "Anonymous:#{SecureRandom.uuid}")
15
20
  end
16
21
 
17
22
  def cached_methods
@@ -19,10 +24,10 @@ module Legion
19
24
  end
20
25
 
21
26
  def cache_method(method_name, ttl:, scope: :local, exclude_from_key: [])
22
- exclude_from_key |= %i[token bypass_local_method_cache]
27
+ exclude_from_key = (exclude_from_key - [:token, 'token']) | [:bypass_local_method_cache]
23
28
  cached_methods[method_name] = { ttl: ttl, scope: scope, exclude_from_key: exclude_from_key }
24
29
 
25
- mod_name = name || 'Anonymous'
30
+ mod_name = name || @cache_identity
26
31
  config = cached_methods[method_name]
27
32
 
28
33
  wrapper = Module.new do
@@ -51,39 +56,20 @@ module Legion
51
56
  end
52
57
 
53
58
  def self.build_cache_key(mod_name, method_name, exclude:, **kwargs)
54
- filtered = kwargs.except(*exclude)
55
- args_hash = Digest::MD5.hexdigest(filtered.sort.to_s)
59
+ filtered = kwargs.except(*(exclude - [:token, 'token']))
60
+ args_hash = Digest::SHA256.hexdigest(filtered.sort.to_s)
56
61
  "#{mod_name}.#{method_name}.#{args_hash}"
57
62
  end
58
63
 
59
64
  def self.cache_read(key, scope:)
60
- case scope
61
- when :global
62
- return Legion::Cache.get(key) if global_cache_available?
63
-
64
- memory_read(key)
65
- else
66
- result = local_cache_read(key)
67
- result.equal?(LOCAL_CACHE_MISS) ? memory_read(key) : result
68
- end
65
+ result = scope == :global ? global_cache_read(key) : local_cache_read(key)
66
+ result.equal?(LOCAL_CACHE_MISS) ? memory_read(key) : result
69
67
  end
70
68
 
71
69
  def self.cache_write(key, value, ttl:, scope:)
72
- case scope
73
- when :global
74
- if global_cache_available?
75
- Legion::Cache.set(key, value, ttl: ttl, async: false)
76
- else
77
- memory_write(key, value, ttl)
78
- end
79
- else
80
- if local_cache_available?
81
- result = local_cache_write(key, value, ttl: ttl)
82
- memory_write(key, value, ttl) unless result
83
- else
84
- memory_write(key, value, ttl)
85
- end
86
- end
70
+ result = scope == :global ? global_cache_write(key, value, ttl: ttl) : local_cache_write(key, value, ttl: ttl)
71
+ result ||= local_cache_write(key, value, ttl: ttl) if scope == :global
72
+ memory_write(key, value, ttl) unless result
87
73
  end
88
74
 
89
75
  def self.global_cache_available?
@@ -94,6 +80,25 @@ module Legion
94
80
  defined?(Legion::Cache::Local) && Legion::Cache::Local.respond_to?(:connected?) && Legion::Cache::Local.connected?
95
81
  end
96
82
 
83
+ def self.global_cache_read(key)
84
+ result = Legion::Cache.get(key) if global_cache_available?
85
+ return result unless result.nil?
86
+
87
+ local_cache_read(key)
88
+ rescue StandardError => e
89
+ handle_exception(e, level: :warn, operation: :global_cache_read, key: key)
90
+ local_cache_read(key)
91
+ end
92
+
93
+ def self.global_cache_write(key, value, ttl:)
94
+ return unless global_cache_available?
95
+
96
+ Legion::Cache.set(key, value, ttl: ttl, async: false)
97
+ rescue StandardError => e
98
+ handle_exception(e, level: :warn, operation: :global_cache_write, key: key, ttl: ttl)
99
+ nil
100
+ end
101
+
97
102
  def self.local_cache_read(key)
98
103
  return LOCAL_CACHE_MISS unless local_cache_available?
99
104
 
@@ -115,23 +120,19 @@ module Legion
115
120
 
116
121
  # In-memory fallback store (class-level, process-wide)
117
122
  def self.memory_store
118
- @memory_store ||= {}
123
+ @memory_store
119
124
  end
120
125
 
121
126
  def self.memory_read(key)
122
- entry = memory_store[key]
123
- return nil unless entry
124
- return nil if Time.now.utc > entry[:expires_at]
125
-
126
- entry[:value]
127
+ memory_store.get(key)
127
128
  end
128
129
 
129
130
  def self.memory_write(key, value, ttl)
130
- memory_store[key] = { value: value, expires_at: Time.now.utc + ttl }
131
+ memory_store.set(key, value, ttl: ttl)
131
132
  end
132
133
 
133
134
  def self.memory_clear!
134
- @memory_store = {}
135
+ memory_store.clear
135
136
  end
136
137
  end
137
138
  end