Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions crates/arroyo-worker/src/lib.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Oversized non-fatal error messages can still exceed the gRPC limit and fail the job

The non-fatal error message and details are sent to the controller untruncated (error: message/details at crates/arroyo-worker/src/lib.rs:605-608), unlike the fatal path which was capped, so a large error can still blow past the 4MB gRPC message limit.
Impact: A single oversized non-fatal error (e.g. from bad input data) makes the RPC fail, which cancels the worker and takes the whole job down — the exact scenario this change was meant to prevent.

Why the non-fatal path is also at risk

The fatal path at crates/arroyo-worker/src/lib.rs:579-586 now wraps both error and details in maybe_truncate(..., MAX_TASK_ERROR_FIELD_BYTES). The NonfatalErrorReq built at crates/arroyo-worker/src/lib.rs:597-610 assigns error: message and details directly with no size guard. The details value originates from deserialization/bad-data handling (crates/arroyo-operator/src/context.rs:389), which can embed raw offending record contents and thus be arbitrarily large. When such a message exceeds the 4MB limit, send_control_rpc! returns an error and cancel_token.cancel() is invoked (crates/arroyo-worker/src/lib.rs:637-643), terminating the job.

(Refers to lines 605-608)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use tonic::{Request, Response, Status};
use tracing::{debug, error, info, warn};

use crate::job_controller::controller::WorkerJobController;
use crate::utils::to_d2;
use crate::utils::{MAX_TASK_ERROR_FIELD_BYTES, maybe_truncate, to_d2};
use arroyo_datastream::logical::LogicalProgram;
use arroyo_planner::physical::new_registry;
use arroyo_rpc::config::config;
Expand Down Expand Up @@ -576,11 +576,14 @@ impl WorkerState {
error: Some(rpc::TaskError {
task_id,
subtask_idx,
error: error.message,
error: maybe_truncate(error.message, MAX_TASK_ERROR_FIELD_BYTES),
error_domain: rpc::ErrorDomain::from(error.domain) as i32,
retry_hint: rpc::RetryHint::from(error.retry_hint) as i32,
operator_id: error.operator_id.unwrap_or_default(),
details: error.details.unwrap_or_default(),
details: maybe_truncate(
error.details.unwrap_or_default(),
MAX_TASK_ERROR_FIELD_BYTES
),
}),
};
send_control_rpc!(
Expand All @@ -599,10 +602,10 @@ impl WorkerState {
task_id,
operator_id,
subtask_idx,
error: message,
error: maybe_truncate(message, MAX_TASK_ERROR_FIELD_BYTES),
error_domain: rpc::ErrorDomain::External as i32,
retry_hint: rpc::RetryHint::NoRetry as i32,
details,
details: maybe_truncate(details, MAX_TASK_ERROR_FIELD_BYTES),
}),
};
send_control_rpc!(
Expand Down
53 changes: 53 additions & 0 deletions crates/arroyo-worker/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use arroyo_operator::operator::Registry;
use arroyo_planner::physical::new_registry;
use std::fmt::Write;
use std::sync::Arc;
use tracing::warn;

fn format_arrow_schema_fields(schema: &Schema) -> Vec<(String, String)> {
schema
Expand Down Expand Up @@ -135,3 +136,55 @@ pub async fn to_d2(logical: &LogicalProgram) -> anyhow::Result<String> {

Ok(d2)
}

pub(crate) const MAX_TASK_ERROR_FIELD_BYTES: usize = 64 * 1024;

pub(crate) fn maybe_truncate(mut value: String, max_size_bytes: usize) -> String {
let original_bytes = value.len();
if original_bytes <= max_size_bytes {
return value;
}

let suffix = format!(" [truncated; original_bytes={original_bytes}]");
let mut end = max_size_bytes - suffix.len();
while !value.is_char_boundary(end) {
end -= 1;
}

value.truncate(end);
value.push_str(&suffix);

warn!(
"Truncated oversized String from {} bytes to {} bytes: {}",
original_bytes,
value.len(),
value

@cmackenzie1 cmackenzie1 Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read this right, these logs will be ~64kB of unknown data. Is that something we want to include in the log?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to log them as they can be useful to understand the root cause. In theory (🤞), we shouldn't really have this truncation happen often, if at all.

);
value
}

#[cfg(test)]
mod tests {
use super::maybe_truncate;

#[test]
fn maybe_truncate_preserves_value_at_limit() {
let value = "a".repeat(64);

assert_eq!(maybe_truncate(value.clone(), value.len()), value);
}

#[test]
fn maybe_truncate_respects_limit() {
let value = "a".repeat(100);
let max_size_bytes = 64;

let truncated = maybe_truncate(value, max_size_bytes);

assert_eq!(truncated.len(), max_size_bytes);
assert_eq!(
truncated,
format!("{} [truncated; original_bytes=100]", "a".repeat(32))
);
}
}
Loading