Module: Parse::LockBackend Private

Defined in:
lib/parse/lock_backend.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

Shared low-level primitives for both CreateLock (the internal lock used by first_or_create! / create_or_update!) and Lock (the public mutual-exclusion primitive). The extraction exists so Lock does not reach into CreateLock's private methods via .send — a brittle coupling pattern called out in the v5.1.0 round-2 review. Both callers now depend on a small documented surface and any future refactor of the lock store discovery / degraded-detection heuristic / atomic-SETNX semantics happens in exactly one place.

Not a public API for application code. @!visibility private is intentional. End users compose with locking through Parse::Lock.acquire (block-form) or first_or_create! / create_or_update! (find-or-create). This module is documented only because SDK extension authors and security auditors need to know where the SETNX semantics actually live.

Constant Summary collapse

DEFAULT_POLL_BASE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Base poll interval for the wait-loop spin in the caller's acquire loop. Caller adds jitter via poll_interval; this constant is the midpoint.

0.05
DEFAULT_POLL_JITTER =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Half-width of the symmetric jitter applied around DEFAULT_POLL_BASE. Spreads contended-acquire spin starts so N waiters don't all hit try_acquire on the same monotonic tick after a release.

0.015
DEGRADED_WARNING_THROTTLE_SECONDS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Throttle floor for handle_degraded(:warn_throttled). One warning per process per this many seconds; subsequent degraded acquisitions are silent.

60
PROCESS_MUTEX_REGISTRY_MAX =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Upper bound on the in-process Mutex registry (degraded fallback path). Without a cap, every distinct lock key leaks a permanent Mutex — a memory-exhaustion vector when keys are high-cardinality or attacker-influenced. Real deployments use a handful of lock keys, so this ceiling is far above normal use and only bites a runaway/abusive caller.

4096

Class Method Summary collapse

Class Method Details

.auto_secretString

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns per-process random secret. Memoized.

Returns:

  • (String)

    per-process random secret. Memoized.



387
388
389
# File 'lib/parse/lock_backend.rb', line 387

def auto_secret
  @auto_secret ||= SecureRandom.hex(32)
end

.checkin_process_mutex(key) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Release a pending-acquirer reservation taken by checkout_process_mutex.

Parameters:



272
273
274
275
276
277
278
279
280
281
# File 'lib/parse/lock_backend.rb', line 272

def checkin_process_mutex(key)
  @process_mutex_registry_lock ||= Mutex.new
  @process_mutex_registry_lock.synchronize do
    pending = (@process_mutex_pending ||= Hash.new(0))
    if pending.key?(key)
      pending[key] -= 1
      pending.delete(key) if pending[key] <= 0
    end
  end
end

.checkout_process_mutex(key) ⇒ Mutex

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Atomically get-or-create the per-key Mutex AND reserve it for the caller (pending-acquirer count += 1) so eviction cannot reclaim it before the caller locks it. MUST be balanced by checkin_process_mutex in an ensure.

Parameters:

Returns:

  • (Mutex)


256
257
258
259
260
261
262
263
264
# File 'lib/parse/lock_backend.rb', line 256

def checkout_process_mutex(key)
  @process_mutex_registry_lock ||= Mutex.new
  @process_mutex_registry_lock.synchronize do
    mutex = registry_get_or_create(key)
    pending = (@process_mutex_pending ||= Hash.new(0))
    pending[key] += 1
    mutex
  end
end

.configured_secretString?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns operator-configured HMAC secret from Parse.synchronize_create_secret or PARSE_STACK_LOCK_SECRET. The env-var and accessor names carry "synchronize_create" / "LOCK" historical naming; both Parse::CreateLock and Parse::Lock consume the same value.

Returns:

  • (String, nil)

    operator-configured HMAC secret from Parse.synchronize_create_secret or PARSE_STACK_LOCK_SECRET. The env-var and accessor names carry "synchronize_create" / "LOCK" historical naming; both Parse::CreateLock and Parse::Lock consume the same value.



379
380
381
382
383
384
# File 'lib/parse/lock_backend.rb', line 379

def configured_secret
  if Parse.respond_to?(:synchronize_create_secret) && Parse.synchronize_create_secret
    return Parse.synchronize_create_secret.to_s
  end
  ENV["PARSE_STACK_LOCK_SECRET"]
