parse-stack-next 5.6.0 → 5.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +219 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +366 -113
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +6 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +6 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +136 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +120 -5
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module Cache
|
|
8
|
+
# Owns the physical layout of every key this SDK writes to a shared cache
|
|
9
|
+
# backend, and the patterns used to delete them again.
|
|
10
|
+
#
|
|
11
|
+
# A single object generates keys *and* the patterns that clear them, so the
|
|
12
|
+
# two can never disagree. Previously the caching middleware composed keys
|
|
13
|
+
# from a `cache_namespace:` it held privately while `Parse::Cache::Redis`
|
|
14
|
+
# held a separate `namespace`, and `Parse::Client#clear_cache!` cleared
|
|
15
|
+
# using only the latter. A client namespaced at the middleware but not at
|
|
16
|
+
# the wrapper would therefore delete every SDK key on the database rather
|
|
17
|
+
# than its own.
|
|
18
|
+
#
|
|
19
|
+
# Layout:
|
|
20
|
+
#
|
|
21
|
+
# parse-stack:<version>:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>
|
|
22
|
+
#
|
|
23
|
+
# Every clear is a strict prefix of this, so narrowing the scope can only
|
|
24
|
+
# ever delete a subset:
|
|
25
|
+
#
|
|
26
|
+
# all of this client parse-stack:v1:<app_scope>[:<ns>]:*
|
|
27
|
+
# one family parse-stack:v1:<app_scope>[:<ns>]:<family>:*
|
|
28
|
+
# one tenant parse-stack:v1:<app_scope>[:<ns>]:<family>:T:<tenant>:*
|
|
29
|
+
#
|
|
30
|
+
# `app_scope` is a digest of the Parse application id and server URL rather
|
|
31
|
+
# than the raw values. Two apps sharing one Redis with no namespace
|
|
32
|
+
# configured would otherwise collide, and a raw application id could carry
|
|
33
|
+
# glob metacharacters (`*`, `[`) that would silently widen a SCAN pattern.
|
|
34
|
+
# The digest is fixed-length and glob-safe by construction.
|
|
35
|
+
#
|
|
36
|
+
# Create-locks are deliberately NOT part of this layout. They keep the
|
|
37
|
+
# historical `parse-stack:foc:v1:` prefix from {Parse::Model::CreateLock}.
|
|
38
|
+
# Moving them would mean that during a rolling deploy two workers compute
|
|
39
|
+
# different lock keys, stop contending on the same key, and silently lose
|
|
40
|
+
# mutual exclusion for the length of the deploy.
|
|
41
|
+
class Keyspace
|
|
42
|
+
# Root segment for every key this SDK owns.
|
|
43
|
+
ROOT = "parse-stack"
|
|
44
|
+
|
|
45
|
+
# Layout version. Bump only for a breaking key-shape change, which
|
|
46
|
+
# orphans every existing entry and therefore needs a migration note.
|
|
47
|
+
VERSION = "v1"
|
|
48
|
+
|
|
49
|
+
# Occupies the namespace position when no namespace is configured. Chosen
|
|
50
|
+
# because {#normalize_segment} rejects it as caller input, so a caller can
|
|
51
|
+
# never collide with the unnamespaced keyspace by naming their namespace
|
|
52
|
+
# after the sentinel.
|
|
53
|
+
NO_NAMESPACE = "_"
|
|
54
|
+
|
|
55
|
+
# Key families. `cache` is the Faraday response cache, `idn` is
|
|
56
|
+
# session-token identity, `role` is role closures.
|
|
57
|
+
FAMILIES = %i[cache idn role].freeze
|
|
58
|
+
|
|
59
|
+
# Length of the truncated app/server digest. 12 hex characters is 48
|
|
60
|
+
# bits, which is ample for distinguishing apps on one database while
|
|
61
|
+
# keeping keys readable.
|
|
62
|
+
SCOPE_DIGEST_LENGTH = 12
|
|
63
|
+
|
|
64
|
+
# Characters that carry meaning in a Redis glob pattern. A namespace or
|
|
65
|
+
# tenant containing one of these could widen a SCAN pattern beyond its
|
|
66
|
+
# intended scope, so they are rejected at construction rather than
|
|
67
|
+
# escaped, since a caller passing one is a configuration bug.
|
|
68
|
+
# `:` is included deliberately alongside the glob metacharacters. It is the
|
|
69
|
+
# segment separator, so a namespace of `foo:bar` would forge an extra
|
|
70
|
+
# segment and make the pattern for `foo` also match `foo:bar`, which is
|
|
71
|
+
# the same subset violation the sentinel above prevents.
|
|
72
|
+
GLOB_METACHARS = /[\*\?\[\]\\\x00:]/.freeze
|
|
73
|
+
|
|
74
|
+
# @return [String] digest identifying the Parse app and server.
|
|
75
|
+
attr_reader :app_scope
|
|
76
|
+
|
|
77
|
+
# @return [String, nil] the raw Parse application id. Retained alongside
|
|
78
|
+
# the digest because Parse Server's own cache keys use it verbatim
|
|
79
|
+
# (`<appId>:role:<userId>`), so an attached read needs the original
|
|
80
|
+
# even though our own layout uses the digest.
|
|
81
|
+
attr_reader :app_id
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] validated namespace, or nil when unset.
|
|
84
|
+
attr_reader :namespace
|
|
85
|
+
|
|
86
|
+
# @return [String] layout version segment.
|
|
87
|
+
attr_reader :version
|
|
88
|
+
|
|
89
|
+
# @param app_id [String, nil] the Parse application id.
|
|
90
|
+
# @param server_url [String, nil] the Parse server URL.
|
|
91
|
+
# @param namespace [String, nil] optional operator-supplied namespace.
|
|
92
|
+
# @param version [String] layout version, for tests and migrations.
|
|
93
|
+
# @raise [ArgumentError] if the namespace is unusable as a key segment.
|
|
94
|
+
def initialize(app_id: nil, server_url: nil, namespace: nil, version: VERSION)
|
|
95
|
+
@app_id = app_id&.to_s
|
|
96
|
+
@app_scope = self.class.digest_scope(app_id, server_url)
|
|
97
|
+
@namespace = normalize_segment(namespace, "namespace")
|
|
98
|
+
@version = version.to_s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Digest of the app id and server URL. Public so callers can compare two
|
|
102
|
+
# keyspaces for equivalence without reaching into internals.
|
|
103
|
+
#
|
|
104
|
+
# @return [String] fixed-length, glob-safe hex digest.
|
|
105
|
+
def self.digest_scope(app_id, server_url)
|
|
106
|
+
material = "#{app_id}\x00#{server_url}"
|
|
107
|
+
Digest::SHA256.hexdigest(material)[0, SCOPE_DIGEST_LENGTH]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Prefix shared by every key this keyspace owns, across all families.
|
|
111
|
+
# @return [String]
|
|
112
|
+
def root_prefix
|
|
113
|
+
# The namespace segment is ALWAYS emitted, using NO_NAMESPACE when unset.
|
|
114
|
+
# Without it an unnamespaced root is a strict prefix of every namespaced
|
|
115
|
+
# root for the same app, so `<root>:*` would also delete every named
|
|
116
|
+
# namespace's keys. Narrowing must only ever delete a subset, and a
|
|
117
|
+
# sentinel is the only way to keep the segment count fixed.
|
|
118
|
+
[ROOT, @version, @app_scope, @namespace || NO_NAMESPACE].join(":")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Prefix for one family.
|
|
122
|
+
# @param family [Symbol, String]
|
|
123
|
+
# @return [String]
|
|
124
|
+
# @raise [ArgumentError] on an unknown family.
|
|
125
|
+
def family_prefix(family)
|
|
126
|
+
"#{root_prefix}:#{assert_family!(family)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build a key for the `idn` or `role` families.
|
|
130
|
+
#
|
|
131
|
+
# Neither takes an auth discriminator: a role key is keyed by user id and
|
|
132
|
+
# an identity key by session token, so the key already *is* the auth
|
|
133
|
+
# identity. Only the response cache has one URL yielding different bodies
|
|
134
|
+
# to different callers, and it uses {#cache_key}.
|
|
135
|
+
#
|
|
136
|
+
# @param family [Symbol, String] `:idn` or `:role`.
|
|
137
|
+
# @param segments [Array<String>] trailing key segments, joined with `:`.
|
|
138
|
+
# Not validated for glob characters: they are only ever written and read
|
|
139
|
+
# as literal keys, never used as a SCAN pattern.
|
|
140
|
+
# @param tenant [String, nil] ambient cache tenant, if any.
|
|
141
|
+
# @return [String]
|
|
142
|
+
# @raise [ArgumentError] if called for the `cache` family.
|
|
143
|
+
def key(family, *segments, tenant: nil)
|
|
144
|
+
if family.to_sym == :cache
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"Parse::Cache::Keyspace: use #cache_key for the cache family, " \
|
|
147
|
+
"so the auth discriminator cannot be omitted"
|
|
148
|
+
end
|
|
149
|
+
parts = [family_prefix(family)]
|
|
150
|
+
parts << "T:#{normalize_segment(tenant, "tenant")}" unless tenant.nil?
|
|
151
|
+
parts.concat(segments.map(&:to_s))
|
|
152
|
+
parts.join(":")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Build a response-cache key.
|
|
156
|
+
#
|
|
157
|
+
# `auth:` is mandatory and has no default. A master-key request bypasses
|
|
158
|
+
# ACL, CLP and `protectedFields`, so the same URL returns a strictly
|
|
159
|
+
# fuller body than a session-token request, and two different sessions can
|
|
160
|
+
# differ from each other through `protectedFields` entity rules and row
|
|
161
|
+
# ACLs. Collapsing those into one key would serve privileged fields to an
|
|
162
|
+
# unprivileged caller out of the cache, so the key cannot be built without
|
|
163
|
+
# stating which auth produced the body.
|
|
164
|
+
#
|
|
165
|
+
# The URL is digested and placed *before* the discriminator so that every
|
|
166
|
+
# auth variant of one resource shares a prefix, which is what makes
|
|
167
|
+
# {#resource_pattern} able to invalidate a write for all callers rather
|
|
168
|
+
# than only the three variants the old `delete_cache_variants` could name.
|
|
169
|
+
#
|
|
170
|
+
# @param url [String] the request URL.
|
|
171
|
+
# @param auth [Symbol, String] `:anon`, `:master`, or a session-token digest.
|
|
172
|
+
# @param tenant [String, nil] ambient cache tenant, if any.
|
|
173
|
+
# @return [String]
|
|
174
|
+
def cache_key(url, auth:, tenant: nil)
|
|
175
|
+
"#{resource_prefix(url, tenant: tenant)}:#{normalize_auth(auth)}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Pattern matching every auth variant of one resource. Used to invalidate
|
|
179
|
+
# a resource on write for all callers, including sessions this process
|
|
180
|
+
# has never seen.
|
|
181
|
+
#
|
|
182
|
+
# @param url [String] the request URL.
|
|
183
|
+
# @param tenant [String, nil] ambient cache tenant, if any.
|
|
184
|
+
# @return [String]
|
|
185
|
+
def resource_pattern(url, tenant: nil)
|
|
186
|
+
"#{resource_prefix(url, tenant: tenant)}:*"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Glob pattern selecting keys to clear. With no arguments it selects
|
|
190
|
+
# every key this keyspace owns and nothing else.
|
|
191
|
+
#
|
|
192
|
+
# @param family [Symbol, String, nil] narrow to one family.
|
|
193
|
+
# @param tenant [String, nil] narrow to one tenant. Requires `family`,
|
|
194
|
+
# since tenant is positioned inside the family segment.
|
|
195
|
+
# @return [String]
|
|
196
|
+
# @raise [ArgumentError] if a tenant is given without a family.
|
|
197
|
+
def pattern(family: nil, tenant: nil)
|
|
198
|
+
if tenant && family.nil?
|
|
199
|
+
raise ArgumentError,
|
|
200
|
+
"Parse::Cache::Keyspace#pattern requires a family: when a tenant: is given"
|
|
201
|
+
end
|
|
202
|
+
return "#{root_prefix}:*" if family.nil?
|
|
203
|
+
|
|
204
|
+
prefix = family_prefix(family)
|
|
205
|
+
return "#{prefix}:*" if tenant.nil?
|
|
206
|
+
"#{prefix}:T:#{normalize_segment(tenant, "tenant")}:*"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Whether two keyspaces address the same key space. Used to detect a
|
|
210
|
+
# client reconfigured with a different namespace or app.
|
|
211
|
+
# @return [Boolean]
|
|
212
|
+
def ==(other)
|
|
213
|
+
other.is_a?(Keyspace) && other.root_prefix == root_prefix
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
alias eql? ==
|
|
217
|
+
|
|
218
|
+
def hash
|
|
219
|
+
root_prefix.hash
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def to_s
|
|
223
|
+
root_prefix
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def inspect
|
|
227
|
+
"#<Parse::Cache::Keyspace #{root_prefix}>"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Auth discriminator for an anonymous (unauthenticated) request.
|
|
231
|
+
AUTH_ANON = "anon"
|
|
232
|
+
|
|
233
|
+
# Auth discriminator for a master-key request.
|
|
234
|
+
AUTH_MASTER = "mk"
|
|
235
|
+
|
|
236
|
+
# A session-token discriminator must look like the truncated SHA-256 the
|
|
237
|
+
# caching middleware produces. Anything else is a caller bug, and a raw
|
|
238
|
+
# token must never reach a key.
|
|
239
|
+
TOKEN_DIGEST_RE = /\A[0-9a-f]{16,64}\z/.freeze
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
# Prefix shared by every auth variant of one resource.
|
|
244
|
+
def resource_prefix(url, tenant: nil)
|
|
245
|
+
parts = [family_prefix(:cache)]
|
|
246
|
+
parts << "T:#{normalize_segment(tenant, "tenant")}" unless tenant.nil?
|
|
247
|
+
parts << Digest::SHA256.hexdigest(url.to_s)
|
|
248
|
+
parts.join(":")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def normalize_auth(auth)
|
|
252
|
+
case auth
|
|
253
|
+
when :anon, "anon" then AUTH_ANON
|
|
254
|
+
when :master, "master", "mk" then AUTH_MASTER
|
|
255
|
+
when String
|
|
256
|
+
unless auth.match?(TOKEN_DIGEST_RE)
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"Parse::Cache::Keyspace auth: must be :anon, :master, or a hex " \
|
|
259
|
+
"session-token digest; a raw session token must never be used as " \
|
|
260
|
+
"a key segment"
|
|
261
|
+
end
|
|
262
|
+
auth
|
|
263
|
+
else
|
|
264
|
+
raise ArgumentError,
|
|
265
|
+
"Parse::Cache::Keyspace auth: must be :anon, :master, or a hex " \
|
|
266
|
+
"session-token digest; got #{auth.class}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def assert_family!(family)
|
|
271
|
+
sym = family.to_sym
|
|
272
|
+
unless FAMILIES.include?(sym)
|
|
273
|
+
raise ArgumentError,
|
|
274
|
+
"Parse::Cache::Keyspace family must be one of #{FAMILIES.join(", ")}; got #{family.inspect}"
|
|
275
|
+
end
|
|
276
|
+
sym.to_s
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Normalize an operator-supplied key segment. Returns nil for an
|
|
280
|
+
# absent/empty value so callers can treat "unset" uniformly.
|
|
281
|
+
def normalize_segment(value, label)
|
|
282
|
+
return nil if value.nil?
|
|
283
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
284
|
+
raise ArgumentError,
|
|
285
|
+
"Parse::Cache::Keyspace #{label} must be a String or Symbol; got #{value.class}"
|
|
286
|
+
end
|
|
287
|
+
segment = value.to_s.chomp(":")
|
|
288
|
+
return nil if segment.empty?
|
|
289
|
+
if segment == NO_NAMESPACE
|
|
290
|
+
raise ArgumentError,
|
|
291
|
+
"Parse::Cache::Keyspace #{label} must not be #{NO_NAMESPACE.inspect}; " \
|
|
292
|
+
"that value is reserved for the unnamespaced keyspace"
|
|
293
|
+
end
|
|
294
|
+
if segment.match?(GLOB_METACHARS)
|
|
295
|
+
raise ArgumentError,
|
|
296
|
+
"Parse::Cache::Keyspace #{label} must not contain \":\", Redis glob " \
|
|
297
|
+
"characters (*, ?, [, ], \\), or NUL; got #{value.inspect}. \":\" is the " \
|
|
298
|
+
"segment separator, so allowing it would let one value forge extra key " \
|
|
299
|
+
"segments and escape its own part of the keyspace. A single trailing " \
|
|
300
|
+
"\":\" is stripped for convenience, so \"web\" and \"web:\" are equivalent."
|
|
301
|
+
end
|
|
302
|
+
segment
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Cache
|
|
6
|
+
# The derived half of Moneta's store interface.
|
|
7
|
+
#
|
|
8
|
+
# Moneta gets these from `Moneta::Defaults`, which the SDK's own store
|
|
9
|
+
# wrappers do not include. That gap was invisible for a long time because
|
|
10
|
+
# the Faraday caching middleware only ever calls the primitives. It is not
|
|
11
|
+
# invisible to applications: the README has documented
|
|
12
|
+
# `Parse.cache["key"] = value` and `Parse.cache.fetch(...)` for years, and
|
|
13
|
+
# neither existed on {Parse::Cache::Redis}. Anyone following the
|
|
14
|
+
# documentation got `NoMethodError`.
|
|
15
|
+
#
|
|
16
|
+
# **Required primitives.** An including class must implement `load`,
|
|
17
|
+
# `store`, `delete`, and `key?`, each taking Moneta's options argument.
|
|
18
|
+
# `load` is the read primitive, NOT `[]`: an earlier version of this
|
|
19
|
+
# module derived `load` from `[]` and consulted a `moneta_backing_store`
|
|
20
|
+
# that defaulted to `self`, so `load(key, expires: 60)` asked whether self
|
|
21
|
+
# responded to the method it was already executing and recursed until
|
|
22
|
+
# `SystemStackError`. Every option-carrying read went the same way, since
|
|
23
|
+
# `fetch`, `values_at`, `slice`, and `fetch_values` all route through it.
|
|
24
|
+
# A wrapper knows how to reach its own backing store; this module does
|
|
25
|
+
# not, and should not guess.
|
|
26
|
+
#
|
|
27
|
+
# Optional capabilities that cannot be derived (`create`, `increment`,
|
|
28
|
+
# `decrement`, `expire`) are deliberately absent: they need backend
|
|
29
|
+
# support, and claiming them unconditionally would turn a feature check
|
|
30
|
+
# into a runtime error. Feature-detect those with `respond_to?`.
|
|
31
|
+
module MonetaSurface
|
|
32
|
+
# @param key [String]
|
|
33
|
+
# @return [Object, nil]
|
|
34
|
+
def [](key)
|
|
35
|
+
load(key, {})
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param key [String]
|
|
39
|
+
# @param value [Object]
|
|
40
|
+
# @return [Object] the stored value, so assignment chains as Ruby
|
|
41
|
+
# expects.
|
|
42
|
+
def []=(key, value)
|
|
43
|
+
store(key, value, {})
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Copied from `Moneta::Defaults#fetch`, deliberately line for line.
|
|
48
|
+
#
|
|
49
|
+
# Its two shapes read the second positional differently: without a block
|
|
50
|
+
# it is the default value, with one it is the OPTIONS hash and the block
|
|
51
|
+
# supplies the fallback. Passing both a block and a third argument is an
|
|
52
|
+
# error, not something to silently absorb.
|
|
53
|
+
#
|
|
54
|
+
# This has now been wrong twice from interpretation: first treating the
|
|
55
|
+
# second argument as a default in both shapes, which dropped the
|
|
56
|
+
# options, then accepting a block alongside a third argument and
|
|
57
|
+
# ignoring one of the two hashes. Reproducing the upstream method is
|
|
58
|
+
# cheaper than continuing to infer it.
|
|
59
|
+
#
|
|
60
|
+
# @param key [String]
|
|
61
|
+
# @param default [Object] the fallback without a block, the options with
|
|
62
|
+
# one.
|
|
63
|
+
# @param options [Hash, nil]
|
|
64
|
+
# @raise [ArgumentError] when given both a block and `options`.
|
|
65
|
+
# @return [Object]
|
|
66
|
+
def fetch(key, default = nil, options = nil)
|
|
67
|
+
if block_given?
|
|
68
|
+
raise ArgumentError, "Only one argument accepted if block is given" if options
|
|
69
|
+
result = load(key, default || {})
|
|
70
|
+
result == nil ? yield(key) : result
|
|
71
|
+
else
|
|
72
|
+
result = load(key, options || {})
|
|
73
|
+
result == nil ? default : result
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param keys [Array<String>]
|
|
78
|
+
# @return [Array<Object, nil>] values in the order requested, nil for
|
|
79
|
+
# misses.
|
|
80
|
+
def values_at(*keys, **options)
|
|
81
|
+
keys.map { |key| load(key, options) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param keys [Array<String>]
|
|
85
|
+
# @return [Array<Object>] values in the order requested, with the block
|
|
86
|
+
# result substituted for misses.
|
|
87
|
+
def fetch_values(*keys, **options)
|
|
88
|
+
values = values_at(*keys, **options)
|
|
89
|
+
return values unless block_given?
|
|
90
|
+
keys.zip(values).map do |key, value|
|
|
91
|
+
value == nil ? yield(key) : value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param keys [Array<String>]
|
|
96
|
+
# @return [Array<Array(String, Object)>] `[key, value]` pairs for the
|
|
97
|
+
# present keys only. Pairs rather than a Hash because that is what
|
|
98
|
+
# Moneta returns, and the point of this module is to behave the way a
|
|
99
|
+
# Moneta store does, not the way a Hash does.
|
|
100
|
+
def slice(*keys, **options)
|
|
101
|
+
keys.each_with_object([]) do |key, out|
|
|
102
|
+
value = load(key, options)
|
|
103
|
+
out << [key, value] unless value.nil?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param pairs [Hash, Enumerable] key/value pairs to write.
|
|
108
|
+
# @return [self] matching Moneta, which returns the store.
|
|
109
|
+
def merge!(pairs, options = {})
|
|
110
|
+
pairs.each do |key, value|
|
|
111
|
+
if block_given?
|
|
112
|
+
# The existing value is read with the SAME options the write will
|
|
113
|
+
# use, so a conflict block never decides against a value fetched
|
|
114
|
+
# under different terms than the one being stored.
|
|
115
|
+
existing = load(key, options)
|
|
116
|
+
value = yield(key, existing, value) unless existing.nil?
|
|
117
|
+
end
|
|
118
|
+
store(key, value, options)
|
|
119
|
+
end
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
alias_method :update, :merge!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/parse/cache/pool.rb
CHANGED
|
@@ -36,15 +36,37 @@ module Parse
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def [](key)
|
|
39
|
-
|
|
39
|
+
load(key, {})
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Moneta's read primitive, carrying its options argument (`expires:` to
|
|
43
|
+
# refresh a TTL on read, for instance).
|
|
44
|
+
#
|
|
45
|
+
# Feature-detected rather than called blind. Every real Moneta store
|
|
46
|
+
# implements `load`, but a custom store that only implements `[]` would
|
|
47
|
+
# otherwise reach `Kernel#load`, which is private (so the failure is a
|
|
48
|
+
# confusing NoMethodError) and, were it public, would try to load a
|
|
49
|
+
# FILE named after the cache key. `respond_to?` answers false for the
|
|
50
|
+
# private Kernel method, so this check is exact.
|
|
51
|
+
def load(key, options = {})
|
|
52
|
+
@pool.with do |store|
|
|
53
|
+
next store.load(key, options || {}) if store.respond_to?(:load)
|
|
54
|
+
store[key]
|
|
55
|
+
end
|
|
44
56
|
end
|
|
45
57
|
|
|
46
|
-
def
|
|
47
|
-
@pool.with
|
|
58
|
+
def key?(key, options = {})
|
|
59
|
+
@pool.with do |store|
|
|
60
|
+
next store.key?(key, options || {}) if options_aware?(store, :key?)
|
|
61
|
+
store.key?(key)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delete(key, options = {})
|
|
66
|
+
@pool.with do |store|
|
|
67
|
+
next store.delete(key, options || {}) if options_aware?(store, :delete)
|
|
68
|
+
store.delete(key)
|
|
69
|
+
end
|
|
48
70
|
end
|
|
49
71
|
|
|
50
72
|
def store(key, value, options = {})
|
|
@@ -78,6 +100,27 @@ module Parse
|
|
|
78
100
|
# calls are no-ops. `ConnectionPool#shutdown` raises
|
|
79
101
|
# `ConnectionPool::PoolShuttingDownError` on a second invocation,
|
|
80
102
|
# so we gate it with a `@closed` flag.
|
|
103
|
+
# Whether `store` takes Moneta's options argument for `name`.
|
|
104
|
+
#
|
|
105
|
+
# Decided from arity and memoized per method, not by calling with
|
|
106
|
+
# options and rescuing ArgumentError. That retry had three problems: a
|
|
107
|
+
# store's own argument validation also raises ArgumentError, so a
|
|
108
|
+
# genuine rejection was retried instead of surfaced; for `delete` the
|
|
109
|
+
# retry meant the deletion could run TWICE; and because each attempt
|
|
110
|
+
# took its own checkout, the second ran against a DIFFERENT connection
|
|
111
|
+
# than the first. Every pooled store is built by the same block, so one
|
|
112
|
+
# probe answers for all of them.
|
|
113
|
+
def options_aware?(store, name)
|
|
114
|
+
@options_aware ||= {}
|
|
115
|
+
return @options_aware[name] if @options_aware.key?(name)
|
|
116
|
+
arity = begin
|
|
117
|
+
store.method(name).arity
|
|
118
|
+
rescue NameError
|
|
119
|
+
0
|
|
120
|
+
end
|
|
121
|
+
@options_aware[name] = arity.negative? || arity >= 2
|
|
122
|
+
end
|
|
123
|
+
|
|
81
124
|
def close
|
|
82
125
|
return if @closed
|
|
83
126
|
@closed = true
|