legion-cache 1.3.18 → 1.3.20

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: aded92a74b60ff3cae8cab7d33d38f147bf17ac308dfd78695b2e6e2ed70298e
4
- data.tar.gz: 244c88d6428745bd91d949dc7738cf65392e644d2143b3c092ab9b84fc610cd2
3
+ metadata.gz: 9f7b5b5352d50ed073af5771dd82ce824db96f0cc508beb942250dfc447428df
4
+ data.tar.gz: 42f6988a094eaaf4ba63c0b91ac35dd614c91bba256f574e2b97a93df4d63686
5
5
  SHA512:
6
- metadata.gz: d6b790d262f8d9735c7d3a31a5222b449408deb00126f5184b8404fafbfa95a30a2eb3826a1cff5c0a44420fe0510399ff47c002ec816b65f596079977d00e4b
7
- data.tar.gz: 774eb4fd9532934894e4d38ab52cba094e5fbab03c33eeb12f9aae0a244efebfddf3eea454a97445598143de680e2eac01ddd6b4f256b4ce116408e398c07e50
6
+ metadata.gz: 155a80836657e0f8e0d1260ad02d2082fa82f80fed10f7f861d794fc47e7538dbe70305c2561d99e457fe5e8ee2c40d09985bd1a5c8351cb9a2cc26913f43804
7
+ data.tar.gz: c17d8a5c0a71f85488713ccbff577d5b7beb4f7a88126a393c1978d7f6a89c20e71f3ede41ce8e7d1f214664a24c72eb7dba3f2d2d8dd5a09363c0b7cda66ce2
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.3.20] - 2026-03-31
6
+
7
+ ### Fixed
8
+ - Forward `timeout` setting to `::Redis.new` — was silently using redis gem's 1.0s default instead of configured 5s, causing spurious timeouts on service mesh connections
9
+ - Forward `timeout` to `::Redis.new` in cluster mode path as well
10
+
11
+ ### Changed
12
+ - Increase `reconnect_attempts` from `1` to `[0, 0.5, 1]` (shared) / `[0, 0.25, 0.5]` (local) — 3 retries with escalating backoff instead of 1 instant retry, improving resilience for service mesh and remote Redis connections
13
+
14
+ ## [1.3.19] - 2026-03-31
15
+
16
+ ### Added
17
+ - `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)
18
+ - `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)
19
+
5
20
  ## [1.3.18] - 2026-03-29
6
21
 
7
22
  ### Added
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
@@ -54,6 +54,162 @@ module Legion
54
54
  !Legion::Cache.get(cache_namespace + key).nil?
55
55
  end
56
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
+ {}
115
+ end
116
+
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
+
57
213
  # --- Core Operations (local tier) ---
58
214
 
59
215
  def local_cache_set(key, value, ttl: nil, phi: false)
@@ -147,6 +303,56 @@ module Legion
147
303
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
148
304
  .downcase
149
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
150
356
  end
151
357
  end
152
358
  end
@@ -12,7 +12,7 @@ module Legion
12
12
  extend self
13
13
 
14
14
  def client(pool_size: 20, timeout: 5, server: nil, servers: [], cluster: nil, replica: false, # rubocop:disable Metrics/ParameterLists
15
- fixed_hostname: nil, username: nil, password: nil, db: nil, reconnect_attempts: 1, **)
15
+ fixed_hostname: nil, username: nil, password: nil, db: nil, reconnect_attempts: [0, 0.5, 1], **)
16
16
  return @client unless @client.nil?
17
17
 
18
18
  @pool_size = pool_size
@@ -31,10 +31,10 @@ module Legion
31
31
  end
32
32
 
33
33
  def build_redis_client(server: nil, servers: [], cluster: nil, replica: false, fixed_hostname: nil, # rubocop:disable Metrics/ParameterLists
34
- username: nil, password: nil, db: nil, reconnect_attempts: 1)
34
+ username: nil, password: nil, db: nil, reconnect_attempts: [0, 0.5, 1])
35
35
  nodes = Array(cluster).compact
36
36
  if nodes.any?
37
- opts = { cluster: nodes, reconnect_attempts: reconnect_attempts }
37
+ opts = { cluster: nodes, reconnect_attempts: reconnect_attempts, timeout: @timeout }
38
38
  opts[:replica] = true if replica
39
39
  opts[:fixed_hostname] = fixed_hostname unless fixed_hostname.nil?
40
40
  opts[:username] = username unless username.nil?
@@ -45,7 +45,8 @@ module Legion
45
45
  driver: 'redis', server: server, servers: servers
46
46
  )
47
47
  host, port = resolved.first.split(':')
48
- redis_opts = { host: host, port: port.to_i, reconnect_attempts: reconnect_attempts }
48
+ redis_opts = { host: host, port: port.to_i, reconnect_attempts: reconnect_attempts,
49
+ timeout: @timeout }
49
50
  redis_opts[:username] = username unless username.nil?
50
51
  redis_opts[:password] = password unless password.nil?
51
52
  redis_opts[:db] = db unless db.nil?
@@ -33,7 +33,7 @@ module Legion
33
33
  username: nil,
34
34
  password: nil,
35
35
  db: nil,
36
- reconnect_attempts: 1
36
+ reconnect_attempts: [0, 0.5, 1].freeze
37
37
  }
38
38
  end
39
39
 
@@ -56,7 +56,7 @@ module Legion
56
56
  username: nil,
57
57
  password: nil,
58
58
  db: nil,
59
- reconnect_attempts: 1
59
+ reconnect_attempts: [0, 0.25, 0.5].freeze
60
60
  }
61
61
  end
62
62
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Cache
5
- VERSION = '1.3.18'
5
+ VERSION = '1.3.20'
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.18
4
+ version: 1.3.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity