Class: Parse::Cache::ScopedView

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

Overview

An immutable, per-client view over one shared Redis backend.

Redis is a connection pool: sharing ONE backend across several Parse::Client instances (one Redis, several Parse apps) is a normal and supported deployment. What is not supported is sharing ownership of a single keyspace binding, which is what the old Parse::Cache::Redis#keyspace= setter allowed: client B calling keyspace = ks_b on a backend client A already configured rebinds the one @keyspace ivar out from under A. A's caching middleware had already captured A's keyspace object and keeps writing/reading under it, while clear, identity, roles, and the memoized upstream_roles on the now-shared backend answer with B's keyspace instead. A stops invalidating its own entries, or worse, a scoped clear issued through A now deletes B's keys.

A ScopedView closes that hole by never mutating the backend at all. Parse::Cache::Redis#scoped(keyspace) hands back one of these per caller, each carrying its own keyspace and its own memoized identity / roles / upstream_roles planes, all backed by the same underlying connection pool:

backend  = Parse::Cache::Redis.new(url: redis_url)
view_a   = backend.scoped(keyspace_a)
view_b   = backend.scoped(keyspace_b)

view_a and view_b share backend's pooled Redis connections but can never see or clear each other's keys.

Moneta surface. Implements [], key?, delete, store (and create / increment when the backend supports them) by delegating straight to the backend, so a view is a drop-in replacement anywhere a bare Parse::Cache::Redis was accepted: most importantly, the Faraday caching middleware.

Locks are NOT scoped. lock_acquire / lock_release delegate to the backend unchanged, still using the historical parse-stack:foc:v1: prefix from Parse::CreateLock. During a rolling deploy two workers must compute the SAME lock key regardless of which client/keyspace they were configured with, or first_or_create! silently loses cross-process mutual exclusion for the length of the deploy.

No flush_db!. A whole-database flush is a connection-level operation, not something a scoped view over one client's slice of the keyspace should be able to trigger.

Constant Summary collapse

DEFAULT_IDENTITY_TTL =

Safe defaults for callers that create a plane implicitly, most notably the webhook invalidation handlers. Keep these aligned with Parse::Authorization::Context's defaults: the first accessor call memoizes the plane and therefore fixes its generation lifetime.

3600
DEFAULT_ROLE_TTL =
30

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from MonetaSurface

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

Constructor Details

#initialize(backend:, keyspace:) ⇒ ScopedView

Returns a new instance of ScopedView.

Parameters:

Raises:

  • (ArgumentError)

    if keyspace is not a Parse::Cache::Keyspace.



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
106
107
108
109
110
111
112
113
# File 'lib/parse/cache/scoped_view.rb', line 81

def initialize(backend:, keyspace:)
  unless keyspace.is_a?(Parse::Cache::Keyspace)
    raise ArgumentError,
          "Parse::Cache::ScopedView keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
  end
  @backend = backend
  # The keyspace itself has no setters to begin with, but freezing it
  # here is a cheap extra guarantee that nothing downstream (this view
  # included) can mutate the layout this view was constructed with.
  @keyspace = keyspace.freeze

  # `create` and `increment` are only defined when the backend itself
  # supports them, so `respond_to?(:create)` on a view accurately
  # reflects what the backend can actually do rather than always
  # claiming support and raising NoMethodError on first use.
  if @backend.respond_to?(:create)
    define_singleton_method(:create) do |key, value, options = {}|
      @backend.create(key, value, options)
    end
  end
  if @backend.respond_to?(:increment)
    define_singleton_method(:increment) do |key, amount = 1, options = {}|
      @backend.increment(key, amount, options)
    end
  end
  # Value-preserving TTL. SubCache feature-detects this to avoid the
  # re-store that would lose a concurrent generation increment.
  if @backend.respond_to?(:expire)
    define_singleton_method(:expire) do |key, ttl|
      @backend.expire(key, ttl)
    end
  end
end

Instance Attribute Details

#backendParse::Cache::Redis (readonly)

