vishalmore90 opened a new issue, #41238:
URL: https://github.com/apache/beam/issues/41238

   
   ### What happened?
   
   In Apache Beam's YAML SDK, `YamlProviders.Flatten._merge_schemas` 
(`sdks/python/apache_beam/yaml/yaml_provider.py:1026-1066`) unifies schemas 
across input `PCollection`s. However, the implementation has three critical 
flaws:
   
   #### 1. Non-deterministic Field Ordering Across Runs and Workers
   In `sdks/python/apache_beam/yaml/yaml_provider.py:1050-1064`:
   ```python
   # Merge all field names and types.
   all_field_names = set().union(*(s.keys() for s in schemas))
   unified_fields = {}
   for name in all_field_names:
     ...
     unified_fields[name] = ...
   
   # Create unified schema
   if unified_fields:
     unified_schema = named_fields_to_schema(list(unified_fields.items()))
     return named_tuple_from_schema(unified_schema)
   ```
   Because `all_field_names` is an unordered Python `set`, iterating over it 
(`for name in all_field_names`) depends on hash randomization 
(`PYTHONHASHSEED`). Across different runs, workers, or Python environments, the 
fields in `unified_schema` and the generated NamedTuple class are ordered 
non-deterministically.
   
   For example, when input schemas have fields `['first', 'second', 'third', 
'fourth', 'fifth']`:
   - With `PYTHONHASHSEED=1`: fields are ordered `['fifth', 'first', 'third', 
'fourth', 'second']`
   - With `PYTHONHASHSEED=42`: fields are ordered `['third', 'fifth', 'fourth', 
'second', 'first']`
   - With `PYTHONHASHSEED=100`: fields are ordered `['first', 'fifth', 
'second', 'fourth', 'third']`
   
   In Beam schemas and Python `namedtuple`s, field ordering determines 
positional indexing (`row[0]`, `row[1]`), schema compatibility checks, and 
column ordering in schema-aware sinks (e.g., BigQuery, Parquet, Avro). 
Non-deterministic ordering causes schema mismatch errors and inconsistent sink 
schemas.
   
   #### 2. Loss of Concrete Type: `Optional[T]` Merged with `T` Degrades to 
`Optional[Any]`
   In `sdks/python/apache_beam/yaml/yaml_provider.py:1052-1057`:
   ```python
   for name in all_field_names:
     present_types = {s[name] for s in schemas if name in s}
     if len(present_types) > 1:
       unified_fields[name] = Optional[Any]
     else:
       unified_fields[name] = Optional[present_types.pop()]
   ```
   In Python typing, `int != Optional[int]` (since `Optional[int]` is 
`Union[int, NoneType]`). If one input `PCollection` defines a field as `int` 
and another input defines it as `Optional[int]`, `present_types` becomes `{int, 
Optional[int]}`, which has length 2.
   
   Consequently, `len(present_types) > 1` triggers, and the field's type is 
degraded to `Optional[Any]`, completely discarding the concrete `int` type 
constraint even though the type is identical across all inputs.
   
   #### 3. Unconditional Wrapping in `Optional[...]` and Performance Overhead 
on Identical Schemas
   Even when all input `PCollection`s share the exact same schema with 
non-optional, required fields:
   - Every field is unconditionally wrapped in `Optional[...]` (e.g. `int` 
becomes `Optional[int]`).
   - `_merge_schemas` returns a newly synthesized schema rather than 
recognizing that schemas are already uniform.
   - `Flatten.expand` applies `beam.Map(_unify_element_with_schema)` to every 
input collection, forcing every single row in the pipeline to be converted from 
NamedTuple to `dict` and back to NamedTuple, incurring substantial per-element 
CPU and memory overhead instead of directly delegating to native `beam.Flatten`.
   
   ---
   
   ### Steps to Reproduce
   
   Run the following minimal script:
   
   ```python
   from typing import Optional, Any
   from apache_beam.yaml.yaml_provider import YamlProviders
   
   # Case 1: Combining int and Optional[int]
   s1 = {"id": int, "name": str}
   s2 = {"id": Optional[int], "name": str}
   
   all_field_names = set().union(*(s.keys() for s in [s1, s2]))
   unified_fields = {}
   for name in all_field_names:
       present_types = {s[name] for s in [s1, s2] if name in s}
       if len(present_types) > 1:
           unified_fields[name] = Optional[Any]
       else:
           unified_fields[name] = Optional[present_types.pop()]
   
   print("Unified 'id' type:", unified_fields["id"])
   # Output: typing.Optional[typing.Any] (Expected: typing.Optional[int])
   ```
   
   ---
   
   ### Proposed Solution
   
   1. **Preserve Deterministic Field Ordering**:
      Instead of `set().union(*(s.keys() for s in schemas))`, preserve 
insertion order across input schemas using `dict.fromkeys`:
      ```python
      all_field_names = list(dict.fromkeys(name for s in schemas for name in 
s.keys()))
      ```
   2. **Unwrap `Optional` Before Comparing Types**:
      Extract inner types using 
`apache_beam.typehints.trivial_inference.extract_optional_type` (or 
`typing.get_args`) so that `{int, Optional[int]}` resolves to `int` and unifies 
cleanly as `Optional[int]`.
   3. **Only Mark Fields Optional When Necessary**:
      A field only needs to be `Optional` if it was already optional in an 
input schema or is missing from at least one input schema (`len([s for s in 
schemas if name in s]) < len(schemas)`).
   4. **Bypass Unification When Schemas Are Already Identical**:
      If all input schemas are identical, return `None` from `_merge_schemas` 
so `Flatten.expand` uses native `beam.Flatten(**pipeline_arg)` with zero 
overhead.
   
   ---
   
   ### Issue Priority
   
   Priority: 2 (default / most bugs should be filed as P2)
   
   ### Issue Components
   
   - [x] Component: Beam YAML
   - [x] Component: Python SDK
   
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to