parse-stack-next 5.7.3 → 5.7.5

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.
@@ -19,7 +19,9 @@ module Parse
19
19
  # * {Cohere} — embed-{english,multilingual}-v3.0 and `*-light-v3.0`.
20
20
  # Distinguishes `:search_query` / `:search_document` at the wire.
21
21
  # * {Voyage} — voyage-4 family (incl. open-weight `voyage-4-nano`),
22
- # voyage-3 family, voyage-code-3, voyage-finance-2, voyage-law-2.
22
+ # voyage-3 family, voyage-code-4, voyage-code-3, voyage-finance-2,
23
+ # voyage-law-2, multimodal, and contextualized chunk models
24
+ # (voyage-context-4, voyage-context-3).
23
25
  # Distinguishes input types.
24
26
  # * {Jina} — jina-embeddings-v3/v4/v5 (text + omni-text mode),
25
27
  # jina-code-embeddings-{0.5b,1.5b}. Matryoshka via `dimensions:`.
@@ -428,7 +428,10 @@ module Parse
428
428
  #
429
429
  # @param field [Symbol, nil] limit to one embed target; nil
430
430
  # processes every declared directive.
431
- # @param batch_size [Integer] rows fetched per round (default 100).
431
+ # @param batch_size [Integer] rows fetched per query page (default
432
+ # 100). This pages the records; it does NOT batch provider
433
+ # requests. Each record is saved individually and makes its own
434
+ # embedding call.
432
435
  # @param limit [Integer, nil] stop after re-embedding at most
433
436
  # this many records across all directives; nil = no cap.
434
437
  # @param where [Hash, nil] extra query constraints (e.g.
@@ -527,7 +530,10 @@ module Parse
527
530
  #
528
531
  # @param field [Symbol, nil] limit the backfill to one embed
529
532
  # target; nil processes every declared directive.
530
- # @param batch_size [Integer] rows fetched per round (default 100).
533
+ # @param batch_size [Integer] rows fetched per query page (default
534
+ # 100). This pages the records; it does NOT batch provider
535
+ # requests. Each record is saved individually and makes its own
536
+ # embedding call.
531
537
  # @param limit [Integer, nil] stop after embedding at most this
532
538
  # many records across all directives; nil = no cap.
533
539
  # @param where [Hash, nil] extra query constraints AND-ed with the
data/lib/parse/mongodb.rb CHANGED
@@ -69,6 +69,9 @@ module Parse
69
69
  #
70
70
  # @note Requires the 'mongo' gem to be installed. Add to your Gemfile:
71
71
  # gem 'mongo', '~> 2.18'
72
+ # Use 2.26 or newer against MongoDB 9.0, which is the first driver
73
+ # release that handles 9.0's overload (Intelligent Workload
74
+ # Management) errors.
72
75
  module MongoDB
73
76
  # Error raised when mongo gem is not available
74
77
  class GemNotAvailable < StandardError; end
@@ -191,6 +194,17 @@ module Parse
191
194
  find listDatabases connPoolStats serverStatus
192
195
  ].freeze
193
196
 
197
+ # MongoDB error code for QueryPlanKilled. MongoDB 9.0 kills a running
198
+ # query with it when an indexed field the query references becomes
199
+ # multikey (an insert or update stores an array there) mid-flight.
200
+ MONGO_QUERY_PLAN_KILLED_CODE = 175
201
+
202
+ # How many times a read killed by {MONGO_QUERY_PLAN_KILLED_CODE} is
203
+ # re-run before the error propagates. MongoDB's guidance is to re-run
204
+ # once the write that changed the index completes, so a small budget
205
+ # is enough; a persistent kill is a real failure the caller sees.
206
+ QUERY_KILLED_RETRIES = 2
207
+
194
208
  class << self
195
209
  # @!attribute [rw] enabled
196
210
  # Feature flag to enable/disable direct MongoDB queries.
@@ -1862,7 +1876,7 @@ module Parse
1862
1876
  if (mode = normalize_read_preference(read_preference))
1863
1877
  coll = coll.with(read: { mode: mode })
1864
1878
  end
1865
- results = coll.aggregate(pipeline, agg_opts).to_a
1879
+ results = with_query_killed_retry(collection_name) { coll.aggregate(pipeline, agg_opts).to_a }
1866
1880
  Parse::ACLScope.redact_results!(results, resolution)
1867
1881
 
1868
1882
  # Post-fetch pointerFields filter: drop rows where none of the
@@ -2145,7 +2159,7 @@ module Parse
2145
2159
  cursor = cursor.projection(options[:projection]) if options[:projection]
2146
2160
  cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
2147
2161
  cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
2148
- results = cursor.to_a
2162
+ results = with_query_killed_retry(collection_name) { cursor.to_a }
2149
2163
 
2150
2164
  if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
2151
2165
  # Trim the sentinel row and warn — the caller asked for everything
@@ -2451,6 +2465,39 @@ module Parse
2451
2465
  # MongoDB error code for MaxTimeMSExpired
2452
2466
  MONGO_MAX_TIME_MS_EXPIRED_CODE = 50
2453
2467
 
2468
+ # Run a read and re-run it when the server killed its plan (see
2469
+ # {MONGO_QUERY_PLAN_KILLED_CODE}). Only wraps reads: re-running a
2470
+ # find or aggregate that returned nothing has no side effects. Each
2471
+ # retry emits `parse.mongodb.query_killed_retry`.
2472
+ #
2473
+ # @param collection_name [String] for the notification payload.
2474
+ # @yieldreturn [Object] the read's result.
2475
+ def with_query_killed_retry(collection_name)
2476
+ attempts = 0
2477
+ begin
2478
+ yield
2479
+ rescue StandardError => e
2480
+ raise unless query_plan_killed?(e) && attempts < QUERY_KILLED_RETRIES
2481
+ attempts += 1
2482
+ ActiveSupport::Notifications.instrument(
2483
+ "parse.mongodb.query_killed_retry",
2484
+ collection: collection_name.to_s, attempt: attempts,
2485
+ )
2486
+ retry
2487
+ end
2488
+ end
2489
+
2490
+ # @return [Boolean] true when `err` is the driver's report of a
2491
+ # killed query plan. Matches the numeric code first and the code
2492
+ # name second, since older response-parsing paths have surfaced
2493
+ # only one of the two.
2494
+ def query_plan_killed?(err)
2495
+ return false unless defined?(::Mongo::Error::OperationFailure)
2496
+ return false unless err.is_a?(::Mongo::Error::OperationFailure)
2497
+ return true if err.respond_to?(:code) && err.code == MONGO_QUERY_PLAN_KILLED_CODE
2498
+ err.respond_to?(:code_name) && err.code_name == "QueryPlanKilled"
2499
+ end
2500
+
2454
2501
  # Inspect a driver exception and raise {ExecutionTimeout} if it carries
2455
2502
  # error code 50 (MaxTimeMSExpired). Otherwise, the original exception is
2456
2503
  # re-raised by the caller.
data/lib/parse/query.rb CHANGED
@@ -2439,11 +2439,11 @@ module Parse
2439
2439
  original_limit = @limit
2440
2440
  @limit = count
2441
2441
 
2442
- begin
2443
- items = results_direct
2444
- ensure
2445
- @limit = original_limit
2446
- end
2442
+ items = begin
2443
+ results_direct
2444
+ ensure
2445
+ @limit = original_limit
2446
+ end
2447
2447
 
2448
2448
  count == 1 ? items.first : items.first(count)
2449
2449
  end