Returns the shared backend this view reads and writes through. Exposed for introspection (e.g. tests asserting two views share one connection pool). The backend itself has no keyspace concept at all anymore (#scoped is the only way to associate a keyspace with it), so there is nothing on the backend left to rebind out from under this or any other view.

Returns:

  • (Parse::Cache::Redis)

    the shared backend this view reads and writes through. Exposed for introspection (e.g. tests asserting two views share one connection pool). The backend itself has no keyspace concept at all anymore (#scoped is the only way to associate a keyspace with it), so there is nothing on the backend left to rebind out from under this or any other view.



76
77
78
# File 'lib/parse/cache/scoped_view.rb', line 76

def backend
  @backend
end

#keyspaceParse::Cache::Keyspace (readonly)

Returns this view's key layout. Fixed at construction; there is no setter, so a view can never be rebound to a different keyspace after the fact.

Returns:

  • (Parse::Cache::Keyspace)

    this view's key layout. Fixed at construction; there is no setter, so a view can never be rebound to a different keyspace after the fact.



68
69
70
# File 'lib/parse/cache/scoped_view.rb', line 68

def keyspace
  @keyspace
end

Instance Method Details

#[](key) ⇒ Object



127
128
129
# File 'lib/parse/cache/scoped_view.rb', line 127

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

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

Clear cached entries belonging to THIS view's keyspace, and nothing else: never anything from another view over the same backend.

Parameters:

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

    explicit namespace prefix to scan-delete, narrowed inside this view's root_prefix. See Redis#clear for the exact semantics; the difference here is that there is no unscoped/FLUSHDB fallback branch to fall into, because a view always has a keyspace.

  • family (Symbol, String, nil) (defaults to: nil)

    narrow to one family.

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

    narrow to one tenant (requires family).

Returns:

  • (self)


156
157
158
159
160
161
162
163
164
# File 'lib/parse/cache/scoped_view.rb', line 156

def clear(scope: nil, family: nil, tenant: nil)
  if scope
    prefix = @backend.send(:validate_scope!, scope)
    raw_delete_matching!("#{@keyspace.root_prefix}:#{prefix}:*")
  else
    raw_delete_matching!(@keyspace.pattern(family: family, tenant: tenant))
  end
  self
end

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



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

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

#delete_matching(pattern) ⇒ Integer

Delete every key matching a glob pattern, refusing anything outside this view's own keyspace.

The check requires the segment boundary (root_prefix followed by :), not a bare string prefix: every pattern this keyspace actually generates (Keyspace#pattern, Keyspace#resource_pattern) is "<root_prefix>:...", so this rejects nothing legitimate. A bare start_with?(root_prefix) would accept a pattern belonging to a DIFFERENT namespace that merely shares a prefix: a view whose namespace is "foo" would happily delete_matching a pattern for namespace "foobar", since the string "...:foobar:..." starts with "...:foo".

Parameters:

  • pattern (String)

    a Redis glob pattern.

Returns:

  • (Integer)

    number of keys removed.



181
182
183
184
185
# File 'lib/parse/cache/scoped_view.rb', line 181

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

#identity(ttl: DEFAULT_IDENTITY_TTL) ⇒ Parse::Cache::SubCache

Parameters:

  • ttl (Integer, nil) (defaults to: DEFAULT_IDENTITY_TTL)

    default entry TTL; explicitly pass nil only for a deliberately permanent plane.

Returns:



195
196
197
# File 'lib/parse/cache/scoped_view.rb', line 195

def identity(ttl: DEFAULT_IDENTITY_TTL)
  @identity ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :idn, ttl: ttl)
end

#inspectObject



233
234
235
# File 'lib/parse/cache/scoped_view.rb', line 233

def inspect
  "#<Parse::Cache::ScopedView #{@keyspace.root_prefix}>"
end

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

Returns:

  • (Boolean)


131
132
133
# File 'lib/parse/cache/scoped_view.rb', line 131

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

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

load is the read primitive, delegated to the backend so the options argument reaches something that can honor it. Deriving it from [] here is what produced infinite recursion in an earlier version.



123
124
125
# File 'lib/parse/cache/scoped_view.rb', line 123

def load(key, options = {})
  @backend.load(key, options || {})
end

#lock_acquire(key, owner, ttl) ⇒ Object

See Also:



224
225
226
# File 'lib/parse/cache/scoped_view.rb', line 224

def lock_acquire(key, owner, ttl)
  @backend.lock_acquire(key, owner, ttl)
end

#lock_release(key, owner) ⇒ Object

See Also:



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

def lock_release(key, owner)
  @backend.lock_release(key, owner)
end

#roles(ttl: DEFAULT_ROLE_TTL) ⇒ Parse::Cache::SubCache

Parameters:

  • ttl (Integer, nil) (defaults to: DEFAULT_ROLE_TTL)

    default entry TTL; explicitly pass nil only for a deliberately permanent plane.

Returns:



202
203
204
# File 'lib/parse/cache/scoped_view.rb', line 202

def roles(ttl: DEFAULT_ROLE_TTL)
  @roles ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :role, ttl: ttl)
end

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



139
140
141
# File 'lib/parse/cache/scoped_view.rb', line 139

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

#upstream_rolesParse::Cache::UpstreamRoles?

Read-only consumer of Parse Server's own role cache, scoped to this view's app id and role plane. See Redis#upstream_roles for the full rationale; the only difference here is that the app id and roles plane come from THIS view's keyspace, never a sibling view's.

Returns:



212
213
214
215
216
217
218
219
# File 'lib/parse/cache/scoped_view.rb', line 212

def upstream_roles
  return nil if @backend.parse_cache_url.nil?
  @upstream_roles ||= Parse::Cache::UpstreamRoles.new(
    client: @backend.send(:upstream_client),
    app_id: @keyspace.app_id,
    roles_plane: roles,
  )
end