This is an automated email from the ASF dual-hosted git repository.
shahar1 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 ea7677ed172 Fix an issue with getting dataflow_job_id for Dataflow
Runner when logging is disabled in Pipeline (#72809)
ea7677ed172 is described below
commit ea7677ed17271db01fa5ce83d708b2c58c88984b
Author: Maksim <[email protected]>
AuthorDate: Fri Nov 13 08:11:26 2026 +0100
Fix an issue with getting dataflow_job_id for Dataflow Runner when logging
is disabled in Pipeline (#72809)
---
.../airflow/providers/apache/beam/hooks/beam.py | 30 ++++-
.../providers/apache/beam/operators/beam.py | 65 +++++++++-
.../beam/tests/unit/apache/beam/hooks/test_beam.py | 108 ++++++++++++++++-
.../tests/unit/apache/beam/operators/test_beam.py | 135 ++++++++++++++++++++-
.../providers/google/cloud/hooks/dataflow.py | 54 +++++++--
.../tests/unit/google/cloud/hooks/test_dataflow.py | 111 +++++++++++++++++
6 files changed, 488 insertions(+), 15 deletions(-)
diff --git
a/providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py
b/providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py
index 86ec5595503..647435778e1 100644
--- a/providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py
+++ b/providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py
@@ -142,8 +142,8 @@ def process_fd(
func_log = fd_to_log[fd]
for line_raw in iter(fd.readline, b""):
- line = line_raw.decode()
- if process_line_callback:
+ line = line_raw.decode(errors="replace")
+ if process_line_callback and line.endswith("\n"):
process_line_callback(line)
func_log(line.rstrip("\n"))
if is_dataflow_job_id_exist_callback and
is_dataflow_job_id_exist_callback():
@@ -156,6 +156,7 @@ def run_beam_command(
process_line_callback: Callable[[str], None] | None = None,
working_directory: str | None = None,
is_dataflow_job_id_exist_callback: Callable[[], bool] | None = None,
+ check_job_id_by_name_callback: Callable[[], bool] | None = None,
) -> None:
"""
Run pipeline command in subprocess.
@@ -176,13 +177,25 @@ def run_beam_command(
stderr=subprocess.PIPE,
close_fds=True,
)
+
+ if (
+ (is_dataflow_job_id_exist_callback or check_job_id_by_name_callback)
+ and hasattr(os, "set_blocking")
+ and proc.stdout
+ and proc.stderr
+ ):
+ os.set_blocking(proc.stdout.fileno(), False)
+ os.set_blocking(proc.stderr.fileno(), False)
+
# Waits for Apache Beam pipeline to complete.
log.info("Start waiting for Apache Beam process to complete.")
reads = [fd for fd in (proc.stderr, proc.stdout) if fd is not None]
while True:
# Wait for at least one available fd.
readable_fds, _, _ = select.select(reads, [], [], 5)
- if readable_fds is None:
+ if not readable_fds:
+ if check_job_id_by_name_callback and
check_job_id_by_name_callback():
+ return
log.info("Waiting for Apache Beam process to complete.")
continue
@@ -203,6 +216,11 @@ def run_beam_command(
if proc.returncode != 0:
raise AirflowException(f"Apache Beam process failed with return code
{proc.returncode}")
+ if check_job_id_by_name_callback and not (
+ is_dataflow_job_id_exist_callback and
is_dataflow_job_id_exist_callback()
+ ):
+ check_job_id_by_name_callback()
+
class BeamHook(BaseHook):
"""
@@ -228,6 +246,7 @@ class BeamHook(BaseHook):
process_line_callback: Callable[[str], None] | None = None,
working_directory: str | None = None,
is_dataflow_job_id_exist_callback: Callable[[], bool] | None = None,
+ check_job_id_by_name_callback: Callable[[], bool] | None = None,
) -> None:
cmd = [*command_prefix, f"--runner={self.runner}"]
if variables:
@@ -238,6 +257,7 @@ class BeamHook(BaseHook):
working_directory=working_directory,
log=self.log,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=check_job_id_by_name_callback,
)
def start_python_pipeline(
@@ -250,6 +270,7 @@ class BeamHook(BaseHook):
py_system_site_packages: bool = False,
process_line_callback: Callable[[str], None] | None = None,
is_dataflow_job_id_exist_callback: Callable[[], bool] | None = None,
+ check_job_id_by_name_callback: Callable[[], bool] | None = None,
):
"""
Start Apache Beam python pipeline.
@@ -319,6 +340,7 @@ class BeamHook(BaseHook):
command_prefix=command_prefix,
process_line_callback=process_line_callback,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=check_job_id_by_name_callback,
)
def start_java_pipeline(
@@ -328,6 +350,7 @@ class BeamHook(BaseHook):
job_class: str | None = None,
process_line_callback: Callable[[str], None] | None = None,
is_dataflow_job_id_exist_callback: Callable[[], bool] | None = None,
+ check_job_id_by_name_callback: Callable[[], bool] | None = None,
) -> None:
"""
Start Apache Beam Java pipeline.
@@ -347,6 +370,7 @@ class BeamHook(BaseHook):
command_prefix=command_prefix,
process_line_callback=process_line_callback,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=check_job_id_by_name_callback,
)
def start_go_pipeline(
diff --git
a/providers/apache/beam/src/airflow/providers/apache/beam/operators/beam.py
b/providers/apache/beam/src/airflow/providers/apache/beam/operators/beam.py
index 2e3f84cf508..68a49a66f29 100644
--- a/providers/apache/beam/src/airflow/providers/apache/beam/operators/beam.py
+++ b/providers/apache/beam/src/airflow/providers/apache/beam/operators/beam.py
@@ -161,6 +161,29 @@ class BeamDataflowMixin(metaclass=ABCMeta):
return is_dataflow_job_id_exist
+ def _check_dataflow_job_id_by_name_callback(self) -> Callable[[], bool]:
+ def check_dataflow_job_id_by_name() -> bool:
+ if (
+ not self.dataflow_job_id
+ and self.dataflow_hook
+ and hasattr(self.dataflow_hook, "get_job_id_of_running_job")
+ ):
+ try:
+ self.dataflow_job_id =
self.dataflow_hook.get_job_id_of_running_job(
+ job_name=self.dataflow_job_name, # type: ignore
+ project_id=self.dataflow_config.project_id,
+ location=self.dataflow_config.location,
+ )
+ except Exception:
+ self.log.warning( # type: ignore
+ "Failed to get Dataflow job ID by job name '%s'",
+ self.dataflow_job_name, # type: ignore
+ exc_info=True,
+ )
+ return True if self.dataflow_job_id else False
+
+ return check_dataflow_job_id_by_name
+
class BeamBasePipelineOperator(BaseOperator, BeamDataflowMixin, ABC):
"""
@@ -324,6 +347,24 @@ class
BeamRunPythonPipelineOperator(BeamBasePipelineOperator):
For more information on how to use this operator, take a look at the
guide:
:ref:`howto/operator:BeamRunPythonPipelineOperator`
+ .. warning::
+ When running with the ``DataflowRunner``, the operator stops reading
from and waiting
+ on the local Python subprocess as soon as the Dataflow Job ID is
detected, and monitors
+ the job via the Dataflow API instead (or defers when
``deferrable=True``).
+
+ This means any **additional code** in your ``py_file`` that is written
to execute
+ *after* the pipeline block (e.g., local post-processing, file uploads,
or local logging
+ on the Airflow worker) is not waited on and may not be executed,
especially in
+ deferrable mode where the worker task yields immediately upon deferral.
+
+ If you require local post-processing, split the post-processing into a
separate
+ Airflow task (e.g., using ``PythonOperator``).
+
+ Also note that when ``dataflow_config.append_job_name=False`` and the
launcher does not
+ log a Job ID within 5 seconds, the fallback lookup by job name may
attach to an already
+ running job with the same name from a concurrent run instead of
waiting for the launcher
+ to fail with a duplicate job name error.
+
.. seealso::
For more detail on Apache Beam have a look at the reference:
https://beam.apache.org/documentation/
@@ -464,6 +505,7 @@ class
BeamRunPythonPipelineOperator(BeamBasePipelineOperator):
py_system_site_packages=self.py_system_site_packages,
process_line_callback=self.process_line_callback,
is_dataflow_job_id_exist_callback=self.is_dataflow_job_id_exist_callback,
+
check_job_id_by_name_callback=self._check_dataflow_job_id_by_name_callback(),
)
location = self.dataflow_config.location or DEFAULT_DATAFLOW_LOCATION
@@ -474,7 +516,7 @@ class
BeamRunPythonPipelineOperator(BeamBasePipelineOperator):
project_id=self.dataflow_config.project_id,
)
- if self.deferrable:
+ if self.deferrable and self.dataflow_job_id:
trigger_args = {
"job_id": self.dataflow_job_id,
"project_id": self.dataflow_config.project_id,
@@ -532,6 +574,24 @@ class
BeamRunJavaPipelineOperator(BeamBasePipelineOperator):
For more information on how to use this operator, take a look at the
guide:
:ref:`howto/operator:BeamRunJavaPipelineOperator`
+ .. warning::
+ When running with the ``DataflowRunner``, the operator stops reading
from and waiting
+ on the local Java subprocess as soon as the Dataflow Job ID is
detected, and monitors
+ the job via the Dataflow API instead (or defers when
``deferrable=True``).
+
+ This means any **additional code** in your self-executing ``jar`` that
is written to
+ execute locally *after* the pipeline finishes on the worker is not
waited on and may
+ not be executed, especially in deferrable mode where the worker task
yields immediately
+ upon deferral.
+
+ If you require local post-processing, split the post-processing into a
separate
+ Airflow task.
+
+ Also note that when ``dataflow_config.append_job_name=False`` and the
launcher does not
+ log a Job ID within 5 seconds, the fallback lookup by job name may
attach to an already
+ running job with the same name from a concurrent run instead of
waiting for the launcher
+ to fail with a duplicate job name error.
+
.. seealso::
For more detail on Apache Beam have a look at the reference:
https://beam.apache.org/documentation/
@@ -663,6 +723,7 @@ class BeamRunJavaPipelineOperator(BeamBasePipelineOperator):
job_class=self.job_class,
process_line_callback=self.process_line_callback,
is_dataflow_job_id_exist_callback=self.is_dataflow_job_id_exist_callback,
+
check_job_id_by_name_callback=self._check_dataflow_job_id_by_name_callback(),
)
if self.dataflow_job_name and self.dataflow_config.location:
DataflowJobLink.persist(
@@ -671,7 +732,7 @@ class BeamRunJavaPipelineOperator(BeamBasePipelineOperator):
job_id=self.dataflow_job_id,
project_id=self.dataflow_config.project_id,
)
- if self.deferrable:
+ if self.deferrable and self.dataflow_job_id:
trigger_args = {
"job_id": self.dataflow_job_id,
"project_id": self.dataflow_config.project_id,
diff --git a/providers/apache/beam/tests/unit/apache/beam/hooks/test_beam.py
b/providers/apache/beam/tests/unit/apache/beam/hooks/test_beam.py
index 5b25e6c0929..8fffdcfd323 100644
--- a/providers/apache/beam/tests/unit/apache/beam/hooks/test_beam.py
+++ b/providers/apache/beam/tests/unit/apache/beam/hooks/test_beam.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import copy
+import io
import logging
import os
import re
@@ -33,6 +34,7 @@ from airflow.providers.apache.beam.hooks.beam import (
BeamAsyncHook,
BeamHook,
beam_options_to_args,
+ process_fd,
run_beam_command,
)
from airflow.providers.common.compat.sdk import AirflowException
@@ -113,6 +115,7 @@ class TestBeamHook:
working_directory=None,
log=ANY,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=None,
)
@mock.patch("airflow.providers.apache.beam.hooks.beam.subprocess.check_output",
return_value=b"2.35.0")
@@ -177,6 +180,7 @@ class TestBeamHook:
working_directory=None,
log=ANY,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=None,
)
@pytest.mark.parametrize(
@@ -225,6 +229,7 @@ class TestBeamHook:
cmd=expected_cmd,
process_line_callback=process_line_callback,
is_dataflow_job_id_exist_callback=is_dataflow_job_id_exist_callback,
+ check_job_id_by_name_callback=None,
working_directory=None,
log=ANY,
)
@@ -283,6 +288,7 @@ class TestBeamHook:
working_directory=None,
log=ANY,
is_dataflow_job_id_exist_callback=None,
+ check_job_id_by_name_callback=None,
)
@mock.patch(BEAM_STRING.format("run_beam_command"))
@@ -312,6 +318,7 @@ class TestBeamHook:
working_directory=None,
log=ANY,
is_dataflow_job_id_exist_callback=None,
+ check_job_id_by_name_callback=None,
)
@mock.patch(BEAM_STRING.format("shutil.which"))
@@ -343,6 +350,7 @@ class TestBeamHook:
working_directory=go_workspace,
log=ANY,
is_dataflow_job_id_exist_callback=None,
+ check_job_id_by_name_callback=None,
)
@mock.patch(BEAM_STRING.format("shutil.which"))
@@ -389,13 +397,15 @@ class TestBeamHook:
working_directory=None,
log=ANY,
is_dataflow_job_id_exist_callback=None,
+ check_job_id_by_name_callback=None,
)
class TestBeamRunner:
+ @mock.patch("os.set_blocking")
@mock.patch("subprocess.Popen")
@mock.patch("select.select")
- def test_beam_wait_for_done_logging(self, mock_select, mock_popen, caplog):
+ def test_beam_wait_for_done_logging(self, mock_select, mock_popen,
mock_set_blocking, caplog):
logger_name = "fake-beam-wait-for-done-logger"
fake_logger = logging.getLogger(logger_name)
@@ -428,6 +438,7 @@ class TestBeamRunner:
with pytest.raises(AirflowException, match="Apache Beam process failed
with return code 1"):
run_beam_command(cmd, fake_logger)
+ mock_set_blocking.assert_not_called()
mock_popen.assert_called_once_with(
cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
close_fds=True, cwd=None
)
@@ -441,6 +452,101 @@ class TestBeamRunner:
assert "apache-beam-stderr-3" in warn_messages
assert "apache-beam-other-stderr" in warn_messages
+ @mock.patch("os.set_blocking")
+ @mock.patch("subprocess.Popen", autospec=True)
+ @mock.patch("select.select")
+ def test_run_beam_command_checks_job_id_by_name_on_timeout(
+ self, mock_select, mock_popen, mock_set_blocking
+ ):
+ fake_logger = MagicMock(spec=logging.Logger)
+ cmd = ["fake", "cmd"]
+ mock_proc = mock_popen.return_value
+ fake_stderr_fd = MagicMock(name="FakeStderr", spec=io.BufferedReader)
+ fake_stdout_fd = MagicMock(name="FakeStdout", spec=io.BufferedReader)
+ fake_stdout_fd.fileno.return_value = 10
+ fake_stderr_fd.fileno.return_value = 11
+ mock_proc.stderr = fake_stderr_fd
+ mock_proc.stdout = fake_stdout_fd
+
+ mock_select.side_effect = [
+ ([], [], []),
+ ([], [], []),
+ ]
+ check_job_id_by_name_callback = MagicMock(spec=callable,
side_effect=[False, True])
+
+ run_beam_command(
+ cmd,
+ fake_logger,
+ check_job_id_by_name_callback=check_job_id_by_name_callback,
+ )
+
+ mock_set_blocking.assert_has_calls([mock.call(10, False),
mock.call(11, False)])
+ assert check_job_id_by_name_callback.call_args_list == [
+ mock.call(),
+ mock.call(),
+ ]
+ mock_proc.poll.assert_not_called()
+
+ @mock.patch("os.set_blocking")
+ @mock.patch("subprocess.Popen", autospec=True)
+ @mock.patch("select.select")
+ def test_run_beam_command_checks_job_id_by_name_after_clean_exit(
+ self, mock_select, mock_popen, mock_set_blocking
+ ):
+ fake_logger = MagicMock(spec=logging.Logger)
+ cmd = ["fake", "cmd"]
+ mock_proc = mock_popen.return_value
+ fake_stderr_fd = MagicMock(name="FakeStderr", spec=io.BufferedReader)
+ fake_stdout_fd = MagicMock(name="FakeStdout", spec=io.BufferedReader)
+ fake_stdout_fd.fileno.return_value = 10
+ fake_stderr_fd.fileno.return_value = 11
+ fake_stdout_fd.readline.return_value = b""
+ fake_stderr_fd.readline.return_value = b""
+ mock_proc.stderr = fake_stderr_fd
+ mock_proc.stdout = fake_stdout_fd
+ mock_proc.poll.return_value = 0
+ mock_proc.returncode = 0
+
+ mock_select.return_value = ([fake_stdout_fd], [], [])
+ is_job_id_exist_callback = MagicMock(spec=callable, return_value=False)
+ check_job_id_by_name_callback = MagicMock(spec=callable,
return_value=True)
+
+ run_beam_command(
+ cmd,
+ fake_logger,
+ is_dataflow_job_id_exist_callback=is_job_id_exist_callback,
+ check_job_id_by_name_callback=check_job_id_by_name_callback,
+ )
+
+ check_job_id_by_name_callback.assert_called_once_with()
+
+ def
test_process_fd_decodes_invalid_utf8_and_skips_partial_lines_in_callback(self):
+ fake_logger = MagicMock(spec=logging.Logger)
+ mock_proc = MagicMock(name="FakeProc", spec=subprocess.Popen)
+ fake_stdout_fd = MagicMock(name="FakeStdout", spec=io.BufferedReader)
+ fake_stderr_fd = MagicMock(name="FakeStderr", spec=io.BufferedReader)
+ mock_proc.stdout = fake_stdout_fd
+ mock_proc.stderr = fake_stderr_fd
+ fake_stdout_fd.readline.side_effect = [
+ b"Submitted job: 2026-11-0",
+ b"bad \xff\xfe line\n",
+ b"",
+ ]
+ process_line_callback = MagicMock(spec=callable)
+
+ process_fd(
+ mock_proc,
+ fake_stdout_fd,
+ fake_logger,
+ process_line_callback=process_line_callback,
+ )
+
+ process_line_callback.assert_called_once_with("bad \ufffd\ufffd
line\n")
+ assert fake_logger.info.call_args_list == [
+ mock.call("Submitted job: 2026-11-0"),
+ mock.call("bad \ufffd\ufffd line"),
+ ]
+
class TestBeamOptionsToArgs:
@pytest.mark.parametrize(
diff --git
a/providers/apache/beam/tests/unit/apache/beam/operators/test_beam.py
b/providers/apache/beam/tests/unit/apache/beam/operators/test_beam.py
index 7e8fe2a1d51..82a04917d45 100644
--- a/providers/apache/beam/tests/unit/apache/beam/operators/test_beam.py
+++ b/providers/apache/beam/tests/unit/apache/beam/operators/test_beam.py
@@ -138,6 +138,92 @@ class TestBeamBasePipelineOperator:
op.dataflow_job_id = "sample_df_job_same_value_id"
mock_ti.xcom_push.assert_not_called()
+ @mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
+ def test_check_dataflow_job_id_by_name_callback(self, dataflow_hook_mock,
beam_hook_mock):
+ op = BeamBasePipelineOperator(
+ task_id=TASK_ID,
+ runner="DataflowRunner",
+ dataflow_config={"project_id": TEST_PROJECT, "location":
"us-central1"},
+ )
+ op._cast_dataflow_config()
+ (
+ _,
+ op.dataflow_job_name,
+ _,
+ _,
+ is_dataflow_job_id_exist_callback,
+ ) = op._init_pipeline_options(format_pipeline_options=True,
job_name_variable_key="job_name")
+ check_by_name_callback = op._check_dataflow_job_id_by_name_callback()
+
+ assert is_dataflow_job_id_exist_callback is not None
+ assert is_dataflow_job_id_exist_callback() is False
+
dataflow_hook_mock.return_value.get_job_id_of_running_job.assert_not_called()
+
+ dataflow_hook_mock.return_value.get_job_id_of_running_job.return_value
= None
+ assert check_by_name_callback() is False
+ assert op.dataflow_job_id is None
+
+ dataflow_hook_mock.return_value.get_job_id_of_running_job.return_value
= JOB_ID
+ assert check_by_name_callback() is True
+ assert is_dataflow_job_id_exist_callback() is True
+ assert op.dataflow_job_id == JOB_ID
+
dataflow_hook_mock.return_value.get_job_id_of_running_job.assert_called_with(
+ job_name=op.dataflow_job_name,
+ project_id=TEST_PROJECT,
+ location="us-central1",
+ )
+
+ @mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
+ def test_check_dataflow_job_id_by_name_callback_handles_lookup_error(
+ self, dataflow_hook_mock, beam_hook_mock
+ ):
+ op = BeamBasePipelineOperator(
+ task_id=TASK_ID,
+ runner="DataflowRunner",
+ dataflow_config={"project_id": TEST_PROJECT, "location":
"us-central1"},
+ )
+ op._cast_dataflow_config()
+ (
+ _,
+ op.dataflow_job_name,
+ _,
+ _,
+ _,
+ ) = op._init_pipeline_options(format_pipeline_options=True,
job_name_variable_key="job_name")
+ check_by_name_callback = op._check_dataflow_job_id_by_name_callback()
+
+ dataflow_hook_mock.return_value.get_job_id_of_running_job.side_effect
= RuntimeError(
+ "missing dataflow.jobs.list permission"
+ )
+ assert check_by_name_callback() is False
+ assert op.dataflow_job_id is None
+
+ @mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
+ def test_check_dataflow_job_id_by_name_callback_without_hook_method(
+ self, dataflow_hook_mock, beam_hook_mock
+ ):
+ op = BeamBasePipelineOperator(
+ task_id=TASK_ID,
+ runner="DataflowRunner",
+ dataflow_config={"project_id": TEST_PROJECT, "location":
"us-central1"},
+ )
+ op._cast_dataflow_config()
+ (
+ _,
+ op.dataflow_job_name,
+ _,
+ _,
+ _,
+ ) = op._init_pipeline_options(format_pipeline_options=True,
job_name_variable_key="job_name")
+ del dataflow_hook_mock.return_value.get_job_id_of_running_job
+ check_by_name_callback = op._check_dataflow_job_id_by_name_callback()
+
+ assert check_by_name_callback() is False
+ assert op.dataflow_job_id is None
+
class TestBeamRunPythonPipelineOperator:
@pytest.fixture(autouse=True)
@@ -269,6 +355,7 @@ class TestBeamRunPythonPipelineOperator:
py_system_site_packages=False,
process_line_callback=mock.ANY,
is_dataflow_job_id_exist_callback=mock.ANY,
+ check_job_id_by_name_callback=mock.ANY,
)
@mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
@@ -495,6 +582,7 @@ class TestBeamRunJavaPipelineOperator:
job_class=JOB_CLASS,
process_line_callback=mock.ANY,
is_dataflow_job_id_exist_callback=mock.ANY,
+ check_job_id_by_name_callback=mock.ANY,
)
@mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
@@ -1013,6 +1101,7 @@ class TestBeamRunPythonPipelineOperatorAsync:
dataflow_config=dataflow_config,
**self.default_op_kwargs,
)
+ op._dataflow_job_id = JOB_ID
magic_mock = mock.MagicMock()
if AIRFLOW_V_3_0_PLUS:
with pytest.raises(TaskDeferred) as exc:
@@ -1041,16 +1130,36 @@ class TestBeamRunPythonPipelineOperatorAsync:
)
beam_hook_mock.return_value.start_python_pipeline.assert_called_once()
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("GCSHook"))
+ def
test_exec_dataflow_runner_falls_back_to_wait_for_done_when_job_id_missing(
+ self, gcs_hook_mock, dataflow_hook_mock, beam_hook_mock,
persist_link_mock
+ ):
+ op = BeamRunPythonPipelineOperator(
+ runner="DataflowRunner",
+ **self.default_op_kwargs,
+ )
+ res = op.execute(context=mock.MagicMock())
+ dataflow_hook_mock.return_value.wait_for_done.assert_called_once_with(
+ job_name=op.dataflow_job_name,
+ location=op.dataflow_config.location,
+ job_id=None,
+ project_id=op.dataflow_config.project_id,
+ )
+ assert res == {"dataflow_job_id": None}
+
@mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
@mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
@mock.patch(BEAM_OPERATOR_PATH.format("GCSHook"))
@mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
def test_on_kill_dataflow_runner(self, dataflow_hook_mock, _, __, ___):
op = BeamRunPythonPipelineOperator(runner="DataflowRunner",
**self.default_op_kwargs)
+ op._dataflow_job_id = JOB_ID
dataflow_cancel_job = dataflow_hook_mock.return_value.cancel_job
with pytest.raises(TaskDeferred):
op.execute(context=mock.MagicMock())
- op.dataflow_job_id = JOB_ID
op.on_kill()
dataflow_cancel_job.assert_called_once_with(
job_id=JOB_ID, project_id=op.dataflow_config.project_id,
location=op.dataflow_config.location
@@ -1062,6 +1171,7 @@ class TestBeamRunPythonPipelineOperatorAsync:
def test_on_kill_direct_runner(self, _, dataflow_mock, __):
dataflow_cancel_job = dataflow_mock.return_value.cancel_job
op = BeamRunPythonPipelineOperator(runner="DataflowRunner",
**self.default_op_kwargs)
+ op._dataflow_job_id = JOB_ID
if AIRFLOW_V_3_0_PLUS:
with pytest.raises(TaskDeferred):
op.execute(mock.MagicMock())
@@ -1076,6 +1186,7 @@ class TestBeamRunPythonPipelineOperatorAsync:
pytest.warns(AirflowProviderDeprecationWarning,
match=exception_msg),
):
op.execute(mock.MagicMock())
+ op._dataflow_job_id = None
op.on_kill()
dataflow_cancel_job.assert_not_called()
@@ -1141,6 +1252,7 @@ class TestBeamRunJavaPipelineOperatorAsync:
op = BeamRunJavaPipelineOperator(
runner="DataflowRunner", dataflow_config=dataflow_config,
**self.default_op_kwargs
)
+ op._dataflow_job_id = JOB_ID
dataflow_hook_mock.return_value.is_job_dataflow_running.return_value =
False
magic_mock = mock.MagicMock()
if AIRFLOW_V_3_0_PLUS:
@@ -1170,6 +1282,25 @@ class TestBeamRunJavaPipelineOperatorAsync:
)
beam_hook_mock.return_value.start_python_pipeline.assert_not_called()
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("DataflowHook"))
+ @mock.patch(BEAM_OPERATOR_PATH.format("GCSHook"))
+ def
test_exec_dataflow_runner_falls_back_to_wait_for_done_when_job_id_missing(
+ self, gcs_hook_mock, dataflow_hook_mock, beam_hook_mock,
persist_link_mock
+ ):
+ dataflow_hook_mock.return_value.is_job_dataflow_running.return_value =
False
+ op = BeamRunJavaPipelineOperator(runner="DataflowRunner",
**self.default_op_kwargs)
+ res = op.execute(context=mock.MagicMock())
+ dataflow_hook_mock.return_value.wait_for_done.assert_called_once_with(
+ job_name=op.dataflow_job_name,
+ location=op.dataflow_config.location,
+ job_id=None,
+ multiple_jobs=False,
+ project_id=op.dataflow_config.project_id,
+ )
+ assert res == {"dataflow_job_id": None}
+
@mock.patch(BEAM_OPERATOR_PATH.format("DataflowJobLink.persist"))
@mock.patch(BEAM_OPERATOR_PATH.format("BeamHook"))
@mock.patch(BEAM_OPERATOR_PATH.format("GCSHook"))
@@ -1178,9 +1309,9 @@ class TestBeamRunJavaPipelineOperatorAsync:
dataflow_hook_mock.return_value.is_job_dataflow_running.return_value =
False
dataflow_cancel_job = dataflow_hook_mock.return_value.cancel_job
op = BeamRunJavaPipelineOperator(runner="DataflowRunner",
**self.default_op_kwargs)
+ op._dataflow_job_id = JOB_ID
with pytest.raises(TaskDeferred):
op.execute(context=mock.MagicMock())
- op.dataflow_job_id = JOB_ID
op.on_kill()
dataflow_cancel_job.assert_called_once_with(
job_id=JOB_ID, project_id=op.dataflow_config.project_id,
location=op.dataflow_config.location
diff --git
a/providers/google/src/airflow/providers/google/cloud/hooks/dataflow.py
b/providers/google/src/airflow/providers/google/cloud/hooks/dataflow.py
index e3fd6da93fc..c5d5a074ddb 100644
--- a/providers/google/src/airflow/providers/google/cloud/hooks/dataflow.py
+++ b/providers/google/src/airflow/providers/google/cloud/hooks/dataflow.py
@@ -434,13 +434,40 @@ class
_DataflowJobsController(DataflowJobTerminalStateHelper):
autoscaling_events.extend(response.get("autoscalingEvents", []))
return autoscaling_events
- def _fetch_all_jobs(self) -> list[dict]:
- request = (
- self._dataflow.projects()
- .locations()
- .jobs()
- .list(projectId=self._project_number, location=self._job_location)
- )
+ def fetch_job_id(self) -> str | None:
+ if self._job_id:
+ return self._job_id
+ if not self._job_name:
+ raise ValueError("Missing both dataflow job ID and name.")
+ jobs = self._fetch_all_jobs(jobs_filter="ACTIVE")
+ job_name = self._job_name.lower()
+ active_jobs = [
+ job
+ for job in jobs
+ if job.get("name") == job_name
+ and job.get("currentState") not in
DataflowJobStatus.TERMINAL_STATES
+ ]
+ if active_jobs:
+ for job in active_jobs:
+ self.log.info(
+ "Google Cloud DataFlow job %s is state: %s",
+ job["name"],
+ job["currentState"],
+ )
+ active_jobs.sort(key=lambda j: j.get("createTime", ""),
reverse=True)
+ self._job_id = active_jobs[0]["id"]
+ else:
+ self.log.info("Google Cloud DataFlow job not available yet..")
+ return self._job_id
+
+ def _fetch_all_jobs(self, jobs_filter: str | None = None) -> list[dict]:
+ list_kwargs: dict[str, Any] = {
+ "projectId": self._project_number,
+ "location": self._job_location,
+ }
+ if jobs_filter is not None:
+ list_kwargs["filter"] = jobs_filter
+ request =
self._dataflow.projects().locations().jobs().list(**list_kwargs)
all_jobs: list[dict] = []
while request is not None:
response = request.execute(num_retries=self._num_retries)
@@ -1124,6 +1151,19 @@ class DataflowHook(GoogleBaseHook):
)
return jobs_controller.fetch_job_by_id(job_id)
+ @GoogleBaseHook.fallback_to_default_project_id
+ def get_job_id_of_running_job(
+ self, job_name: str, project_id: str = PROVIDE_PROJECT_ID, location:
str = DEFAULT_DATAFLOW_LOCATION
+ ) -> str | None:
+ """Get the job_id for currently running Dataflow Job by job_name."""
+ jobs_controller = _DataflowJobsController(
+ dataflow=self.get_conn(),
+ project_number=project_id,
+ name=job_name,
+ location=location,
+ )
+ return jobs_controller.fetch_job_id()
+
@GoogleBaseHook.fallback_to_default_project_id
def fetch_job_metrics_by_id(
self,
diff --git a/providers/google/tests/unit/google/cloud/hooks/test_dataflow.py
b/providers/google/tests/unit/google/cloud/hooks/test_dataflow.py
index 0cbbddeabfd..3bae5c17407 100644
--- a/providers/google/tests/unit/google/cloud/hooks/test_dataflow.py
+++ b/providers/google/tests/unit/google/cloud/hooks/test_dataflow.py
@@ -672,6 +672,26 @@ class TestDataflowTemplateHook:
with pytest.raises(AirflowException):
self.dataflow_hook.extract_job_id({"not_id": True})
+ @mock.patch(DATAFLOW_STRING.format("_DataflowJobsController"))
+ @mock.patch(DATAFLOW_STRING.format("DataflowHook.get_conn"))
+ def test_get_job_id_of_running_job(self, mock_get_conn, jobs_controller):
+ jobs_controller.return_value.fetch_job_id.return_value = TEST_JOB_ID
+
+ result = self.dataflow_hook.get_job_id_of_running_job(
+ job_name=UNIQUE_JOB_NAME,
+ project_id=TEST_PROJECT,
+ location=TEST_LOCATION,
+ )
+
+ jobs_controller.assert_called_once_with(
+ dataflow=mock_get_conn.return_value,
+ name=UNIQUE_JOB_NAME,
+ project_number=TEST_PROJECT,
+ location=TEST_LOCATION,
+ )
+ jobs_controller.return_value.fetch_job_id.assert_called_once_with()
+ assert result == TEST_JOB_ID
+
class TestDataflowJob:
def setup_method(self):
@@ -1368,6 +1388,97 @@ class TestDataflowJob:
mock_fetch_responses.assert_called_once_with(job_id=TEST_JOB_ID)
assert result == ["event_1", "event_2"]
+ @pytest.mark.parametrize(
+ ("jobs", "expected_job_id"),
+ [
+ (
+ [
+ {
+ "id": "job-done-id",
+ "name": "test-job",
+ "currentState": DataflowJobStatus.JOB_STATE_DONE,
+ "createTime": "2026-06-25T16:00:00Z",
+ },
+ {
+ "id": "job-prefix-only-id",
+ "name": "test-job-other",
+ "currentState": DataflowJobStatus.JOB_STATE_RUNNING,
+ "createTime": "2026-06-25T16:10:00Z",
+ },
+ {
+ "id": "job-active-older-id",
+ "name": "test-job",
+ "currentState": DataflowJobStatus.JOB_STATE_PENDING,
+ "createTime": "2026-06-25T15:00:00Z",
+ },
+ {
+ "id": "job-active-newer-id",
+ "name": "test-job",
+ "currentState": DataflowJobStatus.JOB_STATE_RUNNING,
+ "createTime": "2026-06-25T15:30:00Z",
+ },
+ ],
+ "job-active-newer-id",
+ ),
+ (
+ [
+ {
+ "id": "job-done-id",
+ "name": "test-job",
+ "currentState": DataflowJobStatus.JOB_STATE_DONE,
+ "createTime": "2026-06-25T15:00:00Z",
+ }
+ ],
+ None,
+ ),
+ (
+ [
+ {
+ "id": "job-prefix-only-id",
+ "name": "test-job-suffix",
+ "currentState": DataflowJobStatus.JOB_STATE_RUNNING,
+ "createTime": "2026-06-25T15:00:00Z",
+ }
+ ],
+ None,
+ ),
+ ],
+ )
+ def test_fetch_job_id(self, jobs, expected_job_id):
+ mock_jobs =
self.mock_dataflow.projects.return_value.locations.return_value.jobs.return_value
+ mock_jobs.list.return_value.execute.return_value = {"jobs": jobs}
+ mock_jobs.list_next.return_value = None
+
+ jobs_controller = _DataflowJobsController(
+ dataflow=self.mock_dataflow,
+ project_number=TEST_PROJECT,
+ name="Test-Job",
+ location=TEST_LOCATION,
+ multiple_jobs=False,
+ )
+
+ result = jobs_controller.fetch_job_id()
+
+ mock_jobs.list.assert_called_once_with(
+ projectId=TEST_PROJECT,
+ location=TEST_LOCATION,
+ filter="ACTIVE",
+ )
+ assert result == expected_job_id
+ assert jobs_controller._job_id == expected_job_id
+
+ def test_fetch_job_id_without_job_name_returns_existing_job_id(self):
+ jobs_controller = _DataflowJobsController(
+ dataflow=self.mock_dataflow,
+ project_number=TEST_PROJECT,
+ name=None,
+ location=TEST_LOCATION,
+ job_id=TEST_JOB_ID,
+ )
+
+ assert jobs_controller.fetch_job_id() == TEST_JOB_ID
+ self.mock_dataflow.projects.assert_not_called()
+
@pytest.mark.db_test
class TestDataflowPipelineHook: