Class: Parse::Cache::Keyspace

Inherits:
Object
  • Object
show all
Defined in:
lib/parse/cache/keyspace.rb

Overview

Owns the physical layout of every key this SDK writes to a shared cache backend, and the patterns used to delete them again.

A single object generates keys and the patterns that clear them, so the two can never disagree. Previously the caching middleware composed keys from a cache_namespace: it held privately while Parse::Cache::Redis held a separate namespace, and Parse::Client#clear_cache! cleared using only the latter. A client namespaced at the middleware but not at the wrapper would therefore delete every SDK key on the database rather than its own.

Layout:

parse-stack:<version>:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>

Every clear is a strict prefix of this, so narrowing the scope can only ever delete a subset:

all of this client   parse-stack:v1:<app_scope>[:<ns>]:*
one family           parse-stack:v1:<app_scope>[:<ns>]:<family>:*
one tenant           parse-stack:v1:<app_scope>[:<ns>]:<family>:T:<tenant>:*

app_scope is a digest of the Parse application id and server URL rather than the raw values. Two apps sharing one Redis with no namespace configured would otherwise collide, and a raw application id could carry glob metacharacters (*, [) that would silently widen a SCAN pattern. The digest is fixed-length and glob-safe by construction.

Create-locks are deliberately NOT part of this layout. They keep the historical parse-stack:foc:v1: prefix from Model::CreateLock. Moving them would mean that during a rolling deploy two workers compute different lock keys, stop contending on the same key, and silently lose mutual exclusion for the length of the deploy.

Constant Summary collapse

ROOT =

Root segment for every key this SDK owns.

"parse-stack"
VERSION =

Layout version. Bump only for a breaking key-shape change, which orphans every existing entry and therefore needs a migration note.

"v1"
NO_NAMESPACE =

Occupies the namespace position when no namespace is configured. Chosen because #normalize_segment rejects it as caller input, so a caller can never collide with the unnamespaced keyspace by naming their namespace after the sentinel.

"_"
FAMILIES =

Key families. cache is the Faraday response cache, idn is session-token identity, role is role closures.

%i[cache idn role].freeze
SCOPE_DIGEST_LENGTH =

Length of the truncated app/server digest. 12 hex characters is 48 bits, which is ample for distinguishing apps on one database while keeping keys readable.

12
GLOB_METACHARS =

Characters that carry meaning in a Redis glob pattern. A namespace or tenant containing one of these could widen a SCAN pattern beyond its intended scope, so they are rejected at construction rather than escaped, since a caller passing one is a configuration bug. : is included deliberately alongside the glob metacharacters. It is the segment separator, so a namespace of foo:bar would forge an extra segment and make the pattern for foo also match foo:bar, which is the same subset violation the sentinel above prevents.

/[\*\?\[\]\\\x00:]/.freeze
AUTH_ANON =

Auth discriminator for an anonymous (unauthenticated) request.

"anon"
AUTH_MASTER =

Auth discriminator for a master-key request.

"mk"
TOKEN_DIGEST_RE =

A session-token discriminator must look like the truncated SHA-256 the caching middleware produces. Anything else is a caller bug, and a raw token must never reach a key.

/\A[0-9a-f]{16,64}\z/.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app_id: nil, server_url: nil, namespace: nil, version: VERSION) ⇒ Keyspace

Returns a new instance of Keyspace.

Parameters:

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

    the Parse application id.

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

    the Parse server URL.

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

    optional operator-supplied namespace.

  • version (String) (defaults to: VERSION)

    layout version, for tests and migrations.

Raises:

  • (ArgumentError)

    if the namespace is unusable as a key segment.



94
95
96
97
98
99
# File 'lib/parse/cache/keyspace.rb', line 94

def initialize(app_id: nil, server_url: nil, namespace: nil, version: VERSION)
  @app_id = app_id&.to_s
  @app_scope = self.class.digest_scope(app_id, server_url)
  @namespace = normalize_segment(namespace, "namespace")
  @version = version.to_s
end

Instance Attribute Details

#app_idString? (readonly)

Returns the raw Parse application id. Retained alongside the digest because Parse Server's own cache keys use it verbatim (<appId>:role:<userId>), so an attached read needs the original even though our own layout uses the digest.

Returns:

  • (String, nil)

    the raw Parse application id. Retained alongside the digest because Parse Server's own cache keys use it verbatim (<appId>:role:<userId>), so an attached read needs the original even though our own layout uses the digest.



81
82
83
# File 'lib/parse/cache/keyspace.rb', line 81

def app_id
  @app_id
end

#app_scopeString (readonly)

Returns digest identifying the Parse app and server.

Returns:

  • (String)

    digest identifying the Parse app and server.



75
76
77
# File 'lib/parse/cache/keyspace.rb', line 75

def app_scope
  @app_scope
end

#namespaceString? (readonly)

Returns validated namespace, or nil when unset.

Returns:

  • (String, nil)

    validated namespace, or nil when unset.



84
85
86
# File 'lib/parse/cache/keyspace.rb', line 84

def namespace
  @namespace
end

#versionString (readonly)

Returns layout version segment.

Returns:

  • (String)

    layout version segment.



87
88
89
# File 'lib/parse/cache/keyspace.rb', line 87

def version
  @version
end

Class Method Details

.digest_scope(app_id, server_url) ⇒ String

Digest of the app id and server URL. Public so callers can compare two keyspaces for equivalence without reaching into internals.

Returns:

  • (String)

    fixed-length, glob-safe hex digest.



105
106
107
108
# File 'lib/parse/cache/keyspace.rb', line 105

