factorix 0.5.1 → 0.7.0

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +1 -1
  4. data/exe/factorix +17 -0
  5. data/lib/factorix/api/mod_download_api.rb +11 -6
  6. data/lib/factorix/api/mod_info.rb +2 -2
  7. data/lib/factorix/api/mod_management_api.rb +1 -1
  8. data/lib/factorix/api/mod_portal_api.rb +6 -49
  9. data/lib/factorix/api_credential.rb +1 -1
  10. data/lib/factorix/cache/base.rb +116 -0
  11. data/lib/factorix/cache/entry.rb +25 -0
  12. data/lib/factorix/cache/file_system.rb +137 -57
  13. data/lib/factorix/cache/redis.rb +287 -0
  14. data/lib/factorix/cache/s3.rb +388 -0
  15. data/lib/factorix/cli/commands/backup_support.rb +1 -1
  16. data/lib/factorix/cli/commands/base.rb +3 -3
  17. data/lib/factorix/cli/commands/cache/evict.rb +19 -24
  18. data/lib/factorix/cli/commands/cache/stat.rb +66 -67
  19. data/lib/factorix/cli/commands/command_wrapper.rb +5 -5
  20. data/lib/factorix/cli/commands/completion.rb +1 -2
  21. data/lib/factorix/cli/commands/confirmable.rb +1 -1
  22. data/lib/factorix/cli/commands/download_support.rb +2 -7
  23. data/lib/factorix/cli/commands/mod/check.rb +1 -1
  24. data/lib/factorix/cli/commands/mod/disable.rb +1 -1
  25. data/lib/factorix/cli/commands/mod/download.rb +7 -7
  26. data/lib/factorix/cli/commands/mod/edit.rb +10 -13
  27. data/lib/factorix/cli/commands/mod/enable.rb +1 -1
  28. data/lib/factorix/cli/commands/mod/image/add.rb +3 -6
  29. data/lib/factorix/cli/commands/mod/image/edit.rb +2 -5
  30. data/lib/factorix/cli/commands/mod/image/list.rb +5 -8
  31. data/lib/factorix/cli/commands/mod/install.rb +7 -7
  32. data/lib/factorix/cli/commands/mod/list.rb +7 -7
  33. data/lib/factorix/cli/commands/mod/search.rb +13 -12
  34. data/lib/factorix/cli/commands/mod/settings/dump.rb +3 -3
  35. data/lib/factorix/cli/commands/mod/settings/restore.rb +2 -2
  36. data/lib/factorix/cli/commands/mod/show.rb +22 -23
  37. data/lib/factorix/cli/commands/mod/sync.rb +8 -8
  38. data/lib/factorix/cli/commands/mod/uninstall.rb +1 -1
  39. data/lib/factorix/cli/commands/mod/update.rb +11 -43
  40. data/lib/factorix/cli/commands/mod/upload.rb +7 -10
  41. data/lib/factorix/cli/commands/path.rb +2 -2
  42. data/lib/factorix/cli/commands/portal_support.rb +27 -0
  43. data/lib/factorix/cli/commands/version.rb +1 -1
  44. data/lib/factorix/container.rb +155 -0
  45. data/lib/factorix/dependency/parser.rb +1 -1
  46. data/lib/factorix/errors.rb +3 -0
  47. data/lib/factorix/http/cache_decorator.rb +5 -5
  48. data/lib/factorix/http/client.rb +3 -3
  49. data/lib/factorix/info_json.rb +7 -7
  50. data/lib/factorix/mod_list.rb +2 -2
  51. data/lib/factorix/mod_settings.rb +2 -2
  52. data/lib/factorix/portal.rb +3 -2
  53. data/lib/factorix/runtime/user_configurable.rb +9 -9
  54. data/lib/factorix/service_credential.rb +3 -3
  55. data/lib/factorix/transfer/downloader.rb +19 -11
  56. data/lib/factorix/version.rb +1 -1
  57. data/lib/factorix.rb +110 -1
  58. data/sig/factorix/api/mod_download_api.rbs +1 -2
  59. data/sig/factorix/cache/base.rbs +28 -0
  60. data/sig/factorix/cache/entry.rbs +14 -0
  61. data/sig/factorix/cache/file_system.rbs +7 -6
  62. data/sig/factorix/cache/redis.rbs +36 -0
  63. data/sig/factorix/cache/s3.rbs +38 -0
  64. data/sig/factorix/container.rbs +15 -0
  65. data/sig/factorix/errors.rbs +3 -0
  66. data/sig/factorix/portal.rbs +1 -1
  67. data/sig/factorix.rbs +99 -0
  68. metadata +27 -4
  69. data/lib/factorix/application.rb +0 -218
  70. data/sig/factorix/application.rbs +0 -86
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "fileutils"
5
+ require "json"
5
6
  require "pathname"
