Skip to content

fix(transform): avoid overflow in adjust_boundary at type extremes - #3645

Open
hsdfat wants to merge 1 commit into
apache:mainfrom
hsdfat:fix-adjust-boundary-overflow-3637
Open

hsdfat wants to merge 1 commit into
apache:mainfrom
hsdfat:fix-adjust-boundary-overflow-3637

Conversation

@hsdfat

@hsdfat hsdfat commented Nov 6, 2026 •

Copy link
Copy Markdown

Which issue does this PR close?

What changes are included in this PR?

Transform::adjust_boundary turns < v into <= v - 1 and > v into >= v + 1 with unchecked arithmetic. At a type's minimum or maximum, that panics in debug builds and wraps in release. For int, long, date and the four timestamp types, the shift now uses checked_sub / checked_add. On overflow it returns Ok(None). Callers already treat None as "no projection", and the predicate can't match any value anyway (nothing is below i32::MIN). The decimal arms are unchanged; they already go through try_decrement_number / try_increment_number.

Are these changes tested?

Yes, with unit tests in spec/transform.rs:

  • test_adjust_boundary_overflow_returns_none covers < at MIN and > at MAX for each affected type.
  • test_adjust_boundary_near_extremes checks that MIN+1 and MAX-1 still shift normally.
  • test_project_strict_comparison_past_extreme_returns_none goes through Transform::project for a day transform on a date and a truncate transform on a long. Both panicked before this change.

AI Disclosure

The change and tests were drafted with AI assistance (Claude). I reviewed every line and ran the transform tests, clippy and fmt. The point a reviewer may want to check: on overflow this returns None (no projection) rather than an always-false predicate. That seemed the smallest change, since the caller already handles None.

A strict comparison past an integer, date or timestamp type's minimum or
maximum now yields no boundary instead of overflowing, so inclusive
projection returns no predicate rather than panicking.

Part of apache#3637

@mbutrovich mbutrovich left a comment

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.

Thanks @hsdfat for picking this up.

On the question in the description, returning None looks right to me. InclusiveProjection skips a part with no projection, so the partition filter stays true. The inclusive projection rule only requires the partition predicate to match every row the scan predicate matches, and x < i32::MIN matches none.

Comment on lines 779 to 828
PredicateOperator::LessThan => match (datum.data_type(), datum.literal()) {
(PrimitiveType::Int, PrimitiveLiteral::Int(v)) => Some(Datum::int(v - 1)),
(PrimitiveType::Long, PrimitiveLiteral::Long(v)) => Some(Datum::long(v - 1)),
(PrimitiveType::Int, PrimitiveLiteral::Int(v)) => v.checked_sub(1).map(Datum::int),
(PrimitiveType::Long, PrimitiveLiteral::Long(v)) => {
v.checked_sub(1).map(Datum::long)
}
(PrimitiveType::Decimal { .. }, PrimitiveLiteral::Int128(_)) => {
Some(Self::try_decrement_number(datum)?)
}
(PrimitiveType::Date, PrimitiveLiteral::Int(v)) => Some(Datum::date(v - 1)),
(PrimitiveType::Date, PrimitiveLiteral::Int(v)) => {
v.checked_sub(1).map(Datum::date)
}
(PrimitiveType::Timestamp, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamp_micros(v - 1))
v.checked_sub(1).map(Datum::timestamp_micros)
}
(PrimitiveType::Timestamptz, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamptz_micros(v - 1))
v.checked_sub(1).map(Datum::timestamptz_micros)
}
(PrimitiveType::TimestampNs, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamp_nanos(v - 1))
v.checked_sub(1).map(Datum::timestamp_nanos)
}
(PrimitiveType::TimestamptzNs, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamptz_nanos(v - 1))
v.checked_sub(1).map(Datum::timestamptz_nanos)
}
_ => Some(datum.to_owned()),
},
PredicateOperator::GreaterThan => match (datum.data_type(), datum.literal()) {
(PrimitiveType::Int, PrimitiveLiteral::Int(v)) => Some(Datum::int(v + 1)),
(PrimitiveType::Long, PrimitiveLiteral::Long(v)) => Some(Datum::long(v + 1)),
(PrimitiveType::Int, PrimitiveLiteral::Int(v)) => v.checked_add(1).map(Datum::int),
(PrimitiveType::Long, PrimitiveLiteral::Long(v)) => {
v.checked_add(1).map(Datum::long)
}
(PrimitiveType::Decimal { .. }, PrimitiveLiteral::Int128(_)) => {
Some(Self::try_increment_number(datum)?)
}
(PrimitiveType::Date, PrimitiveLiteral::Int(v)) => Some(Datum::date(v + 1)),
(PrimitiveType::Date, PrimitiveLiteral::Int(v)) => {
v.checked_add(1).map(Datum::date)
}
(PrimitiveType::Timestamp, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamp_micros(v + 1))
v.checked_add(1).map(Datum::timestamp_micros)
}
(PrimitiveType::Timestamptz, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamptz_micros(v + 1))
v.checked_add(1).map(Datum::timestamptz_micros)
}
(PrimitiveType::TimestampNs, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamp_nanos(v + 1))
v.checked_add(1).map(Datum::timestamp_nanos)
}
(PrimitiveType::TimestamptzNs, PrimitiveLiteral::Long(v)) => {
Some(Datum::timestamptz_nanos(v + 1))
v.checked_add(1).map(Datum::timestamptz_nanos)
}
_ => Some(datum.to_owned()),
},

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.

