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 7644e35f8b7 Add retry-safe batch_id_prefix to 
DataprocCreateBatchOperator (#69855)
7644e35f8b7 is described below

commit 7644e35f8b78761e4ab0c01f386f67d5e8cc0811
Author: Amir Mor <[email protected]>
AuthorDate: Sun Oct 18 09:09:14 2026 +0300

    Add retry-safe batch_id_prefix to DataprocCreateBatchOperator (#69855)
    
    Add retry-safe batch ID prefixes to DataprocCreateBatchOperator
    
    Allow retries to create batches with distinct IDs, and document the new 
option
    with a runnable example.
    
    ---------
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 providers/google/docs/operators/cloud/dataproc.rst |  13 ++
 .../providers/google/cloud/operators/dataproc.py   |  52 +++++--
 .../cloud/dataproc/example_dataproc_batch.py       |  22 +++
 .../unit/google/cloud/operators/test_dataproc.py   | 170 +++++++++++++++++++++
 4 files changed, 247 insertions(+), 10 deletions(-)

diff --git a/providers/google/docs/operators/cloud/dataproc.rst 
b/providers/google/docs/operators/cloud/dataproc.rst
index 183a6b10443..056597b6399 100644
--- a/providers/google/docs/operators/cloud/dataproc.rst
+++ b/providers/google/docs/operators/cloud/dataproc.rst
@@ -512,6 +512,19 @@ The executable example below still imports the 
compatibility name
     :start-after: [START how_to_cloud_dataproc_create_batch_operator]
     :end-before: [END how_to_cloud_dataproc_create_batch_operator]
 
+When you need a readable ID and retry-safe uniqueness, set ``batch_id_prefix`` 
instead of ``batch_id``.
+The operator generates a new ``<batch_id_prefix>-<8 hex characters>`` ID for 
each task attempt, so
+retried attempts submit a new batch instead of attaching to the batch of a 
previous attempt.
+``batch_id_prefix`` cannot be combined with ``request_id``, because Dataproc 
returns the original
+operation for a repeated ``request_id``.
+The prefix is used as-is (no normalization), and Dataproc validates the 
resulting batch ID.
+
+.. exampleinclude:: 
/../../google/tests/system/google/cloud/dataproc/example_dataproc_batch.py
+    :language: python
+    :dedent: 4
+    :start-after: [START 
how_to_cloud_dataproc_create_batch_operator_with_prefix]
+    :end-before: [END how_to_cloud_dataproc_create_batch_operator_with_prefix]
+
 For creating a batch with Persistent History Server first you should create a 
Managed Spark Cluster
 with specific parameters. Documentation how create cluster you can find
 `here 
<https://cloud.google.com/dataproc/docs/concepts/jobs/history-server#setting_up_a_persistent_history_server>`__:
diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py 
b/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
index 6ef81c4bf15..9d004d8b780 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
@@ -22,6 +22,7 @@ from __future__ import annotations
 import inspect
 import re
 import time
+import uuid
 from collections.abc import MutableSequence, Sequence
 from dataclasses import dataclass
 from datetime import datetime, timedelta
@@ -2415,9 +2416,12 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
     :param project_id: Optional. The ID of the Google Cloud project that the 
cluster belongs to. (templated)
     :param region: Required. The Cloud Dataproc region in which to handle the 
request. (templated)
     :param batch: Required. The batch to create. (templated)
-    :param batch_id: Required. The ID to use for the batch, which will become 
the final component
+    :param batch_id: Optional. The ID to use for the batch, which will become 
the final component
         of the batch's resource name.
         This value must be 4-63 characters. Valid characters are 
/[a-z][0-9]-/. (templated)
+    :param batch_id_prefix: Optional. Prefix for a batch ID generated on each 
task attempt, in the form
+        ``<batch_id_prefix>-<8 hex characters>``, so a retried attempt submits 
a new batch.
+        Mutually exclusive with ``batch_id`` and ``request_id``. (templated)
     :param request_id: Optional. A unique id used to identify the request. If 
the server receives two
         ``CreateBatchRequest`` requests with the same id, then the second 
request will be ignored and
         the first ``google.longrunning.Operation`` created and stored in the 
backend is returned.
@@ -2450,11 +2454,13 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
         "project_id",
         "batch",
         "batch_id",
+        "batch_id_prefix",
         "region",
         "gcp_conn_id",
         "impersonation_chain",
     )
     operator_extra_links = (DataprocBatchLink(),)
+    _BATCH_ID_SUFFIX_LENGTH = 8
 
     def __init__(
         self,
@@ -2463,6 +2469,7 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
         project_id: str = PROVIDE_PROJECT_ID,
         batch: dict | Batch,
         batch_id: str | None = None,
+        batch_id_prefix: str | None = None,
         request_id: str | None = None,
         num_retries_if_resource_is_not_ready: int = 0,
         retry: Retry | _MethodDefault = DEFAULT,
@@ -2489,6 +2496,7 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
         self.project_id = project_id
         self.batch = batch
         self.batch_id = batch_id
+        self.batch_id_prefix = batch_id_prefix
         self.request_id = request_id
         self.num_retries_if_resource_is_not_ready = 
num_retries_if_resource_is_not_ready
         self.retry = retry
@@ -2510,16 +2518,17 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
                 "Both asynchronous and deferrable parameters were passed. 
Please, provide only one."
             )
 
+        requested_batch_id = self._resolve_requested_batch_id()
         batch_id: str = ""
-        if self.batch_id:
-            batch_id = self.batch_id
+        if requested_batch_id:
+            batch_id = requested_batch_id
             self.log.info("Starting batch %s", batch_id)
             # Persist the link earlier so users can observe the progress
             DataprocBatchLink.persist(
                 context=context,
                 project_id=self.project_id,
                 region=self.region,
-                batch_id=self.batch_id,
+                batch_id=batch_id,
             )
         else:
             self.log.info("Starting batch. The batch ID will be generated 
since it was not provided.")
@@ -2535,7 +2544,7 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
                 region=self.region,
                 project_id=self.project_id,
                 batch=self.batch,
-                batch_id=self.batch_id,
+                batch_id=requested_batch_id,
                 request_id=self.request_id,
                 retry=self.retry,
                 timeout=self.timeout,
@@ -2598,13 +2607,33 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
             attempt = self.num_retries_if_resource_is_not_ready
             while attempt > 0:
                 attempt -= 1
-                batch, batch_id = self.retry_batch_creation(batch_id)
+                batch, batch_id = self.retry_batch_creation(
+                    previous_batch_id=batch_id,
+                    requested_batch_id=requested_batch_id,
+                )
                 if not 
self.hook.check_error_for_resource_is_not_ready_msg(batch.state_message):
                     break
 
         self.handle_batch_status(context, batch.state.name, batch_id, 
batch.state_message)
         return Batch.to_dict(batch)
 
+    def _resolve_requested_batch_id(self) -> str | None:
+        if self.batch_id and self.batch_id_prefix:
+            raise ValueError("batch_id and batch_id_prefix are mutually 
exclusive")
+        if self.batch_id_prefix and self.request_id:
+            # Dataproc returns the original operation for a repeated 
request_id, so a retried
+            # attempt would reattach to the previous batch despite its new 
batch ID.
+            raise ValueError("batch_id_prefix and request_id are mutually 
exclusive")
+        if self.batch_id:
+            return self.batch_id
+        if self.batch_id_prefix:
+            return 
self._build_unique_batch_id_from_prefix(self.batch_id_prefix)
+        return None
+
+    @classmethod
+    def _build_unique_batch_id_from_prefix(cls, batch_id_prefix: str) -> str:
+        return f"{batch_id_prefix}-{uuid.uuid4().hex[: 
cls._BATCH_ID_SUFFIX_LENGTH]}"
+
     @cached_property
     def hook(self) -> DataprocHook:
         return DataprocHook(gcp_conn_id=self.gcp_conn_id, 
impersonation_chain=self.impersonation_chain)
@@ -2646,8 +2675,10 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
     def retry_batch_creation(
         self,
         previous_batch_id: str,
+        requested_batch_id: str | None = None,
     ):
-        self.log.info("Retrying creation process for batch_id %s", 
self.batch_id)
+        requested_batch_id = requested_batch_id or self.batch_id
+        self.log.info("Retrying creation process for batch_id %s", 
previous_batch_id)
         self.log.info("Deleting previous failed Batch")
         self.hook.delete_batch(
             batch_id=previous_batch_id,
@@ -2657,13 +2688,14 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
             timeout=self.timeout,
             metadata=self.metadata,
         )
-        self.log.info("Starting a new creation for batch_id %s", self.batch_id)
+        self.log.info("Starting a new creation for batch_id %s", 
requested_batch_id)
+        batch_id = requested_batch_id or previous_batch_id
         try:
             self.operation = self.hook.create_batch(
                 region=self.region,
                 project_id=self.project_id,
                 batch=self.batch,
-                batch_id=self.batch_id,
+                batch_id=requested_batch_id,
                 request_id=self.request_id,
                 retry=self.retry,
                 timeout=self.timeout,
@@ -2671,7 +2703,7 @@ class 
DataprocCreateBatchOperator(GoogleCloudBaseOperator):
             )
         except AlreadyExists:
             self.log.info("Batch with given id already exists.")
-            self.log.info("Attaching to the job %s if it is still running.", 
self.batch_id)
+            self.log.info("Attaching to the job %s if it is still running.", 
requested_batch_id)
         else:
             if self.operation and self.operation.metadata:
                 batch_id = self.operation.metadata.batch.split("/")[-1]
diff --git 
a/providers/google/tests/system/google/cloud/dataproc/example_dataproc_batch.py 
b/providers/google/tests/system/google/cloud/dataproc/example_dataproc_batch.py
index a3fdb3c7dfa..c2c44bd136c 100644
--- 
a/providers/google/tests/system/google/cloud/dataproc/example_dataproc_batch.py
+++ 
b/providers/google/tests/system/google/cloud/dataproc/example_dataproc_batch.py
@@ -52,6 +52,7 @@ BATCH_ID = f"batch-{ENV_ID}-{DAG_ID}".replace("_", "-")
 BATCH_ID_2 = f"batch-{ENV_ID}-{DAG_ID}-2".replace("_", "-")
 BATCH_ID_3 = f"batch-{ENV_ID}-{DAG_ID}-3".replace("_", "-")
 BATCH_ID_4 = f"batch-{ENV_ID}-{DAG_ID}-4".replace("_", "-")
+BATCH_ID_PREFIX = f"batch-{ENV_ID}-pfx".replace("_", "-")
 
 BATCH_CONFIG = {
     "spark_batch": {
@@ -101,6 +102,17 @@ with DAG(
     )
     # [END how_to_cloud_dataproc_create_batch_operator]
 
+    # [START how_to_cloud_dataproc_create_batch_operator_with_prefix]
+    create_batch_with_prefix = DataprocCreateBatchOperator(
+        task_id="create_batch_with_prefix",
+        project_id=PROJECT_ID,
+        region=REGION,
+        batch=BATCH_CONFIG,
+        batch_id_prefix=BATCH_ID_PREFIX,
+        result_retry=Retry(maximum=100.0, initial=10.0, multiplier=1.0),
+    )
+    # [END how_to_cloud_dataproc_create_batch_operator_with_prefix]
+
     # [START how_to_cloud_dataproc_batch_async_sensor]
     batch_async_sensor = DataprocBatchSensor(
         task_id="batch_async_sensor",
@@ -158,10 +170,18 @@ with DAG(
         task_id="delete_batch_4", project_id=PROJECT_ID, region=REGION, 
batch_id=BATCH_ID_4
     )
     # [END how_to_cloud_dataproc_delete_batch_operator]
+    # The prefixed batch gets a generated suffix, so its ID is only known at 
runtime.
+    delete_batch_with_prefix = DataprocDeleteBatchOperator(
+        task_id="delete_batch_with_prefix",
+        project_id=PROJECT_ID,
+        region=REGION,
+        batch_id="{{ 
task_instance.xcom_pull('create_batch_with_prefix')['name'].split('/') | last 
}}",
+    )
     delete_batch.trigger_rule = TriggerRule.ALL_DONE
     delete_batch_2.trigger_rule = TriggerRule.ALL_DONE
     delete_batch_3.trigger_rule = TriggerRule.ALL_DONE
     delete_batch_4.trigger_rule = TriggerRule.ALL_FAILED
+    delete_batch_with_prefix.trigger_rule = TriggerRule.ALL_DONE
 
     (
         # TEST SETUP
@@ -172,6 +192,7 @@ with DAG(
         >> batch_async_sensor
         >> get_batch
         >> list_batches
+        >> create_batch_with_prefix
         >> create_batch_4
         >> cancel_operation
         # TEST TEARDOWN
@@ -179,6 +200,7 @@ with DAG(
         >> delete_batch_2
         >> delete_batch_3
         >> delete_batch_4
+        >> delete_batch_with_prefix
     )
 
     from tests_common.test_utils.watcher import watcher
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_dataproc.py 
b/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
index c1cb55f566f..c8b61bfb12f 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
@@ -3742,6 +3742,176 @@ class TestDataprocCreateWorkflowTemplateOperator:
 
 
 class TestDataprocCreateBatchOperator:
+    @pytest.mark.parametrize(
+        ("extra_kwargs", "match"),
+        [
+            pytest.param({"batch_id": BATCH_ID}, "batch_id and 
batch_id_prefix", id="batch_id"),
+            pytest.param({"request_id": REQUEST_ID}, "batch_id_prefix and 
request_id", id="request_id"),
+        ],
+    )
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def test_batch_id_prefix_mutually_exclusive_params(self, mock_hook, 
extra_kwargs, match):
+        operator = DataprocCreateBatchOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id_prefix="batch-prefix",
+            **extra_kwargs,
+        )
+        with pytest.raises(ValueError, match=match):
+            operator.execute(context=MagicMock())
+        mock_hook.return_value.create_batch.assert_not_called()
+
+    @mock.patch(DATAPROC_PATH.format("uuid.uuid4"))
+    def test_build_unique_batch_id_from_prefix_preserves_prefix_verbatim(self, 
mock_uuid):
+        mock_uuid.return_value.hex = "abc12345def67890"
+        batch_id = 
DataprocCreateBatchOperator._build_unique_batch_id_from_prefix("Batch_Prefix")
+
+        assert batch_id == "Batch_Prefix-abc12345"
+
+    @mock.patch.object(DataprocCreateBatchOperator, "log", 
new_callable=mock.MagicMock)
+    @mock.patch(DATAPROC_PATH.format("uuid.uuid4"))
+    @mock.patch(DATAPROC_PATH.format("Batch.to_dict"))
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def test_execute_with_batch_id_prefix(self, mock_hook, to_dict_mock, 
mock_uuid, mock_log):
+        mock_uuid.return_value.hex = "abc12345def67890"
+        expected_batch_id = "batch-prefix-abc12345"
+        operator = DataprocCreateBatchOperator(
+            task_id=TASK_ID,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id_prefix="batch-prefix",
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+        mock_hook.return_value.create_batch.return_value.metadata.batch = 
f"prefix/{expected_batch_id}"
+        batch_state_succeeded = Batch(state=Batch.State.SUCCEEDED)
+        mock_hook.return_value.wait_for_batch.return_value = 
batch_state_succeeded
+
+        operator.execute(context=MagicMock())
+
+        mock_hook.return_value.create_batch.assert_called_once_with(
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id=expected_batch_id,
+            request_id=None,
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+        to_dict_mock.assert_called_once_with(batch_state_succeeded)
+        logs_link = DATAPROC_BATCH_LINK.format(
+            region=GCP_REGION, project_id=GCP_PROJECT, 
batch_id=expected_batch_id
+        )
+        mock_log.info.assert_has_calls(
+            [
+                mock.call("Starting batch %s", expected_batch_id),
+                mock.call("The batch %s was created.", expected_batch_id),
+                mock.call("Waiting for the completion of batch job %s", 
expected_batch_id),
+                mock.call("Batch job %s completed.\nDriver logs: %s", 
expected_batch_id, logs_link),
+            ]
+        )
+
+    @mock.patch(DATAPROC_PATH.format("uuid.uuid4"))
+    @mock.patch(DATAPROC_PATH.format("Batch.to_dict"))
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def test_execute_with_batch_id_prefix_generates_new_id_per_task_attempt(
+        self, mock_hook, to_dict_mock, mock_uuid
+    ):
+        mock_uuid.side_effect = [mock.Mock(hex="aaaaaaaa1111"), 
mock.Mock(hex="bbbbbbbb2222")]
+        operator = DataprocCreateBatchOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id_prefix="batch-prefix",
+        )
+        mock_hook.return_value.wait_for_batch.return_value = 
Batch(state=Batch.State.SUCCEEDED)
+
+        operator.execute(context=MagicMock())
+        operator.execute(context=MagicMock())
+
+        assert [c.kwargs["batch_id"] for c in 
mock_hook.return_value.create_batch.call_args_list] == [
+            "batch-prefix-aaaaaaaa",
+            "batch-prefix-bbbbbbbb",
+        ]
+
+    @mock.patch(DATAPROC_PATH.format("uuid.uuid4"))
+    @mock.patch(DATAPROC_PATH.format("Batch.to_dict"))
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def 
test_execute_with_batch_id_prefix_retries_resource_not_ready_with_same_id(
+        self, mock_hook, to_dict_mock, mock_uuid
+    ):
+        mock_uuid.return_value.hex = "abc12345def67890"
+        expected_batch_id = "batch-prefix-abc12345"
+        operator = DataprocCreateBatchOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id_prefix="batch-prefix",
+            num_retries_if_resource_is_not_ready=1,
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+        mock_hook.return_value.create_batch.return_value.metadata.batch = 
f"prefix/{expected_batch_id}"
+        mock_hook.return_value.wait_for_batch.side_effect = [
+            Batch(state=Batch.State.FAILED, state_message="resource not 
ready"),
+            Batch(state=Batch.State.SUCCEEDED),
+        ]
+        
mock_hook.return_value.check_error_for_resource_is_not_ready_msg.side_effect = 
[True, False]
+
+        operator.execute(context=MagicMock())
+
+        mock_uuid.assert_called_once()
+        mock_hook.return_value.delete_batch.assert_called_once_with(
+            batch_id=expected_batch_id,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+        assert [c.kwargs["batch_id"] for c in 
mock_hook.return_value.create_batch.call_args_list] == [
+            expected_batch_id,
+            expected_batch_id,
+        ]
+
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def test_retry_batch_creation_already_exists_attaches_to_batch_id(self, 
mock_hook):
+        op = DataprocCreateBatchOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            batch=BATCH,
+            batch_id=BATCH_ID,
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+        mock_hook.return_value.create_batch.side_effect = AlreadyExists("")
+        mock_hook.return_value.wait_for_batch.return_value = 
Batch(state=Batch.State.SUCCEEDED)
+
+        _, batch_id = op.retry_batch_creation(BATCH_ID)
+
+        assert batch_id == BATCH_ID
+        assert 
mock_hook.return_value.create_batch.call_args.kwargs["batch_id"] == BATCH_ID
+        mock_hook.return_value.wait_for_batch.assert_called_once_with(
+            batch_id=BATCH_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            retry=RETRY,
+            timeout=TIMEOUT,
+            metadata=METADATA,
+        )
+
     @mock.patch.object(DataprocCreateBatchOperator, "log", 
new_callable=mock.MagicMock)
     @mock.patch(DATAPROC_PATH.format("Batch.to_dict"))
     @mock.patch(DATAPROC_PATH.format("DataprocHook"))

Reply via email to