6
7
  require "zlib"
7
8
 
@@ -12,7 +13,12 @@ module Factorix
12
13
  # Uses a two-level directory structure to store cached files,
13
14
  # with file locking to handle concurrent access and TTL support
14
15
  # for cache expiration.
15
- class FileSystem
16
+ #
17
+ # Cache entries consist of:
18
+ # - Data file: the cached content (optionally compressed)
19
+ # - Metadata file (.metadata): JSON containing the logical key
20
+ # - Lock file (.lock): used for concurrent access control
21
+ class FileSystem < Base
16
22
  # @!parse
17
23
  # # @return [Dry::Logger::Dispatcher]
18
24
  # attr_reader :logger
@@ -29,52 +35,46 @@ module Factorix
29
35
  private_constant :ZLIB_CMF_BYTE
30
36
 
31
37
  # Initialize a new file system cache storage.
32
- # Creates the cache directory if it doesn't exist
38
+ # Creates the cache directory if it doesn't exist.
39
+ # Cache directory is auto-calculated as: factorix_cache_dir / cache_type
33
40
  #
34
- # @param cache_dir [Pathname] path to the cache directory
35
- # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
41
+ # @param cache_type [Symbol] cache type for directory name (e.g., :api, :download)
36
42
  # @param max_file_size [Integer, nil] maximum file size in bytes (nil for unlimited)
37
43
  # @param compression_threshold [Integer, nil] compress data larger than this size in bytes
38
44
  # (nil: no compression, 0: always compress, N: compress if >= N bytes)
39
- def initialize(cache_dir, ttl: nil, max_file_size: nil, compression_threshold: nil, logger: nil)
40
- super(logger:)
41
- @cache_dir = cache_dir
42
- @ttl = ttl
45
+ # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
46
+ def initialize(cache_type:, max_file_size: nil, compression_threshold: nil, **)
47
+ super(**)
48
+ @cache_dir = Container[:runtime].factorix_cache_dir / cache_type.to_s
43
49
  @max_file_size = max_file_size
44
50
  @compression_threshold = compression_threshold
45
51
  @cache_dir.mkpath
46
- logger.info("Initializing cache", dir: @cache_dir.to_s, ttl: @ttl, max_size: @max_file_size, compression_threshold: @compression_threshold)
52
+ logger.info("Initializing cache", root: @cache_dir.to_s, ttl: @ttl, max_size: @max_file_size, compression_threshold: @compression_threshold)
47
53
  end
48
54
 
49
- # Generate a cache key for the given URL string.
50
- # Uses SHA1 to create a unique, deterministic key
51
- #
52
- # @param url_string [String] URL string to generate key for
53
- # @return [String] cache key
54
- # Use Digest(:SHA1) instead of Digest::SHA1 for thread-safety (Ruby 2.2+)
55
- def key_for(url_string) = Digest(:SHA1).hexdigest(url_string)
56
-
57
55
  # Check if a cache entry exists and is not expired.
58
56
  # A cache entry is considered to exist if its file exists and is not expired
59
57
  #
60
- # @param key [String] cache key to check
58
+ # @param key [String] logical cache key
61
59
  # @return [Boolean] true if the cache entry exists and is valid, false otherwise
62
60
  def exist?(key)
63
- return false unless cache_path_for(key).exist?
61
+ internal_key = storage_key_for(key)
62
+ return false unless cache_path_for(internal_key).exist?
64
63
  return true if @ttl.nil?
65
64
 
66
65
  !expired?(key)
67
66
  end
68
67
 
69
- # Fetch a cached file and copy it to the output path.
68
+ # Write cached content to a file.
70
69
  # If the cache entry doesn't exist or is expired, returns false without modifying the output path.
71
70
  # Automatically decompresses zlib-compressed cache entries.
72
71
  #
