use crate::split_tests::framework::InconclusiveError;
use super::framework;
use std::error::Error;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant};
use azure_data_cosmos::feed::ContinuationToken;
use azure_data_cosmos::options::{CreateContainerOptions, ReadFeedRangesOptions};
use azure_data_cosmos::{
clients::ContainerClient,
feed::FeedScope,
models::{ContainerProperties, ThroughputProperties},
options::{MaxItemCountHint, QueryOptions},
};
use framework::{MockItem, TestClient, TestOptions};
use futures::{StreamExt, TryStreamExt};
const SPLIT_POLL_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const SPLIT_POLL_INTERVAL: Duration = Duration::from_secs(15);
async fn force_split_and_wait(
container_client: &ContainerClient,
starting_partitions: usize,
) -> Result<usize, Box<dyn Error>> {
let split_start = Instant::now();
let new_throughput = ThroughputProperties::manual(13000);
let mut poller = container_client
.begin_replace_throughput(new_throughput, None)
.await?;
println!("Throughput update initiated, polling for completion...");
let mut last_throughput = None;
let mut poll_count = 0;
while let Some(status) = poller.try_next().await? {
if split_start.elapsed() >= SPLIT_POLL_TIMEOUT {
return Err(InconclusiveError::SplitNotCompleted.into());
}
assert!(status.status().is_success());
last_throughput = Some(status.into_model()?);
if poll_count % 15 == 0 {
println!(
"Throughput update in progress... polled {} times, last observed throughput: {} RU/s",
poll_count,
last_throughput.as_ref().and_then(|t| t.throughput()).unwrap_or(0)
);
}
poll_count += 1;
}
let final_throughput =
last_throughput.expect("throughput poller should have yielded at least one response");
assert_eq!(Some(13000), final_throughput.throughput());
println!(
"Throughput update completed, new throughput: {} RU/s",
final_throughput.throughput().unwrap_or(0)
);
let mut iterations = 0;
let observed_ranges = loop {
let ranges = container_client
.read_feed_ranges(Some(
ReadFeedRangesOptions::default().with_force_refresh(true),
))
.await?;
if ranges.len() > starting_partitions {
break ranges;
}
if split_start.elapsed() >= SPLIT_POLL_TIMEOUT {
return Err(InconclusiveError::SplitNotCompleted.into());
}
if iterations % 4 == 0 {
println!(
"Waiting for split to complete... elapsed: {:?}, last observed physical partition count: {}",
split_start.elapsed(),
ranges.len()
);
}
tokio::time::sleep(SPLIT_POLL_INTERVAL).await;
iterations += 1;
};
println!(
"Split observed after {:?}; physical partition count: {}",
split_start.elapsed(),
observed_ranges.len()
);
Ok(observed_ranges.len())
}
#[tokio::test]
#[cfg_attr(
not(test_category = "split"),
ignore = "requires test_category 'split'"
)]
pub async fn query_resume_across_split_covers_both_snapshot_shapes() -> Result<(), Box<dyn Error>> {
const PK_COUNT: usize = 50;
const ITEMS_PER_PK: usize = 10;
const PAGE_SIZE: u32 = 10;
const PAGES_FOR_TOKEN_B: usize = 5;
TestClient::run_with_unique_db(
async |run_context, db_client| {
let properties =
ContainerProperties::new("QueryResumeAcrossSplit", "/partitionKey".into());
let throughput = ThroughputProperties::manual(1000);
let container_client = Arc::new(
run_context
.create_container(
db_client,
properties,
Some(CreateContainerOptions::default().with_throughput(throughput)),
)
.await?,
);
println!(
"Container created; seeding {PK_COUNT} partition keys × {ITEMS_PER_PK} items"
);
let mut expected_ids: Vec<String> = Vec::new();
for p in 0..PK_COUNT {
let partition_key = format!("pk{p}");
for i in 0..ITEMS_PER_PK {
let item = MockItem {
id: format!("{p}-{i}"),
partition_key: partition_key.clone(),
merge_order: p * ITEMS_PER_PK + i,
};
expected_ids.push(item.id.clone());
container_client
.create_item(item.partition_key.clone(), &item.id.clone(), item, None)
.await?;
}
}
let ranges_before = container_client.read_feed_ranges(None).await?;
assert!(
!ranges_before.is_empty(),
"expected at least one physical partition before split, got {}",
ranges_before.len()
);
let partitions_before = ranges_before.len();
let mut token_a_collected: Vec<String> = Vec::new();
let mut token_b_collected: Vec<String> = Vec::new();
let (token_a, token_b) = {
let options = QueryOptions::default().with_max_item_count(
MaxItemCountHint::Limit(NonZeroU32::new(PAGE_SIZE).unwrap()),
);
let mut pages = container_client
.query_items::<MockItem>(
"SELECT * FROM c",
FeedScope::full_container(),
Some(options),
)
.await?
.into_pages();
let first_page = pages
.next()
.await
.expect("query should yield at least one page before split")?;
for item in first_page.into_items() {
token_a_collected.push(item.id);
}
let token_a_serialized = pages.to_continuation_token()?.as_str().to_owned();
token_b_collected.extend(token_a_collected.iter().cloned());
for page_idx in 1..PAGES_FOR_TOKEN_B {
let Some(page) = pages.next().await else {
panic!(
"pre-split drain expected at least {PAGES_FOR_TOKEN_B} pages but stopped at {page_idx}"
);
};
let page = page?;
for item in page.into_items() {
token_b_collected.push(item.id);
}
}
let token_b_serialized = pages.to_continuation_token()?.as_str().to_owned();
(
ContinuationToken::from_string(token_a_serialized),
ContinuationToken::from_string(token_b_serialized),
)
};
println!(
"Captured token A after {} items, token B after {} items; forcing split",
token_a_collected.len(),
token_b_collected.len()
);
let partitions_after =
force_split_and_wait(&container_client, partitions_before).await?;
assert!(
partitions_after > partitions_before,
"split must increase partition count: before={partitions_before}, after={partitions_after}"
);
drain_from_token(
&container_client,
token_a,
PAGE_SIZE,
&mut token_a_collected,
)
.await?;
assert_drained_matches_expected(&token_a_collected, &expected_ids, "token A");
drain_from_token(
&container_client,
token_b,
PAGE_SIZE,
&mut token_b_collected,
)
.await?;
assert_drained_matches_expected(&token_b_collected, &expected_ids, "token B");
Ok(())
},
Some(TestOptions::new().with_timeout(Duration::from_secs(40 * 60))),
)
.await
}
async fn drain_from_token(
container_client: &ContainerClient,
start_token: ContinuationToken,
page_size: u32,
collected: &mut Vec<String>,
) -> Result<(), Box<dyn Error>> {
let mut continuation = Some(start_token);
loop {
let mut resume_options = QueryOptions::default()
.with_max_item_count(MaxItemCountHint::Limit(NonZeroU32::new(page_size).unwrap()));
if let Some(token) = continuation.take() {
resume_options = resume_options.with_continuation_token(token);
}
let mut pages = container_client
.query_items::<MockItem>(
"SELECT * FROM c",
FeedScope::full_container(),
Some(resume_options),
)
.await?
.into_pages();
let Some(page) = pages.next().await else {
break;
};
let page = page?;
for item in page.into_items() {
collected.push(item.id);
}
let token = pages.to_continuation_token()?;
let serialized = token.as_str().to_owned();
drop(pages);
continuation = Some(ContinuationToken::from_string(serialized));
}
Ok(())
}
fn assert_drained_matches_expected(collected: &[String], expected: &[String], label: &str) {
let mut collected_sorted = collected.to_vec();
collected_sorted.sort();
let mut expected_sorted = expected.to_vec();
expected_sorted.sort();
assert_eq!(
collected.len(),
expected.len(),
"{label}: collected {} items but expected {} (duplicates or losses across split)",
collected.len(),
expected.len()
);
assert_eq!(
collected_sorted, expected_sorted,
"{label}: items collected across split should match the seeded ground truth (order-independent)"
);
}