tina4ruby 3.13.94 → 3.13.97

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -13,6 +13,12 @@ module Tina4
13
13
  # auth, so credentials are ignored.
14
14
  class MemcachedBackend < BaseBackend
15
15
  PREFIX = "tina4:cache:"
16
+ # The SHARED namespace generation counter. clear bumps it; every real key
17
+ # carries it, so a bump invalidates every instance at once.
18
+ GENERATION_KEY = "#{PREFIX}generation".freeze
19
+ # memcached reads the `set` exptime field as RELATIVE seconds at or below
20
+ # 30 days, and as an ABSOLUTE unix timestamp above it.
21
+ MAX_RELATIVE_EXPTIME = 2_592_000
16
22
 
17
23
  def initialize(url: "memcached://localhost:11211", max_entries: 1000)
18
24
  cleaned = url.sub(%r{^memcached://}, "").sub(%r{^memcache://}, "")
@@ -46,30 +52,81 @@ module Tina4
46
52
 
47
53
  def set(key, value, ttl)
48
54
  data = JSON.generate(value)
49
- exptime = ttl > 0 ? ttl : 0
50
- payload = "set #{mc_key(key)} 0 #{exptime} #{data.bytesize}\r\n#{data}\r\n"
55
+ k = mc_key(key)
56
+ payload = "set #{k} 0 #{exptime(ttl)} #{data.bytesize}\r\n#{data}\r\n"
51
57
  command(payload, "\r\n")
58
+ # Keys THIS backend wrote, mapped to the moment each expires (0 = never).
59
+ #
60
+ # This uses the RAW ttl, never the converted exptime. exptime(ttl)
61
+ # returns an ABSOLUTE unix timestamp past the 30-day cliff, and feeding
62
+ # that to Time.now.to_f + ... computes now + (now + ttl) - roughly twice
63
+ # the current epoch, a date in 2076. It fails SILENTLY: the shadow map
64
+ # would never expire anything, so the local bookkeeping stops matching
65
+ # what the server holds and stats reports expired entries as live
66
+ # forever.
67
+ @own ||= {}
68
+ @own[k] = ttl > 0 ? (Time.now.to_f + ttl) : 0.0
52
69
  end
53
70
 
54
71
  def delete(key)
55
- command("delete #{mc_key(key)}\r\n", "\r\n").start_with?("DELETED")
72
+ k = mc_key(key)
73
+ result = command("delete #{k}\r\n", "\r\n").start_with?("DELETED")
74
+ (@own ||= {}).delete(k)
75
+ result
56
76
  end
57
77
 
78
+ # Invalidate EVERY entry this cache can serve, on EVERY instance.
79
+ #
80
+ # Two wrong answers were shipped before this one. +flush_all+ wipes EVERY
81
+ # key on the instance including every other application's - cache_clear is
82
+ # public API, so calling it destroyed other tenants' data. Deleting only
83
+ # the keys THIS process wrote fixed that but broke the contract the other
84
+ # way: a second instance kept serving rows the first had just invalidated,
85
+ # because it had never seen those keys.
86
+ #
87
+ # The namespace generation does both. Bumping the shared counter orphans
88
+ # every previously-written entry for every instance at once, and touches
89
+ # nothing outside our own prefix. The orphans are reclaimed by memcached's
90
+ # own TTL and LRU - unreachable is what "removed" means for a cache.
91
+ #
92
+ # The local write log is still cleared so stats reports honestly, and its
93
+ # keys are deleted eagerly so the space comes back immediately rather than
94
+ # waiting for eviction.
58
95
  def clear
59
96
  @hits = 0
60
97
  @misses = 0
61
- command("flush_all\r\n", "\r\n")
98
+ (@own || {}).each_key { |k| command("delete #{k}\r\n", "\r\n") }
99
+ @own = {}
100
+ # incr is atomic, so two instances clearing at once still both advance.
101
+ return if command("incr #{GENERATION_KEY} 1\r\n", "\r\n").strip.match?(/\A\d+\z/)
102
+
103
+ # No counter yet: create it. `add` fails harmlessly if another instance
104
+ # created it in the gap, and the incr then applies.
105
+ command("add #{GENERATION_KEY} 0 0 1\r\n1\r\n", "\r\n")
106
+ command("incr #{GENERATION_KEY} 1\r\n", "\r\n")
62
107
  end
63
108
 
109
+ # Report OUR entries, not the whole server's.
110
+ #
111
+ # This used to read memcached's +curr_items+, which is a GLOBAL counter:
112
+ # it includes every key written by every other tenant of that server. On a
113
+ # shared memcached (the normal deployment) +size+ was reporting somebody
114
+ # else's data, and every other backend here is scoped - memory counts its
115
+ # own hash, redis/valkey scan their own prefix, file counts its own
116
+ # directory, mongo its own collection, database its own table. Memcached
117
+ # was the only one leaking.
118
+ #
119
+ # It cannot be fixed by asking the server: memcached has no KEYS or
120
+ # prefix-scan command. So the count comes from our own write log, filtered
121
+ # by the TTLs we set. That is exact for the keys this process wrote; a key
122
+ # EVICTED early under memory pressure is invisible to us and would be
123
+ # over-counted, which is a far smaller and more honest error than counting
124
+ # another application's keys.
64
125
  def stats
65
- size = 0
66
- resp = command("stats\r\n", "END\r\n")
67
- resp.split("\r\n").each do |line|
68
- if line.start_with?("STAT curr_items ")
69
- size = line.split[2].to_i
70
- end
71
- end
72
- { hits: @hits, misses: @misses, size: size, backend: "memcached" }
126
+ now = Time.now.to_f
127
+ # Drop the expired ones so the log cannot grow without bound.
128
+ @own = (@own || {}).select { |_k, expires| expires.zero? || expires > now }
129
+ { hits: @hits, misses: @misses, size: @own.size, backend: "memcached" }
73
130
  end
74
131
 
75
132
  def name
@@ -78,8 +135,62 @@ module Tina4
78
135
 
79
136
  private
80
137
 
138
+ # Read the SHARED namespace generation from the server.
139
+ #
140
+ # memcached has no KEYS scan and no prefix delete, so the only way to
141
+ # invalidate globally without destroying other tenants is the documented
142
+ # namespace idiom: every real key carries a generation, and clear bumps
143
+ # it. Every instance then computes a different key and the old entries
144
+ # become unreachable at once, expiring under the server's own TTL/LRU.
145
+ #
146
+ # The generation is read from the server on every operation, deliberately.
147
+ # Caching it in-process would reintroduce exactly the bug this fixes: an
148
+ # instance holding a stale generation keeps computing the OLD key, and the
149
+ # old key still holds the old value, so it serves a stale hit after
150
+ # another instance cleared. One extra round trip on a sub-millisecond
151
+ # local service is the price of cross-instance invalidation.
152
+ def generation
153
+ resp = command("get #{GENERATION_KEY}\r\n", "END\r\n")
154
+ if resp.start_with?("VALUE")
155
+ begin
156
+ header, rest = resp.split("\r\n", 2)
157
+ nbytes = header.split[3].to_i
158
+ return rest[0, nbytes] if rest
159
+ rescue StandardError
160
+ nil
161
+ end
162
+ end
163
+ "0"
164
+ end
165
+
166
+ # Hash to a safe, bounded key (memcached keys: no spaces/control, <= 250
167
+ # chars). The generation sits IN the key so a clear on ANY instance
168
+ # orphans it for every instance at once.
81
169
  def mc_key(key)
82
- PREFIX + Digest::SHA256.hexdigest(key)
170
+ "#{PREFIX}#{generation}:#{Digest::SHA256.hexdigest(key)}"
171
+ end
172
+
173
+ # Translate a Tina4 ttl (always RELATIVE seconds) into memcached's exptime
174
+ # field, which changes meaning at 30 days.
175
+ #
176
+ # Above MAX_RELATIVE_EXPTIME the server reads the field as an ABSOLUTE
177
+ # unix timestamp, so a raw ttl of, say, 60 days was read as a date in 1970
178
+ # and the entry vanished the instant it landed. memcached still answers
179
+ # STORED, so the symptom was a 100% miss rate with nothing logged - a
180
+ # cache that looks like it is working and never returns a hit.
181
+ #
182
+ # CONVERT, never CLAMP. Clamping a too-large ttl down to the cliff also
183
+ # makes the entry survive, and is ALSO wrong: it silently discards more
184
+ # than half the lifetime the operator explicitly configured, which is the
185
+ # same class of silent-wrong-answer as the bug it replaces.
186
+ #
187
+ # @param ttl [Integer] relative seconds; <= 0 means no expiry
188
+ # @return [Integer] the exptime field to send
189
+ def exptime(ttl)
190
+ return 0 if ttl <= 0
191
+ return Time.now.to_i + ttl if ttl > MAX_RELATIVE_EXPTIME
192
+
193
+ ttl
83
194
  end
84
195
 
85
196
  def command(payload, terminator)
@@ -71,6 +71,21 @@ module Tina4
71
71
  "memory"
72
72
  end
73
73
 
74
+ # Evict expired entries and return how many went.
75
+ #
76
+ # An entry stored with ttl <= 0 has expires_at nil - the no-expiry
77
+ # sentinel - and the nil check is what keeps a permanent entry off the
78
+ # eviction list. Dropping it would evict every permanent entry on the
79
+ # first sweep, silently, and report it as a successful reclaim.
80
+ def sweep
81
+ @mutex.synchronize do
82
+ now = monotonic
83
+ before = @store.size
84
+ @store.reject! { |_k, (_value, expires_at)| expires_at && now > expires_at }
85
+ before - @store.size
86
+ end
87
+ end
88
+
74
89
  private
75
90
 
76
91
  def monotonic
@@ -109,29 +109,40 @@ module Tina4
109
109
  end
110
110
  end
111
111
 
112
+ # Remove EVERY entry this cache can serve - on BOTH transports.
113
+ #
114
+ # The raw RESP path used to be an empty branch with a comment: "no easy
115
+ # pattern delete, rely on TTL". That made clear() a no-op on the
116
+ # ZERO-DEPENDENCY default install, so a write never invalidated the
117
+ # persistent DB query cache and every instance kept serving pre-write rows
118
+ # until the TTL ran out (ADR-0024 rule 4: the default provider counts
119
+ # double).
120
+ #
121
+ # SCAN, not KEYS: clear() runs on every write in persistent DB-cache mode,
122
+ # and KEYS is O(N) and blocks the whole server for its duration. Redis's
123
+ # own documentation says to prefer SCAN in production.
124
+ #
125
+ # The scan is scoped to our prefix, so another application sharing the
126
+ # server is untouched. FLUSHALL/FLUSHDB would take their data with it and
127
+ # is never used here.
112
128
  def clear
113
129
  @hits = 0
114
130
  @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
131
+ return scan_delete_with_client if @client
132
+ return unless @use_raw
133
+
134
+ scan_delete_with_raw_resp
124
135
  end
125
136
 
137
+ # Report OUR entries, counted with a scoped SCAN.
138
+ #
139
+ # size used to be a hardcoded 0 unless the `redis` GEM was loaded, so on
140
+ # the ZERO-DEPENDENCY raw RESP transport - the default install - stats
141
+ # reported 0 no matter how many entries were cached. Anything reading that
142
+ # number (a dashboard, db.cache_stats, an operator checking whether a
143
+ # clear worked) was reading a constant.
126
144
  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 }
145
+ { hits: @hits, misses: @misses, size: scan_count, backend: @name }
135
146
  end
136
147
 
137
148
  def name
@@ -173,58 +184,168 @@ module Tina4
173
184
  false
174
185
  end
175
186
 
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"
187
+ # Encode one command as a RESP array of bulk strings.
188
+ def resp_encode(*args)
189
+ out = +"*#{args.size}\r\n"
180
190
  args.each do |arg|
181
- s = arg.to_s
182
- cmd << "$#{s.bytesize}\r\n#{s}\r\n"
191
+ value = arg.to_s
192
+ out << "$#{value.bytesize}\r\n#{value}\r\n"
183
193
  end
194
+ out
195
+ end
184
196
 
197
+ # Read ONE complete RESP reply and return String | nil | Array.
198
+ #
199
+ # Reads through the socket's BUFFERED gets/read so a reply larger than one
200
+ # socket read is assembled in full. The previous implementation did a
201
+ # single recv(65536) and string-split the result, which silently truncated
202
+ # any bulk value or multi-bulk reply bigger than that buffer - so a large
203
+ # cached entry came back corrupt and a key scan came back short.
204
+ def resp_read(sock)
205
+ line = sock.gets("\r\n")
206
+ return nil if line.nil?
207
+
208
+ prefix = line[0]
209
+ body = line[1..].to_s.chomp
210
+ case prefix
211
+ when "+", ":" then body
212
+ when "-" then nil
213
+ when "$" then resp_read_bulk(sock, body.to_i)
214
+ when "*" then resp_read_array(sock, body.to_i)
215
+ else body
216
+ end
217
+ end
218
+
219
+ # A bulk string: exactly `length` bytes, then the trailing CRLF.
220
+ def resp_read_bulk(sock, length)
221
+ return nil if length.negative?
222
+
223
+ payload = sock.read(length + 2)
224
+ return nil if payload.nil?
225
+
226
+ payload[0, length].to_s.force_encoding(Encoding::UTF_8)
227
+ end
228
+
229
+ # A multi-bulk array: `count` complete replies, read recursively.
230
+ def resp_read_array(sock, count)
231
+ return nil if count.negative?
232
+
233
+ Array.new(count) { resp_read(sock) }
234
+ end
235
+
236
+ # Open an authenticated RESP socket. The caller closes it.
237
+ #
238
+ # Kept separate from resp_command so a multi-step conversation (the SCAN
239
+ # cursor loop in clear) runs over ONE connection instead of reconnecting
240
+ # per command.
241
+ def resp_session
185
242
  sock = TCPSocket.new(@host, @port)
243
+ # SO_RCVTIMEO bounds the raw syscalls; IO#timeout (Ruby 3.2+) is the one
244
+ # that bounds the BUFFERED gets/read this class now uses, so a server
245
+ # that accepts the connection and never replies cannot hang a request.
186
246
  sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
247
+ sock.timeout = 5 if sock.respond_to?(:timeout=)
187
248
 
188
249
  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?("+")
250
+ sock.write(@username ? resp_encode("AUTH", @username, @password) : resp_encode("AUTH", @password))
251
+ unless resp_read(sock) == "OK"
197
252
  sock.close
198
- return nil
253
+ raise IOError, "redis AUTH rejected"
199
254
  end
200
255
  end
201
256
 
202
257
  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)
