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 on return/break/raise exiting the block (via ensure). Critical caveat: if your critical section runs LONGER than ttl:, 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. Size ttl: 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-key Mutex keyed 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 via on_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" — acquire will keep polling until the wait: 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.

Examples:

bulk-import dedup

Parse::Lock.acquire("import:#{batch_id}", ttl: 10) do
  # Only one worker runs the import for this batch; the rest
  # either get LockTimeoutError or see the already-imported state.
  run_batch_import(batch_id)
end

cron singleton

Parse::Lock.acquire("cron:nightly-rollup", ttl: 5, wait: 0) do
  # wait: 0 → either acquire immediately or raise. Other workers
  # discover "someone else has it" without spinning.
  compute_nightly_rollup
end

external-API idempotency key (idempotent body required)

Parse::Lock.acquire("stripe-webhook:#{evt_id}", ttl: 30, wait: 0.5) do
  # Serializes concurrent deliveries of the same evt_id so they
  # don't race. This is NOT exactly-once: if processing outruns
  # `ttl:`, a second delivery can acquire and run in parallel.
  # `process_webhook` must be idempotent (e.g. check an
  # already-processed marker keyed by evt_id) so a double run is a
  # no-op, not a double charge.
  process_webhook(evt_id)
end

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 a secret: "a" misconfiguration is refused at the boundary rather than silently degrading the lock-pinning resistance claim. Applies ONLY to the secret: kwarg path; the operator-configured PARSE_STACK_LOCK_SECRET path 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

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.)

Parameters:

  • key (String)

    a stable identifier for the resource being guarded. By default hashed via HMAC-SHA256 keyed with the operator-configured secret (PARSE_STACK_LOCK_SECRET or Parse.synchronize_create_secret); when no secret is configured AND the store is cross-process (Redis), falls back to plain SHA-256 with a one-time [Parse::Lock:SECURITY] warning that names the enumeration + lock-pinning risks and the remediation knob. When the store is process-local (Memory / Null / nil), an auto-derived per-process secret is used regardless — process-local locking already implies single-process, so a per-process secret doesn't break cross-process equality. Use secret: to override per call. Must be a non-empty String of at most 1024 bytes — longer keys are refused (a runaway-string bug that turned into a multi-megabyte key would silently break Redis perf).

  • ttl (Integer) (defaults to: DEFAULT_TTL)

    seconds the lock is held before self-clearing. Clamped to 1..30. Pick a value comfortably longer than your expected critical-section duration; the TTL is a crash-recovery floor, not a hard cap on work.

  • wait (Float) (defaults to: DEFAULT_WAIT)

    seconds to wait for the lock if another holder has it. Clamped to 0.0..30. Pass 0 to fail-fast (raise LockTimeoutError immediately if contended).

  • on_degraded (Symbol) (defaults to: :warn)

    action when the store is process-local: :warn (default — one warning per call), :warn_throttled (one warning per minute), :proceed (silent), :raise (raise Parse::Lock::UnavailableError). Asymmetric-degradation residual risk: if two processes target the same Redis but disagree on degraded-detection (e.g. process A has Parse.synchronize_create_store = nil while process B has it wired to Redis), A takes the auto_secret branch and B takes the nil/plain-SHA branch. They derive different store keys for the same raw key and silently fail to mutually exclude. The :warn mode fires only on the degraded process (A); the operator may not connect "A logged a degraded warning" with "B is also running but with a different effective lock surface." Mitigation: set Parse.synchronize_create_store uniformly across deployment workers, OR pass on_degraded: :raise so any disagreement surfaces loudly.

  • secret (Symbol, String, nil) (defaults to: :auto)

    HMAC secret selection. :auto (default) uses Parse::LockBackend.lock_secret_for — picks up PARSE_STACK_LOCK_SECRET / Parse.synchronize_create_secret when set, auto-derives a per-process secret for degraded stores, falls back to plain SHA-256 with a security warn for cross-process stores without a configured secret. A String overrides the resolution and uses that secret directly (useful when a single flow needs a different keying than the global default). nil explicitly opts out of HMAC and uses plain SHA-256 — no warn, since the opt-out is deliberate.

Yields:

  • (owner)

    runs the block with the lock held. The block receives the unique owner token for this acquisition — usable as a fencing token by callers whose protected resource can reject stale tokens. Most callers ignore it. (On the degraded in-process Mutex path a fresh token is still supplied so the block signature is stable.)

Returns:

  • (Object)

    the block's return value.

Raises:



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