Class: Parse::Cache::Redis
- Inherits:
-
Object
- Object
- Parse::Cache::Redis
- 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
keyonly if its current value equalsexpected. 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
-
#namespace ⇒ String?
readonly
Cache key namespace prefix (or nil if not set).
-
#parse_cache_url ⇒ String?
readonly
Parse Server's cache database URL, when attached.
-
#pool_size ⇒ Integer
readonly
Pool size.
-
#url ⇒ String
readonly
Redis connection URL.
Instance Method Summary collapse
- #[](key) ⇒ Object
-
#clear(scope: nil, family: nil, tenant: nil) ⇒ Object
Clear cached entries belonging to this wrapper.
-
#close ⇒ Object
Close all pooled connections.
-
#create(key, value, options = {}) ⇒ Object
Atomic SETNX.
-
#delete(key, options = {}) ⇒ Object
Returns the DECODED value, matching Moneta, which returns what was removed.
-
#delete_matching(pattern) ⇒ Integer
Delete every key matching a glob pattern.
-
#expire(key, ttl) ⇒ Boolean
Set a TTL on an existing key WITHOUT touching its value.
-
#flush_db! ⇒ Object
Issue
FLUSHDBon the backing Redis DB, regardless of whether a namespace is configured. -
#increment(key, amount = 1, options = {}) ⇒ Object
Atomic counter increment.
-
#initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5, parse_cache_url: nil, **moneta_options) ⇒ Redis
constructor
A new instance of Redis.
- #key?(key, options = {}) ⇒ Boolean
-
#load(key, options = {}) ⇒ Object
Moneta's read primitive.
-
#lock_acquire(key, owner, ttl) ⇒ Boolean
Atomically acquire a lock: SET key=owner only if absent, with a native expiry.
-
#lock_release(key, owner) ⇒ Boolean
Atomically release a lock via compare-and-delete.
-
#scoped(keyspace) ⇒ Parse::Cache::ScopedView
There is deliberately no
keyspacereader/writer and nokeyspace:constructor option on this class anymore. -
#store(key, value, options = {}) ⇒ Object
Returns the value as given, matching Moneta.
-
#upstream_roles ⇒ Parse::Cache::UpstreamRoles?
Read-only view of Parse Server's role cache, or nil when not attached.
-
#verify_upstream_isolation!(on_degraded: :warn_throttled) ⇒ Boolean, :unknown
Verify the attached endpoint is a different database from ours, and warn if not.
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.
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, **) @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 .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. = { expires: true }.merge() # 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. = .merge(value_serializer: nil) @moneta_options = @closed = false @pool = Pool.new(size: pool_size, timeout: pool_timeout) do Moneta.new(:Redis, { url: url }.merge()) end end |
Instance Attribute Details
#namespace ⇒ String? (readonly)
Returns 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_url ⇒ String? (readonly)
Returns 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_size ⇒ Integer (readonly)
Returns pool size.
46 47 48 |
# File 'lib/parse/cache/redis.rb', line 46 def pool_size @pool_size end |
#url ⇒ String (readonly)
Returns 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.
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 |
#close ⇒ Object
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, = {}) @pool.create(key, encode_value(value), ) 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, = {}) decode_value(@pool.delete(key, || {})) 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.
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.
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, = {}) @pool.increment(key, amount, ) end |
#key?(key, options = {}) ⇒ Boolean
314 315 316 |
# File 'lib/parse/cache/redis.rb', line 314 def key?(key, = {}) @pool.key?(key, || {}) 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, = {}) decode_value(@pool.load(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 create —
Moneta.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.
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.
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.
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, = {}) @pool.store(key, encode_value(value), || {}) value end |
#upstream_roles ⇒ Parse::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.
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.
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 |