Class: Parse::Cache::Redis

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

Methods included from MonetaSurface

#[]=, #fetch, #fetch_values, #merge!, #slice, #values_at

Constructor Details

#initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5, parse_cache_url: nil, **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.



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/parse/cache/redis.rb', line 254

def initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5,
               parse_cache_url: nil, **moneta_options)
  @url = url
  # Parse Server's own cache database, read-only and optional. It must NOT
  # be the same database as `url:`: on released Parse Server a `_Role`
  # write FLUSHDBs the whole database, which would take this SDK's
  # response cache and, worse, its create-locks with it.
  @parse_cache_url = parse_cache_url
  @namespace = normalize_namespace(namespace)
  # A caller-supplied Moneta `prefix:` silently rewrites the physical key
  # layout underneath us, which would break every SCAN pattern this class
  # builds and quietly restore the unscoped-clear behavior the keyspace
  # exists to prevent. Reject it rather than trying to compose with it.
  if moneta_options.key?(:prefix)
    raise ArgumentError,
          "Parse::Cache::Redis does not accept a Moneta prefix: option; it would " \
          "change the physical key layout that scoped clearing depends on. Use " \
          "namespace: instead."
  end
  @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).



43
44
45
# File 'lib/parse/cache/redis.rb', line 43

def namespace
  @namespace
end

#parse_cache_urlString? (readonly)

Returns Parse Server's cache database URL, when attached.

Returns:

  • (String, nil)

    Parse Server's cache database URL, when attached.



79
80
81
# File 'lib/parse/cache/redis.rb', line 79

def parse_cache_url
  @parse_cache_url
end

#pool_sizeInteger (readonly)

Returns pool size.

Returns:

  • (Integer)

    pool size.



46
47
48
# File 'lib/parse/cache/redis.rb', line 46

def pool_size
  @pool_size
end

#urlString (readonly)

Returns Redis connection URL.

Returns:

  • (String)

    Redis connection URL.



49
50
51
# File 'lib/parse/cache/redis.rb', line 49

def url
  @url
end

Instance Method Details

#[](key) ⇒ Object



310
311
312
# File 'lib/parse/cache/redis.rb', line 310

def [](key)
  load(key, {})
end

#clear(scope: nil, family: nil, tenant: 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!.

This backend never has a keyspace of its own (see #scoped), so family: / tenant: cannot be honored here: interpreting them needs a root_prefix, and only a ScopedView has one.

Passing either is therefore a hard error rather than a silent no-op. Ignoring them would take the else branch below and issue FLUSHDB, so a caller asking to clear ONE family on an unnamespaced backend would wipe the entire database: every other family, every other app sharing the backend, and the parse-stack:foc:v1:* create-locks whose loss silently removes first_or_create! mutual exclusion. A request to narrow must never widen. Use backend.scoped(keyspace).clear(family:) instead.

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.

Raises:

  • (ArgumentError)

    when family: or tenant: is given.



460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/parse/cache/redis.rb', line 460

def clear(scope: nil, family: nil, tenant: nil)
  unless family.nil? && tenant.nil?
    raise ArgumentError,
          "Parse::Cache::Redis#clear cannot honor family:/tenant: without a keyspace, " \
          "and ignoring them here would fall through to FLUSHDB. " \
          "Call backend.scoped(keyspace).clear(family:, tenant:) instead."
  end

  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.



507
508
509
510
511
# File 'lib/parse/cache/redis.rb', line 507

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.)



340
341
342
# File 'lib/parse/cache/redis.rb', line 340

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

#delete(key, options = {}) ⇒ Object

Returns the DECODED value, matching Moneta, which returns what was removed. This used to hand back the raw JSON string this wrapper had encoded on the way in, so delete and store returned "{\"a\":1}" where every other read returned {"a" => 1}.



322
323
324
# File 'lib/parse/cache/redis.rb', line 322

def delete(key, options = {})
  decode_value(@pool.delete(key, options || {}))
end

#delete_matching(pattern) ⇒ Integer

Delete every key matching a glob pattern. Exposed so the caching middleware can evict all auth variants of one resource on write, which it cannot do by naming keys: it has no way to enumerate the entries belonging to sessions this process has never seen.

This backend never has a keyspace of its own (see #scoped), so a direct call here is always a no-op: there is no root_prefix to confine the pattern to. Callers with a keyspace should call backend.scoped(keyspace).delete_matching(pattern) instead, which confines the pattern to that view's own root_prefix before it ever reaches Redis.

Parameters:

  • pattern (String)

    a Redis glob pattern.

Returns:

  • (Integer)

    number of keys removed. Always 0 on this backend.



493
494
495
# File 'lib/parse/cache/redis.rb', line 493

def delete_matching(pattern)
  0
end

#expire(key, ttl) ⇒ Boolean

Set a TTL on an existing key WITHOUT touching its value.

Exists because INCR sets no expiry, and the only other way to add one through Moneta is to re-store the value, which loses concurrent increments. For SubCache's generation counters a lost increment moves the counter backwards, and since generation checks are equality comparisons, a counter returning to a previously issued value re-admits the identity entries that value had invalidated. PEXPIRE touches only the TTL, so it cannot do that.

Parameters:

  • key (String)

    the physical key.

  • ttl (Numeric)

    seconds.

Returns:

  • (Boolean)

    true when the key existed and the TTL was set.



362
363
364
365
366
367
368
369
# File 'lib/parse/cache/redis.rb', line 362

def expire(key, ttl)
  return false if ttl.nil?
  @pool.pool.with do |store|
    !!backend_client(store).pexpire(key, (ttl.to_f * 1000).round)
  end
rescue StandardError
  false
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.



501
502
503
504
# File 'lib/parse/cache/redis.rb', line 501

def flush_db!
  @pool.clear
  self
end

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

Atomic counter increment. Forwarded for Moneta surface parity.



345
346
347
# File 'lib/parse/cache/redis.rb', line 345

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

#key?(key, options = {}) ⇒ Boolean

Returns:

  • (Boolean)


314
315
316
# File 'lib/parse/cache/redis.rb', line 314

def key?(key, options = {})
  @pool.key?(key, options || {})
end

#load(key, options = {}) ⇒ Object

Moneta's read primitive. Defined here rather than derived, because only this class knows how to reach its pool and how to decode what comes back.



306
307
308
# File 'lib/parse/cache/redis.rb', line 306

def load(key, options = {})
  decode_value(@pool.load(key, options || {}))
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).



401
402
403
404
405
406
407
# File 'lib/parse/cache/redis.rb', line 401

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.



417
418
419
420
421
422
# File 'lib/parse/cache/redis.rb', line 417

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

#scoped(keyspace) ⇒ Parse::Cache::ScopedView

There is deliberately no keyspace reader/writer and no keyspace: constructor option on this class anymore. This backend is a shared connection pool: several Parse::Client instances (several Parse apps, or several tenants) pointing one Parse::Cache::Redis at the same Redis is a normal, supported deployment. A mutable keyspace binding on the SHARED object was not, because a second client calling keyspace = ks_b rebound the one @keyspace ivar out from under the first: client A's caching middleware kept using A's keyspace object (captured at construction) while clear, identity, roles, and the memoized upstream_roles on this now-shared object answered with B's. A stopped invalidating its own entries, or a scoped clear issued through A deleted B's keys instead.

#scoped replaces that mutable path entirely: it hands back a ScopedView carrying its own keyspace and its own memoized identity/roles/upstream_roles, so two callers can share this backend's connection pool without ever being able to share, or steal, keyspace ownership.

Derive a per-client view over this shared backend.

Parameters:

Returns:



74
75
76
# File 'lib/parse/cache/redis.rb', line 74

def scoped(keyspace)
  Parse::Cache::ScopedView.new(backend: self, keyspace: keyspace)
end

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

Returns the value as given, matching Moneta. The encoded form is an implementation detail of how it is stored and must not leak out.



328
329
330
331
# File 'lib/parse/cache/redis.rb', line 328

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

#upstream_rolesParse::Cache::UpstreamRoles?

Read-only view of Parse Server's role cache, or nil when not attached.

Uses a raw redis-rb client rather than the Moneta pool: Moneta's Redis adapter issues a MULTI/PEXPIRE pipeline on every read when built with expires:, which a credential restricted to +get +pttl rejects with NOPERM.

This backend-level reader has no keyspace, and therefore no app id or role-plane freshness gate to scope itself with: #scoped is the only way to bind one to an app. Its app_id is nil, so key_for / roles_for on this instance can never match a real Parse Server entry (which is always <appId>:role:<userId>). They will only ever report a miss. Prefer backend.scoped(keyspace).upstream_roles for any real read. This method is NOT used by #verify_upstream_isolation!, which needs a database-wide probe rather than one scoped to a single (missing) app id and builds its own reader.

Returns:



99
100
101
102
# File 'lib/parse/cache/redis.rb', line 99

def upstream_roles
  return nil if @parse_cache_url.nil?
  @upstream_roles ||= UpstreamRoles.new(client: upstream_client, app_id: nil, roles_plane: nil)
end

#verify_upstream_isolation!(on_degraded: :warn_throttled) ⇒ Boolean, :unknown

Verify the attached endpoint is a different database from ours, and warn if not.

Deliberately a warning rather than a refusal: the hazard comes entirely from the upstream FLUSHDB bug, so it disappears on a server carrying the scoped-clear fix, and refusing to boot would be permanently wrong there. It routes through the existing degraded-lock path so the caller's on_degraded: decides whether to warn or raise, because the consequence that is not merely a performance loss is a create-lock deleted mid-hold, which silently removes first_or_create! mutual exclusion.

Three outcomes, because two of them were previously collapsed into one and the collapse hid a false negative:

  • true: isolation positively established. A sentinel written to our database was NOT visible through the upstream connection.
  • false: sharing positively established, and warned about.
  • :unknown: neither could be established. Truthy, so callers that branch on truthiness behave as before, but distinguishable for callers that want to escalate. This is what a credential restricted to ~<appId>:role:* produces: the sentinel read comes back NOPERM, which says nothing about which database it was denied on.

The scan alone cannot return true. It only ever finds a Parse-Server-shaped key or fails to, and "no such key" is equally consistent with a separate database and with a shared one on which Parse Server has not yet cached a role. A stack that was just deployed is in that second state, which is precisely when an operator runs this check, so treating the empty scan as proof of isolation returned a confident "isolated" for the shared case it exists to catch.

Returns:

  • (Boolean, :unknown)

    see above.



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/parse/cache/redis.rb', line 137

def verify_upstream_isolation!(on_degraded: :warn_throttled)
  return true if @parse_cache_url.nil?
  # Deliberately NOT {#upstream_roles}: this backend has no keyspace
  # (and therefore no app id: that can only come from {#scoped} now)
  # to build a reader from, and there is no single "the" app id to use
  # here anyway: this backend can be shared by clients of more than one
  # app (see {#scoped}), and this check is a database-sharing probe,
  # not a per-app one.
  #
  # `UpstreamRoles#shares_database_with?` scans for
  # `"#{app_id}:role:*"`. Two wrong ways to pick `app_id` were tried and
  # rejected here, in favor of a third:
  #
  # - `nil` turns that into the literal pattern `":role:*"`, which never
  #   matches a real Parse Server key (`<appId>:role:<userId>`, no
  #   leading colon), so the probe always reports "isolated", even on
  #   a database that is genuinely shared. Silently disables the exact
  #   warning this method exists to raise.
  # - A bare `"*"` wildcard produces `"*:role:*"`. Redis (and
  #   `File.fnmatch`) glob `*` crosses `:` just like any other
  #   character, so that pattern ALSO matches this SDK's own role-plane
  #   keys (`parse-stack:v1:<scope>:<ns>:role:<userId>`). The probe
  #   would report "shared" as soon as the role plane held anything,
  #   even on a database that is genuinely isolated. A permanent false
  #   positive is worse than the false negative it replaced: it trains
  #   operators to ignore the warning.
  #
  # The fix keeps the broad `"*"` wildcard (so this still catches ANY
  # app's cached role, not one we'd have to already know the id of) and
  # keeps only keys matching Parse Server's own three-segment
  # `<appId>:role:<userId>` shape, so `shares_database_with?` can only
  # ever see a genuine upstream entry. See {ExcludeOwnKeysScanner} for
  # why the filter matches the upstream shape rather than rejecting a
  # `parse-stack:` prefix.
  reader = UpstreamRoles.new(client: upstream_client, app_id: "*")
  # The probe scans OUR database for THEIR key pattern, so it needs a
  # scannable client rather than this Moneta-shaped wrapper.
  shared = @pool.pool.with do |store|
    reader.shares_database_with?(ExcludeOwnKeysScanner.new(backend_client(store)))
  end

  unless shared
    # The scan found nothing, which does not distinguish a separate
    # database from a shared one Parse Server has not written to yet.
    # Settle it by writing a key only we can have written and asking
    # the upstream connection whether it can see it.
    #
    # Only two of the three outcomes return. `:shared` deliberately
    # falls through to the warning path below, which is the same
    # handling a positive scan gets.
    case sentinel_probe
    when :isolated then return true
    when :unknown
      warn "[Parse::Cache::Redis] could not verify that parse_cache_url addresses a " \
           "different Redis database than url. The probe key was neither readable nor " \
           "conclusively absent through the upstream connection, which is what a " \
           "credential restricted to ~<appId>:role:* produces. Confirm the two " \
           "databases differ by hand, or grant the reader GET on parse-stack:probe:* " \
           "so this check can answer. " \
           "See https://github.com/parse-community/parse-server/issues/10617"
      return :unknown
    end
  end

  if defined?(Parse::LockBackend)
    Parse::LockBackend.handle_degraded(
      on_degraded, "cache:shared-database", source: "Parse::Cache::Redis",
    )
  end
  warn "[Parse::Cache::Redis] parse_cache_url resolves to the same Redis database as " \
       "url. Parse Server clears its cache with FLUSHDB on every _Role write, which " \
       "deletes this SDK's cached responses and its parse-stack:foc:v1:* create-locks, " \
       "so first_or_create! loses cross-process mutual exclusion. Point the two at " \
       "different databases (redis://host/0 and redis://host/1), or run a Parse Server " \
       "carrying the scoped-clear fix. " \
       "See https://github.com/parse-community/parse-server/issues/10617"
  false
end