This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 5c4a963f73 fix(python-worker): coerce integral floats to int for 
INT/LONG fields (#6053)
5c4a963f73 is described below

commit 5c4a963f73c5d9e30eddde32b469b6c0069aed24
Author: Eugene Gu <[email protected]>
AuthorDate: Sat Jul 4 13:38:08 2026 -0700

    fix(python-worker): coerce integral floats to int for INT/LONG fields 
(#6053)
    
    ### What changes were proposed in this PR?
    
    **Problem.** Pandas-based Python operators (e.g. Sort via
    `TableOperator`) build a DataFrame from input tuples. When an INT/LONG
    column contains nulls, pandas promotes the whole column to float64
    because an int column cannot hold NaN, so `119` becomes `119.0`. On
    output, the worker's strict schema validation in `Tuple.finalize()` then
    fails with `TypeError: Unmatched type for field 'weight', expected
    AttributeType.INT, got 119.0 (<class 'float'>) instead.` This crashed
    every workflow whose CSV had an integer column with at least one missing
    value, and the only workaround was manually inserting a Type Casting
    operator for each affected column.
    
    **Why fix it in the Python worker (option (a) of the issue).** The CSV
    schema inference is correct (an all-integer column with nulls *is*
    INTEGER; the JVM side handles null ints fine), and a UI per-column
    override (option (c)) would not remove the crash. The type contract is
    broken by pandas at the Python-worker boundary, so the fix belongs at
    that boundary's single chokepoint: `Tuple.cast_to_schema()`, which
    already performs safe casts (NaN -> None, object -> pickled bytes) right
    before `validate_schema()`.
    
    **The fix** (in `Tuple.cast_to_schema()` only; `validate_schema()` is
    unchanged): when the target type is INT or LONG and the value is a float
    (including `np.float64`) with a zero fractional part, cast it back to
    int — but only when the result is provably the original integer:
    
    - INT window: Arrow int32 capacity `[-2^31, 2^31 - 1]`. int32 values are
    always exactly representable in float64, so capacity is the only
    constraint.
    - LONG window: the float64 exact-integer range `[-(2^53) + 1, 2^53 - 1]`
    instead of int64 capacity. Above 2^53, float64 rounds, so the received
    float may already be a corrupted rendition of the original integer;
    coercing it would turn a loud validation error into silent data
    corruption. The endpoint 2^53 itself is excluded because it is ambiguous
    (`2^53 + 1` also rounds to float `2^53`).
    - The range check compares the converted int rather than floats, to
    avoid float rounding at the window endpoints.
    - Non-integral, infinite, and out-of-window floats are left untouched so
    `validate_schema()` still rejects them: lossy coercion must never happen
    silently. An out-of-window integral float additionally logs an
    actionable warning suggesting a cast to STRING or DOUBLE (or LONG for
    large integers in an INT field).
    
    **Deliberate behavior change for reviewers to note.** Restructuring the
    if-chain in `cast_to_schema()` also fixes a pre-existing stale-variable
    bug: a NaN destined for a BINARY field was first set to None and then
    re-pickled from the stale local variable, producing pickled-NaN bytes
    instead of None. NaN in a BINARY field now correctly finalizes to None
    (guarded by a dedicated test).
    
    **The changed logic in `core/models/tuple.py`.** A new module-level
    constant defines the safely coercible window per integral type:
    
    ```python
    # Signed value ranges of the integral AttributeTypes within which an
    # integral float can be safely cast back to int. INT is bounded by Arrow
    # int32 capacity. LONG is bounded by the float64 exact-integer window
    # rather than int64 capacity: above 2**53 float64 rounds, so the received
    # float may already be a corrupted rendition of the original integer. The
    # endpoint 2**53 itself is excluded because it is ambiguous (2**53 + 1
    # also rounds to float 2**53).
    INTEGRAL_TYPE_RANGES = {
        AttributeType.INT: (-(2**31), 2**31 - 1),
        AttributeType.LONG: (-(2**53) + 1, 2**53 - 1),
    }
    ```
    
    `cast_to_schema()`'s per-field loop is restructured from two independent
    `if`s into mutually exclusive branches (null handling / integral-float
    coercion / BINARY pickling), which both hosts the new coercion and
    eliminates the stale-variable read described above:
    
    ```python
    # convert NaN to None to support null value conversion
    if checknull(field_value):
        self[field_name] = None
    elif field_value is not None:
        field_type = schema.get_attr_type(field_name)
        if (
            field_type in INTEGRAL_TYPE_RANGES
            and isinstance(field_value, float)
            and field_value.is_integer()
        ):
            # pandas promotes an int column holding nulls to float64
            # (119 -> 119.0), so convert integral floats destined for
            # INT/LONG back to int -- but only when the result fits the
            # safe range. Compare on the int result to avoid float
            # rounding at the endpoints.
            min_value, max_value = INTEGRAL_TYPE_RANGES[field_type]
            int_value = int(field_value)
            if min_value <= int_value <= max_value:
                self[field_name] = int_value
            else:
                logger.warning(...)  # actionable guidance, see diff
        elif field_type == AttributeType.BINARY and not isinstance(
            field_value, bytes
        ):
            self[field_name] = b"pickle    " + pickle.dumps(field_value)
    ```
    
    The outer per-field `try/except` (keep the value unchanged if a cast
    fails, continue with the next field) is preserved, and
    `validate_schema()` is untouched, so anything the coercion deliberately
    skips still fails validation loudly.
    
    ### Any related issues, documentation, discussions?
    
    Fixes #5935
    
    ### How was this PR tested?
    
    TDD: the tests were written first and confirmed to reproduce the crash
    (red), then the fix turned them green.
    
    - 34 new test cases in `amber/src/test/python/core/models/test_tuple.py`
    (59 total in the file, all passing): coercion cases including the int32
    and float64-exact-window boundaries and `np.float64`; rejection of
    non-integral / infinite / out-of-window floats; the out-of-window
    warning; NaN/None handling; DOUBLE and STRING fields staying untouched;
    tests pinning the coercion into `cast_to_schema` rather than
    `validate_schema`; and an integration-style test reproducing the full
    pipeline (`Table.from_tuple_likes` -> float64 promotion -> `as_tuples`
    -> `finalize`).
    - Full Python worker suite: `cd amber && pytest -m "not integration"` —
    all pass.
    - `ruff check` and `ruff format --check` clean on both changed files.
    - `sbt "scalafixAll --check"` and `sbt scalafmtCheckAll` pass.
    - Backend `AMBER_TEST_FILTER=skip-integration sbt test`: the full suite
    passes — 0 failed, 0 aborted (WorkflowCore 1570, amber 1076, all other
    service modules green). Run against a clean iceberg catalog, matching
    how CI provisions one per run.
    - Manual reproduction of the issue scenario (CSV with an integer column
    containing blanks -> Sort) is covered by the integration-style unit test
    above, which exercises the same `Table` -> `finalize` code path the
    worker uses.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Fable 5)
    
    ---------
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../python/core/models/schema/attribute_type.py    |  11 ++
 amber/src/main/python/core/models/tuple.py         |  40 +++++-
 amber/src/test/python/core/models/test_tuple.py    | 155 ++++++++++++++++++++-
 3 files changed, 199 insertions(+), 7 deletions(-)

diff --git a/amber/src/main/python/core/models/schema/attribute_type.py 
b/amber/src/main/python/core/models/schema/attribute_type.py
index 24d0745f41..666ee69ede 100644
--- a/amber/src/main/python/core/models/schema/attribute_type.py
+++ b/amber/src/main/python/core/models/schema/attribute_type.py
@@ -99,3 +99,14 @@ FROM_PYOBJECT_MAPPING = {
     datetime.datetime: AttributeType.TIMESTAMP,
     largebinary: AttributeType.LARGE_BINARY,
 }
+
+# Signed value ranges within which an integral float can be safely cast back
+# to int. INT is bounded by Arrow int32 capacity. LONG is bounded by the
+# float64 exact-integer window rather than int64 capacity: above 2**53 float64
+# rounds, so the received float may already be a corrupted rendition of the
+# original integer. The endpoint 2**53 itself is excluded because it is
+# ambiguous (2**53 + 1 also rounds to float 2**53).
+INTEGRAL_TYPE_RANGES = {
+    AttributeType.INT: (-(2**31), 2**31 - 1),
+    AttributeType.LONG: (-(2**53) + 1, 2**53 - 1),
+}
diff --git a/amber/src/main/python/core/models/tuple.py 
b/amber/src/main/python/core/models/tuple.py
index 1493ec0033..4b5a2e0ee9 100644
--- a/amber/src/main/python/core/models/tuple.py
+++ b/amber/src/main/python/core/models/tuple.py
@@ -30,7 +30,11 @@ from typing import Any, List, Iterator, Callable
 from typing_extensions import Protocol, runtime_checkable
 
 from core.models.type.large_binary import largebinary
-from .schema.attribute_type import TO_PYOBJECT_MAPPING, AttributeType
+from .schema.attribute_type import (
+    INTEGRAL_TYPE_RANGES,
+    TO_PYOBJECT_MAPPING,
+    AttributeType,
+)
 from .schema.field import Field
 from .schema.schema import Schema
 
@@ -303,9 +307,10 @@ class Tuple:
         """
         Safely cast each field value to match the target schema.
         If failed, the value will stay not changed.
-        This current conducts two kinds of casts:
+        This current conducts three kinds of casts:
             1. cast NaN to None;
-            2. cast any object to bytes (using pickle).
+            2. cast integral floats to int for INT/LONG fields;
+            3. cast any object to bytes (using pickle).
         :param schema: The target Schema that describes the target 
AttributeType to
             cast.
         :return:
@@ -317,10 +322,33 @@ class Tuple:
                 # convert NaN to None to support null value conversion
                 if checknull(field_value):
                     self[field_name] = None
-
-                if field_value is not None:
+                elif field_value is not None:
                     field_type = schema.get_attr_type(field_name)
-                    if field_type == AttributeType.BINARY and not isinstance(
+                    if (
+                        field_type in INTEGRAL_TYPE_RANGES
+                        and isinstance(field_value, float)
+                        and field_value.is_integer()
+                    ):
+                        # pandas 2.2.3 promotes an int column holding nulls to
+                        # float64 (119 -> 119.0), so convert integral floats
+                        # destined for INT/LONG back to int — but only within
+                        # the safe range above; out-of-range floats are left
+                        # unchanged so validation still fails. Compare on the
+                        # int result to avoid float rounding at the endpoints.
+                        min_value, max_value = INTEGRAL_TYPE_RANGES[field_type]
+                        int_value = int(field_value)
+                        if min_value <= int_value <= max_value:
+                            self[field_name] = int_value
+                        else:
+                            logger.warning(
+                                f"Field '{field_name}': integral float "
+                                f"{field_value} is outside the safely 
coercible "
+                                f"range of {field_type}; leaving it unchanged "
+                                f"(schema validation will fail). Consider "
+                                f"casting the column to STRING or DOUBLE (or "
+                                f"LONG for large integers in an INT field)."
+                            )
+                    elif field_type == AttributeType.BINARY and not isinstance(
                         field_value, bytes
                     ):
                         self[field_name] = b"pickle    " + 
pickle.dumps(field_value)
diff --git a/amber/src/test/python/core/models/test_tuple.py 
b/amber/src/test/python/core/models/test_tuple.py
index f786e88a19..3d61fb10f5 100644
--- a/amber/src/test/python/core/models/test_tuple.py
+++ b/amber/src/test/python/core/models/test_tuple.py
@@ -21,8 +21,9 @@ import pyarrow
 import pytest
 import numpy as np
 from copy import deepcopy
+from loguru import logger
 
-from core.models import Tuple, ArrowTableTupleProvider
+from core.models import Table, Tuple, ArrowTableTupleProvider
 from core.models.schema.schema import Schema
 
 
@@ -152,6 +153,158 @@ class TestTuple:
         assert isinstance(tuple_["scores"], bytes)
         assert tuple_["height"] is None
 
+    # Pandas-based operators (e.g. TableOperator via Table.from_tuple_likes)
+    # promote an int column containing nulls to float64, so an INT field can
+    # arrive at finalize() as 119.0. finalize() must coerce such integral
+    # floats back to int when they fit the target type's range, while still
+    # rejecting non-integral, infinite, and out-of-range floats.
+
+    @pytest.mark.parametrize(
+        "raw_value, expected",
+        [
+            (119.0, 119),
+            (-3.0, -3),
+            (-0.0, 0),
+            # int32 boundaries are exactly representable as float64
+            (2147483647.0, 2**31 - 1),
+            (-2147483648.0, -(2**31)),
+            # np.float64 subclasses float and must be coerced the same way
+            (np.float64(119.0), 119),
+        ],
+    )
+    def test_finalize_coerces_integral_float_to_int(self, raw_value, expected):
+        tuple_ = Tuple({"weight": raw_value})
+        tuple_.finalize(Schema(raw_schema={"weight": "INTEGER"}))
+        assert tuple_["weight"] == expected
+        assert type(tuple_["weight"]) is int
+
+    @pytest.mark.parametrize(
+        "raw_value, expected",
+        [
+            (3000000000.0, 3000000000),
+            # np.float64 subclasses float and must be coerced the same way
+            (np.float64(3000000000.0), 3000000000),
+            # boundaries of the float64 exact-integer window: every integer
+            # in [-(2**53) + 1, 2**53 - 1] maps to a unique float64
+            (float(2**53 - 1), 2**53 - 1),
+            (float(-(2**53) + 1), -(2**53) + 1),
+        ],
+    )
+    def test_finalize_coerces_integral_float_to_long(self, raw_value, 
expected):
+        tuple_ = Tuple({"count": raw_value})
+        tuple_.finalize(Schema(raw_schema={"count": "LONG"}))
+        assert tuple_["count"] == expected
+        assert type(tuple_["count"]) is int
+
+    def test_finalize_tuples_from_pandas_promoted_int_column(self):
+        # Mirrors the real pipeline: pandas promotes the INT column to
+        # float64 inside Table.from_tuple_likes because of the null row
+        # (119 -> 119.0, None -> NaN). finalize() must restore the int
+        # and map NaN back to None.
+        table = Table([{"weight": 119}, {"weight": None}])
+        assert table["weight"].dtype == "float64"
+        schema = Schema(raw_schema={"weight": "INTEGER"})
+        finalized = []
+        for tuple_ in table.as_tuples():
+            tuple_.finalize(schema)
+            finalized.append(tuple_)
+        assert finalized[0]["weight"] == 119
+        assert type(finalized[0]["weight"]) is int
+        assert finalized[1]["weight"] is None
+
+    @pytest.mark.parametrize(
+        "null_value",
+        [None, float("nan"), np.float64("nan")],
+        ids=["none", "nan", "np-nan"],
+    )
+    def test_finalize_keeps_null_int_field_as_none(self, null_value):
+        tuple_ = Tuple({"weight": null_value})
+        tuple_.finalize(Schema(raw_schema={"weight": "INTEGER"}))
+        assert tuple_["weight"] is None
+
+    @pytest.mark.parametrize(
+        "attr_type, raw_value",
+        [
+            # non-integral floats must never be silently truncated
+            ("INTEGER", 119.5),
+            ("LONG", 3.5),
+            ("INTEGER", float("inf")),
+            ("INTEGER", float("-inf")),
+            # integral floats outside the target range must not be coerced
+            # into ints that would overflow Arrow int32
+            ("INTEGER", 3e9),
+            ("INTEGER", 2147483648.0),  # int32 max + 1
+            ("INTEGER", -2147483649.0),  # int32 min - 1
+            # for LONG, floats beyond the float64 exact-integer window must
+            # be rejected even though they fit int64: float64 rounds above
+            # 2**53, so the received float may already be a corrupted
+            # rendition of the original integer. The endpoint 2**53 itself
+            # is ambiguous (2**53 + 1 also rounds to float 2**53).
+            ("LONG", float(2**53)),
+            ("LONG", float(-(2**53))),
+            ("LONG", -9223372036854775808.0),  # -(2**63), fits int64
+            ("LONG", 9223372036854775808.0),  # 2**63, above long max
+            ("LONG", 1e20),
+            # coercion only applies to INT/LONG targets
+            ("STRING", 119.0),
+        ],
+    )
+    def test_finalize_rejects_uncoercible_float(self, attr_type, raw_value):
+        tuple_ = Tuple({"weight": raw_value})
+        with pytest.raises(TypeError, match="Unmatched type"):
+            tuple_.finalize(Schema(raw_schema={"weight": attr_type}))
+
+    def test_cast_to_schema_warns_on_out_of_range_integral_float(self):
+        # An integral float outside the coercible window must be left
+        # unchanged, and a guidance warning emitted: the follow-up
+        # validation error alone would not explain the pandas float64
+        # promotion or how to work around it.
+        messages = []
+        handler_id = logger.add(messages.append, level="WARNING")
+        try:
+            tuple_ = Tuple({"weight": 3e9})
+            tuple_.cast_to_schema(Schema(raw_schema={"weight": "INTEGER"}))
+        finally:
+            logger.remove(handler_id)
+        assert tuple_["weight"] == 3e9
+        assert type(tuple_["weight"]) is float
+        assert any("outside the safely coercible range" in str(m) for m in 
messages)
+
+    @pytest.mark.parametrize("raw_value", [0.5, 2.0])
+    def test_finalize_leaves_double_field_untouched(self, raw_value):
+        tuple_ = Tuple({"ratio": raw_value})
+        tuple_.finalize(Schema(raw_schema={"ratio": "DOUBLE"}))
+        assert tuple_["ratio"] == raw_value
+        assert type(tuple_["ratio"]) is float
+
+    def test_finalize_keeps_plain_int_unchanged(self):
+        tuple_ = Tuple({"weight": 119})
+        tuple_.finalize(Schema(raw_schema={"weight": "INTEGER"}))
+        assert tuple_["weight"] == 119
+        assert type(tuple_["weight"]) is int
+
+    def test_cast_to_schema_coerces_integral_float(self):
+        # The coercion must live in cast_to_schema(), not validate_schema()
+        tuple_ = Tuple({"weight": 119.0})
+        tuple_.cast_to_schema(Schema(raw_schema={"weight": "INTEGER"}))
+        assert tuple_["weight"] == 119
+        assert type(tuple_["weight"]) is int
+
+    def test_validate_schema_still_rejects_integral_float(self):
+        # validate_schema() alone must stay strict: coercing there instead
+        # of in cast_to_schema() would let unfinalized floats slip through
+        tuple_ = Tuple({"weight": 119.0})
+        with pytest.raises(TypeError, match="Unmatched type"):
+            tuple_.validate_schema(Schema(raw_schema={"weight": "INTEGER"}))
+
+    def test_finalize_maps_nan_in_binary_field_to_none(self):
+        # NaN in a BINARY field must become None, not a pickled NaN.
+        # Guards the cast_to_schema() branch structure: after the NaN->None
+        # conversion, the stale pre-conversion value must not be re-pickled.
+        tuple_ = Tuple({"payload": float("nan")})
+        tuple_.finalize(Schema(raw_schema={"payload": "BINARY"}))
+        assert tuple_["payload"] is None
+
     def test_hash(self):
         schema = Schema(
             raw_schema={

Reply via email to