This is an automated email from the ASF dual-hosted git repository.
guan404ming 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 f306f1848f6 Refresh HITL review details on task clear (#73235)
f306f1848f6 is described below
commit f306f1848f6a847abe482bee29c52c83f6dfb742
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Sep 30 23:22:54 2026 +0800
Refresh HITL review details on task clear (#73235)
* Refresh HITL review details on task clear
A cleared or retried HITL task re-runs execute and posts a fresh request,
but the existing row kept the first attempt's subject, body, defaults and
params. Reviewers then saw and approved stale content, and pre-filled params
returned the first attempt's output as the task result.
* Re-read the HITL row under lock before validating a response
* Pin refreshed HITL columns and lock TI explicitly
---
.../api_fastapi/core_api/routes/public/hitl.py | 17 +++----
.../api_fastapi/execution_api/routes/hitl.py | 54 +++++++++++++---------
.../core_api/routes/public/test_hitl.py | 28 +++++++++++
.../execution_api/versions/head/test_hitl.py | 36 +++++++++++++--
4 files changed, 101 insertions(+), 34 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
index 25862ab527b..4771d1de934 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
@@ -171,20 +171,21 @@ def update_hitl_detail(
# Execution API park transition, so a human response racing the worker's
park cannot deadlock.
# Locking the TI also serializes respond-vs-clear (the clear path locks
the TI, not the HITL row).
locked_ti = (
- session.get(TI, task_instance.id, with_for_update={"of": TI})
+ session.get(TI, task_instance.id, with_for_update={"of": TI},
populate_existing=True)
if isinstance(task_instance, TI)
else None
)
# Lock the hitl_detail row (FOR UPDATE OF hitl_detail). of= scopes the
lock to hitl_detail, which
# eager-joins task_instance (lazy="joined"); a bare with_for_update()
would emit FOR UPDATE against
- # the nullable side of that outer join, which Postgres rejects. The
joinedloaded relationship object
- # reused below is the same identity-mapped row, now locked for this
transaction.
- session.execute(
+ # the nullable side of that outer join, which Postgres rejects.
populate_existing re-reads the
+ # joinedloaded row under the lock, so assignees and options are validated
against the request committed
+ # by a concurrent upsert from the re-run, not the snapshot taken before
locking.
+ hitl_detail_model = session.scalars(
select(HITLDetailModel)
.where(HITLDetailModel.ti_id == task_instance.id)
.with_for_update(of=HITLDetailModel)
- )
- hitl_detail_model = task_instance.hitl_detail
+ .execution_options(populate_existing=True)
+ ).one()
if hitl_detail_model.response_received:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -225,10 +226,10 @@ def update_hitl_detail(
"Multiple options chosen but this Human-in-the-loop task accepts
only a single option.",
)
- hitl_detail_model.responded_by = hitl_user
+ hitl_detail_model.responded_by = dict(hitl_user)
hitl_detail_model.responded_at = timezone.utcnow()
hitl_detail_model.chosen_options =
update_hitl_detail_payload.chosen_options
- hitl_detail_model.params_input = update_hitl_detail_payload.params_input
+ hitl_detail_model.params_input =
dict(update_hitl_detail_payload.params_input)
session.add(hitl_detail_model)
# Event-driven resume: if the task is parked waiting for this input,
transition it directly,
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
index 74cda44efe7..5d34cc6921f 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
@@ -32,6 +32,7 @@ from airflow.api_fastapi.execution_api.datamodels.hitl import
(
)
from airflow.api_fastapi.execution_api.security import ExecutionAPIRoute,
require_auth
from airflow.models.hitl import HITLDetail
+from airflow.models.taskinstance import TaskInstance as TI
router = VersionedAPIRouter(
route_class=ExecutionAPIRoute,
@@ -61,34 +62,43 @@ def upsert_hitl_detail(
1. If a HITLOperator task instance does not have a HITLDetail,
a new HITLDetail is created without a response section.
2. If a HITLOperator task instance has a HITLDetail but lacks a response,
- the existing HITLDetail is returned.
+ the request part is refreshed from the payload and the HITLDetail is
returned.
This situation occurs when a task instance is cleared before a response
is received.
3. If a HITLOperator task instance has both a HITLDetail and a response
section,
- the existing response is removed, and the HITLDetail is returned.
+ the request part is refreshed, the existing response is removed, and
the HITLDetail is returned.
This happens when a task instance is cleared after a response has been
received.
This design ensures that each task instance has only one HITLDetail.
"""
- hitl_detail_model =
session.scalar(select(HITLDetail).where(HITLDetail.ti_id == task_instance_id))
+ request_part = {
+ "options": payload.options,
+ "subject": payload.subject,
+ "body": payload.body,
+ "defaults": payload.defaults,
+ "multiple": payload.multiple,
+ "params": payload.params,
+ "assignees": [user.model_dump() for user in payload.assigned_users],
+ "created_at": timezone.utcnow(),
+ }
+ # Same lock order as the park transition and the Core API response path
(TaskInstance, then
+ # hitl_detail), so a response landing between the read and the flush
cannot survive the rewrite.
+ session.get(TI, task_instance_id, with_for_update={"of": TI})
+ hitl_detail_model = session.scalar(
+ select(HITLDetail).where(HITLDetail.ti_id ==
task_instance_id).with_for_update(of=HITLDetail)
+ )
if not hitl_detail_model:
- hitl_detail_model = HITLDetail(
- ti_id=task_instance_id,
- options=payload.options,
- subject=payload.subject,
- body=payload.body,
- defaults=payload.defaults,
- multiple=payload.multiple,
- params=payload.params,
- assignees=[user.model_dump() for user in payload.assigned_users],
- )
- session.add(hitl_detail_model)
- elif hitl_detail_model.response_received:
- # Cleanup the response part of HITLDetail as we only store one
response for one task instance.
- # It normally happens after retry, we keep only the latest response.
- hitl_detail_model.responded_by = None
- hitl_detail_model.responded_at = None
- hitl_detail_model.chosen_options = None
- hitl_detail_model.params_input = {}
- session.add(hitl_detail_model)
+ hitl_detail_model = HITLDetail(ti_id=task_instance_id, **request_part)
+ else:
+ if hitl_detail_model.response_received:
+ # Cleanup the response part of HITLDetail as we only store one
response for one task instance.
+ # It normally happens after retry, we keep only the latest
response.
+ hitl_detail_model.responded_by = None
+ hitl_detail_model.responded_at = None
+ hitl_detail_model.chosen_options = None
+ hitl_detail_model.params_input = {}
+ # A retry re-runs the operator with regenerated content, so the row
must follow the latest request.
+ for column, value in request_part.items():
+ setattr(hitl_detail_model, column, value)
+ session.add(hitl_detail_model)
return HITLDetailRequest.model_validate(hitl_detail_model)
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
index 75f7d65ecbd..87cd5cbf871 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
@@ -30,6 +30,7 @@ from sqlalchemy.orm import Session
from airflow._shared.serialization import CLASSNAME, FORBIDDEN_XCOM_KEYS
from airflow._shared.timezones.timezone import utc, utcnow
+from airflow.api_fastapi.core_api.routes.public import hitl as hitl_routes
from airflow.models.dag import DagModel
from airflow.models.dagbundle import DagBundleModel
from airflow.models.hitl import HITLDetail
@@ -426,6 +427,33 @@ class TestUpdateHITLDetailEndpoint:
assert response.status_code == 400
assert "Invalid options" in response.json()["detail"]
+ @pytest.mark.usefixtures("sample_hitl_detail")
+ def test_should_validate_against_row_refreshed_under_lock(
+ self,
+ test_client: TestClient,
+ sample_ti_url_identifier: str,
+ sample_ti: TaskInstance,
+ ) -> None:
+ original = hitl_routes._get_task_instance_with_hitl_detail
+
+ def load_then_rewrite_options(**kwargs: Any) -> Any:
+ ti = original(**kwargs)
+ kwargs["session"].execute(
+ update(HITLDetail).where(HITLDetail.ti_id ==
sample_ti.id).values(options=["Retry"]),
+ execution_options={"synchronize_session": False},
+ )
+ return ti
+
+ with mock.patch.object(
+ hitl_routes, "_get_task_instance_with_hitl_detail",
side_effect=load_then_rewrite_options
+ ):
+ response = test_client.patch(
+ f"{sample_ti_url_identifier}/hitlDetails",
+ json={"chosen_options": ["Retry"], "params_input": {}},
+ )
+ assert response.status_code == 200
+ assert response.json()["chosen_options"] == ["Retry"]
+
@pytest.mark.usefixtures("sample_hitl_detail")
@pytest.mark.parametrize("reserved_key", sorted(FORBIDDEN_XCOM_KEYS))
@pytest.mark.parametrize(
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
index 0b51dfead30..aeda1196886 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
import pytest
import time_machine
from httpx import Client
+from sqlalchemy import select
from uuid6 import uuid7
from airflow._shared.timezones.timezone import convert_to_utc
@@ -103,6 +104,7 @@ def expected_sample_hitl_detail_dict(sample_ti:
TaskInstance) -> dict[str, Any]:
"existing hitl detail with response",
],
)
+@time_machine.travel(datetime(2025, 7, 4, 0, 0, 0), tick=False)
def test_upsert_hitl_detail(
client: TestClient,
create_task_instance: CreateTaskInstance,
@@ -113,26 +115,52 @@ def test_upsert_hitl_detail(
session.commit()
if existing_hitl_detail_args:
- session.add(HITLDetail(ti_id=ti.id, **existing_hitl_detail_args))
+ session.add(
+ HITLDetail(
+ ti_id=ti.id,
+ created_at=convert_to_utc(datetime(2025, 7, 1, 0, 0, 0)),
+ **existing_hitl_detail_args,
+ )
+ )
session.commit()
+ request_kwargs = {
+ "subject": "Regenerated subject",
+ "body": "regenerated body",
+ "options": ["Retry", "Skip"],
+ "defaults": ["Skip"],
+ "multiple": True,
+ "params": {"input_1": 3},
+ "assigned_users": [{"id": "2", "name": "reviewer"}],
+ }
response = client.post(
f"/execution/hitlDetails/{ti.id}",
json={
"ti_id": str(ti.id),
- **default_hitl_detail_request_kwargs,
+ **request_kwargs,
},
)
expected_json = {
"ti_id": str(ti.id),
- **default_hitl_detail_request_kwargs,
+ **request_kwargs,
}
- expected_json["assigned_users"] = expected_json.pop("assignees") or []
assert response.status_code == 201
assert response.json() == expected_json
+ session.expire_all()
+ hitl_detail = session.scalars(select(HITLDetail).where(HITLDetail.ti_id ==
ti.id)).one()
+ assert hitl_detail.subject == "Regenerated subject"
+ assert hitl_detail.body == "regenerated body"
+ assert hitl_detail.options == ["Retry", "Skip"]
+ assert hitl_detail.defaults == ["Skip"]
+ assert hitl_detail.multiple is True
+ assert hitl_detail.params == {"input_1": 3}
+ assert hitl_detail.assignees == [{"id": "2", "name": "reviewer"}]
+ assert hitl_detail.created_at == convert_to_utc(datetime(2025, 7, 4, 0, 0,
0))
+ assert hitl_detail.response_received is False
+
def test_upsert_hitl_detail_with_empty_option(
client: TestClient,