// Copyright (c) 2023-2026 ParadeDB, Inc.
//
// This file is part of ParadeDB - Postgres for Search and Analytics
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
use anyhow::Result;
use rstest::*;
use sqlx::Executor;
use tests::fixtures::*;
const SETUP_SQL: &str = r#"
CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;
CREATE TABLE mem_a (id bigint PRIMARY KEY, state text);
CREATE TABLE mem_b (id bigint PRIMARY KEY, aid bigint, u text);
CREATE INDEX mem_a_idx ON mem_a USING bm25 (id, state)
WITH (key_field = 'id', target_segment_count = '3', mutable_segment_rows = '0');
CREATE INDEX mem_b_idx ON mem_b USING bm25 (id, aid, u)
WITH (key_field = 'id', target_segment_count = '3');
INSERT INTO mem_a SELECT g, 'active' FROM generate_series(1, 50000) g;
INSERT INTO mem_a SELECT g, 'merged' FROM generate_series(50001, 100000) g;
INSERT INTO mem_b SELECT g, g, 'u1' FROM generate_series(1, 100000) g;
"#;
const MPP_GUCS: &str = r#"
SET paradedb.enable_join_custom_scan TO on;
-- The fixture tables are tiny; disable the size gate so MPP engages.
SET paradedb.mpp_min_rows TO 0;
SET max_parallel_workers_per_gather TO 2;
SET max_parallel_workers TO 2;
SET min_parallel_table_scan_size TO 0;
SET parallel_setup_cost TO 0;
SET parallel_tuple_cost TO 0;
SET paradedb.mpp_test_panic_in_worker TO on;
"#;
const MPP_QUERY: &str = r#"
SELECT count(*)
FROM mem_b le
JOIN mem_a sv ON sv.id = le.aid
WHERE le.id @@@ paradedb.term('u', 'u1')
AND sv.id @@@ paradedb.term('state', 'merged')
"#;
#[rstest]
#[tokio::test]
async fn mpp_error_masking(database: Db) -> Result<()> {
let mut setup = database.connection().await;
setup.execute(SETUP_SQL).await?;
let guc_exists: Option<(String,)> = sqlx::query_as(
"SELECT name FROM pg_settings WHERE name = 'paradedb.mpp_test_panic_in_worker'",
)
.fetch_optional(&mut setup)
.await?;
if guc_exists.is_none() {
println!(
"Skipping mpp_error_masking: paradedb.mpp_test_panic_in_worker GUC is not present (non-debug build)"
);
return Ok(());
}
setup.execute(MPP_GUCS).await?;
let iterations = 10;
let mut real_error_count = 0;
let mut masked_error_count = 0;
let explain_rows: Vec<(String,)> = sqlx::query_as(&format!("EXPLAIN {}", MPP_QUERY))
.fetch_all(&mut setup)
.await
.unwrap();
let explain_str = explain_rows
.into_iter()
.map(|(r,)| r)
.collect::>()
.join("\n");
println!("EXPLAIN output:\n{explain_str}");
assert!(
explain_str.contains("DistributedExec") && explain_str.contains("PgSearchScan"),
"Expected EXPLAIN plan to use MPP (DistributedExec & PgSearchScan), got:\n{explain_str}"
);
for i in 0..iterations {
let res = setup.execute(MPP_QUERY).await;
if let Err(e) = res {
let err_str = e.to_string().to_lowercase();
println!("Iteration {}: got error: {}", i, err_str);
if err_str.contains("artificial panic") {
real_error_count += 1;
} else if err_str.contains("transport receiver detached") {
masked_error_count += 1;
} else {
panic!("Unexpected error: {e}");
}
} else {
panic!("Expected query to fail due to panic, but it succeeded");
}
}
assert_eq!(
masked_error_count, 0,
"Failed: observed {masked_error_count} masked errors ('transport receiver detached') and {real_error_count} real errors."
);
assert_eq!(
real_error_count, iterations,
"Expected all {iterations} iterations to return the real error."
);
Ok(())
}