Class: Tina4::CacheBackends::MemcachedBackend

Inherits:
BaseBackend
  • Object
show all
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 set exptime field as RELATIVE seconds at or below 30 days, and as an ABSOLUTE unix timestamp above it.

2_592_000

Instance Method Summary collapse

Methods inherited from BaseBackend

#sweep

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

Returns:

  • (Boolean)


34
35
36
# File 'lib/tina4/cache_backends/memcached_backend.rb', line 34

def available?
  @available
end

#clearObject

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

#nameObject



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

#statsObject

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