This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 83118a13 test(vindex): make PK-vector concurrency tests deterministic
(#685)
83118a13 is described below
commit 83118a13e6702f095c87395866a03a54941030a9
Author: QuakeWang <[email protected]>
AuthorDate: Thu Aug 6 20:52:20 2026 +0800
test(vindex): make PK-vector concurrency tests deterministic (#685)
---
crates/paimon/src/vindex/pkvector/bucket.rs | 416 +++++++++++++++++++---------
1 file changed, 279 insertions(+), 137 deletions(-)
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs
b/crates/paimon/src/vindex/pkvector/bucket.rs
index c52a72e5..bf67e9bb 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -933,6 +933,7 @@ pub(crate) async fn bucket_search_batch(
mod tests {
use super::*;
use crate::spec::PrimaryKeyIndexSourceFile as PkVectorSourceFile;
+ use crate::vindex::executor::ensure_global_index_executor_capacity;
use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
use crate::vindex::pkvector::exact::exact_search;
use crate::vindex::pkvector::reader::test_support::ArrayReader;
@@ -2416,13 +2417,117 @@ mod tests {
);
}
+ const SCORER_START_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(5);
+ const SCORER_RELEASE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10);
+
+ static GATED_SCORER_TEST_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>>
=
+ std::sync::OnceLock::new();
+
+ async fn lock_gated_scorer_tests() -> tokio::sync::MutexGuard<'static, ()>
{
+ GATED_SCORER_TEST_LOCK
+ .get_or_init(|| tokio::sync::Mutex::new(()))
+ .lock()
+ .await
+ }
+
+ type ScorerIdentity = (usize, String);
+ type StartedScorer = (ScorerIdentity, std::sync::mpsc::Sender<()>);
+
+ /// Reports a scorer's identity to the test task, then blocks until that
task
+ /// releases it. Both sides use timeouts so a scheduling regression fails
instead
+ /// of hanging the suite.
+ #[derive(Clone)]
+ struct ScorerGate {
+ started: tokio::sync::mpsc::UnboundedSender<StartedScorer>,
+ }
+
+ impl ScorerGate {
+ fn wait_for_release(&self, query_id: usize, segment: &str) ->
crate::Result<()> {
+ let (release, released) = std::sync::mpsc::channel();
+ self.started
+ .send(((query_id, segment.to_string()), release))
+ .map_err(|_| data_invalid("scorer gate closed before recording
a start"))?;
+ released
+ .recv_timeout(SCORER_RELEASE_TIMEOUT)
+ .map_err(|error| {
+ data_invalid(format!(
+ "scorer gate did not release query {query_id} segment
'{segment}': {error}"
+ ))
+ })?;
+ Ok(())
+ }
+ }
+
+ fn scorer_gate() -> (
+ ScorerGate,
+ tokio::sync::mpsc::UnboundedReceiver<StartedScorer>,
+ ) {
+ let (started, receiver) = tokio::sync::mpsc::unbounded_channel();
+ (ScorerGate { started }, receiver)
+ }
+
+ async fn wait_for_started_scorers(
+ receiver: &mut tokio::sync::mpsc::UnboundedReceiver<StartedScorer>,
+ count: usize,
+ ) -> Result<Vec<StartedScorer>, String> {
+ let mut scorers = Vec::with_capacity(count);
+ while scorers.len() < count {
+ let scorer = match tokio::time::timeout(SCORER_START_TIMEOUT,
receiver.recv()).await {
+ Ok(Some(scorer)) => scorer,
+ Ok(None) => {
+ return Err("scorer gate closed before all starts
arrived".to_string());
+ }
+ Err(_) => {
+ receiver.close();
+ return Err(format!(
+ "timed out waiting for {count} scorer starts; observed
{}",
+ scorers.len()
+ ));
+ }
+ };
+ scorers.push(scorer);
+ }
+ Ok(scorers)
+ }
+
+ fn release_started_scorers(scorers: Vec<StartedScorer>) ->
Vec<ScorerIdentity> {
+ let mut identities = Vec::with_capacity(scorers.len());
+ for (identity, release) in scorers {
+ identities.push(identity);
+ let _ = release.send(());
+ }
+ identities.sort();
+ identities
+ }
+
+ async fn expect_started_scorers(
+ receiver: &mut tokio::sync::mpsc::UnboundedReceiver<StartedScorer>,
+ expected: &[(usize, &str)],
+ ) -> Result<(), String> {
+ let scorers = wait_for_started_scorers(receiver,
expected.len()).await?;
+ let observed = release_started_scorers(scorers);
+ let mut expected = expected
+ .iter()
+ .map(|(query_id, segment)| (*query_id, (*segment).to_string()))
+ .collect::<Vec<_>>();
+ expected.sort();
+ if observed != expected {
+ return Err(format!(
+ "unexpected scorer starts: expected {expected:?}, observed
{observed:?}"
+ ));
+ }
+ Ok(())
+ }
+
/// ANN searcher probe that records the peak number of `search_batch` calls
- /// running simultaneously. Each call does a real blocking sleep so
overlapping
- /// calls are observable; `peak` is the max concurrent count seen. Mirrors
the
- /// blocking CPU nature of the real vindex scorer.
+ /// running simultaneously. Concurrency tests attach a gate so the test
task can
+ /// inspect which scorers are active before releasing them.
struct PeakProbeAnn {
inflight: std::sync::Arc<std::sync::atomic::AtomicUsize>,
peak: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+ gate: Option<ScorerGate>,
+ query_id: usize,
+ gated_segment: Option<&'static str>,
}
impl PkVectorAnnSearcher for PeakProbeAnn {
fn load_segment(
@@ -2433,7 +2538,7 @@ mod tests {
}
fn search_batch(
&self,
- _segment: &BucketAnnSegment,
+ segment: &BucketAnnSegment,
_segment_bytes: Bytes,
queries: &[&[f32]],
_metric: VectorSearchMetric,
@@ -2446,8 +2551,18 @@ mod tests {
use std::sync::atomic::Ordering::SeqCst;
let current = self.inflight.fetch_add(1, SeqCst) + 1;
self.peak.fetch_max(current, SeqCst);
- std::thread::sleep(std::time::Duration::from_millis(50));
+ let gate_result = if let Some(gate) = &self.gate {
+ if self.gated_segment.is_none_or(|gated| gated ==
segment.path) {
+ gate.wait_for_release(self.query_id, &segment.path)
+ } else {
+ Ok(())
+ }
+ } else {
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ Ok(())
+ };
self.inflight.fetch_sub(1, SeqCst);
+ gate_result?;
Ok(queries.iter().map(|_| Vec::new()).collect())
}
}
@@ -2457,6 +2572,7 @@ mod tests {
/// test can assert how many segment searches ran at once.
fn n_segment_bucket(
n: usize,
+ gate: Option<ScorerGate>,
) -> (
Vec<BucketAnnSegment>,
Vec<BucketActiveFile>,
@@ -2479,42 +2595,45 @@ mod tests {
let probe = std::sync::Arc::new(PeakProbeAnn {
inflight:
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
peak: peak.clone(),
+ gate,
+ query_id: 0,
+ gated_segment: None,
});
(segments, actives, probe, peak)
}
#[tokio::test]
async fn ann_segments_search_in_parallel_at_concurrency_above_one() {
- // Two ANN segments in one bucket. At concurrency 4 their (blocking)
searches
- // must overlap: peak simultaneous ANN calls must exceed 1. A serial
ANN loop
- // yields peak == 1 and fails this assertion.
- let (segments, active_files, probe, peak) = n_segment_bucket(2);
+ let _test_guard = lock_gated_scorer_tests().await;
+ ensure_global_index_executor_capacity(4);
+ let (gate, mut started) = scorer_gate();
+ let (segments, active_files, probe, _peak) = n_segment_bucket(2,
Some(gate));
let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
let factory = unreachable_search();
- let out = bucket_search(
- Some(searcher),
- &segments,
- &active_files,
- &HashMap::new(),
- &factory,
- &[0.0, 0.0],
- VectorSearchMetric::L2,
- 8,
- 8,
- &HashMap::new(),
- false,
- None,
- 4,
- Some(SearchBudget::per_query_only(4)),
- )
- .await
- .unwrap();
+ let search = async {
+ bucket_search(
+ Some(searcher),
+ &segments,
+ &active_files,
+ &HashMap::new(),
+ &factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 8,
+ 8,
+ &HashMap::new(),
+ false,
+ None,
+ 4,
+ Some(SearchBudget::per_query_only(4)),
+ )
+ .await
+ };
+ let observe = expect_started_scorers(&mut started, &[(0, "seg-0"), (0,
"seg-1")]);
+ let (out, observed) = tokio::join!(search, observe);
+ observed.expect("two ANN segment scorers must start before release");
+ let out = out.unwrap();
assert!(out.indexed.is_empty());
- assert!(
- peak.load(std::sync::atomic::Ordering::SeqCst) >= 2,
- "ANN segment searches must run in parallel at concurrency 4;
observed peak {}",
- peak.load(std::sync::atomic::Ordering::SeqCst)
- );
}
#[tokio::test]
@@ -2522,7 +2641,7 @@ mod tests {
// At concurrency 1 the ANN segments must NOT overlap: peak
simultaneous
// calls is exactly 1 (still off the async worker on the dedicated
executor,
// but one at a time). Guards the one-worker budget without regressing
inline.
- let (segments, active_files, probe, peak) = n_segment_bucket(3);
+ let (segments, active_files, probe, peak) = n_segment_bucket(3, None);
let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
let factory = unreachable_search();
bucket_search(
@@ -2555,35 +2674,66 @@ mod tests {
// Four ANN segments, shared budget of 2: at most 2 segment searches
may run
// at once even though concurrency (fan-out width) is 4. Mirrors
Java's single
// shared pool sized to threadNum capping total in-flight leaf work.
- let (segments, active_files, probe, peak) = n_segment_bucket(4);
+ let _test_guard = lock_gated_scorer_tests().await;
+ ensure_global_index_executor_capacity(4);
+ let (gate, mut started) = scorer_gate();
+ let (segments, active_files, probe, peak) = n_segment_bucket(4,
Some(gate));
let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
let factory = unreachable_search();
- bucket_search(
- Some(searcher),
- &segments,
- &active_files,
- &HashMap::new(),
- &factory,
- &[0.0, 0.0],
- VectorSearchMetric::L2,
- 8,
- 8,
- &HashMap::new(),
- false,
- None,
- 4,
- Some(SearchBudget::per_query_only(2)),
- )
- .await
- .unwrap();
- let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
- assert!(
- observed <= 2,
- "shared budget must cap concurrent ANN segment searches at 2;
observed {observed}"
+ let shared = Arc::new(Semaphore::new(2));
+ let search = async {
+ bucket_search(
+ Some(searcher),
+ &segments,
+ &active_files,
+ &HashMap::new(),
+ &factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 8,
+ 8,
+ &HashMap::new(),
+ false,
+ None,
+ 4,
+ Some(SearchBudget::shared_for_test(shared.clone())),
+ )
+ .await
+ };
+ let observe = async {
+ let first = wait_for_started_scorers(&mut started, 2).await?;
+ let first_available = shared.available_permits();
+ let mut identities = release_started_scorers(first);
+
+ let second = wait_for_started_scorers(&mut started, 2).await?;
+ let second_available = shared.available_permits();
+ identities.extend(release_started_scorers(second));
+ identities.sort();
+ Ok::<_, String>((identities, [first_available, second_available]))
+ };
+ let (out, observed) = tokio::join!(search, observe);
+ let (identities, available_permits) =
+ observed.expect("all four gated ANN scorers must start in two
waves");
+ out.unwrap();
+ assert_eq!(
+ identities,
+ vec![
+ (0, "seg-0".to_string()),
+ (0, "seg-1".to_string()),
+ (0, "seg-2".to_string()),
+ (0, "seg-3".to_string()),
+ ],
+ "every ANN segment must pass through the controlled gate exactly
once"
);
- assert!(
- observed >= 2,
- "test must actually exercise ANN overlap; observed {observed}"
+ assert_eq!(
+ available_permits,
+ [0, 0],
+ "both shared-budget permits must remain held while each scorer
wave is gated"
+ );
+ let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
+ assert_eq!(
+ observed, 2,
+ "shared budget must cap concurrent ANN segment searches at exactly
2"
);
}
@@ -2594,7 +2744,7 @@ mod tests {
// validation, BEFORE any segment search leaf is spawned — so the
probe's
// peak stays 0. Guards Codex's "validate all segments before
launching any
// job" requirement.
- let (mut segments, _active_files, probe, peak) = n_segment_bucket(2);
+ let (mut segments, _active_files, probe, peak) = n_segment_bucket(2,
None);
// Break segment 1's source row count vs the active file (active says
2).
segments[1] = BucketAnnSegment {
source_meta: meta(&[("cov-1", 99)]),
@@ -2638,35 +2788,38 @@ mod tests {
// Multi-query (2 queries) routes through the `bucket_search_batch`
multi-query
// path (not the batch-of-one short-circuit), so this covers the batch
ANN
// parallel loop specifically. Two segments at concurrency 4 must
overlap.
- let (segments, active_files, probe, peak) = n_segment_bucket(2);
+ let _test_guard = lock_gated_scorer_tests().await;
+ ensure_global_index_executor_capacity(4);
+ let (gate, mut started) = scorer_gate();
+ let (segments, active_files, probe, _peak) = n_segment_bucket(2,
Some(gate));
let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
let factory = unreachable_search();
let q0: &[f32] = &[0.0, 0.0];
let q1: &[f32] = &[1.0, 1.0];
- let out = bucket_search_batch(
- Some(searcher),
- &segments,
- &active_files,
- &HashMap::new(),
- &factory,
- &[q0, q1],
- VectorSearchMetric::L2,
- 8,
- 8,
- &HashMap::new(),
- false,
- None,
- 4,
- Some(SearchBudget::per_query_only(4)),
- )
- .await
- .unwrap();
+ let search = async {
+ bucket_search_batch(
+ Some(searcher),
+ &segments,
+ &active_files,
+ &HashMap::new(),
+ &factory,
+ &[q0, q1],
+ VectorSearchMetric::L2,
+ 8,
+ 8,
+ &HashMap::new(),
+ false,
+ None,
+ 4,
+ Some(SearchBudget::per_query_only(4)),
+ )
+ .await
+ };
+ let observe = expect_started_scorers(&mut started, &[(0, "seg-0"), (0,
"seg-1")]);
+ let (out, observed) = tokio::join!(search, observe);
+ observed.expect("two batch ANN segment scorers must start before
release");
+ let out = out.unwrap();
assert_eq!(out.len(), 2, "one result list per query");
- assert!(
- peak.load(std::sync::atomic::Ordering::SeqCst) >= 2,
- "batch ANN segment searches must run in parallel at concurrency 4;
observed peak {}",
- peak.load(std::sync::atomic::Ordering::SeqCst)
- );
}
/// ANN searcher whose `search_batch` panics, exercising the dedicated
@@ -2700,7 +2853,7 @@ mod tests {
// A panic inside the dedicated-executor ANN leaf must surface as a
mapped
// `UnexpectedError` ("ANN segment search task failed"), not abort the
runtime
// or hang. Uses the parallel path (concurrency 4).
- let (segments, active_files, _probe, _peak) = n_segment_bucket(1);
+ let (segments, active_files, _probe, _peak) = n_segment_bucket(1,
None);
let searcher: Arc<dyn PkVectorAnnSearcher> = Arc::new(PanicAnn);
let factory = unreachable_search();
let err = bucket_search(
@@ -2779,7 +2932,7 @@ mod tests {
// are slow. The drain must (a) run ALL 4 scorers to completion even
though
// seg-1 errors early, and (b) surface seg-1's error (lowest index),
not seg-2's
// and not whichever finished first.
- let (segments, active_files, _probe, _peak) = n_segment_bucket(4);
+ let (segments, active_files, _probe, _peak) = n_segment_bucket(4,
None);
let completed =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let searcher: Arc<dyn PkVectorAnnSearcher> = Arc::new(DrainProbeAnn {
completed: completed.clone(),
@@ -2819,41 +2972,6 @@ mod tests {
);
}
- /// ANN searcher that records peak concurrent calls into an EXTERNALLY
shared
- /// counter, so two independent `bucket_search` invocations can observe
their
- /// combined in-flight count. Each call blocks briefly so overlap is
observable.
- struct SharedPeakAnn {
- inflight: std::sync::Arc<std::sync::atomic::AtomicUsize>,
- peak: std::sync::Arc<std::sync::atomic::AtomicUsize>,
- }
- impl PkVectorAnnSearcher for SharedPeakAnn {
- fn load_segment(
- &self,
- segment: &BucketAnnSegment,
- ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
- empty_ann_loader(segment)
- }
- fn search_batch(
- &self,
- _segment: &BucketAnnSegment,
- _segment_bytes: Bytes,
- queries: &[&[f32]],
- _metric: VectorSearchMetric,
- _limit: usize,
- _active_source_files: &HashSet<String>,
- _dvs: &HashMap<String, Arc<DeletionVector>>,
- _opts: &HashMap<String, String>,
- _residual_ranges: Option<&HashMap<String,
roaring::RoaringTreemap>>,
- ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
- use std::sync::atomic::Ordering::SeqCst;
- let current = self.inflight.fetch_add(1, SeqCst) + 1;
- self.peak.fetch_max(current, SeqCst);
- std::thread::sleep(std::time::Duration::from_millis(60));
- self.inflight.fetch_sub(1, SeqCst);
- Ok(queries.iter().map(|_| Vec::new()).collect())
- }
- }
-
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shared_budget_caps_ann_across_concurrent_searches() {
// Two independent searches (simulating two concurrent PK-vector
queries), each
@@ -2863,17 +2981,26 @@ mod tests {
// queries, which is what the process-global static does in
production. Before
// the fix, each query built its own Semaphore, so the combined peak
could
// reach 2 (segments) x 2 (queries) = 4.
+ let _test_guard = lock_gated_scorer_tests().await;
+ ensure_global_index_executor_capacity(4);
+ let (gate, mut started) = scorer_gate();
let inflight =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let peak = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let shared = Arc::new(Semaphore::new(2));
- let run = |budget: SearchBudget| {
+ let run = |query_id: usize, budget: SearchBudget| {
+ let gate = gate.clone();
let inflight = inflight.clone();
let peak = peak.clone();
async move {
- let (segments, active_files, _p, _pk) = n_segment_bucket(2);
- let searcher: Arc<dyn PkVectorAnnSearcher> =
- Arc::new(SharedPeakAnn { inflight, peak });
+ let (segments, active_files, _p, _pk) = n_segment_bucket(2,
None);
+ let searcher: Arc<dyn PkVectorAnnSearcher> =
Arc::new(PeakProbeAnn {
+ inflight,
+ peak,
+ gate: Some(gate),
+ query_id,
+ gated_segment: Some("seg-0"),
+ });
let factory = unreachable_search();
bucket_search(
Some(searcher),
@@ -2891,24 +3018,39 @@ mod tests {
4,
Some(budget),
)
- .await
- .unwrap();
+ .await?;
+ Ok::<_, crate::Error>(())
}
};
// Both searches share the SAME budget semaphore.
- let a = run(SearchBudget::shared_for_test(shared.clone()));
- let b = run(SearchBudget::shared_for_test(shared.clone()));
- tokio::join!(a, b);
+ let a = run(0, SearchBudget::shared_for_test(shared.clone()));
+ let b = run(1, SearchBudget::shared_for_test(shared.clone()));
+ let observe = async {
+ let scorers = wait_for_started_scorers(&mut started, 2).await?;
+ let available_permits = shared.available_permits();
+ let identities = release_started_scorers(scorers);
+ Ok::<_, String>((identities, available_permits))
+ };
+ let (a_out, b_out, observed) = tokio::join!(a, b, observe);
+ let (identities, available_permits) =
+ observed.expect("one scorer from each query must start before
release");
+ a_out.unwrap();
+ b_out.unwrap();
+ assert_eq!(
+ identities,
+ vec![(0, "seg-0".to_string()), (1, "seg-0".to_string())],
+ "the controlled gate must observe scorer overlap across both
queries"
+ );
+ assert_eq!(
+ available_permits, 0,
+ "both shared-budget permits must be held by the gated cross-query
scorers"
+ );
let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
- assert!(
- observed <= 2,
- "shared budget must cap ANN across concurrent searches at 2;
observed {observed}"
- );
- assert!(
- observed >= 2,
- "test must actually exercise cross-search overlap; observed
{observed}"
+ assert_eq!(
+ observed, 2,
+ "shared budget must cap ANN across concurrent searches at exactly
2"
);
}