From a57b0b9b580c2a8249d4bf44c9a8efc7688f12b4 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:53:59 -0700 Subject: [PATCH] feat(plugin): allow disabling the tenant plugin model providers cache (#39632) --- api/.env.example | 1 + api/configs/feature/__init__.py | 6 ++ api/controllers/inner_api/__init__.py | 2 - .../workspace/plugin_model_providers.py | 39 ----------- api/core/plugin/plugin_service.py | 15 +++-- .../workspace/test_plugin_model_providers.py | 64 ------------------- .../services/plugin/test_plugin_service.py | 20 ++++++ docker/envs/core-services/shared.env.example | 1 + 8 files changed, 39 insertions(+), 109 deletions(-) delete mode 100644 api/controllers/inner_api/workspace/plugin_model_providers.py delete mode 100644 api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py diff --git a/api/.env.example b/api/.env.example index d9fed2d9318..683042a70ac 100644 --- a/api/.env.example +++ b/api/.env.example @@ -666,6 +666,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003 PLUGIN_REMOTE_INSTALL_HOST=localhost PLUGIN_MAX_PACKAGE_SIZE=15728640 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400 # Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users. # Example: langgenius/openai,langgenius/gemini diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 70c629b8070..223d22082db 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -266,6 +266,12 @@ class PluginConfig(BaseSettings): default=60 * 60, ) + PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field( + description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed " + "by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.", + default=True, + ) + PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field( description="TTL in seconds for caching tenant plugin model providers in Redis", default=60 * 60 * 24, diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index 986ebd29738..f47861cf274 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_config as _agent_config from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin -from .workspace import plugin_model_providers as _plugin_model_providers from .workspace import workspace as _workspace api.add_namespace(inner_api_ns) @@ -36,7 +35,6 @@ __all__ = [ "_knowledge_retrieval", "_mail", "_plugin", - "_plugin_model_providers", "_runtime_credentials", "_workspace", "api", diff --git a/api/controllers/inner_api/workspace/plugin_model_providers.py b/api/controllers/inner_api/workspace/plugin_model_providers.py deleted file mode 100644 index 50008a5bd82..00000000000 --- a/api/controllers/inner_api/workspace/plugin_model_providers.py +++ /dev/null @@ -1,39 +0,0 @@ -from flask_restx import Resource -from pydantic import BaseModel, ConfigDict, Field - -from controllers.common.schema import register_schema_model -from controllers.console.wraps import setup_required -from controllers.inner_api import inner_api_ns -from controllers.inner_api.wraps import enterprise_inner_api_only -from core.plugin.plugin_service import PluginService - - -class InvalidatePluginModelProvidersCachePayload(BaseModel): - model_config = ConfigDict(extra="forbid") - - tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated") - - -register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload) - - -@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate") -class EnterprisePluginModelProvidersCacheInvalidate(Resource): - @setup_required - @enterprise_inner_api_only - @inner_api_ns.doc( - "enterprise_invalidate_plugin_model_providers_cache", - responses={ - 200: "Cache invalidated", - 400: "Invalid request", - 401: "Unauthorized - invalid API key", - }, - ) - @inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__]) - def post(self): - args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {}) - - for tenant_id in args.tenant_ids: - PluginService.invalidate_plugin_model_providers_cache(tenant_id) - - return {"result": "success"}, 200 diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 89274b635ac..e2cf702c5bd 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -434,14 +434,18 @@ class PluginService: exc_info=True, ) + @classmethod + def _fetch_plugin_model_providers_uncached( + cls, tenant_id: str, client: PluginModelClient | None + ) -> tuple[ProviderEntity, ...]: + model_client = client or PluginModelClient() + return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)) + @classmethod def _fetch_and_cache_plugin_model_providers( cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None ) -> tuple[ProviderEntity, ...]: - model_client = client or PluginModelClient() - providers = tuple( - cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id) - ) + providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client) generation = cls._load_plugin_model_providers_generation(tenant_id) if generation is not None and generation == refresh_generation: cls._store_cached_plugin_model_providers(tenant_id, generation, providers) @@ -471,6 +475,9 @@ class PluginService: are intentionally owned by this service so tenant isolation and cache expiry are handled in one place. """ + if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: + return cls._fetch_plugin_model_providers_uncached(tenant_id, client) + deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT while True: diff --git a/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py b/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py deleted file mode 100644 index 25902117ce5..00000000000 --- a/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py +++ /dev/null @@ -1,64 +0,0 @@ -import inspect -from unittest.mock import call, patch - -import pytest -from flask import Flask -from pydantic import ValidationError - -from controllers.inner_api.workspace.plugin_model_providers import ( - EnterprisePluginModelProvidersCacheInvalidate, - InvalidatePluginModelProvidersCachePayload, -) - - -class TestInvalidatePluginModelProvidersCachePayload: - def test_valid_payload(self): - payload = InvalidatePluginModelProvidersCachePayload.model_validate( - {"tenant_ids": ["tenant-alpha", "tenant-beta"]} - ) - assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"] - - def test_missing_tenant_ids_defaults_to_empty(self): - payload = InvalidatePluginModelProvidersCachePayload.model_validate({}) - assert payload.tenant_ids == [] - - def test_unknown_field_rejected(self): - with pytest.raises(ValidationError): - InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7}) - - -class TestEnterprisePluginModelProvidersCacheInvalidate: - @pytest.fixture - def api_instance(self): - return EnterprisePluginModelProvidersCacheInvalidate() - - def _post(self, api_instance, app: Flask, payload): - unwrapped_post = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns: - mock_ns.payload = payload - return unwrapped_post(api_instance) - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]}) - - assert result == ({"result": "success"}, 200) - assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [ - call("tenant-alpha"), - call("tenant-beta"), - ] - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, {"tenant_ids": []}) - - assert result == ({"result": "success"}, 200) - mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, None) - - assert result == ({"result": "success"}, 200) - mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index 278898926b9..b33fe27e075 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -246,6 +246,26 @@ class TestPluginModelProviderCache: call([cache_key]), ] + def test_fetch_plugin_model_providers_bypasses_redis_when_cache_disabled(self) -> None: + """With the cache disabled the daemon is the only source, and Redis is never touched.""" + with patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as config: + config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False + client = Mock() + client.fetch_model_providers.return_value = [_build_plugin_model_provider()] + + from core.plugin.plugin_service import PluginService + + first = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + second = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + + assert [provider.provider for provider in first] == ["langgenius/openai/openai"] + assert [provider.provider for provider in second] == ["langgenius/openai/openai"] + assert client.fetch_model_providers.call_count == 2 + redis_client.get.assert_not_called() + redis_client.mget.assert_not_called() + redis_client.setex.assert_not_called() + redis_client.lock.assert_not_called() + def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None: """Redis read failures do not block provider discovery for the tenant.""" with patch(f"{MODULE}.redis_client") as redis_client: diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 5fe6ab974e1..dbbe76c6a47 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -73,6 +73,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 PGDATA=/var/lib/postgresql/data/pgdata PLUGIN_MAX_PACKAGE_SIZE=52428800 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400 # Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users. # Example: langgenius/openai,langgenius/gemini