Class: Tina4::ResponseCache

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/response_cache.rb

Overview

Multi-backend response cache for GET requests.

Backends are selected via the TINA4_CACHE_BACKEND env var:

memory — in-process LRU cache (default, zero deps)
redis  — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
file   — JSON files in data/cache/

Environment:

TINA4_CACHE_BACKEND      — memory | redis | file  (default: memory)
TINA4_CACHE_URL           — redis://localhost:6379  (redis only)
TINA4_CACHE_TTL           — default TTL in seconds  (default: 0 = disabled)
TINA4_CACHE_MAX_ENTRIES   — maximum cache entries   (default: 1000)

Usage:

cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
hit = cache.get("GET", "/api/users")

# Direct API (same across all 4 languages)
cache.cache_set("key", {"data" => "value"}, ttl: 120)
value = cache.cache_get("key")
cache.cache_delete("key")

Defined Under Namespace

Classes: CacheEntry

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(ttl: nil, max_entries: nil, status_codes: [200], backend: nil, cache_url: nil, cache_dir: nil) ⇒ ResponseCache

Returns a new instance of ResponseCache.

Parameters:

  • ttl (Integer) (defaults to: nil)

    default TTL in seconds (0 = disabled)

  • max_entries (Integer) (defaults to: nil)

    maximum cache entries

  • status_codes (Array<Integer>) (defaults to: [200])

    only cache these status codes

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

    cache backend: memory|redis|file

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

    Redis URL

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

    File cache directory



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/tina4/response_cache.rb', line 36

def initialize(ttl: nil, max_entries: nil, status_codes: [200],
               backend: nil, cache_url: nil, cache_dir: nil)
  @ttl = ttl || (ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 0)
  @max_entries = max_entries || (ENV["TINA4_CACHE_MAX_ENTRIES"] ? ENV["TINA4_CACHE_MAX_ENTRIES"].to_i : 1000)
  @status_codes = status_codes
  @backend_name = backend || ENV.fetch("TINA4_CACHE_BACKEND", "memory").downcase.strip
  @cache_url = cache_url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379")
  @cache_dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
  @store = {}
  @mutex = Mutex.new
  @hits = 0
  @misses = 0

  # Initialize backend
  init_backend
end

Instance Attribute Details

#max_entriesObject (readonly)

Maximum entries setting.



293
294
295
# File 'lib/tina4/response_cache.rb', line 293

def max_entries
  @max_entries
end

#ttlObject (readonly)

Current TTL setting.



290
291
292
# File 'lib/tina4/response_cache.rb', line 290

def ttl
  @ttl
end

Instance Method Details

#backend_nameObject

Active backend name.



296
297
298
# File 'lib/tina4/response_cache.rb', line 296

def backend_name
  @backend_name
end

#cache_delete(key) ⇒ Boolean

Delete a key from the cache.

Parameters:

  • key (String)

Returns:

  • (Boolean)


200
201
202
# File 'lib/tina4/response_cache.rb', line 200

def cache_delete(key)
  backend_delete("direct:#{key}")
end

#cache_get(key) ⇒ Object?

Get a value from the cache by key.

Parameters:

  • key (String)

Returns:

  • (Object, nil)


156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/tina4/response_cache.rb', line 156

def cache_get(key)
  full_key = "direct:#{key}"
  raw = backend_get(full_key)

  if raw.nil?
    @mutex.synchronize { @misses += 1 }
    return nil
  end

  if raw.is_a?(Hash)
    expires_at = raw["expires_at"] || raw[:expires_at] || 0
    if expires_at > 0 && Time.now.to_f > expires_at
      backend_delete(full_key)
      @mutex.synchronize { @misses += 1 }
      return nil
    end
    @mutex.synchronize { @hits += 1 }
    raw["value"] || raw[:value]
  else
    @mutex.synchronize { @hits += 1 }
    raw
  end
end

#cache_key(method, url) ⇒ String

Build a cache key from method and URL.

Parameters:

  • method (String)
  • url (String)

Returns:

  • (String)


65
66
67
# File 'lib/tina4/response_cache.rb', line 65

def cache_key(method, url)
  "#{method}:#{url}"
end

#cache_response(method, url, status_code, content_type, body, ttl: nil) ⇒ Object

Store a response in the cache.

Parameters:

  • method (String)
  • url (String)
  • status_code (Integer)
  • content_type (String)
  • body (String)
  • ttl (Integer, nil) (defaults to: nil)

    override default TTL



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/tina4/response_cache.rb', line 120

def cache_response(method, url, status_code, content_type, body, ttl: nil)
  return unless enabled?
  return unless method == "GET"
  return unless @status_codes.include?(status_code)

  effective_ttl = ttl || @ttl
  key = cache_key(method, url)
  expires_at = Time.now.to_f + effective_ttl

  entry_data = {
    "body" => body,
    "content_type" => content_type,
    "status_code" => status_code,
    "expires_at" => expires_at
  }

  case @backend_name
  when "memory"
    @mutex.synchronize do
      if @store.size >= @max_entries && !@store.key?(key)
        oldest_key = @store.keys.first
        @store.delete(oldest_key)
      end
      @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
    end
  else
    backend_set(key, entry_data, effective_ttl)
  end
