tina4ruby 3.13.23 → 3.13.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,16 +14,27 @@ module Tina4
14
14
  # middleware by attaching ResponseCache to your route, not by calling
15
15
  # the (private) internal_lookup / internal_store directly.
16
16
  #
17
- # Backends are selected via the TINA4_CACHE_BACKEND env var:
18
- # memory in-process LRU cache (default, zero deps)
19
- # redis Redis / Valkey (uses `redis` gem or raw RESP over TCP)
20
- # file — JSON files in data/cache/
17
+ # Backends are selected via the TINA4_CACHE_BACKEND env var and built by the
18
+ # unified factory (Tina4::CacheBackends.create_backend):
19
+ # memory in-process LRU cache (default, zero deps)
20
+ # file — JSON files in data/cache/
21
+ # redis — Redis (redis gem or raw RESP over TCP)
22
+ # valkey — Valkey (Redis wire protocol; reports "valkey")
23
+ # memcached — Memcached (zero-dep text protocol over TCP)
24
+ # mongodb — MongoDB TTL collection (requires the mongo gem)
25
+ # database — tina4_cache table in any Tina4-supported database
26
+ #
27
+ # A configured network/driver backend that is unreachable degrades to the
28
+ # file backend (a real working cache), never a silent no-op.
21
29
  #
22
30
  # Environment:
23
- # TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
24
- # TINA4_CACHE_URL — redis://localhost:6379 (redis only)
31
+ # TINA4_CACHE_BACKEND — memory|file|redis|valkey|memcached|mongodb|database
32
+ # TINA4_CACHE_URL — connection URL (redis/valkey/memcached/mongo) OR
33
+ # SQL URL for database (falls back to TINA4_DATABASE_URL)
25
34
  # TINA4_CACHE_TTL — default TTL in seconds (default: 60)
26
35
  # TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
36
+ # TINA4_CACHE_DIR — file backend directory (default: data/cache)
37
+ # TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD — credentials when not in the URL
27
38
  #
28
39
  class ResponseCache
29
40
  CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
@@ -187,52 +198,32 @@ module Tina4
187
198
  #
188
199
  # @return [Hash] with :hits, :misses, :size, :backend, :keys
189
200
  def cache_stats
190
- @mutex.synchronize do
191
- case @backend_name
192
- when "memory"
201
+ if memory_backend?
202
+ @mutex.synchronize do
193
203
  now = Time.now.to_f
194
204
  @store.reject! { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }
