legion-cache 1.3.17 → 1.3.19

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: 2d0de0aa53a2886b16a2d2e10bb18e37128a0e0d50d118d689c8f87aa15fcb72
4
- data.tar.gz: 49387e7f795a779e595b700c2afe8eda19b7bc0e07273306ae57ca9ec71b2782
3
+ metadata.gz: d491473adc0cc25e2737e471f1dbc644544e2bbc25c67d9ab8e1402836c9f9a7
4
+ data.tar.gz: dd72aaf34ad2f97c37117a9b882a9cf76a34a4bd6d1701316a42eff5dafb1317
5
5
  SHA512:
6
- metadata.gz: af05e3b8f771a81307f9c3fb81c6dcb6a9fad154bd127660d71a9e645c44636fb1317ef70db618fa5e139fb617d012747bb477017226b829cc5d26ff3db6cd5a
7
- data.tar.gz: 0e31e8c387e44ff1c8e4089cabd04536df39e373cbc38bc90dc1beba85b080d92d77887ee1f4faccf4ef96bb0a02bc51d43bdebda53231e627d26f57e014775f
6
+ metadata.gz: 756116bb625d9aafc055c12c835777e91d1defbe9cc1308a1021789ac27a64382fcc1fb5378d78afb365cc96f0f6a403330d2c4694d151be2bd2c13631303f4c
7
+ data.tar.gz: 117f46e60ca535ee8ce0c4080e3a6f8c71e2cade0ea023ff6edd1fff6ad96cce2ee43448184400f90d5ce58980f58a54f00dc710c5f1fd53127e9a59745a624c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.3.19] - 2026-03-31
6
+
7
+ ### Added
8
+ - `cache_mget` / `cache_mset` (and `local_cache_mget` / `local_cache_mset`) on `Helper` mixin — delegates to Redis batch ops, falls back to sequential get/set on Memcached (closes #3)
9
+ - `cache_hset`, `cache_hgetall`, `cache_hdel`, `cache_zadd`, `cache_zrangebyscore`, `cache_zrem`, `cache_expire` on `Helper` mixin — delegates to `RedisHash` with namespace prefixing; hash ops fall back to JSON-serialized Memcached values, sorted-set ops raise `NotImplementedError`, expire is a no-op on Memcached (closes #4)
10
+
11
+ ## [1.3.18] - 2026-03-29
12
+
13
+ ### Added
14
+ - Layered TTL resolution in Helper (per-call → LEX override → Settings → FALLBACK_TTL)
15
+ - `cache_default_ttl` / `local_cache_default_ttl` — LEX-overridable default TTL methods
16
+ - `cache_exist?` / `local_cache_exist?` — key existence checks
17
+ - `cache_connected?` / `local_cache_connected?` — connection status helpers
18
+ - `cache_pool_size` / `cache_pool_available` — pool info (shared tier)
19
+ - `local_cache_pool_size` / `local_cache_pool_available` — pool info (local tier)
20
+ - `phi:` keyword argument on `cache_set` / `local_cache_set` for PHI TTL enforcement
21
+ - `default_ttl` key in Settings.default and Settings.local (defaults to 60)
22
+
5
23
  ## [1.3.17] - 2026-03-25
6
24
 
7
25
  ### Added
data/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@
8
8
  Caching wrapper for the LegionIO framework. Provides a consistent interface for Memcached (via `dalli`) and Redis (via `redis` gem) with connection pooling. Driver selection is config-driven.
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/legion-cache
11
- **Version**: 1.3.12
11
+ **Version**: 1.3.17
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Architecture
@@ -39,6 +39,7 @@ Legion::Cache (singleton module)
39
39
  ├── Memory # Lite mode adapter: pure in-memory cache, TTL expiry, Mutex thread-safety
40
40
  │ └── Activated by LEGION_MODE=lite env var; no Redis/Memcached required
41
41
  ├── Helper # Injectable cache mixin for LEX extensions (namespaced cache_*/local_cache_*)
42
+ ├── RedisHash # Redis-specific sorted set + hash operations (hset/hgetall/hdel/zadd/zrangebyscore/zrem/expire); Redis-only, module_function pattern
42
43
  ├── Local # Local cache tier (localhost Redis/Memcached, fallback target)
43
44
  │ ├── .setup # Connect to local cache server (auto-detect driver)
44
45
  │ ├── .shutdown # Close local connection
@@ -134,10 +135,25 @@ Dalli enforces a 1MB client-side limit by default (`value_max_bytes: 1_048_576`)
134
135
  | `lib/legion/cache/memory.rb` | Lite mode Memory adapter: in-memory store with TTL + Mutex thread-safety |
135
136
  | `lib/legion/cache/helper.rb` | Injectable cache mixin for LEX extensions |
136
137
  | `lib/legion/cache/local.rb` | Local cache tier (localhost, fallback target) |
138
+ | `lib/legion/cache/redis_hash.rb` | Redis sorted set + hash operations (hset/hgetall/hdel/zadd/zrangebyscore/zrem/expire) |
137
139
  | `lib/legion/cache/pool.rb` | Connection pool management |
138
140
  | `lib/legion/cache/settings.rb` | Default configuration + local defaults |
139
141
  | `lib/legion/cache/version.rb` | VERSION constant |
140
142
 
143
+ ## PHI TTL Cap
144
+
145
+ When `phi: true` is passed to `set`, the TTL is capped at `cache.compliance.phi_max_ttl` (default 3600s). This enforces the HIPAA PHI TTL policy in legion-logging. The `enforce_phi_ttl(ttl, phi: false)` method applies the cap; without `phi: true` the TTL is passed through unchanged.
146
+
147
+ ```json
148
+ {
149
+ "cache": {
150
+ "compliance": {
151
+ "phi_max_ttl": 3600
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
141
157
  ## Role in LegionIO
142
158
 
143
159
  Optional caching layer initialized during `Legion::Service` startup. Used by `legion-data` for model caching (Sequel caching plugin) and by extensions for general-purpose caching.
data/Gemfile CHANGED
@@ -8,5 +8,6 @@ group :test do
8
8
  gem 'rspec'
9
9
  gem 'rspec_junit_formatter'
10
10
  gem 'rubocop'
11
+ gem 'rubocop-legion'
11
12
  gem 'simplecov'
12
13
  end
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.12
5
+ **Version**: 1.3.17
6
6
 
7
7
  ## Installation
8
8
 
@@ -3,12 +3,38 @@
3
3
  module Legion
4
4
  module Cache
5
5
  module Helper
6
+ FALLBACK_TTL = 60
7
+
8
+ # --- TTL Resolution ---
9
+ # Override in your LEX to set a custom default TTL for the extension.
10
+ # Resolution chain: per-call ttl: kwarg -> LEX override -> Settings -> FALLBACK_TTL
11
+ def cache_default_ttl
12
+ return FALLBACK_TTL unless defined?(Legion::Settings)
13
+
14
+ Legion::Settings.dig(:cache, :default_ttl) || FALLBACK_TTL
15
+ rescue StandardError
16
+ FALLBACK_TTL
17
+ end
18
+
19
+ def local_cache_default_ttl
20
+ return cache_default_ttl unless defined?(Legion::Settings)
21
+
22
+ Legion::Settings.dig(:cache_local, :default_ttl) || cache_default_ttl
23
+ rescue StandardError
24
+ cache_default_ttl
25
+ end
26
+
27
+ # --- Namespace ---
28
+
6
29
  def cache_namespace
7
30
  @cache_namespace ||= derive_cache_namespace
8
31
  end
9
32
 
10
- def cache_set(key, value, ttl: 60)
11
- Legion::Cache.set(cache_namespace + key, value, ttl)
33
+ # --- Core Operations (shared tier) ---
34
+
35
+ def cache_set(key, value, ttl: nil, phi: false)
36
+ effective_ttl = ttl || cache_default_ttl
37
+ Legion::Cache.set(cache_namespace + key, value, effective_ttl, phi: phi)
12
38
  end
13
39
 
14
40
  def cache_get(key)
@@ -19,12 +45,177 @@ module Legion
19
45
  Legion::Cache.delete(cache_namespace + key)
20
46
  end
21
47
 
22
- def cache_fetch(key, ttl: 60, &)
23
- Legion::Cache.fetch(cache_namespace + key, ttl, &)
48
+ def cache_fetch(key, ttl: nil, &)
49
+ effective_ttl = ttl || cache_default_ttl
50
+ Legion::Cache.fetch(cache_namespace + key, effective_ttl, &)
51
+ end
52
+
53
+ def cache_exist?(key)
54
+ !Legion::Cache.get(cache_namespace + key).nil?
55
+ end
56
+
57
+ # --- Batch Operations (shared tier) ---
58
+ # Issue #3: mget/mset with Memcached safety
59
+
60
+ # Returns a Hash of { key => value } pairs. Prefixes all keys with cache_namespace.
61
+ # Delegates to Legion::Cache.mget on Redis; falls back to sequential gets on Memcached.
62
+ def cache_mget(*keys)
63
+ keys = keys.flatten
64
+ return {} if keys.empty?
65
+
66
+ namespaced = keys.map { |k| cache_namespace + k }
67
+
68
+ if cache_redis?
69
+ raw = Legion::Cache.mget(*namespaced)
70
+ keys.to_h { |k| [k, raw[cache_namespace + k]] }
71
+ else
72
+ keys.to_h { |k| [k, Legion::Cache.get(cache_namespace + k)] }
73
+ end
74
+ rescue StandardError => e
75
+ log_cache_error('cache_mget', e)
76
+ {}
77
+ end
78
+
79
+ # Stores multiple key-value pairs. Accepts a Hash of { key => value }.
80
+ # TTL follows the same resolution chain as cache_set.
81
+ # Delegates to Legion::Cache.mset on Redis; falls back to sequential sets on Memcached.
82
+ def cache_mset(hash, ttl: nil)
83
+ return true if hash.empty?
84
+
85
+ effective_ttl = ttl || cache_default_ttl
86
+
87
+ if cache_redis?
88
+ namespaced = hash.transform_keys { |k| cache_namespace + k }
89
+ Legion::Cache.mset(namespaced)
90
+ else
91
+ hash.each { |k, v| Legion::Cache.set(cache_namespace + k, v, effective_ttl) }
92
+ true
93
+ end
94
+ rescue StandardError => e
95
+ log_cache_error('cache_mset', e)
96
+ false
97
+ end
98
+
99
+ # --- Batch Operations (local tier) ---
100
+
101
+ def local_cache_mget(*keys)
102
+ keys = keys.flatten
103
+ return {} if keys.empty?
104
+
105
+ if local_cache_redis?
106
+ namespaced = keys.map { |k| cache_namespace + k }
107
+ raw = Legion::Cache::Local.mget(*namespaced)
108
+ keys.to_h { |k| [k, raw[cache_namespace + k]] }
109
+ else
110
+ keys.to_h { |k| [k, Legion::Cache::Local.get(cache_namespace + k)] }
111
+ end
112
+ rescue StandardError => e
113
+ log_cache_error('local_cache_mget', e)
114
+ {}
24
115
  end
25
116
 
26
- def local_cache_set(key, value, ttl: 60)
27
- Legion::Cache::Local.set(cache_namespace + key, value, ttl)
117
+ def local_cache_mset(hash, ttl: nil)
118
+ return true if hash.empty?
119
+
120
+ effective_ttl = ttl || local_cache_default_ttl
121
+
122
+ if local_cache_redis?
123
+ namespaced = hash.transform_keys { |k| cache_namespace + k }
124
+ Legion::Cache::Local.mset(namespaced)
125
+ else
126
+ hash.each { |k, v| Legion::Cache::Local.set(cache_namespace + k, v, effective_ttl) }
127
+ true
128
+ end
129
+ rescue StandardError => e
130
+ log_cache_error('local_cache_mset', e)
131
+ false
132
+ end
133
+
134
+ # --- RedisHash Helpers (shared tier) ---
135
+ # Issue #4: namespaced wrappers for RedisHash operations with Memcached fallback
136
+
137
+ def cache_hset(key, hash)
138
+ if cache_redis?
139
+ Legion::Cache::RedisHash.hset(cache_namespace + key, hash)
140
+ else
141
+ memcached_hash_merge(cache_namespace + key, hash)
142
+ end
143
+ rescue StandardError => e
144
+ log_cache_error('cache_hset', e)
145
+ false
146
+ end
147
+
148
+ def cache_hgetall(key)
149
+ if cache_redis?
150
+ Legion::Cache::RedisHash.hgetall(cache_namespace + key)
151
+ else
152
+ memcached_hash_load(cache_namespace + key)
153
+ end
154
+ rescue StandardError => e
155
+ log_cache_error('cache_hgetall', e)
156
+ nil
157
+ end
158
+
159
+ def cache_hdel(key, *fields)
160
+ if cache_redis?
161
+ Legion::Cache::RedisHash.hdel(cache_namespace + key, *fields)
162
+ else
163
+ memcached_hash_delete_fields(cache_namespace + key, fields)
164
+ end
165
+ rescue StandardError => e
166
+ log_cache_error('cache_hdel', e)
167
+ 0
168
+ end
169
+
170
+ def cache_zadd(key, score, member)
171
+ raise_sorted_set_unsupported('cache_zadd') unless cache_redis?
172
+
173
+ Legion::Cache::RedisHash.zadd(cache_namespace + key, score, member)
174
+ rescue NotImplementedError
175
+ raise
176
+ rescue StandardError => e
177
+ log_cache_error('cache_zadd', e)
178
+ false
179
+ end
180
+
181
+ def cache_zrangebyscore(key, min, max, limit: nil)
182
+ raise_sorted_set_unsupported('cache_zrangebyscore') unless cache_redis?
183
+
184
+ Legion::Cache::RedisHash.zrangebyscore(cache_namespace + key, min, max, limit: limit)
185
+ rescue NotImplementedError
186
+ raise
187
+ rescue StandardError => e
188
+ log_cache_error('cache_zrangebyscore', e)
189
+ []
190
+ end
191
+
192
+ def cache_zrem(key, member)
193
+ raise_sorted_set_unsupported('cache_zrem') unless cache_redis?
194
+
195
+ Legion::Cache::RedisHash.zrem(cache_namespace + key, member)
196
+ rescue NotImplementedError
197
+ raise
198
+ rescue StandardError => e
199
+ log_cache_error('cache_zrem', e)
200
+ false
201
+ end
202
+
203
+ # Sets TTL on a key. No-op on Memcached (TTL is set at write time).
204
+ def cache_expire(key, seconds)
205
+ return false unless cache_redis?
206
+
207
+ Legion::Cache::RedisHash.expire(cache_namespace + key, seconds)
208
+ rescue StandardError => e
209
+ log_cache_error('cache_expire', e)
210
+ false
211
+ end
212
+
213
+ # --- Core Operations (local tier) ---
214
+
215
+ def local_cache_set(key, value, ttl: nil, phi: false)
216
+ effective_ttl = ttl || local_cache_default_ttl
217
+ effective_ttl = Legion::Cache.enforce_phi_ttl(effective_ttl, phi: phi)
218
+ Legion::Cache::Local.set(cache_namespace + key, value, effective_ttl)
28
219
  end
29
220
 
30
221
  def local_cache_get(key)
@@ -35,8 +226,57 @@ module Legion
35
226
  Legion::Cache::Local.delete(cache_namespace + key)
36
227
  end
37
228
 
38
- def local_cache_fetch(key, ttl: 60, &)
39
- Legion::Cache::Local.fetch(cache_namespace + key, ttl, &)
229
+ def local_cache_fetch(key, ttl: nil, &)
230
+ effective_ttl = ttl || local_cache_default_ttl
231
+ Legion::Cache::Local.fetch(cache_namespace + key, effective_ttl, &)
232
+ end
233
+
234
+ def local_cache_exist?(key)
235
+ !Legion::Cache::Local.get(cache_namespace + key).nil?
236
+ end
237
+
238
+ # --- Status ---
239
+
240
+ def cache_connected?
241
+ Legion::Cache.connected?
242
+ end
243
+
244
+ def local_cache_connected?
245
+ Legion::Cache::Local.connected?
246
+ end
247
+
248
+ # --- Pool Info ---
249
+
250
+ def cache_pool_size
251
+ return 0 unless cache_connected?
252
+
253
+ Legion::Cache.pool_size
254
+ rescue StandardError
255
+ 0
256
+ end
257
+
258
+ def cache_pool_available
259
+ return 0 unless cache_connected?
260
+
261
+ Legion::Cache.available
262
+ rescue StandardError
263
+ 0
264
+ end
265
+
266
+ def local_cache_pool_size
267
+ return 0 unless local_cache_connected?
268
+
269
+ Legion::Cache::Local.pool_size
270
+ rescue StandardError
271
+ 0
272
+ end
273
+
274
+ def local_cache_pool_available
275
+ return 0 unless local_cache_connected?
276
+
277
+ Legion::Cache::Local.available
278
+ rescue StandardError
279
+ 0
40
280
  end
41
281
 
42
282
  private
@@ -63,6 +303,56 @@ module Legion
63
303
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
64
304
  .downcase
65
305
  end
306
+
307
+ def cache_redis?
308
+ Legion::Cache::RedisHash.redis_available?
309
+ end
310
+
311
+ def local_cache_redis?
312
+ defined?(Legion::Cache::Local) &&
313
+ Legion::Cache::Local.respond_to?(:mget) &&
314
+ Legion::Cache::Local.connected?
315
+ end
316
+
317
+ def memcached_hash_merge(full_key, new_fields)
318
+ current = memcached_hash_load(full_key) || {}
319
+ merged = current.merge(new_fields.transform_keys(&:to_s))
320
+ Legion::Cache.set(full_key, Legion::JSON.dump(merged), cache_default_ttl)
321
+ true
322
+ end
323
+
324
+ def memcached_hash_load(full_key)
325
+ raw = Legion::Cache.get(full_key)
326
+ return nil if raw.nil?
327
+
328
+ parsed = Legion::JSON.load(raw)
329
+ # Legion::JSON.load returns symbol keys; convert to string keys to mirror Redis hgetall
330
+ parsed.transform_keys(&:to_s)
331
+ rescue StandardError
332
+ nil
333
+ end
334
+
335
+ def memcached_hash_delete_fields(full_key, fields)
336
+ current = memcached_hash_load(full_key)
337
+ return 0 if current.nil?
338
+
339
+ str_fields = fields.map(&:to_s)
340
+ removed = str_fields.count { |f| current.key?(f) }
341
+ str_fields.each { |f| current.delete(f) }
342
+ Legion::Cache.set(full_key, Legion::JSON.dump(current), cache_default_ttl)
343
+ removed
344
+ end
345
+
346
+ def raise_sorted_set_unsupported(method)
347
+ raise NotImplementedError,
348
+ "#{method} requires a Redis backend — sorted sets are not supported on Memcached"
349
+ end
350
+
351
+ def log_cache_error(method, error)
352
+ return unless defined?(Legion::Logging)
353
+
354
+ Legion::Logging.warn "[cache:helper] #{method} failed: #{error.class} — #{error.message}"
355
+ end
66
356
  end
67
357
  end
68
358
  end
@@ -25,6 +25,7 @@ module Legion
25
25
  cache_nils: false,
26
26
  pool_size: 10,
27
27
  timeout: 5,
28
+ default_ttl: 60,
28
29
  serializer: Legion::JSON,
29
30
  cluster: nil,
30
31
  replica: false,
@@ -50,6 +51,7 @@ module Legion
50
51
  cache_nils: false,
51
52
  pool_size: 5,
52
53
  timeout: 3,
54
+ default_ttl: 60,
53
55
  serializer: Legion::JSON,
54
56
  username: nil,
55
57
  password: nil,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Cache
5
- VERSION = '1.3.17'
5
+ VERSION = '1.3.19'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.17
4
+ version: 1.3.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity