// 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 .
//! MPP JoinScans whose broadcast build side keeps rows in a mutable segment, run while another
//! connection keeps updating that side. The node that checks visibility resolves the build
//! side's packed `DocAddress`es with a reader it opened itself, and a mutable segment is
//! materialized per open, so unless every reader replays the leader's segment view the
//! addresses one reader packed land in another reader's shorter (or differently ordered)
//! `DocId` space: a bitpacker overflow panic, or a silently wrong ctid.
//!
//! Two probe tables give the two consumer placements. The single-segment probe keeps the join
//! and its visibility check on the leader, whose `categories` reader opens at plan time, well
//! before the workers open theirs; that wide window is what makes this test fail fast without
//! the replay. The multi-segment probe pushes the join into a worker stage, so the resolver is
//! rebuilt in a worker, the shape from the original report.
use anyhow::Result;
use rstest::*;
use sqlx::{AssertSqlSafe, Executor, PgConnection};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tests::fixtures::*;
use tokio::time::sleep;
// The build side (`categories`) has two immutable segments plus the mutable one the writer keeps
// growing, so its scan is distributed and broadcast. `mppc_test` stays in one segment (the
// leader-hosted shape); `mppc_test_multi` spans three (the worker-hosted shape).
const SETUP_SQL: &str = r#"
CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;
CREATE TABLE mppc_categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE mppc_test (
id SERIAL8 NOT NULL PRIMARY KEY,
message TEXT,
category_id INTEGER NOT NULL
);
CREATE TABLE mppc_test_multi (
id SERIAL8 NOT NULL PRIMARY KEY,
message TEXT,
category_id INTEGER NOT NULL
);
CREATE INDEX mppc_test_idx ON mppc_test USING paradedb (id, message, category_id);
CREATE INDEX mppc_test_multi_idx ON mppc_test_multi USING paradedb (id, message, category_id);
-- Zeroed layer sizes keep the merge policy off both paths, so the build side holds its two
-- immutable segments and its mutable one for the whole run. A merge would swap the segment set
-- under the readers and hide what this test measures.
CREATE INDEX mppc_categories_idx ON mppc_categories USING paradedb (id, (name::pdb.literal))
WITH (layer_sizes = '0', background_layer_sizes = '0');
SET paradedb.global_mutable_segment_rows = 0;
INSERT INTO mppc_categories (id, name)
SELECT i, 'category ' || i::text FROM generate_series(1, 50) AS s(i);
INSERT INTO mppc_categories (id, name)
SELECT i, 'category ' || i::text FROM generate_series(51, 100) AS s(i);
INSERT INTO mppc_test (message, category_id)
SELECT (ARRAY['beer wine cheese', 'beer wine', 'beer cheese', 'beer',
'wine cheese', 'wine', 'cheese', 'bread butter'])[1 + (i % 8)] || ' ' || i::text,
1 + (i % 100)
FROM generate_series(1, 5000) AS s(i);
INSERT INTO mppc_test_multi (message, category_id)
SELECT (ARRAY['beer wine cheese', 'beer wine', 'beer cheese', 'beer',
'wine cheese', 'wine', 'cheese', 'bread butter'])[1 + (i % 8)] || ' ' || i::text,
1 + (i % 100)
FROM generate_series(1, 5000) AS s(i);
INSERT INTO mppc_test_multi (message, category_id)
SELECT (ARRAY['beer wine cheese', 'beer wine', 'beer cheese', 'beer',
'wine cheese', 'wine', 'cheese', 'bread butter'])[1 + (i % 8)] || ' ' || i::text,
1 + (i % 100)
FROM generate_series(5001, 10000) AS s(i);
INSERT INTO mppc_test_multi (message, category_id)
SELECT (ARRAY['beer wine cheese', 'beer wine', 'beer cheese', 'beer',
'wine cheese', 'wine', 'cheese', 'bread butter'])[1 + (i % 8)] || ' ' || i::text,
1 + (i % 100)
FROM generate_series(10001, 15000) AS s(i);
RESET paradedb.global_mutable_segment_rows;
-- Start the build side's mutable log before the readers do.
UPDATE mppc_categories SET name = 'category ' || id::text || ' v0' WHERE id <= 10;
ANALYZE mppc_test;
ANALYZE mppc_test_multi;
ANALYZE mppc_categories;
"#;
// Forces the join through MPP and zeroes the parallel costs so the planner always picks it.
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 8;
SET min_parallel_table_scan_size TO 0;
SET parallel_setup_cost TO 0;
SET parallel_tuple_cost TO 0;
"#;
// `t.category_id` rides along so each row can prove which category it joined to. A resolver
// that lands on the wrong address still returns a well-formed name, so only the id catches it.
const LEADER_QUERY: &str = r#"
SELECT t.id, t.category_id, c.name
FROM mppc_test t
JOIN mppc_categories c ON t.category_id = c.id
WHERE t.message ||| 'beer'
ORDER BY t.id
LIMIT 25
"#;
const WORKER_QUERY: &str = r#"
SELECT t.id, t.category_id, c.name
FROM mppc_test_multi t
JOIN mppc_categories c ON t.category_id = c.id
WHERE t.message ||| 'beer'
ORDER BY t.id
LIMIT 25
"#;
// Every category id in 1..=100 exists, so the join never drops a `beer` row and the answer is the
// 25 smallest matching ids whatever the writer does to the category names.
const LEADER_EXPECTED_IDS: &str =
"SELECT id FROM mppc_test WHERE message LIKE '%beer%' ORDER BY id LIMIT 25";
const WORKER_EXPECTED_IDS: &str =
"SELECT id FROM mppc_test_multi WHERE message LIKE '%beer%' ORDER BY id LIMIT 25";
const RUN_FOR: Duration = Duration::from_secs(20);
const READERS: usize = 3;
// Every Nth reader iteration runs the query under EXPLAIN ANALYZE instead, whose output shows
// whether the launch actually went distributed or fell back to serial. Anchored on the FIRST
// iteration: a loaded host may only get through a handful of iterations, and the final assert
// needs at least one sample.
const EXPLAIN_EVERY: usize = 25;
async fn explain(conn: &mut PgConnection, analyze: bool, query: &str) -> Result {
let options = if analyze {
"ANALYZE, VERBOSE, COSTS OFF, TIMING OFF, SUMMARY OFF"
} else {
"VERBOSE, COSTS OFF"
};
let rows: Vec<(String,)> =
sqlx::query_as(AssertSqlSafe(format!("EXPLAIN ({options}) {query}")))
.fetch_all(&mut *conn)
.await?;
Ok(rows
.into_iter()
.map(|(line,)| line)
.collect::>()
.join("\n"))
}
#[rstest]
#[tokio::test]
async fn mpp_joinscan_survives_mutable_build_side_churn(database: Db) -> Result<()> {
let mut setup = database.connection().await;
setup.execute(SETUP_SQL).await?;
setup.execute(MPP_GUCS).await?;
let leader_plan = explain(&mut setup, false, LEADER_QUERY).await?;
assert!(
leader_plan.contains("DistributedExec"),
"the leader-hosted join must plan as DistributedExec:\n{leader_plan}"
);
let worker_plan = explain(&mut setup, false, WORKER_QUERY).await?;
assert!(
worker_plan.contains("DistributedExec") && worker_plan.contains("Stage 2"),
"the worker-hosted join must plan with the join in a worker stage:\n{worker_plan}"
);
let expected_leader: Vec = sqlx::query_scalar(LEADER_EXPECTED_IDS)
.fetch_all(&mut setup)
.await?;
let expected_worker: Vec = sqlx::query_scalar(WORKER_EXPECTED_IDS)
.fetch_all(&mut setup)
.await?;
assert_eq!(expected_leader.len(), 25);
assert_eq!(expected_worker.len(), 25);
let stop = Arc::new(AtomicBool::new(false));
let queries = Arc::new(AtomicUsize::new(0));
let updates = Arc::new(AtomicUsize::new(0));
let distributed = Arc::new(AtomicUsize::new(0));
let failures = Arc::new(std::sync::Mutex::new(Vec::::new()));
// One writer keeps the build side's mutable segment growing: every UPDATE appends to its
// add/remove log, which is what moves the segment's `DocId` space between two opens.
let writer = {
let mut conn = database.connection().await;
let stop = Arc::clone(&stop);
let updates = Arc::clone(&updates);
tokio::spawn(async move {
let mut n = 0u64;
while !stop.load(Ordering::Relaxed) {
n += 1;
let sql = format!(
"UPDATE mppc_categories SET name = 'category ' || id::text || ' v{n}' \
WHERE id <= 10"
);
if let Err(e) = conn.execute(AssertSqlSafe(sql)).await {
panic!("writer failed: {e}");
}
updates.fetch_add(1, Ordering::Relaxed);
// Yield so the writer doesn't starve the readers on a loaded host; the race
// window only needs the log to move between two opens, not a firehose.
sleep(Duration::from_millis(5)).await;
}
})
};
let mut readers = Vec::new();
for reader_id in 0..READERS {
let mut conn = database.connection().await;
conn.execute(MPP_GUCS).await?;
let stop = Arc::clone(&stop);
let queries = Arc::clone(&queries);
let distributed = Arc::clone(&distributed);
let failures = Arc::clone(&failures);
let expected_leader = expected_leader.clone();
let expected_worker = expected_worker.clone();
readers.push(tokio::spawn(async move {
let mut i = 0usize;
while !stop.load(Ordering::Relaxed) {
i += 1;
let (query, expected) = if i.is_multiple_of(2) {
(LEADER_QUERY, &expected_leader)
} else {
(WORKER_QUERY, &expected_worker)
};
if i % EXPLAIN_EVERY == 1 {
// EXPLAIN ANALYZE executes the query too; a serial fallback shows a plan
// without DistributedExec. Count rather than fail: a starved host may
// legitimately fall back sometimes, but never going distributed at all
// means this test exercised nothing.
match explain(&mut conn, true, query).await {
Ok(plan) if plan.contains("DistributedExec") => {
distributed.fetch_add(1, Ordering::Relaxed);
}
Ok(_) => {}
Err(e) => failures
.lock()
.unwrap()
.push(format!("reader {reader_id}: explain analyze failed: {e}")),
}
queries.fetch_add(1, Ordering::Relaxed);
continue;
}
let rows: Result, _> =
sqlx::query_as(query).fetch_all(&mut conn).await;
match rows {
Err(e) => failures
.lock()
.unwrap()
.push(format!("reader {reader_id}: query failed: {e}")),
Ok(rows) => {
let ids: Vec = rows.iter().map(|(id, _, _)| *id).collect();
if &ids != expected {
failures.lock().unwrap().push(format!(
"reader {reader_id}: ids {ids:?} != expected {expected:?}"
));
}
// The writer only appends a ` v` suffix, so the id prefix stays.
for (id, category_id, name) in &rows {
let expected_name = format!("category {category_id}");
if *name != expected_name
&& !name.starts_with(&format!("{expected_name} v"))
{
failures.lock().unwrap().push(format!(
"reader {reader_id}: row {id} wanted category \
{category_id} but joined {name:?}"
));
}
}
}
}
queries.fetch_add(1, Ordering::Relaxed);
}
}));
}
let start = Instant::now();
while start.elapsed() < RUN_FOR {
sleep(Duration::from_millis(200)).await;
if !failures.lock().unwrap().is_empty() {
break;
}
}
stop.store(true, Ordering::Relaxed);
for reader in readers {
reader.await?;
}
writer.await?;
let failures = failures.lock().unwrap().clone();
assert!(
failures.is_empty(),
"{} of {} MPP join queries failed under {} concurrent updates; first: {}",
failures.len(),
queries.load(Ordering::Relaxed),
updates.load(Ordering::Relaxed),
failures[0]
);
assert!(
queries.load(Ordering::Relaxed) > 0 && updates.load(Ordering::Relaxed) > 0,
"the readers and the writer must both have run"
);
assert!(
distributed.load(Ordering::Relaxed) > 0,
"no query ran distributed; the test exercised nothing (queries={}, updates={})",
queries.load(Ordering::Relaxed),
updates.load(Ordering::Relaxed)
);
Ok(())
}