parse-stack-next 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
@@ -74,13 +74,26 @@ module Parse
74
74
  # @param query [Hash] query constraints (where clause)
75
75
  # @param fields [Array<String>, nil] specific fields to watch
76
76
  # @param session_token [String, nil] session token for authentication
77
- def initialize(client:, class_name:, query: {}, fields: nil, session_token: nil)
77
+ # @param use_master_key [Boolean] an intent assertion that this
78
+ # subscription needs master-key (ACL-bypassing) scope. It does
79
+ # NOT put `masterKey` on the subscribe frame: Parse Server has no
80
+ # per-subscription master key — `client.hasMasterKey` is fixed
81
+ # per connection at connect time, so one subscription on a scoped
82
+ # socket can never be selectively elevated. The flag is honored
83
+ # only when the parent client is an admin connection (built with
84
+ # `use_master_key: true`), where the whole connection is already
85
+ # elevated; on a non-admin connection the client warns and the
86
+ # subscription stays ACL-scoped. For mixed scoped + admin needs,
87
+ # use two separate clients. Defaults to false.
88
+ def initialize(client:, class_name:, query: {}, fields: nil,
89
+ session_token: nil, use_master_key: false)
78
90
  @monitor = Monitor.new
79
91
  @client = client
80
92
  @class_name = class_name
81
93
  @query = query
82
94
  @fields = fields
83
95
  @session_token = session_token
96
+ @use_master_key = use_master_key == true
84
97
  @request_id = generate_request_id
85
98
  @state = :pending
86
99
  @callbacks = Hash.new { |h, k| h[k] = [] }
@@ -97,6 +110,20 @@ module Parse
97
110
  @monitor.synchronize { @state }
98
111
  end
99
112
 
113
+ # Redacting inspect — the default `inspect` would expose
114
+ # `@session_token` (and, via `@client`, the client's master/REST
115
+ # keys) in any log line, backtrace, error page, or error reporter
116
+ # that renders the subscription. Reads `@state` directly rather
117
+ # than through the monitor so a diagnostic inspect never blocks on
118
+ # the lock.
119
+ # @return [String]
120
+ def inspect
121
+ token = @session_token.nil? || @session_token.empty? ? "nil" : "[REDACTED]"
122
+ "#<#{self.class.name} request_id=#{@request_id.inspect} " \
123
+ "class_name=#{@class_name.inspect} state=#{@state.inspect} " \
124
+ "use_master_key=#{@use_master_key} session_token=#{token}>"
125
+ end
126
+
100
127
  # Register a callback for a specific event type
101
128
  # @param event_type [Symbol] :create, :update, :delete, :enter, :leave, :error, :subscribe, :unsubscribe
102
129
  # @yield [object, original] block to call when event occurs
@@ -214,10 +241,26 @@ module Parse
214
241
 
215
242
  msg[:query][:fields] = fields if fields&.any?
216
243
  msg[:sessionToken] = session_token if session_token
244
+ # The subscribe frame deliberately NEVER carries `masterKey`.
245
+ # Parse Server's `_handleSubscribe` does not read it — master-key
246
+ # (ACL-bypass) authorization is resolved once, per connection, in
247
+ # `_handleConnect` (`client.hasMasterKey`). Emitting it here put a
248
+ # privileged credential on the wire for ZERO server-side effect.
249
+ # `use_master_key: true` at the subscription level is an intent
250
+ # assertion validated by the client (which warns when it cannot
251
+ # be honored on a non-admin connection); the actual elevation is
252
+ # the admin connection's connect frame. See
253
+ # {Parse::LiveQuery::Client#use_master_key}.
217
254
 
218
255
  msg
219
256
  end
220
257
 
258
+ # @return [Boolean] whether this subscription opted into
259
+ # per-subscription master-key auth via `use_master_key: true`.
260
+ def use_master_key?
261
+ @use_master_key == true
262
+ end
263
+
221
264
  # Build the unsubscribe message
222
265
  # @return [Hash]
223
266
  def to_unsubscribe_message
@@ -252,9 +295,19 @@ module Parse
252
295
  # @api private
253
296
  def fail!(error)
254
297
  @monitor.synchronize { @state = :error }
