Class: Factorix::Cache::Redis

Inherits:
Base
  • Object
show all
Defined in:
lib/factorix/cache/redis.rb

Overview

Redis-based cache storage implementation.

Stores cache entries in Redis with automatic namespace prefixing. Metadata (size, created_at) stored in separate hash keys. Supports distributed locking with Lua script for atomic release.

Examples:

Configuration

Factorix.configure do |config|
  config.cache.api.backend = :redis
  config.cache.api.redis.url = "redis://localhost:6379/0"
  config.cache.api.redis.lock_timeout = 30
end

Constant Summary collapse

DEFAULT_LOCK_TIMEOUT =

Default timeout for distributed lock acquisition in seconds.

30

Instance Attribute Summary

Attributes inherited from Base

#ttl

Instance Method Summary collapse

Constructor Details

#initialize(cache_type:, url: nil, lock_timeout: DEFAULT_LOCK_TIMEOUT) ⇒ Redis

Initialize a new Redis cache storage.

Parameters:

  • url (String, nil) (defaults to: nil)

    Redis URL (defaults to REDIS_URL env)

  • cache_type (String, Symbol)

    Cache type for namespace (e.g., :api, :download)

  • lock_timeout (Integer) (defaults to: DEFAULT_LOCK_TIMEOUT)

    Timeout for lock acquisition in seconds

  • ttl (Integer, nil)

    time-to-live in seconds (nil for unlimited)



55
56
57
58
59
60
61
62
# File 'lib/factorix/cache/redis.rb', line 55

def initialize(cache_type:, url: nil, lock_timeout: DEFAULT_LOCK_TIMEOUT, **)
  super(**)
  @url = url || ENV.fetch("REDIS_URL", nil)
  @redis = ::Redis.new(url: @url)
  @namespace = "factorix-cache:#{cache_type}"
  @lock_timeout = lock_timeout
  logger.info("Initializing Redis cache", namespace: @namespace, ttl: @ttl, lock_timeout: @lock_timeout)
end

Instance Method Details

#age(key) ⇒ Integer?

Get the age of a cache entry in seconds.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (Integer, nil)

    age in seconds, or nil if entry doesn’t exist



151
152
153
154
155
156
157
158
159
# File 'lib/factorix/cache/redis.rb', line 151

def age(key)
  value = @redis.hget(meta_key(key), "created_at")
  return nil if value.nil?

  created_at = Integer(value, 10)
  return nil if created_at.zero?

  Time.now.to_i - created_at
end

#backend_infoHash

Return backend-specific information.

Returns:

  • (Hash)

    backend configuration



242
243
244
245
246
247
248
249
# File 'lib/factorix/cache/redis.rb', line 242

def backend_info
  {
    type: "redis",
    url: mask_url(@url),
    namespace: @namespace,
    lock_timeout: @lock_timeout
  }
end

#clearvoid

This method returns an undefined value.

Clear all cache entries in this namespace.



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/factorix/cache/redis.rb', line 129

def clear
  logger.info("Clearing Redis cache namespace", namespace: @namespace)
  count = 0
  cursor = "0"
  pattern = "#{@namespace}:*"

  loop do
    cursor, keys = @redis.scan(cursor, match: pattern, count: 100)
    unless keys.empty?
      @redis.del(*keys)
      count += keys.size
    end
    break if cursor == "0"
  end

  logger.info("Cache cleared", keys_removed: count)
end

#delete(key) ⇒ Boolean

Delete a cache entry.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (Boolean)

    true if deleted, false if not found



120
121
122
123
124
# File 'lib/factorix/cache/redis.rb', line 120

def delete(key)
  deleted = @redis.del(data_key(key), meta_key(key))
  logger.debug("Deleted from cache", key:) if deleted.positive?
  deleted.positive?
end

#each {|key, entry| ... } ⇒ Enumerator

Enumerate cache entries.

Yields:

  • (key, entry)

    logical key and Entry object

Yield Parameters:

  • key (String)

    logical cache key

  • entry (Entry)

    cache entry metadata

Returns:

  • (Enumerator)

    if no block given



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/factorix/cache/redis.rb', line 211

