muditagrawal2007 opened a new issue, #74991:
URL: https://github.com/apache/airflow/issues/74991

   ### Summary
   
   `BaseOperator.template_fields_extra` is typed `ClassVar[frozenset[str]]`. 
The PR that introduces it (apache/airflow#74920) iterates that value in three 
places to build the merged `template_fields` tuple and the 
`MappedOperator.template_fields_extra` value. **`frozenset` iteration order is 
not stable across Python processes** — string hashing in CPython 3.3+ is 
randomized per process via `PYTHONHASHSEED`. The result is a different 
`template_fields_extra` / merged `template_fields` order in the Dag processor, 
scheduler, worker, and tests, and a different serialized value in the metadata 
DB on every parse.
   
   ### Reproduce
   
   ```text
   $ for i in 1 2 3 4 5; do python3 -c 
"print(tuple(frozenset({'alpha','beta','gamma'})))"; done
   ('gamma', 'beta', 'alpha')
   ('beta', 'alpha', 'gamma')
   ('alpha', 'gamma', 'beta')
   ('gamma', 'alpha', 'beta')
   ('beta', 'gamma', 'alpha')
   ```
   
   ### Where it bites in the existing PR
   
   - `task-sdk/src/airflow/sdk/bases/operator.py:1269-1271` — 
`BaseOperator.__init__` does `*type(self).template_fields_extra` (a 
`frozenset`); the merged `template_fields` order depends on the process.
   - `task-sdk/src/airflow/sdk/definitions/mappedoperator.py:254-262` — 
`OperatorPartial.__call__` does 
`tuple(dict.fromkeys((*self.operator_class.template_fields, 
*self.operator_class.template_fields_extra)))` and 
`template_fields_extra=tuple(self.operator_class.template_fields_extra)`. The 
order flows into `MappedOperator.template_fields` and `template_fields_extra`.
   - `task-sdk/src/airflow/sdk/bases/decorator.py:723-732` — same shape on the 
`@task` decorator path.
   - `airflow-core/src/airflow/serialization/serialized_objects.py:1373` — the 
resulting tuple is written into `SerializedBaseOperator.template_fields_extra` 
and persisted.
   - `airflow-core/src/airflow/serialization/dag_version_diff.py:268` — 
`template_fields_extra` is one of the fields the diff uses to compute the next 
serialized Dag version, so order drift between parses triggers spurious 
Dag-version churn.
   
   ### Concrete test failures
   
   - 
`task-sdk/tests/task_sdk/bases/test_operator.py::TestTemplateFieldsExtra::test_template_fields_extra_preserves_order_and_deduplicates`
 asserts a deterministic order from `frozenset({"arg2", "arg3"})`; that 
assertion only holds in processes whose `PYTHONHASHSEED` happens to iterate the 
set in that order. Running under a different seed flips the list to `["arg1", 
"arg3", "arg2"]` and the test fails.
   - 
`test_template_fields_extra_preserves_order_and_deduplicates_through_mapped_operator`
 asserts the same property through `.partial().expand()` and is similarly 
seed-dependent.
   - The serialization tests in 
`airflow-core/tests/unit/serialization/test_dag_serialization.py` already work 
around the instability: `test_serialize_template_fields_extra_roundtrip` (line 
2096) and `test_serialize_template_fields_extra_roundtrip_mapped` use 
`sorted()` / `set()` on the result. The author of the PR felt the instability 
and patched the test instead of fixing the data type.
   
   ### Why this contradicts the new docstring
   
   `task-sdk/src/airflow/sdk/bases/operator.py:954-959`:
   
   > Additional fields, declared by subclasses, that should be rendered with 
Jinja and treated as `XComArg` capable in addition to `template_fields`. Use 
this for string fields that are intentionally not promoted into 
`template_fields` but still need to receive XCom references (such as 
`some_task.output`) from upstream tasks.
   
   The docstring promises a deterministic, declared order; `frozenset[str]` 
cannot deliver either. The merge in `__init__` is described as 
`dict.fromkeys((*self.template_fields, *extra))` "preserving order, 
deduplicating" — but `*extra` over a `frozenset` does not preserve any 
user-declared order at all.
   
   ### Impact
   
   - Spurious Dag-version diffs on every reparse when the seed rolls 
differently between processes. This is the same class of bug as the 
recently-closed apache/airflow#53957 ("`@task.kubernetes` decorator causes 
different dag hash on every serialized dag update").
   - `MappedOperator.template_fields_extra` and the merged `template_fields` 
are written to the metadata DB; reading them back across processes yields a 
tuple that may not match the in-memory object.
   - The order of fields in the rendered-template UI table for any operator 
that opts in is process-dependent.
   
   ### Suggested fix
   
   1. Change `BaseOperator.template_fields_extra` to `ClassVar[Sequence[str]] = 
()`. Accept `tuple`, `list`, or `frozenset`; document the supported types and 
the fact that declaration order is what is preserved.
   2. Stop materialising `tuple(self.operator_class.template_fields_extra)`; 
instead, cast / coerce to `tuple` while preserving the source order. Same 
change in `OperatorPartial.__call__`, `_TaskDecorator`, and the 
`BaseOperator.__init__` merge.
   3. Tighten `MappedOperator.template_fields_extra` and 
`SerializedBaseOperator.template_fields_extra` to `Sequence[str]` so the 
contract is explicit in the type.
   4. Update the order/dedup tests so they feed an ordered `tuple` and assert 
that order verbatim. The `frozenset` cases should be either dropped or 
rewritten to assert only the set, not the order.
   5. Add a regression test that re-runs the order-preservation assertion in a 
subprocess with `PYTHONHASHSEED=random` so a future change cannot silently make 
the order seed-dependent again.
   
   ### Environment
   
   - Reproduced on macOS, CPython 3.14.7.
   - Discovered while reviewing apache/airflow#74920.
   


-- 
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