Class: Tina4::SessionHandlers::MemcachedHandler

Inherits:
Object
  • Object
show all
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

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(options = {})
  options ||= {}
  @host = options[:host] || ENV["TINA4_SESSION_MEMCACHED_HOST"] || "localhost"
  @port = (options[:port] || ENV["TINA4_SESSION_MEMCACHED_PORT"] || 11_211).to_i
  @prefix = options[:prefix] || ENV["TINA4_SESSION_MEMCACHED_PREFIX"] || "tina4:session:"
  @ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
  @timeout = (options[:timeout] || 5).to_f
end

Instance Method Details

#cleanupObject

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.

Parameters:

  • max_age (Integer, nil)

    accepted for interface parity; memcached owns its own expiry, so nothing here consults it.



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