Copilot commented on code in PR #70446:
URL: https://github.com/apache/airflow/pull/70446#discussion_r3650615499


##########
airflow-core/src/airflow/cli/commands/dag_command.py:
##########
@@ -79,6 +81,89 @@
 # Chunk size for bulk delete.
 _RUN_CHUNK_SIZE = 500
 
+def _normalize_serialized_dag_for_stability_check(serialized_dag: dict[str, 
Any]) -> dict[str, Any]:

Review Comment:
   PEP 8 / ruff will flag this block because top-level function definitions 
must be preceded by two blank lines. Add an extra blank line between the 
module-level constant and the first helper.



##########
airflow-core/src/airflow/cli/commands/dag_command.py:
##########
@@ -79,6 +81,89 @@
 # Chunk size for bulk delete.
 _RUN_CHUNK_SIZE = 500
 
+def _normalize_serialized_dag_for_stability_check(serialized_dag: dict[str, 
Any]) -> dict[str, Any]:
+    normalized = SerializedDagModel._sort_serialized_dag_dict(serialized_dag)
+    normalized["dag"].pop("fileloc", None)
+    normalized["dag"].pop("bundle_name", None)
+    return normalized
+
+def _serialize_dag_for_stability_check(dag: DAG) -> tuple[str, dict[str, Any]]:
+    serialized_dag = DagSerialization.to_dict(dag)
+    return SerializedDagModel.hash(serialized_dag), 
_normalize_serialized_dag_for_stability_check(serialized_dag)
+
+def _format_stability_diff(
+    dag_id: str,
+    first_serialized_dag: dict[str, Any],
+    second_serialized_dag: dict[str, Any],
+) -> str:
+    before = json.dumps(first_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    after = json.dumps(second_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    diff = difflib.unified_diff(
+        before,
+        after,
+        fromfile=f"parse 1: {dag_id} ",
+        tofile=f"parse 2: {dag_id} ",
+        lineterm="",
+    )
+    return "\n".join(diff)
+
+def _parse_dags_for_stability_check(dag_folder: str | None) -> DagBag:
+    return DagBag(dag_folder=dag_folder, safe_mode=True, load_op_links=False)

Review Comment:
   The stability check currently forces DagBag safe_mode=True, which overrides 
the configured DAG_DISCOVERY_SAFE_MODE and can silently skip Dag files. This 
makes the CLI check incomplete in deployments that disable safe_mode (or want 
it honored).



##########
airflow-core/src/airflow/cli/commands/dag_command.py:
##########
@@ -79,6 +81,89 @@
 # Chunk size for bulk delete.
 _RUN_CHUNK_SIZE = 500
 
+def _normalize_serialized_dag_for_stability_check(serialized_dag: dict[str, 
Any]) -> dict[str, Any]:
+    normalized = SerializedDagModel._sort_serialized_dag_dict(serialized_dag)
+    normalized["dag"].pop("fileloc", None)
+    normalized["dag"].pop("bundle_name", None)
+    return normalized
+
+def _serialize_dag_for_stability_check(dag: DAG) -> tuple[str, dict[str, Any]]:
+    serialized_dag = DagSerialization.to_dict(dag)
+    return SerializedDagModel.hash(serialized_dag), 
_normalize_serialized_dag_for_stability_check(serialized_dag)
+
+def _format_stability_diff(
+    dag_id: str,
+    first_serialized_dag: dict[str, Any],
+    second_serialized_dag: dict[str, Any],
+) -> str:
+    before = json.dumps(first_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    after = json.dumps(second_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    diff = difflib.unified_diff(
+        before,
+        after,
+        fromfile=f"parse 1: {dag_id} ",
+        tofile=f"parse 2: {dag_id} ",
+        lineterm="",
+    )
+    return "\n".join(diff)
+
+def _parse_dags_for_stability_check(dag_folder: str | None) -> DagBag:
+    return DagBag(dag_folder=dag_folder, safe_mode=True, load_op_links=False)
+
+@cli_utils.action_cli
+@providers_configuration_loaded
+def dag_stability_check(args) -> None:
+    dag_hashes_by_id: dict[str, list[str]] = {}
+    serialized_dags_by_id: dict[str, list[dict[str, Any]]] = {}
+    seen_dag_ids: set[str] = set()
+    nParse = 2 #NOTE: Set parsing number 2, in most of the case twice parse 
should catch the stability issues. 
+
+    for iteration in range(1, nParse+1):
+        dagbag = _parse_dags_for_stability_check(args.dag_folder)
+
+        dags = dagbag.dags
+        if args.dag_id is not None:
+            dags = {args.dag_id: dagbag.dags[args.dag_id]} if args.dag_id in 
dagbag.dags else {}
+
+        for dag_id, dag in sorted(dags.items()):
+            dag_hash, serialized_dag = _serialize_dag_for_stability_check(dag)
+            dag_hashes_by_id.setdefault(dag_id, []).append(dag_hash)
+            serialized_dags_by_id.setdefault(dag_id, []).append(serialized_dag)
+            seen_dag_ids.add(dag_id)
+
+        if args.fail_fast:
+            for dag_id, dag_hashes in dag_hashes_by_id.items():
+                if len(set(dag_hashes)) > 1:
+                    break
+            else:
+                continue
+            break
+
+    if args.dag_id is not None and args.dag_id not in seen_dag_ids:
+        raise SystemExit(f"Dag {args.dag_id!r} was not found.")
+
+    unstable_dag_ids = [
+        dag_id for dag_id, dag_hashes in sorted(dag_hashes_by_id.items()) if 
len(set(dag_hashes)) > 1
+    ]
+    if unstable_dag_ids:
+        print("Dag stability check failed. The following Dags produced 
different serialized output:")
+        for dag_id in unstable_dag_ids:
+            dag_hashes = dag_hashes_by_id[dag_id]
+            print(f"\ndag_id: {dag_id}:")
+            for iteration, dag_hash in enumerate(dag_hashes, start=1):

Review Comment:
   The per-Dag header line prints as "dag_id: <id>:" (duplicated label + 
trailing colon), which is confusing in the failure output and makes grepping 
harder. Consider printing just "<dag_id>:" (or "dag_id: <id>") instead.



##########
airflow-core/src/airflow/cli/cli_config.py:
##########
@@ -1242,6 +1252,21 @@ class GroupCommand(NamedTuple):
         func=lazy_load_command("airflow.cli.commands.dag_command.dag_report"),
         args=(ARG_BUNDLE_NAME, ARG_OUTPUT, ARG_VERBOSE),
     ),
+    ActionCommand(
+        name="stability",
+        help="Check that serialized Dags are stable across repeated parses to 
prevent dag_version inflection",
+        description=(

Review Comment:
   Typo in help text: this should be "dag_version inflation" (the feature this 
command is intended to help diagnose), not "inflection".



##########
airflow-core/docs/faq.rst:
##########
@@ -417,7 +417,7 @@ configuration option:
 
 Additionally, you can catch these issues earlier in your development workflow 
by using the
 `AIR304 <https://docs.astral.sh/ruff/rules/airflow3-dag-dynamic-value/>`_ ruff 
rule, which detects
-dynamic values in Dag and Task constructors as part of static linting. See
+dynamic values in Dag and Task constructors as part of static linting or 
``airflow dags stability`` CLI command. See
 :ref:`best_practices/code_quality_and_linting` for how to set up ruff with 
Airflow-specific rules.

Review Comment:
   This sentence is grammatically ambiguous: it reads as if the ruff rule 
detects issues "as part of ... CLI command". Consider splitting into two 
sentences so it's clear the CLI command is an alternative way to catch the same 
issues.



##########
airflow-core/src/airflow/cli/commands/dag_command.py:
##########
@@ -79,6 +81,89 @@
 # Chunk size for bulk delete.
 _RUN_CHUNK_SIZE = 500
 
+def _normalize_serialized_dag_for_stability_check(serialized_dag: dict[str, 
Any]) -> dict[str, Any]:
+    normalized = SerializedDagModel._sort_serialized_dag_dict(serialized_dag)
+    normalized["dag"].pop("fileloc", None)
+    normalized["dag"].pop("bundle_name", None)
+    return normalized
+
+def _serialize_dag_for_stability_check(dag: DAG) -> tuple[str, dict[str, Any]]:
+    serialized_dag = DagSerialization.to_dict(dag)
+    return SerializedDagModel.hash(serialized_dag), 
_normalize_serialized_dag_for_stability_check(serialized_dag)
+
+def _format_stability_diff(
+    dag_id: str,
+    first_serialized_dag: dict[str, Any],
+    second_serialized_dag: dict[str, Any],
+) -> str:
+    before = json.dumps(first_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    after = json.dumps(second_serialized_dag, indent=2, 
sort_keys=True).splitlines()
+    diff = difflib.unified_diff(
+        before,
+        after,
+        fromfile=f"parse 1: {dag_id} ",
+        tofile=f"parse 2: {dag_id} ",
+        lineterm="",
+    )
+    return "\n".join(diff)
+
+def _parse_dags_for_stability_check(dag_folder: str | None) -> DagBag:
+    return DagBag(dag_folder=dag_folder, safe_mode=True, load_op_links=False)
+
+@cli_utils.action_cli
+@providers_configuration_loaded
+def dag_stability_check(args) -> None:
+    dag_hashes_by_id: dict[str, list[str]] = {}
+    serialized_dags_by_id: dict[str, list[dict[str, Any]]] = {}
+    seen_dag_ids: set[str] = set()
+    nParse = 2 #NOTE: Set parsing number 2, in most of the case twice parse 
should catch the stability issues. 
+
+    for iteration in range(1, nParse+1):

Review Comment:
   Use snake_case for local variables and clean up the inline comment spacing 
to match project style (and avoid ruff E261/E262).



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