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:

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)

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



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

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.



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

def max_entries
  @max_entries
end

#ttlObject (readonly)

Current TTL setting.



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

def ttl
  @ttl
end

Instance Method Details

#_internal_lookup(method, url) ⇒ Object

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



284
285
286
# File 'lib/tina4/response_cache.rb', line 284

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.



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

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.



95
96
97
98
99
100
101
102
103
104
105
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
# File 'lib/tina4/response_cache.rb', line 95

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.



278
279
280
# File 'lib/tina4/response_cache.rb', line 278

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.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/tina4/response_cache.rb', line 67

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)


182
183
184
# File 'lib/tina4/response_cache.rb', line 182

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)


138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/tina4/response_cache.rb', line 138

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)



167
168
169
170
171
172
173
174
175
176
# File 'lib/tina4/response_cache.rb', line 167

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



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

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.



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

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)


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

def enabled?
  @ttl > 0
end

#sweepInteger

Remove expired entries.

Returns:

  • (Integer)

    number of entries removed



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/tina4/response_cache.rb', line 242

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