end

#cache_set(key, value, ttl: 0) ⇒ Object

Store a value in the cache with optional TTL.

Parameters:

  • key (String)
  • value (Object)
  • ttl (Integer) (defaults to: 0)

    TTL in seconds (0 uses default)



185
186
187
188
189
190
191
192
193
194
# File 'lib/tina4/response_cache.rb', line 185

def cache_set(key, value, ttl: 0)
  effective_ttl = ttl > 0 ? ttl : @ttl
  effective_ttl = 60 if effective_ttl <= 0 # fallback for direct API
  full_key = "direct:#{key}"
  entry = {
    "value" => value,
    "expires_at" => Time.now.to_f + effective_ttl
  }
  backend_set(full_key, entry, effective_ttl)
end

#cache_statsHash

Get cache statistics.

Returns:

  • (Hash)

    with :hits, :misses, :size, :backend, :keys



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/tina4/response_cache.rb', line 207

def cache_stats
  @mutex.synchronize do
    case @backend_name
    when "memory"
      now = Time.now.to_f
      @store.reject! { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }
      { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
    when "file"
      sweep
      files = Dir.glob(File.join(@cache_dir, "*.json"))
      { hits: @hits, misses: @misses, size: files.size, backend: @backend_name, keys: [] }
    when "redis"
      size = 0
      if @redis_client
        begin
          keys = @redis_client.keys("tina4:cache:*")
          size = keys.size
        rescue StandardError
        end
      end
      { hits: @hits, misses: @misses, size: size, backend: @backend_name, keys: [] }
    else
      { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
    end
  end
end

#clear_cacheObject

Clear all cached responses.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/tina4/response_cache.rb', line 235

def clear_cache
  @mutex.synchronize do
    @hits = 0
    @misses = 0

    case @backend_name
    when "memory"
      @store.clear
    when "file"
      Dir.glob(File.join(@cache_dir, "*.json")).each { |f| File.delete(f) rescue nil }
    when "redis"
      if @redis_client
        begin
          keys = @redis_client.keys("tina4:cache:*")
          @redis_client.del(*keys) unless keys.empty?
        rescue StandardError
        end
      end
    end
  end
end

#enabled?Boolean

Check if caching is enabled.

Returns:

  • (Boolean)


56
57
58
# File 'lib/tina4/response_cache.rb', line 56

def enabled?
  @ttl > 0
end

#get(method, url) ⇒ CacheEntry?

Retrieve a cached response. Returns nil on miss or expired entry.

Parameters:

  • method (String)
  • url (String)

Returns:



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/tina4/response_cache.rb', line 74

def get(method, url)
  return nil unless enabled?
  return nil unless method == "GET"

  key = cache_key(method, url)
  entry = backend_get(key)

  if entry.nil?
    @mutex.synchronize { @misses += 1 }
    return nil
  end

  # For memory backend, entry is a CacheEntry; for others, reconstruct
  if entry.is_a?(CacheEntry)
    if Time.now.to_f > entry.expires_at
      backend_delete(key)
      @mutex.synchronize { @misses += 1 }
      return nil
    end
    @mutex.synchronize { @hits += 1 }
    entry
  elsif entry.is_a?(Hash)
    expires_at = entry["expires_at"] || entry[:expires_at] || 0
    if Time.now.to_f > expires_at
      backend_delete(key)
      @mutex.synchronize { @misses += 1 }
      return nil
    end
    @mutex.synchronize { @hits += 1 }
    CacheEntry.new(
      entry["body"] || entry[:body],
      entry["content_type"] || entry[:content_type],
      entry["status_code"] || entry[:status_code],
      expires_at
    )
  end
end

#sweepInteger

Remove expired entries.

Returns:

  • (Integer)

    number of entries removed



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/tina4/response_cache.rb', line 260

def sweep
  case @backend_name
  when "memory"
    @mutex.synchronize do
      now = Time.now.to_f
      keys_to_remove = @store.select { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }.keys
      keys_to_remove += @store.select { |_k, v| v.is_a?(Hash) && (v["expires_at"] || 0) > 0 && now > (v["expires_at"] || 0) }.keys
      keys_to_remove.each { |k| @store.delete(k) }
      keys_to_remove.size
    end
  when "file"
    removed = 0
    now = Time.now.to_f
    Dir.glob(File.join(@cache_dir, "*.json")).each do |f|
      begin
        data = JSON.parse(File.read(f))
        if data["expires_at"] && now > data["expires_at"]
          File.delete(f)
          removed += 1
        end
      rescue StandardError
      end
    end
    removed
  else
    0
  end
end