This is an automated email from the ASF dual-hosted git repository.

ephraimbuddy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 67014e1056e Make TriggerRunnerSupervisor.job optional (#66006)
67014e1056e is described below

commit 67014e1056ec3da98d5550552dc96137c85c2b89
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Thu Apr 30 10:27:01 2026 +0100

    Make TriggerRunnerSupervisor.job optional (#66006)
    
    * Make TriggerRunnerSupervisor.job optional
    
    Allow subclasses targeting AIP-92 (Execution-API-backed triggerers) to
    construct without a metadata-DB-backed Job. heartbeat() and load_triggers()
    no-op when job is None; _create_workload() falls back to the supervisor's
    UUID for the log filename; emit_metrics() routes through a new metric_tags()
    hook that subclasses can override to supply hostname or other identity tags
    from any source.
    
    * Fail loudly when TriggerRunnerSupervisor methods are called without a Job
    
    heartbeat(), load_triggers(), and metric_tags() now raise RuntimeError
    when self.job is None instead of silently no-opping or returning empty
    tags. The empty-tags path was unsafe — DualStatsManager would hit the
    legacy-name branch on every tick and raise ValueError because hostname
    is a required name variable for triggers.running and
    triggerer.capacity_left. The silent no-ops in heartbeat/load_triggers
    also turned a missing subclass override into a zombie supervisor that
    appeared alive but did nothing.
    
    Also rename a local that shadowed the builtin id, document the int-vs-UUID
    log-suffix divergence at the _create_workload site, and update the class
    docstring to spell out the override contract for AIP-92 subclasses.
---
 .../src/airflow/jobs/triggerer_job_runner.py       |  57 +++++++--
 airflow-core/tests/unit/jobs/test_triggerer_job.py | 133 +++++++++++++++++++++
 2 files changed, 183 insertions(+), 7 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 122adb4974b..cef5c4a259d 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -31,6 +31,7 @@ from datetime import datetime
 from socket import socket
 from traceback import format_exception
 from typing import TYPE_CHECKING, Annotated, Any, BinaryIO, ClassVar, Literal, 
TextIO, TypedDict
+from uuid import uuid4
 
 import anyio
 import attrs
@@ -395,9 +396,16 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
 
     This class (which runs in the main/sync process) is responsible for 
querying the DB, sending RunTrigger
     workload messages to the subprocess, and collecting results and updating 
them in the DB.
+
+    ``job`` is optional so AIP-92 Execution-API-backed subclasses can run 
without a metadata-DB
+    ``Job``. Such subclasses **must** override the methods that read 
``self.job`` —
+    :meth:`heartbeat`, :meth:`load_triggers`, and :meth:`metric_tags` — to 
source their data
+    from the Execution API or another backend. The base implementations of 
those methods raise
+    :class:`RuntimeError` when called with ``job=None`` so a missing override 
fails immediately
+    rather than silently zombieing the supervisor.
     """
 
-    job: Job
+    job: Job | None = None
     capacity: int
     queues: set[str] | None = None
 
@@ -435,11 +443,12 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
     def start(  # type: ignore[override]
         cls,
         *,
-        job: Job,
+        job: Job | None = None,
         logger=None,
         **kwargs,
     ):
-        proc = super().start(id=job.id, job=job, target=cls.run_in_process, 
logger=logger, **kwargs)
+        proc_id = job.id if job is not None else uuid4()
+        proc = super().start(id=proc_id, job=job, target=cls.run_in_process, 
logger=logger, **kwargs)
 
         msg = messages.StartTriggerer()
         proc.send_msg(msg, request_id=0)
@@ -623,6 +632,11 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         self.emit_metrics()
 
     def heartbeat(self):
+        if self.job is None:
+            raise RuntimeError(
+                "TriggerRunnerSupervisor.heartbeat() requires a Job; "
+                "subclasses without a metadata-DB Job must override this 
method."
+            )
         perform_heartbeat(self.job, 
heartbeat_callback=self.heartbeat_callback, only_if_necessary=True)
 
     def heartbeat_callback(self, session: Session | None = None) -> None:
@@ -630,6 +644,11 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
 
     def load_triggers(self) -> None:
         """Assign triggers to this triggerer and update the runner with the 
IDs it should run."""
+        if self.job is None:
+            raise RuntimeError(
+                "TriggerRunnerSupervisor.load_triggers() requires a Job; "
+                "subclasses without a metadata-DB Job must override this 
method."
+            )
         Trigger.assign_unassigned(
             self.job.id,
             self.capacity,
@@ -674,12 +693,31 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         """Record that a trigger failed."""
         Trigger.submit_failure(trigger_id=trigger_id, exc=exc)
 
+    def metric_tags(self) -> dict[str, str]:
+        """
+        Return extra tags applied to gauges emitted by :meth:`emit_metrics`.
+
+        Subclasses that don't have a metadata-DB ``Job`` (e.g. AIP-92 
Execution-API-backed
+        triggerers) **must** override this to supply ``hostname`` (and any 
other identity
+        tags) from another source. ``triggers.running`` and 
``triggerer.capacity_left``
+        declare ``hostname`` as a required name variable in 
``metrics_template.yaml``, so
+        an empty dict here would crash :meth:`emit_metrics` on every tick.
+        """
+        hostname = self.job.hostname if self.job is not None else None
+        if hostname is None:
+            raise RuntimeError(
+                "TriggerRunnerSupervisor.metric_tags() requires a Job with a 
hostname; "
+                "subclasses without a metadata-DB Job must override this 
method."
+            )
+        return {"hostname": hostname}
+
     def emit_metrics(self):
+        extra_tags = self.metric_tags()
         DualStatsManager.gauge(
             "triggers.running",
             len(self.running_triggers),
             tags={},
-            extra_tags={"hostname": self.job.hostname},
+            extra_tags=extra_tags,
         )
 
         capacity_left = self.capacity - len(self.running_triggers)
@@ -687,7 +725,7 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
             "triggerer.capacity_left",
             capacity_left,
             tags={},
-            extra_tags={"hostname": self.job.hostname},
+            extra_tags=extra_tags,
         )
 
     def _create_workload(
@@ -715,9 +753,14 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         log_path = render_log_fname(ti=trigger.task_instance)
         ser_ti = TaskInstanceDTO.model_validate(trigger.task_instance, 
from_attributes=True)
 
-        # When producing logs from TIs, include the job id producing the logs 
to disambiguate it.
+        # When producing logs from TIs, include the supervisor id producing 
the logs to disambiguate it.
+        # Note: with a Job this is an int (Job.id); without a Job it's a UUID. 
The reader side
+        # (FileTaskHandler.add_triggerer_suffix) currently formats the suffix 
from
+        # ``ti.triggerer_job.id``, which only resolves the int form — AIP-92 
subclasses without a
+        # metadata Job need their own log retrieval path.
+        log_id = self.job.id if self.job is not None else self.id
         self.logger_cache[trigger.id] = TriggerLoggingFactory(
-            log_path=f"{log_path}.trigger.{self.job.id}.log",
+            log_path=f"{log_path}.trigger.{log_id}.log",
             ti=ser_ti,  # type: ignore
         )
 
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 7f553b4b81e..a2db553c6fa 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -304,6 +304,139 @@ def 
test_run_context_exits_when_subprocess_dies(supervisor_builder, mocker):
     assert events == ["enter", "tick", "exit"]
 
 
[email protected]
+def jobless_supervisor(mocker):
+    """Build a TriggerRunnerSupervisor with ``job=None`` for testing the 
optional-job path."""
+    import psutil
+
+    process = mocker.Mock(spec=psutil.Process, pid=42)
+    mock_stdin = mocker.Mock(spec=socket)
+    mock_stdin.write = mocker.Mock()
+    mock_stdin.sendall = mocker.Mock()
+
+    supervisor = TriggerRunnerSupervisor(
+        process_log=mocker.Mock(spec=FilteringBoundLogger),
+        id=uuid.uuid4(),
+        job=None,
+        pid=process.pid,
+        stdin=mock_stdin,
+        process=process,
+        capacity=10,
+    )
+    mock_selector = mocker.Mock(spec=selectors.DefaultSelector)
+    mock_selector.select.return_value = []
+    supervisor.selector = mock_selector
+    return supervisor
+
+
+def test_start_without_job_generates_uuid_id(mocker):
+    """start() called without a Job should generate a UUID for the supervisor 
id."""
+    from airflow.sdk.execution_time.supervisor import WatchedSubprocess
+
+    fake_proc = mocker.Mock()
+    captured: dict = {}
+
+    @classmethod
+    def fake_super_start(cls, **kwargs):
+        captured.update(kwargs)
+        return fake_proc
+
+    mocker.patch.object(WatchedSubprocess, "start", fake_super_start)
+
+    TriggerRunnerSupervisor.start(capacity=10)
+
+    assert isinstance(captured["id"], uuid.UUID)
+    assert captured["job"] is None
+    fake_proc.send_msg.assert_called_once()
+
+
+def test_heartbeat_raises_without_job(jobless_supervisor, mocker):
+    """heartbeat() must fail loudly when job is None so missing subclass 
overrides surface."""
+    perform_heartbeat = 
mocker.patch("airflow.jobs.triggerer_job_runner.perform_heartbeat")
+
+    with pytest.raises(RuntimeError, match="must override this method"):
+        jobless_supervisor.heartbeat()
+
+    perform_heartbeat.assert_not_called()
+
+
+def test_metric_tags_default_uses_job_hostname(supervisor_builder):
+    """metric_tags() defaults to {"hostname": job.hostname} when a job is 
present."""
+    supervisor = supervisor_builder()
+
+    assert supervisor.metric_tags() == {"hostname": supervisor.job.hostname}
+
+
+def test_metric_tags_raises_without_job(jobless_supervisor):
+    """metric_tags() must fail loudly when job is None — empty tags would 
crash emit_metrics()."""
+    with pytest.raises(RuntimeError, match="must override this method"):
+        jobless_supervisor.metric_tags()
+
+
+def test_emit_metrics_uses_metric_tags_override(jobless_supervisor, mocker):
+    """Subclasses can supply tags by overriding metric_tags() instead of 
threading args."""
+    gauge = 
mocker.patch("airflow.jobs.triggerer_job_runner.DualStatsManager.gauge")
+    mocker.patch.object(
+        TriggerRunnerSupervisor,
+        "metric_tags",
+        return_value={"hostname": "astro-host", "deployment": "demo"},
+    )
+
+    jobless_supervisor.emit_metrics()
+
+    assert gauge.call_count == 2
+    for call in gauge.call_args_list:
+        assert call.kwargs["extra_tags"] == {"hostname": "astro-host", 
"deployment": "demo"}
+
+
+def test_load_triggers_raises_without_job(jobless_supervisor, mocker):
+    """load_triggers() must fail loudly when job is None so missing subclass 
overrides surface."""
+    assign_unassigned = 
mocker.patch("airflow.jobs.triggerer_job_runner.Trigger.assign_unassigned")
+    ids_for_triggerer = 
mocker.patch("airflow.jobs.triggerer_job_runner.Trigger.ids_for_triggerer")
+    update_triggers = mocker.patch.object(TriggerRunnerSupervisor, 
"update_triggers")
+
+    with pytest.raises(RuntimeError, match="must override this method"):
+        jobless_supervisor.load_triggers()
+
+    assign_unassigned.assert_not_called()
+    ids_for_triggerer.assert_not_called()
+    update_triggers.assert_not_called()
+
+
+def test_create_workload_uses_supervisor_id_without_job(jobless_supervisor, 
mocker):
+    """_create_workload() should fall back to self.id for the log filename 
when job is None."""
+    trigger = mocker.Mock()
+    trigger.id = 7
+    trigger.classpath = "some.path.Trigger"
+    trigger.encrypted_kwargs = ""
+    trigger.task_instance.dag_version_id = uuid.uuid4()
+    trigger.task_instance.task_id = "t"
+    trigger.task_instance.trigger_timeout = None
+
+    mocker.patch(
+        "airflow.jobs.triggerer_job_runner.TaskInstanceDTO.model_validate",
+        return_value=mocker.Mock(spec=TaskInstanceDTO),
+    )
+
+    dag_bag = mocker.Mock()
+    serialized_dag_model = mocker.Mock()
+    task = mocker.Mock(start_from_trigger=False)
+    serialized_dag_model.dag.get_task.return_value = task
+    dag_bag.get_serialized_dag_model.return_value = serialized_dag_model
+
+    render_log_fname = mocker.Mock(return_value="/logs/ti")
+
+    jobless_supervisor._create_workload(
+        trigger=trigger,
+        dag_bag=dag_bag,
+        render_log_fname=render_log_fname,
+        session=mocker.Mock(),
+    )
+
+    factory = jobless_supervisor.logger_cache[trigger.id]
+    assert factory.log_path == f"/logs/ti.trigger.{jobless_supervisor.id}.log"
+
+
 def test_trigger_lifecycle(spy_agency: SpyAgency, session, testing_dag_bundle):
     """
     Checks that the triggerer will correctly see a new Trigger in the database

Reply via email to