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

dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new c4bfcd5ea75 [FLINK-40370][python] Fix bytes-backed Avro decimal 
framing (#28996)
c4bfcd5ea75 is described below

commit c4bfcd5ea7521eac786ebf578b7cd549695864b0
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Thu Sep 3 10:05:12 2026 -0400

    [FLINK-40370][python] Fix bytes-backed Avro decimal framing (#28996)
---
 flink-python/pyflink/fn_execution/formats/avro.py  |  41 ++++++-
 .../pyflink/fn_execution/tests/test_avro_format.py | 133 +++++++++++++++++++++
 2 files changed, 173 insertions(+), 1 deletion(-)

diff --git a/flink-python/pyflink/fn_execution/formats/avro.py 
b/flink-python/pyflink/fn_execution/formats/avro.py
index 722fa926f49..8a6abee8f0b 100644
--- a/flink-python/pyflink/fn_execution/formats/avro.py
+++ b/flink-python/pyflink/fn_execution/formats/avro.py
@@ -17,7 +17,7 @@
 
################################################################################
 import struct
 
-from avro.errors import AvroTypeException, SchemaResolutionException
+from avro.errors import AvroOutOfScaleException, AvroTypeException, 
SchemaResolutionException
 from avro.io import (
     BinaryDecoder,
     BinaryEncoder,
@@ -87,6 +87,23 @@ class FlinkAvroDecoder(BinaryDecoder):
         assert (nbytes >= 0), nbytes
         return self.read(nbytes)
 
+    def read_decimal_from_bytes(self, precision, scale):
+        # avro's implementation sizes the payload with read_long, which is an 
Avro zig-zag long
+        # upstream but a fixed 8-byte long here. On the JVM a bytes-backed 
decimal is framed like
+        # any other bytes field, so the size is a 4-byte int.
+        size = self.read_int()
+        if size == 0:
+            # Data written before this fix sized the payload with the fixed 
8-byte write_long, so
+            # what was just read is the always-zero high half of that prefix 
and the size is in the
+            # next int. Such data can still be sitting in Python state written 
by an earlier
+            # version, so it stays readable.
+            #
+            # The two framings are unambiguous: avro writes at least one byte 
of unscaled value for
+            # every decimal, zero included, so a payload length of zero never 
occurs in the current
+            # framing and can only be the historical one.
+            size = self.read_int()
+        return self.read_decimal_from_fixed(precision, scale, size)
+
     def skip_int(self):
         self.skip(4)
 
@@ -198,6 +215,28 @@ class FlinkAvroEncoder(BinaryEncoder):
         self.write_int(len(datum))
         self.write(datum)
 
+    def write_decimal_bytes(self, datum, scale):
+        # avro's implementation sizes the payload with write_long, which is an 
Avro zig-zag long
+        # upstream but a fixed 8-byte long here, so the JVM reader consumed 
the size as the whole
+        # bytes field and every field after it shifted. Frame it as a bytes 
field instead; the
+        # payload itself is the same two's-complement big-endian unscaled 
value.
+        sign, digits, exp = datum.as_tuple()
+        if (-1 * int(exp)) > scale:
+            raise AvroOutOfScaleException(scale, datum, exp)
+
+        unscaled_datum = 0
+        for digit in digits:
+            unscaled_datum = (unscaled_datum * 10) + digit
+
+        bits_req = unscaled_datum.bit_length() + 1
+        if sign:
+            unscaled_datum = -unscaled_datum
+
+        bytes_req = bits_req // 8
+        bytes_req += 1 if (bytes_req << 3) < bits_req else 0
+
+        self.write_bytes(unscaled_datum.to_bytes(bytes_req, 'big', 
signed=True))
+
 
 class FlinkAvroDatumWriter(DatumWriter):
 
diff --git a/flink-python/pyflink/fn_execution/tests/test_avro_format.py 
b/flink-python/pyflink/fn_execution/tests/test_avro_format.py
new file mode 100644
index 00000000000..bbdb153df65
--- /dev/null
+++ b/flink-python/pyflink/fn_execution/tests/test_avro_format.py
@@ -0,0 +1,133 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""Tests for the JVM-compatible Avro encoder and decoder."""
+import io
+import logging
+import unittest
+from decimal import Decimal
+
+import avro.schema
+
+from pyflink.fn_execution.formats.avro import (
+    FlinkAvroDatumReader,
+    FlinkAvroDatumWriter,
+    FlinkAvroDecoder,
+    FlinkAvroEncoder,
+)
+from pyflink.testing.test_case_utils import PyFlinkTestCase
+
+DECIMAL_THEN_INT = """
+{
+  "type": "record",
+  "name": "DecimalRecord",
+  "fields": [
+    {
+      "name": "amount",
+      "type": {"type": "bytes", "logicalType": "decimal", "precision": 8, 
"scale": 2}
+    },
+    {"name": "tail", "type": "int"}
+  ]
+}
+"""
+
+
+class AvroFormatTests(PyFlinkTestCase):
+
+    @staticmethod
+    def _encode(schema, record):
+        buffer = io.BytesIO()
+        FlinkAvroDatumWriter(schema).write(record, FlinkAvroEncoder(buffer))
+        return buffer.getvalue()
+
+    def test_decimal_bytes_are_framed_like_any_other_bytes_field(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        encoded = self._encode(schema, {"amount": Decimal("12.34"), "tail": 7})
+
+        # 4-byte length, the two's-complement unscaled value, then the next 
field. Sizing the
+        # payload as an 8-byte long instead shifts every following field.
+        self.assertEqual("0000000204d200000007", encoded.hex())
+
+    def test_decimal_bytes_are_readable_field_by_field(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        encoded = self._encode(schema, {"amount": Decimal("12.34"), "tail": 7})
+
+        decoder = FlinkAvroDecoder(io.BytesIO(encoded))
+        self.assertEqual(b"\x04\xd2", decoder.read_bytes())
+        self.assertEqual(7, decoder.read_int())
+
+    def test_decimal_payload_is_unchanged_two_s_complement(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        expected = {
+            "0.00": "00",
+            "12.34": "04d2",
+            "-12.34": "fb2e",
+            "1.28": "0080",
+            "-1.28": "ff80",
+            "-1.29": "ff7f",
+        }
+        for value, payload in expected.items():
+            with self.subTest(value=value):
+                encoded = self._encode(schema, {"amount": Decimal(value), 
"tail": 0})
+                length = len(bytes.fromhex(payload))
+                self.assertEqual(length.to_bytes(4, "big").hex(), 
encoded[:4].hex())
+                self.assertEqual(payload, encoded[4:4 + length].hex())
+
+    def test_decimal_written_by_the_old_framing_is_still_readable(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        record = {"amount": Decimal("12.34"), "tail": 7}
+
+        # What an earlier version wrote: the payload length as a fixed 8-byte 
long. Kept readable
+        # because Python state may still hold it.
+        legacy = bytes.fromhex("000000000000000204d200000007")
+        decoded = FlinkAvroDatumReader(schema, schema).read(
+            FlinkAvroDecoder(io.BytesIO(legacy)))
+        self.assertEqual(record, decoded)
+
+        # The current framing is read from the same method, so neither shape 
needs a flag.
+        current = self._encode(schema, record)
+        self.assertEqual("0000000204d200000007", current.hex())
+        self.assertEqual(
+            record,
+            FlinkAvroDatumReader(schema, 
schema).read(FlinkAvroDecoder(io.BytesIO(current))))
+
+    def test_old_framing_is_readable_for_every_payload_length(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        for value in ("0.00", "12.34", "-12.34", "1.28", "-1.28", "999999.99", 
"-999999.99"):
+            with self.subTest(value=value):
+                current = self._encode(schema, {"amount": Decimal(value), 
"tail": 7})
+                size, payload = current[:4], current[4:-4]
+                # re-frame the same payload the way the old encoder did
+                legacy = int.from_bytes(size, "big").to_bytes(8, "big") + 
payload + current[-4:]
+                decoded = FlinkAvroDatumReader(schema, schema).read(
+                    FlinkAvroDecoder(io.BytesIO(legacy)))
+                self.assertEqual({"amount": Decimal(value), "tail": 7}, 
decoded)
+
+    def test_decimal_round_trip(self):
+        schema = avro.schema.parse(DECIMAL_THEN_INT)
+        for value in ("0.00", "12.34", "-12.34", "1.27", "-1.28", "999999.99", 
"-999999.99"):
+            with self.subTest(value=value):
+                record = {"amount": Decimal(value), "tail": 7}
+                encoded = self._encode(schema, record)
+                decoded = FlinkAvroDatumReader(schema, schema).read(
+                    FlinkAvroDecoder(io.BytesIO(encoded)))
+                self.assertEqual(record, decoded)
+
+
+if __name__ == '__main__':
+    logging.getLogger().setLevel(logging.INFO)
+    unittest.main()

Reply via email to