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

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



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

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.



261
262
263
# File 'lib/tina4/response_cache.rb', line 261

def max_entries
  @max_entries
end

#ttlObject (readonly)

Current TTL setting.



258
259
260
# File 'lib/tina4/response_cache.rb', line 258

def ttl
  @ttl
end

Instance Method Details

#_internal_lookup(method, url) ⇒ Object

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



270
271
272
# File 'lib/tina4/response_cache.rb', line 270

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.



276
277
278
# File 'lib/tina4/response_cache.rb', line 276

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.



107
108
109
110
111
112
113
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
# File 'lib/tina4/response_cache.rb', line 107

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

  internal_store(method, url, status.to_i, content_type.to_s, body)
  # 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.



264
265
266
# File 'lib/tina4/response_cache.rb', line 264

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.



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
# File 'lib/tina4/response_cache.rb', line 78

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)
  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))
      return [request, 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)


198
199
200
# File 'lib/tina4/response_cache.rb', line 198

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)


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

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)



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

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



205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/tina4/response_cache.rb', line 205

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.



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

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)


68
69
70
# File 'lib/tina4/response_cache.rb', line 68

def enabled?
  @ttl > 0
end

#sweepInteger

Remove expired entries.

Returns:

  • (Integer)

    number of entries removed



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 238

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
  elsif @backend.respond_to?(:sweep)
    # The file backend supports an explicit sweep that returns a count.
    @backend.sweep
  else
    # Network/db backends expire entries lazily (TTL) — parity with
    # Python, whose non-memory backends return 0 from sweep.
    0
  end
end