Class: Tina4::ResponseCache

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

Overview

Multi-backend response cache for GET requests.

Public surface (parity with Python tina4_python.cache):

- Tina4::ResponseCache — middleware class
- Tina4.cache_stats     — module function returning cache stats
- Tina4.clear_cache     — module function flushing all cached entries
- Tina4.cache_get / cache_set / cache_delete — module-level KV API

The internal lookup/store of GET responses is performed by the middleware hooks (before_cache, after_cache) and is NOT exposed publicly. Use the middleware by attaching ResponseCache to your route, not by calling the (private) internal_lookup / internal_store directly.

Backends are selected via the TINA4_CACHE_BACKEND env var and built by the unified factory (Tina4::CacheBackends.create_backend):

memory    — in-process LRU cache (default, zero deps)
file      — JSON files in data/cache/
redis     — Redis (redis gem or raw RESP over TCP)
valkey    — Valkey (Redis wire protocol; reports "valkey")
memcached — Memcached (zero-dep text protocol over TCP)
mongodb   — MongoDB TTL collection (requires the mongo gem)
database  — tina4_cache table in any Tina4-supported database

A configured network/driver backend that is unreachable degrades to the file backend (a real working cache), never a silent no-op.

Environment:

TINA4_CACHE_BACKEND      — memory|file|redis|valkey|memcached|mongodb|database
TINA4_CACHE_URL           — connection URL (redis/valkey/memcached/mongo) OR
                          SQL URL for database (falls back to TINA4_DATABASE_URL)
TINA4_CACHE_TTL           — default TTL in seconds  (default: 60)
TINA4_CACHE_MAX_ENTRIES   — maximum cache entries   (default: 1000)
TINA4_CACHE_DIR           — file backend directory  (default: data/cache)
TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD — credentials when not in the URL

Defined Under Namespace

Classes: CacheEntry

Instance Attribute Summary collapse

Class Method 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



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/tina4/response_cache.rb', line 51

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 : 60)
  @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.



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

def max_entries
  @max_entries
end

#ttlObject (readonly)

Current TTL setting.



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

def ttl
  @ttl
end

Class Method Details

.after_response_cache(request, response) ⇒ Array

Returns the [request, response] pair.

Parameters:

  • request (Object)
  • response (Object)

Returns:

  • (Array)

    the [request, response] pair



180
181
182
# File 'lib/tina4/response_cache.rb', line 180

def after_response_cache(request, response)
  Tina4.cache_instance.after_cache(request, response)
end

.before_response_cache(request, response) ⇒ Tina4::Response, Array

Middleware.discover_methods walks klass.singleton_class and calls klass.send(name, ...) -- it finds CLASS methods only. before_cache and after_cache are INSTANCE methods, so attaching the class discovered no hooks at all and the cache was a SILENT no-op: no warning, no header, no caching. These two delegate to the module-level singleton (the same instance Tina4.cache_get uses), which reads TTL and backend from ENV.

Parameters:

  • request (Object)
  • response (Object)

Returns:

  • (Tina4::Response, Array)

    the Response on a HIT (short-circuit), otherwise the [request, response] pair



173
174
175
# File 'lib/tina4/response_cache.rb', line 173

def before_response_cache(request, response)
  Tina4.cache_instance.before_cache(request, response)
end

Instance Method Details

#_internal_lookup(method, url) ⇒ Object

Public for parity tests only; do not use in application code.



306
307
308
# File 'lib/tina4/response_cache.rb', line 306

def _internal_lookup(method, url)
  internal_lookup(method, url)
end

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

Public for parity tests only; do not use in application code.



312
313
314
# File 'lib/tina4/response_cache.rb', line 312

def _internal_store(method, url, status_code, content_type, body, ttl: nil)
  internal_store(method, url, status_code, content_type, body, ttl: ttl)
end

#after_cache(request, response) ⇒ Object

Middleware hook — captures the response body and stores it after the route handler runs.



114
115
116
117
118
119
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
149
150
151
152
153
154
155
156
157
# File 'lib/tina4/response_cache.rb', line 114

