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 90bec8cb5af Fix flaky test_migration_stairway by retrying each
migration step (#66162)
90bec8cb5af is described below
commit 90bec8cb5af4a126d53008b49d1b14c21046c02e
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Sat May 2 01:49:54 2026 +0800
Fix flaky test_migration_stairway by retrying each migration step (#66162)
---
.../tests/unit/migrations/test_migration_utils.py | 80 +++++++++++++++++++---
1 file changed, 70 insertions(+), 10 deletions(-)
diff --git a/airflow-core/tests/unit/migrations/test_migration_utils.py
b/airflow-core/tests/unit/migrations/test_migration_utils.py
index bfc19e5ec2a..24a3ce580b4 100644
--- a/airflow-core/tests/unit/migrations/test_migration_utils.py
+++ b/airflow-core/tests/unit/migrations/test_migration_utils.py
@@ -32,11 +32,16 @@ immediately identifiable in CI logs.
from __future__ import annotations
+import time
+from collections.abc import Callable
+from functools import partial
+
import pytest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from alembic.script import ScriptDirectory
from sqlalchemy import text
+from sqlalchemy.exc import MissingGreenlet, OperationalError
from airflow import settings
from airflow.migrations.utils import (
@@ -68,6 +73,56 @@ pytestmark = pytest.mark.db_test
# 2.10.3, and keeps the number of steps manageable on slower backends.
_STAIRWAY_START_REVISION = _REVISION_HEADS_MAP["3.0.0"]
+# CI shows two transient failure modes for this test:
+# 1. PostgreSQL "too many clients already" when the metadata DB pool is
+# saturated by other parallel tests at the moment a step runs. This
+# surfaces as ``sqlalchemy.exc.OperationalError``.
+# 2. SQLAlchemy ``MissingGreenlet`` during async-engine disposal between
+# steps, which can leak into the next migration call.
+# Both are environmental; rebuilding the ORM (dispose + reconfigure with
+# NullPool) between attempts clears the leaked connections and restores
+# ``settings.Session`` so the next ``@provide_session``-wrapped call has a
+# working session.
+_STEP_RETRIES = 3
+_STEP_RETRY_BACKOFF_SECONDS = 2.0
+_TRANSIENT_STEP_ERRORS: tuple[type[BaseException], ...] = (OperationalError,
MissingGreenlet)
+
+
+def _run_step_with_retry(step: Callable[[], None], *, revision_id: str, label:
str) -> None:
+ """
+ Run a single migration step, retrying on known transient backend errors.
+
+ Only retries on :class:`sqlalchemy.exc.OperationalError` (e.g. PostgreSQL
+ ``too many clients already``) and :class:`sqlalchemy.exc.MissingGreenlet`
+ (async-engine disposal leaking into the next call) so genuine migration
+ regressions still surface immediately.
+
+ Rebuilds the ORM between attempts: a plain ``dispose_orm`` would leave
+ ``settings.Session`` set to ``None``, causing the next
+ ``@provide_session``-wrapped call to raise
+ ``RuntimeError("Session must be set before!")``. NullPool is reapplied so
+ the fixture's connection-leak guard is preserved across retries.
+ """
+ last_exc: BaseException | None = None
+ for attempt in range(1, _STEP_RETRIES + 1):
+ try:
+ step()
+ return
+ except _TRANSIENT_STEP_ERRORS as exc:
+ last_exc = exc
+ if attempt == _STEP_RETRIES:
+ break
+ # Free pooled connections and rebuild Session/engine before retry.
+ try:
+ settings.dispose_orm(do_log=False)
+ settings.configure_orm(disable_connection_pool=True)
+ except Exception:
+ pass
+ time.sleep(_STEP_RETRY_BACKOFF_SECONDS * attempt)
+ raise AssertionError(
+ f"Stairway test failed at revision {revision_id!r} during {label}
after {_STEP_RETRIES} attempts"
+ ) from last_exc
+
def _get_revisions_in_order() -> list[str]:
"""Return revision IDs in upgrade order, starting after the stairway
base."""
@@ -144,16 +199,21 @@ def test_migration_stairway(stairway_db) -> None:
for revision_id in revisions:
try:
- # Step 1: upgrade to this revision.
- upgradedb(to_revision=revision_id)
-
- # Step 2: downgrade exactly one step back.
- downgrade(to_revision="-1")
-
- # Step 3: re-apply so the next iteration starts from the right
state.
- upgradedb(to_revision=revision_id)
- except Exception as e:
- raise AssertionError(f"Stairway test failed at revision
{revision_id!r}") from e
+ _run_step_with_retry(
+ partial(upgradedb, to_revision=revision_id),
+ revision_id=revision_id,
+ label="upgrade",
+ )
+ _run_step_with_retry(
+ partial(downgrade, to_revision="-1"),
+ revision_id=revision_id,
+ label="downgrade",
+ )
+ _run_step_with_retry(
+ partial(upgradedb, to_revision=revision_id),
+ revision_id=revision_id,
+ label="re-upgrade",
+ )
finally:
# upgradedb()/downgrade() each enter ``_single_connection_pool``,
# which calls ``reconfigure_orm()`` and resets the pool to default.