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

amoghrajesh 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 794cbd8b51c Add durable execution to `DatabricksRunNowOperator` 
(#69174)
794cbd8b51c is described below

commit 794cbd8b51cd8c992e5d1212657d47ea851dd0ca
Author: Amogh Desai <[email protected]>
AuthorDate: Wed Jul 1 14:18:28 2026 +0530

    Add durable execution to `DatabricksRunNowOperator` (#69174)
---
 providers/databricks/docs/operators/run_now.rst    |  39 +++
 .../providers/databricks/operators/databricks.py   |  98 +++++++-
 .../unit/databricks/operators/test_databricks.py   | 270 +++++++++++++++++++--
 3 files changed, 381 insertions(+), 26 deletions(-)

diff --git a/providers/databricks/docs/operators/run_now.rst 
b/providers/databricks/docs/operators/run_now.rst
index f39d872a0c1..30f64139b9c 100644
--- a/providers/databricks/docs/operators/run_now.rst
+++ b/providers/databricks/docs/operators/run_now.rst
@@ -74,6 +74,45 @@ contains ``job_parameters``, it is left untouched.
   # i.e. the same dict, passed straight through to the run-now request body.
 
 
+Durable execution
+^^^^^^^^^^^^^^^^^
+
+``DatabricksRunNowOperator`` triggers a run of an existing job and then polls 
it to completion on
+the worker. By default the operator runs in a *durable* mode that makes this 
crash-safe: the
+Databricks run id is persisted to Airflow's task state store before polling 
begins, so if the
+worker crashes or is preempted and the task is retried, the operator 
reconnects to the run that is
+already executing on Databricks instead of triggering a duplicate run of the 
same job.
+
+On retry the operator checks the prior run's state:
+
+* if it is still running, the operator reconnects and continues polling
+* if it already succeeded, the operator returns immediately without triggering 
a new run
+* if it failed terminally, or the run no longer exists because its history 
expired, the operator
+  triggers a fresh run
+
+This avoids triggering the same Databricks job twice when a worker is lost 
mid-run, which is common
+for long-running jobs.
+
+Durable execution requires Airflow 3.3 or newer, since it relies on the task 
state store. On earlier
+Airflow versions the flag is a no-op and the operator always triggers a fresh 
run on retry,
+exactly as before. If the task state store is unavailable at runtime, the 
operator logs that crash
+recovery is disabled and behaves the same way.
+
+To opt out and always trigger a fresh run on retry, set ``durable=False``:
+
+.. code-block:: python
+
+  run_now = DatabricksRunNowOperator(
+      task_id="run_now",
+      job_id=123,
+      durable=False,
+  )
+
+Durable execution applies to the synchronous path. When ``deferrable=True`` is 
set, the Triggerer
+already tracks the run across the wait, so deferrable mode takes precedence 
and ``durable`` has no
+effect.
+
+
 DatabricksRunNowDeferrableOperator
 ==================================
 
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
index e9f2a016063..16827406466 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
@@ -980,7 +980,7 @@ class DatabricksSubmitRunOperator(ResumableJobMixin, 
BaseOperator):
         _handle_deferrable_databricks_operator_completion(event, self.log)
 
 
-class DatabricksRunNowOperator(BaseOperator):
+class DatabricksRunNowOperator(ResumableJobMixin, BaseOperator):
     """
     Runs an existing Spark job run to Databricks using the 
api/2.2/jobs/run-now API endpoint.
 
@@ -1163,6 +1163,10 @@ class DatabricksRunNowOperator(BaseOperator):
             (https://docs.databricks.com/api/workspace/jobs/update). If 
nothing is matched, then repair
             will not get triggered.
     :param cancel_previous_runs: Cancel all existing running jobs before 
submitting new one.
+    :param durable: When ``True`` (the default), the Databricks run id is 
persisted to task state
+        before polling begins so that a worker crash and retry reconnects to 
the existing run
+        instead of triggering a duplicate run of the same job. Set to 
``False`` to always trigger a
+        fresh run on retry. Requires Airflow 3.3+; on earlier versions it is 
silently ignored.
 
     .. note::
         If ``job_parameters`` is not set in ``json`` and the operator's 
``params`` dict is
@@ -1171,6 +1175,8 @@ class DatabricksRunNowOperator(BaseOperator):
         hardcoding them in ``json``.
     """
 
+    external_id_key = "databricks_run_now_id"
+
     # Used in airflow.models.BaseOperator
     template_fields: Sequence[str] = (
         "json",
@@ -1283,10 +1289,21 @@ class DatabricksRunNowOperator(BaseOperator):
         )
 
     def execute(self, context: Context):
+        if self.deferrable:
+            json = self._prepare_run_now_json()
+            self.run_id = self._hook.run_now(json)
+            _handle_deferrable_databricks_operator_execution(self, self._hook, 
self.log, context)
+        else:
+            return self.execute_resumable(context)
+
+    def _build_run_now_payload(self) -> dict[str, Any]:
+        # Utility to build the run payload: merge, validate, resolve job_name 
-> job_id, inject params.
+        # Kept separate from cancel_previous_runs so the reconnect path can 
rebuild the payload
+        # (for repair_run) without re-cancelling the run it is reconnecting to.
         json = self._get_merged_json()
         self._validate_merged_json(json)
         # Validate payload types before touching the hook so an invalid 
payload fails fast,
-        # before find_job_id_by_name / cancel_all_runs hit the Databricks API.
+        # before find_job_id_by_name hits the Databricks API.
         json = cast("dict[str, Any]", normalise_json_content(json))
         hook = self._hook
         if "job_name" in json:
@@ -1298,23 +1315,82 @@ class DatabricksRunNowOperator(BaseOperator):
             json["job_id"] = job_id
             del json["job_name"]
 
+        if not json.get("job_parameters") and self.params:
+            json["job_parameters"] = dict(self.params)
+
+        return json
+
+    def _prepare_run_now_json(self) -> dict[str, Any]:
+        json = self._build_run_now_payload()
         if self.cancel_previous_runs:
             if (job_id := json.get("job_id")) is None:
                 raise ValueError(
                     "cancel_previous_runs=True requires either job_id or 
job_name to be provided."
                 )
+            self._hook.cancel_all_runs(job_id)
 
-            hook.cancel_all_runs(job_id)
+        self._merged_json = json
+        return json
 
-        if not json.get("job_parameters") and self.params:
-            json["job_parameters"] = dict(self.params)
+    def submit_job(self, context: Context) -> int:
+        json = self._prepare_run_now_json()
+        # Set run_id the instant the run exists so on_kill can cancel it even 
if the worker dies
+        # before polling begins.
+        self.run_id = self._hook.run_now(json)
+        self.log.info("Run submitted with run_id: %s", self.run_id)
+        return self.run_id
 
-        self._merged_json = json
-        self.run_id = hook.run_now(json)
-        if self.deferrable:
-            _handle_deferrable_databricks_operator_execution(self, hook, 
self.log, context)
-        else:
-            _handle_databricks_operator_execution(self, hook, self.log, 
context)
+    def get_job_status(self, external_id: JsonValue, context: Context) -> str:
+        # Databricks splits run state across life_cycle_state and 
result_state; the mixin's status
+        # interface is a single string, so encode both as "LIFE_CYCLE:RESULT" 
and decode them in
+        # is_job_active / is_job_succeeded.
+        try:
+            run_info = self._hook.get_run(external_id)
+        except DatabricksApiError as e:
+            # A stored run whose history expired (or was deleted) returns 404. 
Report a sentinel that
+            # is neither active nor succeeded so the mixin resubmits fresh 
instead of failing the task.
+            if e.http_status_code == 404:
+                return "NOT_FOUND:"
+            raise
+        state = RunState(**run_info["state"])
+        return f"{state.life_cycle_state}:{state.result_state}"
+
+    def is_job_active(self, status: str) -> bool:
+        life_cycle_state = status.split(":", 1)[0]
+        return life_cycle_state in (
+            "PENDING",
+            "QUEUED",
+            "RUNNING",
+            "TERMINATING",
+            "BLOCKED",
+            "WAITING_FOR_RETRY",
+        )
+
+    def is_job_succeeded(self, status: str) -> bool:
+        return status == "TERMINATED:SUCCESS"
+
+    def poll_until_complete(self, external_id: JsonValue, context: Context) -> 
None:
+        # submit_job and the stored external id are always Databricks run ids 
(int).
+        self.run_id = cast("int", external_id)
+        # On reconnect submit_job did not run, so rebuild the merged payload 
(read by the repair path
+        # in the poll helper). _build_run_now_payload resolves job_name -> 
job_id but omits
+        # cancel_previous_runs, which would otherwise cancel the run we are 
reconnecting to.
+        if not getattr(self, "_merged_json", None):
+            self._merged_json = self._build_run_now_payload()
+        # The run already exists here (fresh submit logged in submit_job, or 
reconnect logged by the
+        # mixin), so the poll helper must not announce a submission.
+        _handle_databricks_operator_execution(self, self._hook, self.log, 
context, announce_submission=False)
+        # The helper pushed run_id/run_page_url xcoms; record that so 
get_job_result does not push again.
+        self._run_xcoms_pushed = True
+
+    def get_job_result(self, external_id: JsonValue, context: Context) -> None:
+        self.run_id = cast("int", external_id)
+        # The already-succeeded retry path skips polling, so push the run 
xcoms here for parity with the
+        # normal success path. When polling ran, poll_until_complete already 
pushed them.
+        if not getattr(self, "_run_xcoms_pushed", False) and self.do_xcom_push 
and context is not None:
+            context["ti"].xcom_push(key=XCOM_RUN_ID_KEY, value=self.run_id)
+            context["ti"].xcom_push(key=XCOM_RUN_PAGE_URL_KEY, 
value=self._hook.get_run_page_url(self.run_id))
+        return None
 
     def execute_complete(self, context: Context, event: dict[str, Any] | None 
= None) -> None:
         if event:
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_databricks.py 
b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
index ccf742338c0..9c83c19ebe9 100644
--- a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
@@ -1981,6 +1981,7 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_exec_with_json_string_and_templated_named_parameters(self, 
db_mock_class):
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             json='{"job_id": "1", "notebook_params": {"source": "json"}, 
"jar_params": ["json"]}',
             job_id="{{ params.job_id }}",
@@ -2010,7 +2011,7 @@ class TestDatabricksRunNowOperator:
             r"Type \<(type|class) \'datetime.datetime\'\> used "
             r"for parameter json\[test\] is not a number or a string"
         )
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=json)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=json, durable=False)
         with pytest.raises(AirflowException, match=exception_message):
             op.execute(None)
 
