Module: Parse::CreateLock
- Defined in:
- lib/parse/model/core/create_lock.rb
Overview
Mutual-exclusion primitive for ‘first_or_create!` / `create_or_update!` to prevent TOCTOU duplicate creation under concurrency. Backed by a Moneta cache store (typically Redis) using atomic `#create` (SETNX semantics).
This is the *latency optimization* layer; a MongoDB unique index on the constrained tuple is the correctness floor that survives Redis outages, TTL expiry races, and bypassed locks. The lock here is best-effort and fails open by design when the store is unreachable.
Threading model:
-
Process-local fallback: when the store is the in-memory Moneta adapter (or unconfigured), the lock degrades to a per-key ‘Mutex`. Threads in the same Ruby process serialize; cross-process callers do not. This is safe for single-Puma-process tests but does not protect production deployments with multiple dynos / workers.
-
Cross-process: when the store is Redis-backed Moneta, ‘#create` is atomic and the lock excludes other processes.
Key derivation: HMAC-SHA256 of a canonical payload when a secret is configured (preferred); plain SHA256 otherwise (deterministic across processes — required for Redis-backed locking — but key material is enumerable via Redis MONITOR/snapshots). Operators wanting hardened key material against snapshot/MONITOR exposure should set ‘PARSE_STACK_LOCK_SECRET` or `Parse.synchronize_create_secret`.
Constant Summary collapse
- DEFAULT_TTL =
3- DEFAULT_WAIT =
2.0- DEFAULT_POLL_BASE =
0.05- DEFAULT_POLL_JITTER =
0.015- MAX_TTL =
30- MAX_WAIT =
30- MAX_PAYLOAD_BYTES =
8_192- MAX_DEPTH =
4- KEY_PREFIX =
"parse-stack:foc:v1:"- DEGRADED_WARNING_THROTTLE_SECONDS =
60
Class Method Summary collapse
-
.canonical_key(parse_class:, query_attrs:, session_token: nil, master_key: nil) ⇒ String
Canonical lock key for the given inputs.
-
.synchronize(parse_class:, query_attrs:, options: {}, session_token: nil, master_key: nil, &block) ⇒ Object
Run ‘block` while holding a mutex keyed by the canonical form of `parse_class + auth context + query_attrs`.
Class Method Details
.canonical_key(parse_class:, query_attrs:, session_token: nil, master_key: nil) ⇒ String
Canonical lock key for the given inputs. Public for tests.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/parse/model/core/create_lock.rb', line 115 def canonical_key(parse_class:, query_attrs:, session_token: nil, master_key: nil) principal = principal_marker(session_token, master_key) attrs_payload = canonicalize_attrs(query_attrs, parse_class: parse_class) app_id = parse_application_id payload = "#{app_id}|#{parse_class}|#{principal}|#{attrs_payload}" if payload.bytesize > MAX_PAYLOAD_BYTES raise Parse::CreateLockInvalidKey, "synchronize key payload exceeds #{MAX_PAYLOAD_BYTES} bytes (got #{payload.bytesize})" end secret = lock_secret_for(store: lock_store) digest = if secret OpenSSL::HMAC.hexdigest("SHA256", secret, payload) else Digest::SHA256.hexdigest(payload) end "#{KEY_PREFIX}#{digest}" end |
.synchronize(parse_class:, query_attrs:, options: {}, session_token: nil, master_key: nil, &block) ⇒ Object
Run ‘block` while holding a mutex keyed by the canonical form of `parse_class + auth context + query_attrs`. Yields nothing; the block’s return value is returned.
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 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 |
# File 'lib/parse/model/core/create_lock.rb', line 59 def synchronize(parse_class:, query_attrs:, options: {}, session_token: nil, master_key: nil, &block) raise ArgumentError, "block required" unless block_given? ttl = clamp(Integer([:ttl] || DEFAULT_TTL), 1, MAX_TTL) wait = clamp(Float([:wait] || DEFAULT_WAIT), 0.0, MAX_WAIT) on_degraded = [:on_degraded] || :warn key = canonical_key( parse_class: parse_class, query_attrs: query_attrs, session_token: session_token, master_key: master_key, ) store = lock_store if degraded_store?(store) handle_degraded(on_degraded, key) return process_mutex(key).synchronize(&block) end owner = SecureRandom.uuid acquired_at = nil start = monotonic_now loop do if try_acquire(store, key, owner, ttl) acquired_at = monotonic_now wait_ms = ((acquired_at - start) * 1000).round instrument("acquired", key, wait_ms: wait_ms) break end elapsed = monotonic_now - start if elapsed >= wait waited_ms = (elapsed * 1000).round instrument("timeout", key, waited_ms: waited_ms) raise Parse::CreateLockTimeoutError, "Could not acquire create-lock for #{parse_class} within #{wait}s" end instrument("contended", key, elapsed_ms: (elapsed * 1000).round) if elapsed > 0 sleep(poll_interval) end begin yield ensure if acquired_at release(store, key, owner) held_ms = ((monotonic_now - acquired_at) * 1000).round instrument("released", key, held_ms: held_ms) end end end |