tina4ruby 3.13.93 → 3.13.96
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 +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- 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/dev_mailbox.rb +5 -1
- 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 +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- 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 +317 -82
- 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 +194 -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 +243 -29
- 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 +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module SessionHandlers
|
|
7
|
+
# A MongoDB command that came back with `ok != 1`, or a transport failure,
|
|
8
|
+
# surfaced as an exception so the handler can tell a real error apart from a
|
|
9
|
+
# genuine miss (an empty firstBatch). Same split RespError makes for RESP.
|
|
10
|
+
class MongoWireError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Zero-dependency synchronous MongoDB client speaking the wire protocol
|
|
13
|
+
# (OP_MSG, opcode 2013) over a TCP socket, with a minimal BSON codec.
|
|
14
|
+
#
|
|
15
|
+
# WHY IT EXISTS (session_contract.json #6, ADR-0024). Python, PHP and Node
|
|
16
|
+
# have always shipped a raw OP_MSG fallback for the mongodb SESSION backend;
|
|
17
|
+
# Ruby alone hard-required the `mongo` gem and RAISED without it. The same
|
|
18
|
+
# .env therefore worked in three frameworks and failed in the fourth, which
|
|
19
|
+
# breaks the zero-dependency promise ASYMMETRICALLY - worse than breaking it
|
|
20
|
+
# consistently, because nothing about the configuration hints at it. This is
|
|
21
|
+
# the missing fourth transport, ported from the proven Python master
|
|
22
|
+
# (tina4_python/session_handlers/mongodb_handler.py) rather than invented.
|
|
23
|
+
#
|
|
24
|
+
# It is deliberately NOT a general Mongo driver. It implements exactly the
|
|
25
|
+
# four operations the session handler performs - find_one by _id, upsert by
|
|
26
|
+
# _id, delete_one by _id, and delete_many for gc - and it mirrors the SHAPE
|
|
27
|
+
# of the `mongo` gem's Collection for those four so the handler's read,
|
|
28
|
+
# write, destroy and gc bodies are IDENTICAL on both transports. That is not
|
|
29
|
+
# cosmetic: a branch inside each operation is a branch that can be wrong on
|
|
30
|
+
# one path only, and this fallback would then ship untested behind a
|
|
31
|
+
# condition nobody exercises.
|
|
32
|
+
#
|
|
33
|
+
# HARD-WON DETAILS, preserved from the three siblings:
|
|
34
|
+
# * The COMMAND NAME comes FIRST in the document and `$db` LAST. Reversed,
|
|
35
|
+
# the server reads `$db` as the command name and answers CommandNotFound.
|
|
36
|
+
# * BSON is little-endian throughout, string lengths are BYTE counts (not
|
|
37
|
+
# character counts), and a Time is encoded as BSON UTC datetime (0x09,
|
|
38
|
+
# int64 milliseconds) so a document written here is byte-identical in
|
|
39
|
+
# shape to one the gem writes.
|
|
40
|
+
# * An OP_MSG reply can arrive split across TCP segments, so the body is
|
|
41
|
+
# read by its exact declared byte count rather than one recv().
|
|
42
|
+
#
|
|
43
|
+
# NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4): the
|
|
44
|
+
# socket is opened on the FIRST command, inside the log-loud-and-degrade
|
|
45
|
+
# policy, never here.
|
|
46
|
+
class MongoWireClient
|
|
47
|
+
OP_MSG = 2013
|
|
48
|
+
|
|
49
|
+
# BSON element type bytes, named so the codec reads as the spec does.
|
|
50
|
+
TYPE_DOUBLE = 0x01
|
|
51
|
+
TYPE_STRING = 0x02
|
|
52
|
+
TYPE_DOCUMENT = 0x03
|
|
53
|
+
TYPE_ARRAY = 0x04
|
|
54
|
+
TYPE_BINARY = 0x05
|
|
55
|
+
TYPE_OBJECT_ID = 0x07
|
|
56
|
+
TYPE_BOOLEAN = 0x08
|
|
57
|
+
TYPE_DATETIME = 0x09
|
|
58
|
+
TYPE_NULL = 0x0A
|
|
59
|
+
TYPE_INT32 = 0x10
|
|
60
|
+
TYPE_TIMESTAMP = 0x11
|
|
61
|
+
TYPE_INT64 = 0x12
|
|
62
|
+
|
|
63
|
+
INT32_MIN = -2_147_483_648
|
|
64
|
+
INT32_MAX = 2_147_483_647
|
|
65
|
+
|
|
66
|
+
def initialize(host:, port:, database:, collection:, timeout: 10)
|
|
67
|
+
@host = host
|
|
68
|
+
@port = port
|
|
69
|
+
@database = database
|
|
70
|
+
@collection = collection
|
|
71
|
+
@timeout = timeout
|
|
72
|
+
@socket = nil
|
|
73
|
+
@request_id = 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Documents matching +filter+, capped at one.
|
|
77
|
+
#
|
|
78
|
+
# Returns an ARRAY so the caller can write `find(...).first` exactly as it
|
|
79
|
+
# does against the gem's Collection, which answers a lazy view. One shared
|
|
80
|
+
# call site, two transports.
|
|
81
|
+
def find(filter)
|
|
82
|
+
reply = command("find" => @collection, "filter" => filter, "limit" => 1, "$db" => @database)
|
|
83
|
+
reply.dig("cursor", "firstBatch") || []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Apply +update+ (an operator document such as `{"$set" => {...}}`, or a
|
|
87
|
+
# full replacement) to the single document matching +filter+.
|
|
88
|
+
def update_one(filter, update, upsert: false)
|
|
89
|
+
command(
|
|
90
|
+
"update" => @collection,
|
|
91
|
+
"updates" => [{ "q" => filter, "u" => update, "upsert" => upsert }],
|
|
92
|
+
"$db" => @database
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_one(filter)
|
|
97
|
+
delete(filter, 1)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def delete_many(filter)
|
|
101
|
+
delete(filter, 0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def close
|
|
105
|
+
@socket&.close
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil # already closed / never opened - nothing to do
|
|
108
|
+
ensure
|
|
109
|
+
@socket = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def delete(filter, limit)
|
|
115
|
+
command(
|
|
116
|
+
"delete" => @collection,
|
|
117
|
+
"deletes" => [{ "q" => filter, "limit" => limit }],
|
|
118
|
+
"$db" => @database
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Send one OP_MSG command and return the decoded reply document.
|
|
123
|
+
#
|
|
124
|
+
# RAISES MongoWireError on a transport failure or on `ok != 1`, so the
|
|
125
|
+
# Session boundary can log loud and degrade. A genuine MISS is not a
|
|
126
|
+
# failure and comes back as an empty firstBatch, never as an exception -
|
|
127
|
+
# collapsing the two is how a dead backend silently logs everyone out.
|
|
128
|
+
#
|
|
129
|
+
# The `ok` check lives HERE, outside the transport, on purpose. Folding it
|
|
130
|
+
# into #exchange would need a `rescue MongoWireError; raise` guard to stop
|
|
131
|
+
# a command error being relabelled as a transport failure, and that guard
|
|
132
|
+
# would also skip the socket drop for a genuine half-read reply - leaving
|
|
133
|
+
# a poisoned socket for the next caller to trip over.
|
|
134
|
+
def command(document)
|
|
135
|
+
reply = exchange(document)
|
|
136
|
+
return reply if reply["ok"].to_f == 1.0
|
|
137
|
+
|
|
138
|
+
raise MongoWireError, "MongoDB command failed: #{reply["errmsg"] || reply.inspect}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One request, one reply, on the shared socket. ANY failure in here is a
|
|
142
|
+
# transport failure, so the socket is dropped and the next command
|
|
143
|
+
# reconnects rather than inheriting the damage.
|
|
144
|
+
def exchange(document)
|
|
145
|
+
socket = connection
|
|
146
|
+
@request_id += 1
|
|
147
|
+
body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
|
|
148
|
+
header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
|
|
149
|
+
socket.write(header + body)
|
|
150
|
+
read_reply(socket)
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
close
|
|
153
|
+
raise MongoWireError, "MongoDB transport failed: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def connection
|
|
157
|
+
@socket ||= begin
|
|
158
|
+
socket = Socket.tcp(@host, @port, connect_timeout: @timeout)
|
|
159
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [@timeout, 0].pack("l_2"))
|
|
160
|
+
socket
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def read_reply(socket)
|
|
165
|
+
header = read_exact(socket, 16)
|
|
166
|
+
message_length = header.unpack1("V")
|
|
167
|
+
payload = read_exact(socket, message_length - 16)
|
|
168
|
+
# OP_MSG payload: flagBits(4) + section kind(1), then the body document.
|
|
169
|
+
decode_document(payload, [5])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_exact(socket, count)
|
|
173
|
+
buffer = +"".b
|
|
174
|
+
while buffer.bytesize < count
|
|
175
|
+
chunk = socket.read(count - buffer.bytesize)
|
|
176
|
+
raise MongoWireError, "connection closed mid-reply" if chunk.nil? || chunk.empty?
|
|
177
|
+
|
|
178
|
+
buffer << chunk
|
|
179
|
+
end
|
|
180
|
+
buffer
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ── Minimal BSON encoder ────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def encode_document(document)
|
|
186
|
+
body = +"".b
|
|
187
|
+
document.each { |key, value| body << encode_element(key.to_s, value) }
|
|
188
|
+
body << "\x00".b
|
|
189
|
+
[body.bytesize + 4].pack("V") + body
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def encode_element(key, value)
|
|
193
|
+
ckey = key.b + "\x00".b
|
|
194
|
+
|
|
195
|
+
case value
|
|
196
|
+
when nil then TYPE_NULL.chr + ckey
|
|
197
|
+
when true, false then TYPE_BOOLEAN.chr + ckey + (value ? "\x01".b : "\x00".b)
|
|
198
|
+
when Integer then encode_integer(ckey, value)
|
|
199
|
+
when Float then TYPE_DOUBLE.chr + ckey + [value].pack("E")
|
|
200
|
+
when Time then TYPE_DATETIME.chr + ckey + [(value.to_f * 1000).round].pack("q<")
|
|
201
|
+
when Hash then TYPE_DOCUMENT.chr + ckey + encode_document(value)
|
|
202
|
+
when Array then TYPE_ARRAY.chr + ckey + encode_document(indexed(value))
|
|
203
|
+
else encode_string(ckey, value.to_s)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def encode_integer(ckey, value)
|
|
208
|
+
return TYPE_INT32.chr + ckey + [value].pack("l<") if value.between?(INT32_MIN, INT32_MAX)
|
|
209
|
+
|
|
210
|
+
TYPE_INT64.chr + ckey + [value].pack("q<")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A BSON string is a BYTE count followed by the bytes and a terminator, so
|
|
214
|
+
# the length must be bytesize - a UTF-8 payload counted in characters
|
|
215
|
+
# under-reports and the server rejects the document.
|
|
216
|
+
#
|
|
217
|
+
# This is also the catch-all for the encoder: a String, a Symbol and
|
|
218
|
+
# anything else this codec has no BSON type for all become strings, which
|
|
219
|
+
# is what Python, PHP and Node all do at the same point.
|
|
220
|
+
def encode_string(ckey, value)
|
|
221
|
+
bytes = value.b
|
|
222
|
+
TYPE_STRING.chr + ckey + [bytes.bytesize + 1].pack("V") + bytes + "\x00".b
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A BSON array is a document whose keys are the string indexes.
|
|
226
|
+
def indexed(array)
|
|
227
|
+
array.each_with_index.to_h { |value, index| [index.to_s, value] }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# ── Minimal BSON decoder ────────────────────────────────────────────
|
|
231
|
+
#
|
|
232
|
+
# +position+ is a single-element array used as a shared cursor, matching
|
|
233
|
+
# the Python master's `pos = [0]` - nested documents advance the one
|
|
234
|
+
# cursor rather than each level tracking its own offset.
|
|
235
|
+
|
|
236
|
+
def decode_document(data, position)
|
|
237
|
+
document_length = data[position[0], 4].unpack1("V")
|
|
238
|
+
position[0] += 4
|
|
239
|
+
finish = position[0] + document_length - 5
|
|
240
|
+
|
|
241
|
+
document = {}
|
|
242
|
+
while position[0] < finish
|
|
243
|
+
type = data.getbyte(position[0])
|
|
244
|
+
position[0] += 1
|
|
245
|
+
|
|
246
|
+
key_end = data.index("\x00".b, position[0])
|
|
247
|
+
key = data[position[0]...key_end].force_encoding(Encoding::UTF_8)
|
|
248
|
+
position[0] = key_end + 1
|
|
249
|
+
|
|
250
|
+
document[key] = decode_value(data, position, type, finish)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
position[0] += 1 # document terminator
|
|
254
|
+
document
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def decode_value(data, position, type, finish)
|
|
258
|
+
case type
|
|
259
|
+
when TYPE_DOUBLE then take(data, position, 8).unpack1("E")
|
|
260
|
+
when TYPE_STRING then decode_string(data, position)
|
|
261
|
+
when TYPE_DOCUMENT then decode_document(data, position)
|
|
262
|
+
when TYPE_ARRAY then decode_document(data, position).values
|
|
263
|
+
when TYPE_BINARY then decode_binary(data, position)
|
|
264
|
+
when TYPE_OBJECT_ID then take(data, position, 12).unpack1("H*")
|
|
265
|
+
when TYPE_BOOLEAN then take(data, position, 1).getbyte(0) != 0
|
|
266
|
+
when TYPE_DATETIME then Time.at(take(data, position, 8).unpack1("q<") / 1000.0)
|
|
267
|
+
when TYPE_NULL then nil
|
|
268
|
+
when TYPE_INT32 then take(data, position, 4).unpack1("l<")
|
|
269
|
+
when TYPE_TIMESTAMP then take(data, position, 8).unpack1("Q<")
|
|
270
|
+
when TYPE_INT64 then take(data, position, 8).unpack1("q<")
|
|
271
|
+
else
|
|
272
|
+
# An unknown type cannot be sized, so its length cannot be skipped.
|
|
273
|
+
# Stop at the document boundary rather than loop forever on a byte
|
|
274
|
+
# this codec does not understand.
|
|
275
|
+
position[0] = finish
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def decode_string(data, position)
|
|
281
|
+
length = take(data, position, 4).unpack1("V")
|
|
282
|
+
value = data[position[0], length - 1].force_encoding(Encoding::UTF_8)
|
|
283
|
+
position[0] += length
|
|
284
|
+
value
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def decode_binary(data, position)
|
|
288
|
+
length = take(data, position, 4).unpack1("V")
|
|
289
|
+
position[0] += 1 # subtype
|
|
290
|
+
take(data, position, length)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def take(data, position, count)
|
|
294
|
+
slice = data[position[0], count]
|
|
295
|
+
position[0] += count
|
|
296
|
+
slice
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -5,7 +5,9 @@ require_relative "resp_client"
|
|
|
5
5
|
module Tina4
|
|
6
6
|
module SessionHandlers
|
|
7
7
|
# Redis-backed session handler. Prefers the `redis` gem when it is installed
|
|
8
|
-
# (parity with
|
|
8
|
+
# (parity with Python, which prefers redis-py the same way, INSIDE this one
|
|
9
|
+
# handler rather than as a separate backend name — Node's `redis-npm`
|
|
10
|
+
# backend did the latter and was retired 2026-07-31 as drift); otherwise
|
|
9
11
|
# speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
|
|
10
12
|
# Tina4 app stores sessions in Redis with no extra gem.
|
|
11
13
|
class RedisHandler
|
|
@@ -14,8 +16,10 @@ module Tina4
|
|
|
14
16
|
# TINA4_SESSION_BACKEND=redis can actually be pointed at a server by env.
|
|
15
17
|
# An explicit constructor option always wins over the environment.
|
|
16
18
|
def initialize(options = {})
|
|
17
|
-
@prefix = options[:prefix] || "tina4:session:"
|
|
18
|
-
|
|
19
|
+
@prefix = options[:prefix] || ENV["TINA4_SESSION_REDIS_PREFIX"] || "tina4:session:"
|
|
20
|
+
# TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
|
|
21
|
+
# 86400. 3600 matches Python (the master), PHP and Node.
|
|
22
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
19
23
|
@host = options[:host] || ENV["TINA4_SESSION_REDIS_HOST"] || "localhost"
|
|
20
24
|
@port = options[:port] || (ENV["TINA4_SESSION_REDIS_PORT"] ? ENV["TINA4_SESSION_REDIS_PORT"].to_i : 6379)
|
|
21
25
|
@db = options[:db] || (ENV["TINA4_SESSION_REDIS_DB"] ? ENV["TINA4_SESSION_REDIS_DB"].to_i : 0)
|
|
@@ -33,13 +37,23 @@ module Tina4
|
|
|
33
37
|
nil
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
# Write session data. A per-call +ttl+ WINS over the handler default, so
|
|
41
|
+
# asking for a 60s session really gets 60s; 0 uses the default. Every
|
|
42
|
+
# handler in every Tina4 framework takes this third argument -- Session#write
|
|
43
|
+
# passes it, and a handler that did not accept it raised ArgumentError,
|
|
44
|
+
# which safe_write swallowed into a silent STALE write.
|
|
45
|
+
#
|
|
46
|
+
# @param session_id [String] the session id
|
|
47
|
+
# @param data [Hash] the payload to store
|
|
48
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
49
|
+
def write(session_id, data, ttl = 0)
|
|
37
50
|
key = "#{@prefix}#{session_id}"
|
|
38
51
|
payload = JSON.generate(data)
|
|
52
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
39
53
|
if @redis
|
|
40
|
-
@redis.setex(key,
|
|
54
|
+
@redis.setex(key, effective_ttl, payload)
|
|
41
55
|
else
|
|
42
|
-
@resp.setex(key,
|
|
56
|
+
@resp.setex(key, effective_ttl, payload)
|
|
43
57
|
end
|
|
44
58
|
end
|
|
45
59
|
|
|
@@ -11,7 +11,11 @@ module Tina4
|
|
|
11
11
|
class ValkeyHandler
|
|
12
12
|
def initialize(options = {})
|
|
13
13
|
@prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
|
|
14
|
-
|
|
14
|
+
# TINA4_SESSION_VALKEY_TTL stays as the valkey-specific override, but it
|
|
15
|
+
# now falls back to TINA4_SESSION_TTL - the ONE session-lifetime variable
|
|
16
|
+
# every backend must honour (ADR-0024) - instead of a hard-coded 86400.
|
|
17
|
+
# 3600 matches Python (the master), PHP and Node.
|
|
18
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_VALKEY_TTL"] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
15
19
|
@host = options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost"
|
|
16
20
|
@port = options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379)
|
|
17
21
|
@db = options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0)
|
|
@@ -29,13 +33,23 @@ module Tina4
|
|
|
29
33
|
nil
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
# Write session data. A per-call +ttl+ WINS over the handler default, so
|
|
37
|
+
# asking for a 60s session really gets 60s; 0 uses the default. Every
|
|
38
|
+
# handler in every Tina4 framework takes this third argument -- Session#write
|
|
39
|
+
# passes it, and a handler that did not accept it raised ArgumentError,
|
|
40
|
+
# which safe_write swallowed into a silent STALE write.
|
|
41
|
+
#
|
|
42
|
+
# @param session_id [String] the session id
|
|
43
|
+
# @param data [Hash] the payload to store
|
|
44
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
45
|
+
def write(session_id, data, ttl = 0)
|
|
33
46
|
key = "#{@prefix}#{session_id}"
|
|
34
47
|
payload = JSON.generate(data)
|
|
48
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
35
49
|
if @redis
|
|
36
|
-
@redis.setex(key,
|
|
50
|
+
@redis.setex(key, effective_ttl, payload)
|
|
37
51
|
else
|
|
38
|
-
@resp.setex(key,
|
|
52
|
+
@resp.setex(key, effective_ttl, payload)
|
|
39
53
|
end
|
|
40
54
|
end
|
|
41
55
|
|
data/lib/tina4/shutdown.rb
CHANGED
|
@@ -1,21 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tina4
|
|
4
|
+
# Graceful shutdown on SIGTERM / SIGINT.
|
|
5
|
+
#
|
|
6
|
+
# The order is the contract, identical in all four Tina4 frameworks:
|
|
7
|
+
#
|
|
8
|
+
# 1. Stop accepting FIRST. The listening socket closes before anything is
|
|
9
|
+
# drained, so a connection arriving after the signal gets a clean
|
|
10
|
+
# CONNECTION REFUSED - not a 503, not a TCP reset.
|
|
11
|
+
# 2. Tell live WebSocket peers we are going away (RFC 6455 close code 1001).
|
|
12
|
+
# 3. Drain in-flight requests, bounded by TINA4_SHUTDOWN_TIMEOUT.
|
|
13
|
+
# 4. Stop background tasks, close database connections, exit 0.
|
|
14
|
+
#
|
|
15
|
+
# SIGHUP is deliberately NOT trapped: the Rust CLI owns file watching and
|
|
16
|
+
# production logs go to stdout, so neither Puma's log-reopen nor gunicorn's
|
|
17
|
+
# config-reload use for SIGHUP is a Tina4 need.
|
|
4
18
|
module Shutdown
|
|
19
|
+
# Matches Kubernetes' default terminationGracePeriodSeconds and Gunicorn's
|
|
20
|
+
# graceful_timeout, and is the same default in Python, PHP and Node.
|
|
5
21
|
DEFAULT_TIMEOUT = 30 # seconds
|
|
6
22
|
|
|
7
23
|
class << self
|
|
8
24
|
attr_reader :in_flight_count
|
|
9
25
|
|
|
10
|
-
|
|
26
|
+
# The resolved TINA4_SHUTDOWN_TIMEOUT. Public because the production path
|
|
27
|
+
# does not drain in Ruby at all - it maps this onto Puma's own
|
|
28
|
+
# force_shutdown_after so the documented env var means the same thing
|
|
29
|
+
# whichever server owns the socket.
|
|
30
|
+
attr_reader :timeout
|
|
31
|
+
|
|
32
|
+
# trap_signals: false when another server owns INT/TERM (Puma does). A
|
|
33
|
+
# Tina4 trap on that path would be installed but never usefully serviced:
|
|
34
|
+
# there is no listener to close and nothing calls track_request, so if the
|
|
35
|
+
# other server's own trap install ever failed, ours would swallow the
|
|
36
|
+
# default terminate and do nothing - the process would survive the signal.
|
|
37
|
+
def setup(server: nil, timeout: nil, trap_signals: true)
|
|
11
38
|
@server = server
|
|
12
|
-
@timeout = (timeout
|
|
39
|
+
@timeout = resolve_timeout(timeout)
|
|
13
40
|
@shutting_down = false
|
|
41
|
+
@shutdown_complete = false
|
|
14
42
|
@mutex = Mutex.new
|
|
15
43
|
@in_flight_count = 0
|
|
16
44
|
@in_flight_cv = ConditionVariable.new
|
|
17
45
|
|
|
18
|
-
install_signal_handlers
|
|
46
|
+
install_signal_handlers if trap_signals
|
|
19
47
|
end
|
|
20
48
|
|
|
21
49
|
def shutting_down?
|
|
@@ -40,51 +68,173 @@ module Tina4
|
|
|
40
68
|
@shutting_down = true
|
|
41
69
|
Tina4::Log.info("Shutdown signal received, stopping gracefully...")
|
|
42
70
|
|
|
43
|
-
|
|
71
|
+
stop_accepting
|
|
72
|
+
drained = wait_for_in_flight
|
|
73
|
+
release_resources
|
|
74
|
+
|
|
75
|
+
Tina4::Log.info("Shutdown complete")
|
|
76
|
+
@shutdown_complete = true
|
|
77
|
+
return if drained
|
|
78
|
+
|
|
79
|
+
# The deadline expired with requests still in flight. WEBrick's accept
|
|
80
|
+
# loop joins its worker threads with NO timeout of its own, so simply
|
|
81
|
+
# returning here would hang the process for as long as the slowest
|
|
82
|
+
# handler runs - exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
|
|
83
|
+
# Flush first: exit! runs no at_exit handlers and does not flush stdio,
|
|
84
|
+
# which would swallow the warning that explains the forced exit.
|
|
85
|
+
$stdout.flush
|
|
86
|
+
$stderr.flush
|
|
87
|
+
exit!(0)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The teardown NO web server can do for us, because no web server knows
|
|
91
|
+
# these things exist: live WebSocket peers owed an RFC 6455 close frame,
|
|
92
|
+
# Tina4 background threads, and ORM-bound database connections.
|
|
93
|
+
#
|
|
94
|
+
# Public so the production path can run exactly the same teardown from an
|
|
95
|
+
# ensure around Puma's launcher: Puma owns the socket, the drain and the
|
|
96
|
+
# signals there, but a database connection it has never heard of would
|
|
97
|
+
# otherwise leak on every single shutdown.
|
|
98
|
+
def release_resources
|
|
99
|
+
close_websockets
|
|
100
|
+
stop_background_tasks
|
|
101
|
+
close_database
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Block until initiate_shutdown has finished every teardown step.
|
|
105
|
+
#
|
|
106
|
+
# stop_accepting unblocks WEBrick's accept loop immediately, so its #start
|
|
107
|
+
# returns as soon as the in-flight workers are joined - which can be while
|
|
108
|
+
# the signal handler's thread is still stopping background tasks and
|
|
109
|
+
# closing database connections. The server's main thread calls this so the
|
|
110
|
+
# process does not exit out from under that teardown.
|
|
111
|
+
def wait_for_completion(timeout = nil)
|
|
112
|
+
return unless @shutting_down
|
|
113
|
+
|
|
114
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
115
|
+
(timeout || @timeout.to_f + 5)
|
|
116
|
+
until @shutdown_complete || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
117
|
+
sleep 0.02
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# TINA4_SHUTDOWN_TIMEOUT in seconds (same name and default in all four
|
|
124
|
+
# frameworks). An unparseable or negative value warns and falls back to the
|
|
125
|
+
# default: `"abc".to_i` is 0, and a silent 0-second drain would force-close
|
|
126
|
+
# every in-flight request on the first signal. An explicit 0 is honoured -
|
|
127
|
+
# that is a deliberate choice, not a silent one.
|
|
128
|
+
def resolve_timeout(explicit)
|
|
129
|
+
raw = explicit || ENV["TINA4_SHUTDOWN_TIMEOUT"]
|
|
130
|
+
return DEFAULT_TIMEOUT if raw.nil? || raw.to_s.strip.empty?
|
|
131
|
+
|
|
132
|
+
seconds = Float(raw)
|
|
133
|
+
raise ArgumentError, "must not be negative" if seconds.negative?
|
|
134
|
+
|
|
135
|
+
seconds
|
|
136
|
+
rescue ArgumentError, TypeError
|
|
137
|
+
Tina4::Log.warning(
|
|
138
|
+
"TINA4_SHUTDOWN_TIMEOUT=#{raw.inspect} is not a non-negative number, using #{DEFAULT_TIMEOUT}s"
|
|
139
|
+
)
|
|
140
|
+
DEFAULT_TIMEOUT
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Close the listening socket BEFORE draining. WEBrick's #shutdown wakes its
|
|
144
|
+
# accept loop and closes the listeners; it does not touch the worker
|
|
145
|
+
# threads already running, which the loop's own ensure joins - so new
|
|
146
|
+
# connections are refused while in-flight requests still run to completion
|
|
147
|
+
# and write their full response. Measured on webrick 1.9.2 / Ruby 4.0.2:
|
|
148
|
+
# #shutdown returns in ~0.1ms, a connection attempted 0.3s later is
|
|
149
|
+
# ECONNREFUSED, and the 2s handler mid-flight still returns a full 200.
|
|
150
|
+
def stop_accepting
|
|
151
|
+
return unless @server.respond_to?(:shutdown)
|
|
152
|
+
|
|
153
|
+
@server.shutdown
|
|
154
|
+
Tina4::Log.info("Stopped accepting new connections")
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
Tina4::Log.error("Error closing the listening socket: #{e.message}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# RFC 6455 close code 1001 "going away" is what a server sends when it is
|
|
160
|
+
# shutting down, so clients reconnect elsewhere instead of treating the
|
|
161
|
+
# drop as an error. Both live managers are covered: the process-wide route
|
|
162
|
+
# engine (Tina4::WebSocket.current, published by RackApp) and the
|
|
163
|
+
# dev-reload channel's own manager - the same pair DevAdmin#ws_managers
|
|
164
|
+
# enumerates.
|
|
165
|
+
def close_websockets
|
|
166
|
+
closed = 0
|
|
167
|
+
live_websocket_managers.each do |manager|
|
|
168
|
+
manager.connections.values.each do |connection|
|
|
169
|
+
connection.close(code: 1001, reason: "going away")
|
|
170
|
+
closed += 1
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
Tina4::Log.warning("Error closing WebSocket #{connection.id}: #{e.message}")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
Tina4::Log.info("Closed #{closed} WebSocket connection(s) with 1001 going away") if closed.positive?
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
Tina4::Log.error("Error closing WebSocket connections: #{e.message}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def live_websocket_managers
|
|
181
|
+
managers = []
|
|
182
|
+
return managers unless defined?(Tina4::WebSocket)
|
|
183
|
+
|
|
184
|
+
managers << Tina4::WebSocket.current if Tina4::WebSocket.current
|
|
185
|
+
managers << Tina4::DevReload.manager if defined?(Tina4::DevReload)
|
|
186
|
+
managers
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# true when everything drained, false when the deadline expired.
|
|
190
|
+
def wait_for_in_flight
|
|
44
191
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
45
192
|
@mutex.synchronize do
|
|
46
193
|
while @in_flight_count > 0
|
|
47
194
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
195
|
if remaining <= 0
|
|
49
|
-
Tina4::Log.warning(
|
|
50
|
-
|
|
196
|
+
Tina4::Log.warning(
|
|
197
|
+
"TINA4_SHUTDOWN_TIMEOUT=#{@timeout}s reached with #{@in_flight_count} " \
|
|
198
|
+
"request(s) still in flight, forcing close"
|
|
199
|
+
)
|
|
200
|
+
return false
|
|
51
201
|
end
|
|
52
202
|
@in_flight_cv.wait(@mutex, remaining)
|
|
53
203
|
end
|
|
54
204
|
end
|
|
205
|
+
true
|
|
206
|
+
end
|
|
55
207
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
begin
|
|
59
|
-
Tina4::Background.stop_all
|
|
60
|
-
Tina4::Log.info("Background tasks stopped")
|
|
61
|
-
rescue => e
|
|
62
|
-
Tina4::Log.error("Error stopping background tasks: #{e.message}")
|
|
63
|
-
end
|
|
64
|
-
end
|
|
208
|
+
def stop_background_tasks
|
|
209
|
+
return unless defined?(Tina4::Background)
|
|
65
210
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
rescue => e
|
|
72
|
-
Tina4::Log.error("Error closing database: #{e.message}")
|
|
73
|
-
end
|
|
74
|
-
end
|
|
211
|
+
Tina4::Background.stop_all
|
|
212
|
+
Tina4::Log.info("Background tasks stopped")
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
Tina4::Log.error("Error stopping background tasks: #{e.message}")
|
|
215
|
+
end
|
|
75
216
|
|
|
76
|
-
|
|
217
|
+
# Close the DEFAULT connection and every NAMED one. Only closing
|
|
218
|
+
# Tina4.database leaked every connection registered with
|
|
219
|
+
# bind_database(db, name:) - a model pointed at a secondary database held
|
|
220
|
+
# its connection open through shutdown.
|
|
221
|
+
def close_database
|
|
222
|
+
connections = [Tina4.database, *Tina4.databases.values].compact.uniq(&:object_id)
|
|
223
|
+
return if connections.empty?
|
|
77
224
|
|
|
78
|
-
|
|
79
|
-
|
|
225
|
+
connections.each do |connection|
|
|
226
|
+
connection.close
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
Tina4::Log.error("Error closing database: #{e.message}")
|
|
229
|
+
end
|
|
230
|
+
Tina4::Log.info("Database connections closed (#{connections.size})")
|
|
80
231
|
end
|
|
81
232
|
|
|
82
|
-
private
|
|
83
|
-
|
|
84
233
|
def install_signal_handlers
|
|
85
234
|
%w[INT TERM].each do |signal|
|
|
86
235
|
Signal.trap(signal) do
|
|
87
|
-
#
|
|
236
|
+
# A trap context must stay async-signal-safe (no mutexes, no IO), so
|
|
237
|
+
# the real work happens on a thread.
|
|
88
238
|
Thread.new { initiate_shutdown }
|
|
89
239
|
end
|
|
90
240
|
end
|