| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # Note: Any AirflowException raised is expected to cause the TaskInstance |
| # to be marked in an ERROR state |
| |
| from __future__ import annotations |
| |
| import json |
| import os |
| import re |
| |
| import pytest |
| |
| from airflow.configuration import conf |
| from airflow.models import DagBag |
| |
| try: |
| from airflow.sdk import TriggerRule |
| except ImportError: |
| # Compatibility for Airflow < 3.1 |
| from airflow.utils.trigger_rule import TriggerRule # type: ignore[no-redef,attr-defined] |
| |
| DAGS_DIR = os.path.join(os.path.dirname(__file__), "../src/performance_dags/performance_dag") |
| |
| |
| def setup_dag( |
| dag_count="1", |
| task_count="10", |
| start_date="", |
| start_ago="1h", |
| schedule_interval_env="@once", |
| dag_shape="no_structure", |
| sleep_time="0", |
| operator_type="bash", |
| start_paused="1", |
| task_trigger_rule=TriggerRule.ALL_SUCCESS, |
| **extra_args, |
| ): |
| os.environ["PERF_DAGS_COUNT"] = dag_count |
| os.environ["PERF_TASKS_COUNT"] = task_count |
| os.environ["PERF_START_DATE"] = start_date |
| os.environ["PERF_START_AGO"] = start_ago |
| os.environ["PERF_SCHEDULE_INTERVAL"] = schedule_interval_env |
| os.environ["PERF_SHAPE"] = dag_shape |
| os.environ["PERF_SLEEP_TIME"] = sleep_time |
| os.environ["PERF_OPERATOR_TYPE"] = operator_type |
| os.environ["PERF_START_PAUSED"] = start_paused |
| os.environ["PERF_TASKS_TRIGGER_RULE"] = task_trigger_rule |
| os.environ["PERF_OPERATOR_EXTRA_KWARGS"] = json.dumps(extra_args) |
| |
| |
| def get_top_level_tasks(dag): |
| result = [] |
| for task in dag.tasks: |
| if not task.upstream_list: |
| result.append(task) |
| return result |
| |
| |
| def get_leaf_tasks(dag): |
| result = [] |
| for task in dag.tasks: |
| if not task.downstream_list: |
| result.append(task) |
| return result |
| |
| |
| # Test fixture |
| @pytest.fixture(scope="session", autouse=True) |
| def airflow_config(): |
| """ |
| Update airflow config for the test. |
| |
| It sets the following configuration values: |
| - core.unit_test_mode: True |
| - lineage.backend: "" |
| |
| Returns: |
| AirflowConfigParser: The Airflow configuration object. |
| """ |
| conf.set("lineage", "backend", "") |
| return conf |
| |
| |
| def get_dags(dag_count=1, task_count=10, operator_type="bash", dag_shape="no_structure"): |
| """Generate a tuple of dag_id, <DAG objects> in the DagBag.""" |
| setup_dag( |
| task_count=str(task_count), |
| dag_count=str(dag_count), |
| operator_type=operator_type, |
| dag_shape=dag_shape, |
| ) |
| dag_bag = DagBag(DAGS_DIR, include_examples=False) |
| |
| def strip_path_prefix(path): |
| return os.path.relpath(path, DAGS_DIR) |
| |
| return [(k, v, strip_path_prefix(v.fileloc)) for k, v in dag_bag.dags.items()] |
| |
| |
| def get_import_errors(): |
| """Generate a tuple for import errors in the dag bag.""" |
| dag_bag = DagBag(DAGS_DIR, include_examples=False) |
| |
| def strip_path_prefix(path): |
| return os.path.relpath(path, DAGS_DIR) |
| |
| # prepend "(None,None)" to ensure that a test object is always created even if it's a no op. |
| return [(None, None)] + [(strip_path_prefix(k), v.strip()) for k, v in dag_bag.import_errors.items()] |
| |
| |
| @pytest.mark.parametrize("rel_path,rv", get_import_errors(), ids=[x[0] for x in get_import_errors()]) |
| def test_file_imports(rel_path, rv): |
| """Test for import errors on a file.""" |
| if rel_path and rv: |
| pytest.fail(f"{rel_path} failed to import with message \n {rv}") |
| |
| |
| @pytest.mark.parametrize("dag_count,task_count", [(1, 1), (1, 10), (10, 10), (10, 100)]) |
| def test_performance_dag(dag_count, task_count): |
| dags = get_dags(dag_count=dag_count, task_count=task_count) |
| assert len(dags) == dag_count |
| ids = [x[0] for x in dags] |
| pattern = f"performance_dag__SHAPE_no_structure__DAGS_COUNT_\\d+_of_{dag_count}__TASKS_COUNT_{task_count}__START_DATE_1h__SCHEDULE_INTERVAL_once" |
| for id in ids: |
| assert re.search(pattern, id) |
| for dag in dags: |
| performance_dag = dag[1] |
| assert len(performance_dag.tasks) == task_count, f"DAG has no {task_count} tasks" |
| for task in performance_dag.tasks: |
| t_rule = task.trigger_rule |
| assert t_rule == "all_success", f"{task} in DAG has the trigger rule {t_rule}" |
| assert task.operator_name == "BashOperator", f"{task} should be based on bash operator" |
| |
| |
| def test_performance_dag_shape_binary_tree(): |
| def assert_two_downstream(task): |
| assert len(task.downstream_list) <= 2 |
| for downstream_task in task.downstream_list: |
| assert_two_downstream(downstream_task) |
| |
| dags = get_dags(task_count=100, dag_shape="binary_tree") |
| id, dag, _ = dags[0] |
| assert ( |
| id |
| == "performance_dag__SHAPE_binary_tree__DAGS_COUNT_1_of_1__TASKS_COUNT_100__START_DATE_1h__SCHEDULE_INTERVAL_once" |
| ) |
| assert len(dag.tasks) == 100 |
| top_level_tasks = get_top_level_tasks(dag) |
| assert len(top_level_tasks) == 1 |
| for task in top_level_tasks: |
| assert_two_downstream(task) |