|
23 | 23 | import enum |
24 | 24 | import logging |
25 | 25 | import math |
| 26 | +import pickle |
| 27 | +import textwrap |
26 | 28 | import unittest |
27 | 29 | from decimal import Decimal |
28 | 30 | from typing import Any |
@@ -606,6 +608,149 @@ def test_param_windowed_value_coder(self): |
606 | 608 | 1, (window.IntervalWindow(11, 21), ), |
607 | 609 | PaneInfo(True, False, 1, 2, 3)))) |
608 | 610 |
|
| 611 | + def test_cross_process_encoding_of_special_types_is_deterministic(self): |
| 612 | + """Test cross-process determinism for all special deterministic types""" |
| 613 | + # pylint: disable=line-too-long |
| 614 | + script = textwrap.dedent( |
| 615 | + '''\ |
| 616 | + import pickle |
| 617 | + import sys |
| 618 | + import collections |
| 619 | + import enum |
| 620 | + import logging |
| 621 | +
|
| 622 | + from apache_beam.coders import coders |
| 623 | + from apache_beam.coders import proto2_coder_test_messages_pb2 as test_message |
| 624 | + from typing import NamedTuple |
| 625 | +
|
| 626 | + try: |
| 627 | + import dataclasses |
| 628 | + except ImportError: |
| 629 | + dataclasses = None |
| 630 | +
|
| 631 | + logging.basicConfig( |
| 632 | + level=logging.INFO, |
| 633 | + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', |
| 634 | + stream=sys.stderr, |
| 635 | + force=True |
| 636 | + ) |
| 637 | +
|
| 638 | + # Define all the special types that encode_special_deterministic handles |
| 639 | + MyNamedTuple = collections.namedtuple('A', ['x', 'y']) |
| 640 | + MyTypedNamedTuple = NamedTuple('MyTypedNamedTuple', [('f1', int), ('f2', str)]) |
| 641 | +
|
| 642 | + class MyEnum(enum.Enum): |
| 643 | + E1 = 5 |
| 644 | + E2 = enum.auto() |
| 645 | + E3 = 'abc' |
| 646 | +
|
| 647 | + MyIntEnum = enum.IntEnum('MyIntEnum', 'I1 I2 I3') |
| 648 | + MyIntFlag = enum.IntFlag('MyIntFlag', 'F1 F2 F3') |
| 649 | + MyFlag = enum.Flag('MyFlag', 'F1 F2 F3') |
| 650 | +
|
| 651 | + if dataclasses is not None: |
| 652 | + @dataclasses.dataclass(frozen=True) |
| 653 | + class FrozenDataClass: |
| 654 | + a: int |
| 655 | + b: int |
| 656 | +
|
| 657 | + class DefinesGetAndSetState: |
| 658 | + def __init__(self, value): |
| 659 | + self.value = value |
| 660 | +
|
| 661 | + def __getstate__(self): |
| 662 | + return self.value |
| 663 | +
|
| 664 | + def __setstate__(self, value): |
| 665 | + self.value = value |
| 666 | +
|
| 667 | + def __eq__(self, other): |
| 668 | + return type(other) is type(self) and other.value == self.value |
| 669 | + |
| 670 | + # Test cases for all special deterministic types |
| 671 | + # NOTE: When this script run in a subprocess the module is considered |
| 672 | + # __main__. Dill cannot pickle enums in __main__ because it |
| 673 | + # needs to define a way to create the type if it does not exist |
| 674 | + # in the session, and reaches recursion depth limits. |
| 675 | + test_cases = [ |
| 676 | + ("proto_message", test_message.MessageA(field1='value')), |
| 677 | + ("named_tuple_simple", MyNamedTuple(1, 2)), |
| 678 | + ("typed_named_tuple", MyTypedNamedTuple(1, 'a')), |
| 679 | + ("named_tuple_list", [MyNamedTuple(1, 2), MyTypedNamedTuple(1, 'a')]), |
| 680 | + # ("enum_single", MyEnum.E1), |
| 681 | + # ("enum_list", list(MyEnum)), |
| 682 | + # ("int_enum_list", list(MyIntEnum)), |
| 683 | + # ("int_flag_list", list(MyIntFlag)), |
| 684 | + # ("flag_list", list(MyFlag)), |
| 685 | + ("getstate_setstate_simple", DefinesGetAndSetState(1)), |
| 686 | + ("getstate_setstate_complex", DefinesGetAndSetState((1, 2, 3))), |
| 687 | + ("getstate_setstate_list", [DefinesGetAndSetState(1), DefinesGetAndSetState((1, 2, 3))]), |
| 688 | + ] |
| 689 | +
|
| 690 | + if dataclasses is not None: |
| 691 | + test_cases.extend([ |
| 692 | + ("frozen_dataclass", FrozenDataClass(1, 2)), |
| 693 | + ("frozen_dataclass_list", [FrozenDataClass(1, 2), FrozenDataClass(3, 4)]), |
| 694 | + ]) |
| 695 | +
|
| 696 | + coder = coders.FastPrimitivesCoder() |
| 697 | + deterministic_coder = coders.DeterministicFastPrimitivesCoder(coder, 'step') |
| 698 | + |
| 699 | + results = {} |
| 700 | + for test_name, value in test_cases: |
| 701 | + try: |
| 702 | + encoded = deterministic_coder.encode(value) |
| 703 | + results[test_name] = encoded |
| 704 | + except Exception as e: |
| 705 | + logging.warning("Encoding failed with %s", e) |
| 706 | + sys.exit(1) |
| 707 | + |
| 708 | + sys.stdout.buffer.write(pickle.dumps(results)) |
| 709 | + |
| 710 | + |
| 711 | + ''') |
| 712 | + |
| 713 | + def run_subprocess(): |
| 714 | + import subprocess |
| 715 | + import sys |
| 716 | + |
| 717 | + result = subprocess.run([sys.executable, '-c', script], |
| 718 | + capture_output=True, |
| 719 | + timeout=30, |
| 720 | + check=False) |
| 721 | + |
| 722 | + self.assertEqual( |
| 723 | + 0, result.returncode, f"Subprocess failed: {result.stderr}") |
| 724 | + return pickle.loads(result.stdout) |
| 725 | + |
| 726 | + results1 = run_subprocess() |
| 727 | + results2 = run_subprocess() |
| 728 | + |
| 729 | + coder = coders.FastPrimitivesCoder() |
| 730 | + deterministic_coder = coders.DeterministicFastPrimitivesCoder(coder, 'step') |
| 731 | + |
| 732 | + for test_name in results1: |
| 733 | + data1 = results1[test_name] |
| 734 | + data2 = results2[test_name] |
| 735 | + |
| 736 | + self.assertEqual( |
| 737 | + data1, data2, f"Cross-process encoding differs for {test_name}") |
| 738 | + self.assertGreater(len(data1), 1) |
| 739 | + |
| 740 | + try: |
| 741 | + decoded1 = deterministic_coder.decode(data1) |
| 742 | + decoded2 = deterministic_coder.decode(data2) |
| 743 | + except Exception as e: |
| 744 | + logging.warning("Could not decode %s data due to %s", test_name, e) |
| 745 | + continue |
| 746 | + |
| 747 | + self.assertEqual( |
| 748 | + decoded1, decoded2, f"Cross-process decoding differs for {test_name}") |
| 749 | + self.assertIsInstance( |
| 750 | + decoded1, |
| 751 | + type(decoded2), |
| 752 | + f"Cross-process decoding differs for {test_name}") |
| 753 | + |
609 | 754 | def test_proto_coder(self): |
610 | 755 | # For instructions on how these test proto message were generated, |
611 | 756 | # see coders_test.py |
|
0 commit comments