parse-stack-next 5.8.0 → 5.8.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 +318 -0
- data/README.md +25 -19
- data/docs/caching.md +50 -0
- data/docs/mcp_guide.md +15 -3
- data/docs/webhooks_guide.md +59 -7
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent.rb +205 -20
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +49 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +35 -2
- data/lib/parse/client.rb +26 -2
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +279 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +19 -4
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +19 -1
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +31 -27
- data/lib/parse/query.rb +793 -55
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +19 -0
- data/lib/parse/vector_search/hybrid.rb +74 -15
- data/lib/parse/vector_search.rb +214 -7
- data/lib/parse/webhooks/payload.rb +43 -0
- data/lib/parse/webhooks.rb +312 -9
- metadata +1 -1
data/lib/parse/stack/version.rb
CHANGED
data/lib/parse/stack.rb
CHANGED
|
@@ -62,6 +62,25 @@ module Parse
|
|
|
62
62
|
# the first attempt and gone by the retry; the fiber-local state lives
|
|
63
63
|
# for the lifetime of the block).
|
|
64
64
|
#
|
|
65
|
+
# Mongo-direct reads follow the same rule: an explicit `master: true`
|
|
66
|
+
# passed to `Parse::MongoDB.aggregate`, a `Parse::Query` direct terminal,
|
|
67
|
+
# Atlas Search, or vector search runs in the public scope inside the
|
|
68
|
+
# block (or raises `ACLRequired` when `require_session_token` is on), and
|
|
69
|
+
# the `parse.mongodb.aggregate` notification payload carries
|
|
70
|
+
# `master_dropped: true`. `Parse::AtlasSearch.faceted_search` refuses to
|
|
71
|
+
# run inside the block. A LiveQuery admin connection opened inside the
|
|
72
|
+
# block sends no master key; one that already connected with it stays
|
|
73
|
+
# elevated, because Parse Server authorizes per connection.
|
|
74
|
+
#
|
|
75
|
+
# The SDK's own metadata reads keep the master key inside the block: the
|
|
76
|
+
# class schemas and role graph it reads to enforce a scope, index
|
|
77
|
+
# statistics, and the Atlas Search index listing. Those requests are
|
|
78
|
+
# marked individually; the block's state is never lifted while they run.
|
|
79
|
+
#
|
|
80
|
+
# This is a guard against accidental master-key use, not an isolation
|
|
81
|
+
# boundary. Code inside the block can call {Parse.with_master_key}, and
|
|
82
|
+
# `Parse::MongoDB.find` and `Parse::MongoDB.indexes` take no scope at all.
|
|
83
|
+
#
|
|
65
84
|
# @yield runs the block with master-key disabled
|
|
66
85
|
# @return [Object] the block's return value
|
|
67
86
|
# @example
|
|
@@ -399,9 +399,10 @@ module Parse
|
|
|
399
399
|
# Build the native `$rankFusion` pipeline (without ACL/CLP
|
|
400
400
|
# stages). Public-ish via {.native_pipeline} for snapshot tests;
|
|
401
401
|
# the live path appends ACL enforcement in {#run_native}.
|
|
402
|
-
def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:,
|
|
403
|
+
owner_vector_filter: nil, owner_match: nil)
|
|
404
|
+
vsel = vector_search_stage(vec, oversample, owner_filter: owner_vector_filter)
|
|
405
|
+
lsel = lexical_search_stage(lex, oversample, owner_match: owner_match)
|
|
405
406
|
stage = {
|
|
406
407
|
"input" => {
|
|
407
408
|
"pipelines" => { "vector" => vsel, "lexical" => lsel },
|
|
@@ -437,19 +438,44 @@ module Parse
|
|
|
437
438
|
# AFTER the ACL `$match`, so trimming to `k` there would
|
|
438
439
|
# reintroduce the underfill the window exists to prevent. The
|
|
439
440
|
# trim to `k` happens client-side once enforcement is done.
|
|
441
|
+
pointer_fields = resolve_pointer_fields!(collection_name, resolution)
|
|
440
442
|
native_pipeline_for(lex, vec, candidate_window, resolution,
|
|
441
443
|
k_constant: fusion[:k_constant] || DEFAULT_K_CONSTANT,
|
|
442
|
-
weights: fusion[:weights], limit: candidate_window
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
weights: fusion[:weights], limit: candidate_window,
|
|
445
|
+
pointer_fields: pointer_fields,
|
|
446
|
+
owner_vector_filter: owner_vector_filter(collection_name, vec, pointer_fields, resolution))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:,
|
|
450
|
+
pointer_fields: nil, owner_vector_filter: nil)
|
|
451
|
+
scoped = !(resolution.nil? || resolution.master?)
|
|
452
|
+
# Ownership inside each `$rankFusion` input as well as after it:
|
|
453
|
+
# every input is capped at `oversample`, so a final `$match`
|
|
454
|
+
# alone only sees the fused top rows and can underfill when
|
|
455
|
+
# other users' rows dominate both branches. The lexical input
|
|
456
|
+
# filters before its `$limit`; the vector input uses the
|
|
457
|
+
# `$vectorSearch.filter` prefilter when the owner path is
|
|
458
|
+
# filter-indexed and served (see Parse::VectorSearch).
|
|
459
|
+
owner_match = if scoped && pointer_fields
|
|
460
|
+
Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
|
|
461
|
+
end
|
|
462
|
+
pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights,
|
|
463
|
+
owner_vector_filter: (owner_vector_filter if scoped),
|
|
464
|
+
owner_match: owner_match)]
|
|
447
465
|
# The fused RRF score is surfaced via `{ $meta: "score" }`
|
|
448
466
|
# (a numeric), not "scoreDetails" (a breakdown document).
|
|
449
467
|
pipeline << { "$addFields" => { "_hybrid_score" => { "$meta" => "score" } } }
|
|
450
468
|
unless resolution.nil? || resolution.master?
|
|
451
469
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
452
470
|
pipeline << acl_match if acl_match
|
|
471
|
+
# pointerFields / readUserFields ownership before `$limit`, so
|
|
472
|
+
# rows ranked above the caller's own rows cannot crowd them
|
|
473
|
+
# out of the page.
|
|
474
|
+
if pointer_fields
|
|
475
|
+
pipeline << {
|
|
476
|
+
"$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
|
|
477
|
+
}
|
|
478
|
+
end
|
|
453
479
|
end
|
|
454
480
|
pipeline << { "$sort" => { "_hybrid_score" => -1 } }
|
|
455
481
|
pipeline << { "$limit" => limit }
|
|
@@ -491,8 +517,11 @@ module Parse
|
|
|
491
517
|
)
|
|
492
518
|
end
|
|
493
519
|
|
|
520
|
+
owner_filter = owner_vector_filter(collection_name, vec, pointer_fields, resolution)
|
|
494
521
|
pipeline = native_pipeline_for(lex, vec, oversample, resolution,
|
|
495
|
-
k_constant: k_constant, weights: weights, limit: oversample
|
|
522
|
+
k_constant: k_constant, weights: weights, limit: oversample,
|
|
523
|
+
pointer_fields: pointer_fields,
|
|
524
|
+
owner_vector_filter: owner_filter)
|
|
496
525
|
rows = run_pipeline!(collection_name, pipeline,
|
|
497
526
|
authorizing_client: Parse::ACLScope.client_of(resolution))
|
|
498
527
|
|
|
@@ -516,6 +545,8 @@ module Parse
|
|
|
516
545
|
rows.select! { |doc| native_row_visible?(doc, perms_set) }
|
|
517
546
|
Parse::ACLScope.redact_results!(rows, resolution)
|
|
518
547
|
Parse::CLPScope.redact_protected_fields!(rows, protected_fields) if protected_fields.any?
|
|
548
|
+
# Defense in depth: the pointerFields `$match` already ran
|
|
549
|
+
# before `$limit`, so this should drop nothing.
|
|
519
550
|
if pointer_fields
|
|
520
551
|
rows = Parse::CLPScope.filter_by_pointer_fields(rows, pointer_fields, resolution.user_id)
|
|
521
552
|
end
|
|
@@ -535,7 +566,13 @@ module Parse
|
|
|
535
566
|
rows
|
|
536
567
|
rescue Parse::CLPScope::Denied
|
|
537
568
|
raise
|
|
538
|
-
rescue StandardError
|
|
569
|
+
rescue StandardError => e
|
|
570
|
+
# A refused owner prefilter means Atlas serves an index version
|
|
571
|
+
# without that filter path; stop pushing it down so the client
|
|
572
|
+
# fallback below (and later searches) run without it.
|
|
573
|
+
if owner_filter && Parse::VectorSearch.owner_prefilter_rejected?(e, owner_filter)
|
|
574
|
+
Parse::VectorSearch.owner_prefilter_unavailable!(collection_name, vector_index_name(vec))
|
|
575
|
+
end
|
|
539
576
|
# Native execution failed (e.g. a cluster that probed as
|
|
540
577
|
# supported but rejects this exact shape, or a transient error).
|
|
541
578
|
# Fall back to the client-side path rather than failing the
|
|
@@ -543,7 +580,23 @@ module Parse
|
|
|
543
580
|
nil
|
|
544
581
|
end
|
|
545
582
|
|
|
546
|
-
|
|
583
|
+
# The owner prefilter for the native vector input, or nil when it
|
|
584
|
+
# cannot be pushed down (master, no pointerFields, or the owner
|
|
585
|
+
# path not filter-indexed in the index Atlas is serving).
|
|
586
|
+
def owner_vector_filter(collection_name, vec, pointer_fields, resolution)
|
|
587
|
+
return nil if resolution.nil? || resolution.master? || pointer_fields.nil?
|
|
588
|
+
Parse::VectorSearch.send(:owner_vector_prefilter, collection_name, vector_index_name(vec),
|
|
589
|
+
pointer_fields, resolution)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# The vector index the native input searches: the branch's
|
|
593
|
+
# `index:` or {Parse::VectorSearch.default_index}, the same
|
|
594
|
+
# fallback {Parse::VectorSearch.search} uses on the client path.
|
|
595
|
+
def vector_index_name(vec)
|
|
596
|
+
vec[:index] || Parse::VectorSearch.default_index
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def vector_search_stage(vec, oversample, owner_filter: nil)
|
|
547
600
|
# Parity with Parse::VectorSearch: Atlas requires
|
|
548
601
|
# `numCandidates >= limit` and caps it at 10_000. The default
|
|
549
602
|
# (`oversample * MULTIPLIER`) can blow past 10_000 for a large
|
|
@@ -554,19 +607,23 @@ module Parse
|
|
|
554
607
|
num_candidates = (vec[:num_candidates] || oversample * Parse::VectorSearch::DEFAULT_NUM_CANDIDATES_MULTIPLIER).to_i
|
|
555
608
|
num_candidates = [[num_candidates, oversample].max, 10_000].min
|
|
556
609
|
stage = {
|
|
557
|
-
"index" => vec
|
|
610
|
+
"index" => vector_index_name(vec).to_s,
|
|
558
611
|
"path" => vec[:field].to_s,
|
|
559
612
|
"queryVector" => vec[:query_vector],
|
|
560
613
|
"numCandidates" => num_candidates,
|
|
561
614
|
"limit" => oversample,
|
|
562
615
|
}
|
|
563
|
-
|
|
616
|
+
caller_filter = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
|
|
617
|
+
prefilter = [caller_filter, owner_filter].compact
|
|
618
|
+
unless prefilter.empty?
|
|
619
|
+
stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
|
|
620
|
+
end
|
|
564
621
|
inner = [{ "$vectorSearch" => stage }]
|
|
565
622
|
inner << { "$match" => vec[:filter] } if vec[:filter]
|
|
566
623
|
inner
|
|
567
624
|
end
|
|
568
625
|
|
|
569
|
-
def lexical_search_stage(lex, oversample)
|
|
626
|
+
def lexical_search_stage(lex, oversample, owner_match: nil)
|
|
570
627
|
require_relative "../atlas_search" if defined?(Parse::AtlasSearch::SearchBuilder).nil?
|
|
571
628
|
builder = Parse::AtlasSearch::SearchBuilder.new(index_name: lex[:index])
|
|
572
629
|
fields = lex[:fields]
|
|
@@ -575,7 +632,9 @@ module Parse
|
|
|
575
632
|
else
|
|
576
633
|
Array(fields).each { |f| builder.text(query: lex[:query], path: f.to_s, fuzzy: lex[:fuzzy]) }
|
|
577
634
|
end
|
|
578
|
-
inner = [builder.build
|
|
635
|
+
inner = [builder.build]
|
|
636
|
+
inner << { "$match" => owner_match } if owner_match
|
|
637
|
+
inner << { "$limit" => oversample }
|
|
579
638
|
inner << { "$match" => lex[:filter] } if lex[:filter]
|
|
580
639
|
inner
|
|
581
640
|
end
|
data/lib/parse/vector_search.rb
CHANGED
|
@@ -47,8 +47,10 @@ module Parse
|
|
|
47
47
|
#
|
|
48
48
|
# * CLP `find` boundary check — refuses calls the equivalent REST
|
|
49
49
|
# find would refuse.
|
|
50
|
-
# *
|
|
51
|
-
#
|
|
50
|
+
# * `pointerFields` / `readUserFields` ownership `$match` after
|
|
51
|
+
# `$vectorSearch`, pushed into the `$vectorSearch.filter` prefilter
|
|
52
|
+
# when every owner pointer (`_p_<field>`) is declared as a
|
|
53
|
+
# `type: "filter"` field of the index.
|
|
52
54
|
# * Post-`$vectorSearch` ACL `$match` injection (Parse Server's
|
|
53
55
|
# `_rperm` predicate).
|
|
54
56
|
# * Post-fetch `protectedFields` redaction.
|
|
@@ -124,9 +126,10 @@ module Parse
|
|
|
124
126
|
#
|
|
125
127
|
# * `candidate_limit` / `num_candidates` — the requested window.
|
|
126
128
|
# * `post_filter_count` — rows returned by the pipeline, i.e. after
|
|
127
|
-
# the server-side ACL `$match
|
|
129
|
+
# the server-side ACL `$match`, the pointerFields `$match`, and any
|
|
130
|
+
# caller `filter`.
|
|
128
131
|
# * `post_pointer_count` — rows left after client-side redaction and
|
|
129
|
-
# pointer-field
|
|
132
|
+
# the defense-in-depth pointer-field re-check.
|
|
130
133
|
# * `returned_count` — rows handed back, after trimming to `k`.
|
|
131
134
|
# * `underfilled` — the caller received fewer than `k`.
|
|
132
135
|
#
|
|
@@ -137,6 +140,10 @@ module Parse
|
|
|
137
140
|
# Accepted {.index_drift_policy} values.
|
|
138
141
|
INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
|
|
139
142
|
|
|
143
|
+
# Guards the lazy creation of the owner-prefilter negative cache mutex.
|
|
144
|
+
PREFILTER_MUTEX_INIT = Mutex.new
|
|
145
|
+
private_constant :PREFILTER_MUTEX_INIT
|
|
146
|
+
|
|
140
147
|
class << self
|
|
141
148
|
# Policy applied when first-query index verification (see
|
|
142
149
|
# {Parse::Core::VectorSearchable}) finds the deployed Atlas
|
|
@@ -319,7 +326,14 @@ module Parse
|
|
|
319
326
|
# result set is trimmed to `k` after enforcement runs.
|
|
320
327
|
"limit" => candidate_limit,
|
|
321
328
|
}
|
|
322
|
-
|
|
329
|
+
caller_prefilter = vector_filter if vector_filter && !vector_filter.empty?
|
|
330
|
+
owner_prefilter = unless resolution.master?
|
|
331
|
+
owner_vector_prefilter(collection_name, index_name, pointer_fields, resolution)
|
|
332
|
+
end
|
|
333
|
+
prefilter = [caller_prefilter, owner_prefilter].compact
|
|
334
|
+
unless prefilter.empty?
|
|
335
|
+
vs_stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
|
|
336
|
+
end
|
|
323
337
|
pipeline = [{ "$vectorSearch" => vs_stage }]
|
|
324
338
|
|
|
325
339
|
pipeline << {
|
|
@@ -337,12 +351,39 @@ module Parse
|
|
|
337
351
|
unless resolution.master?
|
|
338
352
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
339
353
|
pipeline << acl_match if acl_match
|
|
354
|
+
# pointerFields / readUserFields ownership, server-side so it
|
|
355
|
+
# runs on the whole candidate window before the trim to `k`.
|
|
356
|
+
# When every owner pointer is filter-indexed it also ran as the
|
|
357
|
+
# `$vectorSearch.filter` prefilter above; this stage then drops
|
|
358
|
+
# nothing but still covers the array-of-pointers storage form.
|
|
359
|
+
if pointer_fields
|
|
360
|
+
pipeline << {
|
|
361
|
+
"$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
|
|
362
|
+
}
|
|
363
|
+
end
|
|
340
364
|
end
|
|
341
365
|
|
|
342
366
|
pipeline << { "$match" => filter } if filter
|
|
343
367
|
|
|
344
|
-
|
|
345
|
-
|
|
368
|
+
authorizing_client = Parse::ACLScope.client_of(resolution)
|
|
369
|
+
raw_results = begin
|
|
370
|
+
run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
|
|
371
|
+
authorizing_client: authorizing_client)
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
raise unless owner_prefilter && owner_prefilter_rejected?(e, owner_prefilter)
|
|
374
|
+
# Atlas is serving an index version without the owner filter
|
|
375
|
+
# path (a rebuild in progress, or a stale cached definition).
|
|
376
|
+
# Stop pushing it down and rerun once without it; the
|
|
377
|
+
# ownership `$match` after `$vectorSearch` still enforces it.
|
|
378
|
+
owner_prefilter_unavailable!(collection_name, index_name)
|
|
379
|
+
if caller_prefilter
|
|
380
|
+
vs_stage["filter"] = caller_prefilter
|
|
381
|
+
else
|
|
382
|
+
vs_stage.delete("filter")
|
|
383
|
+
end
|
|
384
|
+
run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
|
|
385
|
+
authorizing_client: authorizing_client)
|
|
386
|
+
end
|
|
346
387
|
# Already past the server-side ACL `$match` and any caller
|
|
347
388
|
# `filter` — NOT the number $vectorSearch emitted.
|
|
348
389
|
post_filter_count = raw_results.length
|
|
@@ -353,6 +394,8 @@ module Parse
|
|
|
353
394
|
unless resolution.master?
|
|
354
395
|
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
355
396
|
Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
|
|
397
|
+
# Defense in depth: the pointerFields `$match` already ran in
|
|
398
|
+
# the pipeline, so this should drop nothing.
|
|
356
399
|
if pointer_fields
|
|
357
400
|
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
358
401
|
raw_results, pointer_fields, resolution.user_id,
|
|
@@ -442,6 +485,52 @@ module Parse
|
|
|
442
485
|
# @return [String, nil]
|
|
443
486
|
attr_accessor :default_index
|
|
444
487
|
|
|
488
|
+
# @!visibility private
|
|
489
|
+
# Whether an error is Atlas refusing one of the owner prefilter's
|
|
490
|
+
# paths because the served index does not declare it as
|
|
491
|
+
# `type: "filter"`. Atlas words the refusal the same way for any
|
|
492
|
+
# path, so the message must name an owner path: a caller
|
|
493
|
+
# `vector_filter` on an unindexed field raises as it always did and
|
|
494
|
+
# does not switch the owner prefilter off for other callers.
|
|
495
|
+
# @param error [Exception]
|
|
496
|
+
# @param owner_prefilter [Hash] the owner clause that was pushed down.
|
|
497
|
+
# @return [Boolean]
|
|
498
|
+
def owner_prefilter_rejected?(error, owner_prefilter)
|
|
499
|
+
message = error.message.to_s
|
|
500
|
+
return false unless message.match?(PREFILTER_REJECTED_PATTERN)
|
|
501
|
+
owner_prefilter_paths(owner_prefilter).any? { |path| message.include?(path) }
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# @!visibility private
|
|
505
|
+
# The `_p_<field>` paths an owner prefilter clause names.
|
|
506
|
+
# @param owner_prefilter [Hash]
|
|
507
|
+
# @return [Array<String>]
|
|
508
|
+
def owner_prefilter_paths(owner_prefilter)
|
|
509
|
+
return [] unless owner_prefilter.is_a?(Hash)
|
|
510
|
+
clauses = owner_prefilter.key?("$or") ? Array(owner_prefilter["$or"]) : [owner_prefilter]
|
|
511
|
+
clauses.flat_map { |c| c.is_a?(Hash) ? c.keys.map(&:to_s) : [] }.select { |k| k.start_with?("_p_") }
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# @!visibility private
|
|
515
|
+
# Stop pushing the owner prefilter down for this index until the
|
|
516
|
+
# index cache TTL passes, and drop the cached index definition so
|
|
517
|
+
# the next lookup reads what Atlas now reports.
|
|
518
|
+
# @param collection_name [String]
|
|
519
|
+
# @param index_name [String, Symbol]
|
|
520
|
+
def owner_prefilter_unavailable!(collection_name, index_name)
|
|
521
|
+
require_relative "atlas_search"
|
|
522
|
+
Parse::AtlasSearch::IndexManager.clear_cache(collection_name)
|
|
523
|
+
mark_prefilter_unavailable(prefilter_cache_key(collection_name, index_name))
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# @!visibility private
|
|
527
|
+
# Forget every negative prefilter lookup (tests, or after an
|
|
528
|
+
# operator fixes the index and does not want to wait out the TTL).
|
|
529
|
+
def clear_prefilter_cache!
|
|
530
|
+
prefilter_mutex.synchronize { @prefilter_unavailable = {} }
|
|
531
|
+
nil
|
|
532
|
+
end
|
|
533
|
+
|
|
445
534
|
private
|
|
446
535
|
|
|
447
536
|
def require_available!
|
|
@@ -507,6 +596,124 @@ module Parse
|
|
|
507
596
|
label: "VectorSearch")
|
|
508
597
|
end
|
|
509
598
|
|
|
599
|
+
# The pointerFields ownership constraint as a `$vectorSearch.filter`
|
|
600
|
+
# clause, or nil when it cannot be pushed down. Atlas only accepts a
|
|
601
|
+
# prefilter on paths declared `type: "filter"` in the index, so this
|
|
602
|
+
# applies only when every owner pointer's storage path
|
|
603
|
+
# (`_p_<field>`) is filter-indexed. A prefilter keeps other users'
|
|
604
|
+
# rows out of the candidate window entirely, so an owner-scoped
|
|
605
|
+
# caller is not underfilled by higher-ranked rows they cannot read.
|
|
606
|
+
# Any lookup failure returns nil: the post-`$vectorSearch` `$match`
|
|
607
|
+
# still enforces ownership, only the fill is weaker.
|
|
608
|
+
#
|
|
609
|
+
# The prefilter matches only the scalar `_p_<field>` storage form.
|
|
610
|
+
# CLP ownership also accepts an array of pointers stored under the
|
|
611
|
+
# bare field name, so pushing down on such a field would drop rows
|
|
612
|
+
# the caller owns. It is therefore used only when every owner field
|
|
613
|
+
# is declared as a scalar pointer on the local model.
|
|
614
|
+
def owner_vector_prefilter(collection_name, index_name, pointer_fields, resolution)
|
|
615
|
+
return nil if pointer_fields.nil? || pointer_fields.empty?
|
|
616
|
+
uid = resolution.user_id.to_s
|
|
617
|
+
return nil if uid.empty?
|
|
618
|
+
return nil unless scalar_pointer_fields?(collection_name, pointer_fields)
|
|
619
|
+
paths = pointer_fields.map { |f| "_p_#{f}" }
|
|
620
|
+
indexed = vector_filter_paths(collection_name, index_name)
|
|
621
|
+
return nil unless paths.all? { |path| indexed.include?(path) }
|
|
622
|
+
storage = "#{Parse::Model::CLASS_USER}$#{uid}"
|
|
623
|
+
clauses = paths.map { |path| { path => { "$eq" => storage } } }
|
|
624
|
+
clauses.size == 1 ? clauses.first : { "$or" => clauses }
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Whether every owner field is declared as a scalar pointer
|
|
628
|
+
# (`belongs_to`) on the local model for the collection. Read from the
|
|
629
|
+
# model's declared fields, so no schema request is made. An unknown
|
|
630
|
+
# class, an undeclared field, or an array field all answer false.
|
|
631
|
+
def scalar_pointer_fields?(collection_name, pointer_fields)
|
|
632
|
+
klass = Parse::Model.find_class(collection_name.to_s)
|
|
633
|
+
return false unless klass.respond_to?(:fields)
|
|
634
|
+
fields = klass.fields
|
|
635
|
+
pointer_fields.all? { |f| fields[f.to_s.to_sym] == :pointer }
|
|
636
|
+
rescue StandardError
|
|
637
|
+
false
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Paths the named vectorSearch index declares as `type: "filter"`.
|
|
641
|
+
# Read through {Parse::AtlasSearch::IndexManager}, which caches the
|
|
642
|
+
# `$listSearchIndexes` result. Empty unless the index is READY and
|
|
643
|
+
# serving its latest definition: during a rebuild
|
|
644
|
+
# `latestDefinition` already lists the new filter path while Atlas
|
|
645
|
+
# still serves the old version, and a prefilter on that path is
|
|
646
|
+
# refused. A lookup error is remembered for the index cache TTL so a
|
|
647
|
+
# deployment without the `listSearchIndexes` privilege does not pay
|
|
648
|
+
# a failing round trip on every search.
|
|
649
|
+
def vector_filter_paths(collection_name, index_name)
|
|
650
|
+
key = prefilter_cache_key(collection_name, index_name)
|
|
651
|
+
return Set.new if prefilter_unavailable?(key)
|
|
652
|
+
require_relative "atlas_search"
|
|
653
|
+
idx = begin
|
|
654
|
+
Parse::AtlasSearch::IndexManager.get_index(collection_name, index_name.to_s)
|
|
655
|
+
rescue StandardError
|
|
656
|
+
mark_prefilter_unavailable(key)
|
|
657
|
+
return Set.new
|
|
658
|
+
end
|
|
659
|
+
return Set.new unless idx && index_serving_latest?(idx)
|
|
660
|
+
defn = idx["latestDefinition"] || idx[:latestDefinition] || {}
|
|
661
|
+
Array(defn["fields"] || defn[:fields]).each_with_object(Set.new) do |f, set|
|
|
662
|
+
next unless (f["type"] || f[:type]).to_s == "filter"
|
|
663
|
+
set << (f["path"] || f[:path]).to_s
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Whether Atlas is serving the index's latest definition: status
|
|
668
|
+
# READY and, when per-host `statusDetail` is reported, every host's
|
|
669
|
+
# main index at `latestDefinitionVersion`. Missing status fails
|
|
670
|
+
# toward "not serving" so the prefilter is simply skipped.
|
|
671
|
+
def index_serving_latest?(idx)
|
|
672
|
+
return false unless (idx["status"] || idx[:status]).to_s.upcase == "READY"
|
|
673
|
+
latest = definition_version(idx["latestDefinitionVersion"] || idx[:latestDefinitionVersion])
|
|
674
|
+
details = Array(idx["statusDetail"] || idx[:statusDetail])
|
|
675
|
+
return true if latest.nil? || details.empty?
|
|
676
|
+
details.all? do |detail|
|
|
677
|
+
main = detail["mainIndex"] || detail[:mainIndex]
|
|
678
|
+
main && definition_version(main["definitionVersion"] || main[:definitionVersion]) == latest
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def definition_version(value)
|
|
683
|
+
return nil unless value.is_a?(Hash)
|
|
684
|
+
value["version"] || value[:version]
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
PREFILTER_REJECTED_PATTERN = /indexed as (?:a )?filter/i
|
|
688
|
+
private_constant :PREFILTER_REJECTED_PATTERN
|
|
689
|
+
|
|
690
|
+
def prefilter_cache_key(collection_name, index_name)
|
|
691
|
+
"#{collection_name}\x1f#{index_name}"
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def prefilter_mutex
|
|
695
|
+
@prefilter_mutex ||= PREFILTER_MUTEX_INIT.synchronize { @prefilter_mutex ||= Mutex.new }
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def prefilter_unavailable?(key)
|
|
699
|
+
prefilter_mutex.synchronize do
|
|
700
|
+
expires = (@prefilter_unavailable ||= {})[key]
|
|
701
|
+
next false unless expires
|
|
702
|
+
next true if Process.clock_gettime(Process::CLOCK_MONOTONIC) < expires
|
|
703
|
+
@prefilter_unavailable.delete(key)
|
|
704
|
+
false
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def mark_prefilter_unavailable(key)
|
|
709
|
+
require_relative "atlas_search"
|
|
710
|
+
ttl = Parse::AtlasSearch::IndexManager.cache_ttl.to_f
|
|
711
|
+
return if ttl <= 0
|
|
712
|
+
prefilter_mutex.synchronize do
|
|
713
|
+
(@prefilter_unavailable ||= {})[key] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
510
717
|
# Execute the pipeline directly against the MongoDB collection.
|
|
511
718
|
# Mirrors `Parse::AtlasSearch#run_atlas_pipeline!` — bypasses
|
|
512
719
|
# `Parse::MongoDB.aggregate` because that helper prepends an
|
|
@@ -557,6 +557,49 @@ module Parse
|
|
|
557
557
|
# object from @object/@original).
|
|
558
558
|
return @parse_object if defined?(@parse_object) && !@parse_object.nil?
|
|
559
559
|
@parse_object = build_parse_object
|
|
560
|
+
# On a beforeSave create, note when the handler assigns an ACL. The
|
|
561
|
+
# model's dirty tracking ignores an assignment equal to the default
|
|
562
|
+
# stamp (`obj.acl = Parse::ACL.new` under a `{}` default), and the
|
|
563
|
+
# reply must still treat it as the handler's explicit choice.
|
|
564
|
+
if before_save? && @original.blank? && @parse_object.is_a?(Parse::Object)
|
|
565
|
+
@parse_object.extend(HandlerAclAssignment)
|
|
566
|
+
end
|
|
567
|
+
@parse_object
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Marks an object whose handler assigned its ACL. Used only on the
|
|
571
|
+
# object a beforeSave create handler receives.
|
|
572
|
+
# @!visibility private
|
|
573
|
+
module HandlerAclAssignment
|
|
574
|
+
def acl=(value)
|
|
575
|
+
@_webhook_handler_acl_assigned = true
|
|
576
|
+
super
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Mass assignment (`attributes=`, `apply_attributes!`) sets the ACL
|
|
580
|
+
# through these setters rather than `acl=`: `acl` by its Ruby name,
|
|
581
|
+
# `ACL` (an alias bound at class definition) by its remote name.
|
|
582
|
+
def acl_set_attribute!(*args, **kwargs, &block)
|
|
583
|
+
@_webhook_handler_acl_assigned = true
|
|
584
|
+
super
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def ACL_set_attribute!(*args, **kwargs, &block)
|
|
588
|
+
@_webhook_handler_acl_assigned = true
|
|
589
|
+
super
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Forget assignments made before the handler ran (the field-guard
|
|
593
|
+
# pre-step reverts a client ACL through `acl=`).
|
|
594
|
+
# @!visibility private
|
|
595
|
+
def reset_webhook_handler_acl_assigned!
|
|
596
|
+
@_webhook_handler_acl_assigned = false
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# @return [Boolean] whether the handler assigned the ACL.
|
|
600
|
+
def webhook_handler_acl_assigned?
|
|
601
|
+
@_webhook_handler_acl_assigned == true
|
|
602
|
+
end
|
|
560
603
|
end
|
|
561
604
|
|
|
562
605
|
# @!visibility private
|