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
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "digest"
|
|
4
5
|
|
|
5
6
|
module Tina4
|
|
6
7
|
module SessionHandlers
|
|
7
8
|
class FileHandler
|
|
9
|
+
# TINA4_SESSION_PATH selects the session directory, with the SAME
|
|
10
|
+
# precedence the other three frameworks use: an explicit option wins, then
|
|
11
|
+
# the environment, then "data/sessions".
|
|
12
|
+
#
|
|
13
|
+
# Ruby read NO env var here and hard-defaulted to <cwd>/sessions, so it
|
|
14
|
+
# drifted on BOTH halves of ADR-0024: the documented variable did nothing
|
|
15
|
+
# (example/.env.example has advertised "TINA4_SESSION_PATH=data/sessions"
|
|
16
|
+
# the whole time), and the default location differed from Python
|
|
17
|
+
# (session/__init__.py FileSessionHandler), PHP (Session.php:128) and Node
|
|
18
|
+
# (session.ts:184) - all three of which resolve
|
|
19
|
+
# `path || TINA4_SESSION_PATH || "data/sessions"`. Pointing the file
|
|
20
|
+
# backend at a mounted volume worked in three frameworks and was silently
|
|
21
|
+
# ignored in the fourth, which is the exact "one env var and nothing else"
|
|
22
|
+
# promise ADR-0024 makes.
|
|
23
|
+
#
|
|
24
|
+
# The default stays RELATIVE, exactly as the other three leave it, so all
|
|
25
|
+
# four resolve the same path against the same working directory.
|
|
8
26
|
def initialize(options = {})
|
|
9
|
-
@dir = options[:dir] || File.join(
|
|
10
|
-
|
|
27
|
+
@dir = options[:dir] || ENV["TINA4_SESSION_PATH"] || File.join("data", "sessions")
|
|
28
|
+
# TINA4_SESSION_TTL is the ONE session-lifetime variable, and it must
|
|
29
|
+
# reach EVERY backend (ADR-0024). This used to be a hard-coded 86400,
|
|
30
|
+
# so an operator setting a 15-minute session got 24 hours here and got
|
|
31
|
+
# it right only on memcached - the one handler that read the variable.
|
|
32
|
+
# 3600 is the default in Python (the master), PHP and Node.
|
|
33
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
11
34
|
FileUtils.mkdir_p(@dir)
|
|
12
35
|
end
|
|
13
36
|
|
|
@@ -15,21 +38,40 @@ module Tina4
|
|
|
15
38
|
path = session_path(session_id)
|
|
16
39
|
return nil unless File.exist?(path)
|
|
17
40
|
|
|
18
|
-
|
|
19
|
-
if
|
|
41
|
+
stored = JSON.parse(File.read(path))
|
|
42
|
+
if expired?(stored, path)
|
|
20
43
|
File.delete(path)
|
|
21
44
|
return nil
|
|
22
45
|
end
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
JSON.parse(data)
|
|
47
|
+
unwrap(stored)
|
|
26
48
|
rescue JSON::ParserError
|
|
27
49
|
nil
|
|
28
50
|
end
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
# Write session data.
|
|
53
|
+
#
|
|
54
|
+
# The ttl is consumed HERE, at write time, and baked into an ABSOLUTE
|
|
55
|
+
# deadline stored beside the payload, so nothing at read time needs to know
|
|
56
|
+
# what the ttl was. That is what makes a per-call ttl durable: a reader with
|
|
57
|
+
# a different ttl can no longer judge someone else's record.
|
|
58
|
+
#
|
|
59
|
+
# A ttl of 0 (or negative) means NEVER EXPIRES, matching the Python master,
|
|
60
|
+
# Node, and every mainstream implementation measured. It previously fell
|
|
61
|
+
# through to the mtime comparison, where `mtime + 0 < Time.now` made ttl: 0
|
|
62
|
+
# mean "expire immediately" - the opposite meaning, and the only backend in
|
|
63
|
+
# any of the four frameworks that read it that way.
|
|
64
|
+
#
|
|
65
|
+
# @param session_id [String] the session id
|
|
66
|
+
# @param data [Hash] the payload to store
|
|
67
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
68
|
+
def write(session_id, data, ttl = 0)
|
|
69
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
70
|
+
expires_at = effective_ttl.positive? ? Time.now.to_f + effective_ttl : 0.0
|
|
71
|
+
File.write(
|
|
72
|
+
session_path(session_id),
|
|
73
|
+
JSON.generate({ "_data" => data, "_expires" => expires_at })
|
|
74
|
+
)
|
|
33
75
|
end
|
|
34
76
|
|
|
35
77
|
def destroy(session_id)
|
|
@@ -38,29 +80,79 @@ module Tina4
|
|
|
38
80
|
end
|
|
39
81
|
|
|
40
82
|
def cleanup
|
|
41
|
-
|
|
42
|
-
Dir.glob(File.join(@dir, "sess_*")).each do |file|
|
|
43
|
-
File.delete(file) if File.mtime(file) + @ttl < Time.now
|
|
44
|
-
end
|
|
83
|
+
sweep
|
|
45
84
|
end
|
|
46
85
|
|
|
47
86
|
# Garbage-collect expired sessions. Matches the Python interface.
|
|
48
|
-
# @param max_age [Integer]
|
|
49
|
-
|
|
87
|
+
# @param max_age [Integer] accepted for interface parity; expiry is absolute
|
|
88
|
+
# and already baked into the stored deadline, so it is used only for legacy
|
|
89
|
+
# records that carry no deadline of their own.
|
|
90
|
+
def gc(max_age = nil)
|
|
91
|
+
sweep(max_age)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Delete every genuinely-expired record. An absent or zero deadline means
|
|
97
|
+
# never expires, so such a record is never a sweep candidate.
|
|
98
|
+
def sweep(max_age = nil)
|
|
50
99
|
return unless Dir.exist?(@dir)
|
|
51
|
-
|
|
100
|
+
|
|
52
101
|
Dir.glob(File.join(@dir, "sess_*")).each do |file|
|
|
53
|
-
|
|
102
|
+
stored = begin
|
|
103
|
+
JSON.parse(File.read(file))
|
|
104
|
+
rescue JSON::ParserError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
File.delete(file) if stored && expired?(stored, file, max_age)
|
|
54
108
|
rescue StandardError
|
|
55
|
-
# Corrupt or locked file
|
|
109
|
+
# Corrupt or locked file - skip
|
|
56
110
|
end
|
|
57
111
|
end
|
|
58
112
|
|
|
59
|
-
|
|
113
|
+
# Decide whether a stored record has expired.
|
|
114
|
+
#
|
|
115
|
+
# THE CONTRACT: an ABSENT or ZERO deadline means "never expires". It is
|
|
116
|
+
# guarded OUT of the comparison, never fed INTO it. Verified against real
|
|
117
|
+
# mainstream implementations - PHP's native files handler, express-session,
|
|
118
|
+
# Django, Laravel, connect-redis and connect-mongo - none of which deletes a
|
|
119
|
+
# record carrying no expiry.
|
|
120
|
+
#
|
|
121
|
+
# A LEGACY record (a bare payload written before the envelope existed) has no
|
|
122
|
+
# deadline of its own, so it keeps the original mtime comparison. Without
|
|
123
|
+
# that fallback every already-stored session would become immortal on
|
|
124
|
+
# upgrade, which trades data loss for a security bug.
|
|
125
|
+
def expired?(stored, path, max_age = nil)
|
|
126
|
+
if stored.is_a?(Hash) && stored.key?("_expires")
|
|
127
|
+
expires_at = stored["_expires"].to_f
|
|
128
|
+
return expires_at.positive? && expires_at < Time.now.to_f
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
lifetime = (max_age || @ttl).to_i
|
|
132
|
+
lifetime.positive? && File.mtime(path) + lifetime < Time.now
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Return the caller's payload. A legacy bare payload is returned as-is, so a
|
|
136
|
+
# record written by an older version still reads correctly.
|
|
137
|
+
def unwrap(stored)
|
|
138
|
+
return stored["_data"] if stored.is_a?(Hash) && stored.key?("_expires")
|
|
139
|
+
|
|
140
|
+
stored
|
|
141
|
+
end
|
|
60
142
|
|
|
143
|
+
# SHA-256 of the id. A session id can therefore never become a path
|
|
144
|
+
# component, AND two distinct ids can never collide.
|
|
145
|
+
#
|
|
146
|
+
# The previous gsub(/[^a-zA-Z0-9_-]/, "") was traversal-safe but LOSSY: it
|
|
147
|
+
# collapsed "a/b" and "ab" onto the same sess_ab.json, so two different
|
|
148
|
+
# sessions shared one record and one user's data surfaced under another
|
|
149
|
+
# user's id. Parity with the Python master's FileSessionHandler._file
|
|
150
|
+
# (hashlib.sha256(session_id.encode()).hexdigest()).
|
|
151
|
+
#
|
|
152
|
+
# The sess_ prefix is kept deliberately so #cleanup and #gc keep matching
|
|
153
|
+
# with their Dir.glob("sess_*").
|
|
61
154
|
def session_path(session_id)
|
|
62
|
-
|
|
63
|
-
File.join(@dir, "sess_#{safe_id}.json")
|
|
155
|
+
File.join(@dir, "sess_#{Digest::SHA256.hexdigest(session_id.to_s)}.json")
|
|
64
156
|
end
|
|
65
157
|
end
|
|
66
158
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
module SessionHandlers
|
|
9
|
+
# Memcached session handler - zero-dependency text protocol over TCP.
|
|
10
|
+
#
|
|
11
|
+
# Memcached was already one of the seven CACHE backends in all four
|
|
12
|
+
# frameworks but was NOT a session backend in any of them, even though it is
|
|
13
|
+
# the classic PHP session store. This closes that gap.
|
|
14
|
+
#
|
|
15
|
+
# Speaks the memcached TEXT protocol directly over a socket, so there is no
|
|
16
|
+
# gem dependency - the same zero-dependency choice the Redis/Valkey handlers
|
|
17
|
+
# make.
|
|
18
|
+
#
|
|
19
|
+
# BACKEND-FAILURE POLICY. A genuine key miss returns +{}+ silently (no
|
|
20
|
+
# session yet is normal). A TRANSPORT failure - server unreachable,
|
|
21
|
+
# connection dropped mid-reply, a protocol error - RAISES, so the Session
|
|
22
|
+
# layer can log-loud and degrade. Collapsing the two is how a dead cache
|
|
23
|
+
# silently logs every user out.
|
|
24
|
+
#
|
|
25
|
+
# Memcached has no persistence and no replication: a restart drops every
|
|
26
|
+
# session. That is a deliberate trade (it is a cache), and it is why
|
|
27
|
+
# file/database remain the defaults.
|
|
28
|
+
#
|
|
29
|
+
# Environment variables:
|
|
30
|
+
# TINA4_SESSION_MEMCACHED_HOST - hostname (default: localhost)
|
|
31
|
+
# TINA4_SESSION_MEMCACHED_PORT - port (default: 11211)
|
|
32
|
+
# TINA4_SESSION_MEMCACHED_PREFIX - key prefix (default: tina4:session:)
|
|
33
|
+
# TINA4_SESSION_TTL - session TTL in seconds (default: 3600)
|
|
34
|
+
class MemcachedHandler
|
|
35
|
+
# Memcached rejects a key over 250 bytes or containing a space/control
|
|
36
|
+
# character. A key that could break either rule is HASHED rather than
|
|
37
|
+
# truncated - truncating would let two different sessions collide on one
|
|
38
|
+
# key, handing one user another user's session.
|
|
39
|
+
MAX_KEY_BYTES = 250
|
|
40
|
+
|
|
41
|
+
# memcached's exptime field changes meaning at 30 days: at or below this
|
|
42
|
+
# it is RELATIVE seconds, above it the server reads an ABSOLUTE UNIX
|
|
43
|
+
# TIMESTAMP. See #exptime for why we convert instead of clamping.
|
|
44
|
+
MAX_RELATIVE_EXPTIME = 2_592_000
|
|
45
|
+
|
|
46
|
+
def initialize(options = {})
|
|
47
|
+
options ||= {}
|
|
48
|
+
@host = options[:host] || ENV["TINA4_SESSION_MEMCACHED_HOST"] || "localhost"
|
|
49
|
+
@port = (options[:port] || ENV["TINA4_SESSION_MEMCACHED_PORT"] || 11_211).to_i
|
|
50
|
+
@prefix = options[:prefix] || ENV["TINA4_SESSION_MEMCACHED_PREFIX"] || "tina4:session:"
|
|
51
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
52
|
+
@timeout = (options[:timeout] || 5).to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read a session. Returns {} for a genuine miss; RAISES on a transport
|
|
56
|
+
# failure so an outage is never mistaken for "no session yet".
|
|
57
|
+
def read(session_id)
|
|
58
|
+
resp = command("get #{key(session_id)}\r\n", ["END\r\n"])
|
|
59
|
+
return {} unless resp.start_with?("VALUE")
|
|
60
|
+
|
|
61
|
+
header, rest = resp.split("\r\n", 2)
|
|
62
|
+
return {} if rest.nil?
|
|
63
|
+
|
|
64
|
+
bytes = header.split[3].to_i
|
|
65
|
+
parsed = JSON.parse(rest[0, bytes])
|
|
66
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
# A corrupt value is treated as no session rather than crashing the
|
|
69
|
+
# request; the next write replaces it.
|
|
70
|
+
{}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Write a session with a TTL (0 falls back to the configured default).
|
|
74
|
+
def write(session_id, data, ttl = 0)
|
|
75
|
+
effective_ttl = exptime(ttl.to_i.positive? ? ttl.to_i : @ttl)
|
|
76
|
+
payload = JSON.generate(data)
|
|
77
|
+
cmd = "set #{key(session_id)} 0 #{effective_ttl} #{payload.bytesize}\r\n"
|
|
78
|
+
resp = command("#{cmd}#{payload}\r\n",
|
|
79
|
+
["STORED\r\n", "ERROR\r\n", "SERVER_ERROR", "CLIENT_ERROR"])
|
|
80
|
+
return if resp.start_with?("STORED")
|
|
81
|
+
|
|
82
|
+
raise "Memcached did not store the session: #{resp[0, 80].inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Delete a session. A session that was already gone is not an error.
|
|
86
|
+
def destroy(session_id)
|
|
87
|
+
command("delete #{key(session_id)}\r\n", ["DELETED\r\n", "NOT_FOUND\r\n", "ERROR\r\n"])
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# No-op - memcached expires its own keys via the TTL set on write.
|
|
92
|
+
def cleanup
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Garbage-collect expired sessions. Memcached expires its own keys via the
|
|
97
|
+
# TTL set on write, so there is genuinely nothing to sweep - but the
|
|
98
|
+
# ARGUMENT still has to be accepted, because Session#gc calls
|
|
99
|
+
# handler.gc(max_lifetime) with exactly one argument.
|
|
100
|
+
#
|
|
101
|
+
# This was `alias gc cleanup`, and #cleanup takes ZERO arguments, so
|
|
102
|
+
# MemcachedHandler#gc.arity was 0 and EVERY session GC against a memcached
|
|
103
|
+
# backend raised ArgumentError "wrong number of arguments (given 1,
|
|
104
|
+
# expected 0)". Session#gc's rescue then reported it as a BACKEND failure -
|
|
105
|
+
# "Session gc failed (...MemcachedHandler): wrong number of arguments" -
|
|
106
|
+
# so an internal arity bug was misattributed to the operator's memcached,
|
|
107
|
+
# and a perfectly healthy server logged an ERROR on every sweep.
|
|
108
|
+
# FileHandler#gc(max_age = nil) and DatabaseHandler#gc(max_age) both take
|
|
109
|
+
# the argument; memcached was the odd one out.
|
|
110
|
+
#
|
|
111
|
+
# @param max_age [Integer, nil] accepted for interface parity; memcached
|
|
112
|
+
# owns its own expiry, so nothing here consults it.
|
|
113
|
+
def gc(_max_age = nil)
|
|
114
|
+
cleanup
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Convert a ttl in SECONDS to memcached's dual-meaning exptime field.
|
|
120
|
+
#
|
|
121
|
+
# memcached documents exptime as RELATIVE seconds up to 2592000 (30 days),
|
|
122
|
+
# and as an ABSOLUTE UNIX TIMESTAMP for anything larger. Sending a raw ttl
|
|
123
|
+
# of 2592001 therefore does not mean "30 days and one second" - it means
|
|
124
|
+
# 1970-01-31, which is already past, so the item expires the instant it is
|
|
125
|
+
# stored. memcached still replies STORED, so the write looks fine and the
|
|
126
|
+
# very next read is a miss: a silent logout on every request.
|
|
127
|
+
#
|
|
128
|
+
# Measured against real memcached 1.6.45: ttl=2592000 survives, ttl=2592001
|
|
129
|
+
# vanishes instantly.
|
|
130
|
+
#
|
|
131
|
+
# We CONVERT rather than CLAMP. Clamping a 60-day session down to 30 days
|
|
132
|
+
# would silently shorten a lifetime the operator explicitly asked to be
|
|
133
|
+
# longer, which is the same class of lie in the other direction.
|
|
134
|
+
def exptime(ttl)
|
|
135
|
+
return Time.now.to_i + ttl if ttl > MAX_RELATIVE_EXPTIME
|
|
136
|
+
|
|
137
|
+
ttl
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def key(session_id)
|
|
141
|
+
candidate = "#{@prefix}#{session_id}"
|
|
142
|
+
return candidate unless candidate.bytesize > MAX_KEY_BYTES || candidate.match?(/[\x00-\x20\x7f]/)
|
|
143
|
+
|
|
144
|
+
"#{@prefix}#{Digest::SHA256.hexdigest(session_id.to_s)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Run one memcached command and return the raw reply.
|
|
148
|
+
#
|
|
149
|
+
# RAISES on any transport failure. The cache backend swallows these and
|
|
150
|
+
# returns an empty string - correct for a cache (a miss and an outage are
|
|
151
|
+
# both "not cached"), wrong for a session, where an outage must be
|
|
152
|
+
# distinguishable from "no session yet".
|
|
153
|
+
def command(payload, terminators)
|
|
154
|
+
sock = Socket.tcp(@host, @port, connect_timeout: @timeout)
|
|
155
|
+
begin
|
|
156
|
+
sock.write(payload)
|
|
157
|
+
buffer = +""
|
|
158
|
+
until terminators.any? { |t| buffer.include?(t) }
|
|
159
|
+
chunk = begin
|
|
160
|
+
sock.read_nonblock(4096)
|
|
161
|
+
rescue IO::WaitReadable
|
|
162
|
+
raise "timed out waiting for a reply" unless sock.wait_readable(@timeout)
|
|
163
|
+
|
|
164
|
+
retry
|
|
165
|
+
end
|
|
166
|
+
raise "connection closed before a complete reply" if chunk.nil? || chunk.empty?
|
|
167
|
+
|
|
168
|
+
buffer << chunk
|
|
169
|
+
end
|
|
170
|
+
buffer
|
|
171
|
+
ensure
|
|
172
|
+
begin
|
|
173
|
+
sock&.close
|
|
174
|
+
rescue IOError, SystemCallError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
raise "Memcached session backend at #{@host}:#{@port} failed: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
|
+
require_relative "mongo_wire_client"
|
|
3
4
|
|
|
4
5
|
module Tina4
|
|
5
6
|
module SessionHandlers
|
|
@@ -10,56 +11,272 @@ module Tina4
|
|
|
10
11
|
# TINA4_SESSION_MONGO_URL is a legacy alias. The database default is
|
|
11
12
|
# "tina4" (Python's default — Ruby previously drifted to "tina4_sessions").
|
|
12
13
|
# An explicit constructor option always wins over the environment.
|
|
14
|
+
# NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4).
|
|
15
|
+
# `require "mongo"` is a pure load and costs nothing on the wire;
|
|
16
|
+
# Mongo::Client.new is NOT - it starts SDAM topology monitoring and
|
|
17
|
+
# handshakes the server immediately, and ensure_ttl_index then issues a
|
|
18
|
+
# createIndexes round trip (dropping and recreating the index on an
|
|
19
|
+
# IndexOptionsConflict).
|
|
20
|
+
#
|
|
21
|
+
# MEASURED against a REAL counting TCP listener before this change:
|
|
22
|
+
# constructing this handler accepted THREE connections. That traffic sat
|
|
23
|
+
# OUTSIDE the log-loud-and-degrade policy, so an unreachable MongoDB took
|
|
24
|
+
# the app down at construction instead of degrading per request - the one
|
|
25
|
+
# place the policy cannot protect being the FIRST thing that runs.
|
|
26
|
+
#
|
|
27
|
+
# The client, the collection and the TTL index are all built on FIRST USE.
|
|
28
|
+
#
|
|
29
|
+
# TWO TRANSPORTS, ONE RESOLUTION POINT (session_contract.json #6,
|
|
30
|
+
# ADR-0024). The `mongo` gem is used when it is installed; when it is NOT,
|
|
31
|
+
# this handler speaks the MongoDB wire protocol directly over a socket via
|
|
32
|
+
# MongoWireClient - zero dependencies, exactly as Python, PHP and Node have
|
|
33
|
+
# always done. This line USED to be a bare `require "mongo"` whose
|
|
34
|
+
# `rescue LoadError` re-raised "MongoDB session handler requires the
|
|
35
|
+
# 'mongo' gem", so TINA4_SESSION_BACKEND=mongodb worked in three
|
|
36
|
+
# frameworks and blew up in the fourth on identical configuration.
|
|
37
|
+
# MEASURED at v3 HEAD in a real subprocess with no gems resolvable at all:
|
|
38
|
+
# file, redis, valkey and memcached all round-tripped a session and
|
|
39
|
+
# mongodb raised at Session construction.
|
|
40
|
+
#
|
|
41
|
+
# The probe stays a PURE LOAD - `require` opens no socket - so ADR-0021
|
|
42
|
+
# (no network I/O in a constructor) still holds. Guarding on
|
|
43
|
+
# ::Mongo::VERSION rather than on `require` alone is the same defence
|
|
44
|
+
# RedisHandler#build_gem_client uses: a bare `Mongo` constant defined by
|
|
45
|
+
# something else must not be mistaken for the real driver.
|
|
13
46
|
def initialize(options = {})
|
|
14
|
-
|
|
15
|
-
|
|
47
|
+
# TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
|
|
48
|
+
# 86400. 3600 matches Python (the master), PHP and Node.
|
|
49
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
16
50
|
@uri = options[:uri] || ENV["TINA4_SESSION_MONGO_URI"] || ENV["TINA4_SESSION_MONGO_URL"] || "mongodb://localhost:27017"
|
|
17
51
|
@database = options[:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
|
|
18
52
|
@collection_name = options[:collection] || ENV["TINA4_SESSION_MONGO_COLLECTION"] || "sessions"
|
|
19
|
-
|
|
20
|
-
@
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
53
|
+
@gem_available = gem_available?
|
|
54
|
+
@client = nil
|
|
55
|
+
@wire_client = nil
|
|
56
|
+
@collection = nil
|
|
57
|
+
@index_ready = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Release whichever transport was opened.
|
|
61
|
+
#
|
|
62
|
+
# Mongo::Client owns a pool of REAL sockets. Before the client was lazy it
|
|
63
|
+
# was a local variable in #initialize, reachable only through the
|
|
64
|
+
# collection, so every construction leaked a pool and nothing could give it
|
|
65
|
+
# back. Now the handler holds the client, so the handler can close it -
|
|
66
|
+
# parity with the Python master's MongoDBSessionHandler.close() and with
|
|
67
|
+
# Tina4::DocStore.close_doc_store, which already closes its Mongo clients
|
|
68
|
+
# the same way. The zero-dependency transport owns ONE raw socket and is
|
|
69
|
+
# closed the same way, so neither path leaks. Safe to call on a handler
|
|
70
|
+
# that never connected.
|
|
71
|
+
def close
|
|
72
|
+
# Each close is guarded on its own: a failure on one transport must not
|
|
73
|
+
# skip the other, and neither may mask the caller's work.
|
|
74
|
+
[@client, @wire_client].each do |transport|
|
|
75
|
+
transport&.close
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
ensure
|
|
80
|
+
@client = nil
|
|
81
|
+
@wire_client = nil
|
|
82
|
+
@collection = nil
|
|
83
|
+
@index_ready = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
|
|
87
|
+
#
|
|
88
|
+
# THE CONTRACT: an ABSENT or ZERO expiry stamp means "never expires". It is
|
|
89
|
+
# guarded OUT of the comparison, never fed INTO it - so a document written
|
|
90
|
+
# by another framework, an older version, or a direct insert is returned
|
|
91
|
+
# rather than destroyed. Identical to the Python master's _has_expired.
|
|
92
|
+
def self.expired?(doc)
|
|
93
|
+
expires_at = doc["expires_at"].to_f
|
|
94
|
+
expires_at.positive? && expires_at < Time.now.to_f
|
|
26
95
|
end
|
|
27
96
|
|
|
28
97
|
def read(session_id)
|
|
29
|
-
doc =
|
|
98
|
+
doc = collection.find(_id: session_id).first
|
|
30
99
|
return nil unless doc
|
|
100
|
+
|
|
101
|
+
# Expiry is checked HERE, at read time, against the document's own
|
|
102
|
+
# absolute deadline. Relying on the TTL index alone (as this handler used
|
|
103
|
+
# to) cannot honour a short TTL at all: mongod's TTL monitor sweeps once
|
|
104
|
+
# every 60 SECONDS, so a 2-second session stayed readable for up to a
|
|
105
|
+
# minute after it expired. The index is still created, but purely as the
|
|
106
|
+
# background reaper that keeps the collection from growing forever.
|
|
107
|
+
if self.class.expired?(doc)
|
|
108
|
+
destroy(session_id)
|
|
109
|
+
return nil
|
|
110
|
+
end
|
|
111
|
+
|
|
31
112
|
doc["data"]
|
|
32
113
|
end
|
|
33
114
|
|
|
34
|
-
|
|
35
|
-
|
|
115
|
+
# Write session data. A per-call +ttl+ WINS over the handler default.
|
|
116
|
+
#
|
|
117
|
+
# The ttl is consumed HERE, at write time, and baked into an ABSOLUTE
|
|
118
|
+
# deadline (+expires_at+), so nothing at read time needs to know what the
|
|
119
|
+
# ttl was. That field name and meaning are the shape Python (the master),
|
|
120
|
+
# PHP and Node all store, so a session store SHARED between two frameworks
|
|
121
|
+
# carries one shape instead of four. +updated_at+ is still written to feed
|
|
122
|
+
# the TTL index.
|
|
123
|
+
#
|
|
124
|
+
# @param session_id [String] the session id
|
|
125
|
+
# @param data [Hash] the payload to store
|
|
126
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
127
|
+
def write(session_id, data, ttl = 0)
|
|
128
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
129
|
+
now = Time.now
|
|
130
|
+
expires_at = effective_ttl.positive? ? now.to_f + effective_ttl : 0.0
|
|
131
|
+
collection.update_one(
|
|
36
132
|
{ _id: session_id },
|
|
37
|
-
{ "$set" => { data: data,
|
|
133
|
+
{ "$set" => { data: data, expires_at: expires_at,
|
|
134
|
+
updated_at: now - (@ttl - effective_ttl) } },
|
|
38
135
|
upsert: true
|
|
39
136
|
)
|
|
40
137
|
end
|
|
41
138
|
|
|
42
139
|
def destroy(session_id)
|
|
43
|
-
|
|
140
|
+
collection.delete_one(_id: session_id)
|
|
44
141
|
end
|
|
45
142
|
|
|
46
143
|
def cleanup
|
|
47
|
-
|
|
144
|
+
gc
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Garbage-collect expired sessions. Matches the Python master's
|
|
148
|
+
# MongoDBSessionHandler.gc and the FileHandler interface, and it is what
|
|
149
|
+
# Session#gc calls when a handler responds to it.
|
|
150
|
+
#
|
|
151
|
+
# THE REAPER IS NOT OPTIONAL ON THE ZERO-DEPENDENCY TRANSPORT. The TTL
|
|
152
|
+
# index below is created on the gem path only (creating it needs the gem's
|
|
153
|
+
# own index API), so without this sweep a wire-protocol deployment would
|
|
154
|
+
# keep every expired document forever. Expiry itself is unaffected either
|
|
155
|
+
# way - #read checks the document's own absolute deadline.
|
|
156
|
+
#
|
|
157
|
+
# Same contract as #read: only a stamp that is genuinely PRESENT and in
|
|
158
|
+
# the PAST makes a document a deletion candidate. `$gt: 0` is explicit so
|
|
159
|
+
# a document with a zero stamp is never swept, and one with no stamp at
|
|
160
|
+
# all cannot match the range predicate either.
|
|
161
|
+
#
|
|
162
|
+
# @param max_lifetime [Integer] accepted for interface parity; expiry is
|
|
163
|
+
# absolute and already baked into expires_at at write time.
|
|
164
|
+
def gc(max_lifetime = nil)
|
|
165
|
+
_ = max_lifetime
|
|
166
|
+
collection.delete_many("expires_at" => { "$gt" => 0, "$lt" => Time.now.to_f })
|
|
48
167
|
end
|
|
49
168
|
|
|
50
169
|
private
|
|
51
170
|
|
|
171
|
+
# Whether the REAL `mongo` gem is loadable. A pure load - it opens no
|
|
172
|
+
# socket - so this is safe in a constructor (ADR-0021). Returns false when
|
|
173
|
+
# the gem is absent, which selects the zero-dependency wire transport.
|
|
174
|
+
def gem_available?
|
|
175
|
+
require "mongo"
|
|
176
|
+
defined?(::Mongo::VERSION) ? true : false
|
|
177
|
+
rescue LoadError
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The collection, built on FIRST USE rather than at construction, so every
|
|
182
|
+
# byte this handler puts on the wire happens inside the log-loud-and-degrade
|
|
183
|
+
# policy (see #initialize). Called by read/write/destroy/gc.
|
|
184
|
+
#
|
|
185
|
+
# THIS IS THE ONE PLACE THE TRANSPORT IS CHOSEN. read, write, destroy and
|
|
186
|
+
# gc have no branch of their own: whichever transport this returns answers
|
|
187
|
+
# find/update_one/delete_one/delete_many with the same shape, so the
|
|
188
|
+
# request path cannot take one transport while a directly-constructed
|
|
189
|
+
# handler takes the other. A per-operation branch is a branch that can be
|
|
190
|
+
# wrong on one path only - which is exactly how a fallback ships untested.
|
|
191
|
+
def collection
|
|
192
|
+
return @collection if @collection
|
|
193
|
+
|
|
194
|
+
if @gem_available
|
|
195
|
+
@client = Mongo::Client.new(@uri, database: @database)
|
|
196
|
+
@collection = @client[@collection_name]
|
|
197
|
+
ensure_ttl_index
|
|
198
|
+
else
|
|
199
|
+
@wire_client = build_wire_client
|
|
200
|
+
@collection = @wire_client
|
|
201
|
+
end
|
|
202
|
+
@collection
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The zero-dependency transport, pointed at the host and port parsed out of
|
|
206
|
+
# the configured URI. Opens nothing here - MongoWireClient connects on its
|
|
207
|
+
# first command.
|
|
208
|
+
#
|
|
209
|
+
# mongodb+srv:// is REFUSED here rather than half-supported. That scheme is
|
|
210
|
+
# not a host at all: it is a DNS SRV lookup that yields the real seed list,
|
|
211
|
+
# plus mandatory TLS. Parsing it as a hostname would dial
|
|
212
|
+
# "cluster0.example.mongodb.net:27017" in the clear, which does not exist,
|
|
213
|
+
# and the operator would get a bare connection-refused with nothing
|
|
214
|
+
# pointing at the cause. Before this fallback existed they got a clear
|
|
215
|
+
# "requires the 'mongo' gem"; they still get a clear message. Naming the
|
|
216
|
+
# remedy at the point of use is the framework's rule for a genuinely
|
|
217
|
+
# missing capability.
|
|
218
|
+
def build_wire_client
|
|
219
|
+
if @uri.to_s.start_with?("mongodb+srv://")
|
|
220
|
+
raise "mongodb+srv:// needs a DNS SRV lookup and TLS, which the zero-dependency " \
|
|
221
|
+
"MongoDB transport does not do. Install the 'mongo' gem (gem install mongo), " \
|
|
222
|
+
"or point TINA4_SESSION_MONGO_URI at an explicit mongodb://host:port."
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
host, port = parse_host_port(@uri)
|
|
226
|
+
MongoWireClient.new(host: host, port: port, database: @database, collection: @collection_name)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Extract host and port from a MongoDB URI, mirroring the Python master's
|
|
230
|
+
# _parse_url: strip the scheme, strip any credentials, strip the path and
|
|
231
|
+
# query, then take the FIRST host of a seed list.
|
|
232
|
+
#
|
|
233
|
+
# URI.parse is deliberately not used: a perfectly ordinary replica-set URI
|
|
234
|
+
# ("mongodb://a:27017,b:27017/db") is not a valid RFC 3986 authority, so it
|
|
235
|
+
# raises - and defaulting to localhost there would silently dial the WRONG
|
|
236
|
+
# server, which is far worse than any parse error.
|
|
237
|
+
def parse_host_port(uri)
|
|
238
|
+
remainder = uri.to_s.sub(%r{\Amongodb://}, "")
|
|
239
|
+
remainder = remainder.split("@", 2).last.to_s # credentials
|
|
240
|
+
remainder = remainder.split("/", 2).first.to_s # path + query
|
|
241
|
+
remainder = remainder.split(",", 2).first.to_s # first seed host
|
|
242
|
+
host, port = remainder.split(":", 2)
|
|
243
|
+
host = "localhost" if host.nil? || host.empty?
|
|
244
|
+
[host, port.nil? || port.empty? ? 27_017 : port.to_i]
|
|
245
|
+
end
|
|
246
|
+
|
|
52
247
|
# Create the updated_at TTL index. An existing updated_at index with a
|
|
53
248
|
# DIFFERENT expireAfterSeconds raises IndexOptionsConflict (code 85) — a
|
|
54
249
|
# TTL index cannot be modified in place — so drop and recreate it with the
|
|
55
250
|
# requested TTL. This makes re-init idempotent (no per-run error log) and
|
|
56
251
|
# lets a changed session TTL take effect.
|
|
252
|
+
#
|
|
253
|
+
# GEM PATH ONLY - #collection calls it in that branch and nowhere else.
|
|
254
|
+
# Python, PHP and Node create no TTL index on any transport, so the
|
|
255
|
+
# zero-dependency path reaps with #gc instead and expiry itself is
|
|
256
|
+
# unchanged either way (#read checks the document's own deadline). It also
|
|
257
|
+
# could not run here: the rescues below name Mongo::Error, a constant that
|
|
258
|
+
# does not exist when the gem is absent, so evaluating them would raise
|
|
259
|
+
# NameError rather than the LoadError anyone would expect.
|
|
260
|
+
#
|
|
261
|
+
# Runs ONCE per handler (@index_ready), on the first operation. The flag is
|
|
262
|
+
# set BEFORE the round trip so an unreachable server is not re-probed on
|
|
263
|
+
# every read - the same ordering the Python master uses for _table_ready.
|
|
57
264
|
def ensure_ttl_index
|
|
265
|
+
return if @index_ready
|
|
266
|
+
|
|
267
|
+
@index_ready = true
|
|
58
268
|
@collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
59
269
|
rescue Mongo::Error::OperationFailure => e
|
|
60
270
|
raise unless e.code == 85 || e.message.include?("IndexOptionsConflict")
|
|
61
271
|
@collection.indexes.drop_one("updated_at_1")
|
|
62
272
|
@collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
273
|
+
rescue Mongo::Error => e
|
|
274
|
+
# The index is the background REAPER, not the expiry authority - #read
|
|
275
|
+
# checks expires_at on the document itself - so failing to create it must
|
|
276
|
+
# not break sessions. The constructor swallowed this the same way; the
|
|
277
|
+
# only change is WHERE it is swallowed. A genuinely unreachable server
|
|
278
|
+
# still surfaces on the read/write that follows, inside the policy.
|
|
279
|
+
Tina4::Log.error("MongoDB session setup failed: #{e.message}")
|
|
63
280
|
end
|
|
64
281
|
end
|
|
65
282
|
end
|