258
+ sock.write(resp_encode("SELECT", @db))
259
+ resp_read(sock)
206
260
  end
261
+ sock
262
+ end
207
263
 
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")
264
+ # Send one command using the raw RESP protocol over TCP. Returns the
265
+ # decoded reply: a String for simple/bulk/integer replies, nil for a nil
266
+ # reply or an error, and an Array for a multi-bulk reply.
267
+ def resp_command(*args)
268
+ sock = nil
269
+ begin
270
+ sock = resp_session
271
+ sock.write(resp_encode(*args))
272
+ resp_read(sock)
273
+ rescue StandardError
217
274
  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?("-")
275
+ ensure
276
+ close_quietly(sock)
277
+ end
278
+ end
279
+
280
+ # Walk every key under our PREFIX with a scoped SCAN cursor loop over ONE
281
+ # raw RESP connection, yielding each non-empty batch along with the open
282
+ # socket so a caller can act on the batch without reconnecting.
283
+ #
284
+ # SCAN, not KEYS: clear runs on every write in persistent DB-cache mode,
285
+ # and KEYS is O(N) and blocks the whole server for its duration. Redis's
286
+ # own documentation says to prefer SCAN.
287
+ def each_prefixed_key_batch
288
+ sock = nil
289
+ begin
290
+ sock = resp_session
291
+ cursor = "0"
292
+ loop do
293
+ sock.write(resp_encode("SCAN", cursor, "MATCH", "#{PREFIX}*", "COUNT", "500"))
294
+ reply = resp_read(sock)
295
+ break unless reply.is_a?(Array) && reply.size == 2
296
+
297
+ cursor, keys = reply
298
+ yield(sock, keys) if keys.is_a?(Array) && !keys.empty?
299
+ break if cursor.nil? || cursor == "0"
300
+ end
301
+ rescue StandardError
224
302
  nil
