From 3e4ebdc0ecee8583f6349354399fa50091f543ce Mon Sep 17 00:00:00 2001 From: Dagonite Date: Tue, 10 Mar 2026 18:58:55 +0000 Subject: [PATCH 1/2] Add caching to instrument and live-data endpoints --- fia_api/routers/instrument.py | 21 +++++- fia_api/routers/instrument_specs.py | 21 +++++- fia_api/routers/live_data.py | 37 ++++++++-- test/e2e/test_endpoint_cache.py | 107 ++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 test/e2e/test_endpoint_cache.py diff --git a/fia_api/routers/instrument.py b/fia_api/routers/instrument.py index c954a261..ebcaf9fe 100644 --- a/fia_api/routers/instrument.py +++ b/fia_api/routers/instrument.py @@ -1,3 +1,4 @@ +import os from typing import Annotated from fastapi import APIRouter, Depends @@ -5,12 +6,18 @@ from sqlalchemy.orm import Session from fia_api.core.auth.tokens import JWTAPIBearer, get_user_from_token +from fia_api.core.cache import cache_get_json, cache_set_json from fia_api.core.exceptions import AuthError from fia_api.core.services.instrument import get_latest_run_by_instrument_name, update_latest_run_for_instrument from fia_api.core.session import get_db_session InstrumentRouter = APIRouter(prefix="/instrument") jwt_api_security = JWTAPIBearer() +INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS = int(os.environ.get("INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS", "15")) + + +def _latest_run_cache_key(instrument: str) -> str: + return f"fia_api:instrument:latest_run:{instrument.upper()}" @InstrumentRouter.get("/{instrument}/latest-run", tags=["instrument"]) @@ -30,8 +37,19 @@ async def get_instrument_latest_run( if user.role != "staff": # If not staff this is not allowed raise AuthError("User not authorised for this action") + + if INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS > 0: + cached = cache_get_json(_latest_run_cache_key(instrument)) + if isinstance(cached, dict): + return cached + latest_run = get_latest_run_by_instrument_name(instrument.upper(), session) - return {"latest_run": latest_run} + payload = {"latest_run": latest_run} + + if INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS > 0: + cache_set_json(_latest_run_cache_key(instrument), payload, INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS) + + return payload @InstrumentRouter.put("/{instrument}/latest-run", tags=["instrument"]) @@ -55,4 +73,5 @@ async def update_instrument_latest_run( # If not staff this is not allowed raise AuthError("User not authorised for this action") update_latest_run_for_instrument(instrument.upper(), latest_run["latest_run"], session) + cache_set_json(_latest_run_cache_key(instrument), None, 1) return {"latest_run": latest_run["latest_run"]} diff --git a/fia_api/routers/instrument_specs.py b/fia_api/routers/instrument_specs.py index 1186b44c..313efa7b 100644 --- a/fia_api/routers/instrument_specs.py +++ b/fia_api/routers/instrument_specs.py @@ -1,3 +1,4 @@ +import os from typing import Annotated, Any from fastapi import APIRouter, Depends @@ -6,12 +7,18 @@ from sqlalchemy.orm import Session from fia_api.core.auth.tokens import JWTAPIBearer, get_user_from_token +from fia_api.core.cache import cache_get_json, cache_set_json from fia_api.core.exceptions import AuthError from fia_api.core.services.instrument import get_specification_by_instrument_name, update_specification_for_instrument from fia_api.core.session import get_db_session InstrumentSpecRouter = APIRouter() jwt_api_security = JWTAPIBearer() +INSTRUMENT_SPEC_CACHE_TTL_SECONDS = int(os.environ.get("INSTRUMENT_SPEC_CACHE_TTL_SECONDS", "120")) + + +def _spec_cache_key(instrument_name: str) -> str: + return f"fia_api:instrument:spec:{instrument_name.upper()}" @InstrumentSpecRouter.get( @@ -32,7 +39,18 @@ async def get_instrument_specification( if user.role != "staff": # If not staff this is not allowed raise AuthError("User not authorised for this action") - return get_specification_by_instrument_name(instrument_name.upper(), session) + + if INSTRUMENT_SPEC_CACHE_TTL_SECONDS > 0: + cached = cache_get_json(_spec_cache_key(instrument_name)) + if isinstance(cached, dict): + return cached.get("specification") + + specification = get_specification_by_instrument_name(instrument_name.upper(), session) + + if INSTRUMENT_SPEC_CACHE_TTL_SECONDS > 0: + cache_set_json(_spec_cache_key(instrument_name), {"specification": specification}, INSTRUMENT_SPEC_CACHE_TTL_SECONDS) + + return specification @InstrumentSpecRouter.put("/instrument/{instrument_name}/specification", tags=["instrument specifications"]) @@ -54,4 +72,5 @@ async def update_instrument_specification( # If not staff this is not allowed raise AuthError("User not authorised for this action") update_specification_for_instrument(instrument_name.upper(), specification, session) + cache_set_json(_spec_cache_key(instrument_name), None, 1) return specification diff --git a/fia_api/routers/live_data.py b/fia_api/routers/live_data.py index bec8684a..756cf37b 100644 --- a/fia_api/routers/live_data.py +++ b/fia_api/routers/live_data.py @@ -1,4 +1,5 @@ # Live Data Script router +import os from typing import Annotated, Literal from fastapi import APIRouter, Depends @@ -6,7 +7,7 @@ from sqlalchemy.orm import Session from fia_api.core.auth.tokens import JWTAPIBearer, get_user_from_token -from fia_api.core.cache import cache_get, cache_set_json +from fia_api.core.cache import cache_get, cache_get_json, cache_set_json from fia_api.core.exceptions import AuthError from fia_api.core.request_models import LiveDataScriptUpdateRequest from fia_api.core.services.instrument import ( @@ -18,6 +19,8 @@ LiveDataRouter = APIRouter(tags=["live-data"]) jwt_api_security = JWTAPIBearer() +LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS = int(os.environ.get("LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS", "120")) +LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS = int(os.environ.get("LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS", "60")) @LiveDataRouter.get("/live-data/instruments") @@ -28,7 +31,18 @@ async def get_live_data_instruments(session: Annotated[Session, Depends(get_db_s :param session: The current session of the request :return: List of instrument names with live data support enabled """ - return get_instruments_with_live_data_support(session) + cache_key = "fia_api:live_data:instruments" + if LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS > 0: + cached = cache_get_json(cache_key) + if isinstance(cached, list): + return cached + + instruments = get_instruments_with_live_data_support(session) + + if LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS > 0: + cache_set_json(cache_key, instruments, LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS) + + return instruments def _get_traceback_key(instrument: str) -> str: @@ -46,6 +60,10 @@ async def get_instrument_traceback(instrument: str) -> str | None: return cache_get(_get_traceback_key(instrument.lower())) +def _get_script_cache_key(instrument: str) -> str: + return f"fia_api:live_data:script:{instrument.upper()}" + + @LiveDataRouter.get("/live-data/{instrument}/script") async def get_instrument_script(instrument: str, session: Annotated[Session, Depends(get_db_session)]) -> str | None: """ @@ -55,7 +73,17 @@ async def get_instrument_script(instrument: str, session: Annotated[Session, Dep :param session: The current session of the request :return: The live data script or None """ - return get_live_data_script_by_instrument_name(instrument.upper(), session) + if LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS > 0: + cached = cache_get_json(_get_script_cache_key(instrument)) + if isinstance(cached, dict): + return cached.get("script") + + script = get_live_data_script_by_instrument_name(instrument.upper(), session) + + if LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS > 0: + cache_set_json(_get_script_cache_key(instrument), {"script": script}, LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS) + + return script @LiveDataRouter.put("/live-data/{instrument}/script") @@ -79,6 +107,7 @@ async def update_instrument_script( raise AuthError("Only Staff can update Live Data Scripts") update_live_data_script_for_instrument(instrument.upper(), script_request.value, session) - # Clear traceback when script is updated + # Clear traceback and script cache when script is updated cache_set_json(_get_traceback_key(instrument), None, 1) + cache_set_json(_get_script_cache_key(instrument), None, 1) return "ok" diff --git a/test/e2e/test_endpoint_cache.py b/test/e2e/test_endpoint_cache.py new file mode 100644 index 00000000..3afc456b --- /dev/null +++ b/test/e2e/test_endpoint_cache.py @@ -0,0 +1,107 @@ +"""Cache behavior tests for live-data and instrument endpoints.""" + +from http import HTTPStatus +from unittest.mock import patch + +from starlette.testclient import TestClient + +from fia_api.fia_api import app + +from .constants import STAFF_HEADER + +client = TestClient(app) + + +# --- GET /live-data/instruments --- + + +@patch("fia_api.routers.live_data.LIVE_DATA_INSTRUMENTS_CACHE_TTL_SECONDS", 120) +@patch("fia_api.routers.live_data.get_instruments_with_live_data_support") +@patch("fia_api.routers.live_data.cache_set_json") +@patch("fia_api.routers.live_data.cache_get_json") +def test_live_data_instruments_cache_hit(mock_cache_get, mock_cache_set, mock_get_instruments): + cached_payload = ["INSTRUMENT_1", "INSTRUMENT_2"] + mock_cache_get.return_value = cached_payload + + response = client.get("/live-data/instruments") + + assert response.status_code == HTTPStatus.OK + assert response.json() == cached_payload + mock_get_instruments.assert_not_called() + mock_cache_set.assert_not_called() + + +# --- GET /live-data/{instrument}/script --- + + +@patch("fia_api.routers.live_data.LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS", 60) +@patch("fia_api.routers.live_data.get_live_data_script_by_instrument_name") +@patch("fia_api.routers.live_data.cache_set_json") +@patch("fia_api.routers.live_data.cache_get_json") +def test_live_data_script_cache_hit(mock_cache_get, mock_cache_set, mock_get_script): + mock_cache_get.return_value = {"script": "print('hello')"} + + response = client.get("/live-data/TEST/script") + + assert response.status_code == HTTPStatus.OK + assert response.json() == "print('hello')" + mock_get_script.assert_not_called() + mock_cache_set.assert_not_called() + + +@patch("fia_api.routers.live_data.LIVE_DATA_SCRIPT_CACHE_TTL_SECONDS", 60) +@patch("fia_api.routers.live_data.get_live_data_script_by_instrument_name") +@patch("fia_api.routers.live_data.cache_set_json") +@patch("fia_api.routers.live_data.cache_get_json") +def test_live_data_script_cache_hit_none_script(mock_cache_get, mock_cache_set, mock_get_script): + """A cached None script (instrument has no script) should still be a cache hit.""" + mock_cache_get.return_value = {"script": None} + + response = client.get("/live-data/TEST/script") + + assert response.status_code == HTTPStatus.OK + assert response.json() is None + mock_get_script.assert_not_called() + mock_cache_set.assert_not_called() + + +# --- GET /instrument/{instrument_name}/specification --- + + +@patch("fia_api.routers.instrument_specs.INSTRUMENT_SPEC_CACHE_TTL_SECONDS", 120) +@patch("fia_api.core.auth.tokens.requests.post") +@patch("fia_api.routers.instrument_specs.get_specification_by_instrument_name") +@patch("fia_api.routers.instrument_specs.cache_set_json") +@patch("fia_api.routers.instrument_specs.cache_get_json") +def test_instrument_spec_cache_hit(mock_cache_get, mock_cache_set, mock_get_spec, mock_post): + cached_spec = {"foo": "bar", "baz": 42} + mock_cache_get.return_value = {"specification": cached_spec} + mock_post.return_value.status_code = HTTPStatus.OK + + response = client.get("/instrument/TEST/specification", headers=STAFF_HEADER) + + assert response.status_code == HTTPStatus.OK + assert response.json() == cached_spec + mock_get_spec.assert_not_called() + mock_cache_set.assert_not_called() + + +# --- GET /instrument/{instrument}/latest-run --- + + +@patch("fia_api.routers.instrument.INSTRUMENT_LATEST_RUN_CACHE_TTL_SECONDS", 15) +@patch("fia_api.core.auth.tokens.requests.post") +@patch("fia_api.routers.instrument.get_latest_run_by_instrument_name") +@patch("fia_api.routers.instrument.cache_set_json") +@patch("fia_api.routers.instrument.cache_get_json") +def test_instrument_latest_run_cache_hit(mock_cache_get, mock_cache_set, mock_get_latest, mock_post): + cached_payload = {"latest_run": "12345"} + mock_cache_get.return_value = cached_payload + mock_post.return_value.status_code = HTTPStatus.OK + + response = client.get("/instrument/TEST/latest-run", headers=STAFF_HEADER) + + assert response.status_code == HTTPStatus.OK + assert response.json() == cached_payload + mock_get_latest.assert_not_called() + mock_cache_set.assert_not_called() From b20993b0f02a0c60ae53d069bbc1fdc077607940 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 10 Mar 2026 18:59:34 +0000 Subject: [PATCH 2/2] Formatting and linting commit --- fia_api/routers/instrument_specs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fia_api/routers/instrument_specs.py b/fia_api/routers/instrument_specs.py index 313efa7b..4878fd2a 100644 --- a/fia_api/routers/instrument_specs.py +++ b/fia_api/routers/instrument_specs.py @@ -48,7 +48,9 @@ async def get_instrument_specification( specification = get_specification_by_instrument_name(instrument_name.upper(), session) if INSTRUMENT_SPEC_CACHE_TTL_SECONDS > 0: - cache_set_json(_spec_cache_key(instrument_name), {"specification": specification}, INSTRUMENT_SPEC_CACHE_TTL_SECONDS) + cache_set_json( + _spec_cache_key(instrument_name), {"specification": specification}, INSTRUMENT_SPEC_CACHE_TTL_SECONDS + ) return specification