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.



256
257
258
# File 'lib/tina4/response_cache.rb', line 256

def max_entries
  @max_entries
end

#ttlObject (readonly)

Current TTL setting.



253
254
255
# File 'lib/tina4/response_cache.rb', line 253

def ttl
  @ttl
end

Instance Method Details

#_internal_lookup(method, url) ⇒ Object

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



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

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.



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

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.



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

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

  method = if request.respond_to?(:[])
             request[:_cache_method]
           else
             request.instance_variable_get(:@_cache_method)
           end
  url = if request.respond_to?(:[])
          request[:_cache_url]
        else
          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)
  [request, response]
end

#backend_nameObject

Active backend name.



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

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


193
194
195
# File 'lib/tina4/response_cache.rb', line 193

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)


149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/tina4/response_cache.rb', line 149

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)



178
179
180
181
182
183
184
185
186
187
# File 'lib/tina4/response_cache.rb', line 178

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



200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/tina4/response_cache.rb', line 200

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.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/tina4/response_cache.rb', line 214

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



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/tina4/response_cache.rb', line 233

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