NoahKusaba commented on code in PR #2608:
URL: 
https://github.com/apache/datafusion-ballista/pull/2608#discussion_r4355635620


##########
ballista/core/src/client.rs:
##########
@@ -62,6 +64,45 @@ pub struct BallistaClient {
 }
 
 impl BallistaClient {
+    fn encode_action(action: &Action) -> BResult<Vec<u8>> {
+        let serialized_action: protobuf::Action = 
action.to_owned().try_into()?;
+        let mut buf = Vec::with_capacity(serialized_action.encoded_len());
+        serialized_action
+            .encode(&mut buf)
+            .map_err(|e| BallistaError::GrpcActionError(format!("{e:?}")))?;
+        Ok(buf)
+    }
+
+    async fn retry_io<T, F, Fut>(&self, mut op: F) -> Result<T, Status>

Review Comment:
   There's already a generic `with_retry(retries, wait_ms, should_retry, f)` in 
`shuffle_reader.rs:945` with the same shape. Could we make it generic over the 
error type, move it to `utils.rs`, and call it here with `|s: &Status| s.code() 
== Code::Unknown`? Then the crate has one retry helper, not two. One catch: 
`with_retry` counts *retries* and the client counts *total attempts*, so the 
call would pass `self.io_retries_times.saturating_sub(1)`. Happy to leave this 
as a follow-up if you'd rather keep this PR small.



##########
ballista/core/src/client.rs:
##########
@@ -62,6 +64,45 @@ pub struct BallistaClient {
 }
 
 impl BallistaClient {
+    fn encode_action(action: &Action) -> BResult<Vec<u8>> {

Review Comment:
   Nit: encoding into a `Vec` can't fail, so the `map_err` here is dead code. 
`encode_to_vec()` is already used in `serde/mod.rs`. Returning `Bytes` also 
makes the per-attempt `buf.clone()` a refcount bump instead of a copy:
   ```rust
   let proto: protobuf::Action = action.clone().try_into()?;
   Ok(proto.encode_to_vec().into())
   ```



##########
ballista/core/src/client.rs:
##########
@@ -62,6 +64,45 @@ pub struct BallistaClient {
 }
 
 impl BallistaClient {
+    fn encode_action(action: &Action) -> BResult<Vec<u8>> {
+        let serialized_action: protobuf::Action = 
action.to_owned().try_into()?;
+        let mut buf = Vec::with_capacity(serialized_action.encoded_len());
+        serialized_action
+            .encode(&mut buf)
+            .map_err(|e| BallistaError::GrpcActionError(format!("{e:?}")))?;
+        Ok(buf)
+    }
+
+    async fn retry_io<T, F, Fut>(&self, mut op: F) -> Result<T, Status>
+    where
+        F: FnMut(FlightServiceClient<Channel>) -> Fut,
+        Fut: Future<Output = Result<T, Status>>,
+    {
+        let attempts = self.io_retries_times.max(1);

Review Comment:
   Could you add a short comment here explaining how 0 can show up? Besides an 
explicit `0`, the config entry is `UInt16` but `utils.rs:99` casts it with `as 
u8`, so 256 truncates to 0 (and 300 becomes 44). Something like `// 
io_retries_times counts total attempts; 0 can come from config or from u16→u8 
truncation, so always make at least one attempt.` That way the next reader 
knows why the clamp is there.



##########
ballista/core/src/client.rs:
##########
@@ -720,7 +684,73 @@ mod tests {
     use futures::{StreamExt, TryStreamExt};
     use prost::bytes::Bytes;
 
-    use crate::client::BlockDataStream;
+    use crate::client::{BallistaClient, BlockDataStream};
+
+    #[tokio::test]
+    async fn retry_io_attempts_once_when_retries_are_zero() {
+        let mut client = BallistaClient::new_for_test("localhost", 50050);
+        client.io_retries_times = 0;
+        client.io_retry_wait_time_ms = 0;
+        let attempts = Arc::new(AtomicUsize::new(0));
+        let observed = Arc::clone(&attempts);
+
+        let error = client
+            .retry_io(move |_| {
+                observed.fetch_add(1, Ordering::Relaxed);
+                async { Err::<(), _>(tonic::Status::unavailable("offline")) }
+            })
+            .await
+            .unwrap_err();
+
+        assert_eq!(error.code(), tonic::Code::Unavailable);
+        assert_eq!(attempts.load(Ordering::Relaxed), 1);
+    }
+
+    #[tokio::test]
+    async fn retry_io_retries_unknown_errors() {
+        let mut client = BallistaClient::new_for_test("localhost", 50050);
+        client.io_retries_times = 3;
+        client.io_retry_wait_time_ms = 0;
+        let attempts = Arc::new(AtomicUsize::new(0));
+        let observed = Arc::clone(&attempts);
+
+        let result = client
+            .retry_io(move |_| {
+                let attempt = observed.fetch_add(1, Ordering::Relaxed);
+                async move {
+                    if attempt < 2 {
+                        Err(tonic::Status::unknown("transient"))
+                    } else {
+                        Ok("done")
+                    }
+                }
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(result, "done");
+        assert_eq!(attempts.load(Ordering::Relaxed), 3);
+    }
+
+    #[tokio::test]
+    async fn retry_io_does_not_retry_other_statuses() {
+        let mut client = BallistaClient::new_for_test("localhost", 50050);
+        client.io_retries_times = 3;
+        client.io_retry_wait_time_ms = 0;
+        let attempts = Arc::new(AtomicUsize::new(0));
+        let observed = Arc::clone(&attempts);
+
+        let error = client
+            .retry_io(move |_| {
+                observed.fetch_add(1, Ordering::Relaxed);
+                async { Err::<(), _>(tonic::Status::invalid_argument("bad")) }
+            })
+            .await
+            .unwrap_err();
+
+        assert_eq!(error.code(), tonic::Code::InvalidArgument);

Review Comment:
   Nit: could the tests compare the message too (e.g. 
`assert_eq!(error.message(), "bad")`)? Checking only the code would still pass 
if a different error with the same code came back.



##########
ballista/core/src/client.rs:
##########
@@ -720,7 +684,73 @@ mod tests {
     use futures::{StreamExt, TryStreamExt};
     use prost::bytes::Bytes;
 
-    use crate::client::BlockDataStream;
+    use crate::client::{BallistaClient, BlockDataStream};
+
+    #[tokio::test]
+    async fn retry_io_attempts_once_when_retries_are_zero() {
+        let mut client = BallistaClient::new_for_test("localhost", 50050);
+        client.io_retries_times = 0;
+        client.io_retry_wait_time_ms = 0;
+        let attempts = Arc::new(AtomicUsize::new(0));
+        let observed = Arc::clone(&attempts);
+
+        let error = client
+            .retry_io(move |_| {
+                observed.fetch_add(1, Ordering::Relaxed);
+                async { Err::<(), _>(tonic::Status::unavailable("offline")) }
+            })
+            .await
+            .unwrap_err();
+
+        assert_eq!(error.code(), tonic::Code::Unavailable);
+        assert_eq!(attempts.load(Ordering::Relaxed), 1);
+    }
+
+    #[tokio::test]
+    async fn retry_io_retries_unknown_errors() {

Review Comment:
   Could we add a test where the op always returns `Unknown`, with 
`io_retries_times = 3`? It should run exactly 3 times and return the last 
error. `attempt + 1 < attempts` is where an off-by-one would hide, and right 
now nothing covers it.



##########
ballista/core/src/client.rs:
##########
@@ -62,6 +64,45 @@ pub struct BallistaClient {
 }
 
 impl BallistaClient {
+    fn encode_action(action: &Action) -> BResult<Vec<u8>> {
+        let serialized_action: protobuf::Action = 
action.to_owned().try_into()?;
+        let mut buf = Vec::with_capacity(serialized_action.encoded_len());
+        serialized_action
+            .encode(&mut buf)
+            .map_err(|e| BallistaError::GrpcActionError(format!("{e:?}")))?;
+        Ok(buf)
+    }
+
+    async fn retry_io<T, F, Fut>(&self, mut op: F) -> Result<T, Status>
+    where
+        F: FnMut(FlightServiceClient<Channel>) -> Fut,
+        Fut: Future<Output = Result<T, Status>>,
+    {
+        let attempts = self.io_retries_times.max(1);
+        let mut attempt = 0;
+
+        loop {
+            if attempt > 0 {

Review Comment:
   Nit: if you keep `retry_io`, the sleep could move into the retry arm. That 
drops the `if attempt > 0` block and merges the two return arms into one:
   ```rust
   let mut attempt = 1;
   loop {
       match op(self.flight_client.clone()).await {
           Err(e) if e.code() == Code::Unknown && attempt < attempts => {
               warn!("Remote shuffle read failed: {e}; retry 
{attempt}/{attempts} in {} ms", self.io_retry_wait_time_ms);
               
tokio::time::sleep(Duration::from_millis(self.io_retry_wait_time_ms)).await;
               attempt += 1;
           }
           result => return result,
       }
   }
   ```
   It also lets the warning log the error that caused the retry, which it 
doesn't today.



##########
ballista/core/src/client.rs:
##########
@@ -720,7 +684,73 @@ mod tests {
     use futures::{StreamExt, TryStreamExt};
     use prost::bytes::Bytes;
 
-    use crate::client::BlockDataStream;
+    use crate::client::{BallistaClient, BlockDataStream};
+
+    #[tokio::test]
+    async fn retry_io_attempts_once_when_retries_are_zero() {
+        let mut client = BallistaClient::new_for_test("localhost", 50050);
+        client.io_retries_times = 0;
+        client.io_retry_wait_time_ms = 0;
+        let attempts = Arc::new(AtomicUsize::new(0));
+        let observed = Arc::clone(&attempts);
+
+        let error = client
+            .retry_io(move |_| {
+                observed.fetch_add(1, Ordering::Relaxed);
+                async { Err::<(), _>(tonic::Status::unavailable("offline")) }

Review Comment:
   `Unavailable` is never retried, so this test passes no matter what 
`io_retries_times` is. And since the code uses `loop`, it would also pass 
without `.max(1)`. Could this use `Status::unknown(...)` so it actually checks 
that zero means one attempt?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to