Class: Parse::Cache::Redis

Inherits:
Object
  • Object
show all
Defined in:
lib/parse/cache/redis.rb

Overview

Ergonomic Redis cache builder for Parse Stack. Composes a ConnectionPool of Moneta-Redis stores and carries an optional namespace that Parse::Client will pick up automatically — there is no need to also pass cache_namespace: to Parse.setup when using this wrapper.

Usage: Parse.setup( cache: Parse::Cache::Redis.new( url: "redis://localhost:6379/0", namespace: "app_x", pool_size: 10, ), expires: 60, ... )

The instance is a Moneta-compatible store (it delegates the four methods the Faraday caching middleware uses — [], key?, delete, store — to a pooled backend), so it can be passed directly to Parse.setup(cache:) / Parse::Client.new(cache:).

Constant Summary collapse

LOCK_RELEASE_SCRIPT =

Lua compare-and-delete: delete key only if its current value equals expected. Atomic on the Redis server (the GET, the compare, and the DEL are one script invocation), which closes the check-then-delete race in a naive GET-then-DEL release where the lease can expire and be re-acquired by another holder between the two commands.

<<~LUA
  if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
  else
    return 0
  end
LUA

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5, **moneta_options) ⇒ Redis

Returns a new instance of Redis.

Parameters:

  • url (String)

    Redis URL (e.g. "redis://localhost:6379/0").

  • namespace (String, nil) (defaults to: nil)

    optional key prefix so multiple Parse apps can share one Redis without colliding. When non-nil, the namespace is automatically forwarded to the caching middleware as cache_namespace:.

  • pool_size (Integer) (defaults to: 5)

    number of pooled Moneta-Redis stores. Defaults to 5 (the Puma default thread count).

    Sizing math (per Faraday request):

    • cache hit: key? + [] = 2 checkouts
    • GET miss + successful store: key? + 3 variant deletes (anonymous + master-key sibling + final key) + 1 store in on_complete = up to 5 checkouts
    • non-GET write (POST/PUT/DELETE): 3 variant deletes = 3 checkouts

    The worst case (5) is on the write-through-after-miss path, not the hit path. Rule of thumb: start at pool_size = RAILS_MAX_THREADS, then bump it up if you observe ConnectionPool::TimeoutError in parse.cache.error notifications (the middleware swallows that error into a passthrough request rather than raising to the caller).

  • pool_timeout (Numeric) (defaults to: 5)

    seconds to wait for a backend checkout before raising ConnectionPool::TimeoutError. Defaults to 5s. The caching middleware catches that error and falls back to a passthrough request rather than raising to the caller.

  • moneta_options (Hash)

    extra options passed through to Moneta.new(:Redis, ...) (e.g. :db, :connect_timeout). expires: true is set automatically so per-key TTLs supplied by the caching middleware (the :expires Faraday option) are honored by Redis. Pass expires: false here to opt out — but note that doing so causes cached responses to live forever, which is rarely what you want for a session-token-scoped response cache.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/parse/cache/redis.rb', line 74

def initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5, **moneta_options)
  @url = url
  @namespace = normalize_namespace(namespace)
  @pool_size = pool_size
  @pool_timeout = pool_timeout
  # Default expires: true so per-call `expires:` (the TTL the
  # Faraday caching middleware passes on store) is honored. The
  # Moneta-Redis adapter ignores per-call expires unless the
  # store was constructed with this flag. Without it, cached
  # session-scoped REST responses outlive their token's
  # validity. Callers can still pass `expires: false` to opt out.
  merged_options = { expires: true }.merge(moneta_options)
  # SECURITY: disable Moneta's value serializer so cached values are NOT
  # Marshal-encoded. We JSON-(de)serialize values ourselves in #store /
  # #[] (see #encode_value / #decode_value). The default Moneta-Redis
  # value serializer is Marshal, which would `Marshal.load` whatever
  # bytes come back from Redis on every cache hit — an arbitrary-code-
  # execution primitive if the Redis cache is shared, unauthenticated,
  # or reachable through a plaintext `redis://` MITM. Forcing nil here
  # (overriding any caller-supplied `value_serializer:`/`serializer:`)
  # keeps that gadget-deserialization vector closed regardless of how
  # the wrapper is configured. Keys keep the default (:marshal) encoding:
  # they are only ever written and SCAN/DEL-compared as opaque strings,
  # never `Marshal.load`ed from Redis content, so they are not a
  # deserialization vector.
  merged_options = merged_options.merge(value_serializer: nil)
  @moneta_options = merged_options
  @closed = false
  @pool = Pool.new(size: pool_size, timeout: pool_timeout) do
    Moneta.new(:Redis, { url: url }.merge(merged_options))
  end
end

Instance Attribute Details

#namespaceString? (readonly)

Returns cache key namespace prefix (or nil if not set).

Returns:

  • (String, nil)

    cache key namespace prefix (or nil if not set).



33
34
35
# File 'lib/parse/cache/redis.rb', line 33

def namespace
  @namespace
end

#pool_sizeInteger (readonly)

Returns pool size.

Returns:

  • (Integer)

    pool size.



36
37
38
# File 'lib/parse/cache/redis.rb', line 36

def pool_size
  @pool_size
end

#urlString (readonly)

Returns Redis connection URL.

Returns:

  • (String)

    Redis connection URL.



39
40
41
# File 'lib/parse/cache/redis.rb', line 39

def url
  @url
end

Instance Method Details

#[](key) ⇒ Object



107
108
109
# File 'lib/parse/cache/redis.rb', line 107

