This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 ac82cecf144 Fix 500 error for event logs with NULL dttm (#68338)
ac82cecf144 is described below
commit ac82cecf1445e6e90c15490fdc64eebc77771e0d
Author: Vasu Madaan <[email protected]>
AuthorDate: Wed Jun 10 20:17:30 2026 +0530
Fix 500 error for event logs with NULL dttm (#68338)
* Fix 500 error when event log has null dttm in API responses
Logs with dttm=NULL cause a Pydantic validation error (500) because
EventLogResponse.dttm is non-optional. Filter them out at the query
level: GET /eventLogs/{id} returns 404, GET /eventLogs excludes them
from results and total_entries.
closes: #68333
* Fix PT028 ruff lint: add noqa for provide_session sentinel pattern
* Apply ruff-format to test_event_logs.py
* Add comments explaining dttm NULL filter and why response model stays
non-optional
---
.../core_api/routes/public/event_logs.py | 22 ++++++++++++--
.../core_api/routes/public/test_event_logs.py | 35 ++++++++++++++++++++++
2 files changed, 55 insertions(+), 2 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
index 5088fb6f6b9..5a8b4ab9092 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
@@ -64,7 +64,15 @@ def get_event_log(
session: SessionDep,
) -> EventLogResponse:
event_log = session.scalar(
- select(Log).where(Log.id ==
event_log_id).options(joinedload(Log.task_instance))
+ # Log.dttm is nullable at the DB level, but EventLogResponse.when is a
non-optional
+ # datetime. Rows with dttm=NULL would cause a Pydantic validation
error (500), so
+ # exclude them here. Such rows can exist in legacy installs or via
direct DB inserts
+ # that bypass Log.__init__ (which always sets dttm =
timezone.utcnow()).
+ # Making EventLogResponse.when nullable would be a breaking API
contract change for
+ # clients that currently rely on `when` always being present.
+ select(Log)
+ .where(Log.id == event_log_id, Log.dttm.is_not(None))
+ .options(joinedload(Log.task_instance))
)
if event_log is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"The Event Log with
id: `{event_log_id}` not found")
@@ -155,7 +163,17 @@ def get_event_logs(
readable_event_logs_filter: ReadableEventLogsFilterDep,
) -> EventLogCollectionResponse:
"""Get all Event Logs."""
- query = select(Log).options(joinedload(Log.task_instance),
joinedload(Log.dag_model))
+ query = (
+ # Log.dttm is nullable at the DB level, but EventLogResponse.when is a
non-optional
+ # datetime. Rows with dttm=NULL would cause a Pydantic validation
error (500), so
+ # exclude them here. Such rows can exist in legacy installs or via
direct DB inserts
+ # that bypass Log.__init__ (which always sets dttm =
timezone.utcnow()).
+ # Making EventLogResponse.when nullable would be a breaking API
contract change for
+ # clients that currently rely on `when` always being present.
+ select(Log)
+ .where(Log.dttm.is_not(None))
+ .options(joinedload(Log.task_instance), joinedload(Log.dag_model))
+ )
event_logs_select, total_entries = paginated_select(
statement=query,
order_by=order_by,
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
index 00d6918673d..eda0926ca71 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
@@ -50,6 +50,7 @@ EVENT_NORMAL = "NORMAL_EVENT"
EVENT_WITH_OWNER = "EVENT_WITH_OWNER"
EVENT_WITH_TASK_INSTANCE = "EVENT_WITH_TASK_INSTANCE"
EVENT_WITH_OWNER_AND_TASK_INSTANCE = "EVENT_WITH_OWNER_AND_TASK_INSTANCE"
+EVENT_WITHOUT_DTTM = "EVENT_WITHOUT_DTTM"
EVENT_NON_EXISTED_ID = 9999
@@ -233,6 +234,19 @@ class TestGetEventLog(TestEventLogsEndpoint):
user=mock.ANY,
)
+ @provide_session
+ def test_should_return_404_for_log_without_dttm(self, test_client, *,
session: Session = NEW_SESSION): # noqa: PT028
+ event_log = Log(event=EVENT_WITHOUT_DTTM)
+ session.add(event_log)
+ session.flush()
+ event_log_id = event_log.id
+ event_log.dttm = None
+ session.commit()
+
+ response = test_client.get(f"/eventLogs/{event_log_id}")
+
+ assert response.status_code == 404
+
class TestGetEventLogs(TestEventLogsEndpoint):
@pytest.mark.parametrize(
@@ -350,6 +364,27 @@ class TestGetEventLogs(TestEventLogsEndpoint):
for event_log, expected_event in zip(resp_json["event_logs"],
expected_events):
assert event_log["event"] == expected_event
+ @provide_session
+ def test_get_event_logs_excludes_logs_without_dttm(
+ self,
+ test_client,
+ *,
+ session: Session = NEW_SESSION, # noqa: PT028
+ ):
+ event_log = Log(event=EVENT_WITHOUT_DTTM)
+ session.add(event_log)
+ session.flush()
+ event_log.dttm = None
+ session.commit()
+
+ with assert_queries_count(3):
+ response = test_client.get("/eventLogs", params={"order_by":
"-when"})
+
+ assert response.status_code == 200
+ resp_json = response.json()
+ assert resp_json["total_entries"] == 4
+ assert EVENT_WITHOUT_DTTM not in {event_log["event"] for event_log in
resp_json["event_logs"]}
+
# Ordering of nulls values is DB specific.
@pytest.mark.backend("sqlite")
@pytest.mark.parametrize(