Class: Parse::Cache::KeyspacedStore
- Inherits:
-
Object
- Object
- Parse::Cache::KeyspacedStore
- 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
- #keyspace ⇒ Parse::Cache::Keyspace readonly
-
#wrapped ⇒ Object
readonly
The wrapped store.
Instance Method Summary collapse
- #[](key) ⇒ Object
-
#clear(scope: nil, family: nil, tenant: nil) ⇒ self
Delete every entry under this keyspace, and nothing else.
- #delete(key, options = {}) ⇒ Object
-
#delete_matching(pattern) ⇒ Integer
Number of keys removed.
-
#initialize(store:, keyspace:) ⇒ KeyspacedStore
constructor
A new instance of KeyspacedStore.
- #inspect ⇒ Object
-
#key?(key, options = {}) ⇒ Boolean
Options are forwarded when the wrapped store can take them, decided by arity at construction.
-
#load(key, options = {}) ⇒ Object
Delegated primitives.
- #store(key, value, options = {}) ⇒ Object
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, (name)] } end |
Instance Attribute Details
#keyspace ⇒ Parse::Cache::Keyspace (readonly)
276 277 278 |
# File 'lib/parse/cache/scoped_view.rb', line 276 def keyspace @keyspace end |
#wrapped ⇒ Object (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.
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.
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, = {}) return @wrapped.delete(key, || {}) if @options_aware[:delete] @wrapped.delete(key) end |
#delete_matching(pattern) ⇒ Integer
Returns 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 |
#inspect ⇒ Object
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.
346 347 348 349 |
# File 'lib/parse/cache/scoped_view.rb', line 346 def key?(key, = {}) return @wrapped.key?(key, || {}) 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, = {}) return @wrapped.load(key, || {}) 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, = {}) = @wrapped.store(key, value, || {}) |