#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stdout,
clippy::print_stderr
)]
use std::sync::Arc;
use arrow::array::{Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::execution::memory_pool::GreedyMemoryPool;
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
use datafusion::execution::session_state::SessionStateBuilder;
use datafusion::prelude::{SessionConfig, SessionContext};
use krishiv_sql::SqlEngine;
const ROWS_PER_BATCH: usize = 2_000;
const NUM_BATCHES: usize = 25;
const PAYLOAD_LEN: usize = 1024;
const TINY_POOL_BYTES: usize = 2 * 1024 * 1024;
fn make_batches(seed_offset: i64) -> Vec<RecordBatch> {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("group_key", DataType::Int64, false),
Field::new("payload", DataType::Utf8, false),
]));
(0..NUM_BATCHES)
.map(|batch_idx| {
let ids: Vec<i64> = (0..ROWS_PER_BATCH)
.map(|i| seed_offset + (batch_idx * ROWS_PER_BATCH + i) as i64)
.collect();
let ids_desc: Vec<i64> = ids.iter().rev().copied().collect();
let groups: Vec<i64> = ids_desc.iter().map(|id| id % 50).collect();
let payload = "x".repeat(PAYLOAD_LEN);
let payloads: Vec<String> = ids_desc
.iter()
.map(|id| format!("{payload}-{id}"))
.collect();
RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int64Array::from(ids_desc)),
Arc::new(Int64Array::from(groups)),
Arc::new(StringArray::from(payloads)),
],
)
.expect("record batch construction")
})
.collect()
}
fn total_rows() -> usize {
ROWS_PER_BATCH * NUM_BATCHES
}
#[tokio::test]
async fn sort_spills_under_tiny_memory_pool_and_stays_correct() {
let engine = SqlEngine::new_with_memory_limit(Some(TINY_POOL_BYTES));
engine
.register_record_batches("t", make_batches(0))
.await
.expect("register table");
let df = engine
.sql("SELECT id FROM t ORDER BY id ASC")
.await
.expect("sort query should plan under a tiny memory pool");
let batches = df.collect().await.expect(
"sort should complete by spilling to disk instead of exhausting the tiny memory pool",
);
let ids: Vec<i64> = batches
.iter()
.flat_map(|b| {
b.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("id column is Int64")
.values()
.to_vec()
})
.collect();
assert_eq!(ids.len(), total_rows(), "no rows lost across the spill");
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(ids, sorted, "output must be in ascending order post-spill");
assert_eq!(
ids.first().copied(),
Some(0),
"first row must be the true minimum, not a partially-merged spill artifact"
);
assert_eq!(ids.last().copied(), Some((total_rows() - 1) as i64));
}
#[tokio::test]
async fn grouped_aggregate_spills_under_tiny_memory_pool_and_stays_correct() {
let engine = SqlEngine::new_with_memory_limit(Some(TINY_POOL_BYTES));
engine
.register_record_batches("t", make_batches(0))
.await
.expect("register table");
let df = engine
.sql("SELECT group_key, COUNT(*) AS n FROM t GROUP BY group_key ORDER BY group_key")
.await
.expect("aggregate query should plan under a tiny memory pool");
let batches = df
.collect()
.await
.expect("aggregation should complete by spilling groups to disk");
let mut group_keys = Vec::new();
let mut counts = Vec::new();
for b in &batches {
group_keys.extend(
b.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("group_key is Int64")
.values()
.to_vec(),
);
counts.extend(
b.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("count is Int64")
.values()
.to_vec(),
);
}
assert_eq!(
group_keys.len(),
50,
"exactly 50 distinct group_key values (id % 50)"
);
let expected_per_group = (total_rows() / 50) as i64;
for (key, count) in group_keys.iter().zip(counts.iter()) {
assert_eq!(
*count, expected_per_group,
"group {key} count wrong post-spill (every group must be evenly sized by construction)"
);
}
}
#[tokio::test]
async fn hash_join_spills_under_tiny_memory_pool_and_stays_correct() {
let engine = SqlEngine::new_with_memory_limit(Some(TINY_POOL_BYTES));
engine
.register_record_batches("left_t", make_batches(0))
.await
.expect("register left table");
engine
.register_record_batches("right_t", make_batches(0))
.await
.expect("register right table");
let df = engine
.sql("SELECT COUNT(*) AS n FROM left_t l JOIN right_t r ON l.id = r.id")
.await
.expect("join query should plan under a tiny memory pool");
let batches = df
.collect()
.await
.expect("hash join build side should spill to disk instead of exhausting the tiny pool");
let n: i64 = batches
.iter()
.map(|b| {
b.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("count is Int64")
.value(0)
})
.sum();
assert_eq!(
n,
total_rows() as i64,
"every row must join exactly once on the shared id space post-spill"
);
}
#[tokio::test]
async fn same_workload_fails_fast_without_a_spilling_pool() {
let runtime_env = RuntimeEnvBuilder::new()
.with_memory_pool(Arc::new(GreedyMemoryPool::new(TINY_POOL_BYTES)))
.build_arc()
.expect("build greedy-pool runtime");
let state = SessionStateBuilder::new()
.with_default_features()
.with_config(SessionConfig::new())
.with_runtime_env(runtime_env)
.build();
let ctx = SessionContext::new_with_state(state);
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("group_key", DataType::Int64, false),
Field::new("payload", DataType::Utf8, false),
]));
let batches = make_batches(0);
let mem_table = datafusion::datasource::MemTable::try_new(schema, vec![batches])
.expect("mem table construction");
ctx.register_table("t", Arc::new(mem_table))
.expect("register table");
let df = ctx
.sql("SELECT id FROM t ORDER BY id ASC")
.await
.expect("query should plan (the pool is only checked during execution)");
let result = df.collect().await;
assert!(
result.is_err(),
"a non-spilling pool this tiny must fail on a dataset this large — \
if it succeeds, TINY_POOL_BYTES/dataset size need to be revisited \
so the positive spill tests remain meaningful"
);
let message = result.unwrap_err().to_string();
assert!(
message.to_lowercase().contains("resources exhausted")
|| message.to_lowercase().contains("memory"),
"expected a resource/memory exhaustion error, got: {message}"
);
}
#[tokio::test]
async fn concurrent_queries_share_the_pool_without_corrupting_results() {
let engine = SqlEngine::new_with_memory_limit(Some(TINY_POOL_BYTES));
engine
.register_record_batches("t", make_batches(0))
.await
.expect("register table");
let sort_engine = engine.clone();
let agg_engine = engine.clone();
let (sort_result, agg_result) = tokio::join!(
async move {
sort_engine
.sql("SELECT id FROM t ORDER BY id ASC")
.await
.expect("sort should plan")
.collect()
.await
},
async move {
agg_engine
.sql("SELECT group_key, COUNT(*) AS n FROM t GROUP BY group_key")
.await
.expect("aggregate should plan")
.collect()
.await
},
);
let sort_batches = sort_result
.expect("sort should complete even while sharing the pool with a concurrent aggregate");
let agg_batches = agg_result
.expect("aggregate should complete even while sharing the pool with a concurrent sort");
let sort_rows: usize = sort_batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(sort_rows, total_rows());
let agg_groups: usize = agg_batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(agg_groups, 50);
}