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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -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, **moneta_options)
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
- decode_value(@pool[key])
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
- def delete(key)
116
- @pool.delete(key)
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
- def clear(scope: nil)
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-DEL loop. `count:` is a hint to the server; the actual
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
- redis.del(*keys) unless keys.empty?
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)