Skip to content

Commit f95146b

Browse files
committed
perf(api): memoize ProviderManager.get_configurations within request/task scope (#27299)
Incremental step for #27299 — assembling `ProviderConfigurations` runs six independent DB queries plus a provider-factory load, costing ~1s per call in production. A single workflow step (e.g. a Retrieval node inside an Iteration) invokes this path many times through independently-created `ProviderManager` instances, so per-instance caching is not enough. - `core/provider_manager.py`: add a module-level `RecyclableContextVar[dict[str, ProviderConfigurations]]` and memoize the result in `get_configurations` for the current request/task scope, keyed by `tenant_id`. The cache is shared across `ProviderManager` instances because callers routinely spawn fresh ones via `create_plugin_provider_manager`. - `extensions/ext_celery.py`: call `RecyclableContextVar.increment_thread_recycles()` inside `FlaskTask.__call__`, matching the Flask `before_request` hook in `app_factory.py` so per-task caches reset cleanly between Celery tasks on recycled worker threads. - `tests/unit_tests/core/test_provider_manager.py`: cover the memoization (hit within a scope, per-tenant isolation, sharing across `ProviderManager` instances, reset on new scope) behind an autouse fixture that bumps the recycle counter for each test. No cross-request invalidation is introduced — the cache lives only for the current request/task, so mutations performed through subsequent requests are always observed. This deliberately scopes the change: it targets the reported hot path (iterative retrieval/LLM nodes) with no staleness risk. ## Verification - uv run --group dev basedpyright -> 0 errors, 0 warnings (project-wide). - pytest tests/unit_tests/core/test_provider_manager.py and related -> 40 passed. - ruff check / ruff format --check -> clean.
1 parent 7862b61 commit f95146b

3 files changed

Lines changed: 112 additions & 0 deletions

File tree

api/core/provider_manager.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import contextlib
44
from collections import defaultdict
55
from collections.abc import Sequence
6+
from contextvars import ContextVar
67
from json import JSONDecodeError
78
from typing import TYPE_CHECKING, Any
89

@@ -20,6 +21,7 @@
2021
from sqlalchemy.orm import Session
2122

2223
from configs import dify_config
24+
from contexts.wrapper import RecyclableContextVar
2325
from core.entities.model_entities import DefaultModelEntity, DefaultModelProviderEntity
2426
from core.entities.provider_configuration import ProviderConfiguration, ProviderConfigurations, ProviderModelBundle
2527
from core.entities.provider_entities import (
@@ -60,6 +62,28 @@
6062

6163
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
6264

65+
# Request/task-scoped memoization of `get_configurations`.
66+
#
67+
# Assembling a `ProviderConfigurations` runs six separate DB queries plus a
68+
# provider-factory load, costing ~1s per call in production. A single workflow
69+
# step (e.g. a Retrieval node inside an Iteration) can invoke this path dozens
70+
# of times through independently-created `ProviderManager` instances, so
71+
# per-instance caching is not enough — the cache must live on the execution
72+
# context instead.
73+
#
74+
# `RecyclableContextVar` is Dify's existing pattern for per-request/per-task
75+
# state. It is reset by `RecyclableContextVar.increment_thread_recycles()`,
76+
# which is already called in Flask `before_request` and is called per Celery
77+
# task in `FlaskTask.__call__` (see `extensions/ext_celery.py`).
78+
#
79+
# Skipping `_init_trial_provider_records` on a cache hit is safe because it is
80+
# idempotent — it checks for existing records before inserting. Callers that
81+
# mutate a `ProviderConfiguration` in-place within the same scope will observe
82+
# their own writes on subsequent reads, which matches the pre-cache behavior.
83+
_provider_configurations_cache: RecyclableContextVar[dict[str, ProviderConfigurations]] = RecyclableContextVar(
84+
ContextVar("provider_configurations_cache")
85+
)
86+
6387

6488
class ProviderManager:
6589
"""
@@ -114,6 +138,16 @@ def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
114138
:param tenant_id:
115139
:return:
116140
"""
141+
try:
142+
scope_cache = _provider_configurations_cache.get()
143+
except LookupError:
144+
scope_cache = {}
145+
_provider_configurations_cache.set(scope_cache)
146+
147+
cached = scope_cache.get(tenant_id)
148+
if cached is not None:
149+
return cached
150+
117151
# Get all provider records of the workspace
118152
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
119153

@@ -273,6 +307,8 @@ def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
273307

274308
provider_configurations[str(provider_id_entity)] = provider_configuration
275309

310+
scope_cache[tenant_id] = provider_configurations
311+
276312
# Return the encapsulated object
277313
return provider_configurations
278314

api/extensions/ext_celery.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,15 @@ def get_celery_broker_transport_options() -> CelerySentinelTransportDict | dict[
7575
def init_app(app: DifyApp) -> Celery:
7676
class FlaskTask(Task):
7777
def __call__(self, *args: object, **kwargs: object) -> object:
78+
from contexts.wrapper import RecyclableContextVar
7879
from core.logging.context import init_request_context
7980

8081
with app.app_context():
8182
# Initialize logging context for this task (similar to before_request in Flask)
8283
init_request_context()
84+
# Reset RecyclableContextVar-backed per-task caches, matching the
85+
# Flask before_request hook in app_factory.py.
86+
RecyclableContextVar.increment_thread_recycles()
8387
return self.run(*args, **kwargs)
8488

8589
broker_transport_options = get_celery_broker_transport_options()

api/tests/unit_tests/core/test_provider_manager.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@
66
from graphon.model_runtime.entities.model_entities import ModelType
77
from pytest_mock import MockerFixture
88

9+
from contexts.wrapper import RecyclableContextVar
910
from core.entities.provider_entities import ModelSettings
1011
from core.provider_manager import ProviderManager
1112
from models.provider import LoadBalancingModelConfig, ProviderModelSetting, TenantDefaultModel
1213
from models.provider_ids import ModelProviderID
1314

1415

16+
@pytest.fixture(autouse=True)
17+
def _reset_provider_configurations_cache() -> None:
18+
# Advance the recycle counter so the per-scope memoization inside
19+
# ProviderManager.get_configurations starts fresh for every test.
20+
RecyclableContextVar.increment_thread_recycles()
21+
22+
1523
def _build_provider_manager(mocker: MockerFixture) -> ProviderManager:
1624
return ProviderManager(model_runtime=mocker.Mock())
1725

@@ -563,6 +571,70 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_
563571
mock_session_cls.assert_not_called()
564572

565573

574+
def _stub_get_configurations_helpers(manager: ProviderManager, mocker: MockerFixture) -> Mock:
575+
"""Replace every DB-touching helper on ``manager`` with no-op stubs and return the
576+
``_get_all_providers`` mock so tests can assert how many times the assembly ran."""
577+
all_providers = mocker.patch.object(manager, "_get_all_providers", return_value={})
578+
mocker.patch.object(manager, "_init_trial_provider_records", return_value={})
579+
mocker.patch.object(manager, "_get_all_provider_models", return_value={})
580+
mocker.patch.object(manager, "_get_all_preferred_model_providers", return_value={})
581+
mocker.patch.object(manager, "_get_all_provider_model_settings", return_value={})
582+
mocker.patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={})
583+
mocker.patch.object(manager, "_get_all_provider_model_credentials", return_value={})
584+
mocker.patch("core.provider_manager.ModelProviderFactory")
585+
return all_providers
586+
587+
588+
def test_get_configurations_memoizes_result_within_scope(mocker: MockerFixture) -> None:
589+
manager = _build_provider_manager(mocker)
590+
all_providers = _stub_get_configurations_helpers(manager, mocker)
591+
592+
first = manager.get_configurations("tenant-id")
593+
second = manager.get_configurations("tenant-id")
594+
595+
assert first is second
596+
all_providers.assert_called_once_with("tenant-id")
597+
598+
599+
def test_get_configurations_memoization_is_per_tenant(mocker: MockerFixture) -> None:
600+
manager = _build_provider_manager(mocker)
601+
all_providers = _stub_get_configurations_helpers(manager, mocker)
602+
603+
first = manager.get_configurations("tenant-a")
604+
second = manager.get_configurations("tenant-b")
605+
first_again = manager.get_configurations("tenant-a")
606+
607+
assert first is not second
608+
assert first is first_again
609+
assert all_providers.call_count == 2
610+
611+
612+
def test_get_configurations_memoization_shared_across_manager_instances(mocker: MockerFixture) -> None:
613+
manager_one = _build_provider_manager(mocker)
614+
manager_two = _build_provider_manager(mocker)
615+
one_calls = _stub_get_configurations_helpers(manager_one, mocker)
616+
two_calls = _stub_get_configurations_helpers(manager_two, mocker)
617+
618+
first = manager_one.get_configurations("tenant-id")
619+
second = manager_two.get_configurations("tenant-id")
620+
621+
assert first is second
622+
one_calls.assert_called_once_with("tenant-id")
623+
two_calls.assert_not_called()
624+
625+
626+
def test_get_configurations_memoization_reset_on_new_scope(mocker: MockerFixture) -> None:
627+
manager = _build_provider_manager(mocker)
628+
all_providers = _stub_get_configurations_helpers(manager, mocker)
629+
630+
first = manager.get_configurations("tenant-id")
631+
RecyclableContextVar.increment_thread_recycles()
632+
second = manager.get_configurations("tenant-id")
633+
634+
assert first is not second
635+
assert all_providers.call_count == 2
636+
637+
566638
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
567639
session = Mock()
568640
openai_config = SimpleNamespace(provider_name="openai")

0 commit comments

Comments
 (0)