225
- else
226
- response.strip
303
+ ensure
304
+ close_quietly(sock)
227
305
  end
306
+ end
307
+
308
+ # The same scoped walk over the `redis` gem client. scan(), not keys(),
309
+ # for the reason above.
310
+ def each_prefixed_key_batch_with_client
311
+ cursor = "0"
312
+ loop do
313
+ cursor, keys = @client.scan(cursor, match: "#{PREFIX}*", count: 500)
314
+ yield(keys) if keys && !keys.empty?
315
+ break if cursor.nil? || cursor.to_s == "0"
316
+ end
317
+ rescue StandardError
318
+ nil
319
+ end
320
+
321
+ def scan_delete_with_client
322
+ each_prefixed_key_batch_with_client { |keys| @client.del(*keys) }
323
+ end
324
+
325
+ def scan_delete_with_raw_resp
326
+ each_prefixed_key_batch do |sock, keys|
327
+ sock.write(resp_encode("DEL", *keys))
328
+ resp_read(sock)
329
+ end
330
+ end
331
+
332
+ def scan_count
333
+ return scan_count_with_client if @client
334
+ return 0 unless @use_raw
335
+
336
+ total = 0
337
+ each_prefixed_key_batch { |_sock, keys| total += keys.size }
338
+ total
339
+ end
340
+
341
+ def scan_count_with_client
342
+ total = 0
343
+ each_prefixed_key_batch_with_client { |keys| total += keys.size }
344
+ total
345
+ end
346
+
347
+ def close_quietly(sock)
348
+ sock&.close
228
349
  rescue StandardError