255
- error = SubscriptionError.new(error) if error.is_a?(String)
298
+ # Promote String errors (which come back from the LiveQuery
299
+ # server with messages like "Permission denied (code: 101)")
300
+ # to typed SubscriptionError instances carrying the request_id
301
+ # and class_name as structured context. The resulting
302
+ # `e.message` reads `request_id=<n> class=<X> <server message>`,
303
+ # so a single-line log captures the operational context the
304
+ # raw server string lacks.
305
+ if error.is_a?(String)
306
+ error = SubscriptionError.new(error, request_id: @request_id, class_name: @class_name)
307
+ end
256
308
  Logging.error("Subscription failed",
257
309
  request_id: @request_id,
310
+ class_name: @class_name,
258
311
  error: error)
259
312
  emit(:error, error)
260
313
  end
@@ -57,7 +57,37 @@ module Parse
57
57
  # `rescue Parse::Error` will also catch LiveQuery failures.
58
58
  class Error < Parse::Error; end
59
59
  class ConnectionError < Error; end
60
- class SubscriptionError < Error; end
60
+
61
+ # Raised when the LiveQuery server rejects a subscribe request.
62
+ # Carries the originating `request_id` (the client-assigned
63
+ # sequence number used for op/error correlation on the same socket)
64
+ # and the `class_name` the subscription targeted. Both are present
65
+ # on `Subscription#fail!`-constructed instances; standalone
66
+ # `SubscriptionError.new("...")` callers can omit them and the
67
+ # `message` is preserved verbatim.
68
+ #
69
+ # Mirrors the structure of `Parse::Error::ProtocolError` — the
70
+ # error string contains the contextual prefix when the fields are
71
+ # set so `rescue SubscriptionError => e; e.message` carries enough
72
+ # for a single-line log line without inspecting `e` further.
73
+ class SubscriptionError < Error
74
+ # @return [Integer, nil] request id of the failed subscribe
75
+ attr_reader :request_id
76
+ # @return [String, nil] Parse class the subscription was targeting
77
+ attr_reader :class_name
78
+
79
+ def initialize(message_or_error, request_id: nil, class_name: nil)
80
+ @request_id = request_id
81
+ @class_name = class_name
82
+ text = message_or_error.respond_to?(:message) ? message_or_error.message : message_or_error.to_s
83
+ prefix_parts = []
84
+ prefix_parts << "request_id=#{request_id}" if request_id
85
+ prefix_parts << "class=#{class_name}" if class_name
86
+ prefixed = prefix_parts.empty? ? text : "#{prefix_parts.join(' ')} #{text}"
87
+ super(prefixed)
88
+ end
89
+ end
90
+
61
91
  class AuthenticationError < Error; end
62
92
 
63
93
  # Default LiveQuery events
@@ -163,6 +193,98 @@ module Parse
163
193
  master_key: config.master_key,
164
194
  }
165
195
  end
