parse-stack-next 5.6.0 → 5.7.1
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/CHANGELOG.md +489 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +219 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +366 -113
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +6 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +6 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +136 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +120 -5
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
data/lib/parse/cache/redis.rb
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
require "moneta"
|
|
5
5
|
require "json"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
require_relative "pool"
|
|
8
|
+
require_relative "keyspace"
|
|
9
|
+
require_relative "moneta_surface"
|
|
10
|
+
require_relative "sub_cache"
|
|
11
|
+
require_relative "upstream_roles"
|
|
12
|
+
require_relative "scoped_view"
|
|
7
13
|
|
|
8
14
|
module Parse
|
|
9
15
|
module Cache
|
|
@@ -29,6 +35,10 @@ module Parse
|
|
|
29
35
|
# `delete`, `store` — to a pooled backend), so it can be passed
|
|
30
36
|
# directly to `Parse.setup(cache:)` / `Parse::Client.new(cache:)`.
|
|
31
37
|
class Redis
|
|
38
|
+
# `[]=`, `fetch`, `load`, `values_at`, `slice`, `merge!` and friends,
|
|
39
|
+
# derived from the four primitives below. See {Parse::Cache::MonetaSurface}.
|
|
40
|
+
include Parse::Cache::MonetaSurface
|
|
41
|
+
|
|
32
42
|
# @return [String, nil] cache key namespace prefix (or nil if not set).
|
|
33
43
|
attr_reader :namespace
|
|
34
44
|
|
|
@@ -38,6 +48,176 @@ module Parse
|
|
|
38
48
|
# @return [String] Redis connection URL.
|
|
39
49
|
attr_reader :url
|
|
40
50
|
|
|
51
|
+
# There is deliberately no `keyspace` reader/writer and no `keyspace:`
|
|
52
|
+
# constructor option on this class anymore. This backend is a shared
|
|
53
|
+
# connection pool: several {Parse::Client} instances (several Parse
|
|
54
|
+
# apps, or several tenants) pointing one `Parse::Cache::Redis` at the
|
|
55
|
+
# same Redis is a normal, supported deployment. A mutable keyspace
|
|
56
|
+
# binding on the SHARED object was not, because a second client
|
|
57
|
+
# calling `keyspace = ks_b` rebound the one `@keyspace` ivar out from
|
|
58
|
+
# under the first: client A's caching middleware kept using A's
|
|
59
|
+
# keyspace object (captured at construction) while `clear`, `identity`,
|
|
60
|
+
# `roles`, and the memoized `upstream_roles` on this now-shared object
|
|
61
|
+
# answered with B's. A stopped invalidating its own entries, or a
|
|
62
|
+
# scoped `clear` issued through A deleted B's keys instead.
|
|
63
|
+
#
|
|
64
|
+
# {#scoped} replaces that mutable path entirely: it hands back a
|
|
65
|
+
# {Parse::Cache::ScopedView} carrying its own keyspace and its own
|
|
66
|
+
# memoized identity/roles/upstream_roles, so two callers can share this
|
|
67
|
+
# backend's connection pool without ever being able to share, or steal,
|
|
68
|
+
# keyspace ownership.
|
|
69
|
+
#
|
|
70
|
+
# Derive a per-client view over this shared backend.
|
|
71
|
+
#
|
|
72
|
+
# @param keyspace [Parse::Cache::Keyspace]
|
|
73
|
+
# @return [Parse::Cache::ScopedView]
|
|
74
|
+
def scoped(keyspace)
|
|
75
|
+
Parse::Cache::ScopedView.new(backend: self, keyspace: keyspace)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String, nil] Parse Server's cache database URL, when attached.
|
|
79
|
+
attr_reader :parse_cache_url
|
|
80
|
+
|
|
81
|
+
# Read-only view of Parse Server's role cache, or nil when not attached.
|
|
82
|
+
#
|
|
83
|
+
# Uses a raw redis-rb client rather than the Moneta pool: Moneta's Redis
|
|
84
|
+
# adapter issues a MULTI/PEXPIRE pipeline on every read when built with
|
|
85
|
+
# `expires:`, which a credential restricted to `+get +pttl` rejects with
|
|
86
|
+
# NOPERM.
|
|
87
|
+
#
|
|
88
|
+
# This backend-level reader has no keyspace, and therefore no app id or
|
|
89
|
+
# role-plane freshness gate to scope itself with: {#scoped} is the only
|
|
90
|
+
# way to bind one to an app. Its `app_id` is nil, so `key_for` /
|
|
91
|
+
# `roles_for` on this instance can never match a real Parse Server
|
|
92
|
+
# entry (which is always `<appId>:role:<userId>`). They will only
|
|
93
|
+
# ever report a miss. Prefer `backend.scoped(keyspace).upstream_roles`
|
|
94
|
+
# for any real read. This method is NOT used by
|
|
95
|
+
# {#verify_upstream_isolation!}, which needs a database-wide probe
|
|
96
|
+
# rather than one scoped to a single (missing) app id and builds its
|
|
97
|
+
# own reader.
|
|
98
|
+
# @return [Parse::Cache::UpstreamRoles, nil]
|
|
99
|
+
def upstream_roles
|
|
100
|
+
return nil if @parse_cache_url.nil?
|
|
101
|
+
@upstream_roles ||= UpstreamRoles.new(client: upstream_client, app_id: nil, roles_plane: nil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Verify the attached endpoint is a different database from ours, and warn
|
|
105
|
+
# if not.
|
|
106
|
+
#
|
|
107
|
+
# Deliberately a warning rather than a refusal: the hazard comes entirely
|
|
108
|
+
# from the upstream FLUSHDB bug, so it disappears on a server carrying the
|
|
109
|
+
# scoped-clear fix, and refusing to boot would be permanently wrong there.
|
|
110
|
+
# It routes through the existing degraded-lock path so the caller's
|
|
111
|
+
# `on_degraded:` decides whether to warn or raise, because the consequence
|
|
112
|
+
# that is not merely a performance loss is a create-lock deleted mid-hold,
|
|
113
|
+
# which silently removes `first_or_create!` mutual exclusion.
|
|
114
|
+
#
|
|
115
|
+
# Three outcomes, because two of them were previously collapsed into
|
|
116
|
+
# one and the collapse hid a false negative:
|
|
117
|
+
#
|
|
118
|
+
# - `true`: isolation positively established. A sentinel written to our
|
|
119
|
+
# database was NOT visible through the upstream connection.
|
|
120
|
+
# - `false`: sharing positively established, and warned about.
|
|
121
|
+
# - `:unknown`: neither could be established. Truthy, so callers that
|
|
122
|
+
# branch on truthiness behave as before, but distinguishable for
|
|
123
|
+
# callers that want to escalate. This is what a credential restricted
|
|
124
|
+
# to `~<appId>:role:*` produces: the sentinel read comes back NOPERM,
|
|
125
|
+
# which says nothing about which database it was denied on.
|
|
126
|
+
#
|
|
127
|
+
# The scan alone cannot return `true`. It only ever finds a
|
|
128
|
+
# Parse-Server-shaped key or fails to, and "no such key" is equally
|
|
129
|
+
# consistent with a separate database and with a shared one on which
|
|
130
|
+
# Parse Server has not yet cached a role. A stack that was just
|
|
131
|
+
# deployed is in that second state, which is precisely when an operator
|
|
132
|
+
# runs this check, so treating the empty scan as proof of isolation
|
|
133
|
+
# returned a confident "isolated" for the shared case it exists to
|
|
134
|
+
# catch.
|
|
135
|
+
#
|
|
136
|
+
# @return [Boolean, :unknown] see above.
|
|
137
|
+
def verify_upstream_isolation!(on_degraded: :warn_throttled)
|
|
138
|
+
return true if @parse_cache_url.nil?
|
|
139
|
+
# Deliberately NOT {#upstream_roles}: this backend has no keyspace
|
|
140
|
+
# (and therefore no app id: that can only come from {#scoped} now)
|
|
141
|
+
# to build a reader from, and there is no single "the" app id to use
|
|
142
|
+
# here anyway: this backend can be shared by clients of more than one
|
|
143
|
+
# app (see {#scoped}), and this check is a database-sharing probe,
|
|
144
|
+
# not a per-app one.
|
|
145
|
+
#
|
|
146
|
+
# `UpstreamRoles#shares_database_with?` scans for
|
|
147
|
+
# `"#{app_id}:role:*"`. Two wrong ways to pick `app_id` were tried and
|
|
148
|
+
# rejected here, in favor of a third:
|
|
149
|
+
#
|
|
150
|
+
# - `nil` turns that into the literal pattern `":role:*"`, which never
|
|
151
|
+
# matches a real Parse Server key (`<appId>:role:<userId>`, no
|
|
152
|
+
# leading colon), so the probe always reports "isolated", even on
|
|
153
|
+
# a database that is genuinely shared. Silently disables the exact
|
|
154
|
+
# warning this method exists to raise.
|
|
155
|
+
# - A bare `"*"` wildcard produces `"*:role:*"`. Redis (and
|
|
156
|
+
# `File.fnmatch`) glob `*` crosses `:` just like any other
|
|
157
|
+
# character, so that pattern ALSO matches this SDK's own role-plane
|
|
158
|
+
# keys (`parse-stack:v1:<scope>:<ns>:role:<userId>`). The probe
|
|
159
|
+
# would report "shared" as soon as the role plane held anything,
|
|
160
|
+
# even on a database that is genuinely isolated. A permanent false
|
|
161
|
+
# positive is worse than the false negative it replaced: it trains
|
|
162
|
+
# operators to ignore the warning.
|
|
163
|
+
#
|
|
164
|
+
# The fix keeps the broad `"*"` wildcard (so this still catches ANY
|
|
165
|
+
# app's cached role, not one we'd have to already know the id of) and
|
|
166
|
+
# keeps only keys matching Parse Server's own three-segment
|
|
167
|
+
# `<appId>:role:<userId>` shape, so `shares_database_with?` can only
|
|
168
|
+
# ever see a genuine upstream entry. See {ExcludeOwnKeysScanner} for
|
|
169
|
+
# why the filter matches the upstream shape rather than rejecting a
|
|
170
|
+
# `parse-stack:` prefix.
|
|
171
|
+
reader = UpstreamRoles.new(client: upstream_client, app_id: "*")
|
|
172
|
+
# The probe scans OUR database for THEIR key pattern, so it needs a
|
|
173
|
+
# scannable client rather than this Moneta-shaped wrapper.
|
|
174
|
+
shared = @pool.pool.with do |store|
|
|
175
|
+
reader.shares_database_with?(ExcludeOwnKeysScanner.new(backend_client(store)))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
unless shared
|
|
179
|
+
# The scan found nothing, which does not distinguish a separate
|
|
180
|
+
# database from a shared one Parse Server has not written to yet.
|
|
181
|
+
# Settle it by writing a key only we can have written and asking
|
|
182
|
+
# the upstream connection whether it can see it.
|
|
183
|
+
#
|
|
184
|
+
# Only two of the three outcomes return. `:shared` deliberately
|
|
185
|
+
# falls through to the warning path below, which is the same
|
|
186
|
+
# handling a positive scan gets.
|
|
187
|
+
case sentinel_probe
|
|
188
|
+
when :isolated then return true
|
|
189
|
+
when :unknown
|
|
190
|
+
warn "[Parse::Cache::Redis] could not verify that parse_cache_url addresses a " \
|
|
191
|
+
"different Redis database than url. The probe key was neither readable nor " \
|
|
192
|
+
"conclusively absent through the upstream connection, which is what a " \
|
|
193
|
+
"credential restricted to ~<appId>:role:* produces. Confirm the two " \
|
|
194
|
+
"databases differ by hand, or grant the reader GET on parse-stack:probe:* " \
|
|
195
|
+
"so this check can answer. " \
|
|
196
|
+
"See https://github.com/parse-community/parse-server/issues/10617"
|
|
197
|
+
return :unknown
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if defined?(Parse::LockBackend)
|
|
202
|
+
Parse::LockBackend.handle_degraded(
|
|
203
|
+
on_degraded, "cache:shared-database", source: "Parse::Cache::Redis",
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
warn "[Parse::Cache::Redis] parse_cache_url resolves to the same Redis database as " \
|
|
207
|
+
"url. Parse Server clears its cache with FLUSHDB on every _Role write, which " \
|
|
208
|
+
"deletes this SDK's cached responses and its parse-stack:foc:v1:* create-locks, " \
|
|
209
|
+
"so first_or_create! loses cross-process mutual exclusion. Point the two at " \
|
|
210
|
+
"different databases (redis://host/0 and redis://host/1), or run a Parse Server " \
|
|
211
|
+
"carrying the scoped-clear fix. " \
|
|
212
|
+
"See https://github.com/parse-community/parse-server/issues/10617"
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Note: there is no `identity` / `roles` plane accessor on this class.
|
|
217
|
+
# Both require a keyspace, and a keyspace can only ever be bound
|
|
218
|
+
# through {#scoped} now, never directly on this shared backend. Use
|
|
219
|
+
# `backend.scoped(keyspace).identity` / `.roles` instead.
|
|
220
|
+
|
|
41
221
|
# @param url [String] Redis URL (e.g. `"redis://localhost:6379/0"`).
|
|
42
222
|
# @param namespace [String, nil] optional key prefix so multiple Parse
|
|
43
223
|
# apps can share one Redis without colliding. When non-nil, the
|
|
@@ -71,9 +251,25 @@ module Parse
|
|
|
71
251
|
# note that doing so causes cached responses to live forever,
|
|
72
252
|
# which is rarely what you want for a session-token-scoped
|
|
73
253
|
# response cache.
|
|
74
|
-
def initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5,
|
|
254
|
+
def initialize(url:, namespace: nil, pool_size: 5, pool_timeout: 5,
|
|
255
|
+
parse_cache_url: nil, **moneta_options)
|
|
75
256
|
@url = url
|
|
257
|
+
# Parse Server's own cache database, read-only and optional. It must NOT
|
|
258
|
+
# be the same database as `url:`: on released Parse Server a `_Role`
|
|
259
|
+
# write FLUSHDBs the whole database, which would take this SDK's
|
|
260
|
+
# response cache and, worse, its create-locks with it.
|
|
261
|
+
@parse_cache_url = parse_cache_url
|
|
76
262
|
@namespace = normalize_namespace(namespace)
|
|
263
|
+
# A caller-supplied Moneta `prefix:` silently rewrites the physical key
|
|
264
|
+
# layout underneath us, which would break every SCAN pattern this class
|
|
265
|
+
# builds and quietly restore the unscoped-clear behavior the keyspace
|
|
266
|
+
# exists to prevent. Reject it rather than trying to compose with it.
|
|
267
|
+
if moneta_options.key?(:prefix)
|
|
268
|
+
raise ArgumentError,
|
|
269
|
+
"Parse::Cache::Redis does not accept a Moneta prefix: option; it would " \
|
|
270
|
+
"change the physical key layout that scoped clearing depends on. Use " \
|
|
271
|
+
"namespace: instead."
|
|
272
|
+
end
|
|
77
273
|
@pool_size = pool_size
|
|
78
274
|
@pool_timeout = pool_timeout
|
|
79
275
|
# Default expires: true so per-call `expires:` (the TTL the
|
|
@@ -104,20 +300,34 @@ module Parse
|
|
|
104
300
|
end
|
|
105
301
|
end
|
|
106
302
|
|
|
303
|
+
# Moneta's read primitive. Defined here rather than derived, because
|
|
304
|
+
# only this class knows how to reach its pool and how to decode what
|
|
305
|
+
# comes back.
|
|
306
|
+
def load(key, options = {})
|
|
307
|
+
decode_value(@pool.load(key, options || {}))
|
|
308
|
+
end
|
|
309
|
+
|
|
107
310
|
def [](key)
|
|
108
|
-
|
|
311
|
+
load(key, {})
|
|
109
312
|
end
|
|
110
313
|
|
|
111
|
-
def key?(key)
|
|
112
|
-
@pool.key?(key)
|
|
314
|
+
def key?(key, options = {})
|
|
315
|
+
@pool.key?(key, options || {})
|
|
113
316
|
end
|
|
114
317
|
|
|
115
|
-
|
|
116
|
-
|
|
318
|
+
# Returns the DECODED value, matching Moneta, which returns what was
|
|
319
|
+
# removed. This used to hand back the raw JSON string this wrapper had
|
|
320
|
+
# encoded on the way in, so `delete` and `store` returned
|
|
321
|
+
# `"{\"a\":1}"` where every other read returned `{"a" => 1}`.
|
|
322
|
+
def delete(key, options = {})
|
|
323
|
+
decode_value(@pool.delete(key, options || {}))
|
|
117
324
|
end
|
|
118
325
|
|
|
326
|
+
# Returns the value as given, matching Moneta. The encoded form is an
|
|
327
|
+
# implementation detail of how it is stored and must not leak out.
|
|
119
328
|
def store(key, value, options = {})
|
|
120
|
-
@pool.store(key, encode_value(value), options)
|
|
329
|
+
@pool.store(key, encode_value(value), options || {})
|
|
330
|
+
value
|
|
121
331
|
end
|
|
122
332
|
|
|
123
333
|
# Atomic SETNX. Required so `Parse::CreateLock` can acquire
|
|
@@ -136,6 +346,28 @@ module Parse
|
|
|
136
346
|
@pool.increment(key, amount, options)
|
|
137
347
|
end
|
|
138
348
|
|
|
349
|
+
# Set a TTL on an existing key WITHOUT touching its value.
|
|
350
|
+
#
|
|
351
|
+
# Exists because `INCR` sets no expiry, and the only other way to add
|
|
352
|
+
# one through Moneta is to re-`store` the value, which loses concurrent
|
|
353
|
+
# increments. For {Parse::Cache::SubCache}'s generation counters a lost
|
|
354
|
+
# increment moves the counter backwards, and since generation checks
|
|
355
|
+
# are equality comparisons, a counter returning to a previously issued
|
|
356
|
+
# value re-admits the identity entries that value had invalidated.
|
|
357
|
+
# PEXPIRE touches only the TTL, so it cannot do that.
|
|
358
|
+
#
|
|
359
|
+
# @param key [String] the physical key.
|
|
360
|
+
# @param ttl [Numeric] seconds.
|
|
361
|
+
# @return [Boolean] true when the key existed and the TTL was set.
|
|
362
|
+
def expire(key, ttl)
|
|
363
|
+
return false if ttl.nil?
|
|
364
|
+
@pool.pool.with do |store|
|
|
365
|
+
!!backend_client(store).pexpire(key, (ttl.to_f * 1000).round)
|
|
366
|
+
end
|
|
367
|
+
rescue StandardError
|
|
368
|
+
false
|
|
369
|
+
end
|
|
370
|
+
|
|
139
371
|
# Lua compare-and-delete: delete `key` only if its current value
|
|
140
372
|
# equals `expected`. Atomic on the Redis server (the GET, the
|
|
141
373
|
# compare, and the DEL are one script invocation), which closes the
|
|
@@ -210,7 +442,29 @@ module Parse
|
|
|
210
442
|
# The scope must be a non-empty String; the trailing `:` is added
|
|
211
443
|
# automatically and any trailing `:` in the input is stripped so
|
|
212
444
|
# `"tenant_x"` and `"tenant_x:"` are equivalent.
|
|
213
|
-
|
|
445
|
+
#
|
|
446
|
+
# This backend never has a keyspace of its own (see {#scoped}), so
|
|
447
|
+
# `family:` / `tenant:` cannot be honored here: interpreting them needs
|
|
448
|
+
# a `root_prefix`, and only a {Parse::Cache::ScopedView} has one.
|
|
449
|
+
#
|
|
450
|
+
# Passing either is therefore a hard error rather than a silent
|
|
451
|
+
# no-op. Ignoring them would take the `else` branch below and issue
|
|
452
|
+
# `FLUSHDB`, so a caller asking to clear ONE family on an unnamespaced
|
|
453
|
+
# backend would wipe the entire database: every other family, every
|
|
454
|
+
# other app sharing the backend, and the `parse-stack:foc:v1:*`
|
|
455
|
+
# create-locks whose loss silently removes `first_or_create!` mutual
|
|
456
|
+
# exclusion. A request to narrow must never widen. Use
|
|
457
|
+
# `backend.scoped(keyspace).clear(family:)` instead.
|
|
458
|
+
#
|
|
459
|
+
# @raise [ArgumentError] when `family:` or `tenant:` is given.
|
|
460
|
+
def clear(scope: nil, family: nil, tenant: nil)
|
|
461
|
+
unless family.nil? && tenant.nil?
|
|
462
|
+
raise ArgumentError,
|
|
463
|
+
"Parse::Cache::Redis#clear cannot honor family:/tenant: without a keyspace, " \
|
|
464
|
+
"and ignoring them here would fall through to FLUSHDB. " \
|
|
465
|
+
"Call backend.scoped(keyspace).clear(family:, tenant:) instead."
|
|
466
|
+
end
|
|
467
|
+
|
|
214
468
|
if scope
|
|
215
469
|
prefix = validate_scope!(scope)
|
|
216
470
|
delete_keys_matching!("#{prefix}:*")
|
|
@@ -222,6 +476,24 @@ module Parse
|
|
|
222
476
|
self
|
|
223
477
|
end
|
|
224
478
|
|
|
479
|
+
# Delete every key matching a glob pattern. Exposed so the caching
|
|
480
|
+
# middleware can evict all auth variants of one resource on write, which
|
|
481
|
+
# it cannot do by naming keys: it has no way to enumerate the entries
|
|
482
|
+
# belonging to sessions this process has never seen.
|
|
483
|
+
#
|
|
484
|
+
# This backend never has a keyspace of its own (see {#scoped}), so a
|
|
485
|
+
# direct call here is always a no-op: there is no root_prefix to
|
|
486
|
+
# confine the pattern to. Callers with a keyspace should call
|
|
487
|
+
# `backend.scoped(keyspace).delete_matching(pattern)` instead, which
|
|
488
|
+
# confines the pattern to that view's own root_prefix before it ever
|
|
489
|
+
# reaches Redis.
|
|
490
|
+
#
|
|
491
|
+
# @param pattern [String] a Redis glob pattern.
|
|
492
|
+
# @return [Integer] number of keys removed. Always 0 on this backend.
|
|
493
|
+
def delete_matching(pattern)
|
|
494
|
+
0
|
|
495
|
+
end
|
|
496
|
+
|
|
225
497
|
# Issue `FLUSHDB` on the backing Redis DB, regardless of whether a
|
|
226
498
|
# namespace is configured. Evicts every key on the selected DB,
|
|
227
499
|
# including unrelated tenants — use only for ops tooling that
|
|
@@ -240,6 +512,115 @@ module Parse
|
|
|
240
512
|
|
|
241
513
|
private
|
|
242
514
|
|
|
515
|
+
# Redis-rb-shaped decorator used only by {#verify_upstream_isolation!}.
|
|
516
|
+
# Wraps the raw scan-capable client and keeps only Parse-Server-shaped
|
|
517
|
+
# role keys in each SCAN batch before
|
|
518
|
+
# `UpstreamRoles#shares_database_with?` ever sees it. That is what lets
|
|
519
|
+
# the isolation probe use a broad, app-id-less `"*"` pattern (catching
|
|
520
|
+
# ANY app's role cache key without having to know its app id) while the
|
|
521
|
+
# same glob would otherwise also match this SDK's own role-plane keys
|
|
522
|
+
# (`parse-stack:<version>:<scope>:<ns>:role:<userId>`), making the probe
|
|
523
|
+
# report "shared" as soon as the role plane held anything, on a database
|
|
524
|
+
# that is in fact isolated.
|
|
525
|
+
class ExcludeOwnKeysScanner
|
|
526
|
+
# Parse Server writes exactly `<appId>:role:<userId>`: three
|
|
527
|
+
# colon-separated segments, with `role` in the middle and neither
|
|
528
|
+
# outer segment containing a colon.
|
|
529
|
+
#
|
|
530
|
+
# This keeps only keys of that shape, rather than rejecting keys that
|
|
531
|
+
# look like ours. The difference matters because the two are not
|
|
532
|
+
# complements. Rejecting anything under `parse-stack:` drops a genuine
|
|
533
|
+
# upstream key when the Parse application is itself named
|
|
534
|
+
# `parse-stack`, since `parse-stack:role:U1` starts with that prefix,
|
|
535
|
+
# and the probe then reports a shared database as isolated: the exact
|
|
536
|
+
# false negative this scanner was added to prevent, just reachable
|
|
537
|
+
# through an app id instead of through a glob.
|
|
538
|
+
#
|
|
539
|
+
# Narrowing the rejection to `parse-stack:v1:` would fix that one case
|
|
540
|
+
# and break another. `v1` is {Parse::Cache::Keyspace::VERSION}, but the
|
|
541
|
+
# version is a constructor parameter, so a keyspace built with any
|
|
542
|
+
# other value would no longer be excluded and its role-plane keys would
|
|
543
|
+
# be counted as Parse Server's. Matching the upstream shape depends on
|
|
544
|
+
# nothing this SDK can reconfigure.
|
|
545
|
+
#
|
|
546
|
+
# Our own role-plane keys are
|
|
547
|
+
# `parse-stack:<version>:<scope>:<namespace>:role:<userId>`, six
|
|
548
|
+
# segments, so they can never satisfy the anchored three-segment
|
|
549
|
+
# pattern no matter how the keyspace is configured.
|
|
550
|
+
UPSTREAM_ROLE_KEY = /\A[^:]+:role:[^:]+\z/.freeze
|
|
551
|
+
private_constant :UPSTREAM_ROLE_KEY
|
|
552
|
+
|
|
553
|
+
def initialize(client)
|
|
554
|
+
@client = client
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def scan(cursor, match:, count: 100)
|
|
558
|
+
cursor, keys = @client.scan(cursor, match: match, count: count)
|
|
559
|
+
[cursor, keys.select { |k| UPSTREAM_ROLE_KEY.match?(k.to_s) }]
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
private_constant :ExcludeOwnKeysScanner
|
|
564
|
+
|
|
565
|
+
def upstream_client
|
|
566
|
+
@upstream_client ||= begin
|
|
567
|
+
require "redis"
|
|
568
|
+
::Redis.new(url: @parse_cache_url)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Write a random key to OUR database and ask the upstream connection to
|
|
573
|
+
# read it back. This is the only direction that can establish isolation:
|
|
574
|
+
# a key that just appeared on our database and is not visible through
|
|
575
|
+
# the other connection proves the two are not the same database.
|
|
576
|
+
#
|
|
577
|
+
# The value is random too, so a stale key from a previous run cannot be
|
|
578
|
+
# mistaken for this run's sentinel.
|
|
579
|
+
#
|
|
580
|
+
# An earlier version of this check did the round-trip and treated a nil
|
|
581
|
+
# read as isolated, full stop. That is wrong under the restricted
|
|
582
|
+
# credential this SDK documents (`~<appId>:role:* ... +get +pttl`),
|
|
583
|
+
# where reading anything else raises NOPERM. redis-rb surfaces that as
|
|
584
|
+
# a `CommandError`, which is distinguishable from a nil, so the two are
|
|
585
|
+
# kept apart here instead of both meaning "isolated".
|
|
586
|
+
#
|
|
587
|
+
# @return [:shared, :isolated, :unknown]
|
|
588
|
+
def sentinel_probe
|
|
589
|
+
token = SecureRandom.hex(16)
|
|
590
|
+
key = "#{Keyspace::ROOT}:probe:#{token}"
|
|
591
|
+
begin
|
|
592
|
+
# Raw client, not Moneta: the upstream read is a plain GET and must
|
|
593
|
+
# see the same bytes we wrote, unmediated by a key/value serializer.
|
|
594
|
+
@pool.pool.with { |store| backend_client(store).set(key, token, ex: SENTINEL_TTL) }
|
|
595
|
+
rescue StandardError
|
|
596
|
+
# Cannot write, so cannot establish anything.
|
|
597
|
+
return :unknown
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
begin
|
|
601
|
+
seen = upstream_client.get(key)
|
|
602
|
+
return :shared if seen == token
|
|
603
|
+
# A non-nil value that is not our token means something else owns
|
|
604
|
+
# this key, which should be impossible. Do not call that isolated.
|
|
605
|
+
return :unknown unless seen.nil?
|
|
606
|
+
:isolated
|
|
607
|
+
rescue StandardError
|
|
608
|
+
# NOPERM, a transport failure, a Cluster CROSSSLOT: all say nothing
|
|
609
|
+
# about which database the key lives on.
|
|
610
|
+
:unknown
|
|
611
|
+
ensure
|
|
612
|
+
begin
|
|
613
|
+
@pool.pool.with { |store| backend_client(store).del(key) }
|
|
614
|
+
rescue StandardError
|
|
615
|
+
# The TTL collects it.
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Seconds the probe key lives if the explicit delete does not land.
|
|
621
|
+
SENTINEL_TTL = 10
|
|
622
|
+
private_constant :SENTINEL_TTL
|
|
623
|
+
|
|
243
624
|
# Serialize a cache value to a JSON String before handing it to Moneta
|
|
244
625
|
# (which stores it raw, since the value serializer is disabled — see the
|
|
245
626
|
# constructor). JSON is used instead of Marshal so the read side never
|
|
@@ -267,18 +648,52 @@ module Parse
|
|
|
267
648
|
end
|
|
268
649
|
|
|
269
650
|
def delete_keys_matching!(pattern)
|
|
651
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
652
|
+
deleted = 0
|
|
270
653
|
@pool.pool.with do |store|
|
|
271
654
|
redis = backend_client(store)
|
|
272
|
-
# SCAN-
|
|
655
|
+
# SCAN-UNLINK loop. `count:` is a hint to the server; the actual
|
|
273
656
|
# batch size returned varies. Loop until the cursor wraps back
|
|
274
657
|
# to "0".
|
|
658
|
+
#
|
|
659
|
+
# UNLINK reclaims memory on a background thread, so a large scoped
|
|
660
|
+
# eviction does not stall the server the way DEL would. It has been
|
|
661
|
+
# available since Redis 4.0; fall back to DEL on anything older or on
|
|
662
|
+
# a client that does not expose it.
|
|
663
|
+
unlink = redis.respond_to?(:unlink)
|
|
275
664
|
cursor = "0"
|
|
276
665
|
loop do
|
|
277
666
|
cursor, keys = redis.scan(cursor, match: pattern, count: 1000)
|
|
278
|
-
|
|
667
|
+
unless keys.empty?
|
|
668
|
+
removed = unlink ? redis.unlink(*keys) : redis.del(*keys)
|
|
669
|
+
# Keys can disappear between SCAN and UNLINK/DEL. Redis reports
|
|
670
|
+
# how many it actually removed; using the scanned batch size
|
|
671
|
+
# overstates both this return value and parse.cache.evict.
|
|
672
|
+
deleted += removed.to_i
|
|
673
|
+
end
|
|
279
674
|
break if cursor == "0"
|
|
280
675
|
end
|
|
281
676
|
end
|
|
677
|
+
instrument_eviction(pattern, deleted, started)
|
|
678
|
+
deleted
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Emit a structured event for a scoped eviction so operators can see how
|
|
682
|
+
# much a clear actually removed and how long it took. The pattern is a
|
|
683
|
+
# key prefix, so it is digested rather than logged: a response-cache
|
|
684
|
+
# pattern embeds a URL digest and a tenant, and the identity family's
|
|
685
|
+
# keys are derived from session tokens.
|
|
686
|
+
def instrument_eviction(pattern, deleted, started)
|
|
687
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
688
|
+
ActiveSupport::Notifications.instrument(
|
|
689
|
+
"parse.cache.evict",
|
|
690
|
+
pattern_digest: Digest::SHA256.hexdigest(pattern.to_s)[0, 16],
|
|
691
|
+
deleted: deleted,
|
|
692
|
+
duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2),
|
|
693
|
+
)
|
|
694
|
+
rescue StandardError
|
|
695
|
+
# Instrumentation must never turn a successful eviction into a failure.
|
|
696
|
+
nil
|
|
282
697
|
end
|
|
283
698
|
|
|
284
699
|
def backend_client(moneta_store)
|