Class: Parse::Cache::SubCache

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

Overview

A named plane within a Keyspace, exposing the get / set / invalidate contract that Parse::AtlasSearch's session_cache= and role_cache= slots accept.

The response cache speaks Moneta ([], key?, delete, store) because that is what the Faraday middleware requires. Identity and role resolution speak a different, smaller contract. Rather than flattening both onto one object, where get and [] would sit side by side and clear would be ambiguous about which plane it cleared, each plane is its own object over the same connection. This mirrors Parse Server's own CacheController / SubCache split.

Constant Summary collapse

GENERATION_TTL_FACTOR =

Generation keys live this many times longer than the longest entry they guard. Any factor above 1 is sufficient; 2 leaves margin for clock skew and for an entry written moments before a bump.

2

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(store:, keyspace:, family:, ttl: nil, digest_keys: nil) ⇒ SubCache

Returns a new instance of SubCache.



41
42
43
44
45
46
47
# File 'lib/parse/cache/sub_cache.rb', line 41

def initialize(store:, keyspace:, family:, ttl: nil, digest_keys: nil)
  @store = store
  @keyspace = keyspace
  @family = family.to_sym
  @ttl = ttl
  @digest_keys = digest_keys.nil? ? @family == :idn : digest_keys
end

Instance Attribute Details

#familySymbol (readonly)

Returns the keyspace family this plane writes.

Returns:

  • (Symbol)

    the keyspace family this plane writes.



22
23
24
# File 'lib/parse/cache/sub_cache.rb', line 22

def family
  @family
end

Instance Method Details

#bump_generation(subject) ⇒ Integer

Monotonic per-subject generation, used to invalidate entries that cannot be named.

A _User write tells us a user id, but identity entries are keyed by session token and there is no reverse map, so the entries belonging to that user cannot be enumerated. Bumping a generation the reader checks invalidates all of them in O(1), including tokens this process has never seen, without a master-key _Session query.

Generation keys expire, and their TTL must exceed the longest-lived entry they guard. One key per user id, written on every _User webhook, is unbounded growth on a public signup flow: every account that ever saves leaves a permanent key behind.

The TTL cannot be chosen freely, because expiry resets the counter to 0 and 0 is also the value for a user who has never been bumped. If a generation expired while an entry written at generation 0 were still alive, that entry would compare current again and come back from the dead after having been invalidated. #generation_ttl is therefore GENERATION_TTL_FACTOR times the plane's entry TTL, so every entry predating a bump has expired on its own before the counter can reset. #set clamps per-call TTLs to keep that invariant true.

A plane with no default TTL keeps permanent generations: entries there never expire, so no counter lifetime is safe.

Parameters:

  • subject (String)

    the user id.

Returns:

  • (Integer)

    the new generation.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/parse/cache/sub_cache.rb', line 116

def bump_generation(subject)
  return 0 if subject.nil?
  key = generation_key(subject)
  ttl = generation_ttl
  # Prefer an atomic INCR. A read-then-write loses a concurrent bump, and
  # a lost bump means an entry that should have been invalidated stays
  # readable, which is the permissive direction.
  if @store.respond_to?(:increment)
    value = @store.increment(key).to_i
    # INCR does not set an expiry, and re-applying it on each bump is
    # what keeps an actively-bumped user's counter alive.
    refresh_generation_expiry(key, value, ttl)
    value
  else
    current = @store[key].to_i
    @store.store(key, current + 1, ttl ? { expires: ttl } : {})
    current + 1
  end
end

#clearInteger

Evict every entry in this plane, and nothing outside it.

Returns:

  • (Integer)

    number of keys removed, when the store can report it.



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

def clear
  return 0 unless @store.respond_to?(:delete_matching)
  @store.delete_matching(@keyspace.pattern(family: @family))
end

#epochFloat

Returns the last invalidation epoch, 0.0 when never set.

Returns:

  • (Float)

    the last invalidation epoch, 0.0 when never set.



167
168
169
# File 'lib/parse/cache/sub_cache.rb', line 167

def epoch
  @store[epoch_key].to_f
end

#fresh_since_epoch?(written_at) ⇒ Boolean

Whether an entry written at written_at predates the last invalidation and must therefore be treated as a miss.

Parameters:

  • written_at (Float, nil)

    derived write time in wall-clock seconds.

Returns:

  • (Boolean)

    true when the entry is safe to use.



176
177
178
179
# File 'lib/parse/cache/sub_cache.rb', line 176

def fresh_since_epoch?(written_at)
  return false if written_at.nil?
  written_at.to_f >= epoch
end

#generation(subject) ⇒ Integer

Returns current generation, 0 when never bumped.

Parameters:

  • subject (String)

    the user id.

Returns:

  • (Integer)

    current generation, 0 when never bumped.



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

def generation(subject)
  return 0 if subject.nil?
  @store[generation_key(subject)].to_i
end

#generation_current?(subject, gen) ⇒ Boolean

Whether a value carrying gen is still current for subject.

Returns:

  • (Boolean)


145
146
147
# File 'lib/parse/cache/sub_cache.rb', line 145

def generation_current?(subject, gen)
  generation(subject).to_i == gen.to_i
end

#generation_ttlInteger?

Returns how long a generation key lives, or nil when this plane has no default TTL and generations must therefore be permanent. See #bump_generation for why the two are tied.

Returns:

  • (Integer, nil)

    how long a generation key lives, or nil when this plane has no default TTL and generations must therefore be permanent. See #bump_generation for why the two are tied.



52
53
54
55
# File 'lib/parse/cache/sub_cache.rb', line 52

def generation_ttl
  return nil if @ttl.nil?
  (@ttl * GENERATION_TTL_FACTOR).ceil
end

#get(key) ⇒ Object?

Returns the stored value, or nil on a miss.

Parameters:

  • key (String)

    the logical key (a token digest or user id).

Returns:

  • (Object, nil)

    the stored value, or nil on a miss.



59
60
61
62
# File 'lib/parse/cache/sub_cache.rb', line 59

def get(key)
  return nil if key.nil?
  decode(@store[@keyspace.key(@family, logical_key(key))])
end

#invalidate(key) ⇒ Object

Parameters:

  • key (String)

    the logical key.



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

def invalidate(key)
  return if key.nil?
  @store.delete(@keyspace.key(@family, logical_key(key)))
end

#set(key, value, ttl: nil) ⇒ Object

Parameters:

  • key (String)

    the logical key.

  • value (Object)

    a JSON-serializable value.

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

    seconds; falls back to the plane default.



67
68
69
70
71
72
73
# File 'lib/parse/cache/sub_cache.rb', line 67

def set(key, value, ttl: nil)
  return value if key.nil?
  effective = clamp_ttl(ttl || @ttl)
  options = effective ? { expires: effective } : {}
  @store.store(@keyspace.key(@family, logical_key(key)), encode(value), options)
  value
end

#touch_epoch(at = Time.now.to_f) ⇒ Float

Record that this plane was invalidated at at. Unlike a generation, which answers "is this exact subject stale", the epoch answers "is an entry written before now stale", which is what is needed to reject a foreign cache entry whose write time can only be derived from its remaining TTL.

Parameters:

  • at (Float) (defaults to: Time.now.to_f)

    wall-clock seconds; defaults to now.

Returns:

  • (Float)

    the recorded epoch.



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

def touch_epoch(at = Time.now.to_f)
  current = epoch
  # Never move the epoch backwards: clock skew between workers would
  # otherwise re-admit entries a previous invalidation had rejected.
  value = at > current ? at : current
  @store.store(epoch_key, value, {})
  value
end