kaxil commented on code in PR #76021:
URL: https://github.com/apache/airflow/pull/76021#discussion_r4467490253
##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -7422,39 +7423,135 @@ def execute(self, context):
]
assert ClearTaskStateStore not in sent_types
+ @staticmethod
+ @contextlib.contextmanager
+ def _patch_state_backend(backend):
+ # Both modules resolve the backend independently, so both lookups need
patching.
+ with (
+ mock.patch(
+
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
+ return_value=backend,
+ ),
+ mock.patch(
+
"airflow.sdk.execution_time.task_runner._get_worker_state_store_backend",
+ return_value=backend,
+ ),
+ ):
+ yield backend
+
+ @staticmethod
+ def _run_and_finalize(runtime_ti, log):
+ # The clear lives at the tail of finalize(), so run() alone would not
reach it.
+ context = runtime_ti.get_template_context()
+ state, msg, error = run(runtime_ti, context=context, log=log)
+ finalize(runtime_ti, state, context, log, error)
+ return state, msg, error
+
@conf_vars({("state_store", "clear_on_success"): "True"})
def test_clear_on_success_clears_backend_without_comms_roundtrip(
self, create_runtime_ti, mock_supervisor_comms
):
"""clear_on_success calls backend.clear() directly without sending
ClearTaskStateStore comms."""
- mock_backend = mock.MagicMock()
+ backend = mock.create_autospec(BaseStoreBackend, instance=True)
class MyOperator(BaseOperator):
def execute(self, context):
pass
- task = MyOperator(task_id="t")
- runtime_ti = create_runtime_ti(task=task)
+ runtime_ti = create_runtime_ti(task=MyOperator(task_id="t"))
- with (
- mock.patch(
-
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
- return_value=mock_backend,
- ),
- mock.patch(
-
"airflow.sdk.execution_time.task_runner._get_worker_state_store_backend",
- return_value=mock_backend,
- ),
- ):
- run(runtime_ti, context=runtime_ti.get_template_context(),
log=mock.MagicMock())
+ with self._patch_state_backend(backend):
+ self._run_and_finalize(runtime_ti, mock.MagicMock())
- mock_backend.clear.assert_called_once()
+ backend.clear.assert_called_once()
sent_types = [
type(call.kwargs.get("msg") or (call.args[0] if call.args else
None))
for call in mock_supervisor_comms.send.call_args_list
]
assert ClearTaskStateStore not in sent_types
+ @conf_vars({("state_store", "clear_on_success"): "True"})
+ def test_clear_on_success_runs_after_success_is_reported(self,
create_runtime_ti, mock_supervisor_comms):
+ """The backend is only cleared once SucceedTask has been reported to
the supervisor."""
+ backend = mock.create_autospec(BaseStoreBackend, instance=True)
+ order = []
+ mock_supervisor_comms.send.side_effect = lambda msg, **kwargs:
order.append(type(msg).__name__)
+ backend.clear.side_effect = lambda *a, **kw: order.append("clear")
+
+ class MyOperator(BaseOperator):
+ def execute(self, context):
+ pass
+
+ runtime_ti = create_runtime_ti(task=MyOperator(task_id="t"))
+
+ with self._patch_state_backend(backend):
+ state, _, _ = self._run_and_finalize(runtime_ti, mock.MagicMock())
+
+ assert state == TaskInstanceState.SUCCESS
+ assert order.index("SucceedTask") < order.index("clear")
Review Comment:
This still passes with the clear back in `run()` right after the send, since
nothing runs between `SucceedTask` and `clear` here. An `on_success_callback`
that appends to `order`, with `SucceedTask < callback < clear`, would pin the
move to the end of `finalize()`.
##########
airflow-core/docs/core-concepts/task-state-store.rst:
##########
@@ -316,4 +316,18 @@ When ``[state_store] clear_on_success = True``, all task
state store keys for a
``clear_on_success`` clears **task state store only**. Asset store is
scoped to the asset, not the task instance, and is never affected by this
setting. Asset store persists across runs and must be cleared explicitly.
+.. note::
+
+ With a custom ``[workers] state_store_backend``, the worker deletes the
backend's entries only
+ after the success has been reported, once the task's callbacks and
listeners have run. If that
+ deletion fails the task stays successful and the failure is logged with the
Dag id, run id and
+ task id.
+
+ Those entries are then orphaned: the API server has already removed the
metadata-DB references,
+ ``airflow state-store clean`` covers the metastore backend only, and
+ :meth:`~airflow.sdk.state.BaseStoreBackend.cleanup` is a no-op unless your
backend implements
+ it. Because the storage scope includes the run id, the next Dag run writes
elsewhere rather than
+ overwriting them. Removing them is up to your storage's own lifecycle or
TTL policy, or a
+ ``cleanup()`` implementation on your backend.
Review Comment:
Nothing in Airflow calls `cleanup()` for a worker backend though, since
`airflow state-store clean` skips it. Could this say it has to be called from
the user's own maintenance process, like the state-store cleanup page does?
##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -1835,12 +1838,49 @@ def run(
# a retry policy turned the exception into a reschedule.
if error is not None or state in (TaskInstanceState.FAILED,
TaskInstanceState.UP_FOR_RETRY):
ti._terminal_state_send_failed = True
+ else:
+ if state == TaskInstanceState.SUCCESS:
+ ti._state_store_clear_owed = True
# Return the message to make unit tests easier too
ti.state = state
return state, msg, error
+def _clear_task_state_store_on_success(context: Context, log: Logger) -> None:
+ """
+ Clear the custom state store backend once the task is recorded as
successful.
+
+ Called at the end of finalize(), never before the terminal state is
reported: the
+ backend is remote storage, so a failure deleting from it must not turn a
task that
+ succeeded into a failed one, and a slow delete must not spend the
task_success_overtime
+ budget that the success callbacks and listeners need.
+ """
+ if not conf.getboolean("state_store", "clear_on_success"):
+ return
+ try:
+ # Inside the try: the backend is built on first use, so a bad [workers]
+ # state_store_backend class path raises here for a task that never
touched
+ # task_state_store, and @cache does not cache the raise.
+ if _get_worker_state_store_backend() is None:
+ return
+ log.info(
+ "Clearing task state from custom backend as clear_on_success is
enabled. The database references will be cleared by the API server."
+ )
+ context["task_state_store"]._clear_backend_only()
+ except Exception:
+ ti = context["ti"]
+ log.exception(
+ "Failed to clear task state from custom backend; the task stays
successful. Nothing in Airflow "
+ "reclaims these entries -- the database references are already
gone and the next run uses a "
+ "different run_id -- so only the storage's own lifecycle or TTL
policy will remove them.",
+ dag_id=ti.dag_id,
+ run_id=ti.run_id,
+ task_id=ti.task_id,
+ map_index=ti.map_index,
Review Comment:
The backend key comes from the `TaskScope` in `get_template_context`, which
uses `region_index` and `region_id` rather than `ti.map_index`, so for a task
inside a region this can point at the wrong entries. Could the scope go on the
`Clearing task state` info line instead? That line is also the only record when
the delete hangs past `task_success_overtime`, since `_on_term` only calls
`on_kill()` and this `log.exception` never runs.
##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -1835,12 +1838,49 @@ def run(
# a retry policy turned the exception into a reschedule.
if error is not None or state in (TaskInstanceState.FAILED,
TaskInstanceState.UP_FOR_RETRY):
ti._terminal_state_send_failed = True
+ else:
+ if state == TaskInstanceState.SUCCESS:
+ ti._state_store_clear_owed = True
# Return the message to make unit tests easier too
ti.state = state
return state, msg, error
+def _clear_task_state_store_on_success(context: Context, log: Logger) -> None:
+ """
+ Clear the custom state store backend once the task is recorded as
successful.
+
+ Called at the end of finalize(), never before the terminal state is
reported: the
+ backend is remote storage, so a failure deleting from it must not turn a
task that
+ succeeded into a failed one, and a slow delete must not spend the
task_success_overtime
+ budget that the success callbacks and listeners need.
+ """
+ if not conf.getboolean("state_store", "clear_on_success"):
+ return
+ try:
+ # Inside the try: the backend is built on first use, so a bad [workers]
+ # state_store_backend class path raises here for a task that never
touched
+ # task_state_store, and @cache does not cache the raise.
+ if _get_worker_state_store_backend() is None:
+ return
+ log.info(
+ "Clearing task state from custom backend as clear_on_success is
enabled. The database references will be cleared by the API server."
+ )
+ context["task_state_store"]._clear_backend_only()
Review Comment:
Now that this runs after `on_success_callback` and the success listeners,
anything they write through `task_state_store` gets a fresh DB ref (the set
route only checks `working_set`), and this then deletes the object behind it
and leaves the ref. Could this call `task_state_store.clear()` instead, so the
refs go first? It's one extra message, and the `_clear_backend_only` docstring
about the round-trip being redundant no longer holds anyway.
--
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]