@@ -0,0 +1,282 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "uri"
6
+ require "ipaddr"
7
+ require_relative "../reranker"
8
+
9
+ module Parse
10
+ module Retrieval
11
+ module Reranker
12
+ # Voyage AI cross-encoder reranker. Wraps `POST /v1/rerank`.
13
+ #
14
+ # Takes a query plus a list of document strings and returns a
15
+ # relevance-ordered list of `{ index, relevance_score }` objects.
16
+ # It is a distinct endpoint from `/v1/embeddings`; do NOT confuse it
17
+ # with {Parse::Embeddings::Voyage} (the embeddings provider).
18
+ #
19
+ # == Endpoints
20
+ #
21
+ # The same models are served by Voyage's own API and by MongoDB's
22
+ # Atlas Embedding and Reranking API, with an identical wire contract.
23
+ # An Atlas model API key (prefix {ATLAS_KEY_PREFIX}) routes to
24
+ # {ATLAS_BASE_URL} automatically, matching the embeddings provider.
25
+ # Pass `base_url:` to target either host (or a proxy) explicitly.
26
+ #
27
+ # The HTTP stack mirrors {Cohere}: explicit `proxy: nil` unless
28
+ # opted in, bounded timeouts, capped retries with backoff on
29
+ # 429/5xx, a response-size cap, and a redacted `#inspect`.
30
+ #
31
+ # @example
32
+ # reranker = Parse::Retrieval::Reranker::Voyage.new(
33
+ # api_key: ENV.fetch("VOYAGE_API_KEY"),
34
+ # model: "rerank-3",
35
+ # )
36
+ # reranker.rerank(query: "rain songs", documents: lyrics, top_n: 5)
37
+ class Voyage < Base
38
+ class AuthenticationError < Error; end
39
+ class RateLimitError < Error; end
40
+ class TransientError < Error; end
41
+ class BadRequestError < Error; end
42
+
43
+ DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
44
+ ATLAS_BASE_URL = "https://ai.mongodb.com/v1"
45
+ # Atlas model API keys carry this prefix and authenticate only
46
+ # against {ATLAS_BASE_URL}.
47
+ ATLAS_KEY_PREFIX = "al-"
48
+ DEFAULT_MODEL = "rerank-3"
49
+ DEFAULT_TIMEOUT = 30
50
+ DEFAULT_OPEN_TIMEOUT = 5
51
+ DEFAULT_MAX_RETRIES = 2
52
+
53
+ # Current and still-served rerank models. Informational: the
54
+ # constructor accepts any non-empty model name so a newly released
55
+ # model works without an SDK upgrade.
56
+ MODELS = %w[rerank-3 rerank-3-lite rerank-2.5 rerank-2.5-lite rerank-2 rerank-2-lite].freeze
57
+
58
+ # Voyage documents a cap of 1000 documents per rerank call; the
59
+ # {Base::MAX_DOCUMENTS} cap (1000) already enforces this.
60
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
61
+
62
+ # @param api_key [String] Voyage API key, or an Atlas model API key.
63
+ # @param model [String] rerank model (default {DEFAULT_MODEL}).
64
+ # @param base_url [String, nil] API base. Defaults to
65
+ # {ATLAS_BASE_URL} for an Atlas key, else {DEFAULT_BASE_URL}.
66
+ # @param truncation [Boolean] forward Voyage's `truncation:` field.
67
+ # Defaults `true` (Voyage's default). `false` makes over-length
68
+ # inputs a 400 instead of silently truncating them.
69
+ # @param timeout [Integer] read timeout (seconds).
70
+ # @param open_timeout [Integer] connect timeout (seconds).
71
+ # @param max_retries [Integer] retry budget for 429 / 5xx /
72
+ # transient connection errors.
73
+ # @param allow_faraday_proxy [Boolean] permit Faraday to honor
74
+ # `*_proxy` env vars (default false, explicit `proxy: nil`).
75
+ def initialize(api_key:, model: DEFAULT_MODEL, base_url: nil, truncation: true,
76
+ timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT,
77
+ max_retries: DEFAULT_MAX_RETRIES, allow_faraday_proxy: false)
78
+ validate_api_key!(api_key)
79
+ @api_key = api_key
80
+ @model = model.to_s
81
+ raise ArgumentError, "Reranker::Voyage: model must be non-empty." if @model.empty?
82
+ base_url ||= api_key.start_with?(ATLAS_KEY_PREFIX) ? ATLAS_BASE_URL : DEFAULT_BASE_URL
83
+ @base_url = base_url.to_s
84
+ validate_base_url!(@base_url)
85
+ unless [true, false].include?(truncation)
86
+ raise ArgumentError, "Reranker::Voyage: truncation must be true or false (got #{truncation.inspect})."
87
+ end
88
+ @truncation = truncation
89
+ @timeout = Integer(timeout)
90
+ @open_timeout = Integer(open_timeout)
91
+ @max_retries = Integer(max_retries)
92
+ raise ArgumentError, "Reranker::Voyage: max_retries must be >= 0." if @max_retries.negative?
93
+ @allow_faraday_proxy = allow_faraday_proxy ? true : false
94
+ @connection = build_connection
95
+ end
96
+
97
+ # @return [String] the rerank model name.
98
+ attr_reader :model
99
+
100
+ # @return [Boolean] true when routed through {ATLAS_BASE_URL}.
101
+ def atlas?
102
+ safe_base_host == URI.parse(ATLAS_BASE_URL).host
103
+ end
104
+
105
+ def inspect
106
+ "#<#{self.class} model=#{@model.inspect} base=#{safe_base_host.inspect} " \
107
+ "retries=#{@max_retries} api_key=[REDACTED]>"
108
+ end
109
+
110
+ protected
111
+
112
+ def rerank_scores(query, documents, top_n)
113
+ require_faraday!
114
+ body = {
115
+ "model" => @model,
116
+ "query" => query,
117
+ "documents" => documents,
118
+ "top_k" => top_n,
119
+ "truncation" => @truncation,
120
+ }
121
+ payload = post_rerank(body)
122
+ extract_results!(payload)
123
+ end
124
+
125
+ private
126
+
127
+ def post_rerank(body)
128
+ attempts = 0
129
+ loop do
130
+ attempts += 1
131
+ begin
132
+ response = @connection.post("rerank") { |req| req.body = body.to_json }
133
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
134
+ raise TransientError, "Reranker::Voyage: #{e.class} after #{attempts} attempt(s)." if attempts > @max_retries
135
+ sleep(backoff_seconds(attempts))
136
+ next
137
+ end
138
+
139
+ status = response.status
140
+ return parse_json_body!(response.body) if status >= 200 && status < 300
141
+
142
+ case status
143
+ when 401
144
+ raise AuthenticationError, "Reranker::Voyage: 401 Unauthorized. Check api_key."
145
+ when 403
146
+ # Voyage and Atlas keys are not interchangeable; a key sent to
147
+ # the other host is refused with a 403.
148
+ raise AuthenticationError,
149
+ "Reranker::Voyage: 403 Forbidden from #{safe_base_host}. Atlas model API keys " \
150
+ "(#{ATLAS_KEY_PREFIX}...) work only against #{ATLAS_BASE_URL}, and Voyage keys only " \
151
+ "against #{DEFAULT_BASE_URL}; check that base_url matches the key."
152
+ when 429
153
+ raise RateLimitError, "Reranker::Voyage: 429 rate limited after #{attempts} attempt(s)." if attempts > @max_retries
154
+ sleep(retry_after_seconds(response) || backoff_seconds(attempts))
155
+ when 500..599
156
+ raise TransientError, "Reranker::Voyage: #{status} after #{attempts} attempt(s)." if attempts > @max_retries
157
+ sleep(backoff_seconds(attempts))
158
+ else
159
+ raise BadRequestError, "Reranker::Voyage: #{status} from POST /rerank."
160
+ end
161
+ end
162
+ end
163
+
164
+ # Voyage /v1/rerank response shape:
165
+ # { "object": "list",
166
+ # "data": [ { "index": 0, "relevance_score": 0.45 }, ... ],
167
+ # "model": "rerank-3", "usage": { "total_tokens": 8 } }
168
+ def extract_results!(payload)
169
+ unless payload.is_a?(Hash)
170
+ raise InvalidResponseError, "Reranker::Voyage: response body is not a JSON object."
171
+ end
172
+ data = payload["data"]
173
+ unless data.is_a?(Array)
174
+ raise InvalidResponseError, "Reranker::Voyage: response.data is not an Array."
175
+ end
176
+ data.map do |r|
177
+ unless r.is_a?(Hash)
178
+ raise InvalidResponseError, "Reranker::Voyage: rerank result is not an object (#{r.inspect})."
179
+ end
180
+ Result.new(index: r["index"], relevance_score: r["relevance_score"])
181
+ end
182
+ end
183
+
184
+ def parse_json_body!(body)
185
+ s = body.to_s
186
+ if s.bytesize > MAX_RESPONSE_BYTES
187
+ raise InvalidResponseError,
188
+ "Reranker::Voyage: response body exceeds #{MAX_RESPONSE_BYTES} bytes (#{s.bytesize})."
189
+ end
190
+ JSON.parse(s, max_nesting: 32)
191
+ rescue JSON::ParserError => e
192
+ raise InvalidResponseError, "Reranker::Voyage: response is not valid JSON (#{e.message})."
193
+ end
194
+
195
+ def build_connection
196
+ require_faraday!
197
+ headers = {
198
+ "Authorization" => "Bearer #{@api_key}",
199
+ "Content-Type" => "application/json",
200
+ "Accept" => "application/json",
201
+ "User-Agent" => "parse-stack-reranker/#{Parse::Stack::VERSION rescue "0"}",
202
+ }
203
+ # base_url must end with a trailing slash so Faraday resolves the
204
+ # relative "rerank" path under /v1/ rather than replacing it.
205
+ base = @base_url.end_with?("/") ? @base_url : "#{@base_url}/"
206
+ faraday_opts = { url: base, headers: headers }
207
+ faraday_opts[:proxy] = nil unless @allow_faraday_proxy
208
+ conn = Faraday.new(**faraday_opts) do |f|
209
+ f.options.timeout = @timeout
210
+ f.options.open_timeout = @open_timeout
211
+ f.adapter Faraday.default_adapter
212
+ end
213
+ conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=)
214
+ conn
215
+ end
216
+
217
+ def backoff_seconds(attempt)
218
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
219
+ end
220
+
221
+ def retry_after_seconds(response)
222
+ ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil
223
+ return nil unless ra
224
+ v = ra.to_f
225
+ v.positive? ? [v, 60.0].min : nil
226
+ end
227
+
228
+ def validate_api_key!(api_key)
229
+ unless api_key.is_a?(String) && !api_key.empty?
230
+ raise ArgumentError, "Reranker::Voyage: api_key must be a non-empty String."
231
+ end
232
+ end
233
+
234
+ def validate_base_url!(base_url)
235
+ uri = URI.parse(base_url)
236
+ unless uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
237
+ raise ArgumentError, "Reranker::Voyage: base_url must be http(s) (got #{base_url.inspect})."
238
+ end
239
+ # Credentials in the URL leak into logs and error messages, and
240
+ # userinfo can mask the real host.
241
+ unless uri.userinfo.nil?
242
+ raise ArgumentError,
243
+ "Reranker::Voyage: base_url must not embed userinfo (credentials in the URL)."
244
+ end
245
+ # Plaintext http:// would send the API key in the clear. Permit it
246
+ # only for loopback hosts (a local dev proxy or sidecar).
247
+ if uri.scheme == "http" && !loopback_host?(uri.host)
248
+ raise ArgumentError,
249
+ "Reranker::Voyage: base_url must be https:// for non-loopback hosts " \
250
+ "(refusing to send the API key over plaintext http to #{uri.host.inspect})."
251
+ end
252
+ rescue URI::InvalidURIError => e
253
+ raise ArgumentError, "Reranker::Voyage: invalid base_url #{base_url.inspect} (#{e.message})."
254
+ end
255
+
256
+ # @return [Boolean] true for localhost / 127.0.0.0/8 / ::1 etc.
257
+ def loopback_host?(host)
258
+ return false if host.nil? || host.empty?
259
+ h = host.downcase.sub(/\A\[/, "").sub(/\]\z/, "")
260
+ return true if h == "localhost"
261
+ begin
262
+ IPAddr.new(h).loopback?
263
+ rescue IPAddr::Error
264
+ false
265
+ end
266
+ end
267
+
268
+ def safe_base_host
269
+ URI.parse(@base_url).host
270
+ rescue StandardError
271
+ "?"
272
+ end
273
+
274
+ def require_faraday!
275
+ require "faraday" unless defined?(Faraday)
276
+ rescue LoadError
277
+ raise Error, "Reranker::Voyage requires the `faraday` gem."
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -37,6 +37,8 @@ module Parse
37
37
  # The Cohere `/v2/rerank` adapter is loaded lazily — it requires
