From c539e01687a9d1699db86a9381842853b1eaf193 Mon Sep 17 00:00:00 2001 From: Sameer Mesiah Date: Sun, 14 Jun 2026 00:13:37 +0100 Subject: [PATCH] Triggerer: add batch trigger creation duration metric Add a timing metric that records the time spent creating all pending triggers during a single create_triggers() invocation. Also add a unit test verifying that the metric is emitted with the expected tags when triggers are successfully created. --- airflow-core/newsfragments/68521.feature.rst | 1 + .../src/airflow/jobs/triggerer_job_runner.py | 13 +++++ .../tests/unit/jobs/test_triggerer_job.py | 55 ++++++++++++++++++- .../metrics/metrics_template.yaml | 6 ++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 airflow-core/newsfragments/68521.feature.rst diff --git a/airflow-core/newsfragments/68521.feature.rst b/airflow-core/newsfragments/68521.feature.rst new file mode 100644 index 0000000000000..10c76c8b1c44b --- /dev/null +++ b/airflow-core/newsfragments/68521.feature.rst @@ -0,0 +1 @@ +Added the ``triggerer.batch_trigger_creation_duration`` metric, which measures the total time spent creating pending triggers during a single trigger runner cycle. diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 50a899c2cf256..759735242ab1d 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -1345,6 +1345,12 @@ def create_runtime_ti( async def create_triggers(self): """Drain the to_create queue and create all new triggers that have been requested in the DB.""" + # Emit batch creation duration only when triggers were processed. + has_work = bool(self.to_create) + + if has_work: + creation_start = time.monotonic() + while self.to_create: await asyncio.sleep(0) context: Context | None = None @@ -1419,6 +1425,13 @@ async def create_triggers(self): "events": 0, } + if has_work: + stats.timing( + "triggerer.batch_trigger_creation_duration", + (time.monotonic() - creation_start) * 1000, + tags=prune_dict({"team_name": self.team_name}), + ) + async def cancel_triggers(self): """ Drain the to_cancel queue and ensure all triggers that are not in the DB are cancelled. diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index a67b6af52548a..fa4d68f5fff6a 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -1588,12 +1588,65 @@ async def test_create_triggers_emits_queue_delay_metric( ): await runner.create_triggers() - mock_timing.assert_called_once_with( + mock_timing.assert_any_call( "triggerer.trigger_queue_delay", 1500, tags=expected_tags, ) + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("team_name", "expected_tags"), + [ + pytest.param("team_a", {"team_name": "team_a"}, id="with_team"), + pytest.param(None, {}, id="without_team"), + ], + ) + @patch("airflow.jobs.triggerer_job_runner.stats.timing") + @patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs") + @patch( + "airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath", + return_value=DateTimeTrigger, + ) + async def test_create_triggers_emits_creation_duration_metric( + self, + mock_get_trigger_by_classpath, + mock_decrypt_kwargs, + mock_timing, + team_name, + expected_tags, + ): + mock_decrypt_kwargs.return_value = {"moment": timezone.utcnow() + datetime.timedelta(hours=1)} + + workload = workloads.RunTrigger.model_construct( + id=1, + classpath="abc", + encrypted_kwargs="fake", + ) + + runner = TriggerRunner() + runner.team_name = team_name + runner.to_create.append(workload) + + await runner.create_triggers() + + mock_timing.assert_called_once() + + metric_name, metric_value = mock_timing.call_args.args + + assert metric_name == "triggerer.batch_trigger_creation_duration" + + # Specific metric_value is not being asserted here as time.monotonic is difficult + # to mock deterministically. + assert metric_value >= 0 + assert mock_timing.call_args.kwargs == {"tags": expected_tags} + + task = runner.triggers[workload.id]["task"] + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + await runner.cleanup_finished_triggers() + @pytest.mark.asyncio @patch("airflow.sdk.execution_time.task_runner.SUPERVISOR_COMMS", create=True) async def test_sync_state_to_supervisor(self, supervisor_builder): diff --git a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml index a3e5b6bdd7555..c0d43641559d6 100644 --- a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml +++ b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml @@ -698,6 +698,12 @@ metrics: legacy_name: "dagrun.schedule_delay.{dag_id}" name_variables: ["dag_id"] + - name: "triggerer.batch_trigger_creation_duration" + description: "Time in milliseconds spent creating a batch of pending triggers." + type: "timer" + legacy_name: "-" + name_variables: [] + - name: "scheduler.critical_section_duration" description: "Milliseconds spent in the critical section of scheduler loop" type: "timer"