diff --git a/server/api/api/endpoints/client_spec.py b/server/api/api/endpoints/client_spec.py index 18ec04763b2..4ae7fcc46b4 100644 --- a/server/api/api/endpoints/client_spec.py +++ b/server/api/api/endpoints/client_spec.py @@ -19,11 +19,12 @@ import mlrun.common.schemas import server.api.api.utils import server.api.crud +import server.api.utils.helpers router = APIRouter() -@server.api.api.utils.lru_cache_with_ttl(maxsize=32, ttl_seconds=60 * 5) +@server.api.utils.helpers.lru_cache_with_ttl(maxsize=32, ttl_seconds=60 * 5) def get_cached_client_spec( client_version: typing.Optional[str] = Header( None, alias=mlrun.common.schemas.HeaderNames.client_version diff --git a/server/api/api/utils.py b/server/api/api/utils.py index 4acec73d5a0..b7be33a9a89 100644 --- a/server/api/api/utils.py +++ b/server/api/api/utils.py @@ -15,10 +15,8 @@ import asyncio import collections import copy -import functools import json import re -import time import traceback import typing import uuid @@ -67,40 +65,6 @@ def log_and_raise(status=HTTPStatus.BAD_REQUEST.value, **kw): raise HTTPException(status_code=status, detail=kw) -def lru_cache_with_ttl(maxsize=128, typed=False, ttl_seconds=60): - """ - Thread-safety least-recently used cache with time-to-live (ttl_seconds) limit. - https://stackoverflow.com/a/71634221/5257501 - """ - - class Result: - __slots__ = ("value", "death") - - def __init__(self, value, death): - self.value = value - self.death = death - - def decorator(func): - @functools.lru_cache(maxsize=maxsize, typed=typed) - def cached_func(*args, **kwargs): - value = func(*args, **kwargs) - death = time.monotonic() + ttl_seconds - return Result(value, death) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - result = cached_func(*args, **kwargs) - if result.death < time.monotonic(): - result.value = func(*args, **kwargs) - result.death = time.monotonic() + ttl_seconds - return result.value - - wrapper.cache_clear = cached_func.cache_clear - return wrapper - - return decorator - - def log_path(project, uid) -> Path: return project_logs_path(project) / uid diff --git a/server/api/utils/clients/iguazio.py b/server/api/utils/clients/iguazio.py index 954167cb258..4a37c1bde88 100644 --- a/server/api/utils/clients/iguazio.py +++ b/server/api/utils/clients/iguazio.py @@ -35,6 +35,7 @@ import mlrun.errors import mlrun.utils.helpers import mlrun.utils.singleton +import server.api.utils.helpers import server.api.utils.projects.remotes.leader as project_leader from mlrun.utils import get_in, logger @@ -340,6 +341,7 @@ def is_sync(self): """ return True + @server.api.utils.helpers.lru_cache_with_ttl(maxsize=1, ttl_seconds=60 * 2) def try_get_grafana_service_url(self, session: str) -> typing.Optional[str]: """ Try to find a ready grafana app service, and return its URL diff --git a/server/api/utils/helpers.py b/server/api/utils/helpers.py index c2cf8091931..93a17f16479 100644 --- a/server/api/utils/helpers.py +++ b/server/api/utils/helpers.py @@ -14,7 +14,9 @@ # import asyncio import datetime +import functools import re +import time from typing import Optional import semver @@ -136,3 +138,37 @@ def string_to_timedelta(date_str, raise_on_error=True): return None return datetime.timedelta(seconds=seconds) + + +def lru_cache_with_ttl(maxsize=128, typed=False, ttl_seconds=60): + """ + Thread-safety least-recently used cache with time-to-live (ttl_seconds) limit. + https://stackoverflow.com/a/71634221/5257501 + """ + + class Result: + __slots__ = ("value", "death") + + def __init__(self, value, death): + self.value = value + self.death = death + + def decorator(func): + @functools.lru_cache(maxsize=maxsize, typed=typed) + def cached_func(*args, **kwargs): + value = func(*args, **kwargs) + death = time.monotonic() + ttl_seconds + return Result(value, death) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + result = cached_func(*args, **kwargs) + if result.death < time.monotonic(): + result.value = func(*args, **kwargs) + result.death = time.monotonic() + ttl_seconds + return result.value + + wrapper.cache_clear = cached_func.cache_clear + return wrapper + + return decorator diff --git a/tests/api/utils/clients/test_iguazio.py b/tests/api/utils/clients/test_iguazio.py index 98e8efd94cb..dd15f59e511 100644 --- a/tests/api/utils/clients/test_iguazio.py +++ b/tests/api/utils/clients/test_iguazio.py @@ -215,6 +215,40 @@ async def test_get_grafana_service_url_success( assert grafana_url == expected_grafana_url +@pytest.mark.parametrize("iguazio_client", ("async", "sync"), indirect=True) +@pytest.mark.asyncio +async def test_get_grafana_service_url_cache( + api_url: str, + iguazio_client: server.api.utils.clients.iguazio.Client, + requests_mock: requests_mock_package.Mocker, +): + expected_grafana_url = ( + "https://grafana.default-tenant.app.hedingber-301-1.iguazio-cd2.com" + ) + grafana_service = { + "spec": {"kind": "grafana"}, + "status": { + "state": "ready", + "urls": [ + {"kind": "http", "url": "https-has-precedence"}, + {"kind": "https", "url": expected_grafana_url}, + ], + }, + } + response_body = _generate_app_services_manifests_body([grafana_service]) + requests_mock.get(f"{api_url}/api/app_services_manifests", json=response_body) + grafana_url = await maybe_coroutine( + iguazio_client.try_get_grafana_service_url("session-cookie") + ) + assert grafana_url == expected_grafana_url + + grafana_url = await maybe_coroutine( + iguazio_client.try_get_grafana_service_url("session-cookie") + ) + assert requests_mock.called_once + assert grafana_url == expected_grafana_url + + @pytest.mark.parametrize("iguazio_client", ("async", "sync"), indirect=True) @pytest.mark.asyncio async def test_get_grafana_service_url_ignoring_disabled_service(