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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- 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
|
-
|
|
50
|
-
payload = "set #{
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
253
|
+
raise IOError, "redis AUTH rejected"
|
|
199
254
|
end
|
|
200
255
|
end
|
|
201
256
|
|
|
202
257
|
if @db != 0
|
|
203
|
-
|
|
204
|
-
sock
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
data/lib/tina4/cache_backends.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|