def [](key)
  decode_value(@pool[key])
end

#clear(scope: nil) ⇒ Object

Clear cached entries belonging to this wrapper. Required for Parse::Client#clear_cache! compatibility.

Namespace-scoped when a namespace is set: the wrapper walks <namespace>:* via Redis SCAN and DELs the matching keys, leaving other tenants on the same DB untouched. When no namespace is configured the wrapper falls back to FLUSHDB on the backing DB — same blast radius as previous versions, but only for unnamespaced deployments. To opt into the wide FLUSHDB explicitly (e.g. ops tooling), call #flush_db!.

Parameters:

  • scope (String, nil) (defaults to: nil)

    explicit namespace prefix to scan-delete. When provided, overrides the wrapper's configured @namespace and SCAN-deletes <scope>:* regardless of how the wrapper was built. This is the safe escape hatch for tenants that share a non- namespaced wrapper but still want to evict only their own keys without FLUSHDB-ing siblings (and without wiping parse-stack:foc:v1:* create-lock keys that live on the same DB). The scope must be a non-empty String; the trailing : is added automatically and any trailing : in the input is stripped so "tenant_x" and "tenant_x:" are equivalent.



213
214
215
216
217
218
219
220
221
222
223
# File 'lib/parse/cache/redis.rb', line 213

def clear(scope: nil)
  if scope
    prefix = validate_scope!(scope)
    delete_keys_matching!("#{prefix}:*")
  elsif @namespace
    delete_keys_matching!("#{@namespace}:*")
  else
    @pool.clear
  end
  self
end

#closeObject

Close all pooled connections. Safe to call multiple times.



235
236
237
238
239
# File 'lib/parse/cache/redis.rb', line 235

def close
  return if @closed
  @closed = true
  @pool.close
end

#create(key, value, options = {}) ⇒ Object

Atomic SETNX. Required so Parse::CreateLock can acquire cross-process locks when this wrapper is the configured cache / synchronize_create_store. Returns true only when the key did not already exist. The value goes through the same JSON encoding as #store so a later #[] read round-trips instead of decoding to nil. (Parse::LockBackend never hits this path on this wrapper — it prefers the raw-Redis #lock_acquire/#lock_release pair.)



130
131
132
# File 'lib/parse/cache/redis.rb', line 130

def create(key, value, options = {})
  @pool.create(key, encode_value(value), options)
end

#delete(key) ⇒ Object



115
116
117
# File 'lib/parse/cache/redis.rb', line 115

def delete(key)
  @pool.delete(key)
end

#flush_db!Object

Issue FLUSHDB on the backing Redis DB, regardless of whether a namespace is configured. Evicts every key on the selected DB, including unrelated tenants — use only for ops tooling that owns the whole DB.



229
230
231
232
# File 'lib/parse/cache/redis.rb', line 229

def flush_db!
  @pool.clear
  self
end

#increment(key, amount = 1, options = {}) ⇒ Object

Atomic counter increment. Forwarded for Moneta surface parity.



135
136
137
# File 'lib/parse/cache/redis.rb', line 135

def increment(key, amount = 1, options = {})
  @pool.increment(key, amount, options)
end

#key?(key) ⇒ Boolean

Returns:

  • (Boolean)


111
112
113
# File 'lib/parse/cache/redis.rb', line 111

def key?(key)
  @pool.key?(key)
end

#lock_acquire(key, owner, ttl) ⇒ Boolean

Atomically acquire a lock: SET key=owner only if absent, with a native expiry. Used by LockBackend for Lock and Parse::CreateLock. Deliberately bypasses Moneta's createMoneta.new(:Redis) marshals keys (and, by default, values), so a raw-Redis compare-and-delete on a Moneta-encoded blob would be fragile and coupled to Moneta's serializer config. Routing acquire AND release through plain-string raw Redis here keeps one consistent encoding across both ends of the lock and makes the keys human- inspectable in Redis (parse-stack:lock:v1:<digest>). Lock keys are short-lived (TTL ≤ 30s) so there is no migration concern when a deploy flips encodings.

Parameters:

  • key (String)

    plain-string lock key.

  • owner (String)

    unique-per-acquisition owner token.

  • ttl (Integer)

    seconds until the key self-clears.

Returns:

  • (Boolean)

    true when the key was set (lock acquired).



169
170
171
172
173
174
175
# File 'lib/parse/cache/redis.rb', line 169

def lock_acquire(key, owner, ttl)
  @pool.pool.with do |store|
    redis = backend_client(store)
    # redis-rb returns "OK" on success, nil when NX fails.
    !!redis.set(key, owner, nx: true, ex: ttl)
  end
end

#lock_release(key, owner) ⇒ Boolean

Atomically release a lock via compare-and-delete. Only the holder whose owner token still matches the stored value deletes the key — a holder whose lease already expired and was re-acquired by someone else is a no-op, never a cross-holder delete.

Parameters:

Returns:

  • (Boolean)

    true when this owner's key was deleted.



185
186
187
188
189
190
# File 'lib/parse/cache/redis.rb', line 185

def lock_release(key, owner)
  @pool.pool.with do |store|
    redis = backend_client(store)
    redis.eval(LOCK_RELEASE_SCRIPT, keys: [key], argv: [owner]).to_i == 1
  end
end

#store(key, value, options = {}) ⇒ Object



119
120
121
# File 'lib/parse/cache/redis.rb', line 119

def store(key, value, options = {})
  @pool.store(key, encode_value(value), options)
end