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

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.



271
272
273
# File 'lib/parse/lock_backend.rb', line 271

def auto_secret
  @auto_secret ||= SecureRandom.hex(32)
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.



263
264
265
266
267
268
# File 'lib/parse/lock_backend.rb', line 263

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)


69
70
71
72
73
74
75
76
77
# File 'lib/parse/lock_backend.rb', line 69

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.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/parse/lock_backend.rb', line 96

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.



246
247
248
249
250
251
252
253
254
255
# File 'lib/parse/lock_backend.rb', line 246

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.



51
52
53
54
55
56
57
58
# File 'lib/parse/lock_backend.rb', line 51

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.



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

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.



181
182
183
# File 'lib/parse/lock_backend.rb', line 181

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.

Parameters:

Returns:

  • (Mutex)


193
194
195
196
197
198
199
# File 'lib/parse/lock_backend.rb', line 193

def process_mutex(key)
  @process_mutex_registry_lock ||= Mutex.new
  @process_mutex_registry_lock.synchronize do
    @process_mutex_registry ||= {}
    @process_mutex_registry[key] ||= Mutex.new
  end
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:



166
167
168
169
170
171
172
173
# File 'lib/parse/lock_backend.rb', line 166

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!

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.



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

def reset!
  @degraded_warned_at = nil
  @process_mutex_registry = nil
  @process_mutex_registry_lock = nil
  @auto_secret = nil
  @plain_sha_warned = nil
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)


135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/parse/lock_backend.rb', line 135

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.



281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/parse/lock_backend.rb', line 281

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