Skip to content

Commit b53b843

Browse files
authored
Hold input queues until ready for shutdown (ArroyoSystems#1020)
* Improve Iceberg error mapping * Ensure we don't close in_qs before ready to shutdown
1 parent 6db4371 commit b53b843

3 files changed

Lines changed: 148 additions & 12 deletions

File tree

crates/arroyo-connectors/src/filesystem/sink/iceberg/mod.rs

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use iceberg::transaction::{ApplyTransactionAction, Transaction};
1818
use iceberg::{Catalog, ErrorKind, TableCreation, TableIdent};
1919
use iceberg_catalog_rest::{RestCatalog, RestCatalogConfig};
2020
use itertools::Itertools;
21+
use regex::Regex;
2122
use sha2::{Digest, Sha256};
2223
use std::collections::HashMap;
2324
use std::error::Error;
@@ -77,25 +78,57 @@ fn transaction_id(task_info: &TaskInfo, epoch: u32, table_uuid: Uuid) -> String
7778
format!("tx-{}", to_hex(short))
7879
}
7980

81+
fn extract_catalog_status(display: &str) -> Option<u16> {
82+
let re = Regex::new(r"status: (\d{3})").unwrap();
83+
re.captures(display).and_then(|c| c[1].parse().ok())
84+
}
85+
86+
fn extract_catalog_message(display: &str) -> Option<String> {
87+
let re = Regex::new(r#""message"\s*:\s*"([^"]+)""#).unwrap();
88+
re.captures(display).map(|c| c[1].to_string())
89+
}
90+
8091
fn map_iceberg_error(error: iceberg::Error) -> DataflowError {
8192
let (domain, msg) = match error.kind() {
8293
ErrorKind::PreconditionFailed => (ErrorDomain::Internal, error.to_string()),
8394
ErrorKind::DataInvalid | ErrorKind::NamespaceNotFound | ErrorKind::TableNotFound => {
8495
(ErrorDomain::User, error.to_string())
8596
}
8697
ErrorKind::Unexpected => {
87-
if let Some(source) = error.source() {
88-
if let Some(err) = source.downcast_ref::<reqwest::Error>() {
89-
match err.status().map(|c| c.as_u16()) {
90-
Some(401) | Some(403) | Some(404) => (ErrorDomain::User, err.to_string()),
91-
_ => (ErrorDomain::External, err.to_string()),
98+
// For Unexpected errors from iceberg-catalog-rest, the HTTP status and response
99+
// body are embedded in the error's Display output as context fields. Extract the
100+
// status to determine the domain, and try to pull out a clean error message.
101+
let display = error.to_string();
102+
let status = extract_catalog_status(&display);
103+
104+
let domain = match status {
105+
Some(400..=499) => ErrorDomain::User,
106+
Some(_) => ErrorDomain::External,
107+
// No status found — could be a transport error or something else
108+
None => {
109+
if let Some(source) = error.source() {
110+
if let Some(err) = source.downcast_ref::<reqwest::Error>() {
111+
if err.status().map(|c| c.is_client_error()).unwrap_or(false) {
112+
ErrorDomain::User
113+
} else {
114+
ErrorDomain::External
115+
}
116+
} else {
117+
ErrorDomain::External
118+
}
119+
} else {
120+
ErrorDomain::External
92121
}
93-
} else {
94-
(ErrorDomain::External, source.to_string())
95122
}
123+
};
124+
125+
let msg = if let Some(catalog_msg) = extract_catalog_message(&display) {
126+
format!("Iceberg catalog error: {catalog_msg}")
96127
} else {
97-
(ErrorDomain::External, error.to_string())
98-
}
128+
format!("Iceberg catalog error: {}", error.message())
129+
};
130+
131+
(domain, msg)
99132
}
100133
_ => (ErrorDomain::External, error.to_string()),
101134
};
@@ -349,3 +382,93 @@ impl IcebergTable {
349382
Ok(())
350383
}
351384
}
385+
386+
#[cfg(test)]
387+
mod tests {
388+
use super::*;
389+
390+
#[test]
391+
fn test_map_iceberg_error_catalog_424() {
392+
let error = iceberg::Error::new(
393+
ErrorKind::Unexpected,
394+
"Received response with unexpected status code",
395+
)
396+
.with_context("status", "424 Failed Dependency")
397+
.with_context("headers", r#"{"date": "Tue, 24 Feb 2026 17:27:50 GMT"}"#)
398+
.with_context(
399+
"json",
400+
r#"{"error":{"message":"Failed to list files in location. Please check the storage credentials.","type":"List","code":424}}"#,
401+
);
402+
403+
match map_iceberg_error(error) {
404+
DataflowError::ConnectorError {
405+
domain, error: msg, ..
406+
} => {
407+
assert_eq!(domain, ErrorDomain::User);
408+
assert_eq!(
409+
msg,
410+
"Iceberg catalog error: Failed to list files in location. Please check the storage credentials."
411+
);
412+
}
413+
other => panic!("expected ConnectorError, got: {other:?}"),
414+
}
415+
}
416+
417+
#[test]
418+
fn test_map_iceberg_error_catalog_403_bare_body() {
419+
// Simulates a 403 from the catalog with a bare string body (no JSON structure)
420+
let error = iceberg::Error::new(
421+
ErrorKind::Unexpected,
422+
"Received response with unexpected status code",
423+
)
424+
.with_context("status", "403 Forbidden")
425+
.with_context("headers", r#"{"date": "Wed, 25 Feb 2026 16:16:50 GMT"}"#)
426+
.with_context("json", "Unauthenticated");
427+
428+
match map_iceberg_error(error) {
429+
DataflowError::ConnectorError {
430+
domain, error: msg, ..
431+
} => {
432+
assert_eq!(domain, ErrorDomain::User);
433+
// Falls back to error.message() since no JSON "message" field
434+
assert!(
435+
msg.contains("Received response with unexpected status code"),
436+
"{msg}"
437+
);
438+
}
439+
other => panic!("expected ConnectorError, got: {other:?}"),
440+
}
441+
}
442+
443+
#[test]
444+
fn test_map_iceberg_error_catalog_500() {
445+
let error = iceberg::Error::new(
446+
ErrorKind::Unexpected,
447+
"Received response with unexpected status code",
448+
)
449+
.with_context("status", "500 Internal Server Error")
450+
.with_context(
451+
"json",
452+
r#"{"error":{"message":"Internal server error","type":"ServerError","code":500}}"#,
453+
);
454+
455+
match map_iceberg_error(error) {
456+
DataflowError::ConnectorError { domain, .. } => {
457+
assert_eq!(domain, ErrorDomain::External);
458+
}
459+
other => panic!("expected ConnectorError, got: {other:?}"),
460+
}
461+
}
462+
463+
#[test]
464+
fn test_map_iceberg_error_no_status_falls_back() {
465+
let error = iceberg::Error::new(ErrorKind::Unexpected, "some opaque error");
466+
467+
match map_iceberg_error(error) {
468+
DataflowError::ConnectorError { domain, .. } => {
469+
assert_eq!(domain, ErrorDomain::External);
470+
}
471+
other => panic!("expected ConnectorError, got: {other:?}"),
472+
}
473+
}
474+
}

crates/arroyo-operator/src/operator.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ impl OperatorNode {
230230
out_qs: Vec<Vec<BatchSender>>,
231231
out_schema: Option<Arc<ArroyoSchema>>,
232232
ready: Arc<Barrier>,
233-
) {
233+
) -> Vec<BatchReceiver> {
234234
info!(
235235
"Starting node {}-{} ({})",
236236
self.node_id(),
@@ -291,6 +291,8 @@ impl OperatorNode {
291291
.expect("control response unwrap");
292292
}
293293
}
294+
295+
in_qs
294296
}
295297
}
296298

crates/arroyo-worker/src/engine.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,9 @@ impl Engine {
736736
let join_task = {
737737
let control_tx = control_tx.clone();
738738
tokio::spawn(async move {
739-
operator
739+
// keep in_qs around until all operators are stopped to prevent upstream panics
740+
// trying to write to the queues
741+
let in_qs = operator
740742
.start(
741743
control_tx.clone(),
742744
control_rx,
@@ -747,7 +749,12 @@ impl Engine {
747749
)
748750
.await;
749751

750-
// wait for all other tasks to finish
752+
// drain in the background to prevent potential deadlocks
753+
let drain_handles: Vec<_> = in_qs
754+
.into_iter()
755+
.map(|mut q| tokio::spawn(async move { while q.recv().await.is_some() {} }))
756+
.collect();
757+
751758
debug!(
752759
node = node_id,
753760
subtask_idx = task_index,
@@ -756,6 +763,10 @@ impl Engine {
756763
if stop.wait().await.is_leader() {
757764
debug!("all tasks finished");
758765
}
766+
767+
for h in drain_handles {
768+
h.abort();
769+
}
759770
})
760771
};
761772

0 commit comments

Comments
 (0)