tina4ruby 3.13.22 → 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.
- checksums.yaml +4 -4
- data/lib/tina4/cache_backends/base_backend.rb +58 -0
- data/lib/tina4/cache_backends/database_backend.rb +121 -0
- data/lib/tina4/cache_backends/file_backend.rb +140 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +103 -0
- data/lib/tina4/cache_backends/memory_backend.rb +81 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +133 -0
- data/lib/tina4/cache_backends/redis_backend.rb +233 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +16 -0
- data/lib/tina4/cache_backends.rb +91 -0
- data/lib/tina4/database.rb +178 -3
- data/lib/tina4/rack_app.rb +7 -0
- data/lib/tina4/response_cache.rb +73 -245
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +24 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "base_backend"
|
|
7
|
+
|
|
8
|
+
module Tina4
|
|
9
|
+
module CacheBackends
|
|
10
|
+
# Redis / Valkey backend (parity with Python _RedisBackend). Uses the
|
|
11
|
+
# `redis` gem if it is installed, otherwise falls back to the raw RESP
|
|
12
|
+
# protocol over a TCP socket — so it works with zero runtime dependencies.
|
|
13
|
+
#
|
|
14
|
+
# URL form: scheme://[user[:password]@]host[:port][/db]. Credentials may
|
|
15
|
+
# also be supplied via TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD (parity
|
|
16
|
+
# with TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD). Wrong credentials
|
|
17
|
+
# cause available? to return false, so the factory falls back to file.
|
|
18
|
+
class RedisBackend < BaseBackend
|
|
19
|
+
PREFIX = "tina4:cache:"
|
|
20
|
+
|
|
21
|
+
def initialize(url: "redis://localhost:6379", max_entries: 1000, name: "redis")
|
|
22
|
+
@max_entries = max_entries
|
|
23
|
+
@name = name
|
|
24
|
+
@hits = 0
|
|
25
|
+
@misses = 0
|
|
26
|
+
@client = nil
|
|
27
|
+
@use_raw = false
|
|
28
|
+
|
|
29
|
+
parse_url(url)
|
|
30
|
+
|
|
31
|
+
# Try the redis gem first.
|
|
32
|
+
begin
|
|
33
|
+
require "redis"
|
|
34
|
+
kwargs = { host: @host, port: @port, db: @db, timeout: 5 }
|
|
35
|
+
kwargs[:password] = @password if @password
|
|
36
|
+
kwargs[:username] = @username if @username
|
|
37
|
+
@client = Redis.new(**kwargs)
|
|
38
|
+
@client.ping
|
|
39
|
+
@available = true
|
|
40
|
+
rescue LoadError, StandardError
|
|
41
|
+
@client = nil
|
|
42
|
+
@use_raw = true
|
|
43
|
+
# No gem — usable only if the server answers (and authenticates).
|
|
44
|
+
@available = probe
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def available?
|
|
49
|
+
@available
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get(key)
|
|
53
|
+
full_key = PREFIX + key
|
|
54
|
+
raw = if @client
|
|
55
|
+
begin
|
|
56
|
+
@client.get(full_key)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
elsif @use_raw
|
|
61
|
+
resp_command("GET", full_key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if raw.nil?
|
|
65
|
+
@misses += 1
|
|
66
|
+
return nil
|
|
67
|
+
end
|
|
68
|
+
@hits += 1
|
|
69
|
+
begin
|
|
70
|
+
JSON.parse(raw)
|
|
71
|
+
rescue JSON::ParserError, TypeError
|
|
72
|
+
raw
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set(key, value, ttl)
|
|
77
|
+
full_key = PREFIX + key
|
|
78
|
+
serialized = JSON.generate(value)
|
|
79
|
+
if @client
|
|
80
|
+
begin
|
|
81
|
+
if ttl > 0
|
|
82
|
+
@client.setex(full_key, ttl, serialized)
|
|
83
|
+
else
|
|
84
|
+
@client.set(full_key, serialized)
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError
|
|
87
|
+
end
|
|
88
|
+
elsif @use_raw
|
|
89
|
+
if ttl > 0
|
|
90
|
+
resp_command("SETEX", full_key, ttl.to_s, serialized)
|
|
91
|
+
else
|
|
92
|
+
resp_command("SET", full_key, serialized)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def delete(key)
|
|
98
|
+
full_key = PREFIX + key
|
|
99
|
+
if @client
|
|
100
|
+
begin
|
|
101
|
+
@client.del(full_key) > 0
|
|
102
|
+
rescue StandardError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
elsif @use_raw
|
|
106
|
+
resp_command("DEL", full_key) == "1"
|
|
107
|
+
else
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def clear
|
|
113
|
+
@hits = 0
|
|
114
|
+
@misses = 0
|
|
115
|
+
if @client
|
|
116
|
+
begin
|
|
117
|
+
keys = @client.keys("#{PREFIX}*")
|
|
118
|
+
@client.del(*keys) unless keys.empty?
|
|
119
|
+
rescue StandardError
|
|
120
|
+
end
|
|
121
|
+
elsif @use_raw
|
|
122
|
+
# Raw RESP doesn't support pattern delete easily; rely on TTL.
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stats
|
|
127
|
+
size = 0
|
|
128
|
+
if @client
|
|
129
|
+
begin
|
|
130
|
+
size = @client.keys("#{PREFIX}*").size
|
|
131
|
+
rescue StandardError
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
{ hits: @hits, misses: @misses, size: size, backend: @name }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def name
|
|
138
|
+
@name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def parse_url(url)
|
|
144
|
+
url = "redis://#{url}" unless url.include?("://")
|
|
145
|
+
# Normalise valkey:// → redis:// so URI parses host/port/userinfo.
|
|
146
|
+
normalised = url.sub(%r{^valkey://}, "redis://")
|
|
147
|
+
uri = URI.parse(normalised)
|
|
148
|
+
@host = uri.host || "localhost"
|
|
149
|
+
@port = uri.port || 6379
|
|
150
|
+
db_path = (uri.path || "").sub(%r{^/}, "")
|
|
151
|
+
@db = db_path =~ /\A\d+\z/ ? db_path.to_i : 0
|
|
152
|
+
url_user = uri.user && !uri.user.empty? ? URI.decode_www_form_component(uri.user) : nil
|
|
153
|
+
url_pass = uri.password && !uri.password.empty? ? URI.decode_www_form_component(uri.password) : nil
|
|
154
|
+
@username = url_user || (env_nonempty("TINA4_CACHE_USERNAME"))
|
|
155
|
+
@password = url_pass || (env_nonempty("TINA4_CACHE_PASSWORD"))
|
|
156
|
+
rescue URI::InvalidURIError
|
|
157
|
+
@host = "localhost"
|
|
158
|
+
@port = 6379
|
|
159
|
+
@db = 0
|
|
160
|
+
@username = env_nonempty("TINA4_CACHE_USERNAME")
|
|
161
|
+
@password = env_nonempty("TINA4_CACHE_PASSWORD")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def env_nonempty(key)
|
|
165
|
+
v = ENV[key]
|
|
166
|
+
v && !v.empty? ? v : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Real AUTH+PING handshake so wrong credentials also fall back to file.
|
|
170
|
+
def probe
|
|
171
|
+
resp_command("PING") == "PONG"
|
|
172
|
+
rescue StandardError
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Send a command using the raw RESP protocol over TCP. Returns the simple
|
|
177
|
+
# string / bulk string / integer as a String, or nil on miss/error.
|
|
178
|
+
def resp_command(*args)
|
|
179
|
+
cmd = +"*#{args.size}\r\n"
|
|
180
|
+
args.each do |arg|
|
|
181
|
+
s = arg.to_s
|
|
182
|
+
cmd << "$#{s.bytesize}\r\n#{s}\r\n"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
sock = TCPSocket.new(@host, @port)
|
|
186
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
187
|
+
|
|
188
|
+
if @password
|
|
189
|
+
auth = if @username
|
|
190
|
+
"*3\r\n$4\r\nAUTH\r\n$#{@username.bytesize}\r\n#{@username}\r\n" \
|
|
191
|
+
"$#{@password.bytesize}\r\n#{@password}\r\n"
|
|
192
|
+
else
|
|
193
|
+
"*2\r\n$4\r\nAUTH\r\n$#{@password.bytesize}\r\n#{@password}\r\n"
|
|
194
|
+
end
|
|
195
|
+
sock.write(auth)
|
|
196
|
+
unless sock.recv(1024).start_with?("+")
|
|
197
|
+
sock.close
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if @db != 0
|
|
203
|
+
select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{@db.to_s.bytesize}\r\n#{@db}\r\n"
|
|
204
|
+
sock.write(select_cmd)
|
|
205
|
+
sock.recv(1024)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sock.write(cmd)
|
|
209
|
+
response = sock.recv(65_536)
|
|
210
|
+
sock.close
|
|
211
|
+
|
|
212
|
+
if response.nil? || response.empty?
|
|
213
|
+
nil
|
|
214
|
+
elsif response.start_with?("+")
|
|
215
|
+
response[1..].strip
|
|
216
|
+
elsif response.start_with?("$-1")
|
|
217
|
+
nil
|
|
218
|
+
elsif response.start_with?("$")
|
|
219
|
+
lines = response.split("\r\n")
|
|
220
|
+
lines[1]
|
|
221
|
+
elsif response.start_with?(":")
|
|
222
|
+
response[1..].strip
|
|
223
|
+
elsif response.start_with?("-")
|
|
224
|
+
nil
|
|
225
|
+
else
|
|
226
|
+
response.strip
|
|
227
|
+
end
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "redis_backend"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module CacheBackends
|
|
7
|
+
# Valkey backend (parity with Python _ValkeyBackend). Valkey speaks the
|
|
8
|
+
# Redis wire protocol, so it reuses the Redis client / raw-RESP transport
|
|
9
|
+
# and only reports a different name.
|
|
10
|
+
class ValkeyBackend < RedisBackend
|
|
11
|
+
def initialize(url: "valkey://localhost:6379", max_entries: 1000)
|
|
12
|
+
super(url: url.sub(%r{^valkey://}, "redis://"), max_entries: max_entries, name: "valkey")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_backends/base_backend"
|
|
4
|
+
require_relative "cache_backends/memory_backend"
|
|
5
|
+
require_relative "cache_backends/file_backend"
|
|
6
|
+
require_relative "cache_backends/redis_backend"
|
|
7
|
+
require_relative "cache_backends/valkey_backend"
|
|
8
|
+
require_relative "cache_backends/memcached_backend"
|
|
9
|
+
require_relative "cache_backends/mongo_backend"
|
|
10
|
+
require_relative "cache_backends/database_backend"
|
|
11
|
+
|
|
12
|
+
module Tina4
|
|
13
|
+
# Unified cache backend family (parity with Python tina4_python.cache).
|
|
14
|
+
#
|
|
15
|
+
# Backends are key/value stores with TTL semantics, selected via env vars:
|
|
16
|
+
#
|
|
17
|
+
# memory — in-process LRU cache (default, zero deps)
|
|
18
|
+
# file — JSON files in data/cache/
|
|
19
|
+
# redis — Redis (redis gem or raw RESP over TCP)
|
|
20
|
+
# valkey — Valkey (Redis wire protocol; reports "valkey")
|
|
21
|
+
# memcached — Memcached (zero-dep text protocol over TCP)
|
|
22
|
+
# mongodb — MongoDB TTL collection (requires the mongo gem)
|
|
23
|
+
# database — tina4_cache table in any Tina4-supported database
|
|
24
|
+
#
|
|
25
|
+
# Environment:
|
|
26
|
+
# TINA4_CACHE_BACKEND — memory|file|redis|valkey|memcached|mongodb|database
|
|
27
|
+
# TINA4_CACHE_URL — connection URL (redis/valkey/memcached/mongo) OR
|
|
28
|
+
# SQL URL for database (falls back to TINA4_DATABASE_URL)
|
|
29
|
+
# TINA4_CACHE_TTL — default TTL in seconds (default: 60)
|
|
30
|
+
# TINA4_CACHE_MAX_ENTRIES — max cached entries (default: 1000)
|
|
31
|
+
# TINA4_CACHE_DIR — file backend directory (default: data/cache)
|
|
32
|
+
# TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD — credentials when not in the URL
|
|
33
|
+
module CacheBackends
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Create a cache backend from explicit params or env vars.
|
|
37
|
+
#
|
|
38
|
+
# Graceful degradation: if the configured backend's driver is missing or its
|
|
39
|
+
# service is unreachable, the factory logs a warning and falls back to the
|
|
40
|
+
# file backend (persistent, zero-dep, always available) — never a silent
|
|
41
|
+
# no-op cache.
|
|
42
|
+
#
|
|
43
|
+
# @param backend [String, nil]
|
|
44
|
+
# @param url [String, nil]
|
|
45
|
+
# @param max_entries [Integer, nil]
|
|
46
|
+
# @param cache_dir [String, nil]
|
|
47
|
+
# @return [Tina4::CacheBackends::BaseBackend]
|
|
48
|
+
def create_backend(backend: nil, url: nil, max_entries: nil, cache_dir: nil)
|
|
49
|
+
backend ||= ENV.fetch("TINA4_CACHE_BACKEND", "memory")
|
|
50
|
+
max_entries ||= (ENV["TINA4_CACHE_MAX_ENTRIES"] || "1000").to_i
|
|
51
|
+
backend = backend.to_s.downcase.strip
|
|
52
|
+
|
|
53
|
+
be =
|
|
54
|
+
case backend
|
|
55
|
+
when "redis"
|
|
56
|
+
RedisBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379"),
|
|
57
|
+
max_entries: max_entries)
|
|
58
|
+
when "valkey"
|
|
59
|
+
ValkeyBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "valkey://localhost:6379"),
|
|
60
|
+
max_entries: max_entries)
|
|
61
|
+
when "memcached", "memcache"
|
|
62
|
+
MemcachedBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "memcached://localhost:11211"),
|
|
63
|
+
max_entries: max_entries)
|
|
64
|
+
when "mongodb", "mongo"
|
|
65
|
+
MongoBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "mongodb://localhost:27017"),
|
|
66
|
+
max_entries: max_entries)
|
|
67
|
+
when "database", "db"
|
|
68
|
+
DatabaseBackend.new(url: url, max_entries: max_entries)
|
|
69
|
+
when "file"
|
|
70
|
+
dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
71
|
+
return FileBackend.new(cache_dir: dir, max_entries: max_entries)
|
|
72
|
+
else
|
|
73
|
+
return MemoryBackend.new(max_entries: max_entries)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return be if be.available?
|
|
77
|
+
|
|
78
|
+
# Configured backend unusable — degrade to the file backend.
|
|
79
|
+
begin
|
|
80
|
+
Tina4::Log.warning(
|
|
81
|
+
"Cache backend '#{backend}' is unavailable " \
|
|
82
|
+
"(driver missing or service unreachable) — falling back to 'file'."
|
|
83
|
+
)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# Logging must never break cache construction.
|
|
86
|
+
end
|
|
87
|
+
dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
88
|
+
FileBackend.new(cache_dir: dir, max_entries: max_entries)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/tina4/database.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
4
|
require "digest"
|
|
5
|
+
require "weakref"
|
|
5
6
|
|
|
6
7
|
module Tina4
|
|
7
8
|
# Thread-safe connection pool with round-robin rotation.
|
|
@@ -68,6 +69,43 @@ module Tina4
|
|
|
68
69
|
class Database
|
|
69
70
|
attr_reader :driver, :driver_name, :connected, :pool
|
|
70
71
|
|
|
72
|
+
# Live Database instances, so the request dispatcher can reset the
|
|
73
|
+
# request-scoped query cache on every connection at the start of a request.
|
|
74
|
+
# WeakRefs avoid keeping closed connections (or short-lived script
|
|
75
|
+
# connections) alive — parity with Python's weakref.WeakSet. Guarded by a
|
|
76
|
+
# mutex because connections can be created from multiple threads.
|
|
77
|
+
@instances = []
|
|
78
|
+
@instances_mutex = Mutex.new
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# Register a live connection in the class-level WeakRef registry.
|
|
82
|
+
def register_instance(db)
|
|
83
|
+
@instances_mutex.synchronize do
|
|
84
|
+
@instances << WeakRef.new(db)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Clear the request-scoped query cache on every live Database instance.
|
|
89
|
+
#
|
|
90
|
+
# The request dispatcher calls this at the start of each HTTP request so
|
|
91
|
+
# request-scoped caching never serves rows across requests (zero
|
|
92
|
+
# cross-request staleness). Persistent-mode connections are left alone.
|
|
93
|
+
# Dead WeakRefs (closed/GC'd connections) are pruned as we go.
|
|
94
|
+
def reset_request_caches
|
|
95
|
+
@instances_mutex.synchronize do
|
|
96
|
+
@instances.reject! do |ref|
|
|
97
|
+
begin
|
|
98
|
+
inst = ref.__getobj__
|
|
99
|
+
inst.cache_new_request
|
|
100
|
+
false
|
|
101
|
+
rescue WeakRef::RefError, StandardError
|
|
102
|
+
true # dead reference (or errored) — prune it
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
71
109
|
DRIVERS = {
|
|
72
110
|
"sqlite" => "Tina4::Drivers::SqliteDriver",
|
|
73
111
|
"sqlite3" => "Tina4::Drivers::SqliteDriver",
|
|
@@ -162,14 +200,52 @@ module Tina4
|
|
|
162
200
|
# driver for every call so the whole transaction runs on one connection.
|
|
163
201
|
@tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
|
|
164
202
|
|
|
165
|
-
# Query cache
|
|
166
|
-
|
|
167
|
-
|
|
203
|
+
# Query cache. One store, two layers (parity with Python connection.py):
|
|
204
|
+
# • request-scoped (DEFAULT ON, off-switch TINA4_AUTO_CACHING=false) —
|
|
205
|
+
# dedupes identical SELECTs to protect the DB from rapid repeat reads.
|
|
206
|
+
# Cleared at the START of every HTTP request (so it never serves rows
|
|
207
|
+
# across requests) AND on any write, with a short safety TTL (5s) for
|
|
208
|
+
# non-request contexts (scripts/workers).
|
|
209
|
+
# • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache
|
|
210
|
+
# that is NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL.
|
|
211
|
+
@cache_persistent = truthy?(ENV["TINA4_DB_CACHE"])
|
|
212
|
+
# Default true; honour the same truthy semantics the framework uses
|
|
213
|
+
# (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "true"))).
|
|
214
|
+
@cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "true")
|
|
215
|
+
@cache_enabled = @cache_persistent || @cache_request_scoped
|
|
216
|
+
@cache_ttl = if @cache_persistent
|
|
217
|
+
(ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
218
|
+
else
|
|
219
|
+
(ENV["TINA4_AUTO_CACHING_TTL"] || "5").to_i
|
|
220
|
+
end
|
|
168
221
|
@query_cache = {} # key => { expires_at:, value: }
|
|
169
222
|
@cache_hits = 0
|
|
170
223
|
@cache_misses = 0
|
|
171
224
|
@cache_mutex = Mutex.new
|
|
172
225
|
|
|
226
|
+
# Persistent mode may route through the unified CacheBackend (redis/
|
|
227
|
+
# valkey/memcached/mongodb/database via TINA4_DB_CACHE_BACKEND) so
|
|
228
|
+
# multiple instances can share one cache with global write-invalidation.
|
|
229
|
+
# Request-scoped mode always stays in-process (the @query_cache dict).
|
|
230
|
+
# The DatabaseResult is serialized to a JSON-friendly Hash before storing
|
|
231
|
+
# and reconstructed on read so shared backends work cross-instance.
|
|
232
|
+
@cache_backend = nil
|
|
233
|
+
if @cache_persistent
|
|
234
|
+
begin
|
|
235
|
+
@cache_backend = Tina4::CacheBackends.create_backend(
|
|
236
|
+
backend: ENV["TINA4_DB_CACHE_BACKEND"] || "memory",
|
|
237
|
+
url: ENV["TINA4_DB_CACHE_URL"],
|
|
238
|
+
max_entries: 1000
|
|
239
|
+
)
|
|
240
|
+
rescue StandardError
|
|
241
|
+
@cache_backend = nil # fall back to the in-process dict
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Register this connection so Tina4::Database.reset_request_caches can
|
|
246
|
+
# clear its request-scoped entries at the start of every HTTP request.
|
|
247
|
+
Tina4::Database.register_instance(self)
|
|
248
|
+
|
|
173
249
|
if @pool_size > 0
|
|
174
250
|
# Pooled mode — create a ConnectionPool with lazy driver creation
|
|
175
251
|
@pool = ConnectionPool.new(
|
|
@@ -232,18 +308,34 @@ module Tina4
|
|
|
232
308
|
# ── Query Cache ──────────────────────────────────────────────
|
|
233
309
|
|
|
234
310
|
def cache_stats
|
|
311
|
+
if @cache_backend
|
|
312
|
+
bs = @cache_backend.stats
|
|
313
|
+
return {
|
|
314
|
+
enabled: @cache_enabled,
|
|
315
|
+
mode: cache_mode,
|
|
316
|
+
hits: @cache_hits,
|
|
317
|
+
misses: @cache_misses,
|
|
318
|
+
size: bs[:size],
|
|
319
|
+
backend: bs[:backend] || @cache_backend.name,
|
|
320
|
+
ttl: @cache_ttl
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
235
324
|
@cache_mutex.synchronize do
|
|
236
325
|
{
|
|
237
326
|
enabled: @cache_enabled,
|
|
327
|
+
mode: cache_mode,
|
|
238
328
|
hits: @cache_hits,
|
|
239
329
|
misses: @cache_misses,
|
|
240
330
|
size: @query_cache.size,
|
|
331
|
+
backend: "memory",
|
|
241
332
|
ttl: @cache_ttl
|
|
242
333
|
}
|
|
243
334
|
end
|
|
244
335
|
end
|
|
245
336
|
|
|
246
337
|
def cache_clear
|
|
338
|
+
@cache_backend.clear if @cache_backend
|
|
247
339
|
@cache_mutex.synchronize do
|
|
248
340
|
@query_cache.clear
|
|
249
341
|
@cache_hits = 0
|
|
@@ -251,6 +343,16 @@ module Tina4
|
|
|
251
343
|
end
|
|
252
344
|
end
|
|
253
345
|
|
|
346
|
+
# Clear the request-scoped query cache at the start of an HTTP request.
|
|
347
|
+
#
|
|
348
|
+
# No-op in persistent mode (TINA4_DB_CACHE=true) so cross-request entries
|
|
349
|
+
# survive up to their TTL. Cumulative hit/miss counters are preserved.
|
|
350
|
+
def cache_new_request
|
|
351
|
+
return unless @cache_request_scoped && !@cache_persistent
|
|
352
|
+
|
|
353
|
+
@cache_mutex.synchronize { @query_cache.clear }
|
|
354
|
+
end
|
|
355
|
+
|
|
254
356
|
# Fetch rows and return the records array directly.
|
|
255
357
|
#
|
|
256
358
|
# Symmetric with fetch_one. Cross-framework parity with Python
|
|
@@ -677,11 +779,29 @@ module Tina4
|
|
|
677
779
|
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
678
780
|
end
|
|
679
781
|
|
|
782
|
+
# "persistent" / "request" / "off" — mirrors Python connection.py.
|
|
783
|
+
def cache_mode
|
|
784
|
+
if @cache_persistent
|
|
785
|
+
"persistent"
|
|
786
|
+
elsif @cache_request_scoped
|
|
787
|
+
"request"
|
|
788
|
+
else
|
|
789
|
+
"off"
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
680
793
|
def cache_key(sql, params)
|
|
681
794
|
Digest::SHA256.hexdigest(sql + params.to_s)
|
|
682
795
|
end
|
|
683
796
|
|
|
684
797
|
def cache_get(key)
|
|
798
|
+
# Shared backend (persistent + TINA4_DB_CACHE_BACKEND) — reconstruct the
|
|
799
|
+
# DatabaseResult from the serialized Hash so cross-instance reads work.
|
|
800
|
+
if @cache_backend
|
|
801
|
+
raw = @cache_backend.get(key)
|
|
802
|
+
return raw.is_a?(Hash) ? deserialize_result(raw) : nil
|
|
803
|
+
end
|
|
804
|
+
|
|
685
805
|
@cache_mutex.synchronize do
|
|
686
806
|
entry = @query_cache[key]
|
|
687
807
|
return nil unless entry
|
|
@@ -694,6 +814,11 @@ module Tina4
|
|
|
694
814
|
end
|
|
695
815
|
|
|
696
816
|
def cache_set(key, value)
|
|
817
|
+
if @cache_backend
|
|
818
|
+
@cache_backend.set(key, serialize_result(value), @cache_ttl)
|
|
819
|
+
return
|
|
820
|
+
end
|
|
821
|
+
|
|
697
822
|
@cache_mutex.synchronize do
|
|
698
823
|
@query_cache[key] = {
|
|
699
824
|
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
@@ -703,9 +828,59 @@ module Tina4
|
|
|
703
828
|
end
|
|
704
829
|
|
|
705
830
|
def cache_invalidate
|
|
831
|
+
@cache_backend.clear if @cache_backend
|
|
706
832
|
@cache_mutex.synchronize { @query_cache.clear }
|
|
707
833
|
end
|
|
708
834
|
|
|
835
|
+
# Flatten a DatabaseResult (or a single fetch_one Hash) into a JSON-friendly
|
|
836
|
+
# Hash for shared backends. fetch_one stores a plain Hash row, so we tag the
|
|
837
|
+
# shape so deserialize can return the right thing.
|
|
838
|
+
def serialize_result(value)
|
|
839
|
+
if value.is_a?(Tina4::DatabaseResult)
|
|
840
|
+
{
|
|
841
|
+
"kind" => "result",
|
|
842
|
+
"records" => value.records,
|
|
843
|
+
"count" => value.count,
|
|
844
|
+
"limit" => value.limit,
|
|
845
|
+
"offset" => value.offset,
|
|
846
|
+
"affected_rows" => value.affected_rows,
|
|
847
|
+
"last_id" => value.last_id
|
|
848
|
+
}
|
|
849
|
+
else
|
|
850
|
+
# fetch_one row (Hash) or nil
|
|
851
|
+
{ "kind" => "row", "row" => value }
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Reconstruct from a backend-cached Hash. JSON round-trips string keys, so
|
|
856
|
+
# accept both string and symbol keys. Records are re-symbolised so a cached
|
|
857
|
+
# result is byte-for-byte equivalent to an uncached fetch (driver rows use
|
|
858
|
+
# symbol keys) regardless of which backend stored it.
|
|
859
|
+
def deserialize_result(data)
|
|
860
|
+
kind = data["kind"] || data[:kind]
|
|
861
|
+
if kind == "row"
|
|
862
|
+
row = data["row"] || data[:row]
|
|
863
|
+
symbolize_row(row)
|
|
864
|
+
else
|
|
865
|
+
records = (data["records"] || data[:records] || []).map { |r| symbolize_row(r) }
|
|
866
|
+
Tina4::DatabaseResult.new(
|
|
867
|
+
records,
|
|
868
|
+
count: data["count"] || data[:count] || 0,
|
|
869
|
+
limit: data["limit"] || data[:limit] || 0,
|
|
870
|
+
offset: data["offset"] || data[:offset] || 0,
|
|
871
|
+
affected_rows: data["affected_rows"] || data[:affected_rows] || 0,
|
|
872
|
+
last_id: data["last_id"] || data[:last_id]
|
|
873
|
+
)
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Driver rows use symbol keys; re-key a JSON-round-tripped Hash to match.
|
|
878
|
+
def symbolize_row(row)
|
|
879
|
+
return row unless row.is_a?(Hash)
|
|
880
|
+
|
|
881
|
+
row.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
882
|
+
end
|
|
883
|
+
|
|
709
884
|
def detect_driver(conn)
|
|
710
885
|
case conn.to_s.downcase
|
|
711
886
|
when /\.db$/, /\.sqlite/, /sqlite/
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -43,6 +43,13 @@ module Tina4
|
|
|
43
43
|
path = env["PATH_INFO"] || "/"
|
|
44
44
|
request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
45
|
|
|
46
|
+
# Request-scoped query cache boundary (v3.13.23). Tina4 Ruby runs a
|
|
47
|
+
# long-running Rack server, so the request-scoped DB cache (default-on)
|
|
48
|
+
# would otherwise serve rows from a previous request. Clear it on every
|
|
49
|
+
# live connection at the very start of each request, before any routing.
|
|
50
|
+
# No-op for persistent-mode (TINA4_DB_CACHE=true) connections.
|
|
51
|
+
Tina4::Database.reset_request_caches if defined?(Tina4::Database)
|
|
52
|
+
|
|
46
53
|
# Fast-path: CORS preflight. Real CORS preflight requests carry an
|
|
47
54
|
# Origin header AND an Access-Control-Request-Method header — the
|
|
48
55
|
# browser is asking "may I send this method?" before the actual
|