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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +1 -1
- data/exe/factorix +17 -0
- data/lib/factorix/api/mod_download_api.rb +11 -6
- data/lib/factorix/api/mod_info.rb +2 -2
- data/lib/factorix/api/mod_management_api.rb +1 -1
- data/lib/factorix/api/mod_portal_api.rb +6 -49
- data/lib/factorix/api_credential.rb +1 -1
- data/lib/factorix/cache/base.rb +116 -0
- data/lib/factorix/cache/entry.rb +25 -0
- data/lib/factorix/cache/file_system.rb +137 -57
- data/lib/factorix/cache/redis.rb +287 -0
- data/lib/factorix/cache/s3.rb +388 -0
- data/lib/factorix/cli/commands/backup_support.rb +1 -1
- data/lib/factorix/cli/commands/base.rb +3 -3
- data/lib/factorix/cli/commands/cache/evict.rb +19 -24
- data/lib/factorix/cli/commands/cache/stat.rb +66 -67
- data/lib/factorix/cli/commands/command_wrapper.rb +5 -5
- data/lib/factorix/cli/commands/completion.rb +1 -2
- data/lib/factorix/cli/commands/confirmable.rb +1 -1
- data/lib/factorix/cli/commands/download_support.rb +2 -7
- data/lib/factorix/cli/commands/mod/check.rb +1 -1
- data/lib/factorix/cli/commands/mod/disable.rb +1 -1
- data/lib/factorix/cli/commands/mod/download.rb +7 -7
- data/lib/factorix/cli/commands/mod/edit.rb +10 -13
- data/lib/factorix/cli/commands/mod/enable.rb +1 -1
- data/lib/factorix/cli/commands/mod/image/add.rb +3 -6
- data/lib/factorix/cli/commands/mod/image/edit.rb +2 -5
- data/lib/factorix/cli/commands/mod/image/list.rb +5 -8
- data/lib/factorix/cli/commands/mod/install.rb +7 -7
- data/lib/factorix/cli/commands/mod/list.rb +7 -7
- data/lib/factorix/cli/commands/mod/search.rb +13 -12
- data/lib/factorix/cli/commands/mod/settings/dump.rb +3 -3
- data/lib/factorix/cli/commands/mod/settings/restore.rb +2 -2
- data/lib/factorix/cli/commands/mod/show.rb +22 -23
- data/lib/factorix/cli/commands/mod/sync.rb +8 -8
- data/lib/factorix/cli/commands/mod/uninstall.rb +1 -1
- data/lib/factorix/cli/commands/mod/update.rb +11 -43
- data/lib/factorix/cli/commands/mod/upload.rb +7 -10
- data/lib/factorix/cli/commands/path.rb +2 -2
- data/lib/factorix/cli/commands/portal_support.rb +27 -0
- data/lib/factorix/cli/commands/version.rb +1 -1
- data/lib/factorix/container.rb +155 -0
- data/lib/factorix/dependency/parser.rb +1 -1
- data/lib/factorix/errors.rb +3 -0
- data/lib/factorix/http/cache_decorator.rb +5 -5
- data/lib/factorix/http/client.rb +3 -3
- data/lib/factorix/info_json.rb +7 -7
- data/lib/factorix/mod_list.rb +2 -2
- data/lib/factorix/mod_settings.rb +2 -2
- data/lib/factorix/portal.rb +3 -2
- data/lib/factorix/runtime/user_configurable.rb +9 -9
- data/lib/factorix/service_credential.rb +3 -3
- data/lib/factorix/transfer/downloader.rb +19 -11
- data/lib/factorix/version.rb +1 -1
- data/lib/factorix.rb +110 -1
- data/sig/factorix/api/mod_download_api.rbs +1 -2
- data/sig/factorix/cache/base.rbs +28 -0
- data/sig/factorix/cache/entry.rbs +14 -0
- data/sig/factorix/cache/file_system.rbs +7 -6
- data/sig/factorix/cache/redis.rbs +36 -0
- data/sig/factorix/cache/s3.rbs +38 -0
- data/sig/factorix/container.rbs +15 -0
- data/sig/factorix/errors.rbs +3 -0
- data/sig/factorix/portal.rbs +1 -1
- data/sig/factorix.rbs +99 -0
- metadata +27 -4
- data/lib/factorix/application.rb +0 -218
- 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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
74
|
-
# @param output [Pathname] path to
|
|
75
|
-
# @return [Boolean] true if
|
|
76
|
-
def
|
|
77
|
-
|
|
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
|
|
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
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
171
|
+
logger.info("Clearing cache directory", root: @cache_dir.to_s)
|
|
164
172
|
count = 0
|
|
165
173
|
@cache_dir.glob("**/*").each do |path|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
310
|
+
# @param internal_key [String] internal storage key
|
|
247
311
|
# @return [Pathname] path to the cache file
|
|
248
|
-
private def cache_path_for(
|
|
249
|
-
prefix =
|
|
250
|
-
@cache_dir.join(prefix,
|
|
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
|
|
328
|
+
# @param internal_key [String] internal storage key
|
|
257
329
|
# @return [Pathname] path to the lock file
|
|
258
|
-
private def lock_path_for(
|
|
259
|
-
cache_path_for(
|
|
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
|