Class: Tina4::CacheBackends::MemcachedBackend
- Inherits:
-
BaseBackend
- Object
- BaseBackend
- Tina4::CacheBackends::MemcachedBackend
- Defined in:
- lib/tina4/cache_backends/memcached_backend.rb
Overview
Memcached backend using the zero-dependency text protocol over TCP (parity with Python _MemcachedBackend). Keys are SHA-256 hashed to stay within memcached's 250-char / no-space key constraints. Memcached has no auth, so credentials are ignored.
Constant Summary collapse
- PREFIX =
"tina4:cache:"- GENERATION_KEY =
The SHARED namespace generation counter. clear bumps it; every real key carries it, so a bump invalidates every instance at once.
"#{PREFIX}generation".freeze
- MAX_RELATIVE_EXPTIME =
memcached reads the
setexptime field as RELATIVE seconds at or below 30 days, and as an ABSOLUTE unix timestamp above it. 2_592_000
Instance Method Summary collapse
- #available? ⇒ Boolean
-
#clear ⇒ Object
Invalidate EVERY entry this cache can serve, on EVERY instance.
- #delete(key) ⇒ Object
- #get(key) ⇒ Object
-
#initialize(url: "memcached://localhost:11211", max_entries: 1000) ⇒ MemcachedBackend
constructor
A new instance of MemcachedBackend.
- #name ⇒ Object
- #set(key, value, ttl) ⇒ Object
-
#stats ⇒ Object
Report OUR entries, not the whole server's.
Methods inherited from BaseBackend
Constructor Details
#initialize(url: "memcached://localhost:11211", max_entries: 1000) ⇒ MemcachedBackend
Returns a new instance of MemcachedBackend.
23 24 25 26 27 28 29 30 31 32 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 23 def initialize(url: "memcached://localhost:11211", max_entries: 1000) cleaned = url.sub(%r{^memcached://}, "").sub(%r{^memcache://}, "") parts = cleaned.split("/").first.to_s.split(":") @host = parts[0].nil? || parts[0].empty? ? "localhost" : parts[0] @port = parts[1] && !parts[1].empty? ? parts[1].to_i : 11_211 @max_entries = max_entries @hits = 0 @misses = 0 @available = command("version\r\n", "\r\n").start_with?("VERSION") end |
Instance Method Details
#available? ⇒ Boolean
34 35 36 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 34 def available? @available end |
#clear ⇒ Object
Invalidate EVERY entry this cache can serve, on EVERY instance.
Two wrong answers were shipped before this one. flush_all wipes EVERY
key on the instance including every other application's - cache_clear is
public API, so calling it destroyed other tenants' data. Deleting only
the keys THIS process wrote fixed that but broke the contract the other
way: a second instance kept serving rows the first had just invalidated,
because it had never seen those keys.
The namespace generation does both. Bumping the shared counter orphans every previously-written entry for every instance at once, and touches nothing outside our own prefix. The orphans are reclaimed by memcached's own TTL and LRU - unreachable is what "removed" means for a cache.
The local write log is still cleared so stats reports honestly, and its keys are deleted eagerly so the space comes back immediately rather than waiting for eviction.
95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 95 def clear @hits = 0 @misses = 0 (@own || {}).each_key { |k| command("delete #{k}\r\n", "\r\n") } @own = {} # incr is atomic, so two instances clearing at once still both advance. return if command("incr #{GENERATION_KEY} 1\r\n", "\r\n").strip.match?(/\A\d+\z/) # No counter yet: create it. `add` fails harmlessly if another instance # created it in the gap, and the incr then applies. command("add #{GENERATION_KEY} 0 0 1\r\n1\r\n", "\r\n") command("incr #{GENERATION_KEY} 1\r\n", "\r\n") end |
#delete(key) ⇒ Object
71 72 73 74 75 76 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 71 def delete(key) k = mc_key(key) result = command("delete #{k}\r\n", "\r\n").start_with?("DELETED") (@own ||= {}).delete(k) result end |
#get(key) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 38 def get(key) resp = command("get #{mc_key(key)}\r\n", "END\r\n") if resp.start_with?("VALUE") begin header, rest = resp.split("\r\n", 2) nbytes = header.split[3].to_i @hits += 1 return JSON.parse(rest[0, nbytes]) rescue StandardError end end @misses += 1 nil end |
#name ⇒ Object
132 133 134 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 132 def name "memcached" end |
#set(key, value, ttl) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 53 def set(key, value, ttl) data = JSON.generate(value) k = mc_key(key) payload = "set #{k} 0 #{exptime(ttl)} #{data.bytesize}\r\n#{data}\r\n" command(payload, "\r\n") # Keys THIS backend wrote, mapped to the moment each expires (0 = never). # # This uses the RAW ttl, never the converted exptime. exptime(ttl) # returns an ABSOLUTE unix timestamp past the 30-day cliff, and feeding # that to Time.now.to_f + ... computes now + (now + ttl) - roughly twice # the current epoch, a date in 2076. It fails SILENTLY: the shadow map # would never expire anything, so the local bookkeeping stops matching # what the server holds and stats reports expired entries as live # forever. @own ||= {} @own[k] = ttl > 0 ? (Time.now.to_f + ttl) : 0.0 end |
#stats ⇒ Object
Report OUR entries, not the whole server's.
This used to read memcached's curr_items, which is a GLOBAL counter:
it includes every key written by every other tenant of that server. On a
shared memcached (the normal deployment) size was reporting somebody
else's data, and every other backend here is scoped - memory counts its
own hash, redis/valkey scan their own prefix, file counts its own
directory, mongo its own collection, database its own table. Memcached
was the only one leaking.
It cannot be fixed by asking the server: memcached has no KEYS or prefix-scan command. So the count comes from our own write log, filtered by the TTLs we set. That is exact for the keys this process wrote; a key EVICTED early under memory pressure is invisible to us and would be over-counted, which is a far smaller and more honest error than counting another application's keys.
125 126 127 128 129 130 |
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 125 def stats now = Time.now.to_f # Drop the expired ones so the log cannot grow without bound. @own = (@own || {}).select { |_k, expires| expires.zero? || expires > now } { hits: @hits, misses: @misses, size: @own.size, backend: "memcached" } end |