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

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.

Returns:



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.

Parameters:

  • parse_class (String)

    the Parse class name

  • query_attrs (Hash)

    the query attributes used to derive the key

  • options (Hash) (defaults to: {})

    tuning: ttl:, wait:, on_degraded:

  • session_token (String, nil) (defaults to: nil)

    auth context — included in key

  • master_key (Boolean, nil) (defaults to: nil)

    auth context — included in key

Raises:



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(options[:ttl] || DEFAULT_TTL), 1, MAX_TTL)
  wait = clamp(Float(options[:wait] || DEFAULT_WAIT), 0.0, MAX_WAIT)
  on_degraded = options[: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