Skip to content

Commit 95b28b1

Browse files
claudevdmClaude
andauthored
Add cross-process determinism check for DeterministicFastPrimitivesCoder. (#35433)
* Add cross-process determinism check for DeterministicFastPrimitivesCoder. * Fix test. * Update test name. --------- Co-authored-by: Claude <cvandermerwe@google.com>
1 parent 296a614 commit 95b28b1

1 file changed

Lines changed: 145 additions & 0 deletions

File tree

sdks/python/apache_beam/coders/coders_test_common.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import enum
2424
import logging
2525
import math
26+
import pickle
27+
import textwrap
2628
import unittest
2729
from decimal import Decimal
2830
from typing import Any
@@ -606,6 +608,149 @@ def test_param_windowed_value_coder(self):
606608
1, (window.IntervalWindow(11, 21), ),
607609
PaneInfo(True, False, 1, 2, 3))))
608610

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+
609754
def test_proto_coder(self):
610755
# For instructions on how these test proto message were generated,
611756
# see coders_test.py

0 commit comments

Comments
 (0)