Module: Parse::Lock
- Defined in:
- lib/parse/lock.rb
Overview
Public mutual-exclusion primitive built on the same Redis-backed
store + in-process Mutex fallback used internally by
first_or_create! and create_or_update!. Designed for callers
who need a distributed lock outside the find-or-create flow —
bulk-import dedup, cron-job singletons, idempotency keys for
external API integrations, anywhere two processes might race the
same logical operation.
== Contract
- TTL-bounded — mutual exclusion with a DEADLINE, not exactly-once.
Every acquisition writes a TTL on the Redis key (1..30s, default
3s). If the holder crashes or the process is terminated mid-block,
the lock self-clears after
ttl:seconds — there is no manual recovery step. The block-form API releases on normal return, on exception, and onreturn/break/raiseexiting the block (viaensure). Critical caveat: if your critical section runs LONGER thanttl:, the lease expires WHILE you are still inside the block, a second caller can acquire, and two holders then run concurrently — the lock provides no signal to the first holder that this happened (it logs a[Parse::Lock]warning on release if it detects its own lease overran). There is no fencing token that the protected resource checks, so this primitive does NOT give you exactly-once execution. Sizettl:comfortably above your worst-case section duration, AND make the protected operation idempotent so a rare double-execution is harmless. The block receives its owner token (acquire(key) { |token| ... }) for callers who want to build their own fencing against a token-checking resource. - In-process Mutex fallback when Redis unavailable. If the
configured cache is process-local (Moneta
Memory/Null) or nil, this falls back to a per-keyMutexkeyed in this process. That guards single-process contention but does NOT serialize across processes — operators running multi-worker deployments should configure a Redis-backed cache for the locking to actually serve its purpose. The fallback emits a one-line warn on first use per process (throttle viaon_degraded: :warn_throttled). - Fails closed on acquisition errors. Errors raised by the
underlying store during
SETNX-style acquisition are caught, warned, and treated as "lock not acquired" —acquirewill keep polling until thewait:budget elapses, then raise TimeoutError. The block is NEVER entered without the lock; there is no "best-effort proceed without locking" escape hatch. - TTL/wait clamps.
ttl:is clamped to 1..30s;wait:is clamped to 0.0..30s. Callers asking for longer windows are silently capped (the underlying store cannot reliably hold a minutes-long lock under typical Redis maxmemory eviction policies; documented in the operator guide).
== Cooperation with first_or_create!
Both APIs talk to the same store under the same namespace
(parse-stack:foc:v1:<digest> for first_or_create!,
parse-stack:lock:v1:<your-key> for Lock). The prefix
difference ensures the two namespaces cannot collide — a
"billing-cycle-2026-Q4") cannot block
a first_or_create! for the literally-equal-named row.
Defined Under Namespace
Classes: TimeoutError, UnavailableError
Constant Summary collapse
- KEY_PREFIX =
"parse-stack:lock:v1:"- DEFAULT_TTL =
3- DEFAULT_WAIT =
2.0- MAX_TTL =
30- MAX_WAIT =
30- SECRET_MIN_BYTES =
Minimum byte-length for an explicit
secret:kwarg. 16 bytes ≈ 128 bits of separation between tenants — short enough not to burden an operator who already has a real secret, long enough that asecret: "a"misconfiguration is refused at the boundary rather than silently degrading the lock-pinning resistance claim. Applies ONLY to thesecret:kwarg path; the operator-configuredPARSE_STACK_LOCK_SECRETpath is not length-checked here (different threat model — that's the operator's process-boot configuration, not a per-call argument). 16
Class Method Summary collapse
-
.acquire(key, ttl: DEFAULT_TTL, wait: DEFAULT_WAIT, on_degraded: :warn, secret: :auto) {|owner| ... } ⇒ Object
Acquire
key, run the block, release on return.
Class Method Details
.acquire(key, ttl: DEFAULT_TTL, wait: DEFAULT_WAIT, on_degraded: :warn, secret: :auto) {|owner| ... } ⇒ Object
Acquire key, run the block, release on return. Block-form
only — there is no try_acquire returning a token (the
token-based form makes ensure-release the caller's job, and
any caller forgetting ensure release(token) leaks the key
for ttl: seconds before TTL expiry. Block-form is the safe
default; if you need finer control, build it locally.)
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/parse/lock.rb', line 198 def acquire(key, ttl: DEFAULT_TTL, wait: DEFAULT_WAIT, on_degraded: :warn, secret: :auto, &block) raise ArgumentError, "block required" unless block_given? validated_key = validate_key!(key) validate_on_degraded!(on_degraded) validate_secret!(secret) normalized_ttl = clamp(Integer(ttl), 1, MAX_TTL) normalized_wait = clamp(Float(wait), 0.0, MAX_WAIT) # Route through Parse::LockBackend — the shared module that # also serves Parse::CreateLock. The KEY_PREFIX # ("parse-stack:lock:v1:") is distinct from CreateLock's # ("parse-stack:foc:v1:") so the two namespaces cannot # collide even on literally-equal-named keys. store = Parse::LockBackend.lock_store # Resolve HMAC secret (or nil for plain SHA) per the # `secret:` kwarg semantics above. The `:auto` branch picks # up the operator-configured secret if one exists; the # explicit-String branch overrides it; the explicit-nil # branch opts out without a warn. resolved_secret = case secret when :auto then Parse::LockBackend.lock_secret_for(store: store, source: "Parse::Lock") when String then secret when nil then nil end digest = resolved_secret \ ? OpenSSL::HMAC.hexdigest("SHA256", resolved_secret, validated_key) \ : Digest::SHA256.hexdigest(validated_key) store_key = "#{KEY_PREFIX}#{digest}" if Parse::LockBackend.degraded_store?(store) Parse::LockBackend.handle_degraded( on_degraded, store_key, source: "Parse::Lock", unavailable_error: Parse::Lock::UnavailableError, ) # Supply a token so a `{ |token| ... }` block has a stable # signature across the Redis and degraded paths. There is no # cross-process owner here — the Mutex IS the exclusion — so a # fresh UUID is purely for signature parity / local fencing. return Parse::LockBackend.process_mutex(store_key).synchronize do yield SecureRandom.uuid end end owner = SecureRandom.uuid acquired_at = nil start = Parse::LockBackend.monotonic_now loop do if Parse::LockBackend.try_acquire(store, store_key, owner, normalized_ttl) acquired_at = Parse::LockBackend.monotonic_now break end elapsed = Parse::LockBackend.monotonic_now - start if elapsed >= normalized_wait raise Parse::Lock::TimeoutError, "Parse::Lock.acquire: could not acquire #{key.inspect} within #{normalized_wait}s" end sleep(Parse::LockBackend.poll_interval) end begin yield owner ensure if acquired_at # Detect a lease overrun: if the critical section ran longer # than the TTL, our lock already expired and another caller # may have acquired concurrently. The atomic compare-and- # delete release below is a safe no-op in that case (it won't # delete the new holder's key), but mutual exclusion was NOT # guaranteed for the overrun window — warn loudly so the # operator can raise `ttl:` or confirm the body is idempotent. held = Parse::LockBackend.monotonic_now - acquired_at if held > normalized_ttl warn "[Parse::Lock] critical section for #{key.inspect} ran " \ "#{held.round(2)}s, exceeding ttl: #{normalized_ttl}s — the lease " \ "expired mid-block and another caller may have held the lock " \ "concurrently. Mutual exclusion was NOT guaranteed for the overrun " \ "window. Raise ttl: above your worst-case section duration, or make " \ "the protected operation idempotent." end Parse::LockBackend.release(store, store_key, owner) end end end |