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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "keyspace"
|
|
5
|
+
require_relative "moneta_surface"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
module Cache
|
|
9
|
+
# An immutable, per-client view over one shared {Parse::Cache::Redis}
|
|
10
|
+
# backend.
|
|
11
|
+
#
|
|
12
|
+
# {Parse::Cache::Redis} is a connection pool: sharing ONE backend across
|
|
13
|
+
# several {Parse::Client} instances (one Redis, several Parse apps) is a
|
|
14
|
+
# normal and supported deployment. What is not supported is sharing
|
|
15
|
+
# *ownership* of a single keyspace binding, which is what the old
|
|
16
|
+
# `Parse::Cache::Redis#keyspace=` setter allowed: client B calling
|
|
17
|
+
# `keyspace = ks_b` on a backend client A already configured rebinds the
|
|
18
|
+
# one `@keyspace` ivar out from under A. A's caching middleware had
|
|
19
|
+
# already captured A's keyspace object and keeps writing/reading under
|
|
20
|
+
# it, while `clear`, `identity`, `roles`, and the memoized
|
|
21
|
+
# `upstream_roles` on the now-shared backend answer with B's keyspace
|
|
22
|
+
# instead. A stops invalidating its own entries, or worse, a scoped
|
|
23
|
+
# `clear` issued through A now deletes B's keys.
|
|
24
|
+
#
|
|
25
|
+
# A `ScopedView` closes that hole by never mutating the backend at all.
|
|
26
|
+
# `Parse::Cache::Redis#scoped(keyspace)` hands back one of these per
|
|
27
|
+
# caller, each carrying its own keyspace and its own memoized
|
|
28
|
+
# `identity` / `roles` / `upstream_roles` planes, all backed by the same
|
|
29
|
+
# underlying connection pool:
|
|
30
|
+
#
|
|
31
|
+
# backend = Parse::Cache::Redis.new(url: redis_url)
|
|
32
|
+
# view_a = backend.scoped(keyspace_a)
|
|
33
|
+
# view_b = backend.scoped(keyspace_b)
|
|
34
|
+
#
|
|
35
|
+
# `view_a` and `view_b` share `backend`'s pooled Redis connections but
|
|
36
|
+
# can never see or clear each other's keys.
|
|
37
|
+
#
|
|
38
|
+
# **Moneta surface.** Implements `[]`, `key?`, `delete`, `store` (and
|
|
39
|
+
# `create` / `increment` when the backend supports them) by delegating
|
|
40
|
+
# straight to the backend, so a view is a drop-in replacement anywhere a
|
|
41
|
+
# bare `Parse::Cache::Redis` was accepted: most importantly, the Faraday
|
|
42
|
+
# caching middleware.
|
|
43
|
+
#
|
|
44
|
+
# **Locks are NOT scoped.** `lock_acquire` / `lock_release` delegate to
|
|
45
|
+
# the backend unchanged, still using the historical
|
|
46
|
+
# `parse-stack:foc:v1:` prefix from {Parse::CreateLock}. During a
|
|
47
|
+
# rolling deploy two workers must compute the SAME lock key regardless of
|
|
48
|
+
# which client/keyspace they were configured with, or `first_or_create!`
|
|
49
|
+
# silently loses cross-process mutual exclusion for the length of the
|
|
50
|
+
# deploy.
|
|
51
|
+
#
|
|
52
|
+
# **No `flush_db!`.** A whole-database flush is a connection-level
|
|
53
|
+
# operation, not something a scoped view over one client's slice of the
|
|
54
|
+
# keyspace should be able to trigger.
|
|
55
|
+
class ScopedView
|
|
56
|
+
include Parse::Cache::MonetaSurface
|
|
57
|
+
|
|
58
|
+
# Safe defaults for callers that create a plane implicitly, most notably
|
|
59
|
+
# the webhook invalidation handlers. Keep these aligned with
|
|
60
|
+
# Parse::Authorization::Context's defaults: the first accessor call
|
|
61
|
+
# memoizes the plane and therefore fixes its generation lifetime.
|
|
62
|
+
DEFAULT_IDENTITY_TTL = 3600
|
|
63
|
+
DEFAULT_ROLE_TTL = 30
|
|
64
|
+
|
|
65
|
+
# @return [Parse::Cache::Keyspace] this view's key layout. Fixed at
|
|
66
|
+
# construction; there is no setter, so a view can never be rebound to
|
|
67
|
+
# a different keyspace after the fact.
|
|
68
|
+
attr_reader :keyspace
|
|
69
|
+
|
|
70
|
+
# @return [Parse::Cache::Redis] the shared backend this view reads and
|
|
71
|
+
# writes through. Exposed for introspection (e.g. tests asserting two
|
|
72
|
+
# views share one connection pool). The backend itself has no
|
|
73
|
+
# keyspace concept at all anymore ({#scoped} is the only way to
|
|
74
|
+
# associate a keyspace with it), so there is nothing on the backend
|
|
75
|
+
# left to rebind out from under this or any other view.
|
|
76
|
+
attr_reader :backend
|
|
77
|
+
|
|
78
|
+
# @param backend [Parse::Cache::Redis] the shared connection pool.
|
|
79
|
+
# @param keyspace [Parse::Cache::Keyspace] this view's key layout.
|
|
80
|
+
# @raise [ArgumentError] if `keyspace` is not a Parse::Cache::Keyspace.
|
|
81
|
+
def initialize(backend:, keyspace:)
|
|
82
|
+
unless keyspace.is_a?(Parse::Cache::Keyspace)
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
"Parse::Cache::ScopedView keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
|
|
85
|
+
end
|
|
86
|
+
@backend = backend
|
|
87
|
+
# The keyspace itself has no setters to begin with, but freezing it
|
|
88
|
+
# here is a cheap extra guarantee that nothing downstream (this view
|
|
89
|
+
# included) can mutate the layout this view was constructed with.
|
|
90
|
+
@keyspace = keyspace.freeze
|
|
91
|
+
|
|
92
|
+
# `create` and `increment` are only defined when the backend itself
|
|
93
|
+
# supports them, so `respond_to?(:create)` on a view accurately
|
|
94
|
+
# reflects what the backend can actually do rather than always
|
|
95
|
+
# claiming support and raising NoMethodError on first use.
|
|
96
|
+
if @backend.respond_to?(:create)
|
|
97
|
+
define_singleton_method(:create) do |key, value, options = {}|
|
|
98
|
+
@backend.create(key, value, options)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
if @backend.respond_to?(:increment)
|
|
102
|
+
define_singleton_method(:increment) do |key, amount = 1, options = {}|
|
|
103
|
+
@backend.increment(key, amount, options)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
# Value-preserving TTL. SubCache feature-detects this to avoid the
|
|
107
|
+
# re-store that would lose a concurrent generation increment.
|
|
108
|
+
if @backend.respond_to?(:expire)
|
|
109
|
+
define_singleton_method(:expire) do |key, ttl|
|
|
110
|
+
@backend.expire(key, ttl)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# --- Moneta response-cache interface ---------------------------------
|
|
116
|
+
# Delegate straight to the shared backend. These four methods are all
|
|
117
|
+
# the Faraday caching middleware requires, so a view is a drop-in
|
|
118
|
+
# replacement for a bare Parse::Cache::Redis.
|
|
119
|
+
|
|
120
|
+
# `load` is the read primitive, delegated to the backend so the options
|
|
121
|
+
# argument reaches something that can honor it. Deriving it from `[]`
|
|
122
|
+
# here is what produced infinite recursion in an earlier version.
|
|
123
|
+
def load(key, options = {})
|
|
124
|
+
@backend.load(key, options || {})
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def [](key)
|
|
128
|
+
load(key, {})
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def key?(key, options = {})
|
|
132
|
+
@backend.key?(key, options || {})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def delete(key, options = {})
|
|
136
|
+
@backend.delete(key, options || {})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def store(key, value, options = {})
|
|
140
|
+
@backend.store(key, value, options || {})
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- scoped eviction --------------------------------------------------
|
|
144
|
+
|
|
145
|
+
# Clear cached entries belonging to THIS view's keyspace, and nothing
|
|
146
|
+
# else: never anything from another view over the same backend.
|
|
147
|
+
#
|
|
148
|
+
# @param scope [String, nil] explicit namespace prefix to scan-delete,
|
|
149
|
+
# narrowed inside this view's root_prefix. See
|
|
150
|
+
# {Parse::Cache::Redis#clear} for the exact semantics; the difference
|
|
151
|
+
# here is that there is no unscoped/FLUSHDB fallback branch to fall
|
|
152
|
+
# into, because a view always has a keyspace.
|
|
153
|
+
# @param family [Symbol, String, nil] narrow to one family.
|
|
154
|
+
# @param tenant [String, nil] narrow to one tenant (requires `family`).
|
|
155
|
+
# @return [self]
|
|
156
|
+
def clear(scope: nil, family: nil, tenant: nil)
|
|
157
|
+
if scope
|
|
158
|
+
prefix = @backend.send(:validate_scope!, scope)
|
|
159
|
+
raw_delete_matching!("#{@keyspace.root_prefix}:#{prefix}:*")
|
|
160
|
+
else
|
|
161
|
+
raw_delete_matching!(@keyspace.pattern(family: family, tenant: tenant))
|
|
162
|
+
end
|
|
163
|
+
self
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Delete every key matching a glob pattern, refusing anything outside
|
|
167
|
+
# this view's own keyspace.
|
|
168
|
+
#
|
|
169
|
+
# The check requires the segment boundary (`root_prefix` followed by
|
|
170
|
+
# `:`), not a bare string prefix: every pattern this keyspace actually
|
|
171
|
+
# generates ({Parse::Cache::Keyspace#pattern},
|
|
172
|
+
# {Parse::Cache::Keyspace#resource_pattern}) is `"<root_prefix>:..."`,
|
|
173
|
+
# so this rejects nothing legitimate. A bare `start_with?(root_prefix)`
|
|
174
|
+
# would accept a pattern belonging to a DIFFERENT namespace that merely
|
|
175
|
+
# shares a prefix: a view whose namespace is `"foo"` would happily
|
|
176
|
+
# delete_matching a pattern for namespace `"foobar"`, since the string
|
|
177
|
+
# `"...:foobar:..."` starts with `"...:foo"`.
|
|
178
|
+
#
|
|
179
|
+
# @param pattern [String] a Redis glob pattern.
|
|
180
|
+
# @return [Integer] number of keys removed.
|
|
181
|
+
def delete_matching(pattern)
|
|
182
|
+
return 0 if pattern.nil? || pattern.to_s.empty?
|
|
183
|
+
return 0 unless pattern.to_s.start_with?("#{@keyspace.root_prefix}:")
|
|
184
|
+
raw_delete_matching!(pattern)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- identity / role / upstream planes, one instance per view --------
|
|
188
|
+
# Never shared with the backend's own (legacy, unscoped) `identity` /
|
|
189
|
+
# `roles` / `upstream_roles`, and never shared between two views over
|
|
190
|
+
# the same backend.
|
|
191
|
+
|
|
192
|
+
# @param ttl [Integer, nil] default entry TTL; explicitly pass nil only
|
|
193
|
+
# for a deliberately permanent plane.
|
|
194
|
+
# @return [Parse::Cache::SubCache]
|
|
195
|
+
def identity(ttl: DEFAULT_IDENTITY_TTL)
|
|
196
|
+
@identity ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :idn, ttl: ttl)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @param ttl [Integer, nil] default entry TTL; explicitly pass nil only
|
|
200
|
+
# for a deliberately permanent plane.
|
|
201
|
+
# @return [Parse::Cache::SubCache]
|
|
202
|
+
def roles(ttl: DEFAULT_ROLE_TTL)
|
|
203
|
+
@roles ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :role, ttl: ttl)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Read-only consumer of Parse Server's own role cache, scoped to this
|
|
207
|
+
# view's app id and role plane. See {Parse::Cache::Redis#upstream_roles}
|
|
208
|
+
# for the full rationale; the only difference here is that the app id
|
|
209
|
+
# and roles plane come from THIS view's keyspace, never a sibling
|
|
210
|
+
# view's.
|
|
211
|
+
# @return [Parse::Cache::UpstreamRoles, nil]
|
|
212
|
+
def upstream_roles
|
|
213
|
+
return nil if @backend.parse_cache_url.nil?
|
|
214
|
+
@upstream_roles ||= Parse::Cache::UpstreamRoles.new(
|
|
215
|
+
client: @backend.send(:upstream_client),
|
|
216
|
+
app_id: @keyspace.app_id,
|
|
217
|
+
roles_plane: roles,
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- locks: unscoped, delegated straight to the backend ---------------
|
|
222
|
+
|
|
223
|
+
# @see Parse::Cache::Redis#lock_acquire
|
|
224
|
+
def lock_acquire(key, owner, ttl)
|
|
225
|
+
@backend.lock_acquire(key, owner, ttl)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @see Parse::Cache::Redis#lock_release
|
|
229
|
+
def lock_release(key, owner)
|
|
230
|
+
@backend.lock_release(key, owner)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def inspect
|
|
234
|
+
"#<Parse::Cache::ScopedView #{@keyspace.root_prefix}>"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
# Unguarded SCAN+UNLINK against the shared backend. Safe to call here
|
|
240
|
+
# because both public callers above ({#clear}, {#delete_matching})
|
|
241
|
+
# have already confined `pattern` to this view's own root_prefix
|
|
242
|
+
# before reaching this point.
|
|
243
|
+
def raw_delete_matching!(pattern)
|
|
244
|
+
@backend.send(:delete_keys_matching!, pattern)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Raised when a keyspaced client is asked to clear a cache store that
|
|
249
|
+
# cannot restrict the clear to its own keyspace.
|
|
250
|
+
class UnscopedClearRefused < StandardError; end
|
|
251
|
+
|
|
252
|
+
# Keyspace wrapper for a cache store that is not a
|
|
253
|
+
# {Parse::Cache::Redis} and therefore cannot produce a
|
|
254
|
+
# {Parse::Cache::ScopedView}.
|
|
255
|
+
#
|
|
256
|
+
# `cache_keyspace: true` used to leave such a store installed bare. Key
|
|
257
|
+
# composition still worked, because the caching middleware receives the
|
|
258
|
+
# keyspace directly, so the deployment looked correctly keyspaced. But
|
|
259
|
+
# `Parse::Client#clear_cache!` called the store's own `clear`, and on a
|
|
260
|
+
# plain `Moneta.new(:Redis)` that is `FLUSHDB`. Asking for keyspacing and
|
|
261
|
+
# receiving a database-wide flush inverts the entire point of the option:
|
|
262
|
+
# it deletes other applications' entries and, on a shared database, the
|
|
263
|
+
# `parse-stack:foc:v1:*` create-locks, so a `first_or_create!` holding a
|
|
264
|
+
# lock at that moment silently loses mutual exclusion.
|
|
265
|
+
#
|
|
266
|
+
# This wrapper keeps the store usable for reads and writes and makes the
|
|
267
|
+
# clear honest. Where the store can enumerate its keys (Moneta's
|
|
268
|
+
# `each_key` feature), the clear is scan-and-delete confined to the
|
|
269
|
+
# keyspace, matching what a `ScopedView` does. Where it cannot, the clear
|
|
270
|
+
# raises rather than widening: a store that cannot express "delete only
|
|
271
|
+
# my keys" has no safe answer, and the wrong answer is unrecoverable.
|
|
272
|
+
class KeyspacedStore
|
|
273
|
+
include Parse::Cache::MonetaSurface
|
|
274
|
+
|
|
275
|
+
# @return [Parse::Cache::Keyspace]
|
|
276
|
+
attr_reader :keyspace
|
|
277
|
+
|
|
278
|
+
# @return [Object] the wrapped store. Named `wrapped` rather than
|
|
279
|
+
# `store` because `store` is Moneta's writer method, which this class
|
|
280
|
+
# must keep implementing for the Faraday caching middleware.
|
|
281
|
+
attr_reader :wrapped
|
|
282
|
+
|
|
283
|
+
def initialize(store:, keyspace:)
|
|
284
|
+
unless keyspace.is_a?(Parse::Cache::Keyspace)
|
|
285
|
+
raise ArgumentError,
|
|
286
|
+
"Parse::Cache::KeyspacedStore keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
|
|
287
|
+
end
|
|
288
|
+
@wrapped = store
|
|
289
|
+
@keyspace = keyspace.freeze
|
|
290
|
+
|
|
291
|
+
# Mirror the wrapped store's optional capabilities rather than always
|
|
292
|
+
# claiming them, so `respond_to?` stays truthful and callers that
|
|
293
|
+
# feature-detect (the create-lock path, SubCache's atomic increment)
|
|
294
|
+
# get the same answer they would from the store itself.
|
|
295
|
+
# `fetch` and `each_key` are deliberately NOT in this list.
|
|
296
|
+
#
|
|
297
|
+
# `fetch` must come from {Parse::Cache::MonetaSurface}, which routes
|
|
298
|
+
# through this wrapper's own `load`. Delegating it handed the call
|
|
299
|
+
# straight to the wrapped store and skipped the wrapper entirely.
|
|
300
|
+
#
|
|
301
|
+
# `each_key` is defined below with keyspace filtering. Delegating it
|
|
302
|
+
# enumerated the WHOLE store, so `sdk_cache.each_key` returned the
|
|
303
|
+
# application's keys alongside the SDK's, which is precisely the
|
|
304
|
+
# confusion this class exists to prevent.
|
|
305
|
+
%i[create increment expire features lock_acquire lock_release].each do |name|
|
|
306
|
+
next unless wrapped_supports?(name)
|
|
307
|
+
define_singleton_method(name) { |*args, **kw, &blk| @wrapped.public_send(name, *args, **kw, &blk) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Only claim `each_key` when the wrapped store can genuinely enumerate.
|
|
311
|
+
if wrapped_supports?(:each_key)
|
|
312
|
+
define_singleton_method(:each_key) do |&blk|
|
|
313
|
+
return enum_for(:each_key) unless blk
|
|
314
|
+
prefix = "#{@keyspace.root_prefix}:"
|
|
315
|
+
@wrapped.each_key { |k| blk.call(k) if k.to_s.start_with?(prefix) }
|
|
316
|
+
self
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Probe arity ONCE instead of rescuing at call time. See #key?.
|
|
321
|
+
@options_aware = %i[key? delete].to_h { |name| [name, accepts_options?(name)] }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Moneta response-cache interface ---------------------------------
|
|
325
|
+
|
|
326
|
+
# Delegated primitives. `load` falls back to `[]` for a wrapped store
|
|
327
|
+
# that predates Moneta's options argument, so an older custom store
|
|
328
|
+
# still works and simply ignores the hint.
|
|
329
|
+
def load(key, options = {})
|
|
330
|
+
return @wrapped.load(key, options || {}) if @wrapped.respond_to?(:load)
|
|
331
|
+
@wrapped[key]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def [](key) = load(key, {})
|
|
335
|
+
def store(key, value, options = {}) = @wrapped.store(key, value, options || {})
|
|
336
|
+
|
|
337
|
+
# Options are forwarded when the wrapped store can take them, decided
|
|
338
|
+
# by arity at construction.
|
|
339
|
+
#
|
|
340
|
+
# This used to call with options and `rescue ArgumentError` to retry
|
|
341
|
+
# without them. That is wrong twice over: a store's own argument
|
|
342
|
+
# validation also raises ArgumentError, so a genuine rejection was
|
|
343
|
+
# retried rather than surfaced, and for `delete` the retry meant the
|
|
344
|
+
# deletion could run TWICE, once before the raise and once after.
|
|
345
|
+
# Deciding from arity is exact and happens once.
|
|
346
|
+
def key?(key, options = {})
|
|
347
|
+
return @wrapped.key?(key, options || {}) if @options_aware[:key?]
|
|
348
|
+
@wrapped.key?(key)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def delete(key, options = {})
|
|
352
|
+
return @wrapped.delete(key, options || {}) if @options_aware[:delete]
|
|
353
|
+
@wrapped.delete(key)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Delete every entry under this keyspace, and nothing else.
|
|
357
|
+
#
|
|
358
|
+
# @raise [Parse::Cache::UnscopedClearRefused] when the wrapped store
|
|
359
|
+
# cannot enumerate its keys, and therefore cannot clear within a
|
|
360
|
+
# keyspace. Use a {Parse::Cache::Redis} for scoped clearing, or call
|
|
361
|
+
# `client.cache.clear` to take the unscoped clear deliberately.
|
|
362
|
+
# @return [self]
|
|
363
|
+
def clear(scope: nil, family: nil, tenant: nil)
|
|
364
|
+
prefix = if scope
|
|
365
|
+
"#{@keyspace.root_prefix}:#{scope.to_s.sub(/:\z/, "")}:"
|
|
366
|
+
else
|
|
367
|
+
pattern = @keyspace.pattern(family: family, tenant: tenant)
|
|
368
|
+
pattern.sub(/\*\z/, "")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
unless wrapped_supports?(:each_key)
|
|
372
|
+
raise UnscopedClearRefused,
|
|
373
|
+
"#{@wrapped.class} cannot enumerate its keys, so it cannot clear only the entries " \
|
|
374
|
+
"under #{@keyspace.root_prefix}. Refusing rather than falling back to an " \
|
|
375
|
+
"unscoped clear, which on a Redis-backed store is FLUSHDB and would delete " \
|
|
376
|
+
"other applications' entries and any parse-stack:foc:v1:* create-locks. " \
|
|
377
|
+
"Use Parse::Cache::Redis for scoped clearing, or call " \
|
|
378
|
+
"client.cache.clear to take the unscoped clear deliberately."
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Collect before deleting: mutating during enumeration is undefined
|
|
382
|
+
# across Moneta adapters.
|
|
383
|
+
doomed = []
|
|
384
|
+
@wrapped.each_key { |k| doomed << k if k.to_s.start_with?(prefix) }
|
|
385
|
+
doomed.each { |k| @wrapped.delete(k) }
|
|
386
|
+
self
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# @param pattern [String] a glob pattern, refused unless it sits inside
|
|
390
|
+
# this keyspace.
|
|
391
|
+
# @return [Integer] number of keys removed.
|
|
392
|
+
def delete_matching(pattern)
|
|
393
|
+
return 0 if pattern.nil? || pattern.to_s.empty?
|
|
394
|
+
return 0 unless pattern.to_s.start_with?("#{@keyspace.root_prefix}:")
|
|
395
|
+
return 0 unless wrapped_supports?(:each_key)
|
|
396
|
+
|
|
397
|
+
doomed = []
|
|
398
|
+
@wrapped.each_key { |k| doomed << k if File.fnmatch(pattern, k.to_s, File::FNM_NOESCAPE) }
|
|
399
|
+
doomed.each { |k| @wrapped.delete(k) }
|
|
400
|
+
doomed.size
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def inspect
|
|
404
|
+
"#<Parse::Cache::KeyspacedStore #{@keyspace.root_prefix} over #{@wrapped.class}>"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
private
|
|
408
|
+
|
|
409
|
+
# Whether the wrapped store genuinely provides a capability.
|
|
410
|
+
#
|
|
411
|
+
# `respond_to?` is not enough for a Moneta store. Moneta defines every
|
|
412
|
+
# optional method on every store and has the unsupported ones raise
|
|
413
|
+
# `NotImplementedError`, so `Moneta.new(:Null).respond_to?(:each_key)`
|
|
414
|
+
# is true while calling it raises. Advertising it on that basis made
|
|
415
|
+
# this wrapper claim `each_key`, `create`, and `increment` for a store
|
|
416
|
+
# that has none of them, and a scoped `clear` then leaked
|
|
417
|
+
# `NotImplementedError` instead of the `UnscopedClearRefused` this class
|
|
418
|
+
# promises. `supports?` is Moneta's own answer to that question.
|
|
419
|
+
#
|
|
420
|
+
# It only covers Moneta's feature vocabulary, so anything outside it
|
|
421
|
+
# (`expire`, the lock pair) still falls back to `respond_to?`.
|
|
422
|
+
MONETA_FEATURES = %i[create increment each_key].freeze
|
|
423
|
+
private_constant :MONETA_FEATURES
|
|
424
|
+
|
|
425
|
+
def wrapped_supports?(name)
|
|
426
|
+
return false unless @wrapped.respond_to?(name)
|
|
427
|
+
return true unless MONETA_FEATURES.include?(name)
|
|
428
|
+
return true unless @wrapped.respond_to?(:supports?)
|
|
429
|
+
!!@wrapped.supports?(name)
|
|
430
|
+
rescue StandardError
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @return [Boolean] whether the wrapped method takes an options argument.
|
|
435
|
+
def accepts_options?(name)
|
|
436
|
+
arity = @wrapped.method(name).arity
|
|
437
|
+
arity.negative? || arity >= 2
|
|
438
|
+
rescue NameError
|
|
439
|
+
false
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|