tina4ruby 3.12.2 → 3.12.3
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/ai.rb +8 -1
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/response_cache.rb +172 -94
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +8 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02f359671ab67c2f4d7f3202e8e02f6fcd432690ce034d5951cb84d7c0c297c5
|
|
4
|
+
data.tar.gz: de61dd0e21ad0c9e979a6049b67d3bba6acfe34ada9540564230a352a71927cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44af7a12ed2e7cc6175f59549a4984cc24c2e6a411493fc03e7ec7e742179fb162aaea3d85f89ff48989a018f2174b77a7eb12073f05f665fa705784bc698df5
|
|
7
|
+
data.tar.gz: 677420505af7fbbbe8f5606dd563fbcce96fecb16757ff4d952fe5155b19defbf992d5e86c8bcf9b7449f5082bfcaf742551cb3c3bc92a5312d69e9269b0c31e
|
data/lib/tina4/ai.rb
CHANGED
|
@@ -212,11 +212,18 @@ module Tina4
|
|
|
212
212
|
next unless system("which #{cmd} > /dev/null 2>&1")
|
|
213
213
|
|
|
214
214
|
result = `#{cmd} install --upgrade tina4-ai 2>&1`
|
|
215
|
+
# Subprocess output is ASCII-8BIT — force UTF-8 with byte replacement
|
|
216
|
+
# so non-ASCII content (often emitted by pip on locale mismatch)
|
|
217
|
+
# doesn't crash String#strip with Encoding::CompatibilityError.
|
|
218
|
+
safe_result = result.dup.force_encoding("UTF-8")
|
|
219
|
+
unless safe_result.valid_encoding?
|
|
220
|
+
safe_result = safe_result.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
221
|
+
end
|
|
215
222
|
if $?.success?
|
|
216
223
|
puts " \e[32m✓\e[0m Installed tina4-ai (mdview)"
|
|
217
224
|
return
|
|
218
225
|
else
|
|
219
|
-
puts " \e[33m!\e[0m #{cmd} failed: #{
|
|
226
|
+
puts " \e[33m!\e[0m #{cmd} failed: #{safe_result.strip[0..100]}"
|
|
220
227
|
end
|
|
221
228
|
end
|
|
222
229
|
puts " \e[33m!\e[0m Python/pip not available -- skip tina4-ai"
|
data/lib/tina4/container.rb
CHANGED
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
# Multi-backend response cache for GET requests.
|
|
5
5
|
#
|
|
6
|
+
# Public surface (parity with Python tina4_python.cache):
|
|
7
|
+
# - Tina4::ResponseCache — middleware class
|
|
8
|
+
# - Tina4.cache_stats — module function returning cache stats
|
|
9
|
+
# - Tina4.clear_cache — module function flushing all cached entries
|
|
10
|
+
# - Tina4.cache_get / cache_set / cache_delete — module-level KV API
|
|
11
|
+
#
|
|
12
|
+
# The internal lookup/store of GET responses is performed by the middleware
|
|
13
|
+
# hooks (before_cache, after_cache) and is NOT exposed publicly. Use the
|
|
14
|
+
# middleware by attaching ResponseCache to your route, not by calling
|
|
15
|
+
# the (private) internal_lookup / internal_store directly.
|
|
16
|
+
#
|
|
6
17
|
# Backends are selected via the TINA4_CACHE_BACKEND env var:
|
|
7
18
|
# memory — in-process LRU cache (default, zero deps)
|
|
8
19
|
# redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
|
|
@@ -14,16 +25,6 @@ module Tina4
|
|
|
14
25
|
# TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
15
26
|
# TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
|
|
16
27
|
#
|
|
17
|
-
# Usage:
|
|
18
|
-
# cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
|
|
19
|
-
# cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
|
|
20
|
-
# hit = cache.get("GET", "/api/users")
|
|
21
|
-
#
|
|
22
|
-
# # Direct API (same across all 4 languages)
|
|
23
|
-
# cache.cache_set("key", {"data" => "value"}, ttl: 120)
|
|
24
|
-
# value = cache.cache_get("key")
|
|
25
|
-
# cache.cache_delete("key")
|
|
26
|
-
#
|
|
27
28
|
class ResponseCache
|
|
28
29
|
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
|
|
29
30
|
|
|
@@ -57,94 +58,75 @@ module Tina4
|
|
|
57
58
|
@ttl > 0
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# @param method [String]
|
|
63
|
-
# @param url [String]
|
|
64
|
-
# @return [String]
|
|
65
|
-
def cache_key(method, url)
|
|
66
|
-
"#{method}:#{url}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Retrieve a cached response. Returns nil on miss or expired entry.
|
|
70
|
-
#
|
|
71
|
-
# @param method [String]
|
|
72
|
-
# @param url [String]
|
|
73
|
-
# @return [CacheEntry, nil]
|
|
74
|
-
def get(method, url)
|
|
75
|
-
return nil unless enabled?
|
|
76
|
-
return nil unless method == "GET"
|
|
61
|
+
# ── Middleware hooks ────────────────────────────────────────────
|
|
77
62
|
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
# Middleware hook — checks for a cached entry before the route handler runs.
|
|
64
|
+
# If a cached entry exists for this GET request, short-circuits by replacing
|
|
65
|
+
# the response. Otherwise tags the request so after_cache can capture the
|
|
66
|
+
# response.
|
|
67
|
+
def before_cache(request, response)
|
|
68
|
+
return [request, response] unless enabled?
|
|
80
69
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return nil
|
|
84
|
-
end
|
|
70
|
+
method = (request.respond_to?(:method) ? request.method : "GET").to_s.upcase
|
|
71
|
+
return [request, response] unless method == "GET"
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
73
|
+
url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
|
|
74
|
+
hit = internal_lookup(method, url)
|
|
75
|
+
if hit
|
|
76
|
+
if response.respond_to?(:call)
|
|
77
|
+
new_response = response.call(hit.body, hit.status_code, hit.content_type)
|
|
78
|
+
return [request, new_response]
|
|
92
79
|
end
|
|
93
|
-
@mutex.synchronize { @hits += 1 }
|
|
94
|
-
entry
|
|
95
|
-
elsif entry.is_a?(Hash)
|
|
96
|
-
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
97
|
-
if Time.now.to_f > expires_at
|
|
98
|
-
backend_delete(key)
|
|
99
|
-
@mutex.synchronize { @misses += 1 }
|
|
100
|
-
return nil
|
|
101
|
-
end
|
|
102
|
-
@mutex.synchronize { @hits += 1 }
|
|
103
|
-
CacheEntry.new(
|
|
104
|
-
entry["body"] || entry[:body],
|
|
105
|
-
entry["content_type"] || entry[:content_type],
|
|
106
|
-
entry["status_code"] || entry[:status_code],
|
|
107
|
-
expires_at
|
|
108
|
-
)
|
|
109
80
|
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Store a response in the cache.
|
|
113
|
-
#
|
|
114
|
-
# @param method [String]
|
|
115
|
-
# @param url [String]
|
|
116
|
-
# @param status_code [Integer]
|
|
117
|
-
# @param content_type [String]
|
|
118
|
-
# @param body [String]
|
|
119
|
-
# @param ttl [Integer, nil] override default TTL
|
|
120
|
-
def cache_response(method, url, status_code, content_type, body, ttl: nil)
|
|
121
|
-
return unless enabled?
|
|
122
|
-
return unless method == "GET"
|
|
123
|
-
return unless @status_codes.include?(status_code)
|
|
124
|
-
|
|
125
|
-
effective_ttl = ttl || @ttl
|
|
126
|
-
key = cache_key(method, url)
|
|
127
|
-
expires_at = Time.now.to_f + effective_ttl
|
|
128
|
-
|
|
129
|
-
entry_data = {
|
|
130
|
-
"body" => body,
|
|
131
|
-
"content_type" => content_type,
|
|
132
|
-
"status_code" => status_code,
|
|
133
|
-
"expires_at" => expires_at
|
|
134
|
-
}
|
|
135
81
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
oldest_key = @store.keys.first
|
|
141
|
-
@store.delete(oldest_key)
|
|
142
|
-
end
|
|
143
|
-
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
144
|
-
end
|
|
82
|
+
# Tag for after_cache
|
|
83
|
+
if request.respond_to?(:[]=)
|
|
84
|
+
request[:_cache_method] = method
|
|
85
|
+
request[:_cache_url] = url
|
|
145
86
|
else
|
|
146
|
-
|
|
87
|
+
request.instance_variable_set(:@_cache_method, method)
|
|
88
|
+
request.instance_variable_set(:@_cache_url, url)
|
|
147
89
|
end
|
|
90
|
+
[request, response]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Middleware hook — captures the response body and stores it after the
|
|
94
|
+
# route handler runs.
|
|
95
|
+
def after_cache(request, response)
|
|
96
|
+
return [request, response] unless enabled?
|
|
97
|
+
|
|
98
|
+
method = if request.respond_to?(:[])
|
|
99
|
+
request[:_cache_method]
|
|
100
|
+
else
|
|
101
|
+
request.instance_variable_get(:@_cache_method)
|
|
102
|
+
end
|
|
103
|
+
url = if request.respond_to?(:[])
|
|
104
|
+
request[:_cache_url]
|
|
105
|
+
else
|
|
106
|
+
request.instance_variable_get(:@_cache_url)
|
|
107
|
+
end
|
|
108
|
+
return [request, response] if method.nil? || url.nil?
|
|
109
|
+
|
|
110
|
+
status = if response.respond_to?(:status_code)
|
|
111
|
+
response.status_code
|
|
112
|
+
elsif response.respond_to?(:status)
|
|
113
|
+
response.status
|
|
114
|
+
else
|
|
115
|
+
200
|
|
116
|
+
end
|
|
117
|
+
content_type = if response.respond_to?(:content_type)
|
|
118
|
+
response.content_type
|
|
119
|
+
else
|
|
120
|
+
"application/json"
|
|
121
|
+
end
|
|
122
|
+
body = if response.respond_to?(:body)
|
|
123
|
+
response.body.to_s
|
|
124
|
+
else
|
|
125
|
+
response.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
internal_store(method, url, status.to_i, content_type.to_s, body)
|
|
129
|
+
[request, response]
|
|
148
130
|
end
|
|
149
131
|
|
|
150
132
|
# ── Direct Cache API (same across all 4 languages) ──────────
|
|
@@ -297,8 +279,95 @@ module Tina4
|
|
|
297
279
|
@backend_name
|
|
298
280
|
end
|
|
299
281
|
|
|
282
|
+
# @internal Test seam — exercises the same path the middleware uses.
|
|
283
|
+
# Public for parity tests only; do not use in application code.
|
|
284
|
+
def _internal_lookup(method, url)
|
|
285
|
+
internal_lookup(method, url)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @internal Test seam — exercises the same path the middleware uses.
|
|
289
|
+
# Public for parity tests only; do not use in application code.
|
|
290
|
+
def _internal_store(method, url, status_code, content_type, body, ttl: nil)
|
|
291
|
+
internal_store(method, url, status_code, content_type, body, ttl: ttl)
|
|
292
|
+
end
|
|
293
|
+
|
|
300
294
|
private
|
|
301
295
|
|
|
296
|
+
# Build a cache key from method and URL.
|
|
297
|
+
def cache_key(method, url)
|
|
298
|
+
"#{method}:#{url}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Internal: retrieve a cached response. Used by middleware hooks only.
|
|
302
|
+
def internal_lookup(method, url)
|
|
303
|
+
return nil unless enabled?
|
|
304
|
+
return nil unless method == "GET"
|
|
305
|
+
|
|
306
|
+
key = cache_key(method, url)
|
|
307
|
+
entry = backend_get(key)
|
|
308
|
+
|
|
309
|
+
if entry.nil?
|
|
310
|
+
@mutex.synchronize { @misses += 1 }
|
|
311
|
+
return nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# For memory backend, entry is a CacheEntry; for others, reconstruct
|
|
315
|
+
if entry.is_a?(CacheEntry)
|
|
316
|
+
if Time.now.to_f > entry.expires_at
|
|
317
|
+
backend_delete(key)
|
|
318
|
+
@mutex.synchronize { @misses += 1 }
|
|
319
|
+
return nil
|
|
320
|
+
end
|
|
321
|
+
@mutex.synchronize { @hits += 1 }
|
|
322
|
+
entry
|
|
323
|
+
elsif entry.is_a?(Hash)
|
|
324
|
+
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
325
|
+
if Time.now.to_f > expires_at
|
|
326
|
+
backend_delete(key)
|
|
327
|
+
@mutex.synchronize { @misses += 1 }
|
|
328
|
+
return nil
|
|
329
|
+
end
|
|
330
|
+
@mutex.synchronize { @hits += 1 }
|
|
331
|
+
CacheEntry.new(
|
|
332
|
+
entry["body"] || entry[:body],
|
|
333
|
+
entry["content_type"] || entry[:content_type],
|
|
334
|
+
entry["status_code"] || entry[:status_code],
|
|
335
|
+
expires_at
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Internal: store a response in the cache. Used by middleware hooks only.
|
|
341
|
+
def internal_store(method, url, status_code, content_type, body, ttl: nil)
|
|
342
|
+
return unless enabled?
|
|
343
|
+
return unless method == "GET"
|
|
344
|
+
return unless @status_codes.include?(status_code)
|
|
345
|
+
|
|
346
|
+
effective_ttl = ttl || @ttl
|
|
347
|
+
key = cache_key(method, url)
|
|
348
|
+
expires_at = Time.now.to_f + effective_ttl
|
|
349
|
+
|
|
350
|
+
entry_data = {
|
|
351
|
+
"body" => body,
|
|
352
|
+
"content_type" => content_type,
|
|
353
|
+
"status_code" => status_code,
|
|
354
|
+
"expires_at" => expires_at
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case @backend_name
|
|
358
|
+
when "memory"
|
|
359
|
+
@mutex.synchronize do
|
|
360
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
361
|
+
oldest_key = @store.keys.first
|
|
362
|
+
@store.delete(oldest_key)
|
|
363
|
+
end
|
|
364
|
+
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
365
|
+
end
|
|
366
|
+
else
|
|
367
|
+
backend_set(key, entry_data, effective_ttl)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
302
371
|
# ── Backend initialization ─────────────────────────────────
|
|
303
372
|
|
|
304
373
|
def init_backend
|
|
@@ -519,15 +588,17 @@ module Tina4
|
|
|
519
588
|
end
|
|
520
589
|
end
|
|
521
590
|
|
|
522
|
-
# ── Module-level convenience (singleton)
|
|
591
|
+
# ── Module-level convenience (singleton, parity with Python) ───
|
|
523
592
|
|
|
524
593
|
@default_cache = nil
|
|
525
594
|
|
|
526
595
|
class << self
|
|
596
|
+
# Lazy module-level singleton for cache_stats / clear_cache.
|
|
527
597
|
def cache_instance
|
|
528
598
|
@default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
|
|
529
599
|
end
|
|
530
600
|
|
|
601
|
+
# Module-level KV API (parity with Python tina4_python.cache).
|
|
531
602
|
def cache_get(key)
|
|
532
603
|
cache_instance.cache_get(key)
|
|
533
604
|
end
|
|
@@ -540,12 +611,19 @@ module Tina4
|
|
|
540
611
|
cache_instance.cache_delete(key)
|
|
541
612
|
end
|
|
542
613
|
|
|
543
|
-
|
|
614
|
+
# Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
|
|
615
|
+
def cache_stats
|
|
616
|
+
cache_instance.cache_stats
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
|
|
620
|
+
def clear_cache
|
|
544
621
|
cache_instance.clear_cache
|
|
545
622
|
end
|
|
546
623
|
|
|
547
|
-
|
|
548
|
-
|
|
624
|
+
# Backward-compat alias for cache_clear (deprecated — use clear_cache).
|
|
625
|
+
def cache_clear
|
|
626
|
+
cache_instance.clear_cache
|
|
549
627
|
end
|
|
550
628
|
end
|
|
551
629
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -105,12 +105,16 @@ module Tina4
|
|
|
105
105
|
|
|
106
106
|
# ── Rooms ──────────────────────────────────────────────────
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
# Internal: add connection ID to a room (called by WebSocketConnection#join_room).
|
|
109
|
+
# Mirrors Python's WebSocketManager._join_room — not part of the public API.
|
|
110
|
+
def _join_room(conn_id, room_name)
|
|
109
111
|
@rooms[room_name] ||= Set.new
|
|
110
112
|
@rooms[room_name].add(conn_id)
|
|
111
113
|
end
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
# Internal: remove connection ID from a room (called by WebSocketConnection#leave_room).
|
|
116
|
+
# Mirrors Python's WebSocketManager._leave_room — not part of the public API.
|
|
117
|
+
def _leave_room(conn_id, room_name)
|
|
114
118
|
@rooms[room_name]&.delete(conn_id)
|
|
115
119
|
end
|
|
116
120
|
|
|
@@ -251,12 +255,12 @@ module Tina4
|
|
|
251
255
|
|
|
252
256
|
def join_room(room_name)
|
|
253
257
|
@rooms.add(room_name)
|
|
254
|
-
@ws_server&.
|
|
258
|
+
@ws_server&._join_room(@id, room_name)
|
|
255
259
|
end
|
|
256
260
|
|
|
257
261
|
def leave_room(room_name)
|
|
258
262
|
@rooms.delete(room_name)
|
|
259
|
-
@ws_server&.
|
|
263
|
+
@ws_server&._leave_room(@id, room_name)
|
|
260
264
|
end
|
|
261
265
|
|
|
262
266
|
def broadcast_to_room(room_name, message, exclude_self: false)
|