Class: Parse::Cache::KeyspacedStore

Inherits:
Object
  • Object
show all
Includes:
MonetaSurface
Defined in:
lib/parse/cache/scoped_view.rb

Overview

Keyspace wrapper for a cache store that is not a Redis and therefore cannot produce a ScopedView.

cache_keyspace: true used to leave such a store installed bare. Key composition still worked, because the caching middleware receives the keyspace directly, so the deployment looked correctly keyspaced. But Parse::Client#clear_cache! called the store's own clear, and on a plain Moneta.new(:Redis) that is FLUSHDB. Asking for keyspacing and receiving a database-wide flush inverts the entire point of the option: it deletes other applications' entries and, on a shared database, the parse-stack:foc:v1:* create-locks, so a first_or_create! holding a lock at that moment silently loses mutual exclusion.

This wrapper keeps the store usable for reads and writes and makes the clear honest. Where the store can enumerate its keys (Moneta's each_key feature), the clear is scan-and-delete confined to the keyspace, matching what a ScopedView does. Where it cannot, the clear raises rather than widening: a store that cannot express "delete only my keys" has no safe answer, and the wrong answer is unrecoverable.

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from MonetaSurface

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

Constructor Details

#initialize(store:, keyspace:) ⇒ KeyspacedStore

Returns a new instance of KeyspacedStore.



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/parse/cache/scoped_view.rb', line 283

def initialize(store:, keyspace:)
  unless keyspace.is_a?(Parse::Cache::Keyspace)
    raise ArgumentError,
          "Parse::Cache::KeyspacedStore keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
  end
  @wrapped = store
  @keyspace = keyspace.freeze

  # Mirror the wrapped store's optional capabilities rather than always
  # claiming them, so `respond_to?` stays truthful and callers that
  # feature-detect (the create-lock path, SubCache's atomic increment)
  # get the same answer they would from the store itself.
  # `fetch` and `each_key` are deliberately NOT in this list.
  #
  # `fetch` must come from {Parse::Cache::MonetaSurface}, which routes
  # through this wrapper's own `load`. Delegating it handed the call
  # straight to the wrapped store and skipped the wrapper entirely.
  #
  # `each_key` is defined below with keyspace filtering. Delegating it
  # enumerated the WHOLE store, so `sdk_cache.each_key` returned the
  # application's keys alongside the SDK's, which is precisely the
  # confusion this class exists to prevent.
  %i[create increment expire features lock_acquire lock_release].each do |name|
    next unless wrapped_supports?(name)
    define_singleton_method(name) { |*args, **kw, &blk| @wrapped.public_send(name, *args, **kw, &blk) }
  end

  # Only claim `each_key` when the wrapped store can genuinely enumerate.
  if wrapped_supports?(:each_key)
    define_singleton_method(:each_key) do |&blk|
      return enum_for(:each_key) unless blk
      prefix = "#{@keyspace.root_prefix}:"
      @wrapped.each_key { |k| blk.call(k) if k.to_s.start_with?(prefix) }
      self
    end
  end

  # Probe arity ONCE instead of rescuing at call time. See #key?.
  @options_aware = %i[key? delete].to_h { |name| [name, accepts_options?(name)] }
end

Instance Attribute Details

#keyspaceParse::Cache::Keyspace (readonly)



276
277
278
# File 'lib/parse/cache/scoped_view.rb', line 276

def keyspace
  @keyspace
end

#wrappedObject (readonly)

Returns the wrapped store. Named wrapped rather than store because store is Moneta's writer method, which this class must keep implementing for the Faraday caching middleware.

Returns:

  • (Object)

    the wrapped store. Named wrapped rather than store because store is Moneta's writer method, which this class must keep implementing for the Faraday caching middleware.



281
282
283
# File 'lib/parse/cache/scoped_view.rb', line 281

def wrapped
  @wrapped
end

Instance Method Details

#[](key) ⇒ Object



334
# File 'lib/parse/cache/scoped_view.rb', line 334

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

#clear(scope: nil, family: nil, tenant: nil) ⇒ self

Delete every entry under this keyspace, and nothing else.

Returns:

  • (self)

Raises:

  • (Parse::Cache::UnscopedClearRefused)

    when the wrapped store cannot enumerate its keys, and therefore cannot clear within a keyspace. Use a Redis for scoped clearing, or call client.cache.clear to take the unscoped clear deliberately.



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/parse/cache/scoped_view.rb', line 363

def clear(scope: nil, family: nil, tenant: nil)
  prefix = if scope
      "#{@keyspace.root_prefix}:#{scope.to_s.sub(/:\z/, "")}:"
    else
      pattern = @keyspace.pattern(family: family, tenant: tenant)
      pattern.sub(/\*\z/, "")
    end

  unless wrapped_supports?(:each_key)
    raise UnscopedClearRefused,
          "#{@wrapped.class} cannot enumerate its keys, so it cannot clear only the entries " \
          "under #{@keyspace.root_prefix}. Refusing rather than falling back to an " \
          "unscoped clear, which on a Redis-backed store is FLUSHDB and would delete " \
          "other applications' entries and any parse-stack:foc:v1:* create-locks. " \
          "Use Parse::Cache::Redis for scoped clearing, or call " \
          "client.cache.clear to take the unscoped clear deliberately."
  end

  # Collect before deleting: mutating during enumeration is undefined
  # across Moneta adapters.
  doomed = []
  @wrapped.each_key { |k| doomed << k if k.to_s.start_with?(prefix) }
  doomed.each { |k| @wrapped.delete(k) }
  self
end

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



351
352
353
354
# File 'lib/parse/cache/scoped_view.rb', line 351

def delete(key, options = {})
  return @wrapped.delete(key, options || {}) if @options_aware[:delete]
  @wrapped.delete(key)
end

#delete_matching(pattern) ⇒ Integer

Returns number of keys removed.

Parameters:

  • pattern (String)

    a glob pattern, refused unless it sits inside this keyspace.

Returns:

  • (Integer)

    number of keys removed.



392
393
394
395
396
397
398
399
400
401
# File 'lib/parse/cache/scoped_view.rb', line 392

def delete_matching(pattern)
  return 0 if pattern.nil? || pattern.to_s.empty?
  return 0 unless pattern.to_s.start_with?("#{@keyspace.root_prefix}:")
  return 0 unless wrapped_supports?(:each_key)

  doomed = []
  @wrapped.each_key { |k| doomed << k if File.fnmatch(pattern, k.to_s, File::FNM_NOESCAPE) }
  doomed.each { |k| @wrapped.delete(k) }
  doomed.size
end

#inspectObject



403
404
405
# File 'lib/parse/cache/scoped_view.rb', line 403

def inspect
  "#<Parse::Cache::KeyspacedStore #{@keyspace.root_prefix} over #{@wrapped.class}>"
end

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

Options are forwarded when the wrapped store can take them, decided by arity at construction.

This used to call with options and rescue ArgumentError to retry without them. That is wrong twice over: a store's own argument validation also raises ArgumentError, so a genuine rejection was retried rather than surfaced, and for delete the retry meant the deletion could run TWICE, once before the raise and once after. Deciding from arity is exact and happens once.

Returns:

  • (Boolean)


346
347
348
349
# File 'lib/parse/cache/scoped_view.rb', line 346

def key?(key, options = {})
  return @wrapped.key?(key, options || {}) if @options_aware[:key?]
  @wrapped.key?(key)
end

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

Delegated primitives. load falls back to [] for a wrapped store that predates Moneta's options argument, so an older custom store still works and simply ignores the hint.



329
330
331
332
# File 'lib/parse/cache/scoped_view.rb', line 329

def load(key, options = {})
  return @wrapped.load(key, options || {}) if @wrapped.respond_to?(:load)
  @wrapped[key]
end

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



335
# File 'lib/parse/cache/scoped_view.rb', line 335

def store(key, value, options = {}) = @wrapped.store(key, value, options || {})