73
- # @param key [String] cache key to fetch
74
- # @param output [Pathname] path to copy the cached file to
75
- # @return [Boolean] true if the cache entry was found and copied, false otherwise
76
- def fetch(key, output)
77
- path = cache_path_for(key)
72
+ # @param key [String] logical cache key
73
+ # @param output [Pathname] path to write the cached content to
74
+ # @return [Boolean] true if written successfully, false if not found/expired
75
+ def write_to(key, output)
76
+ internal_key = storage_key_for(key)
77
+ path = cache_path_for(internal_key)
78
78
  unless path.exist?
79
79
  logger.debug("Cache miss", key:)
80
80
  return false
@@ -96,21 +96,21 @@ module Factorix
96
96
  true
97
97
  end
98
98
 
99
- # Read a cached file as a string.
99
+ # Read a cached file as a binary string.
100
100
  # If the cache entry doesn't exist or is expired, returns nil.
101
101
  # Automatically decompresses zlib-compressed cache entries.
102
102
  #
103
- # @param key [String] cache key to read
104
- # @param encoding [Encoding, String] encoding to use (default: ASCII-8BIT for binary)
103
+ # @param key [String] logical cache key
105
104
  # @return [String, nil] cached content or nil if not found/expired
106
- def read(key, encoding: Encoding::ASCII_8BIT)
107
- path = cache_path_for(key)
105
+ def read(key)
106
+ internal_key = storage_key_for(key)
107
+ path = cache_path_for(internal_key)
108
108
  return nil unless path.exist?
109
109
  return nil if expired?(key)
110
110
 
111
111
  data = path.binread
112
112
  data = Zlib.inflate(data) if zlib_compressed?(data)
113
- data.force_encoding(encoding)
113
+ data
114
114
  end
115
115
 
116
116
  # Store a file in the cache.
@@ -118,7 +118,7 @@ module Factorix
118
118
  # Optionally compresses data based on compression_threshold setting.
119
119
  # If the (possibly compressed) size exceeds max_file_size, skips caching and returns false.
120
120
  #
121
- # @param key [String] cache key to store under
121
+ # @param key [String] logical cache key
122
122
  # @param src [Pathname] path of the file to store
123
123
  # @return [Boolean] true if cached successfully, false if skipped due to size limit
124
124
  def store(key, src)
@@ -135,22 +135,30 @@ module Factorix
135
135
  return false
136
136
  end
137
137
 
138
- path = cache_path_for(key)
138
+ internal_key = storage_key_for(key)
139
+ path = cache_path_for(internal_key)
140
+ metadata_path = metadata_path_for(internal_key)
141
+
139
142
  path.dirname.mkpath
140
143
  path.binwrite(data)
144
+ metadata_path.write(JSON.generate({logical_key: key}))
141
145
  logger.debug("Stored in cache", key:, size_bytes: data.bytesize)
142
146
  true
143
147
  end
144
148
 
145
149
  # Delete a specific cache entry.
146
150
  #
147
- # @param key [String] cache key to delete
151
+ # @param key [String] logical cache key
148
152
  # @return [Boolean] true if the entry was deleted, false if it didn't exist
149
153
  def delete(key)
150
- path = cache_path_for(key)
154
+ internal_key = storage_key_for(key)
155
+ path = cache_path_for(internal_key)
156
+ metadata_path = metadata_path_for(internal_key)
157
+
151
158
  return false unless path.exist?
152
159
 
153
160
  path.delete
161
+ metadata_path.delete if metadata_path.exist?
154
162
  logger.debug("Deleted from cache", key:)
155
163
  true
156
164
  end
@@ -160,13 +168,14 @@ module Factorix
160
168
  #
161
169
  # @return [void]
162
170
  def clear
163
- logger.info("Clearing cache directory", dir: @cache_dir.to_s)
171
+ logger.info("Clearing cache directory", root: @cache_dir.to_s)
164
172
  count = 0
165
173
  @cache_dir.glob("**/*").each do |path|
166
- if path.file?
167
- path.delete
168
- count += 1
169
- end
174
+ next unless path.file?
175
+ next if path.extname == ".lock"
176
+
177
+ path.delete
178
+ count += 1
170
179
  end
171
180
  logger.info("Cache cleared", files_removed: count)
172
181
  end
@@ -174,10 +183,11 @@ module Factorix
174
183
  # Get the age of a cache entry in seconds.
175
184
  # Returns nil if the entry doesn't exist.
176
185
  #