Could the checked arithmetic go into try_decrement_number and try_increment_number instead? Apart from the decimal case, these two arms repeat try_increment_number and try_decrement_number type for type, and those helpers still compute v + 1 and v - 1 unchecked. truncate_number_strict calls them for <= and >=, so strict projection hits the same overflow. At the head commit, Transform::strict_project panics in a debug build for truncate[10] with x <= i32::MAX and x >= i64::MIN, and for day with x <= date(i32::MAX) and x >= timestamp_micros(i64::MIN).

If both helpers return Result<Option<Datum>> with None on overflow, the two arms here become:

PredicateOperator::LessThan => Self::try_decrement_number(datum)?,
PredicateOperator::GreaterThan => Self::try_increment_number(datum)?,

The decimal case can use v.checked_add(1).map(|m| datum.with_decimal_mantissa(m)).transpose(). truncate_number_strict then returns Ok(None) when a helper does. None is safe on the strict side too, because StrictProjection starts from AlwaysFalse and skips a part with no projection. Strict projection may then fail to report that every row of a file matches, but it never reports a match that's wrong. This also removes the 14 arms the PR adds here.

Comment on lines +1339 to +1367
fn test_project_strict_comparison_past_extreme_returns_none() {
let cases = [
(
Transform::Day,
PrimitiveType::Date,
PredicateOperator::LessThan,
Datum::date(i32::MIN),
),
(
Transform::Truncate(10),
PrimitiveType::Long,
PredicateOperator::GreaterThan,
Datum::long(i64::MAX),
),
];
for (transform, field_type, op, literal) in cases {
let field = Arc::new(NestedField::optional(1, "col", Type::Primitive(field_type)));
let predicate = BoundPredicate::Binary(BinaryExpression::new(
op,
BoundReference::new(
"col",
field,
Arc::new(StructAccessor::new(1, PrimitiveType::Boolean)),
),
literal,
));
assert_eq!(transform.project("col", &predicate).unwrap(), None);
}
}

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.

Could this test move next to the other projection tests in transform/temporal.rs and transform/truncate.rs, using TestProjectionFixture? Its binary_predicate builds the same BoundPredicate this loop builds by hand, and assert_projection with None checks the result. That also drops the three imports this PR adds to the test module (lines 1207-1211). The strict-projection cases from the comment above would fit in the same place.

The name also says "strict", but the test calls project, and strict_project is a separate method in this file. Something like test_projection_comparison_past_type_extreme would avoid the mix-up.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants