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

jason810496 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 76943ec8e44 Fix Celery result backend compatibility with PgBouncer 
(#74841)
76943ec8e44 is described below

commit 76943ec8e44f69c7d7af26b0848f8d12f2b7c971
Author: Aaron Chen <[email protected]>
AuthorDate: Sat Oct 17 15:48:11 2026 -0700

    Fix Celery result backend compatibility with PgBouncer (#74841)
    
    * Fix Celery result backend compatibility with PgBouncer
    
    * Refactor default Celery executor to improve type casting for result 
backend options
---
 .../tests/unit/always/test_project_structure.py    |   1 -
 providers/celery/docs/celery_executor.rst          |  17 ++
 providers/celery/provider.yaml                     |   2 +
 .../providers/celery/executors/default_celery.py   |  30 +++-
 .../airflow/providers/celery/get_provider_info.py  |   2 +-
 .../unit/celery/executors/test_celery_executor.py  |   7 +-
 .../unit/celery/executors/test_default_celery.py   | 175 +++++++++++++++++++++
 7 files changed, 228 insertions(+), 6 deletions(-)

diff --git a/airflow-core/tests/unit/always/test_project_structure.py 
b/airflow-core/tests/unit/always/test_project_structure.py
index bf80379664c..514e3b16cde 100644
--- a/airflow-core/tests/unit/always/test_project_structure.py
+++ b/airflow-core/tests/unit/always/test_project_structure.py
@@ -72,7 +72,6 @@ class TestProjectStructure:
             "providers/amazon/tests/unit/amazon/aws/sensors/test_emr.py",
             "providers/amazon/tests/unit/amazon/aws/sensors/test_sagemaker.py",
             
"providers/celery/tests/unit/celery/executors/test_celery_executor_utils.py",
-            
"providers/celery/tests/unit/celery/executors/test_default_celery.py",
             
"providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor_utils.py",
             
"providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_exceptions.py",
             
"providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_config.py",
diff --git a/providers/celery/docs/celery_executor.rst 
b/providers/celery/docs/celery_executor.rst
index 31a1619ffb0..d175908549d 100644
--- a/providers/celery/docs/celery_executor.rst
+++ b/providers/celery/docs/celery_executor.rst
@@ -88,6 +88,23 @@ Some caveats:
 - Tasks can consume resources. Make sure your worker has enough resources to 
run ``worker_concurrency`` tasks
 - Queue names are limited to 256 characters, but each broker backend might 
have its own restrictions
 
+PostgreSQL result backends
+--------------------------
+
+For result backends using psycopg (v3), Airflow defaults 
``connect_args.prepare_threshold`` to
+``None`` to disable automatic prepared statements. This supports a 
transaction-mode connection
+pooler that does not track prepared statements. The setting also works when 
connecting directly
+to PostgreSQL. Other database drivers, including psycopg2, do not receive this 
parameter.
+
+An explicit value in ``[celery] result_backend_sqlalchemy_engine_options`` 
takes precedence.
+For example, when connecting directly to PostgreSQL or using a pooler 
configured to support
+prepared statements, you can restore psycopg's default preparation threshold:
+
+.. code-block:: ini
+
+    [celery]
+    result_backend_sqlalchemy_engine_options = {"connect_args": 
{"prepare_threshold": 5}}
+
 Redis maintenance
 -----------------
 
diff --git a/providers/celery/provider.yaml b/providers/celery/provider.yaml
index 80277f56fc1..4d93fa78870 100644
--- a/providers/celery/provider.yaml
+++ b/providers/celery/provider.yaml
@@ -275,6 +275,8 @@ config:
       result_backend_sqlalchemy_engine_options:
         description: |
           Optional configuration dictionary to pass to the Celery result 
backend SQLAlchemy engine.
+          For psycopg (v3), ``connect_args.prepare_threshold`` defaults to 
``None`` to disable
+          automatic prepared statements unless explicitly overridden.
         version_added: ~
         type: string
         example: '{"pool_recycle": 1800}'
diff --git 
a/providers/celery/src/airflow/providers/celery/executors/default_celery.py 
b/providers/celery/src/airflow/providers/celery/executors/default_celery.py
index 8cc8a0ca445..e57719f070e 100644
--- a/providers/celery/src/airflow/providers/celery/executors/default_celery.py
+++ b/providers/celery/src/airflow/providers/celery/executors/default_celery.py
@@ -23,7 +23,10 @@ import json
 import logging
 import re
 import ssl
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
+
+from sqlalchemy.engine import make_url
+from sqlalchemy.exc import ArgumentError, NoSuchModuleError
 
 from airflow.exceptions import AirflowConfigException as 
CoreAirflowConfigException
 from airflow.providers.celery.version_compat import AIRFLOW_V_3_0_PLUS
@@ -112,6 +115,23 @@ def _broker_transport_options(broker_url: str, conf: 
AirflowSDKConfigParser | An
     return broker_transport_options
 
 
+def _get_database_engine_options(result_backend: str, options: dict[str, Any]) 
-> dict[str, Any]:
+    if not result_backend.startswith("db+"):
+        return options
+    try:
+        driver = make_url(result_backend.removeprefix("db+")).get_driver_name()
+    except (ArgumentError, NoSuchModuleError):
+        # Defer invalid or unavailable dialects to Celery's backend 
initialization.
+        return options
+    if driver != "psycopg":
+        return options
+    # Celery creates its own engines, so metadata-engine defaults do not 
protect transaction poolers.
+    return {
+        **options,
+        "connect_args": {"prepare_threshold": None, 
**options.get("connect_args", {})},
+    }
+
+
 def get_default_celery_config(team_conf: AirflowSDKConfigParser | Any) -> 
dict[str, Any]:
     """
     Build Celery configuration using team-aware config.
@@ -177,8 +197,12 @@ def get_default_celery_config(team_conf: 
AirflowSDKConfigParser | Any) -> dict[s
         ),
         "result_backend": result_backend,
         "result_backend_transport_options": result_backend_transport_options,
-        "database_engine_options": team_conf.getjson(
-            "celery", "result_backend_sqlalchemy_engine_options", fallback={}
+        "database_engine_options": _get_database_engine_options(
+            result_backend,
+            cast(
+                "dict[str, Any]",
+                team_conf.getjson("celery", 
"result_backend_sqlalchemy_engine_options", fallback={}),
+            ),
         ),
         "worker_concurrency": team_conf.getint("celery", "WORKER_CONCURRENCY", 
fallback=16),
         "worker_enable_remote_control": team_conf.getboolean(
diff --git a/providers/celery/src/airflow/providers/celery/get_provider_info.py 
b/providers/celery/src/airflow/providers/celery/get_provider_info.py
index ec9be555b00..f5ecc04ddb9 100644
--- a/providers/celery/src/airflow/providers/celery/get_provider_info.py
+++ b/providers/celery/src/airflow/providers/celery/get_provider_info.py
@@ -141,7 +141,7 @@ def get_provider_info():
                         "default": None,
                     },
                     "result_backend_sqlalchemy_engine_options": {
-                        "description": "Optional configuration dictionary to 
pass to the Celery result backend SQLAlchemy engine.\n",
+                        "description": "Optional configuration dictionary to 
pass to the Celery result backend SQLAlchemy engine.\nFor psycopg (v3), 
``connect_args.prepare_threshold`` defaults to ``None`` to disable\nautomatic 
prepared statements unless explicitly overridden.\n",
                         "version_added": None,
                         "type": "string",
                         "example": '{"pool_recycle": 1800}',
diff --git 
a/providers/celery/tests/unit/celery/executors/test_celery_executor.py 
b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
index 39d7bf0175b..2247944d4b6 100644
--- a/providers/celery/tests/unit/celery/executors/test_celery_executor.py
+++ b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
@@ -492,7 +492,12 @@ class TestCeleryExecutor:
         assert not executor.has_task(ti)
         mock_fail.assert_not_called()
 
-    @conf_vars({("celery", "result_backend_sqlalchemy_engine_options"): 
'{"pool_recycle": 1800}'})
+    @conf_vars(
+        {
+            ("celery", "result_backend"): "db+sqlite:///:memory:",
+            ("celery", "result_backend_sqlalchemy_engine_options"): 
'{"pool_recycle": 1800}',
+        }
+    )
     def test_result_backend_sqlalchemy_engine_options(self):
         import importlib
 
diff --git 
a/providers/celery/tests/unit/celery/executors/test_default_celery.py 
b/providers/celery/tests/unit/celery/executors/test_default_celery.py
new file mode 100644
index 00000000000..77aab202f1c
--- /dev/null
+++ b/providers/celery/tests/unit/celery/executors/test_default_celery.py
@@ -0,0 +1,175 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+
+import pytest
+from celery import current_app
+from celery.contrib.testing.app import setup_default_app
+from sqlalchemy import event
+from sqlalchemy.engine import make_url
+
+from airflow.providers.celery.executors import default_celery
+from airflow.providers.celery.executors.celery_executor_utils import 
create_celery_app
+from airflow.providers.common.compat.sdk import conf
+
+from tests_common.test_utils.config import conf_vars
+
+pytestmark = pytest.mark.skipif(
+    not default_celery._USE_PSYCOPG3,
+    reason="Psycopg3 result backends require psycopg and SQLAlchemy 2.0+",
+)
+
+
+class ConnectionAttemptIntercepted(Exception):
+    pass
+
+
[email protected]
+def restore_current_celery_app():
+    with setup_default_app(current_app._get_current_object()):
+        yield
+
+
+def get_backend_connect_kwargs(backend):
+    engine = backend.session_manager.get_engine(backend.url, 
**backend.engine_options)
+    connect_kwargs = {}
+
+    @event.listens_for(engine, "do_connect")
+    def capture_connect_kwargs(dialect, connection_record, args, kwargs):
+        connect_kwargs.update(kwargs)
+        raise ConnectionAttemptIntercepted
+
+    try:
+        with pytest.raises(ConnectionAttemptIntercepted):
+            engine.connect()
+        return engine.dialect.driver, connect_kwargs
+    finally:
+        engine.dispose()
+
+
[email protected]("restore_current_celery_app")
[email protected](
+    "result_backend",
+    [
+        pytest.param(None, id="derived-from-metadata"),
+        pytest.param("db+postgresql+psycopg://user:pass@host/db", 
id="explicit-psycopg"),
+        pytest.param("db+postgresql://user:pass@host/db", 
id="sqlalchemy-default-driver"),
+        pytest.param("db+postgresql+psycopg2://user:pass@host/db", 
id="explicit-psycopg2"),
+    ],
+)
[email protected](
+    "engine_options",
+    [
+        pytest.param({}, id="default-options"),
+        pytest.param({"pool_recycle": 1800, "connect_args": 
{"connect_timeout": 7}}, id="custom-options"),
+    ],
+)
+def test_result_backend_disables_psycopg_preparation(result_backend, 
engine_options):
+    expected_url = result_backend or 
"db+postgresql+psycopg://user:pass@host/db"
+    expected_driver = 
make_url(expected_url.removeprefix("db+")).get_driver_name()
+    expected_connect_args = {
+        **({"prepare_threshold": None} if expected_driver == "psycopg" else 
{}),
+        **engine_options.get("connect_args", {}),
+    }
+    expected_options = {**engine_options}
+    if expected_connect_args:
+        expected_options["connect_args"] = expected_connect_args
+
+    with conf_vars(
+        {
+            # --skip-db-tests sets the deprecated [core] key, which takes 
precedence over [database].
+            ("core", "sql_alchemy_conn"): None,
+            ("database", "sql_alchemy_conn"): "postgresql://user:pass@host/db",
+            ("celery", "result_backend"): result_backend,
+            ("celery", "result_backend_sqlalchemy_engine_options"): 
json.dumps(engine_options),
+            ("celery", "extra_celery_config"): 
'{"database_create_tables_at_setup": false}',
+        }
+    ):
+        with create_celery_app(conf) as app:
+            backend = app.backend
+            driver, connect_kwargs = get_backend_connect_kwargs(backend)
+            assert driver == expected_driver
+            assert backend.engine_options == expected_options
+            assert {key: connect_kwargs[key] for key in expected_connect_args} 
== expected_connect_args
+            if expected_driver != "psycopg":
+                assert "prepare_threshold" not in connect_kwargs
+
+
[email protected]("restore_current_celery_app")
[email protected]("prepare_threshold", [None, 0, 5])
+def 
test_result_backend_preserves_explicit_prepare_threshold(prepare_threshold):
+    options = {
+        "pool_recycle": 1800,
+        "connect_args": {"prepare_threshold": prepare_threshold, 
"connect_timeout": 7},
+    }
+    with conf_vars(
+        {
+            ("celery", "result_backend"): 
"db+postgresql+psycopg://user:pass@host/db",
+            ("celery", "result_backend_sqlalchemy_engine_options"): 
json.dumps(options),
+            ("celery", "extra_celery_config"): 
'{"database_create_tables_at_setup": false}',
+        }
+    ):
+        with create_celery_app(conf) as app:
+            backend = app.backend
+            driver, connect_kwargs = get_backend_connect_kwargs(backend)
+            assert driver == "psycopg"
+            assert backend.engine_options == options
+            assert connect_kwargs["prepare_threshold"] == prepare_threshold
+            assert connect_kwargs["connect_timeout"] == 7
+
+
[email protected]("restore_current_celery_app")
+@conf_vars(
+    {
+        ("celery", "result_backend"): "db+sqlite:///:memory:",
+        ("celery", "result_backend_sqlalchemy_engine_options"): "{}",
+        ("celery", "extra_celery_config"): 
'{"database_create_tables_at_setup": false}',
+    }
+)
+def test_result_backend_does_not_add_psycopg_options_to_sqlite():
+    with create_celery_app(conf) as app:
+        backend = app.backend
+        driver, connect_kwargs = get_backend_connect_kwargs(backend)
+        assert driver == "pysqlite"
+        assert backend.engine_options == {}
+        assert "prepare_threshold" not in connect_kwargs
+
+
+@conf_vars(
+    {
+        ("celery", "result_backend"): "redis://redis:6379/0",
+        ("celery", "result_backend_sqlalchemy_engine_options"): 
'{"pool_recycle": 1800}',
+    }
+)
+def test_non_database_result_backend_keeps_engine_options():
+    config = default_celery.get_default_celery_config(conf)
+    assert config["result_backend"] == "redis://redis:6379/0"
+    assert config["database_engine_options"] == {"pool_recycle": 1800}
+
+
+@conf_vars(
+    {
+        ("celery", "result_backend"): "db+bad_schema:///",
+        ("celery", "result_backend_sqlalchemy_engine_options"): 
'{"pool_recycle": 1800}',
+    }
+)
+def test_unknown_database_dialect_keeps_engine_options():
+    config = default_celery.get_default_celery_config(conf)
+    assert config["result_backend"] == "db+bad_schema:///"
+    assert config["database_engine_options"] == {"pool_recycle": 1800}

Reply via email to