177
- # @param key [String] cache key
186
+ # @param key [String] logical cache key
178
187
  # @return [Float, nil] age in seconds, or nil if entry doesn't exist
179
188
  def age(key)
180
- path = cache_path_for(key)
189
+ internal_key = storage_key_for(key)
190
+ path = cache_path_for(internal_key)
181
191
  return nil unless path.exist?
182
192
 
183
193
  Time.now - path.mtime
@@ -186,7 +196,7 @@ module Factorix
186
196
  # Check if a cache entry has expired based on TTL.
187
197
  # Returns false if TTL is not set (unlimited) or if entry doesn't exist.
188
198
  #
189
- # @param key [String] cache key
199
+ # @param key [String] logical cache key
190
200
  # @return [Boolean] true if expired, false otherwise
191
201
  def expired?(key)
192
202
  return false if @ttl.nil?
@@ -200,10 +210,11 @@ module Factorix
200
210
  # Get the size of a cached file in bytes.
201
211
  # Returns nil if the entry doesn't exist or is expired.
202
212
  #
203
- # @param key [String] cache key
213
+ # @param key [String] logical cache key
204
214
  # @return [Integer, nil] file size in bytes, or nil if entry doesn't exist/expired
205
215
  def size(key)
206
- path = cache_path_for(key)
216
+ internal_key = storage_key_for(key)
217
+ path = cache_path_for(internal_key)
207
218
  return nil unless path.exist?
208
219
  return nil if expired?(key)
209
220
 
@@ -211,13 +222,14 @@ module Factorix
211
222
  end
212
223
 
213
224
  # Executes the given block with a file lock.
214
- # Uses flock for process-safe file locking and automatically removes stale locks
225
+ # Uses flock for process-safe file locking and automatically removes stale locks.
215
226
  #
216
- # @param key [String] cache key to lock
227
+ # @param key [String] logical cache key
217
228
  # @yield Executes the block with exclusive file lock
218
229
  # @return [void]
219
230
  def with_lock(key)
220
- lock_path = lock_path_for(key)
231
+ internal_key = storage_key_for(key)
232
+ lock_path = lock_path_for(internal_key)
221
233
  cleanup_stale_lock(lock_path)
222
234
 
223
235
  lock_path.dirname.mkpath
@@ -240,23 +252,83 @@ module Factorix
240
252
  end
241
253
  end
242
254
 
243
- # Get the cache file path for the given key.
255
+ # Enumerate cache entries.
256
+ #
257
+ # Yields [key, entry] pairs similar to Hash#each.
258
+ # Skips entries without metadata files (legacy entries).
259
+ #
260
+ # @yield [key, entry] logical key and Entry object
261
+ # @yieldparam key [String] logical cache key
262
+ # @yieldparam entry [Entry] cache entry metadata
263
+ # @return [Enumerator] if no block given
264
+ def each
265
+ return enum_for(__method__) unless block_given?
266
+
267
+ @cache_dir.glob("**/*").each do |path|
268
+ next unless path.file?
269
+ next if path.extname == ".metadata" || path.extname == ".lock"
270
+
271
+ metadata_path = Pathname("#{path}.metadata")
272
+ next unless metadata_path.exist?
273
+
274
+ logical_key = JSON.parse(metadata_path.read)["logical_key"]
275
+ age = Time.now - path.mtime
276
+ entry = Entry.new(
277
+ size: path.size,
278
+ age:,
279
+ expired: @ttl ? age > @ttl : false
280
+ )
281
+
282
+ yield logical_key, entry
283
+ end
284
+ end
285
+
286
+ # Return backend-specific information.
287
+ #
288
+ # @return [Hash] backend configuration and status
289
+ def backend_info
290
+ {
291
+ type: "file_system",
292
+ directory: @cache_dir.to_s,
293
+ max_file_size: @max_file_size,
294
+ compression_threshold: @compression_threshold,
295
+ stale_locks: count_stale_locks
296
+ }
297
+ end
298
+
299
+ # Generate a storage key for the given logical key.
300
+ # Uses SHA1 to create a unique, deterministic key.
301
+ # Use Digest(:SHA1) instead of Digest::SHA1 for thread-safety (Ruby 2.2+)
302
+ #
303
+ # @param logical_key [String] logical key to generate storage key for
304
+ # @return [String] storage key (SHA1 hash)
305
+ private def storage_key_for(logical_key) = Digest(:SHA1).hexdigest(logical_key)
306
+
307
+ # Get the cache file path for the given internal key.
244
308
  # Uses a two-level directory structure to avoid too many files in one directory