196
+
197
+ # Block until the process receives one of `signals` (default
198
+ # `INT` and `TERM`), then gracefully shut down the supplied
199
+ # LiveQuery client and return. Designed for long-running
200
+ # rake-task-style consumers of LiveQuery (`rake livequery:tail`,
201
+ # `rake installations:watch`, etc.) where the caller's natural
202
+ # idiom is "subscribe, then wait forever; on Ctrl-C, clean up."
203
+ #
204
+ # **Why a helper:** Signal.trap blocks on MRI / macOS run in a
205
+ # restricted context — calling `client.unsubscribe` /
206
+ # `client.close` (which themselves take the client's internal
207
+ # Monitor) directly from the trap raises `ThreadError: can't be
208
+ # called from trap context` on the platforms that enforce
209
+ # `:signal_safe?`. The safe idiom is "set a flag in the trap,
210
+ # poll from the main thread, perform the shutdown there." This
211
+ # method bundles that idiom so callers don't have to re-derive
212
+ # it (and so they don't deploy the unsafe version and hit the
213
+ # ThreadError in production).
214
+ #
215
+ # The supplied block (if any) runs once before the wait loop,
216
+ # so callers can hand-off subscription setup that should not
217
+ # race the trap installation:
218
+ #
219
+ # @example
220
+ # Parse::LiveQuery.run_until_signal! do |client|
221
+ # client.subscribe(Post, where: { published: true }) do |sub|
222
+ # sub.on(:create) { |obj| puts "new post: #{obj.id}" }
223
+ # end
224
+ # end
225
+ #
226
+ # @param client [Parse::LiveQuery::Client, nil] client to shut
227
+ # down on signal. Defaults to `Parse::LiveQuery.client` (the
228
+ # process-wide default).
229
+ # @param signals [Array<Symbol, String>] signal names to trap.
230
+ # Defaults to `%i[INT TERM]` — common for SIGINT (Ctrl-C) and
231
+ # SIGTERM (orchestrator stop).
232
+ # @param shutdown_timeout [Float] seconds to allow
233
+ # {Parse::LiveQuery::Client#shutdown} to drain pending events.
234
+ # @param poll_interval [Float] seconds between sentinel checks.
235
+ # Lower values reduce shutdown latency; higher values reduce
236
+ # wakeup overhead on idle processes. Default 0.25s.
237
+ # @yieldparam client [Parse::LiveQuery::Client] passed once
238
+ # before the wait loop starts. Optional.
239
+ # @return [void] returns after the client has been shut down.
240
+ def run_until_signal!(client: nil, signals: %i[INT TERM],
241
+ shutdown_timeout: 5.0, poll_interval: 0.25)
242
+ ensure_enabled!
243
+ unless signals.is_a?(Array) && !signals.empty?
244
+ raise ArgumentError,
245
+ "Parse::LiveQuery.run_until_signal!: signals must be a non-empty Array " \
246
+ "(got #{signals.inspect}). An empty list would block the poll loop forever " \
247
+ "with no trap installed."
248
+ end
249
+ target = client || self.client
250
+
251
+ # Sentinel is a single-element queue rather than an instance
252
+ # variable so the trap handler does only the absolute minimum
253
+ # work (one `push`) — no mutex acquisition, no allocation
254
+ # beyond what `<<` does internally.
255
+ stop_signal = Queue.new
256
+ installed = []
257
+ begin
258
+ # Yield BEFORE installing traps (so a SIGINT during caller
259
+ # setup still aborts normally) but INSIDE the begin/ensure so a
260
+ # raise from the block — including Interrupt — still runs the
261
+ # shutdown/restore cleanup below rather than leaking the
262
+ # client's connection and threads.
263
+ yield(target) if block_given?
264
+
265
+ signals.each do |sig|
266
+ prior = Signal.trap(sig) { stop_signal << sig }
267
+ installed << [sig, prior]
268
+ end
269
+
270
+ # Block until a signal arrives. Use `Queue#pop` with the
271
+ # poll loop so trap-context limitations don't matter — we
272
+ # only ever ENQUEUE from the trap; the dequeue is here on
273
+ # the main thread.
274
+ loop do
275
+ sig = stop_signal.pop(true) rescue nil
276
+ break if sig
277
+ sleep poll_interval
278
+ end
279
+ ensure
280
+ # Restore the prior trap handlers so re-running the helper
281
+ # (e.g. in tests, or in a parent process that traps INT
282
+ # itself) does not leak our handler.
283
+ installed.each { |sig, prior| Signal.trap(sig, prior) if prior }
284
+ # Shutdown from the main thread, not the trap context.
285
+ target.shutdown(timeout: shutdown_timeout) if target.respond_to?(:shutdown)
286
+ end
287
+ end
166
288
  end
167
289
  end
168
290
  end