229
350
  nil
230
351
  end
@@ -69,8 +69,17 @@ module Tina4
69
69
  when "file"
70
70
  dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
71
71
  return FileBackend.new(cache_dir: dir, max_entries: max_entries)
72
- else
72
+ when "memory", ""
73
73
  return MemoryBackend.new(max_entries: max_entries)
74
+ else
75
+ # An UNRECOGNISED name RAISES, naming the bad value and the valid
76
+ # ones -- the contract the session layer already settled on. Falling
77
+ # through to memory turned a typo (TINA4_CACHE_BACKEND=redsi) into a
78
+ # running app with a per-process cache while the operator believed it
79
+ # was Redis.
80
+ raise ArgumentError,
81
+ "Unknown cache backend '#{backend}'. Valid backends: " \
82
+ "memory, file, redis, valkey, memcached, mongodb, database."
74
83
  end
75
84
 
76
85
  return be if be.available?
data/lib/tina4/cli.rb CHANGED
@@ -529,36 +529,14 @@ module Tina4
529
529
 
530
530
  # Use Puma only when explicitly requested via --production flag
531
531
  # WEBrick is used for development (supports dev toolbar/reload)
532
- if options[:production]
533
- begin
534
- require "puma"
535
- require "puma/configuration"
536
- require "puma/launcher"
537
-
538
- puma_host = options[:host]
539
- puma_port = options[:port]
540
-
541
- config = Puma::Configuration.new do |user_config|
542
- user_config.bind "tcp://#{puma_host}:#{puma_port}"
543
- user_config.app app
544
- user_config.threads 0, 16
545
- user_config.workers 0
546
- user_config.environment "production"
547
- user_config.log_requests false
548
- user_config.quiet
549
- end
550
-
551
- Tina4::Log.info("Production server: puma")
552
-
553
- # Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
554
- Tina4::Shutdown.setup
555
-
556
- launcher = Puma::Launcher.new(config)
557
- launcher.run
558
- return
559
- rescue LoadError
560
- # Puma not installed, fall through to WEBrick
561
- end
532
+ # Same production server, same shutdown contract as Tina4.run! - one
533
+ # implementation, so the two entry points cannot drift again (the old
534
+ # copy here claimed "we handle DB cleanup" and did no cleanup at all).
535
+ # TINA4_DEFAULT_WEBSERVER=TRUE pins the built-in server even with
536
+ # --production.
537
+ if options[:production] && !Tina4.builtin_webserver_pinned? && Tina4.puma_available?
538
+ Tina4.start_puma_server(app, host: options[:host], port: options[:port])
539
+ return
562
540
  end