245
309
  #
246
- # @param key [String] cache key
310
+ # @param internal_key [String] internal storage key
247
311
  # @return [Pathname] path to the cache file
248
- private def cache_path_for(key)
249
- prefix = key[0, 2]
250
- @cache_dir.join(prefix, key[2..])
312
+ private def cache_path_for(internal_key)
313
+ prefix = internal_key[0, 2]
314
+ @cache_dir.join(prefix, internal_key[2..])
315
+ end
316
+
317
+ # Get the metadata file path for the given internal key.
318
+ #
319
+ # @param internal_key [String] internal storage key
320
+ # @return [Pathname] path to the metadata file
321
+ private def metadata_path_for(internal_key)
322
+ Pathname("#{cache_path_for(internal_key)}.metadata")
251
323
  end
252
324
 
253
- # Get the lock file path for the given key.
325
+ # Get the lock file path for the given internal key.
254
326
  # Lock files are stored alongside cache files with a .lock extension
255
327
  #
256
- # @param key [String] cache key
328
+ # @param internal_key [String] internal storage key
257
329
  # @return [Pathname] path to the lock file
258
- private def lock_path_for(key)
259
- cache_path_for(key).sub_ext(".lock")
330
+ private def lock_path_for(internal_key)
331
+ cache_path_for(internal_key).sub_ext(".lock")
260
332
  end
261
333
 
262
334
  # Check if data should be compressed based on compression_threshold setting.
@@ -302,6 +374,14 @@ module Factorix
302
374
  nil
303
375
  end
304
376
  end
377
+
378
+ # Count stale lock files in the cache directory.
379
+ #
380
+ # @return [Integer] number of stale lock files
381
+ private def count_stale_locks
382
+ cutoff = Time.now - LOCK_FILE_LIFETIME
383
+ @cache_dir.glob("**/*.lock").count {|path| path.mtime < cutoff }
384
+ end
305
385
  end
306
386
  end
307
387
  end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "redis"
