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
data/docs/caching.md ADDED
@@ -0,0 +1,748 @@
1
+ # Caching
2
+
3
+ Parse Stack Next has several independent caches. They do not share a
4
+ configuration knob, a TTL, or a backend, and only some of them are shared
5
+ across processes. This document describes each one: what it stores, how its key
6
+ is built, what bounds its staleness, and what happens when its backend goes
7
+ away.
8
+
9
+ If you only want the short version: configure `cache:` and `expires:` for the
10
+ HTTP response cache, turn on `cache_keyspace: true` so clearing is scoped, and
11
+ remember that every other cache in the table below is process-local unless you
12
+ explicitly move it to Redis.
13
+
14
+ ## Overview
15
+
16
+ | Plane | Stores | Key | Default TTL | Shared across workers |
17
+ |---|---|---|---|---|
18
+ | HTTP response cache (`Parse::Middleware::Caching`) | body and headers of successful `GET` responses | request URL plus an auth discriminator | `expires:` (3 seconds) | Only if the store is (Redis yes, Moneta memory no) |
19
+ | Identity plane (`view.identity`) | session token to user id | token, under the `idn` keyspace family | `client.authorization.identity_cache_ttl` (3600) | Yes |
20
+ | Role plane (`view.roles`) | user id to role-name closure | user id, under the `role` keyspace family | `client.authorization.role_cache_ttl` (30) | Yes |
21
+ | Authorization default caches (`Parse::Authorization::MemoryCache`) | the same two mappings | token / user id | same two TTLs | No |
22
+ | Upstream role reader (`view.upstream_roles`) | nothing, it is read-only, and role resolution does not consume it | `<appId>:role:<userId>` written by Parse Server | n/a, entries age out upstream | Reads Parse Server's database |
23
+ | CLP schema cache (`Parse::CLPScope`) | class-level permissions per class | class name | `POSITIVE_TTL` 3600, `NEGATIVE_TTL` 5 | No |
24
+ | Atlas index catalog (`Parse::AtlasSearch::IndexManager`) | search index definitions per collection | collection name | `DEFAULT_CACHE_TTL` 300 | No |
25
+ | Embedding cache (`Parse::Embeddings::Cache`) | query-side embedding vectors | provider, model, dimensions, input type, digest of input | 600, disabled by default | No, unless given a Moneta store |
26
+ | Audience cache (`Parse::Audience`) | audience objects by name | audience name | `DEFAULT_CACHE_TTL` 300 | No |
27
+ | Rank-fusion probe (`Parse::VectorSearch::Hybrid`) | whether the cluster supports `$rankFusion` | collection name | `PROBE_CACHE_TTL` 3600 | No |
28
+ | Model registry (`Parse::Model`) | Parse class name to Ruby class | class name | none | No |
29
+ | Known classes (`Parse::Query.known_parse_classes`) | class names from the schema endpoint | n/a, one list | none, memoized once | No |
30
+ | Client config (`Parse::Client#config`) | the application config hash | n/a, per client | none, until `config!` | No |
31
+ | Webhook replay guard (`Parse::Webhooks::ReplayProtection`) | digests of seen webhook deliveries | request id and body digest | `DEFAULT_REPLAY_WINDOW` 300, 10,000 entries | No |
32
+ | Create-locks and `Parse::Lock` | lock ownership tokens | `parse-stack:foc:v1:<digest>`, `parse-stack:lock:v1:<digest>` | 3 seconds, capped at 30 | Only on a Redis-backed store |
33
+
34
+ Process-local means one copy per Ruby process. Two Puma workers, or a web dyno
35
+ and a worker dyno, each keep their own, and nothing invalidates the other's.
36
+ That is fine for a schema cache and dangerous for anything you expect to
37
+ revoke.
38
+
39
+ ## Setting up a backend
40
+
41
+ ### Any Moneta store
42
+
43
+ The `cache:` option accepts any [Moneta](https://github.com/minad/moneta) store,
44
+ or anything that responds to `[]`, `key?`, `delete`, and `store`. The client
45
+ validates that surface at setup and raises `ArgumentError` otherwise.
46
+
47
+ ```ruby
48
+ Parse.setup(
49
+ server_url: ENV.fetch("PARSE_SERVER_URL"),
50
+ application_id: ENV.fetch("PARSE_APP_ID"),
51
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
52
+ cache: Moneta.new(:Memory),
53
+ expires: 10,
54
+ )
55
+ ```
56
+
57
+ There is no default store. With `cache:` unset, the caching middleware is never
58
+ added to the connection and nothing is cached. With a store configured but
59
+ `expires:` at 0 or unset, the client warns and skips the middleware entirely,
60
+ which is the most common reason a cache appears to do nothing. The default when
61
+ you do pass a store is `expires: 3`.
62
+
63
+ A `Moneta.new(:Memory)` store is process-local. It is a reasonable default for
64
+ tests and for a single-process deployment, and it is the wrong choice behind
65
+ multiple workers: a write through worker A does not invalidate worker B's copy,
66
+ so B keeps serving the stale body until its own TTL expires.
67
+
68
+ ### `Parse::Cache::Redis`
69
+
70
+ The bundled wrapper adds a connection pool, an optional namespace that flows
71
+ automatically into the client, JSON value encoding, atomic lock primitives, and
72
+ scoped clearing.
73
+
74
+ ```ruby
75
+ store = Parse::Cache::Redis.new(
76
+ url: "redis://localhost:6379/0",
77
+ namespace: "web",
78
+ pool_size: 10,
79
+ )
80
+
81
+ Parse.setup(cache: store, expires: 10, cache_keyspace: true, ...)
82
+ ```
83
+
84
+ Passing a `redis://` URL string to `cache:` builds the same wrapper for you.
85
+
86
+ Use the wrapper rather than a bare `Moneta.new(:Redis, ...)`. Moneta serializes
87
+ values with Marshal by default, so every cache read would `Marshal.load` bytes
88
+ returned by Redis, which is a remote-code-execution primitive when that Redis is
89
+ shared, unauthenticated, or reachable over a plaintext connection. The wrapper
90
+ forces `value_serializer: nil` and encodes values as JSON itself. If you supply
91
+ your own Moneta store, build it with `value_serializer: nil`.
92
+
93
+ The wrapper refuses a Moneta `prefix:` option, because it would rewrite the
94
+ physical key layout underneath the SCAN patterns that scoped clearing depends
95
+ on. Use `namespace:` instead.
96
+
97
+ ### Pool sizing
98
+
99
+ Each pooled backend is one Redis connection, and each cache operation checks one
100
+ out. Per Faraday request:
101
+
102
+ * cache hit: `key?` then `[]`, so 2 checkouts
103
+ * `GET` miss followed by a successful store: `key?`, three variant deletes, and
104
+ one `store` in the completion callback, so up to 5 checkouts
105
+ * non-`GET` write: the variant deletes, so about 3 checkouts, plus one more for
106
+ the scoped SCAN when a keyspace is configured
107
+
108
+ The worst case is the write-through-after-miss path, not the hit path. Start at
109
+ `pool_size = RAILS_MAX_THREADS` and raise it if you see
110
+ `ConnectionPool::TimeoutError` on the `parse.cache.error` notification. The
111
+ checkout timeout defaults to 5 seconds, and the middleware turns that error into
112
+ a passthrough request rather than raising to your code.
113
+
114
+ ### The `expires: false` caveat
115
+
116
+ The wrapper passes `expires: true` to the Moneta Redis adapter. That flag is
117
+ what makes the adapter honor the per-key TTL the caching middleware supplies on
118
+ each `store` call. Passing `expires: false` yourself disables it, and the
119
+ adapter then ignores every per-call TTL, so cached responses live until
120
+ something explicitly deletes them.
121
+
122
+ That is almost always wrong here. Response-cache entries are scoped to an auth
123
+ identity, so entries written for a session token would outlive the token's
124
+ validity with no bound at all. Only pass `expires: false` if you are managing
125
+ key lifetime entirely outside the SDK.
126
+
127
+ ## Response caching
128
+
129
+ There is one HTTP response cache. It is a Faraday middleware, it stores the
130
+ body and headers of successful `GET` responses, and it keys them by the request
131
+ URL.
132
+
133
+ This is the point most people get wrong: "query caching" and "object caching"
134
+ are not two systems. `Post.find("abc")` issues a `GET` to
135
+ `/classes/Post/abc`, and `Post.query(status: "published").results` issues a
136
+ `GET` to `/classes/Post/?where=...`. Both go through the same middleware and
137
+ land in the same store. The only difference is the URL being cached, and the
138
+ default opt-in posture described below.
139
+
140
+ A response is stored only when all of the following hold:
141
+
142
+ * the method is `GET`
143
+ * the status is 200, 203, 300, 301, or 302
144
+ * the body is present
145
+ * the `content-length` response header is between 20 and 1,250,000
146
+
147
+ The `content-length` requirement is a real constraint, not a formality. A
148
+ response that arrives without that header is not cached, because the middleware
149
+ reads the header and compares the integer value.
150
+
151
+ ### Opt-in, and `Parse.default_query_cache`
152
+
153
+ Object fetches cache by default once a store is configured. Queries do not: a
154
+ new `Parse::Query` initializes its `cache` attribute from
155
+ `Parse.default_query_cache`, which is `false`, and a query with caching off
156
+ sends `Cache-Control: no-cache`, which turns the middleware into a passthrough
157
+ for that request.
158
+
159
+ ```ruby
160
+ # Opt in per query.
161
+ Post.all(limit: 500, cache: true)
162
+
163
+ # Opt in for a specific duration, in seconds.
164
+ Post.query(:published.eq => true, :cache => 300).results
165
+
166
+ # Opt out for a single query.
167
+ Post.query(status: "draft", cache: false).results
168
+
169
+ # Flip the global default to opt-out behavior.
170
+ Parse.default_query_cache = true
171
+ ```
172
+
173
+ The client warns once at setup when the middleware is enabled while
174
+ `Parse.default_query_cache` is false, so that the opt-in behavior is not a
175
+ surprise.
176
+
177
+ ### Per-request headers
178
+
179
+ The client translates the `cache:` request option into three headers the
180
+ middleware consumes and then strips before the request goes out:
181
+
182
+ | Option | Header | Effect |
183
+ |---|---|---|
184
+ | `cache: false` | `Cache-Control: no-cache` | Neither read nor write for this request |
185
+ | `cache: <Integer>` | `X-Parse-Stack-Cache-Expires` | Overrides the TTL for this request |
186
+ | `cache: :write_only` | `X-Parse-Stack-Cache-Write-Only` | Skip the read, still write the fresh response |
187
+ | `cache: true` | none | Use the middleware default TTL |
188
+
189
+ Write-only mode is what `fetch!` and `reload!` use by default, so a refresh
190
+ always contacts the server and simultaneously refreshes the cached copy for
191
+ later readers. Set `Parse.cache_write_on_fetch = false` to make those calls
192
+ bypass the cache in both directions instead. `fetch_cache!` is the opposite:
193
+ `fetch!` with `cache: true`, which will accept a cached body.
194
+
195
+ ### Invalidation on write
196
+
197
+ Any non-`GET` request evicts the cached entry for the same URL. With a keyspace
198
+ configured, the eviction is a pattern delete across every auth variant of that
199
+ resource, which reaches entries belonging to sessions this process has never
200
+ seen. Without a keyspace, the middleware can only name the variants it knows
201
+ about (the caller's own entry, the anonymous one, and the master-key one), so
202
+ other sessions' copies survive until their TTL.
203
+
204
+ Invalidation matches the exact URL, and query URLs carry their `where`
205
+ parameters. Saving one `Post` therefore evicts `/classes/Post/<id>` but not the
206
+ cached result of `/classes/Post/?where={"status":"published"}`. Cached list
207
+ results are bounded by TTL alone. Keep `expires:` short if your application
208
+ caches queries.
209
+
210
+ A cache miss on a `GET` also opportunistically deletes stale sibling variants of
211
+ the same URL, so an old entry from a different request flavor does not linger.
212
+
213
+ ### Idempotency and retries
214
+
215
+ Request idempotency and response caching are mostly orthogonal. Parse Server's
216
+ idempotency applies to writes, and it is off by default and labelled
217
+ experimental there; the middleware caches only `GET` responses, so on the
218
+ caching path the two never meet. Three points are worth knowing anyway.
219
+
220
+ * Cache keys are built from the URL digest and the auth discriminator only. No
221
+ request header contributes, so a per-request id cannot fragment the cache into
222
+ one entry per request.
223
+ * A write the server rejects as a duplicate still triggers the non-`GET`
224
+ invalidation on this side. Note that Parse Server rejects such a request with
225
+ `DUPLICATE_REQUEST` rather than replaying the original response, so the extra
226
+ eviction is wasted work, never a wrong answer.
227
+ * Ordering matters if a retry layer is ever added below the cache. The caching
228
+ middleware sits directly above the adapter, and a hit returns before the
229
+ adapter runs, so anything registered below it does not execute on a hit.
230
+
231
+ ## Auth scoping of cached responses
232
+
233
+ The cache key carries an auth discriminator, and this is a correctness property
234
+ rather than an optimization.
235
+
236
+ The same URL returns different bodies to different callers. A master-key request
237
+ bypasses ACL, class-level permissions, and `protectedFields`, so it receives a
238
+ strictly fuller body than a session request. Two different sessions can also
239
+ differ from each other, through row ACLs and through `protectedFields` entity
240
+ rules. Collapsing those into one entry would hand privileged fields to an
241
+ unprivileged caller straight out of the cache.
242
+
243
+ So the key includes one of three things:
244
+
245
+ * `mk` for a master-key request
246
+ * the first 32 hex characters of the SHA-256 of the session token
247
+ * `anon` for an unauthenticated request
248
+
249
+ Raw session tokens never become key material. With a keyspace configured, the
250
+ URL digest is placed before the discriminator, so every auth variant of one
251
+ resource shares a prefix, which is exactly what lets a write invalidate the
252
+ resource for all callers with a single pattern delete.
253
+
254
+ One consequence worth planning for: a heavily multi-user endpoint produces one
255
+ cache entry per session per URL. Cardinality scales with active sessions, not
256
+ with distinct resources.
257
+
258
+ ## Identity and role caching
259
+
260
+ Mongo-direct queries do not go through Parse Server, so Parse Server's
261
+ per-request ACL enforcement does not apply to them. The SDK enforces ACL itself
262
+ on that path, and to do so it needs two facts about the caller:
263
+
264
+ 1. the `_User.objectId` behind the session token
265
+ 2. the transitive closure of role names that user inherits
266
+
267
+ Both are expensive. The first is a `/users/me` round-trip. The second walks the
268
+ `_Role` graph. `Parse::Authorization` resolves and caches them, one context per
269
+ `Parse::Client`, reachable as `client.authorization`. Three consumers reach
270
+ through it: Atlas Search, `Parse::MongoDB.aggregate`-backed aggregates, and
271
+ direct queries. The result feeds `permission_strings`, which is the sole input
272
+ to both the `_rperm` match and the class-level-permission gate used by
273
+ `Parse::ACLScope`, `Parse::AtlasSearch`, `Query#results_direct`, and
274
+ `Query#count_direct`.
275
+
276
+ State lives on the client rather than at module level because a token belongs
277
+ to one application. A set of module-level globals would let a token minted by
278
+ a secondary application resolve against the default application's caches, or
279
+ worse, match a different user with the same token shape there. `client:` is
280
+ therefore required with no default below the `Parse::Authorization.configure`
281
+ boundary described next.
282
+
283
+ ### The process-local defaults
284
+
285
+ Out of the box both caches are `Parse::Authorization::MemoryCache`, a
286
+ mutex-guarded hash with per-entry TTL. TTLs come from
287
+ `client.authorization.identity_cache_ttl` (3600 seconds) and
288
+ `client.authorization.role_cache_ttl` (30 seconds). The identity plane is named
289
+ for what it stores: one user id per token, never a `_Session` row or session
290
+ object, so `identity_cache` replaces the older `session_cache` name that
291
+ invited readers to reason about `_Session` semantics that were never involved.
292
+ The asymmetry between the two TTLs is deliberate: a stale token-to-user mapping
293
+ only extends the life of a revoked session, while a stale role closure produces
294
+ a wrong access-control decision, so the role TTL is short enough that a grant
295
+ or revoke lands within seconds.
296
+
297
+ These caches are per process. Each Puma worker and each dyno resolves and holds
298
+ its own copy, and `context.invalidate` in one process does not reach the
299
+ others. Expired entries are dropped lazily when the key is next read, so a
300
+ process that sees a large number of distinct tokens holds them until each is
301
+ read again or until `context.reset_caches!` runs.
302
+
303
+ ### The shared planes
304
+
305
+ A keyspaced client's `Parse::Cache::ScopedView` exposes two planes shaped for
306
+ those slots:
307
+
308
+ ```ruby
309
+ store = Parse::Cache::Redis.new(url: "redis://localhost:6379/0")
310
+ Parse.setup(cache: store, expires: 10, cache_keyspace: true, ...)
311
+
312
+ view = Parse.client.sdk_cache # the scoped view derived at setup
313
+ Parse::Authorization.configure(
314
+ identity_cache: view.identity(ttl: 3600),
315
+ role_cache: view.roles(ttl: 30),
316
+ )
317
+ ```
318
+
319
+ `Parse::Authorization.configure` is a boundary convenience for the common
320
+ single-application case: it configures the default client's context, the same
321
+ one `Parse.client.authorization` returns. It is not the source of truth, the
322
+ context is. For a named secondary client, configure its own context directly
323
+ instead:
324
+
325
+ ```ruby
326
+ other_view = other_client.sdk_cache
327
+ other_client.authorization.configure(
328
+ identity_cache: other_view.identity(ttl: 3600),
329
+ role_cache: other_view.roles(ttl: 30),
330
+ )
331
+ ```
332
+
333
+ Both require `cache_keyspace: true`; without a keyspace they raise
334
+ `ArgumentError` rather than writing into an unscoped key space. Each plane
335
+ writes inside its own keyspace family, so clearing one reaches neither the other
336
+ nor the response cache.
337
+
338
+ Two behaviors to know before you rely on these:
339
+
340
+ * Values round-trip through JSON. The identity plane stores a generation-tagged
341
+ user id, while the role plane tags a Ruby `Set` before writing it and rebuilds
342
+ the `Set` when reading it back, so both shared planes can serve hits without
343
+ changing the resolver's expected value shape.
344
+ * Sub-TTL revocation is automatic for both triggers, as long as
345
+ `client.authorization.identity_cache` and `client.authorization.role_cache`
346
+ are set to planes from the SAME view `Parse::Cache::Invalidation` was
347
+ installed against (the pattern shown above: both derived from
348
+ `Parse.client.sdk_cache`). The `after_logout` trigger invalidates the identity
349
+ entry using the same raw session token the resolver stores it under, so a
350
+ logout on any client (mobile SDK, dashboard, Node cloud code) evicts the
351
+ shared entry immediately rather than waiting out `identity_cache_ttl`.
352
+ Likewise, a `_User` write bumps that user's generation, and the resolver
353
+ checks it on every read, so a stale entry is rejected the moment the bump
354
+ lands rather than surviving until the TTL expires. You still may want an
355
+ explicit `client.authorization.invalidate(token)` /
356
+ `.invalidate_user_roles(user_id)` call from your own logout / role-mutation
357
+ code paths for a deployment that has not wired up the webhook endpoint the
358
+ triggers depend on. The deprecated `Parse::AtlasSearch::Session.invalidate` /
359
+ `.invalidate_user_roles` forms still work through 5.x: they delegate to the
360
+ default client's context, so they can only ever address `Parse.client`.
361
+
362
+ On a Redis outage these planes behave differently from the response cache.
363
+ The response cache degrades to a passthrough request; the identity and role
364
+ planes do not swallow connection errors, so a scoped mongo-direct query raises
365
+ rather than silently running with reduced permissions. That fails closed, which
366
+ is the right direction, but it does mean the planes are on the critical path for
367
+ scoped queries once you install them.
368
+
369
+ ## Reading Parse Server's own role cache
370
+
371
+ Parse Server caches each user's transitive role closure under
372
+ `<appId>:role:<userId>` as a JSON array of `role:NAME` strings. Attaching to it
373
+ lets the SDK compare that value with its own role-graph result, or lets trusted
374
+ application code read it explicitly when a webhook payload already supplies a
375
+ user id. This is optional, and nothing in the SDK reads it unless you attach it
376
+ and enable comparison or call it directly.
377
+
378
+ ```ruby
379
+ store = Parse::Cache::Redis.new(
380
+ url: "redis://localhost:6379/0", # the SDK's own cache
381
+ parse_cache_url: "redis://localhost:6379/1", # Parse Server's cache, read-only
382
+ )
383
+ Parse.setup(cache: store, cache_keyspace: true, ...)
384
+ view = Parse.client.sdk_cache
385
+ ```
386
+
387
+ **Role resolution does not consume it.** `Parse::Authorization` always computes
388
+ its own closure, and the value read here never changes an ACL decision. The
389
+ only built-in integration is the compare-only path below. If you want the
390
+ upstream value, call `roles_for` yourself and decide what to do with it. The
391
+ reason for the split is that the moment a value is consumed it becomes an
392
+ authorization input, and this one comes from a database the SDK does not own,
393
+ so it stays observable until the two closures have been reconciled against your
394
+ own traffic:
395
+
396
+ ```ruby
397
+ view = Parse.client.sdk_cache
398
+ Parse::Authorization.configure(
399
+ upstream_role_reader: view.upstream_roles,
400
+ compare_upstream_roles: true,
401
+ )
402
+
403
+ ActiveSupport::Notifications.subscribe("parse.cache.role_compare") do |*, payload|
404
+ # payload: user_digest, matched, upstream_nil, computed_size,
405
+ # upstream_size, only_in_ours, only_in_upstream
406
+ Metrics.increment("role_compare.#{payload[:matched] ? "match" : "divergent"}")
407
+ end
408
+ ```
409
+
410
+ Role names and raw user ids are kept out of the payload; the user is
411
+ identified by a truncated digest.
412
+
413
+ ```ruby
414
+ store.verify_upstream_isolation!
415
+ roles = view.upstream_roles.roles_for(user_id) # Set of bare role names, or nil
416
+ ```
417
+
418
+ **The two URLs must address different Redis databases.** On released Parse
419
+ Server, a `_Role` write clears the cache with `FLUSHDB`. On a shared database
420
+ that deletes the SDK's cached responses and, more seriously, its
421
+ `parse-stack:foc:v1:*` create-locks, so a `first_or_create!` holding a lock at
422
+ that moment silently loses mutual exclusion. See
423
+ [parse-server#10617](https://github.com/parse-community/parse-server/issues/10617).
424
+
425
+ `verify_upstream_isolation!` answers in two steps. It scans the SDK's own
426
+ database for a key shaped like one Parse Server would have written, and if that
427
+ finds nothing it writes a random sentinel to the SDK's database and asks the
428
+ upstream connection to read it back. The scan alone can only prove sharing: an
429
+ empty result looks the same on a separate database and on a shared one where
430
+ Parse Server has not cached a role closure yet, which is the state of a stack
431
+ you have just deployed and are most likely to be checking. Only the sentinel
432
+ establishes the negative.
433
+
434
+ | Return | Meaning |
435
+ |---|---|
436
+ | `true` | Isolation established. The sentinel was not visible upstream. |
437
+ | `false` | Sharing established, and warned about. |
438
+ | `:unknown` | Neither could be shown. Truthy, so `if store.verify_upstream_isolation!` behaves as before. |
439
+
440
+ `:unknown` is what the restricted credential below produces: the sentinel read
441
+ comes back NOPERM, and a denial says nothing about which database denied it.
442
+ Grant the reader `GET` on `parse-stack:probe:*` if you want a definite answer,
443
+ or confirm the two databases differ by hand.
444
+
445
+ Comparing URL strings cannot substitute for any of this: `localhost` against
446
+ `127.0.0.1`, CNAMEs, Sentinel and Cluster topologies, and a database selected
447
+ outside the URL all defeat it. It warns rather than refusing to boot, because
448
+ the hazard disappears on a server carrying the scoped-clear fix, and it routes
449
+ through the same `on_degraded:` handling as the lock code so you can escalate
450
+ it to a raise.
451
+
452
+ The attachment is strictly read-only. The SDK never writes that keyspace,
453
+ because its own closure is depth-capped while Parse Server's is not, and writing
454
+ a subset into a cache the server treats as authoritative would under-permission
455
+ users.
456
+
457
+ Every failure degrades to a miss, and the caller recomputes: a missing key,
458
+ malformed JSON, a value that is not an array of `role:`-prefixed strings, more
459
+ than 4096 roles, a role name longer than 256 characters, a remaining TTL that
460
+ cannot be read or exceeds 60 seconds, an entry older than the SDK's last role
461
+ invalidation, or any transport error. It never fails open.
462
+
463
+ If application code consumes those role names for authorization, that database
464
+ becomes part of its trust base. Restrict the credential:
465
+
466
+ ```
467
+ ACL SETUSER parse-stack-role-reader on >SECRET \
468
+ ~<appId>:role:* resetchannels -@all +get +pttl
469
+ ```
470
+
471
+ `+pttl` is required alongside `+get`. The freshness check derives an entry's
472
+ write time from its remaining TTL, and cannot run without it.
473
+
474
+ ## Invalidation
475
+
476
+ Four mechanisms bound staleness. They stack; none of them replaces the TTL.
477
+
478
+ **Writes through this SDK.** Every non-`GET` evicts the response-cache entries
479
+ for that exact URL, as described above.
480
+
481
+ **Webhook triggers.** When a keyspace is configured, `Parse::Cache::Invalidation`
482
+ registers triggers so that a change made by any client, including a mobile SDK,
483
+ the dashboard, or Node cloud code, invalidates the planes the same way an SDK
484
+ write does:
485
+
486
+ | Trigger | Class | Effect |
487
+ |---|---|---|
488
+ | `after_save`, `after_delete` | `_Role` | Clear the whole role plane and stamp its invalidation epoch |
489
+ | `after_save`, `after_delete` | `_User` | Bump that user's identity generation |
490
+ | `after_logout` | `_Session` | Invalidate the identity entry for that token digest |
491
+
492
+ The role plane is cleared wholesale rather than per user, because a role write
493
+ does not say which users it affected: membership and hierarchy arrive as
494
+ relation deltas, and the cached value is a flattened closure, so a change to a
495
+ parent role reaches every member of every child. Under a scoped SCAN that is
496
+ cheap. The epoch stamp exists so that a role entry read from Parse Server's own
497
+ cache, which is not cleared on a `_Role` delete, is rejected if it predates the
498
+ invalidation.
499
+
500
+ These triggers require a webhook endpoint that Parse Server can reach and that
501
+ you have registered. Where that is not the case, the TTL is the only bound. Pass
502
+ `cache_invalidation_hooks: false` to skip registration.
503
+
504
+ **Explicit calls.** `client.authorization.invalidate(token)`,
505
+ `client.authorization.invalidate_user_roles(user_id)`, and
506
+ `client.authorization.reset_caches!` operate on whichever caches are installed
507
+ in that client's context. The deprecated `Parse::AtlasSearch::Session.invalidate`,
508
+ `.invalidate_user_roles`, and `.reset_caches!` forms still work through 5.x:
509
+ they delegate to the default client's context and are slated for removal in
510
+ 6.0. `Parse::CLPScope.invalidate!(class_name)` and
511
+ `Parse::AtlasSearch.refresh_indexes(collection)` do the same for their planes.
512
+
513
+ **TTL.** Everything else. In particular: cached query results after an unrelated
514
+ write, the CLP schema cache within its hour, the Atlas index catalog within its
515
+ five minutes, and identity entries in a process whose webhook did not fire.
516
+
517
+ ## Locking
518
+
519
+ `Parse::Lock` and the internal create-lock used by `first_or_create!` and
520
+ `create_or_update!` share the cache backend, so their behavior depends on how
521
+ you configured it.
522
+
523
+ `first_or_create!` derives a key from the class name, the auth context, and the
524
+ canonicalized query attributes, then holds `parse-stack:foc:v1:<digest>` for the
525
+ duration of the find-and-create. `Parse::Lock.acquire` does the same for a key
526
+ you supply, under `parse-stack:lock:v1:<digest>`. The prefixes differ so the two
527
+ namespaces cannot collide even for identical names.
528
+
529
+ ```ruby
530
+ Parse::Lock.acquire("import:#{batch_id}", ttl: 10) do
531
+ run_batch_import(batch_id)
532
+ end
533
+
534
+ Subscription.first_or_create!({ workspace: workspace, plan: "pro" })
535
+ ```
536
+
537
+ Defaults: `ttl` 3 seconds, clamped to 1 to 30; `wait` 2 seconds, clamped to 0 to
538
+ 30. The TTL is a crash-recovery floor, not a cap on your work. If the critical
539
+ section outruns the TTL, the lease expires while you are still inside it, a
540
+ second caller can acquire, and `Parse::Lock` warns on release that mutual
541
+ exclusion was not guaranteed for the overrun window. There is no fencing token
542
+ the protected resource checks, so this is mutual exclusion with a deadline, not
543
+ exactly-once execution. Make the protected operation idempotent, and keep a
544
+ unique index on the constrained tuple as the correctness floor beneath
545
+ `first_or_create!`.
546
+
547
+ Key derivation uses HMAC-SHA256 when a secret is configured, through
548
+ `PARSE_STACK_LOCK_SECRET` or `Parse.synchronize_create_secret`, and plain
549
+ SHA-256 otherwise. With a cross-process store and no configured secret the SDK
550
+ warns once, because lock keys are then deterministic: anyone with write access
551
+ to the same Redis can plant a key under a guessable digest and pin that lock
552
+ until TTL expiry. Set the secret, or point
553
+ `Parse.synchronize_create_store` at a Redis database separate from the response
554
+ cache.
555
+
556
+ ### Degraded stores
557
+
558
+ A store is degraded when it is nil, when it does not implement `create`, or when
559
+ the bottom Moneta adapter is a Memory or Null adapter. In that case the lock
560
+ falls back to a per-key in-process `Mutex`: threads inside one process serialize,
561
+ and separate processes do not. `on_degraded:` decides how loudly you hear about
562
+ it.
563
+
564
+ | Mode | Behavior |
565
+ |---|---|
566
+ | `:warn` | One warning per call, the default |
567
+ | `:warn_throttled` | One warning per process per 60 seconds |
568
+ | `:proceed` | Silent |
569
+ | `:raise` | `Parse::Lock::UnavailableError` or `Parse::CreateLockUnavailableError` |
570
+
571
+ Use `:raise` in a multi-worker deployment. The failure that is easy to miss is
572
+ asymmetric degradation: if one worker has `Parse.synchronize_create_store` wired
573
+ to Redis and another does not, they derive different keys for the same logical
574
+ lock and quietly fail to exclude each other, and only the degraded worker warns.
575
+
576
+ On a Redis outage, acquisition errors are treated as "someone else holds it", so
577
+ the caller polls until the `wait` budget elapses and then raises a timeout. The
578
+ block never runs without the lock.
579
+
580
+ ## Multi-tenancy
581
+
582
+ Two mechanisms, at different levels.
583
+
584
+ `namespace:` on the wrapper (or `cache_namespace:` on `Parse.setup`) is a static
585
+ prefix, one per client. Use it when several applications share a Redis database.
586
+ A wrapper's namespace flows into the client automatically, and an explicit
587
+ `cache_namespace:` wins if both are set.
588
+
589
+ `Parse.with_cache_tenant` is dynamic and ambient, held in fiber-local state for
590
+ the duration of a block. Use it when one client serves many tenants.
591
+
592
+ ```ruby
593
+ Parse.with_cache_tenant(tenant_id) do
594
+ Post.query(:published.eq => true, :cache => 60).results
595
+ end
596
+ ```
597
+
598
+ The tenant composes into the key between the namespace and the auth
599
+ discriminator, as `T:<tenant>`, which keeps tenant prefixes unambiguously
600
+ distinct from the 32-character hex of a token digest and from `mk`. Scope values
601
+ must match `/\A[A-Za-z0-9_\-]{1,256}\z/`; a colon is refused, because
602
+ `with_cache_tenant("a:T:b")` would otherwise be indistinguishable from a nested
603
+ pair of scopes and would break the SCAN isolation the feature exists to provide.
604
+ Passing `nil` clears the scope for that block.
605
+
606
+ This is a cache-key boundary, not an access-control boundary. It keeps tenant A's
607
+ cached response from being served on tenant B's request. Data-layer isolation is
608
+ still the job of ACL, class-level permissions, and per-class agent scoping.
609
+
610
+ Clearing is scoped along the same layout. `family:` / `tenant:` / `scope:` are
611
+ scoped-view operations. Call them on `Parse.client.sdk_cache` (the view the
612
+ client derived at setup with `cache_keyspace: true`), never on `Parse.cache`
613
+ or a bare backend, neither of which has a keyspace to scope against.
614
+
615
+ What happens if you get that wrong depends on the store, and the safer
616
+ outcome is not the default one:
617
+
618
+ - **`Parse::Cache::Redis`** raises `ArgumentError`, because it can see that
619
+ it has no keyspace to interpret `family:` against and refuses to widen a
620
+ narrowing request into a `FLUSHDB`.
621
+ - **Any other Moneta store** does something worse and quieter. `Moneta#clear`
622
+ takes an options hash and ignores keys it does not recognize, so
623
+ `store.clear(family: :role)` clears the ENTIRE store and returns normally.
624
+
625
+ Route the call through `sdk_cache` rather than relying on the store to catch
626
+ the mistake:
627
+
628
+ ```ruby
629
+ Parse.client.clear_cache! # everything this client wrote
630
+ Parse.client.sdk_cache.clear(family: :role) # one family
631
+ Parse.client.sdk_cache.clear(family: :cache, tenant: "acme") # one tenant of one family
632
+ Parse.client.sdk_cache.clear(scope: "legacy_prefix") # an explicit prefix
633
+ store.flush_db! # the whole database, ops tooling only
634
+ ```
635
+
636
+ A `tenant:` requires a `family:`, since the tenant segment sits inside the
637
+ family. Scope strings are rejected if they contain Redis glob metacharacters, so
638
+ `scope: "*"` cannot become a full flush by accident.
639
+
640
+ ## Operations and troubleshooting
641
+
642
+ ### The cache seems inert
643
+
644
+ Work down this list.
645
+
646
+ 1. Is `expires:` set and greater than 0? A store with no expiry means the
647
+ middleware was never installed, and the client warns at setup.
648
+ 2. Is this a query? Queries do not cache unless you pass `cache: true` or set
649
+ `Parse.default_query_cache = true`.
650
+ 3. Is the response cacheable? It needs a `GET`, a cacheable status, a non-empty
651
+ body, and a `content-length` between 20 and 1,250,000.
652
+ 4. Is something sending `cache: false`? Health checks and count-only probes do
653
+ so deliberately.
654
+ 5. Is this a `fetch!` or `reload!`? Those default to write-only mode, which by
655
+ design never reads from the cache. Use `fetch_cache!` to accept a cached body.
656
+ 6. Is the caller a different session? Entries are not shared across auth
657
+ identities, so the first request for each session is always a miss.
658
+ 7. Is the store process-local? A Moneta memory store behind several workers hits
659
+ only when the same worker handles the repeat.
660
+ 8. Has something set `Parse::Middleware::Caching.enabled = false`? That is the
661
+ process-wide off switch.
662
+
663
+ To watch it live, set `Parse::Middleware::Caching.logging = true`, which prints
664
+ a line per hit, or subscribe to the notifications below. Hits also carry an
665
+ `X-Cache-Response` header on the response.
666
+
667
+ ### Instrumentation
668
+
669
+ All events are `ActiveSupport::Notifications`.
670
+
671
+ | Event | Payload |
672
+ |---|---|
673
+ | `parse.cache.hit` | `event`, `namespace`, `cache_tenant`, `method`, `url_path` |
674
+ | `parse.cache.miss` | the same, plus `reason` when the miss was forced |
675
+ | `parse.cache.store` | the same, plus `duration_ms` |
676
+ | `parse.cache.delete` | the same, emitted on an invalidating write |
677
+ | `parse.cache.error` | the same, plus `error` (the exception class name only) |
678
+ | `parse.cache.evict` | `pattern_digest`, `deleted`, `duration_ms` |
679
+ | `parse.synchronize_create.acquired` | `key_digest`, `wait_ms` |
680
+ | `parse.synchronize_create.contended` | `key_digest`, `elapsed_ms` |
681
+ | `parse.synchronize_create.timeout` | `key_digest`, `waited_ms` |
682
+ | `parse.synchronize_create.released` | `key_digest`, `held_ms` |
683
+ | `parse.embeddings.embed` | provider, model, dimensions, and `cached: true` on a cache hit |
684
+
685
+ The payloads are deliberately reduced. Cache keys are never emitted, because
686
+ they contain a hashed session-token prefix that would be a side channel for
687
+ enumerating which user has data at which URL. URLs appear as path only, since
688
+ Parse encodes query JSON into the query string. Errors appear as a class name,
689
+ never a message or backtrace.
690
+
691
+ Subscribers run synchronously on the request thread. A blocking subscriber
692
+ blocks every cached request for as long as it runs, and an exception raised
693
+ inside one surfaces as a request failure. Keep them to counter increments or
694
+ non-blocking sinks.
695
+
696
+ ### Safe and unsafe clearing
697
+
698
+ `Parse::Client#clear_cache!` calls `clear` on the store. What that means depends
699
+ on the store:
700
+
701
+ * With a keyspace configured, it is a scoped SCAN and delete over this client's
702
+ own keys. This is the safe case, and the reason to set `cache_keyspace: true`.
703
+ * With no keyspace but a namespace, it is a scoped SCAN over `<namespace>:*`.
704
+ * With neither, `Parse::Cache::Redis` falls back to `FLUSHDB`, and a plain
705
+ Moneta store clears everything it holds.
706
+
707
+ That fallback is the dangerous one on a shared database. It removes other
708
+ applications' data, and it removes the SDK's own `parse-stack:foc:v1:*`
709
+ create-locks, so any `first_or_create!` holding one at that moment loses its
710
+ mutual exclusion without any error being raised.
711
+
712
+ Scoped eviction uses `UNLINK` where the client supports it, so a large clear does
713
+ not stall the server the way `DEL` would, and it reports what it removed on
714
+ `parse.cache.evict`. `flush_db!` remains available as the explicit, deliberate
715
+ full flush for tooling that owns the whole database.
716
+
717
+ ### The other caches
718
+
719
+ Most of the remaining planes have a targeted reset, and all of them are
720
+ process-local, so a reset applies to the calling process only.
721
+
722
+ ```ruby
723
+ Parse::CLPScope.invalidate!("Post") # one class
724
+ Parse::CLPScope.reset_cache! # all classes
725
+ Parse::CLPScope.cache_stats # size and class names
726
+
727
+ Parse::AtlasSearch.refresh_indexes("Post") # one collection, or nil for all
728
+ Parse::AtlasSearch::IndexManager.cache_ttl = 60
729
+
730
+ Parse::Embeddings::Cache.enable!(max_entries: 2048, ttl: 600)
731
+ Parse::Embeddings::Cache.stats # enabled, hits, misses, size
732
+ Parse::Embeddings::Cache.clear!
733
+
734
+ Parse::Audience.cache_ttl = 600
735
+ Parse::Audience.clear_cache!
736
+
737
+ Parse::VectorSearch::Hybrid.clear_probe_cache
738
+ Parse::Query.reset_known_parse_classes!
739
+ Parse.client.config! # force re-fetch of the app config
740
+ ```
741
+
742
+ Two of these deserve a note. The CLP cache fails closed: when a schema fetch
743
+ fails, the class is recorded as unresolvable for 5 seconds and every non-master
744
+ query against it is refused, rather than being allowed to run with no row
745
+ filtering. And the embedding cache is disabled by default; when you enable it
746
+ with a shared Moneta store, build that store with `value_serializer: nil` for
747
+ the same Marshal reason described earlier. Its keys hash the input text, so
748
+ plaintext never lands in the backing store.