Class: Tina4::SessionHandlers::MemcachedHandler
- Inherits:
-
Object
- Object
- Tina4::SessionHandlers::MemcachedHandler
- Defined in:
- lib/tina4/session_handlers/memcached_handler.rb
Overview
Memcached session handler - zero-dependency text protocol over TCP.
Memcached was already one of the seven CACHE backends in all four frameworks but was NOT a session backend in any of them, even though it is the classic PHP session store. This closes that gap.
Speaks the memcached TEXT protocol directly over a socket, so there is no gem dependency - the same zero-dependency choice the Redis/Valkey handlers make.
BACKEND-FAILURE POLICY. A genuine key miss returns {} silently (no
session yet is normal). A TRANSPORT failure - server unreachable,
connection dropped mid-reply, a protocol error - RAISES, so the Session
layer can log-loud and degrade. Collapsing the two is how a dead cache
silently logs every user out.
Memcached has no persistence and no replication: a restart drops every session. That is a deliberate trade (it is a cache), and it is why file/database remain the defaults.
Environment variables:
TINA4_SESSION_MEMCACHED_HOST - hostname (default: localhost)
TINA4_SESSION_MEMCACHED_PORT - port (default: 11211)
TINA4_SESSION_MEMCACHED_PREFIX - key prefix (default: tina4:session:)
TINA4_SESSION_TTL - session TTL in seconds (default: 3600)
Constant Summary collapse
- MAX_KEY_BYTES =
Memcached rejects a key over 250 bytes or containing a space/control character. A key that could break either rule is HASHED rather than truncated - truncating would let two different sessions collide on one key, handing one user another user's session.
250- MAX_RELATIVE_EXPTIME =
memcached's exptime field changes meaning at 30 days: at or below this it is RELATIVE seconds, above it the server reads an ABSOLUTE UNIX TIMESTAMP. See #exptime for why we convert instead of clamping.
2_592_000
Instance Method Summary collapse
-
#cleanup ⇒ Object
No-op - memcached expires its own keys via the TTL set on write.
-
#destroy(session_id) ⇒ Object
Delete a session.
-
#gc(_max_age = nil) ⇒ Object
Garbage-collect expired sessions.
-
#initialize(options = {}) ⇒ MemcachedHandler
constructor
A new instance of MemcachedHandler.
-
#read(session_id) ⇒ Object
Read a session.
-
#write(session_id, data, ttl = 0) ⇒ Object
Write a session with a TTL (0 falls back to the configured default).
Constructor Details
#initialize(options = {}) ⇒ MemcachedHandler
Returns a new instance of MemcachedHandler.
46 47 48 49 50 51 52 53 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 46 def initialize( = {}) ||= {} @host = [:host] || ENV["TINA4_SESSION_MEMCACHED_HOST"] || "localhost" @port = ([:port] || ENV["TINA4_SESSION_MEMCACHED_PORT"] || 11_211).to_i @prefix = [:prefix] || ENV["TINA4_SESSION_MEMCACHED_PREFIX"] || "tina4:session:" @ttl = ([:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i @timeout = ([:timeout] || 5).to_f end |
Instance Method Details
#cleanup ⇒ Object
No-op - memcached expires its own keys via the TTL set on write.
92 93 94 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 92 def cleanup nil end |
#destroy(session_id) ⇒ Object
Delete a session. A session that was already gone is not an error.
86 87 88 89 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 86 def destroy(session_id) command("delete #{key(session_id)}\r\n", ["DELETED\r\n", "NOT_FOUND\r\n", "ERROR\r\n"]) nil end |
#gc(_max_age = nil) ⇒ Object
Garbage-collect expired sessions. Memcached expires its own keys via the TTL set on write, so there is genuinely nothing to sweep - but the ARGUMENT still has to be accepted, because Session#gc calls handler.gc(max_lifetime) with exactly one argument.
This was alias gc cleanup, and #cleanup takes ZERO arguments, so
MemcachedHandler#gc.arity was 0 and EVERY session GC against a memcached
backend raised ArgumentError "wrong number of arguments (given 1,
expected 0)". Session#gc's rescue then reported it as a BACKEND failure -
"Session gc failed (...MemcachedHandler): wrong number of arguments" -
so an internal arity bug was misattributed to the operator's memcached,
and a perfectly healthy server logged an ERROR on every sweep.
FileHandler#gc(max_age = nil) and DatabaseHandler#gc(max_age) both take
the argument; memcached was the odd one out.
113 114 115 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 113 def gc(_max_age = nil) cleanup end |
#read(session_id) ⇒ Object
Read a session. Returns {} for a genuine miss; RAISES on a transport failure so an outage is never mistaken for "no session yet".
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 57 def read(session_id) resp = command("get #{key(session_id)}\r\n", ["END\r\n"]) return {} unless resp.start_with?("VALUE") header, rest = resp.split("\r\n", 2) return {} if rest.nil? bytes = header.split[3].to_i parsed = JSON.parse(rest[0, bytes]) parsed.is_a?(Hash) ? parsed : {} rescue JSON::ParserError # A corrupt value is treated as no session rather than crashing the # request; the next write replaces it. {} end |
#write(session_id, data, ttl = 0) ⇒ Object
Write a session with a TTL (0 falls back to the configured default).
74 75 76 77 78 79 80 81 82 83 |
# File 'lib/tina4/session_handlers/memcached_handler.rb', line 74 def write(session_id, data, ttl = 0) effective_ttl = exptime(ttl.to_i.positive? ? ttl.to_i : @ttl) payload = JSON.generate(data) cmd = "set #{key(session_id)} 0 #{effective_ttl} #{payload.bytesize}\r\n" resp = command("#{cmd}#{payload}\r\n", ["STORED\r\n", "ERROR\r\n", "SERVER_ERROR", "CLIENT_ERROR"]) return if resp.start_with?("STORED") raise "Memcached did not store the session: #{resp[0, 80].inspect}" end |