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.
@@ -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
@@ -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 off by default, opt-in via TINA4_DB_CACHE=true
166
- @cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
167
- @cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
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/
@@ -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