def after_cache(request, response)
  return [request, response] unless enabled?

  # Read the tags using the SAME mechanism before_cache wrote them with.
  # before_cache keys the write on respond_to?(:[]=), so read the same way:
  # a Tina4::Request responds to #[] (read-only param lookup) but NOT #[]=,
  # so the tags live on instance variables, not the param hash.
  if request.respond_to?(:[]=)
    method = request[:_cache_method]
    url = request[:_cache_url]
  else
    method = request.instance_variable_get(:@_cache_method)
    url = request.instance_variable_get(:@_cache_url)
  end
  return [request, response] if method.nil? || url.nil?

  status = if response.respond_to?(:status_code)
             response.status_code
           elsif response.respond_to?(:status)
             response.status
           else
             200
           end
  content_type = if response.respond_to?(:content_type)
                   response.content_type
                 else
                   "application/json"
                 end
  body = if response.respond_to?(:body)
           response.body.to_s
         else
           response.to_s
         end

  # This response came OUT of the cache; re-storing it would also overwrite
  # its X-Cache: HIT header with MISS.
  return [request, response] if header_value(response, "X-Cache") == "HIT"

  internal_store(method, url, status.to_i, content_type.to_s, body, request: request, response: response)
  # The handler ran (cache miss) — annotate the response so clients can
  # see this was a fresh response and how long it will be cached.
  set_cache_headers(response, "MISS", @ttl)
  [request, response]
end

#backend_nameObject

Active backend name.



300
301
302
# File 'lib/tina4/response_cache.rb', line 300

def backend_name
  @backend_name
end

#before_cache(request, response) ⇒ Object

Middleware hook — checks for a cached entry before the route handler runs. If a cached entry exists for this GET request, short-circuits by replacing the response. Otherwise tags the request so after_cache can capture the response.



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 81

def before_cache(request, response)
  return [request, response] unless enabled?

  method = (request.respond_to?(:method) ? request.method : "GET").to_s.upcase
  return [request, response] unless method == "GET"

  url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
  hit = internal_lookup(method, url, request)
  if hit
    if response.respond_to?(:call)
      new_response = response.call(hit.body, hit.status_code, hit.content_type)
      set_cache_headers(new_response, "HIT", remaining_ttl(hit.expires_at))
      # Returning the Response OBJECT is what short-circuits. Returning the
      # [request, response] pair only REBINDS and continues, so the handler
      # ran anyway and the cache never saved a single call -- while still
      # stamping X-Cache: HIT, which made the header a lie.
      return new_response
    end
  end

  # Tag for after_cache
  if request.respond_to?(:[]=)
    request[:_cache_method] = method
    request[:_cache_url] = url
  else
    request.instance_variable_set(:@_cache_method, method)
    request.instance_variable_set(:@_cache_url, url)
  end
  [request, response]
end

#cache_delete(key) ⇒ Boolean

Delete a key from the cache.

Parameters:

  • key (String)

Returns:

  • (Boolean)


235
236
237
# File 'lib/tina4/response_cache.rb', line 235

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)


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/tina4/response_cache.rb', line 191

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_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)



220
221
222
223
224
225
226
227
228
229
# File 'lib/tina4/response_cache.rb', line 220

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



242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/tina4/response_cache.rb', line 242

def cache_stats
  if memory_backend?
    @mutex.synchronize do
      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 }
    end
  else
    st = @backend.stats
    { hits: @hits, misses: @misses, size: st[:size], backend: @backend_name, keys: [] }
  end
end

#clear_cacheObject

Clear all cached responses.



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/tina4/response_cache.rb', line 256

def clear_cache
  if memory_backend?
    @mutex.synchronize do
      @hits = 0
      @misses = 0
      @store.clear
    end
  else
    @mutex.synchronize do
      @hits = 0
      @misses = 0
    end
    @backend.clear
  end
end

#enabled?Boolean

Check if caching is enabled.

Returns:

  • (Boolean)


71
72
73
# File 'lib/tina4/response_cache.rb', line 71

def enabled?
  @ttl > 0
end

#sweepInteger

Remove expired entries.

Returns:

  • (Integer)

    number of entries removed



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/tina4/response_cache.rb', line 275

def sweep
  if memory_backend?
    @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
  else
    # Every backend answers sweep now - the base class returns 0 for the
    # providers that expire server-side, and memory/file/database return a
    # real count. The respond_to?(:sweep) guard that used to be here could
    # not tell "not supported" from "evicted nothing".
    @backend.sweep
  end
end