Class: Tina4::SessionHandlers::MongoHandler

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/session_handlers/mongo_handler.rb

Class Method Summary collapse

Instance Method Summary collapse

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(options = {})
  # TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
  # 86400. 3600 matches Python (the master), PHP and Node.
  @ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
  @uri = options[:uri] || ENV["TINA4_SESSION_MONGO_URI"] || ENV["TINA4_SESSION_MONGO_URL"] || "mongodb://localhost:27017"
  @database = options[:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
  @collection_name = options[: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.

Returns:

  • (Boolean)


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

#cleanupObject



143
144
145
# File 'lib/tina4/session_handlers/mongo_handler.rb', line 143

def cleanup
  gc
end

#closeObject

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.

Parameters:

  • max_lifetime (Integer) (defaults to: nil)

    accepted for interface parity; expiry is absolute and already baked into expires_at at write time.



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.

Parameters:

  • session_id (String)

    the session id

  • data (Hash)

    the payload to store

  • ttl (Integer) (defaults to: 0)

    per-call lifetime in seconds; 0 uses the handler default



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