Skip to content

Commit 4723113

Browse files
[ArrowFlight] Align test mock server with actual server ack behavior. (#537)
1 parent ba70127 commit 4723113

2 files changed

Lines changed: 112 additions & 30 deletions

File tree

rust/tests/src/arrow_tests.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,84 @@ mod arrow_flight_tests {
825825
mod recovery_tests {
826826
use super::*;
827827

828+
/// Regression test for the mock contract: `ack_up_to_records` must be
829+
/// connection-relative. A retriable error forces a second DoPut connection
830+
/// for the same table; the auto-ack on that connection must count only the
831+
/// rows replayed on it, NOT the cumulative rows across both connections.
832+
/// Under the old global row counter the auto-ack would be 6 (3 + 3) and this
833+
/// test would fail, masking slicing/replay bugs.
834+
#[tokio::test]
835+
async fn test_auto_ack_is_connection_relative() -> Result<(), Box<dyn std::error::Error>> {
836+
setup_tracing();
837+
info!("Starting test_auto_ack_is_connection_relative");
838+
839+
let (mock_server, server_url) = start_mock_flight_server().await?;
840+
let schema = create_test_arrow_schema();
841+
842+
// First connection errors (retriable) after the batch is decoded; the
843+
// batch stays unacked and is replayed on the recovered connection, where
844+
// responses are exhausted so it hits the auto-ack path.
845+
mock_server
846+
.inject_responses(
847+
TABLE_NAME,
848+
vec![MockFlightResponse::Error {
849+
status: tonic::Status::unavailable("Temporary network issue"),
850+
delay_ms: 0,
851+
}],
852+
)
853+
.await;
854+
855+
let sdk = ZerobusSdk::builder()
856+
.endpoint(server_url.clone())
857+
.unity_catalog_url("https://mock-uc.com")
858+
.tls_config(Arc::new(NoTlsConfig))
859+
.build()?;
860+
861+
let stream = sdk
862+
.stream_builder()
863+
.table(TABLE_NAME)
864+
.headers_provider(Arc::new(TestHeadersProvider::default()))
865+
.arrow(schema.clone())
866+
.recovery(true)
867+
.recovery_timeout_ms(5000)
868+
.recovery_backoff_ms(100)
869+
.recovery_retries(3)
870+
.build_arrow()
871+
.await?;
872+
873+
// One 3-row batch. It is decoded on connection 1 (errors), then replayed
874+
// and decoded again on connection 2 (auto-acked).
875+
let batch = create_test_record_batch(
876+
schema.clone(),
877+
vec![1, 2, 3],
878+
vec![Some("a"), Some("b"), Some("c")],
879+
);
880+
let offset = stream.ingest_batch(batch).await?;
881+
stream.wait_for_offset(offset).await?;
882+
883+
// Both connections decoded the 3-row batch, so the GLOBAL observation is 6.
884+
assert_eq!(
885+
mock_server.get_total_records_received().await,
886+
6,
887+
"both connections should have decoded the batch (global observation)"
888+
);
889+
890+
// But every auto-ack must be connection-relative: exactly the 3 rows
891+
// replayed on the recovered connection, never the cumulative 6.
892+
let acks = mock_server.get_auto_ack_records().await;
893+
assert!(
894+
!acks.is_empty(),
895+
"expected an auto-ack on the recovered connection"
896+
);
897+
assert!(
898+
acks.iter().all(|&r| r == 3),
899+
"auto-ack must exclude rows from the first connection (expected all == 3), got {:?}",
900+
acks
901+
);
902+
903+
Ok(())
904+
}
905+
828906
#[tokio::test]
829907
async fn test_supervisor_recovery_after_retriable_error(
830908
) -> Result<(), Box<dyn std::error::Error>> {

rust/tests/src/mock_arrow_flight.rs

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ pub struct MockFlightServer {
8888
row_count: Arc<Mutex<u64>>,
8989
/// Track response index across connection attempts
9090
response_indices: Arc<Mutex<HashMap<String, usize>>>,
91-
/// Track expected offset per table (must be strictly sequential starting from 0)
92-
expected_offsets: Arc<Mutex<HashMap<String, i64>>>,
91+
/// Observation of every `ack_up_to_records` value emitted on the auto-ack path,
92+
/// in emission order. Used by tests to assert acks are connection-relative.
93+
auto_ack_records: Arc<Mutex<Vec<u64>>>,
9394
}
9495

9596
impl MockFlightServer {
@@ -100,7 +101,7 @@ impl MockFlightServer {
100101
batch_count: Arc::new(Mutex::new(0)),
101102
row_count: Arc::new(Mutex::new(0)),
102103
response_indices: Arc::new(Mutex::new(HashMap::new())),
103-
expected_offsets: Arc::new(Mutex::new(HashMap::new())),
104+
auto_ack_records: Arc::new(Mutex::new(Vec::new())),
104105
}
105106
}
106107

@@ -128,6 +129,12 @@ impl MockFlightServer {
128129
*self.row_count.lock().await
129130
}
130131

132+
/// Get every `ack_up_to_records` value emitted on the auto-ack path, in order.
133+
#[allow(dead_code)]
134+
pub async fn get_auto_ack_records(&self) -> Vec<u64> {
135+
self.auto_ack_records.lock().await.clone()
136+
}
137+
131138
/// Reset the server state
132139
#[allow(dead_code)]
133140
pub async fn reset(&self) {
@@ -138,8 +145,7 @@ impl MockFlightServer {
138145
*self.max_offset_received.lock().await = -1;
139146
*self.batch_count.lock().await = 0;
140147
*self.row_count.lock().await = 0;
141-
let mut expected_offsets = self.expected_offsets.lock().await;
142-
expected_offsets.clear();
148+
self.auto_ack_records.lock().await.clear();
143149
}
144150
}
145151

@@ -219,11 +225,18 @@ impl FlightService for MockFlightServer {
219225
let batch_count = Arc::clone(&self.batch_count);
220226
let row_count = Arc::clone(&self.row_count);
221227
let response_indices = Arc::clone(&self.response_indices);
222-
let expected_offsets = Arc::clone(&self.expected_offsets);
228+
let auto_ack_records = Arc::clone(&self.auto_ack_records);
223229

224230
tokio::spawn(async move {
225231
let mut stream_responses: Vec<MockFlightResponse> = Vec::new();
226232
let mut is_first_message = true;
233+
// Per-connection state, owned as locals like the real server (fresh per
234+
// DoPut connection). These must not leak across reconnects or concurrent
235+
// same-table streams: `expected_offset` validates wire ordering, and
236+
// `connection_record_count` is the cumulative-record tracker that drives
237+
// auto-acks (the server derives ack_up_to_records per connection).
238+
let mut expected_offset: i64 = 0;
239+
let mut connection_record_count: u64 = 0;
227240

228241
// Load configured responses
229242
{
@@ -240,12 +253,6 @@ impl FlightService for MockFlightServer {
240253
*indices.get(&table_name).unwrap_or(&0)
241254
};
242255

243-
// Reset expected offset to 0 for each new connection
244-
{
245-
let mut offsets = expected_offsets.lock().await;
246-
offsets.insert(table_name.clone(), 0);
247-
}
248-
249256
while let Ok(Some(flight_data)) = stream.message().await {
250257
// Handle schema message (first message has no app_metadata or empty app_metadata)
251258
if is_first_message {
@@ -278,30 +285,23 @@ impl FlightService for MockFlightServer {
278285
if let Some(metadata) = &metadata {
279286
debug!("Received batch with offset_id: {}", metadata.offset_id);
280287

281-
// Validate offset is strictly sequential
282-
let expected = {
283-
let offsets = expected_offsets.lock().await;
284-
*offsets.get(&table_name).unwrap_or(&0)
285-
};
286-
if metadata.offset_id != expected {
288+
// Validate offset is strictly sequential (connection-local).
289+
if metadata.offset_id != expected_offset {
287290
error!(
288291
"Non-incremental offset: expected {}, got {}",
289-
expected, metadata.offset_id
292+
expected_offset, metadata.offset_id
290293
);
291294
let _ = tx
292295
.send(Err(Status::invalid_argument(format!(
293296
"Non-incremental offset: expected {}, actual {}",
294-
expected, metadata.offset_id
297+
expected_offset, metadata.offset_id
295298
))))
296299
.await;
297300
return;
298301
}
299302

300-
// Update expected offset for next batch
301-
{
302-
let mut offsets = expected_offsets.lock().await;
303-
offsets.insert(table_name.clone(), metadata.offset_id + 1);
304-
}
303+
// Update expected offset for next batch.
304+
expected_offset = metadata.offset_id + 1;
305305

306306
// Update max offset
307307
{
@@ -324,6 +324,10 @@ impl FlightService for MockFlightServer {
324324
.map(|rb| rb.length() as u64)
325325
.unwrap_or(0);
326326
if rows > 0 {
327+
// Connection-local counter drives acks (mirrors the server's
328+
// per-connection cumulative tracker); the global row_count is
329+
// kept only as a cross-connection observation metric.
330+
connection_record_count += rows;
327331
let mut count = row_count.lock().await;
328332
*count += rows;
329333
}
@@ -446,10 +450,10 @@ impl FlightService for MockFlightServer {
446450
} else {
447451
// Auto-ack if no more configured responses
448452
if let Some(metadata) = metadata {
449-
let records = {
450-
let count = row_count.lock().await;
451-
*count
452-
};
453+
// Use the connection-local record count so acks are
454+
// connection-relative, matching the real server.
455+
let records = connection_record_count;
456+
auto_ack_records.lock().await.push(records);
453457
let ack_metadata = FlightAckMetadata {
454458
ack_up_to_offset: metadata.offset_id,
455459
ack_up_to_records: records,
@@ -511,7 +515,7 @@ pub async fn start_mock_flight_server(
511515
batch_count: Arc::clone(&mock_server.batch_count),
512516
row_count: Arc::clone(&mock_server.row_count),
513517
response_indices: Arc::clone(&mock_server.response_indices),
514-
expected_offsets: Arc::clone(&mock_server.expected_offsets),
518+
auto_ack_records: Arc::clone(&mock_server.auto_ack_records),
515519
};
516520

517521
let addr: std::net::SocketAddr = "127.0.0.1:0".parse()?;

0 commit comments

Comments
 (0)