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.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/codeql.yml +1 -1
- data/.github/workflows/docs.yml +3 -3
- data/.github/workflows/release.yml +14 -3
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +792 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +8 -5
- data/README.md +15 -0
- data/Rakefile +5 -1
- data/docs/acl_clp_guide.md +553 -0
- data/docs/atlas_vector_search_guide.md +123 -22
- data/docs/client_sdk_guide.md +201 -5
- data/docs/usage_guide.md +21 -0
- data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
- data/lib/parse/agent/tools.rb +153 -1
- data/lib/parse/cache/redis.rb +53 -0
- data/lib/parse/client/caching.rb +18 -1
- data/lib/parse/client.rb +79 -12
- data/lib/parse/embeddings/cohere.rb +143 -6
- data/lib/parse/embeddings/provider.rb +20 -2
- data/lib/parse/embeddings/voyage.rb +102 -0
- data/lib/parse/embeddings.rb +332 -1
- data/lib/parse/live_query/client.rb +167 -4
- data/lib/parse/live_query/configuration.rb +12 -0
- data/lib/parse/live_query/subscription.rb +55 -2
- data/lib/parse/live_query.rb +123 -1
- data/lib/parse/lock.rb +342 -0
- data/lib/parse/lock_backend.rb +308 -0
- data/lib/parse/model/classes/audience.rb +5 -0
- data/lib/parse/model/classes/installation.rb +122 -0
- data/lib/parse/model/classes/job_schedule.rb +3 -1
- data/lib/parse/model/classes/job_status.rb +4 -1
- data/lib/parse/model/classes/push_status.rb +4 -1
- data/lib/parse/model/classes/session.rb +7 -0
- data/lib/parse/model/classes/user.rb +204 -0
- data/lib/parse/model/core/create_lock.rb +28 -146
- data/lib/parse/model/core/embed_managed.rb +162 -13
- data/lib/parse/model/core/parse_reference.rb +17 -1
- data/lib/parse/model/core/querying.rb +26 -2
- data/lib/parse/model/file.rb +523 -18
- data/lib/parse/query.rb +31 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +98 -1
- data/parse-stack-next.gemspec +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/live_query.rb
CHANGED
|
@@ -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
|
-
|
|
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
|