195
205
  { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
196
- when "file"
197
- sweep
198
- files = Dir.glob(File.join(@cache_dir, "*.json"))
199
- { hits: @hits, misses: @misses, size: files.size, backend: @backend_name, keys: [] }
200
- when "redis"
201
- size = 0
202
- if @redis_client
203
- begin
204
- keys = @redis_client.keys("tina4:cache:*")
205
- size = keys.size
206
- rescue StandardError
207
- end
208
- end
209
- { hits: @hits, misses: @misses, size: size, backend: @backend_name, keys: [] }
210
- else
211
- { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
212
206
  end
207
+ else
208
+ st = @backend.stats
209
+ { hits: @hits, misses: @misses, size: st[:size], backend: @backend_name, keys: [] }
213
210
  end
214
211
  end
215
212
 
216
213
  # Clear all cached responses.
217
214
  def clear_cache
218
- @mutex.synchronize do
219
- @hits = 0
220
- @misses = 0
221
-
222
- case @backend_name
223
- when "memory"
215
+ if memory_backend?
216
+ @mutex.synchronize do
217
+ @hits = 0
218
+ @misses = 0
224
219
  @store.clear
225
- when "file"
226
- Dir.glob(File.join(@cache_dir, "*.json")).each { |f| File.delete(f) rescue nil }
227
- when "redis"
228
- if @redis_client
229
- begin
230
- keys = @redis_client.keys("tina4:cache:*")
231
- @redis_client.del(*keys) unless keys.empty?
232
- rescue StandardError
233
- end
234
- end
235
220
  end
221
+ else
222
+ @mutex.synchronize do
223
+ @hits = 0
224
+ @misses = 0
225
+ end
226
+ @backend.clear
236
227
  end
237
228
  end
238
229
 
@@ -240,8 +231,7 @@ module Tina4
240
231
  #
241
232
  # @return [Integer] number of entries removed
242
233
  def sweep
243
- case @backend_name
244
- when "memory"
234
+ if memory_backend?
245
235
  @mutex.synchronize do
246
236
  now = Time.now.to_f
247
237
  keys_to_remove = @store.select { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }.keys
@@ -249,21 +239,12 @@ module Tina4
249
239
  keys_to_remove.each { |k| @store.delete(k) }
250
240
  keys_to_remove.size
251
241
  end
252
- when "file"
253
- removed = 0
254
- now = Time.now.to_f
255
- Dir.glob(File.join(@cache_dir, "*.json")).each do |f|
256
- begin
257
- data = JSON.parse(File.read(f))
258
- if data["expires_at"] && now > data["expires_at"]
259
- File.delete(f)
260
- removed += 1
261
- end
262
- rescue StandardError
263
- end
264
- end
265
- removed
242
+ elsif @backend.respond_to?(:sweep)
243
+ # The file backend supports an explicit sweep that returns a count.
244
+ @backend.sweep
266
245
  else
246
+ # Network/db backends expire entries lazily (TTL) — parity with
247
+ # Python, whose non-memory backends return 0 from sweep.
267
248
  0
268
249
  end
269
250
  end
@@ -370,62 +351,50 @@ module Tina4
370
351
 
371
352
  # ── Backend initialization ─────────────────────────────────
372
353
 
354
+ # Build the storage backend. Memory keeps an in-process LRU store on the
355
+ # ResponseCache itself (for direct CacheEntry access + per-instance stats);
356
+ # every other backend delegates to a Tina4::CacheBackends object, which
357
+ # also handles graceful file-fallback when a network backend is
358
+ # unreachable. @backend_name is reconciled to the ACTUAL backend so a
359
+ # degraded redis correctly reports "file".
373
360
  def init_backend
374
- case @backend_name
375
- when "redis"
376
- init_redis
377
- when "file"
378
- init_file_dir
361
+ if @backend_name == "memory"
362
+ @backend = nil
363
+ return
379
364
  end
380
- end
381
-
382
- def init_redis
383
- @redis_client = nil
384
- begin
385
- require "redis"
386
- parsed = parse_redis_url(@cache_url)
387
- @redis_client = Redis.new(host: parsed[:host], port: parsed[:port], db: parsed[:db], timeout: 5)
388
- @redis_client.ping
389
- rescue LoadError, StandardError
390
- @redis_client = nil
391
- end
392
- end
393
365
 
394
- def parse_redis_url(url)
395
- cleaned = url.sub(%r{^redis://}, "")
396
- parts = cleaned.split(":")
397
- host = parts[0].empty? ? "localhost" : parts[0]
398
- port_and_db = parts[1] ? parts[1].split("/") : ["6379"]
399
- port = port_and_db[0].to_i
400
- port = 6379 if port == 0
401
- db = port_and_db[1] ? port_and_db[1].to_i : 0
402
- { host: host, port: port, db: db }
366
+ @backend = Tina4::CacheBackends.create_backend(
367
+ backend: @backend_name,
368
+ url: @cache_url,
369
+ max_entries: @max_entries,
370
+ cache_dir: @cache_dir
371
+ )
372
+ # Reconcile reported name with reality (e.g. redis → file on fallback).
373
+ @backend_name = @backend.name
403
374
  end
404
375
 
405
- def init_file_dir
406
- require "json"
407
- require "fileutils"
408
- FileUtils.mkdir_p(@cache_dir)
376
+ def memory_backend?
377
+ @backend.nil?
409
378
  end
410
379
 
411
380
  # ── Backend operations ─────────────────────────────────────
381
+ #
382
+ # The middleware stores response entries as a Hash (entry_data) and the
383
+ # direct KV API stores its own wrapper Hash. Both round-trip through the
384
+ # unified backend's get/set/delete; for memory we keep the in-process
385
+ # store with LRU eviction (parity with the previous behaviour and the
386
+ # response-cache CacheEntry path).
412
387
 
413
388
  def backend_get(key)
414
- case @backend_name
415
- when "memory"
389
+ if memory_backend?
416
390
  @mutex.synchronize { @store[key] }
417
- when "redis"
418
- redis_get(key)
419
- when "file"
420
- file_get(key)
421
391
  else
422
- @mutex.synchronize { @store[key] }
392
+ @backend.get(key)
423
393
  end
424
394
  end
425
395
 
426
396
  def backend_set(key, entry, ttl)
427
- case @backend_name
428
- when "memory"
397
+ if memory_backend?
429
398
  @mutex.synchronize do
430
399
  if @store.size >= @max_entries && !@store.key?(key)
431
400
  oldest_key = @store.keys.first
@@ -433,157 +402,16 @@ module Tina4
433
402
  end
434
403
  @store[key] = entry
435
404
  end
436
- when "redis"
437
- redis_set(key, entry, ttl)
438
- when "file"
439
- file_set(key, entry)
440
- end
441
- end
442
-
443
- def backend_delete(key)
444
- case @backend_name
445
- when "memory"
446
- @mutex.synchronize do
447
- !@store.delete(key).nil?
448
- end
449
- when "redis"
450
- redis_delete(key)
451
- when "file"
452
- file_delete(key)
453
- end
454
- end
455
-
456
- # ── Redis operations ───────────────────────────────────────
457
-
458
- def redis_get(key)
459
- full_key = "tina4:cache:#{key}"
460
- if @redis_client
461
- begin
462
- raw = @redis_client.get(full_key)
463
- return nil if raw.nil?
464
- JSON.parse(raw)
465
- rescue StandardError
466
- nil
467
- end
468
- else
469
- resp_get(full_key)
470
- end
471
- end
472
-
473
- def redis_set(key, entry, ttl)
474
- full_key = "tina4:cache:#{key}"
475
- serialized = JSON.generate(entry)
476
- if @redis_client
477
- begin
478
- if ttl > 0
479
- @redis_client.setex(full_key, ttl, serialized)
480
- else
481
- @redis_client.set(full_key, serialized)
482
- end
483
- rescue StandardError
484
- end
485
- else
486
- if ttl > 0
487
- resp_command("SETEX", full_key, ttl.to_s, serialized)
488
- else
489
- resp_command("SET", full_key, serialized)
490
- end
491
- end
492
- end
493
-
494
- def redis_delete(key)
495
- full_key = "tina4:cache:#{key}"
496
- if @redis_client
497
- begin
498
- @redis_client.del(full_key) > 0
499
- rescue StandardError
500
- false
501
- end
502
- else
503
- result = resp_command("DEL", full_key)
504
- result == "1"
505
- end
506
- end
507
-
508
- def resp_get(key)
509
- result = resp_command("GET", key)
510
- return nil if result.nil?
511
- JSON.parse(result) rescue nil
512
- end
513
-
514
- def resp_command(*args)
515
- parsed = parse_redis_url(@cache_url)
516
- cmd = "*#{args.size}\r\n"
517
- args.each { |arg| s = arg.to_s; cmd += "$#{s.bytesize}\r\n#{s}\r\n" }
518
-
519
- sock = TCPSocket.new(parsed[:host], parsed[:port])
520
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
521
- if parsed[:db] > 0
522
- select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{parsed[:db].to_s.bytesize}\r\n#{parsed[:db]}\r\n"
523
- sock.write(select_cmd)
524
- sock.recv(1024)
525
- end
526
- sock.write(cmd)
527
- response = sock.recv(65536)
528
- sock.close
529
-
530
- if response.start_with?("+")
531
- response[1..].strip
532
- elsif response.start_with?("$-1")
533
- nil
534
- elsif response.start_with?("$")
535
- lines = response.split("\r\n")
536
- lines[1]
537
- elsif response.start_with?(":")
538
- response[1..].strip
539
405
  else
540
- nil
541
- end
542
- rescue StandardError
543
- nil
544
- end
545
-
546
- # ── File operations ────────────────────────────────────────
547
-
548
- def file_key_path(key)
549
- require "digest"
550
- safe = Digest::SHA256.hexdigest(key)
551
- File.join(@cache_dir, "#{safe}.json")
552
- end
553
-
554
- def file_get(key)
555
- path = file_key_path(key)
556
- return nil unless File.exist?(path)
557
- begin
558
- data = JSON.parse(File.read(path))
559
- if data["expires_at"] && Time.now.to_f > data["expires_at"]
560
- File.delete(path) rescue nil
561
- return nil
562
- end
563
- data
564
- rescue StandardError
565
- nil
406
+ @backend.set(key, entry, ttl)
566
407
  end
567
408
  end
568
409
 
569
- def file_set(key, entry)
570
- init_file_dir
571
- files = Dir.glob(File.join(@cache_dir, "*.json")).sort_by { |f| File.mtime(f) }
572
- while files.size >= @max_entries
573
- File.delete(files.shift) rescue nil
574
- end
575
- path = file_key_path(key)
576
- File.write(path, JSON.generate(entry))
577
- rescue StandardError
578
- end
579
-
580
- def file_delete(key)
581
- path = file_key_path(key)
582
- if File.exist?(path)
583
- File.delete(path) rescue nil
584
- true
410
+ def backend_delete(key)
411
+ if memory_backend?
412
+ @mutex.synchronize { !@store.delete(key).nil? }
585
413
  else
586
- false
414
+ @backend.delete(key)
587
415
  end
588
416
  end
589
417
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.23"
4
+ VERSION = "3.13.24"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "tina4/dev_mailbox"
51
51
  require_relative "tina4/ai"
52
52
  require_relative "tina4/cache"
53
53
  require_relative "tina4/sql_translation"
54
+ require_relative "tina4/cache_backends"
54
55
  require_relative "tina4/response_cache"
55
56
  require_relative "tina4/html_element"
56
57
  require_relative "tina4/error_overlay"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.23
4
+ version: 3.13.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -206,6 +206,20 @@ dependencies:
206
206
  - - "~>"
207
207
  - !ruby/object:Gem::Version
208
208
  version: '3.8'
209
+ - !ruby/object:Gem::Dependency
210
+ name: mongo
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - "~>"
214
+ - !ruby/object:Gem::Version
215
+ version: '2.19'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - "~>"
221
+ - !ruby/object:Gem::Version
222
+ version: '2.19'
209
223
  - !ruby/object:Gem::Dependency
210
224
  name: pg
211
225
  requirement: !ruby/object:Gem::Requirement
@@ -283,6 +297,15 @@ files:
283
297
  - lib/tina4/auto_crud.rb
284
298
  - lib/tina4/background.rb
285
299
  - lib/tina4/cache.rb
300
+ - lib/tina4/cache_backends.rb
301
+ - lib/tina4/cache_backends/base_backend.rb
302
+ - lib/tina4/cache_backends/database_backend.rb
303
+ - lib/tina4/cache_backends/file_backend.rb
304
+ - lib/tina4/cache_backends/memcached_backend.rb
305
+ - lib/tina4/cache_backends/memory_backend.rb
306
+ - lib/tina4/cache_backends/mongo_backend.rb
307
+ - lib/tina4/cache_backends/redis_backend.rb
308
+ - lib/tina4/cache_backends/valkey_backend.rb
286
309
  - lib/tina4/cli.rb
287
310
  - lib/tina4/constants.rb
288
311
  - lib/tina4/container.rb