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

potiuk 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 4b9c3b54a7b Validate trigger events in Openai deferrable tasks (#69506)
4b9c3b54a7b is described below

commit 4b9c3b54a7bab8022075294b1a9b2066c88350f2
Author: Takayoshi Makabe <[email protected]>
AuthorDate: Sun Aug 2 02:58:54 2026 +0900

    Validate trigger events in Openai deferrable tasks (#69506)
    
    * Add validate trigger events deferrable tasks
    
    * Record the cancelled-batch behaviour change for openai users
    
    A deferred batch that was cancelled previously completed successfully
    with no results, so operators upgrading need to know their tasks will
    start failing where they used to go green.
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 providers/openai/docs/changelog.rst                | 15 ++++++++
 .../src/airflow/providers/openai/exceptions.py     |  4 +++
 .../src/airflow/providers/openai/hooks/openai.py   | 26 +++++++++++++-
 .../airflow/providers/openai/operators/openai.py   |  5 +--
 .../airflow/providers/openai/triggers/openai.py    | 16 ++++-----
 .../openai/tests/unit/openai/hooks/test_openai.py  | 39 +++++++++++++++++++--
 .../tests/unit/openai/operators/test_openai.py     | 40 ++++++++++++++++++++++
 .../tests/unit/openai/triggers/test_openai.py      | 22 ++++++++++++
 8 files changed, 154 insertions(+), 13 deletions(-)

diff --git a/providers/openai/docs/changelog.rst 
b/providers/openai/docs/changelog.rst
index 3d304eb8a94..61d78b800d0 100644
--- a/providers/openai/docs/changelog.rst
+++ b/providers/openai/docs/changelog.rst
@@ -20,6 +20,21 @@
 Changelog
 ---------
 
+.. note::
+    ``OpenAITriggerBatchOperator`` now fails the task when a deferred batch 
ends in a
+    non-success state. Previously only ``status="error"`` raised, so a batch 
that was
+    **cancelled** while the task was deferred completed **successfully** with 
no results,
+    and an unrecognized or missing trigger event either succeeded silently or 
crashed with
+    ``TypeError: 'NoneType' object is not subscriptable``.
+
+    This aligns the deferrable path with the non-deferrable one, which has 
raised for
+    ``CANCELLED``/``CANCELLING`` since ``OpenAIHook.wait_for_batch`` was fixed.
+
+    Dags whose batches are cancelled will now fail where they previously 
reported success.
+    That is the intended correction — the earlier green runs produced no batch 
output — but
+    if you were relying on cancellation being treated as success, handle it 
explicitly, for
+    example with a trigger rule or by catching ``OpenAIBatchJobException`` 
downstream.
+
 1.8.1
 .....
 
diff --git a/providers/openai/src/airflow/providers/openai/exceptions.py 
b/providers/openai/src/airflow/providers/openai/exceptions.py
index 05eee6b24fb..85f015880c5 100644
--- a/providers/openai/src/airflow/providers/openai/exceptions.py
+++ b/providers/openai/src/airflow/providers/openai/exceptions.py
@@ -26,3 +26,7 @@ class OpenAIBatchJobException(AirflowException):
 
 class OpenAIBatchTimeout(AirflowException):
     """Raise when OpenAI Batch Job times out."""
+
+
+class OpenAITriggerEventError(AirflowException):
+    """Raise when a deferred task resumes with a missing or malformed trigger 
event."""
diff --git a/providers/openai/src/airflow/providers/openai/hooks/openai.py 
b/providers/openai/src/airflow/providers/openai/hooks/openai.py
index 8315a907ffa..97dcc4ceec8 100644
--- a/providers/openai/src/airflow/providers/openai/hooks/openai.py
+++ b/providers/openai/src/airflow/providers/openai/hooks/openai.py
@@ -55,7 +55,11 @@ if TYPE_CHECKING:
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.module_loading import import_string
 from airflow.providers.common.compat.sdk import BaseHook
-from airflow.providers.openai.exceptions import OpenAIBatchJobException, 
OpenAIBatchTimeout
+from airflow.providers.openai.exceptions import (
+    OpenAIBatchJobException,
+    OpenAIBatchTimeout,
+    OpenAITriggerEventError,
+)
 
 #: The OpenAI Assistants API (``beta.assistants``/``beta.threads``) is 
deprecated by OpenAI. The hook
 #: methods wrapping it warn and point at the Responses and Conversations APIs 
(``create_response`` /
@@ -88,6 +92,26 @@ class BatchStatus(str, Enum):
         return status in (cls.VALIDATING, cls.IN_PROGRESS, cls.FINALIZING)
 
 
+#: Statuses the provider's trigger emits in its terminal event.
+TRIGGER_EVENT_STATUSES = frozenset({"success", "error", "cancelled"})
+
+
+def validate_execute_complete_event(event: dict[str, Any] | None = None) -> 
dict[str, Any]:
+    """
+    Validate the event a deferred task resumes with, returning it if 
well-formed.
+
+    The event crosses the triggerer/worker boundary through the metadata DB, 
so a
+    resuming task can receive ``None`` or a status its handler does not 
recognize
+    (version skew, a custom trigger). Both must fail loudly instead of crashing
+    opaquely or being misread as an outcome.
+    """
+    if event is None:
+        raise OpenAITriggerEventError("Trigger error: event is None")
+    if event.get("status") not in TRIGGER_EVENT_STATUSES:
+        raise OpenAITriggerEventError(f"Unexpected trigger event status 
{event.get('status')!r}: {event!r}")
+    return event
+
+
 class OpenAIHook(BaseHook):
     """
     Use OpenAI SDK to interact with OpenAI APIs.
diff --git a/providers/openai/src/airflow/providers/openai/operators/openai.py 
b/providers/openai/src/airflow/providers/openai/operators/openai.py
index 0f040a03b55..9d308bf3987 100644
--- a/providers/openai/src/airflow/providers/openai/operators/openai.py
+++ b/providers/openai/src/airflow/providers/openai/operators/openai.py
@@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, Literal
 
 from airflow.providers.common.compat.sdk import BaseOperator, conf
 from airflow.providers.openai.exceptions import OpenAIBatchJobException
-from airflow.providers.openai.hooks.openai import OpenAIHook
+from airflow.providers.openai.hooks.openai import OpenAIHook, 
validate_execute_complete_event
 from airflow.providers.openai.triggers.openai import OpenAIBatchTrigger
 
 if TYPE_CHECKING:
@@ -210,7 +210,8 @@ class OpenAITriggerBatchOperator(BaseOperator):
         Relies on trigger to throw an exception, otherwise it assumes 
execution was
         successful.
         """
-        if event["status"] == "error":
+        event = validate_execute_complete_event(event)
+        if event["status"] != "success":
             raise OpenAIBatchJobException(event["message"])
 
         self.log.info("%s completed successfully.", self.task_id)
diff --git a/providers/openai/src/airflow/providers/openai/triggers/openai.py 
b/providers/openai/src/airflow/providers/openai/triggers/openai.py
index 5800e948490..17c8361824d 100644
--- a/providers/openai/src/airflow/providers/openai/triggers/openai.py
+++ b/providers/openai/src/airflow/providers/openai/triggers/openai.py
@@ -101,13 +101,13 @@ class OpenAIBatchTrigger(BaseTrigger):
                         "batch_id": self.batch_id,
                     }
                 )
-
-            yield TriggerEvent(
-                {
-                    "status": "error",
-                    "message": f"Batch {self.batch_id} has failed.",
-                    "batch_id": self.batch_id,
-                }
-            )
+            else:
+                yield TriggerEvent(
+                    {
+                        "status": "error",
+                        "message": f"Batch {self.batch_id} has failed.",
+                        "batch_id": self.batch_id,
+                    }
+                )
         except Exception as e:
             yield TriggerEvent({"status": "error", "message": str(e), 
"batch_id": self.batch_id})
diff --git a/providers/openai/tests/unit/openai/hooks/test_openai.py 
b/providers/openai/tests/unit/openai/hooks/test_openai.py
index 5e5882e3548..d132a6a3d95 100644
--- a/providers/openai/tests/unit/openai/hooks/test_openai.py
+++ b/providers/openai/tests/unit/openai/hooks/test_openai.py
@@ -38,8 +38,12 @@ from openai.types.vector_stores import VectorStoreFile, 
VectorStoreFileBatch, Ve
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.models import Connection
-from airflow.providers.openai.exceptions import OpenAIBatchJobException, 
OpenAIBatchTimeout
-from airflow.providers.openai.hooks.openai import OpenAIHook
+from airflow.providers.openai.exceptions import (
+    OpenAIBatchJobException,
+    OpenAIBatchTimeout,
+    OpenAITriggerEventError,
+)
+from airflow.providers.openai.hooks.openai import OpenAIHook, 
validate_execute_complete_event
 
 ASSISTANT_ID = "test_assistant_abc123"
 ASSISTANT_NAME = "Test Assistant"
@@ -872,3 +876,34 @@ def 
test_get_conn_workload_identity_custom_missing_token_provider():
     conn = _make_workload_identity_conn("wi_custom_missing", 
{"workload_identity_provider": "custom"})
     with pytest.raises(ValueError, match="Missing required 'token_provider'"):
         OpenAIHook(conn_id=conn.conn_id).get_conn()
+
+
+class TestValidateTriggerEvent:
+    @pytest.mark.parametrize(
+        ("event", "match"),
+        [
+            pytest.param(None, "event is None", id="none"),
+            pytest.param({}, "Unexpected trigger event status None", 
id="missing-status"),
+            pytest.param(
+                {"status": "expired", "batch_id": BATCH_ID},
+                "Unexpected trigger event status",
+                id="unknown-status",
+            ),
+        ],
+    )
+    def test_invalid_event_raises(self, event, match):
+        with pytest.raises(OpenAITriggerEventError, match=match):
+            validate_execute_complete_event(event)
+
+    @pytest.mark.parametrize(
+        "event",
+        [
+            pytest.param({"status": "success", "batch_id": BATCH_ID}, 
id="success"),
+            pytest.param({"status": "error", "batch_id": BATCH_ID, "message": 
"boom"}, id="error"),
+            pytest.param(
+                {"status": "cancelled", "batch_id": BATCH_ID, "message": 
"cancelled"}, id="cancelled"
+            ),
+        ],
+    )
+    def test_valid_event_is_returned(self, event):
+        assert validate_execute_complete_event(event) is event
diff --git a/providers/openai/tests/unit/openai/operators/test_openai.py 
b/providers/openai/tests/unit/openai/operators/test_openai.py
index 954306d42a5..0ec78bb182c 100644
--- a/providers/openai/tests/unit/openai/operators/test_openai.py
+++ b/providers/openai/tests/unit/openai/operators/test_openai.py
@@ -23,6 +23,7 @@ from openai.types.batch import Batch
 from openai.types.responses import Response
 
 from airflow.providers.common.compat.sdk import Context, TaskDeferred
+from airflow.providers.openai.exceptions import OpenAIBatchJobException, 
OpenAITriggerEventError
 from airflow.providers.openai.hooks.openai import OpenAIHook
 from airflow.providers.openai.operators.openai import (
     OpenAIEmbeddingOperator,
@@ -147,3 +148,42 @@ def 
test_openai_trigger_batch_operator_with_deferred(mock_batch, wait_for_comple
     else:
         batch_id = operator.execute(context)
         assert batch_id == BATCH_ID
+
+
+class TestOpenAITriggerBatchOperatorExecuteComplete:
+    def _operator(self):
+        return OpenAITriggerBatchOperator(
+            task_id=TASK_ID,
+            conn_id=CONN_ID,
+            file_id=FILE_ID,
+            endpoint=BATCH_ENDPOINT,
+        )
+
+    def test_success_returns_batch_id(self):
+        event = {"status": "success", "message": "done", "batch_id": BATCH_ID}
+        assert self._operator().execute_complete(Context(), event) == BATCH_ID
+
+    @pytest.mark.parametrize(
+        "event",
+        [
+            pytest.param({"status": "error", "message": "boom", "batch_id": 
BATCH_ID}, id="error"),
+            pytest.param(
+                {"status": "cancelled", "message": "Batch has been 
cancelled.", "batch_id": BATCH_ID},
+                id="cancelled",
+            ),
+        ],
+    )
+    def test_failed_event_raises(self, event):
+        with pytest.raises(OpenAIBatchJobException, match=event["message"]):
+            self._operator().execute_complete(Context(), event)
+
+    @pytest.mark.parametrize(
+        "event",
+        [
+            pytest.param(None, id="none"),
+            pytest.param({"status": "expired", "batch_id": BATCH_ID}, 
id="unknown-status"),
+        ],
+    )
+    def test_invalid_event_raises_instead_of_succeeding(self, event):
+        with pytest.raises(OpenAITriggerEventError):
+            self._operator().execute_complete(Context(), event)
diff --git a/providers/openai/tests/unit/openai/triggers/test_openai.py 
b/providers/openai/tests/unit/openai/triggers/test_openai.py
index 1c1b661f1d8..b8b6c8bac05 100644
--- a/providers/openai/tests/unit/openai/triggers/test_openai.py
+++ b/providers/openai/tests/unit/openai/triggers/test_openai.py
@@ -146,6 +146,28 @@ class TestOpenAIBatchTrigger:
         assert TriggerEvent(expected_result) == task.result()
         asyncio.get_event_loop().stop()
 
+    @pytest.mark.asyncio
+    @mock.patch("airflow.providers.openai.hooks.openai.OpenAIHook.get_batch")
+    async def test_openai_batch_yields_single_terminal_event(self, mock_batch):
+        """A terminal batch must produce exactly one event, not a success 
followed by an error."""
+        mock_batch.return_value = 
self.mock_get_batch(str(BatchStatus.COMPLETED))
+        trigger = OpenAIBatchTrigger(
+            conn_id=self.CONN_ID,
+            batch_id=self.BATCH_ID,
+            poll_interval=self.POLL_INTERVAL,
+            end_time=self.END_TIME,
+        )
+        events = [event async for event in trigger.run()]
+        assert events == [
+            TriggerEvent(
+                {
+                    "status": "success",
+                    "message": f"Batch {self.BATCH_ID} has completed 
successfully.",
+                    "batch_id": self.BATCH_ID,
+                }
+            )
+        ]
+
     @pytest.mark.asyncio
     @mock.patch("airflow.providers.openai.hooks.openai.OpenAIHook.get_batch")
     async def test_openai_batch_for_unexpected_error(self, mock_batch):

Reply via email to