563
541
 
564
542
  Tina4::Log.info("Development server: WEBrick")
@@ -1145,18 +1123,22 @@ module Tina4
1145
1123
  exit 2
1146
1124
  end
1147
1125
 
1148
- result = Tina4::Metrics.offenders(path, top)
1149
- summary = result["summary"]
1150
- found = result["offenders"]
1151
-
1152
- if summary.key?("error")
1153
- puts " metrics error: #{summary['error']}"
1126
+ # ONE engine run. Ask for every offender and slice for display: the gate
1127
+ # must read the FULL set, not the printed top-N, and the old second call
1128
+ # re-ran the whole analysis (its "full_analysis is cached" comment stopped
1129
+ # being true when the in-process analyzer and its cache were deleted).
1130
+ # An Integer, not Float::INFINITY -- Array#first demands an Integer.
1131
+ every_offender = 2**31
1132
+ begin
1133
+ result = Tina4::Metrics.offenders(path, every_offender)
1134
+ rescue Tina4::MetricsEngineError => e
1135
+ warn " metrics error: #{e.message}"
1154
1136
  exit 2
1155
1137
  end
1138
+ summary = result["summary"]
1139
+ all_offenders = result["offenders"]
1140
+ found = all_offenders.first(top)
1156
1141
 
1157
- # Decide exit code from the FULL offender set, not just the printed top-N.
1158
- # full_analysis is cached, so this reuses the same analysis.
1159
- all_offenders = Tina4::Metrics.offenders(path, [summary["total_offenders"], 1].max)["offenders"]
1160
1142
  severities = all_offenders.map { |o| o["severity"] }.to_set
1161
1143
  exit_code = 0
1162
1144
  if fail_on == "warn" && !(severities & %w[warn error]).empty?
@@ -3177,6 +3159,7 @@ module Tina4
3177
3159
  .keys/
3178
3160
  logs/
3179
3161
  sessions/
3162
+ data/queue/
3180
3163
  .queue/
3181
3164
  *.db
3182
3165
  vendor/
@@ -3249,6 +3232,7 @@ module Tina4
3249
3232
  .keys/
3250
3233
  logs/
3251
3234
  sessions/
3235
+ data/queue/
3252
3236
  .queue/
3253
3237
  *.db
3254
3238
  *.gem