parse-stack-next 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +409 -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 +167 -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/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 +97 -15
- 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 +2 -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 +2 -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 +132 -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 +22 -4
- data/parse-stack-next.gemspec +6 -6
- 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.
|