Class: Tina4::SessionHandlers::MongoHandler
- Inherits:
-
Object
- Object
- Tina4::SessionHandlers::MongoHandler
- Defined in:
- lib/tina4/session_handlers/mongo_handler.rb
Class Method Summary collapse
-
.expired?(doc) ⇒ Boolean
Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
Instance Method Summary collapse
- #cleanup ⇒ Object
-
#close ⇒ Object
Release whichever transport was opened.
- #destroy(session_id) ⇒ Object
-
#gc(max_lifetime = nil) ⇒ Object
Garbage-collect expired sessions.
-
#initialize(options = {}) ⇒ MongoHandler
constructor
Connection is configured from TINA4_SESSION_MONGO_* env vars (parity with Python's MongoDBSessionHandler) so TINA4_SESSION_BACKEND=mongodb can be pointed at a server by env.
- #read(session_id) ⇒ Object
-
#write(session_id, data, ttl = 0) ⇒ Object
Write session data.
Constructor Details
#initialize(options = {}) ⇒ MongoHandler
Connection is configured from TINA4_SESSION_MONGO_* env vars (parity with
Python's MongoDBSessionHandler) so TINA4_SESSION_BACKEND=mongodb can be
pointed at a server by env. TINA4_SESSION_MONGO_URI is canonical;
TINA4_SESSION_MONGO_URL is a legacy alias. The database default is
"tina4" (Python's default — Ruby previously drifted to "tina4_sessions").
An explicit constructor option always wins over the environment.
NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4).
require "mongo" is a pure load and costs nothing on the wire;
Mongo::Client.new is NOT - it starts SDAM topology monitoring and
handshakes the server immediately, and ensure_ttl_index then issues a
createIndexes round trip (dropping and recreating the index on an
IndexOptionsConflict).
MEASURED against a REAL counting TCP listener before this change: constructing this handler accepted THREE connections. That traffic sat OUTSIDE the log-loud-and-degrade policy, so an unreachable MongoDB took the app down at construction instead of degrading per request - the one place the policy cannot protect being the FIRST thing that runs.
The client, the collection and the TTL index are all built on FIRST USE.
TWO TRANSPORTS, ONE RESOLUTION POINT (session_contract.json #6,
ADR-0024). The mongo gem is used when it is installed; when it is NOT,
this handler speaks the MongoDB wire protocol directly over a socket via
MongoWireClient - zero dependencies, exactly as Python, PHP and Node have
always done. This line USED to be a bare require "mongo" whose
rescue LoadError re-raised "MongoDB session handler requires the
'mongo' gem", so TINA4_SESSION_BACKEND=mongodb worked in three
frameworks and blew up in the fourth on identical configuration.
MEASURED at v3 HEAD in a real subprocess with no gems resolvable at all:
file, redis, valkey and memcached all round-tripped a session and
mongodb raised at Session construction.
The probe stays a PURE LOAD - require opens no socket - so ADR-0021
(no network I/O in a constructor) still holds. Guarding on
::Mongo::VERSION rather than on require alone is the same defence
RedisHandler#build_gem_client uses: a bare Mongo constant defined by
something else must not be mistaken for the real driver.
46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 46 def initialize( = {}) # TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded # 86400. 3600 matches Python (the master), PHP and Node. @ttl = ([:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i @uri = [:uri] || ENV["TINA4_SESSION_MONGO_URI"] || ENV["TINA4_SESSION_MONGO_URL"] || "mongodb://localhost:27017" @database = [:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4" @collection_name = [:collection] || ENV["TINA4_SESSION_MONGO_COLLECTION"] || "sessions" @gem_available = gem_available? @client = nil @wire_client = nil @collection = nil @index_ready = false end |
Class Method Details
.expired?(doc) ⇒ Boolean
Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
THE CONTRACT: an ABSENT or ZERO expiry stamp means "never expires". It is guarded OUT of the comparison, never fed INTO it - so a document written by another framework, an older version, or a direct insert is returned rather than destroyed. Identical to the Python master's _has_expired.
92 93 94 95 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 92 def self.expired?(doc) expires_at = doc["expires_at"].to_f expires_at.positive? && expires_at < Time.now.to_f end |
Instance Method Details
#cleanup ⇒ Object
143 144 145 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 143 def cleanup gc end |
#close ⇒ Object
Release whichever transport was opened.
Mongo::Client owns a pool of REAL sockets. Before the client was lazy it was a local variable in #initialize, reachable only through the collection, so every construction leaked a pool and nothing could give it back. Now the handler holds the client, so the handler can close it - parity with the Python master's MongoDBSessionHandler.close() and with Tina4::DocStore.close_doc_store, which already closes its Mongo clients the same way. The zero-dependency transport owns ONE raw socket and is closed the same way, so neither path leaks. Safe to call on a handler that never connected.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 71 def close # Each close is guarded on its own: a failure on one transport must not # skip the other, and neither may mask the caller's work. [@client, @wire_client].each do |transport| transport&.close rescue StandardError nil end ensure @client = nil @wire_client = nil @collection = nil @index_ready = false end |
#destroy(session_id) ⇒ Object
139 140 141 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 139 def destroy(session_id) collection.delete_one(_id: session_id) end |
#gc(max_lifetime = nil) ⇒ Object
Garbage-collect expired sessions. Matches the Python master's MongoDBSessionHandler.gc and the FileHandler interface, and it is what Session#gc calls when a handler responds to it.
THE REAPER IS NOT OPTIONAL ON THE ZERO-DEPENDENCY TRANSPORT. The TTL index below is created on the gem path only (creating it needs the gem's own index API), so without this sweep a wire-protocol deployment would keep every expired document forever. Expiry itself is unaffected either way - #read checks the document's own absolute deadline.
Same contract as #read: only a stamp that is genuinely PRESENT and in
the PAST makes a document a deletion candidate. $gt: 0 is explicit so
a document with a zero stamp is never swept, and one with no stamp at
all cannot match the range predicate either.
164 165 166 167 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 164 def gc(max_lifetime = nil) _ = max_lifetime collection.delete_many("expires_at" => { "$gt" => 0, "$lt" => Time.now.to_f }) end |
#read(session_id) ⇒ Object
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 97 def read(session_id) doc = collection.find(_id: session_id).first return nil unless doc # Expiry is checked HERE, at read time, against the document's own # absolute deadline. Relying on the TTL index alone (as this handler used # to) cannot honour a short TTL at all: mongod's TTL monitor sweeps once # every 60 SECONDS, so a 2-second session stayed readable for up to a # minute after it expired. The index is still created, but purely as the # background reaper that keeps the collection from growing forever. if self.class.expired?(doc) destroy(session_id) return nil end doc["data"] end |
#write(session_id, data, ttl = 0) ⇒ Object
Write session data. A per-call ttl WINS over the handler default.
The ttl is consumed HERE, at write time, and baked into an ABSOLUTE
deadline (+expires_at+), so nothing at read time needs to know what the
ttl was. That field name and meaning are the shape Python (the master),
PHP and Node all store, so a session store SHARED between two frameworks
carries one shape instead of four. updated_at is still written to feed
the TTL index.
127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 127 def write(session_id, data, ttl = 0) effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl now = Time.now expires_at = effective_ttl.positive? ? now.to_f + effective_ttl : 0.0 collection.update_one( { _id: session_id }, { "$set" => { data: data, expires_at: expires_at, updated_at: now - (@ttl - effective_ttl) } }, upsert: true ) end |