data/lib/parse/lock.rb ADDED
@@ -0,0 +1,342 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "securerandom"
5
+ require "digest"
6
+ require "openssl"
7
+ require_relative "lock_backend"
8
+
9
+ module Parse
10
+ # Public mutual-exclusion primitive built on the same Redis-backed
11
+ # store + in-process Mutex fallback used internally by
12
+ # `first_or_create!` and `create_or_update!`. Designed for callers
13
+ # who need a distributed lock outside the find-or-create flow —
14
+ # bulk-import dedup, cron-job singletons, idempotency keys for
15
+ # external API integrations, anywhere two processes might race the
16
+ # same logical operation.
17
+ #
18
+ # == Contract
19
+ #
20
+ # * **TTL-bounded — mutual exclusion with a DEADLINE, not exactly-once.**
21
+ # Every acquisition writes a TTL on the Redis key (1..30s, default
22
+ # 3s). If the holder crashes or the process is terminated mid-block,
23
+ # the lock self-clears after `ttl:` seconds — there is no manual
24
+ # recovery step. The block-form API releases on normal return, on
25
+ # exception, and on `return`/`break`/`raise` exiting the block (via
26
+ # `ensure`). **Critical caveat:** if your critical section runs
27
+ # LONGER than `ttl:`, the lease expires WHILE you are still inside
28
+ # the block, a second caller can acquire, and two holders then run
29
+ # concurrently — the lock provides no signal to the first holder
30
+ # that this happened (it logs a `[Parse::Lock]` warning on release if
31
+ # it detects its own lease overran). There is no fencing token that
32
+ # the protected resource checks, so this primitive does NOT give you
33
+ # exactly-once execution. Size `ttl:` comfortably above your
34
+ # worst-case section duration, AND make the protected operation
35
+ # idempotent so a rare double-execution is harmless. The block
36
+ # receives its owner token (`acquire(key) { |token| ... }`) for
37
+ # callers who want to build their own fencing against a
38
+ # token-checking resource.
39
+ # * **In-process Mutex fallback when Redis unavailable.** If the
40
+ # configured cache is process-local (Moneta `Memory` / `Null`) or
41
+ # nil, this falls back to a per-key `Mutex` keyed in this process.
42
+ # That guards single-process contention but does NOT serialize
43
+ # across processes — operators running multi-worker deployments
44
+ # should configure a Redis-backed cache for the locking to
45
+ # actually serve its purpose. The fallback emits a one-line warn
46
+ # on first use per process (throttle via `on_degraded: :warn_throttled`).
47
+ # * **Fails closed on acquisition errors.** Errors raised by the
48
+ # underlying store during `SETNX`-style acquisition are caught,
49
+ # warned, and treated as "lock not acquired" — `acquire` will keep
50
+ # polling until the `wait:` budget elapses, then raise
51
+ # {Parse::Lock::TimeoutError}. The block is NEVER entered without
52
+ # the lock; there is no "best-effort proceed without locking"
53
+ # escape hatch.
54
+ # * **TTL/wait clamps.** `ttl:` is clamped to 1..30s; `wait:` is
55
+ # clamped to 0.0..30s. Callers asking for longer windows are
56
+ # silently capped (the underlying store cannot reliably hold a
57
+ # minutes-long lock under typical Redis maxmemory eviction
58
+ # policies; documented in the operator guide).
59
+ #
60
+ # == Cooperation with `first_or_create!`
61
+ #
62
+ # Both APIs talk to the same store under the same namespace
63
+ # (`parse-stack:foc:v1:<digest>` for `first_or_create!`,
64
+ # `parse-stack:lock:v1:<your-key>` for {Parse::Lock}). The prefix
65
+ # difference ensures the two namespaces cannot collide — a
66
+ # {Parse::Lock.acquire(key: "billing-cycle-2026-Q4")} cannot block
67
+ # a `first_or_create!` for the literally-equal-named row.
68
+ #
69
+ # @example bulk-import dedup
70
+ # Parse::Lock.acquire("import:#{batch_id}", ttl: 10) do
71
+ # # Only one worker runs the import for this batch; the rest
72
+ # # either get LockTimeoutError or see the already-imported state.
73
+ # run_batch_import(batch_id)
74
+ # end
75
+ #
76
+ # @example cron singleton
77
+ # Parse::Lock.acquire("cron:nightly-rollup", ttl: 5, wait: 0) do
78
+ # # wait: 0 → either acquire immediately or raise. Other workers
79
+ # # discover "someone else has it" without spinning.
80
+ # compute_nightly_rollup
81
+ # end
82
+ #
83
+ # @example external-API idempotency key (idempotent body required)
84
+ # Parse::Lock.acquire("stripe-webhook:#{evt_id}", ttl: 30, wait: 0.5) do
85
+ # # Serializes concurrent deliveries of the same evt_id so they
86
+ # # don't race. This is NOT exactly-once: if processing outruns
87
+ # # `ttl:`, a second delivery can acquire and run in parallel.
88
+ # # `process_webhook` must be idempotent (e.g. check an
89
+ # # already-processed marker keyed by evt_id) so a double run is a
90
+ # # no-op, not a double charge.
91
+ # process_webhook(evt_id)
92
+ # end
93
+ module Lock
94
+ KEY_PREFIX = "parse-stack:lock:v1:"
95
+
96
+ DEFAULT_TTL = 3
97
+ DEFAULT_WAIT = 2.0
98
+ MAX_TTL = 30
99
+ MAX_WAIT = 30
100
+
101
+ # Minimum byte-length for an explicit `secret:` kwarg. 16 bytes
102
+ # ≈ 128 bits of separation between tenants — short enough not to
103
+ # burden an operator who already has a real secret, long enough
104
+ # that a `secret: "a"` misconfiguration is refused at the
105
+ # boundary rather than silently degrading the lock-pinning
106
+ # resistance claim. Applies ONLY to the `secret:` kwarg path; the
107
+ # operator-configured `PARSE_STACK_LOCK_SECRET` path is not
108
+ # length-checked here (different threat model — that's the
109
+ # operator's process-boot configuration, not a per-call argument).
110
+ SECRET_MIN_BYTES = 16
111
+
112
+ # Raised when {Parse::Lock.acquire} cannot obtain the lock within
113
+ # the configured `wait:` budget. Distinct from
114
+ # `Parse::CreateLockTimeoutError` (the `first_or_create!` /
115
+ # `create_or_update!` internal) so callers can `rescue` one
116
+ # without picking up the other — namespacing the error under
117
+ # `Parse::Lock::` makes the peer-not-base relationship explicit.
118
+ class TimeoutError < Parse::Error; end
119
+
120
+ # Raised when {Parse::Lock.acquire} is asked to use a degraded
121
+ # (process-local) store with `on_degraded: :raise`. The default
122
+ # behavior is to fall back to an in-process Mutex with a
123
+ # warning; this error only fires when the caller explicitly
124
+ # opts into the strict mode.
125
+ class UnavailableError < Parse::Error; end
126
+
127
+ class << self
128
+ # Acquire `key`, run the block, release on return. Block-form
129
+ # only — there is no `try_acquire` returning a token (the
130
+ # token-based form makes ensure-release the caller's job, and
131
+ # any caller forgetting `ensure release(token)` leaks the key
132
+ # for `ttl:` seconds before TTL expiry. Block-form is the safe
133
+ # default; if you need finer control, build it locally.)
134
+ #
135
+ # @param key [String] a stable identifier for the resource being
136
+ # guarded. By default hashed via HMAC-SHA256 keyed with the
137
+ # operator-configured secret (`PARSE_STACK_LOCK_SECRET` or
138
+ # `Parse.synchronize_create_secret`); when no secret is
139
+ # configured AND the store is cross-process (Redis), falls
140
+ # back to plain SHA-256 with a one-time `[Parse::Lock:SECURITY]`
141
+ # warning that names the enumeration + lock-pinning risks and
142
+ # the remediation knob. When the store is process-local
143
+ # (Memory / Null / nil), an auto-derived per-process secret
144
+ # is used regardless — process-local locking already implies
145
+ # single-process, so a per-process secret doesn't break
146
+ # cross-process equality. Use `secret:` to override per call.
147
+ # Must be a non-empty String of at most 1024 bytes — longer
148
+ # keys are refused (a runaway-string bug that turned into a
149
+ # multi-megabyte key would silently break Redis perf).
150
+ # @param ttl [Integer] seconds the lock is held before
151
+ # self-clearing. Clamped to 1..30. Pick a value comfortably
152
+ # longer than your expected critical-section duration; the
153
+ # TTL is a crash-recovery floor, not a hard cap on work.
154
+ # @param wait [Float] seconds to wait for the lock if another
155
+ # holder has it. Clamped to 0.0..30. Pass 0 to fail-fast
156
+ # (raise LockTimeoutError immediately if contended).
157
+ # @param on_degraded [Symbol] action when the store is
158
+ # process-local: `:warn` (default — one warning per call),
159
+ # `:warn_throttled` (one warning per minute), `:proceed`
160
+ # (silent), `:raise` (raise Parse::Lock::UnavailableError).
161
+ # **Asymmetric-degradation residual risk:** if two processes
162
+ # target the same Redis but disagree on degraded-detection
163
+ # (e.g. process A has `Parse.synchronize_create_store = nil`
164
+ # while process B has it wired to Redis), A takes the
165
+ # `auto_secret` branch and B takes the `nil`/plain-SHA branch.
166
+ # They derive different store keys for the same raw key and
167
+ # silently fail to mutually exclude. The `:warn` mode fires
168
+ # only on the degraded process (A); the operator may not
169
+ # connect "A logged a degraded warning" with "B is also
170
+ # running but with a different effective lock surface."
171
+ # Mitigation: set `Parse.synchronize_create_store` uniformly
172
+ # across deployment workers, OR pass `on_degraded: :raise`
173
+ # so any disagreement surfaces loudly.
174
+ # @param secret [Symbol, String, nil] HMAC secret selection.
175
+ # `:auto` (default) uses {Parse::LockBackend.lock_secret_for}
176
+ # — picks up `PARSE_STACK_LOCK_SECRET` /
177
+ # `Parse.synchronize_create_secret` when set, auto-derives
178
+ # a per-process secret for degraded stores, falls back to
179
+ # plain SHA-256 with a security warn for cross-process
180
+ # stores without a configured secret. A `String` overrides
181
+ # the resolution and uses that secret directly (useful when
182
+ # a single flow needs a different keying than the global
183
+ # default). `nil` explicitly opts out of HMAC and uses plain
184
+ # SHA-256 — no warn, since the opt-out is deliberate.
185
+ # @yield [owner] runs the block with the lock held. The block
186
+ # receives the unique owner token for this acquisition — usable
187
+ # as a fencing token by callers whose protected resource can
188
+ # reject stale tokens. Most callers ignore it. (On the degraded
189
+ # in-process Mutex path a fresh token is still supplied so the
190
+ # block signature is stable.)
191
+ # @return [Object] the block's return value.
192
+ # @raise [ArgumentError] on invalid `key` / `ttl` / `wait` /
193
+ # `on_degraded` / `secret`.
194
+ # @raise [Parse::Lock::TimeoutError] when `wait` elapses without
195
+ # acquisition.
196
+ # @raise [Parse::Lock::UnavailableError] when `on_degraded: :raise`
197
+ # and the store is process-local.
198
+ def acquire(key, ttl: DEFAULT_TTL, wait: DEFAULT_WAIT,
199
+ on_degraded: :warn, secret: :auto, &block)
200
+ raise ArgumentError, "block required" unless block_given?
201
+ validated_key = validate_key!(key)
202
+ validate_on_degraded!(on_degraded)
203
+ validate_secret!(secret)
204
+ normalized_ttl = clamp(Integer(ttl), 1, MAX_TTL)
205
+ normalized_wait = clamp(Float(wait), 0.0, MAX_WAIT)
206
+
207
+ # Route through Parse::LockBackend — the shared module that
208
+ # also serves Parse::CreateLock. The KEY_PREFIX
209
+ # ("parse-stack:lock:v1:") is distinct from CreateLock's
210
+ # ("parse-stack:foc:v1:") so the two namespaces cannot
211
+ # collide even on literally-equal-named keys.
212
+ store = Parse::LockBackend.lock_store
213
+
214
+ # Resolve HMAC secret (or nil for plain SHA) per the
215
+ # `secret:` kwarg semantics above. The `:auto` branch picks
216
+ # up the operator-configured secret if one exists; the
217
+ # explicit-String branch overrides it; the explicit-nil
218
+ # branch opts out without a warn.
219
+ resolved_secret =
220
+ case secret
221
+ when :auto then Parse::LockBackend.lock_secret_for(store: store, source: "Parse::Lock")
222
+ when String then secret
223
+ when nil then nil
224
+ end
225
+ digest = resolved_secret \
226
+ ? OpenSSL::HMAC.hexdigest("SHA256", resolved_secret, validated_key) \
227
+ : Digest::SHA256.hexdigest(validated_key)
228
+ store_key = "#{KEY_PREFIX}#{digest}"
229
+
230
+ if Parse::LockBackend.degraded_store?(store)
231
+ Parse::LockBackend.handle_degraded(
232
+ on_degraded, store_key,
233
+ source: "Parse::Lock",
234
+ unavailable_error: Parse::Lock::UnavailableError,
235
+ )
236
+ # Supply a token so a `{ |token| ... }` block has a stable
237
+ # signature across the Redis and degraded paths. There is no
238
+ # cross-process owner here — the Mutex IS the exclusion — so a
239
+ # fresh UUID is purely for signature parity / local fencing.
240
+ return Parse::LockBackend.process_mutex(store_key).synchronize do
241
+ yield SecureRandom.uuid
242
+ end
243
+ end
244
+
245
+ owner = SecureRandom.uuid
246
+ acquired_at = nil
247
+ start = Parse::LockBackend.monotonic_now
248
+
249
+ loop do
250
+ if Parse::LockBackend.try_acquire(store, store_key, owner, normalized_ttl)
251
+ acquired_at = Parse::LockBackend.monotonic_now
252
+ break
253
+ end
254
+ elapsed = Parse::LockBackend.monotonic_now - start
255
+ if elapsed >= normalized_wait
256
+ raise Parse::Lock::TimeoutError,
257
+ "Parse::Lock.acquire: could not acquire #{key.inspect} within #{normalized_wait}s"
258
+ end
259
+ sleep(Parse::LockBackend.poll_interval)
260
+ end
261
+
262
+ begin
263
+ yield owner
264
+ ensure
265
+ if acquired_at
266
+ # Detect a lease overrun: if the critical section ran longer
267
+ # than the TTL, our lock already expired and another caller
268
+ # may have acquired concurrently. The atomic compare-and-
269
+ # delete release below is a safe no-op in that case (it won't
270
+ # delete the new holder's key), but mutual exclusion was NOT
271
+ # guaranteed for the overrun window — warn loudly so the
272
+ # operator can raise `ttl:` or confirm the body is idempotent.
273
+ held = Parse::LockBackend.monotonic_now - acquired_at
274
+ if held > normalized_ttl
275
+ warn "[Parse::Lock] critical section for #{key.inspect} ran " \
276
+ "#{held.round(2)}s, exceeding ttl: #{normalized_ttl}s — the lease " \
277
+ "expired mid-block and another caller may have held the lock " \
278
+ "concurrently. Mutual exclusion was NOT guaranteed for the overrun " \
279
+ "window. Raise ttl: above your worst-case section duration, or make " \
280
+ "the protected operation idempotent."
281
+ end
282
+ Parse::LockBackend.release(store, store_key, owner)
283
+ end
284
+ end
285
+ end
286
+
287
+ # @!visibility private
288
+ # Reset internal state — intended for test teardown.
289
+ def reset!
290
+ Parse::LockBackend.reset!
291
+ end
292
+
293
+ private
294
+
295
+ VALID_ON_DEGRADED = %i[warn warn_throttled proceed raise].freeze
296
+
297
+ def validate_on_degraded!(value)
298
+ return if VALID_ON_DEGRADED.include?(value)
299
+ raise ArgumentError,
300
+ "Parse::Lock.acquire: on_degraded must be one of " \
301
+ "#{VALID_ON_DEGRADED.inspect} (got #{value.inspect}). " \
302
+ "Refusing to silently fall back to :warn on an unknown safety knob — " \
303
+ "a typo like :riase would otherwise become silent-warn and mask intent."
304
+ end
305
+
306
+ def validate_secret!(value)
307
+ return if value == :auto || value.nil?
308
+ unless value.is_a?(String) && !value.empty?
309
+ raise ArgumentError,
310
+ "Parse::Lock.acquire: secret must be :auto (use the backend's " \
311
+ "resolution), nil (opt out to plain SHA-256), or a non-empty String " \
312
+ "(explicit HMAC key) — got #{value.inspect}."
313
+ end
314
+ if value.bytesize < SECRET_MIN_BYTES
315
+ raise ArgumentError,
316
+ "Parse::Lock.acquire: explicit `secret:` must be at least " \
317
+ "#{SECRET_MIN_BYTES} bytes (got #{value.bytesize}). A short HMAC " \
318
+ "key reduces the separation between tenants sharing one Redis and " \
319
+ "defeats the lock-pinning resistance the HMAC keying is supposed to " \
320
+ "provide. Use SecureRandom.hex(32) or a real operator secret."
321
+ end
322
+ end
323
+
324
+ def validate_key!(key)
325
+ unless key.is_a?(String) && !key.empty?
326
+ raise ArgumentError,
327
+ "Parse::Lock.acquire: key must be a non-empty String (got #{key.class})"
328
+ end
329
+ if key.bytesize > 1024
330
+ raise ArgumentError,
331
+ "Parse::Lock.acquire: key exceeds 1024 bytes (got #{key.bytesize}). " \
332
+ "Hash the inputs to a stable digest before passing them in."
333
+ end
334
+ key
335
+ end
336
+
337
+ def clamp(value, lo, hi)
338
+ [lo, value, hi].sort[1]
339
+ end
340
+ end
341
+ end
342
+ end