@@ -2024,6 +2025,7 @@ class TestDatabricksRunNowOperator:
         into the ``json`` template field, so a retry / deferral-resume 
re-renders from the original
         template instead of a clobbered dict."""
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             job_id=JOB_ID,
             json={"notebook_params": {"a": "b"}},
@@ -2055,7 +2057,7 @@ class TestDatabricksRunNowOperator:
     def test_exec_invalid_payload_fails_before_api_call(self, db_mock_class, 
kwargs):
         """An invalid payload type must fail before ``find_job_id_by_name`` / 
``cancel_all_runs`` /
         ``run_now`` touch the Databricks API."""
-        op = DatabricksRunNowOperator(task_id=TASK_ID, json={"bad": 
datetime.now()}, **kwargs)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, json={"bad": 
datetime.now()}, **kwargs, durable=False)
         db_mock = db_mock_class.return_value
 
         with pytest.raises(AirflowException, match="is not a number or a 
string"):
@@ -2108,6 +2110,7 @@ class TestDatabricksRunNowOperator:
         ``_handle_databricks_operator_execution`` -- the only path that reads 
``operator._merged_json`` --
         so a regression there (e.g. the attribute being unset) fails loudly 
instead of passing CI."""
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             job_id=JOB_ID,
             json={"job_parameters": {"k": "v"}},
