kaxil commented on code in PR #76295:
URL: https://github.com/apache/airflow/pull/76295#discussion_r4443942012
##########
providers/databricks/src/airflow/providers/databricks/operators/databricks.py:
##########
@@ -2275,6 +2286,51 @@ def _monitor_submit_run(self) -> None:
errors = extract_failed_task_errors(self._hook, run, run_state)
self._handle_terminal_run_state(run_state, errors)
+ def submit_job(self, context: Context) -> int | None:
Review Comment:
`_launch_job` makes a second `runs/get` call for the page URL and pushes two
XComs before it returns, and the mixin only persists the run id once
`submit_job` returns. If `get_run_page_url` fails after `submit_run` succeeded,
the try fails with nothing stored and the retry submits a duplicate, which is
the case this PR sets out to close. The siblings return straight after
`submit_run` and push the XComs from the poll hook, so the restructure
suggested on `poll_until_complete` would cover this too.
##########
providers/databricks/src/airflow/providers/databricks/operators/databricks.py:
##########
@@ -2275,6 +2286,51 @@ def _monitor_submit_run(self) -> None:
errors = extract_failed_task_errors(self._hook, run, run_state)
self._handle_terminal_run_state(run_state, errors)
+ def submit_job(self, context: Context) -> int | None:
+ return self._launch_job(context=context)
+
+ def get_job_status(self, external_id: JsonValue, context: Context) -> str:
+ self.databricks_run_id = cast("int", external_id)
+ try:
+ run = self._hook.get_run(external_id)
+ except DatabricksApiError as e:
+ # Run history expired or was deleted: neither active nor
succeeded, so the mixin
+ # resubmits fresh instead of failing the task.
+ if e.http_status_code == 404:
+ return "NOT_FOUND:"
+ raise
+ # Databricks splits run state across life_cycle_state and
result_state; the mixin's status
+ # interface is a single string, so encode both and decode them in the
predicates below.
+ state = RunState(**run["state"])
+ return f"{state.life_cycle_state}:{state.result_state}"
+
+ def is_job_active(self, status: str) -> bool:
+ return status.split(":", 1)[0] 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:
Review Comment:
Nothing pushes `run_id` / `run_page_url` when this reconnects. The mixin's
reconnect branch returns `poll_until_complete(...)` without calling
`get_job_result`, `_launch_job` doesn't run, and `ti_run` has already cleared
the crashed try's XComs. After a crash recovery the run link is empty and a
downstream `xcom_pull(key="run_id")` gets None, deferrable or not.
`DatabricksSubmitRunOperator.poll_until_complete` pushes them itself and sets
`_run_xcoms_pushed`. Can this do the same before `monitor_databricks_job`,
sharing the push with `get_job_result`?
##########
providers/databricks/docs/operators/notebook.rst:
##########
@@ -67,7 +67,8 @@ whole Airflow task:
)
Airflow ``retries`` behaves differently depending on where the operator runs.
For a standalone
-operator, each retry submits a new Databricks run. Inside a
+operator, a retry reconnects to the run it already submitted (see `Durable
execution`_ below).
Review Comment:
Isn't this only true when the worker died mid-run? If the Databricks run
itself failed, which is the usual reason for a retry, the next try submits a
new run, as the section below says. I'd say a retry reconnects only when the
previous try was lost while the run was still going. `task.rst` has the same
sentence.
##########
providers/databricks/tests/unit/databricks/operators/test_databricks.py:
##########
@@ -5267,3 +5267,105 @@ def
test_monitor_waits_out_waiting_for_retry_state(self, mock_databricks_hook, m
operator.monitor_databricks_job()
assert mock_databricks_hook.return_value.get_run.call_count == 3
+
+
[email protected](
+ not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution)
requires Airflow 3.3+"
+)
+class TestDatabricksTaskBaseOperatorDurable:
Review Comment:
`DatabricksTaskBaseOperator` has its own `get_job_status` / `is_job_active`
/ `is_job_succeeded`, so the SubmitRun and RunNow durable classes don't cover
them. Dropping `"BLOCKED"` from `is_job_active`, or turning the 404 branch into
a `raise`, keeps this file green. Could this class get what the SubmitRun one
has: the state predicates parametrized over the lifecycle states, a 404 stored
run and a `TERMINATED:FAILED` stored run that both resubmit, `durable=False`
never touching the store, and xcom assertions on the fresh-submit and reconnect
tests? Nothing passes `durable=` to either subclass yet, so losing the
`durable=durable` forward would go unnoticed too. `_state` is also a copy of
`TestDatabricksSubmitRunOperatorDurable._state` and could move to module level.
##########
providers/databricks/docs/operators/notebook.rst:
##########
@@ -80,3 +81,35 @@ attempt is observed, so downstream failure handling is not
delayed by long-runni
Only unlimited retries (``max_retries=-1``) keep the Airflow task waiting (or
deferring) until the
parent workflow run reaches a terminal state, because Databricks may still
launch a retry attempt
under the same ``task_key`` until then. Sibling tasks in the run continue
independently.
+
+
+Durable execution
+-----------------
+
+Outside a
+:class:`~airflow.providers.databricks.operators.databricks_workflow.DatabricksWorkflowTaskGroup`,
+this operator submits a run and polls it to completion on the worker. By
default it runs in a
+*durable* mode: the Databricks run id is persisted to
+:doc:`task state store <apache-airflow:core-concepts/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 submitting a duplicate.
+
+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
resubmitting
+* if it failed terminally, the operator submits a fresh run
+
+Pass ``durable=False`` to always submit a fresh run on retry.
+
+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 submits a
fresh run on retry.
+If the task state store is unavailable at runtime, the operator logs that
crash recovery is
+disabled and behaves the same way.
+
+Inside a ``DatabricksWorkflowTaskGroup`` nothing changes: the run is submitted
by the workflow
+launch task and this operator only monitors its own task within that shared
run, so a retry already
+reconnects rather than resubmitting.
+
+See :doc:`apache-airflow:core-concepts/resumable-tasks` for how clearing a
task interacts with the
+stored run id, and for the retention caveats around ``airflow state-store
clean``.
Review Comment:
`resumable-tasks` doesn't mention `airflow state-store clean` or retention
at all. That caveat lives in
`administration-and-deployment/task-and-asset-state-store-cleanup` and
`core-concepts/task-state-store`, and `submit_run.rst` states it inline (a
`retry_delay` longer than `default_retention_days` with cleanup in between
means the retry submits fresh). Could this inline that paragraph or point the
retention half at the cleanup page? Same in `task.rst`.
##########
providers/databricks/src/airflow/providers/databricks/operators/databricks.py:
##########
@@ -2275,6 +2286,51 @@ def _monitor_submit_run(self) -> None:
errors = extract_failed_task_errors(self._hook, run, run_state)
self._handle_terminal_run_state(run_state, errors)
+ def submit_job(self, context: Context) -> int | None:
+ return self._launch_job(context=context)
+
+ def get_job_status(self, external_id: JsonValue, context: Context) -> str:
Review Comment:
This is the third copy of `get_job_status` / `is_job_active` /
`is_job_succeeded` in this module, with the same bodies as
`DatabricksSubmitRunOperator` and `DatabricksRunNowOperator`. The
`LIFE_CYCLE:RESULT` string and the active-state tuple are a small protocol
between the three methods, so a state added to one copy later changes reconnect
behaviour for only some of the operators. Could they move into a module-level
helper or a small private mixin all three use?
##########
providers/databricks/docs/operators/notebook.rst:
##########
@@ -80,3 +81,35 @@ attempt is observed, so downstream failure handling is not
delayed by long-runni
Only unlimited retries (``max_retries=-1``) keep the Airflow task waiting (or
deferring) until the
parent workflow run reaches a terminal state, because Databricks may still
launch a retry attempt
under the same ``task_key`` until then. Sibling tasks in the run continue
independently.
+
+
+Durable execution
+-----------------
+
+Outside a
+:class:`~airflow.providers.databricks.operators.databricks_workflow.DatabricksWorkflowTaskGroup`,
+this operator submits a run and polls it to completion on the worker. By
default it runs in a
Review Comment:
Unlike `DatabricksSubmitRunOperator` and `DatabricksRunNowOperator`,
`execute` here goes through `execute_resumable` with `deferrable=True` as well,
so the run id is stored before `_defer_on_run` and a retry still reconnects.
The sibling pages say `durable` has no effect in deferrable mode, and "polls it
to completion on the worker" isn't true once it defers. Is the difference
intended? If so, a sentence saying durable also covers deferrable mode here
would stop readers carrying the sibling pages' rule over. Same in `task.rst`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]