5
+ rescue LoadError
6
+ raise Factorix::Error, "redis gem is required for Redis cache backend. Add it to your Gemfile."
7
+ end
8
+
9
+ require "securerandom"
10
+
11
+ module Factorix
12
+ module Cache
13
+ # Redis-based cache storage implementation.
14
+ #
15
+ # Stores cache entries in Redis with automatic namespace prefixing.
16
+ # Metadata (size, created_at) stored in separate hash keys.
17
+ # Supports distributed locking with Lua script for atomic release.
18
+ #
19
+ # @example Configuration
20
+ # Factorix.configure do |config|
21
+ # config.cache.api.backend = :redis
22
+ # config.cache.api.redis.url = "redis://localhost:6379/0"
23
+ # config.cache.api.redis.lock_timeout = 30
24
+ # end
25
+ class Redis < Base
26
+ # @!parse
27
+ # # @return [Dry::Logger::Dispatcher]
28
+ # attr_reader :logger
29
+ include Import[:logger]
30
+
31
+ # Default timeout for distributed lock acquisition in seconds.
32
+ DEFAULT_LOCK_TIMEOUT = 30
33
+ public_constant :DEFAULT_LOCK_TIMEOUT
34
+
35
+ # TTL for distributed locks in seconds.
36
+ LOCK_TTL = 30
37
+ private_constant :LOCK_TTL
38
+
39
+ # Lua script for atomic lock release (only release if we own it).
40
+ RELEASE_LOCK_SCRIPT = <<~LUA
41
+ if redis.call("get", KEYS[1]) == ARGV[1] then
42
+ return redis.call("del", KEYS[1])
43
+ else
44
+ return 0
45
+ end
46
+ LUA
47
+ private_constant :RELEASE_LOCK_SCRIPT
48
+
49
+ # Initialize a new Redis cache storage.
50
+ #
51
+ # @param url [String, nil] Redis URL (defaults to REDIS_URL env)
52
+ # @param cache_type [String, Symbol] Cache type for namespace (e.g., :api, :download)
53
+ # @param lock_timeout [Integer] Timeout for lock acquisition in seconds
54
+ # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
55
+ def initialize(cache_type:, url: nil, lock_timeout: DEFAULT_LOCK_TIMEOUT, **)
56
+ super(**)
57
+ @url = url || ENV.fetch("REDIS_URL", nil)
58
+ @redis = ::Redis.new(url: @url)
59
+ @namespace = "factorix-cache:#{cache_type}"
60
+ @lock_timeout = lock_timeout
61
+ logger.info("Initializing Redis cache", namespace: @namespace, ttl: @ttl, lock_timeout: @lock_timeout)
62
+ end
63
+
64
+ # Check if a cache entry exists.
65
+ #
66
+ # @param key [String] logical cache key
67
+ # @return [Boolean] true if the cache entry exists
68
+ def exist?(key) = @redis.exists?(data_key(key))
69
+
70
+ # Read a cached entry.
71
+ #
72
+ # @param key [String] logical cache key
73
+ # @return [String, nil] cached content or nil if not found
74
+ def read(key)
75
+ @redis.get(data_key(key))
76
+ end
77
+
78
+ # Write cached content to a file.
79
+ #
80
+ # @param key [String] logical cache key
81
+ # @param output [Pathname] path to write the cached content
82
+ # @return [Boolean] true if written successfully, false if not found
83
+ def write_to(key, output)
84
+ data = @redis.get(data_key(key))
85
+ return false if data.nil?
86
+
87
+ output.binwrite(data)
88
+ logger.debug("Cache hit", key:)
89
+ true
90
+ end
91
+
92
+ # Store data in the cache.
93
+ #
94
+ # @param key [String] logical cache key
95
+ # @param src [Pathname] path to the source file
96
+ # @return [Boolean] true if stored successfully
97
+ def store(key, src)
98
+ data = src.binread
99
+ data_k = data_key(key)
100
+ meta_k = meta_key(key)
101
+
102
+ @redis.multi do |tx|
103
+ tx.set(data_k, data)
104
+ tx.hset(meta_k, "size", data.bytesize, "created_at", Time.now.to_i)
105
+
106
+ if @ttl
107
+ tx.expire(data_k, @ttl)
108
+ tx.expire(meta_k, @ttl)
109
+ end
110
+ end
111
+
112
+ logger.debug("Stored in cache", key:, size_bytes: data.bytesize)
113
+ true
114
+ end
115
+
116
+ # Delete a cache entry.
117
+ #
118
+ # @param key [String] logical cache key
119
+ # @return [Boolean] true if deleted, false if not found
120
+ def delete(key)
121
+ deleted = @redis.del(data_key(key), meta_key(key))
122
+ logger.debug("Deleted from cache", key:) if deleted.positive?
123
+ deleted.positive?
124
+ end
125
+
126
+ # Clear all cache entries in this namespace.
127
+ #
128
+ # @return [void]
129
+ def clear
130
+ logger.info("Clearing Redis cache namespace", namespace: @namespace)
131
+ count = 0
132
+ cursor = "0"
133
+ pattern = "#{@namespace}:*"
134
+
135
+ loop do
136
+ cursor, keys = @redis.scan(cursor, match: pattern, count: 100)
137
+ unless keys.empty?
138
+ @redis.del(*keys)
139
+ count += keys.size
140
+ end
141
+ break if cursor == "0"
142
+ end
143
+
144
+ logger.info("Cache cleared", keys_removed: count)
145
+ end
146
+
147
+ # Get the age of a cache entry in seconds.
148
+ #
149
+ # @param key [String] logical cache key
150
+ # @return [Integer, nil] age in seconds, or nil if entry doesn't exist
151
+ def age(key)
152
+ value = @redis.hget(meta_key(key), "created_at")
153
+ return nil if value.nil?
154
+
155
+ created_at = Integer(value, 10)
156
+ return nil if created_at.zero?
157
+
158
+ Time.now.to_i - created_at
159
+ end
160
+
161
+ # Check if a cache entry has expired.
162
+ # With Redis native EXPIRE, non-existent keys are considered expired.
163
+ #
164
+ # @param key [String] logical cache key
165
+ # @return [Boolean] true if expired (or doesn't exist), false otherwise
166
+ def expired?(key) = !exist?(key)
167
+
168
+ # Get the size of a cached entry in bytes.
169
+ #
170
+ # @param key [String] logical cache key
171
+ # @return [Integer, nil] size in bytes, or nil if entry doesn't exist
172
+ def size(key)
173
+ return nil unless exist?(key)
174
+
175
+ value = @redis.hget(meta_key(key), "size")
176
+ value.nil? ? nil : Integer(value, 10)
177
+ end
178
+
179
+ # Execute a block with a distributed lock.
180
+ # Uses Redis SET NX EX for lock acquisition and Lua script for atomic release.
181
+ #
182
+ # @param key [String] logical cache key
183
+ # @yield block to execute with lock held
184
+ # @raise [LockTimeoutError] if lock cannot be acquired within timeout
185
+ def with_lock(key)
186
+ lkey = lock_key(key)
187
+ lock_value = SecureRandom.uuid
188
+ deadline = Time.now + @lock_timeout
189
+
190
+ until @redis.set(lkey, lock_value, nx: true, ex: LOCK_TTL)
191
+ raise LockTimeoutError, "Failed to acquire lock for key: #{key}" if Time.now > deadline
192
+
193
+ sleep 0.1
194
+ end
195
+
196
+ logger.debug("Acquired lock", key:)
197
+ begin
198
+ yield
199
+ ensure
200
+ @redis.eval(RELEASE_LOCK_SCRIPT, keys: [lkey], argv: [lock_value])
201
+ logger.debug("Released lock", key:)
202
+ end
203
+ end
204
+
205
+ # Enumerate cache entries.
206
+ #
207
+ # @yield [key, entry] logical key and Entry object
208
+ # @yieldparam key [String] logical cache key
209
+ # @yieldparam entry [Entry] cache entry metadata
210
+ # @return [Enumerator] if no block given
211
+ def each
212
+ return enum_for(__method__) unless block_given?
213
+
214
+ cursor = "0"
215
+ pattern = "#{@namespace}:*"
216
+
217
+ loop do
218
+ cursor, keys = @redis.scan(cursor, match: pattern, count: 100)
219
+
220
+ keys.each do |data_k|
221
+ next if data_k.include?(":meta:") || data_k.include?(":lock:")
222
+
223
+ logical_key = logical_key_from_data_key(data_k)
224
+ meta = @redis.hgetall(meta_key(logical_key))
225
+
226
+ entry = Entry.new(
227
+ size: meta["size"] ? Integer(meta["size"], 10) : 0,
228
+ age: meta["created_at"] ? Time.now.to_i - Integer(meta["created_at"], 10) : 0,
229
+ expired: false # Redis handles expiry natively
230
+ )
231
+
232
+ yield logical_key, entry
233
+ end
234
+
235
+ break if cursor == "0"
236
+ end
237
+ end
238
+
239
+ # Return backend-specific information.
240
+ #
241
+ # @return [Hash] backend configuration
242
+ def backend_info
243
+ {
244
+ type: "redis",
245
+ url: mask_url(@url),
246
+ namespace: @namespace,
247
+ lock_timeout: @lock_timeout
248
+ }
249
+ end
250
+
251
+ # Generate data key for the given logical key.
252
+ #
253
+ # @param logical_key [String] logical key
254
+ # @return [String] namespaced data key
255
+ private def data_key(logical_key) = "#{@namespace}:#{logical_key}"
256
+
257
+ # Generate metadata key for the given logical key.
258
+ #
259
+ # @param logical_key [String] logical key
260
+ # @return [String] namespaced metadata key
261
+ private def meta_key(logical_key) = "#{@namespace}:meta:#{logical_key}"
262
+
263
+ # Generate lock key for the given logical key.
264
+ #
265
+ # @param logical_key [String] logical key
266
+ # @return [String] namespaced lock key
267
+ private def lock_key(logical_key) = "#{@namespace}:lock:#{logical_key}"
268
+
269
+ # Extract logical key from data key.
270
+ #
271
+ # @param data_k [String] namespaced data key
272
+ # @return [String] logical key
273
+ private def logical_key_from_data_key(data_k) = data_k.delete_prefix("#{@namespace}:")
274
+
275
+ DEFAULT_URL = "redis://localhost:6379/0"
276
+ private_constant :DEFAULT_URL
277
+
278
+ # Mask credentials in Redis URL for safe display.
279
+ #
280
+ # @param url [String, nil] Redis URL
281
+ # @return [String] URL with credentials masked (defaults to redis://localhost:6379/0)
282
+ private def mask_url(url)
283
+ URI.parse(url || DEFAULT_URL).tap {|uri| uri.userinfo = "***:***" if uri.userinfo }.to_s
284
+ end
285
+ end
286
+ end
287
+ end