@@ -2133,7 +2136,7 @@ class TestDatabricksRunNowOperator:
         Test the execute function in case where the run is successful.
         """
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
         db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
@@ -2167,7 +2170,7 @@ class TestDatabricksRunNowOperator:
         Test the execute function in case where the run failed.
         """
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
         db_mock.get_run = make_run_with_state_mock("TERMINATED", "FAILED")
@@ -2201,7 +2204,7 @@ class TestDatabricksRunNowOperator:
         Test the execute function in case where the run failed.
         """
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
         db_mock.get_run = mock_dict(
@@ -2257,7 +2260,7 @@ class TestDatabricksRunNowOperator:
         Test the execute function in case where the run failed.
         """
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
         db_mock.get_run = mock_dict(
@@ -2334,7 +2337,7 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_wait_for_termination(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
         db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
@@ -2366,7 +2369,9 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_no_wait_for_termination(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
wait_for_termination=False, json=run)
+        op = DatabricksRunNowOperator(
+            task_id=TASK_ID, job_id=JOB_ID, wait_for_termination=False, 
json=run, durable=False
+        )
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
 
@@ -2398,17 +2403,17 @@ class TestDatabricksRunNowOperator:
     def test_init_exception_with_job_name_and_job_id(self, db_mock_class):
         exception_message = "Argument 'job_name' is not allowed with argument 
'job_id'"
 
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
job_name=JOB_NAME)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
job_name=JOB_NAME, durable=False)
         with pytest.raises(AirflowException, match=exception_message):
             op.execute(None)
 
         run = {"job_id": JOB_ID, "job_name": JOB_NAME}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, json=run, durable=False)
         with pytest.raises(AirflowException, match=exception_message):
             op.execute(None)
 
         run = {"job_id": JOB_ID}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, json=run, 
job_name=JOB_NAME)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, json=run, 
job_name=JOB_NAME, durable=False)
         with pytest.raises(AirflowException, match=exception_message):
             op.execute(None)
 
@@ -2417,6 +2422,7 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_exec_exception_with_rendered_job_name_and_job_id(self, 
db_mock_class):
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             json='{"job_id": "42", "job_name": "job-name"}',
         )
@@ -2431,7 +2437,7 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_exec_with_job_name(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_name=JOB_NAME, 
json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_name=JOB_NAME, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.find_job_id_by_name.return_value = JOB_ID
         db_mock.run_now.return_value = RUN_ID
@@ -2464,7 +2470,7 @@ class TestDatabricksRunNowOperator:
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_exec_failure_if_job_id_not_found(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
-        op = DatabricksRunNowOperator(task_id=TASK_ID, job_name=JOB_NAME, 
json=run)
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_name=JOB_NAME, 
json=run, durable=False)
         db_mock = db_mock_class.return_value
         db_mock.find_job_id_by_name.return_value = None
 
@@ -2478,7 +2484,12 @@ class TestDatabricksRunNowOperator:
     def test_cancel_previous_runs(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
         op = DatabricksRunNowOperator(
-            task_id=TASK_ID, job_id=JOB_ID, cancel_previous_runs=True, 
wait_for_termination=False, json=run
+            durable=False,
+            task_id=TASK_ID,
+            job_id=JOB_ID,
+            cancel_previous_runs=True,
+            wait_for_termination=False,
+            json=run,
         )
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
@@ -2512,7 +2523,12 @@ class TestDatabricksRunNowOperator:
     def test_no_cancel_previous_runs(self, db_mock_class):
         run = {"notebook_params": NOTEBOOK_PARAMS, "notebook_task": 
NOTEBOOK_TASK, "jar_params": JAR_PARAMS}
         op = DatabricksRunNowOperator(
-            task_id=TASK_ID, job_id=JOB_ID, cancel_previous_runs=False, 
wait_for_termination=False, json=run
+            durable=False,
+            task_id=TASK_ID,
+            job_id=JOB_ID,
+            cancel_previous_runs=False,
+            wait_for_termination=False,
+            json=run,
         )
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
@@ -2551,6 +2567,7 @@ class TestDatabricksRunNowOperator:
         }
 
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             json=run,
             cancel_previous_runs=True,
@@ -2872,6 +2889,7 @@ class TestDatabricksRunNowOperator:
         (regression test for GH-39002).
         """
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             job_id=JOB_ID,
             params={"env": "prod", "batch_size": 100},
@@ -2892,6 +2910,7 @@ class TestDatabricksRunNowOperator:
         not override it.
         """
         op = DatabricksRunNowOperator(
+            durable=False,
             task_id=TASK_ID,
             job_id=JOB_ID,
             json={"job_parameters": {"explicit": "value"}},
@@ -2907,6 +2926,227 @@ class TestDatabricksRunNowOperator:
         assert actual["job_parameters"] == {"explicit": "value"}
 
 
[email protected](
+    not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution) 
requires Airflow 3.3+"
+)
+class TestDatabricksRunNowOperatorDurable:
+    @staticmethod
+    def _context(task_store=None):
+        ctx: dict = {"ti": MagicMock(stats_tags={})}
+        if task_store is not None:
+            ctx["task_state_store"] = task_store
+        return ctx
+
+    @staticmethod
+    def _state(lifecycle: str, result: str = ""):
+        return {"state": {"life_cycle_state": lifecycle, "result_state": 
result, "state_message": ""}}
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_persists_run_id_to_task_state_store_on_fresh_submit(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = None
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_called_once()
+        task_store.set.assert_called_once_with("databricks_run_now_id", RUN_ID)
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_reconnects_to_active_run_without_resubmitting(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        # status check sees the stored run still RUNNING, the poll then sees 
it finish.
+        db_mock.get_run.side_effect = [self._state("RUNNING"), 
self._state("TERMINATED", "SUCCESS")]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = RUN_ID
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_not_called()
+        task_store.set.assert_not_called()
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_reconnects_to_blocked_run_without_resubmitting(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        # A gated run sits in BLOCKED; the durable retry must reconnect and 
wait, not resubmit.
+        db_mock.get_run.side_effect = [self._state("BLOCKED"), 
self._state("TERMINATED", "SUCCESS")]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = RUN_ID
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_not_called()
+        task_store.set.assert_not_called()
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_reconnect_with_job_name_resolves_job_id_for_repair(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_name=JOB_NAME)
+        db_mock = db_mock_class.return_value
+        db_mock.find_job_id_by_name.return_value = JOB_ID
+        db_mock.get_run.side_effect = [self._state("RUNNING"), 
self._state("TERMINATED", "SUCCESS")]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = RUN_ID
+
+        op.execute(self._context(task_store))
+
+        # The reconnect rebuilds the payload and resolves job_name -> job_id 
so repair_run has it,
+        # without resubmitting the run.
+        db_mock.run_now.assert_not_called()
+        db_mock.find_job_id_by_name.assert_called_once_with(JOB_NAME)
+        assert op._merged_json["job_id"] == JOB_ID
+        assert "job_name" not in op._merged_json
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks._handle_databricks_operator_execution")
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_already_succeeded_pushes_xcoms_without_polling(self, 
db_mock_class, mock_poll):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
+        db_mock.get_run_page_url.return_value = RUN_PAGE_URL
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = RUN_ID
+        ti = MagicMock(stats_tags={})
+
+        op.execute({"ti": ti, "task_state_store": task_store})
+
+        # No duplicate run and no poll loop, but the run xcoms are still 
pushed for parity.
+        db_mock.run_now.assert_not_called()
+        mock_poll.assert_not_called()
+        db_mock.get_run_page_url.assert_called_once_with(RUN_ID)
+        ti.xcom_push.assert_any_call(key="run_id", value=RUN_ID)
+        ti.xcom_push.assert_any_call(key="run_page_url", value=RUN_PAGE_URL)
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_fresh_submit_pushes_run_xcoms_exactly_once(self, db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
+        db_mock.get_run_page_url.return_value = RUN_PAGE_URL
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = None
+        ti = MagicMock(stats_tags={})
+
+        op.execute({"ti": ti, "task_state_store": task_store})
+
+        # Poll helper pushes run_id + run_page_url; get_job_result must not 
push them again.
+        assert ti.xcom_push.call_count == 2
+        db_mock.get_run_page_url.assert_called_once_with(RUN_ID)
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_resubmits_when_stored_run_in_terminal_failure(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        db_mock.get_run.side_effect = [
+            self._state("TERMINATED", "FAILED"),
+            self._state("TERMINATED", "SUCCESS"),
+        ]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = 999
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_called_once()
+        task_store.set.assert_called_once_with("databricks_run_now_id", RUN_ID)
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_resubmits_when_stored_run_no_longer_exists(self, db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        # the stored run's history has expired -> get_run 404s; the fresh run 
then polls to success.
+        db_mock.get_run.side_effect = [
+            DatabricksApiError("Response: RESOURCE_DOES_NOT_EXIST, Status 
Code: 404", http_status_code=404),
+            self._state("TERMINATED", "SUCCESS"),
+        ]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = 999
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_called_once()
+        task_store.set.assert_called_once_with("databricks_run_now_id", RUN_ID)
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_durable_false_never_touches_task_state_store(self, db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
durable=False)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
+        task_store = MagicMock(spec_set=["get", "set"])
+
+        op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_called_once()
+        task_store.get.assert_not_called()
+        task_store.set.assert_not_called()
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_durable_true_submits_fresh_missing_task_state_store(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.run_now.return_value = RUN_ID
+        db_mock.get_run = make_run_with_state_mock("TERMINATED", "SUCCESS")
+
+        op.execute(self._context())  # no task_state_store in context
+
+        db_mock.run_now.assert_called_once()
+        assert op.run_id == RUN_ID
+
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def test_get_job_status_encodes_life_cycle_and_result(self, db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.get_run.return_value = self._state("TERMINATED", "SUCCESS")
+
+        assert op.get_job_status(RUN_ID, {}) == "TERMINATED:SUCCESS"
+
+    @pytest.mark.parametrize(
+        ("status", "expected"),
+        [
+            ("RUNNING:", True),
+            ("PENDING:", True),
+            ("QUEUED:", True),
+            ("TERMINATING:", True),
+            ("BLOCKED:", True),
+            ("WAITING_FOR_RETRY:", True),
+            ("TERMINATED:SUCCESS", False),
+            ("TERMINATED:FAILED", False),
+            ("SKIPPED:", False),
+            ("INTERNAL_ERROR:", False),
+            ("NOT_FOUND:", False),
+        ],
+    )
+    def test_is_job_active(self, status, expected):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        assert op.is_job_active(status) is expected
+
+    @pytest.mark.parametrize(
+        ("status", "expected"),
+        [
+            ("TERMINATED:SUCCESS", True),
+            ("TERMINATED:FAILED", False),
+            ("RUNNING:", False),
+            ("SKIPPED:", False),
+        ],
+    )
+    def test_is_job_succeeded(self, status, expected):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        assert op.is_job_succeeded(status) is expected
+
+
 class TestDatabricksSQLStatementsOperator:
     def test_init(self):
         """

Reply via email to