def each
  return enum_for(__method__) unless block_given?

  cursor = "0"
  pattern = "#{@namespace}:*"

  loop do
    cursor, keys = @redis.scan(cursor, match: pattern, count: 100)

    keys.each do |data_k|
      next if data_k.include?(":meta:") || data_k.include?(":lock:")

      logical_key = logical_key_from_data_key(data_k)
      meta = @redis.hgetall(meta_key(logical_key))

      entry = Entry[
        size: meta["size"] ? Integer(meta["size"], 10) : 0,
        age: meta["created_at"] ? Time.now.to_i - Integer(meta["created_at"], 10) : 0,
        expired: false # Redis handles expiry natively
      ]

      yield logical_key, entry
    end

    break if cursor == "0"
  end
end

#exist?(key) ⇒ Boolean

Check if a cache entry exists.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (Boolean)

    true if the cache entry exists



68
# File 'lib/factorix/cache/redis.rb', line 68

def exist?(key) = @redis.exists?(data_key(key))

#expired?(key) ⇒ Boolean

Check if a cache entry has expired. With Redis native EXPIRE, non-existent keys are considered expired.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (Boolean)

    true if expired (or doesn’t exist), false otherwise



166
# File 'lib/factorix/cache/redis.rb', line 166

def expired?(key) = !exist?(key)

#read(key) ⇒ String?

Read a cached entry.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (String, nil)

    cached content or nil if not found



74
75
76
# File 'lib/factorix/cache/redis.rb', line 74

def read(key)
  @redis.get(data_key(key))
end

#size(key) ⇒ Integer?

Get the size of a cached entry in bytes.

Parameters:

  • key (String)

    logical cache key

Returns:

  • (Integer, nil)

    size in bytes, or nil if entry doesn’t exist



172
173
174
175
176
177
# File 'lib/factorix/cache/redis.rb', line 172

def size(key)
  return nil unless exist?(key)

  value = @redis.hget(meta_key(key), "size")
  value.nil? ? nil : Integer(value, 10)
end

#store(key, src) ⇒ Boolean

Store data in the cache.

Parameters:

  • key (String)

    logical cache key

  • src (Pathname)

    path to the source file

Returns:

  • (Boolean)

    true if stored successfully



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/factorix/cache/redis.rb', line 97

def store(key, src)
  data = src.binread
  data_k = data_key(key)
  meta_k = meta_key(key)

  @redis.multi do |tx|
    tx.set(data_k, data)
    tx.hset(meta_k, "size", data.bytesize, "created_at", Time.now.to_i)

    if @ttl
      tx.expire(data_k, @ttl)
      tx.expire(meta_k, @ttl)
    end
  end

  logger.debug("Stored in cache", key:, size_bytes: data.bytesize)
  true
end

#with_lock(key) { ... } ⇒ Object

Execute a block with a distributed lock. Uses Redis SET NX EX for lock acquisition and Lua script for atomic release.

Parameters:

  • key (String)

    logical cache key

Yields:

  • block to execute with lock held

Raises:



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/factorix/cache/redis.rb', line 185

def with_lock(key)
  lkey = lock_key(key)
  lock_value = SecureRandom.uuid
  deadline = Time.now + @lock_timeout

  until @redis.set(lkey, lock_value, nx: true, ex: LOCK_TTL)
    raise LockTimeoutError, "Failed to acquire lock for key: #{key}" if Time.now > deadline

    sleep 0.1
  end

  logger.debug("Acquired lock", key:)
  begin
    yield
  ensure
    @redis.eval(RELEASE_LOCK_SCRIPT, keys: [lkey], argv: [lock_value])
    logger.debug("Released lock", key:)
  end
end

#write_to(key, output) ⇒ Boolean

Write cached content to a file.

Parameters:

  • key (String)

    logical cache key

  • output (Pathname)

    path to write the cached content

Returns:

  • (Boolean)

    true if written successfully, false if not found



83
84
85
86
87
88
89
90
# File 'lib/factorix/cache/redis.rb', line 83

def write_to(key, output)
  data = @redis.get(data_key(key))
  return false if data.nil?

  output.binwrite(data)
  logger.debug("Cache hit", key:)
  true
end