def self.digest_scope(app_id, server_url)
  material = "#{app_id}\x00#{server_url}"
  Digest::SHA256.hexdigest(material)[0, SCOPE_DIGEST_LENGTH]
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Whether two keyspaces address the same key space. Used to detect a client reconfigured with a different namespace or app.

Returns:

  • (Boolean)


212
213
214
# File 'lib/parse/cache/keyspace.rb', line 212

def ==(other)
  other.is_a?(Keyspace) && other.root_prefix == root_prefix
end

#cache_key(url, auth:, tenant: nil) ⇒ String

Build a response-cache key.

auth: is mandatory and has no default. A master-key request bypasses ACL, CLP and protectedFields, so the same URL returns a strictly fuller body than a session-token request, and two different sessions can differ from each other through protectedFields entity rules and row ACLs. Collapsing those into one key would serve privileged fields to an unprivileged caller out of the cache, so the key cannot be built without stating which auth produced the body.

The URL is digested and placed before the discriminator so that every auth variant of one resource shares a prefix, which is what makes #resource_pattern able to invalidate a write for all callers rather than only the three variants the old delete_cache_variants could name.

Parameters:

  • url (String)

    the request URL.

  • auth (Symbol, String)

    :anon, :master, or a session-token digest.

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

    ambient cache tenant, if any.

Returns:



174
175
176
# File 'lib/parse/cache/keyspace.rb', line 174

def cache_key(url, auth:, tenant: nil)
  "#{resource_prefix(url, tenant: tenant)}:#{normalize_auth(auth)}"
end

#family_prefix(family) ⇒ String

Prefix for one family.

Parameters:

Returns:

Raises:

  • (ArgumentError)

    on an unknown family.



125
126
127
# File 'lib/parse/cache/keyspace.rb', line 125

def family_prefix(family)
  "#{root_prefix}:#{assert_family!(family)}"
end

#hashObject



218
219
220
# File 'lib/parse/cache/keyspace.rb', line 218

def hash
  root_prefix.hash
end

#inspectObject



226
227
228
# File 'lib/parse/cache/keyspace.rb', line 226

def inspect
  "#<Parse::Cache::Keyspace #{root_prefix}>"
end

#key(family, *segments, tenant: nil) ⇒ String

Build a key for the idn or role families.

Neither takes an auth discriminator: a role key is keyed by user id and an identity key by session token, so the key already is the auth identity. Only the response cache has one URL yielding different bodies to different callers, and it uses #cache_key.

Parameters:

  • family (Symbol, String)

    :idn or :role.

  • segments (Array<String>)

    trailing key segments, joined with :. Not validated for glob characters: they are only ever written and read as literal keys, never used as a SCAN pattern.

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

    ambient cache tenant, if any.

Returns:

Raises:

  • (ArgumentError)

    if called for the cache family.



143
144
145
146
147
148
149
150
151
152
153
# File 'lib/parse/cache/keyspace.rb', line 143

def key(family, *segments, tenant: nil)
  if family.to_sym == :cache
    raise ArgumentError,
          "Parse::Cache::Keyspace: use #cache_key for the cache family, " \
          "so the auth discriminator cannot be omitted"
  end
  parts = [family_prefix(family)]
  parts << "T:#{normalize_segment(tenant, "tenant")}" unless tenant.nil?
  parts.concat(segments.map(&:to_s))
  parts.join(":")
end

#pattern(family: nil, tenant: nil) ⇒ String

Glob pattern selecting keys to clear. With no arguments it selects every key this keyspace owns and nothing else.

Parameters:

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

    narrow to one family.

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

    narrow to one tenant. Requires family, since tenant is positioned inside the family segment.

Returns:

Raises:

  • (ArgumentError)

    if a tenant is given without a family.



197
198
199
200
201
202
203
204
205
206
207
# File 'lib/parse/cache/keyspace.rb', line 197

def pattern(family: nil, tenant: nil)
  if tenant && family.nil?
    raise ArgumentError,
          "Parse::Cache::Keyspace#pattern requires a family: when a tenant: is given"
  end
  return "#{root_prefix}:*" if family.nil?

  prefix = family_prefix(family)
  return "#{prefix}:*" if tenant.nil?
  "#{prefix}:T:#{normalize_segment(tenant, "tenant")}:*"
end

#resource_pattern(url, tenant: nil) ⇒ String

Pattern matching every auth variant of one resource. Used to invalidate a resource on write for all callers, including sessions this process has never seen.

Parameters:

  • url (String)

    the request URL.

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

    ambient cache tenant, if any.

Returns:



185
186
187
# File 'lib/parse/cache/keyspace.rb', line 185

def resource_pattern(url, tenant: nil)
  "#{resource_prefix(url, tenant: tenant)}:*"
end

#root_prefixString

Prefix shared by every key this keyspace owns, across all families.

Returns:



112
113
114
115
116
117
118
119
# File 'lib/parse/cache/keyspace.rb', line 112

def root_prefix
  # The namespace segment is ALWAYS emitted, using NO_NAMESPACE when unset.
  # Without it an unnamespaced root is a strict prefix of every namespaced
  # root for the same app, so `<root>:*` would also delete every named
  # namespace's keys. Narrowing must only ever delete a subset, and a
  # sentinel is the only way to keep the segment count fixed.
  [ROOT, @version, @app_scope, @namespace || NO_NAMESPACE].join(":")
end

#to_sObject



222
223
224
# File 'lib/parse/cache/keyspace.rb', line 222

def to_s
  root_prefix
end