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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +26 -0
- data/README.md +152 -30
- data/legion-cache.gemspec +3 -2
- data/lib/legion/cache/async_writer.rb +88 -36
- data/lib/legion/cache/cacheable.rb +37 -36
- data/lib/legion/cache/helper.rb +14 -12
- data/lib/legion/cache/local.rb +118 -109
- data/lib/legion/cache/memcached.rb +90 -26
- data/lib/legion/cache/memory.rb +220 -104
- data/lib/legion/cache/pool.rb +32 -15
- data/lib/legion/cache/reconnector.rb +50 -11
- data/lib/legion/cache/redis.rb +146 -120
- data/lib/legion/cache/redis_hash.rb +16 -13
- data/lib/legion/cache/settings.rb +8 -0
- data/lib/legion/cache/version.rb +1 -1
- data/lib/legion/cache.rb +220 -356
- metadata +35 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 468452176251521d3ab40135e1801f7081f738a2e5d6fd60af7710292a066ba7
|
|
4
|
+
data.tar.gz: 79361a0d3b3f4e5c0baf65deaaec789a4a1750079c298eef92cbc72d3f73bd0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e07439fc6f681b7664561eb1b60a4e592c9633a4b26c8c5445062193f8fd883d3118e09f85317051ee93dba81e8d80c584704688d6564ff48c32f7c550a1527
|
|
7
|
+
data.tar.gz: 7e61e51d9302b24aef31e86fc3ce39ef19c0ff13171968393c2ce0a8bc25931b7fbc1a80686f04ec34cc1f950ec447732fced3a8ae8c97f67e76fb4a581cc4eb
|
data/.gitignore
CHANGED
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
|
|
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
|
-
#
|
|
28
|
-
Legion::Cache.set('foobar', 'testing', 10)
|
|
29
|
-
Legion::Cache.get('foobar')
|
|
30
|
-
Legion::Cache.fetch('
|
|
31
|
-
Legion::Cache.
|
|
32
|
-
Legion::Cache.
|
|
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
|
|
35
|
-
Legion::Cache.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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` (
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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')
|
|
156
|
-
runner.get_presence(user_id: 'me', bypass_local_method_cache: true)
|
|
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`),
|
|
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
|
-
@
|
|
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
|
-
@
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
@
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
@
|
|
98
|
+
@mutex.synchronize { @running }
|
|
81
99
|
end
|
|
82
100
|
|
|
83
101
|
def pool_size
|
|
84
|
-
@
|
|
102
|
+
@mutex.synchronize { @workers.size }
|
|
85
103
|
end
|
|
86
104
|
|
|
87
105
|
def queue_depth
|
|
88
|
-
@
|
|
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
|
|
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 ||
|
|
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::
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
131
|
+
memory_store.set(key, value, ttl: ttl)
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
def self.memory_clear!
|
|
134
|
-
|
|
135
|
+
memory_store.clear
|
|
135
136
|
end
|
|
136
137
|
end
|
|
137
138
|
end
|