38
38
  # Faraday, which the core retrieval path does not.
39
39
  autoload :Cohere, ::File.expand_path("reranker/cohere", __dir__)
40
+ # The Voyage `/v1/rerank` adapter, also lazy for the same reason.
41
+ autoload :Voyage, ::File.expand_path("reranker/voyage", __dir__)
40
42
 
41
43
  # Base error for the reranker layer. Adapters raise subclasses.
42
44
  class Error < StandardError; end
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.7.3"
9
+ VERSION = "5.7.5"
10
10
  end
11
11
  end
@@ -71,6 +71,7 @@ Gem::Specification.new do |spec|
71
71
  # Required for: Parse::MongoDB, Parse::AtlasSearch, mongo_direct query methods
72
72
  # Users can add this to their Gemfile for direct MongoDB access:
73
73
  # gem 'mongo', '~> 2.18'
74
+ # Use mongo 2.26 or newer against MongoDB 9.0 servers.
74
75
  # Note: The gem is loaded at runtime only when MongoDB features are used
75
76
 
76
77
  # Optional dependency for GraphQL schema type generation
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.3
4
+ version: 5.7.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -251,6 +251,7 @@ files:
251
251
  - lib/parse/agent/constraint_translator.rb
252
252
  - lib/parse/agent/describe.rb
253
253
  - lib/parse/agent/errors.rb
254
+ - lib/parse/agent/log_levels.rb
254
255
  - lib/parse/agent/mcp_client.rb
255
256
  - lib/parse/agent/mcp_dispatcher.rb
256
257
  - lib/parse/agent/mcp_rack_app.rb
@@ -406,6 +407,7 @@ files:
406
407
  - lib/parse/retrieval/chunker.rb
407
408
  - lib/parse/retrieval/reranker.rb
408
409
  - lib/parse/retrieval/reranker/cohere.rb
410
+ - lib/parse/retrieval/reranker/voyage.rb
409
411
  - lib/parse/retrieval/retriever.rb
410
412
  - lib/parse/schema.rb
411
413
  - lib/parse/schema/index_migrator.rb