end

.degraded_store?(store) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Decide whether store is process-local (Memory / Null / missing-:create / nil) — i.e. cannot serve as a cross- process lock store, so the caller should fall back to a per-key in-process Mutex. The Cache::Redis wrapper is explicitly accepted because it doesn't expose a Moneta .adapter chain to walk.

Parameters:

  • store (Object, nil)

    candidate store

Returns:

  • (Boolean)


77
78
79
80
81
82
83
84
85
# File 'lib/parse/lock_backend.rb', line 77

def degraded_store?(store)
  return true if store.nil?
  return false if defined?(Parse::Cache::Redis) && store.is_a?(Parse::Cache::Redis)
  return true unless store.respond_to?(:create)
  bottom = walk_to_adapter(store)
  return true if bottom.nil?
  klass_name = bottom.class.name.to_s
  klass_name.include?("Memory") || klass_name.include?("Null")
end

.handle_degraded(mode, key, source: "Parse::LockBackend", unavailable_error: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Emit the configured degraded-store warning. source: lets the caller tag the prefix so an operator reading [Parse::Lock] Lock store is process-local knows which caller surfaced the warning (vs [Parse::CreateLock] for the find-or-create path).

Parameters:

  • mode (Symbol)

    one of :warn, :warn_throttled, :proceed, :raise. Callers are responsible for validating the symbol BEFORE calling this; an unknown value here falls through to plain :warn.

  • key (String)

    the (already-hashed) cache key — used only for the debug snippet in the warning message.

  • source (String) (defaults to: "Parse::LockBackend")

    caller tag for the log prefix.

  • unavailable_error (Class) (defaults to: nil)

    error class to raise in :raise mode. Lets each caller raise its own typed error (Parse::CreateLockUnavailableError vs Parse::Lock::UnavailableError) without coupling here.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/parse/lock_backend.rb', line 104

def handle_degraded(mode, key, source: "Parse::LockBackend",
                    unavailable_error: nil)
  case mode
  when :raise
    err = unavailable_error || Parse::Error
    raise err,
          "#{source}: cross-process lock store unavailable; " \
          "current store is process-local"
  when :proceed
    # silent
  when :warn_throttled
    now = monotonic_now
    if @degraded_warned_at.nil? ||
       (now - @degraded_warned_at) >= DEGRADED_WARNING_THROTTLE_SECONDS
      @degraded_warned_at = now
      warn "[#{source}] Lock store is process-local (Moneta Memory/Null). " \
           "Cross-process locking is NOT in effect. Configure a Redis-backed " \
           "cache to enable distributed locking."
    end
  else
    warn "[#{source}] Lock store is process-local; cross-process locking disabled. " \
         "key_digest=#{key.is_a?(String) ? key[-12..] : key.inspect}"
  end
end

.lock_secret_for(store:, source: "Parse::LockBackend") ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resolve the HMAC secret for lock-key derivation. Behavior depends on store type:

  • Configured secret present — returned verbatim (operator set Parse.synchronize_create_secret or PARSE_STACK_LOCK_SECRET).
  • Degraded (process-local) store, no configured secret — returns the per-process auto-derived random secret. Locking is already process-local in this branch, so a per-process secret is fine and improves test/single-process privacy by preventing KEYS * enumeration.
  • Cross-process store, no configured secret — returns nil with a one-time warn. Per-process auto-derived secrets would break cross-process key equality (and therefore the lock itself), so the caller falls back to plain SHA-256 and gets a loud nudge to configure a real secret.

Parameters:

  • store (Object, nil)

    the lock store (used only for degraded detection).

  • source (String) (defaults to: "Parse::LockBackend")

    caller tag for the warn-once message — "Parse::CreateLock" or "Parse::Lock".

Returns:

  • (String, nil)

    the secret, or nil to indicate plain SHA.



362
363
364
365
366
367
368
369
370
371
# File 'lib/parse/lock_backend.rb', line 362

def lock_secret_for(store:, source: "Parse::LockBackend")
  configured = configured_secret
  return configured if configured && !configured.empty?
  if degraded_store?(store)
    auto_secret
  else
    warn_plain_sha_once(source: source)
    nil
  end
end

.lock_storeObject?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Find the Moneta store the lock should write through. Resolved at call time (not memoized) so a test or an operator can swap Parse.synchronize_create_store after boot and see the change take effect on the next acquisition.

Returns:

  • (Object, nil)

    a Moneta-shaped store, or nil when none is configured / the Parse client is unconfigured.



59
60
61
62
63
64
65
66
# File 'lib/parse/lock_backend.rb', line 59

def lock_store
  if Parse.respond_to?(:synchronize_create_store) && Parse.synchronize_create_store
    return Parse.synchronize_create_store
  end
  Parse.cache
rescue Parse::Error::ConnectionError
  nil
end

.monotonic_nowFloat

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns CLOCK_MONOTONIC seconds.

Returns:

  • (Float)

    CLOCK_MONOTONIC seconds.



317
318
319
# File 'lib/parse/lock_backend.rb', line 317

def monotonic_now
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

.poll_intervalFloat

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Jittered poll interval for the wait-loop. Symmetric jitter around DEFAULT_POLL_BASE of half-width DEFAULT_POLL_JITTER; the result is bounded but non-deterministic so contended waiters don't sync up.

Returns:

  • (Float)

    seconds.



189
190
191
# File 'lib/parse/lock_backend.rb', line 189

def poll_interval
  DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
end

.process_mutex(key) ⇒ Mutex

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Per-key in-process Mutex registry for the degraded fallback path. The first acquisition for a given key creates the Mutex; subsequent acquisitions reuse it. Registry itself is guarded by a tiny outer Mutex so two threads racing the first acquisition of the same key get the same Mutex.

The registry is bounded at PROCESS_MUTEX_REGISTRY_MAX entries via approximate-LRU eviction: reused keys move to the MRU end, and when a new key would overflow the cap the oldest evictable mutexes are dropped first. A Mutex is evictable only when it is neither locked NOR reserved by a pending acquirer (see synchronize_process_mutex); dropping either would let a concurrent acquisition mint a fresh Mutex for the same key and split mutual exclusion. Under pathological all-in-use saturation the registry may briefly exceed the cap rather than break correctness.

NOTE: a Mutex handed back here is eligible for eviction until it is locked. Callers that lock it later (rather than immediately) must go through synchronize_process_mutex, which reserves the key.

Parameters:

Returns:

  • (Mutex)


243
244
245
246
# File 'lib/parse/lock_backend.rb', line 243

def process_mutex(key)
  @process_mutex_registry_lock ||= Mutex.new
  @process_mutex_registry_lock.synchronize { registry_get_or_create(key) }
end

.registry_get_or_create(key) ⇒ Mutex

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Get-or-create + approximate-LRU eviction. MUST be called with @process_mutex_registry_lock already held (all three public entry points synchronize on it before calling in).

Parameters:

Returns:

  • (Mutex)


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
# File 'lib/parse/lock_backend.rb', line 290

def registry_get_or_create(key)
  reg = (@process_mutex_registry ||= {})

  if (existing = reg[key])
    # Move to the MRU end so the eviction scan treats it as
    # recently used (Ruby Hashes preserve insertion order).
    reg.delete(key)
    reg[key] = existing
    return existing
  end

  if reg.size >= PROCESS_MUTEX_REGISTRY_MAX
    pending = (@process_mutex_pending ||= Hash.new(0))
    reg.keys.each do |k|
      break if reg.size < PROCESS_MUTEX_REGISTRY_MAX
      m = reg[k]
      # Evict only Mutexes that are neither locked nor reserved by a
      # pending acquirer — either state means a caller is (about to
      # be) inside it, and reclaiming would split mutual exclusion.
      reg.delete(k) unless m.locked? || pending[k] > 0
    end
  end

  reg[key] = Mutex.new
end

.release(store, key, owner) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compare-and-delete release. When the store exposes an atomic primitive (Parse::Cache::Redis → server-side Lua CAD), use it so a holder whose lease expired and was re-acquired by someone else can never delete the new holder's key. Falls back to a best-effort GET-then-DEL for raw-Moneta stores, where the worst-case cross-holder-delete race is bounded by the short TTL (callers clamp ttl: to ≤ 30s) — documented residual risk for the non-Redis path.

Parameters:



174
175
176
177
178
179
180
181
# File 'lib/parse/lock_backend.rb', line 174

def release(store, key, owner)
  return store.lock_release(key, owner) if store.respond_to?(:lock_release)

  current = store[key]
  store.delete(key) if current == owner
rescue StandardError => e
  warn "[Parse::LockBackend] release error (#{e.class}): #{e.message}"
end

.reset!void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Reset backend-owned state. Intended for test teardown — production code should never call this.



325
326
327
328
329
330
331
332
# File 'lib/parse/lock_backend.rb', line 325

def reset!
  @degraded_warned_at = nil
  @process_mutex_registry = nil
  @process_mutex_registry_lock = nil
  @process_mutex_pending = nil
  @auto_secret = nil
  @plain_sha_warned = nil
end

.synchronize_process_mutex(key) { ... } ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Run block while holding the per-key in-process Mutex for the degraded fallback path, safe against registry eviction. This is the API callers should use — NOT the bare process_mutex accessor.

process_mutex returns an unlocked Mutex; there is a window between the accessor returning and the caller reaching Mutex#synchronize. Under registry saturation the approximate-LRU eviction could observe that Mutex as unlocked and reclaim it, after which a second caller mints a DISTINCT Mutex for the same key — splitting mutual exclusion. This method closes the window by registering the caller as a pending acquirer inside the same critical section that hands out the Mutex, and eviction never reclaims a key with pending acquirers (or a locked Mutex).

Parameters:

Yields:

  • executed while holding the per-key Mutex.

Returns:

  • the block's return value.



211
212
213
214
215
216
217
218
# File 'lib/parse/lock_backend.rb', line 211

def synchronize_process_mutex(key, &block)
  mutex = checkout_process_mutex(key)
  begin
    mutex.synchronize(&block)
  ensure
    checkin_process_mutex(key)
  end
end

.try_acquire(store, key, owner, ttl) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Atomic SETNX-style acquisition. Returns true on success, false on contention OR error (logged). Never raises — the caller's wait loop is the source of truth for "did we get the lock," and a transient store error should look the same to the loop as "someone else has it."

Parameters:

  • store (Object)

    Moneta-shaped store responding to :create and :key?.

  • key (String)

    cache key (already prefixed/hashed).

  • owner (String)

    unique-per-acquisition identifier used by release's compare-and-delete.

  • ttl (Integer)

    seconds before the store entry self- clears (crash-recovery floor).

Returns:

  • (Boolean)


143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/parse/lock_backend.rb', line 143

def try_acquire(store, key, owner, ttl)
  # Prefer the store's native atomic lock primitive when it exposes
  # one (Parse::Cache::Redis). That path uses raw-Redis
  # `SET key owner NX EX ttl` with plain-string encoding so it pairs
  # with the atomic compare-and-delete in {.release}. Falls back to
  # Moneta `:create` (also an atomic SETNX) for raw-Moneta stores.
  return store.lock_acquire(key, owner, ttl) if store.respond_to?(:lock_acquire)

  # Trigger lazy TTL sweep on Moneta::Memory before `:create`
  # (no-op on Redis). Without this, the Memory adapter returns
  # false on `:create` even after TTL expiry until a `:key?`
  # or `:[]` access flushes the stale entry.
  store.key?(key)
  store.create(key, owner, expires: ttl)
rescue StandardError => e
  warn "[Parse::LockBackend] acquire error (#{e.class}): #{e.message}"
  false
end

.warn_plain_sha_once(source:) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

One-time process-scoped warn when a cross-process lock store is in use without an operator-configured HMAC secret. The warning text explains both the enumeration risk (key material is deterministic) and the lock-pinning risk (when the cache and lock store share a Redis DB) and points at the remediation knobs.



397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/parse/lock_backend.rb', line 397

def warn_plain_sha_once(source:)
  return if @plain_sha_warned
  @plain_sha_warned = true
  warn "[#{source}:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
       "Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
       "Risks of running without an HMAC secret: (1) lock keys are deterministic and may expose key " \
       "material content via Redis MONITOR/snapshots; (2) when the response cache and the lock store " \
       "share a Redis DB, any caller with write access to Parse.cache can plant a lock key under a " \
       "guessable digest and pin the lock for that resource until TTL expiry — a targeted DoS / " \
       "lock-pinning primitive. Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') " \
       "to enable HMAC keying, or point Parse.synchronize_create_store at a separate Redis DB from " \
       "the response cache."
end