tina4ruby 3.13.23 → 3.13.26

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
@@ -223,6 +223,25 @@ module Tina4
223
223
  @cache_misses = 0
224
224
  @cache_mutex = Mutex.new
225
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
+
226
245
  # Register this connection so Tina4::Database.reset_request_caches can
227
246
  # clear its request-scoped entries at the start of every HTTP request.
228
247
  Tina4::Database.register_instance(self)
@@ -289,6 +308,19 @@ module Tina4
289
308
  # ── Query Cache ──────────────────────────────────────────────
290
309
 
291
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
+
292
324
  @cache_mutex.synchronize do
293
325
  {
294
326
  enabled: @cache_enabled,
@@ -303,6 +335,7 @@ module Tina4
303
335
  end
304
336
 
305
337
  def cache_clear
338
+ @cache_backend.clear if @cache_backend
306
339
  @cache_mutex.synchronize do
307
340
  @query_cache.clear
308
341
  @cache_hits = 0
@@ -762,6 +795,13 @@ module Tina4
762
795
  end
763
796
 
764
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
+
765
805
  @cache_mutex.synchronize do
766
806
  entry = @query_cache[key]
767
807
  return nil unless entry
@@ -774,6 +814,11 @@ module Tina4
774
814
  end
775
815
 
776
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
+
777
822
  @cache_mutex.synchronize do
778
823
  @query_cache[key] = {
779
824
  expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
@@ -783,9 +828,59 @@ module Tina4
783
828
  end
784
829
 
785
830
  def cache_invalidate
831
+ @cache_backend.clear if @cache_backend
786
832
  @cache_mutex.synchronize { @query_cache.clear }
787
833
  end
788
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
+
789
884
  def detect_driver(conn)
790
885
  case conn.to_s.downcase
791
886
  when /\.db$/, /\.sqlite/, /sqlite/