tina4ruby 3.13.94 → 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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -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(Dir.pwd, "sessions")
10
- @ttl = options[:ttl] || 86400
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
- # Check expiry
19
- if File.mtime(path) + @ttl < Time.now
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
- data = File.read(path)
25
- JSON.parse(data)
47
+ unwrap(stored)
26
48
  rescue JSON::ParserError
27
49
  nil
28
50
  end
29
51
 
30
- def write(session_id, data)
31
- path = session_path(session_id)
32
- File.write(path, JSON.generate(data))
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
- return unless Dir.exist?(@dir)
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] maximum session age in seconds
49
- def gc(max_age)
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
- now = Time.now
100
+
52
101
  Dir.glob(File.join(@dir, "sess_*")).each do |file|
53
- File.delete(file) if File.mtime(file) + max_age < now
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 skip
109
+ # Corrupt or locked file - skip
56
110
  end
57
111
  end
58
112
 
59
- private
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
- safe_id = session_id.gsub(/[^a-zA-Z0-9_-]/, "")
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
- require "mongo"
15
- @ttl = options[:ttl] || 86400
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
- client = Mongo::Client.new(@uri, database: @database)
20
- @collection = client[@collection_name]
21
- ensure_ttl_index
22
- rescue LoadError
23
- raise "MongoDB session handler requires the 'mongo' gem. Install with: gem install mongo"
24
- rescue Mongo::Error => e
25
- Tina4::Log.error("MongoDB session setup failed: #{e.message}")
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 = @collection.find(_id: session_id).first
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
- def write(session_id, data)
35
- @collection.update_one(
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, updated_at: Time.now } },
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
- @collection.delete_one(_id: session_id)
140
+ collection.delete_one(_id: session_id)
44
141
  end
45
142
 
46
143
  def cleanup
47
- # MongoDB TTL index handles cleanup
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