// 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 .
//! A cancel or `pg_terminate_backend` taken while an MPP query runs used to crash the leader during
//! teardown (a die serviced from inside the tokio `block_on`, or DSM control senders dropped after
//! the parallel segment was already detached), which resets the whole cluster. These tests signal a
//! running MPP query from a second connection and assert a witness connection kept open the whole
//! time survives — a cluster reset would have dropped it.
use anyhow::Result;
use rstest::*;
use sqlx::{Executor, PgConnection};
use std::time::{Duration, Instant};
use tests::fixtures::*;
use tokio::time::sleep;
// Three 20k-row tables. `age` is in [0,50), so `users.age = products.age` fans out to millions of
// intermediate rows. That scan-dominated join keeps the leader inside its MPP `block_on` long
// enough for the killer to land a signal mid-flight. A small join can finish before the signal and
// miss the teardown bug entirely.
const SETUP_SQL: &str = r#"
CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;
CREATE TABLE mpp_users (id bigserial primary key, uuid uuid, name text, age int, category text);
CREATE TABLE mpp_products (id bigserial primary key, uuid uuid, name text, age int);
CREATE TABLE mpp_orders (id bigserial primary key, uuid uuid, name text, age int);
CREATE INDEX mpp_users_idx ON mpp_users USING bm25 (id, uuid, name, age, category)
WITH (key_field='id', text_fields='{"uuid":{"tokenizer":{"type":"keyword"},"fast":true},"name":{"tokenizer":{"type":"keyword"},"fast":true},"category":{"tokenizer":{"type":"keyword"},"fast":true}}', numeric_fields='{"age":{"fast":true}}');
CREATE INDEX mpp_products_idx ON mpp_products USING bm25 (id, uuid, name, age)
WITH (key_field='id', text_fields='{"uuid":{"tokenizer":{"type":"keyword"},"fast":true},"name":{"tokenizer":{"type":"keyword"},"fast":true}}', numeric_fields='{"age":{"fast":true}}');
CREATE INDEX mpp_orders_idx ON mpp_orders USING bm25 (id, uuid, name, age)
WITH (key_field='id', text_fields='{"uuid":{"tokenizer":{"type":"keyword"},"fast":true},"name":{"tokenizer":{"type":"keyword"},"fast":true}}', numeric_fields='{"age":{"fast":true}}');
SET paradedb.global_mutable_segment_rows = 0;
INSERT INTO mpp_users (uuid, name, age, category)
SELECT gen_random_uuid(), (ARRAY['bob','alice','carol','dave'])[1 + (g % 4)], g % 50, 'c' || (g % 5)
FROM generate_series(1, 10000) AS g;
INSERT INTO mpp_users (uuid, name, age, category)
SELECT gen_random_uuid(), (ARRAY['bob','alice','carol','dave'])[1 + (g % 4)], g % 50, 'c' || (g % 5)
FROM generate_series(10001, 20000) AS g;
INSERT INTO mpp_products (uuid, name, age)
SELECT gen_random_uuid(), (ARRAY['bob','alice'])[1 + (g % 2)], g % 50
FROM generate_series(1, 10000) AS g;
INSERT INTO mpp_products (uuid, name, age)
SELECT gen_random_uuid(), (ARRAY['bob','alice'])[1 + (g % 2)], g % 50
FROM generate_series(10001, 20000) AS g;
INSERT INTO mpp_orders (uuid, name, age)
SELECT gen_random_uuid(), 'x', g % 50
FROM generate_series(1, 10000) AS g;
INSERT INTO mpp_orders (uuid, name, age)
SELECT gen_random_uuid(), 'x', g % 50
FROM generate_series(10001, 20000) AS g;
RESET paradedb.global_mutable_segment_rows;
ANALYZE mpp_users;
ANALYZE mpp_products;
ANALYZE mpp_orders;
"#;
// Forces the join through MPP and zeroes the parallel costs so the planner always picks it.
// MPP width comes from PG's own parallelism GUCs (#5667): per_gather = 2 caps the launch at
// 2 producers, matching this test's 2-segment tables.
const MPP_GUCS: &str = r#"
SET paradedb.enable_join_custom_scan TO on;
SET max_parallel_workers_per_gather TO 2;
SET max_parallel_workers TO 8;
SET min_parallel_table_scan_size TO 0;
SET parallel_setup_cost TO 0;
SET parallel_tuple_cost TO 0;
SET work_mem TO '512MB';
"#;
// The top-level ORDER BY ... LIMIT is what makes the join JoinScan-eligible; together with the
// `age` fan-out above it plans as `Custom Scan (ParadeDB Join Scan)` -> `DistributedExec` and runs
// long enough to be signalled mid-flight. (The `orders` uuids don't match `products`, so the result
// is empty — irrelevant; the point is the in-flight join work, not the rows.)
const MPP_QUERY: &str = r#"
SELECT mpp_users.id, mpp_users.name, mpp_users.age, mpp_products.age
FROM mpp_users JOIN mpp_products ON mpp_users.age = mpp_products.age
JOIN mpp_orders ON mpp_products.uuid = mpp_orders.uuid
WHERE NOT ((mpp_users.name @@@ 'bob') AND (mpp_users.name @@@ 'bob'))
AND mpp_users.age >= mpp_products.age
ORDER BY mpp_users.id LIMIT 31
"#;
// The crash needs the leader caught mid-`block_on` with its DSM senders live, not during planning
// or worker launch. After the backend first shows up `active`, wait briefly so execution gets well
// inside the join before signalling. A few attempts make the test robust against host timing; on
// the fixed build every attempt is a clean teardown.
const SIGNAL_DELAY: Duration = Duration::from_millis(800);
const ATTEMPTS: usize = 3;
const VICTIM_LOOP_TIMEOUT: Duration = Duration::from_secs(30);
async fn assert_query_plans_as_mpp(conn: &mut PgConnection) -> Result<()> {
conn.execute(MPP_GUCS).await?;
let rows: Vec<(String,)> = sqlx::query_as(&format!("EXPLAIN (COSTS OFF, VERBOSE) {MPP_QUERY}"))
.fetch_all(&mut *conn)
.await?;
let explain = rows
.into_iter()
.map(|(line,)| line)
.collect::>()
.join("\n");
assert!(
explain.contains("DistributedExec"),
"MPP cancel test query must plan as DistributedExec:\n{explain}"
);
Ok(())
}
/// Spin until `victim_app`'s backend is running the MPP query, then run `signal_fn(pid)`
/// (`pg_cancel_backend` or `pg_terminate_backend`) on it. Returns the pid that was signalled.
async fn signal_running_mpp_backend(
killer: &mut PgConnection,
victim_app: &str,
signal_fn: &str,
) -> Result {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
// Parallel workers inherit the leader's `application_name`, so pin to the client backend
// to signal the leader (the connection running the top-level query), not a worker.
let pid: Option = sqlx::query_scalar(
"SELECT pid FROM pg_stat_activity \
WHERE application_name = $1 AND backend_type = 'client backend' \
AND state = 'active' AND query LIKE '%mpp_users.age = mpp_products.age%'",
)
.bind(victim_app)
.fetch_optional(&mut *killer)
.await?;
if let Some(pid) = pid {
// Let execution get well inside the MPP join (senders live) before signalling.
sleep(SIGNAL_DELAY).await;
sqlx::query(&format!("SELECT {signal_fn}($1)"))
.bind(pid)
.execute(&mut *killer)
.await?;
return Ok(pid);
}
if Instant::now() >= deadline {
anyhow::bail!("victim backend never showed up active running the MPP query");
}
sleep(Duration::from_millis(5)).await;
}
}
/// Run the MPP query in a loop on `victim` until its connection or query is cut off. The loop keeps
/// the backend busy so the killer has a wide window to land the signal mid-query.
async fn run_until_signalled(mut victim: PgConnection, app_name: String) -> Result<()> {
if victim
.execute(format!("SET application_name = '{app_name}';").as_str())
.await
.is_err()
{
return Ok(());
}
if victim.execute(MPP_GUCS).await.is_err() {
return Ok(());
}
// A terminate drops the connection and a cancel errors the in-flight query; if the signal
// misses, the deadline turns the would-be hang into a test failure.
let deadline = Instant::now() + VICTIM_LOOP_TIMEOUT;
while victim.execute(MPP_QUERY).await.is_ok() {
if Instant::now() >= deadline {
anyhow::bail!(
"victim query loop was not interrupted within {:?}; the signal likely missed",
VICTIM_LOOP_TIMEOUT
);
}
}
Ok(())
}
async fn assert_cluster_alive(witness: &mut PgConnection) -> Result<()> {
// A backend crash during teardown makes the postmaster reset the cluster, which would have
// killed this long-lived connection. If it still answers, no reset happened.
let one: i32 = sqlx::query_scalar("SELECT 1")
.fetch_one(&mut *witness)
.await?;
assert_eq!(one, 1, "witness connection lost: the backend crashed");
Ok(())
}
#[rstest]
#[tokio::test]
async fn mpp_signal_does_not_crash_backend(database: Db) -> Result<()> {
let mut setup = database.connection().await;
setup.execute(SETUP_SQL).await?;
// Opened before any signal and reused after each, so a cluster reset would surface as a failure
// on this connection.
let mut witness = database.connection().await;
witness
.execute("CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;")
.await?;
assert_cluster_alive(&mut witness).await?;
assert_query_plans_as_mpp(&mut setup).await?;
for (app, signal_fn) in [
("mpp_sig_cancel", "pg_cancel_backend"),
("mpp_sig_terminate", "pg_terminate_backend"),
] {
for attempt in 0..ATTEMPTS {
let app_name = format!("{app}_{attempt}");
let victim = database.connection().await;
let loop_handle = tokio::spawn(run_until_signalled(victim, app_name.clone()));
let mut killer = database.connection().await;
signal_running_mpp_backend(&mut killer, &app_name, signal_fn).await?;
loop_handle.await??;
assert_cluster_alive(&mut witness).await?;
}
}
Ok(())
}