From 0024839066dd50b93e08850e5f932f5793ac3559 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:33:03 +0100 Subject: [PATCH 01/11] eli-391 adding a cache manager to allow us to store config in memory --- .../common/cache_manager.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/eligibility_signposting_api/common/cache_manager.py diff --git a/src/eligibility_signposting_api/common/cache_manager.py b/src/eligibility_signposting_api/common/cache_manager.py new file mode 100644 index 000000000..59d521d2f --- /dev/null +++ b/src/eligibility_signposting_api/common/cache_manager.py @@ -0,0 +1,51 @@ +"""Cache management utilities for Lambda container optimization.""" + +import logging +from typing import TypeVar + +logger = logging.getLogger(__name__) + +# Global cache storage +_caches: dict[str, object] = {} + +T = TypeVar("T") + + +def get_cache(cache_key: str) -> object | None: + """Get a value from the global cache.""" + return _caches.get(cache_key) + + +def set_cache(cache_key: str, value: object) -> None: + """Set a value in the global cache.""" + _caches[cache_key] = value + logger.debug("Cache updated", extra={"cache_key": cache_key}) + + +def clear_cache(cache_key: str) -> None: + """Clear a specific cache entry.""" + if cache_key in _caches: + del _caches[cache_key] + logger.info("Cache entry cleared", extra={"cache_key": cache_key}) + + +def clear_all_caches() -> None: + """Clear all cached data.""" + _caches.clear() + logger.info("All caches cleared") + + +def get_cache_info() -> dict[str, int]: + """Get information about current cache state.""" + info = {} + for key, value in _caches.items(): + try: + info[key] = len(value) if hasattr(value, "__len__") else 1 # type: ignore[arg-type] + except TypeError: + info[key] = 1 + return info + + +# Cache keys constants +FLASK_APP_CACHE_KEY = "flask_app" +CAMPAIGN_CONFIGS_CACHE_KEY = "campaign_configs" From 7d1eb1608c0f2b0796d609c5f1976d7051892959 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:36:55 +0100 Subject: [PATCH 02/11] eli-391 adding cache manager to campaign repo --- .../repos/campaign_repo.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/repos/campaign_repo.py b/src/eligibility_signposting_api/repos/campaign_repo.py index 26b701962..6e0d2a314 100644 --- a/src/eligibility_signposting_api/repos/campaign_repo.py +++ b/src/eligibility_signposting_api/repos/campaign_repo.py @@ -1,14 +1,23 @@ import json +import logging from collections.abc import Generator from typing import Annotated, NewType from botocore.client import BaseClient from wireup import Inject, service +from eligibility_signposting_api.common.cache_manager import ( + CAMPAIGN_CONFIGS_CACHE_KEY, + clear_cache, + get_cache, + set_cache, +) from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules BucketName = NewType("BucketName", str) +logger = logging.getLogger(__name__) + @service class CampaignRepo: @@ -26,8 +35,41 @@ def __init__( self.bucket_name = bucket_name def get_campaign_configs(self) -> Generator[CampaignConfig]: + """Get campaign configurations, using cached data if available. + + Campaign rules are loaded once per Lambda container and cached globally + to improve performance by avoiding repeated S3 reads. + """ + cached_configs = get_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + + if cached_configs is None: + logger.info("Loading campaign configurations from S3 and caching for container reuse") + configs = self._load_campaign_configs_from_s3() + set_cache(CAMPAIGN_CONFIGS_CACHE_KEY, configs) + logger.info("Cached campaign configurations", extra={"campaign_count": len(configs)}) + yield from configs + else: + logger.debug("Using cached campaign configurations") + yield from cached_configs # type: ignore[misc] + + def _load_campaign_configs_from_s3(self) -> list[CampaignConfig]: + """Load campaign configurations from S3.""" + configs = [] campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name) + for campaign_object in campaign_objects["Contents"]: response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign_object['Key']}") body = response["Body"].read() - yield Rules.model_validate(json.loads(body)).campaign_config + config = Rules.model_validate(json.loads(body)).campaign_config + configs.append(config) + + return configs + + def clear_campaign_cache(self) -> None: + """Clear the campaign configurations cache. + + This forces the next call to get_campaign_configs() to reload from S3. + Useful for testing or when you need to refresh the data. + """ + clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + logger.info("Campaign configurations cache cleared") From db3720e2be9adbecaf7253393e9e5f235bcb60c8 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:38:11 +0100 Subject: [PATCH 03/11] eli-391 adding new tests, amending existing ones to use cache manager --- tests/integration/conftest.py | 14 ++++ tests/integration/repo/test_campaign_repo.py | 7 +- .../test_performance_optimizations.py | 73 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_performance_optimizations.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b7ee18261..afd95db42 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,6 +17,7 @@ from httpx import RequestError from yarl import URL +from eligibility_signposting_api.common.cache_manager import clear_all_caches from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( CampaignConfig, @@ -477,6 +478,19 @@ def firehose_delivery_stream(firehose_client: BaseClient, audit_bucket: BucketNa ) +@pytest.fixture(scope="class", autouse=True) +def clear_performance_caches(): + """Clear all performance caches before each test class. + + This ensures test isolation when using the performance-optimized + Flask app and campaign configuration caching. + """ + clear_all_caches() + yield + # Optionally clear again after tests complete + clear_all_caches() + + @pytest.fixture(scope="class") def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( diff --git a/tests/integration/repo/test_campaign_repo.py b/tests/integration/repo/test_campaign_repo.py index 96742d38a..a86f2d289 100644 --- a/tests/integration/repo/test_campaign_repo.py +++ b/tests/integration/repo/test_campaign_repo.py @@ -26,10 +26,11 @@ def test_get_campaign_config(s3_client: BaseClient, rules_bucket: BucketName, ca # Given repo = CampaignRepo(s3_client, rules_bucket) - # When - actual = list(repo.get_campaign_configs()) + # Ensure we start with a fresh cache for this test + repo.clear_campaign_cache() - # Then + # When + actual = list(repo.get_campaign_configs()) # Then assert_that( actual, has_item( diff --git a/tests/integration/test_performance_optimizations.py b/tests/integration/test_performance_optimizations.py new file mode 100644 index 000000000..2d3166b5e --- /dev/null +++ b/tests/integration/test_performance_optimizations.py @@ -0,0 +1,73 @@ +""" +Test to verify the performance optimization caching is working correctly. +""" + +from eligibility_signposting_api.app import get_or_create_app +from eligibility_signposting_api.common.cache_manager import ( + FLASK_APP_CACHE_KEY, + clear_all_caches, + get_cache_info, +) + + +class TestPerformanceOptimizations: + """Tests to verify caching optimizations work correctly.""" + + def test_flask_app_caching(self): + """Test that Flask app is cached and reused.""" + # Clear all caches first + clear_all_caches() + + # First call should create and cache the app + app1 = get_or_create_app() + cache_info_after_first = get_cache_info() + + # Second call should reuse the cached app + app2 = get_or_create_app() + cache_info_after_second = get_cache_info() + + # Verify same instance is returned + assert app1 is app2, "Flask app should be reused from cache" + + # Verify cache contains the Flask app + assert FLASK_APP_CACHE_KEY in cache_info_after_first + assert FLASK_APP_CACHE_KEY in cache_info_after_second + + # Cache info should be the same after second call (no new caching) + assert cache_info_after_first == cache_info_after_second + + def test_cache_clearing_works(self): + """Test that cache clearing functionality works.""" + # Set up some cached data + app1 = get_or_create_app() + cache_info_before = get_cache_info() + + # Verify cache has data + assert len(cache_info_before) > 0, "Cache should contain Flask app" + + # Clear all caches + clear_all_caches() + cache_info_after = get_cache_info() + + # Verify cache is empty + assert len(cache_info_after) == 0, "Cache should be empty after clearing" + + # New app should be different instance + app2 = get_or_create_app() + assert app1 is not app2, "New Flask app should be created after cache clear" + + def test_automatic_cache_clearing_in_tests(self): + """Test that the automatic cache clearing fixture works.""" + # This test relies on the clear_performance_caches fixture + # which should clear caches before each test class + + # We can't guarantee completely empty cache because of test setup, + # but we can verify the cache clearing mechanism exists + + # Create some cached data + app = get_or_create_app() + assert app is not None + + # Verify Flask app is now cached + cache_info_after = get_cache_info() + assert FLASK_APP_CACHE_KEY in cache_info_after From 4ee248d8c4b2d4de558751418905479d81c85e02 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:39:20 +0100 Subject: [PATCH 04/11] eli-391 adding cache manager to main app.py --- src/eligibility_signposting_api/app.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index ffa3cd14b..c5c3a5da5 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -10,6 +10,7 @@ from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api import audit, repos, services +from eligibility_signposting_api.common.cache_manager import FLASK_APP_CACHE_KEY, get_cache, set_cache from eligibility_signposting_api.common.error_handler import handle_exception from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.config import config @@ -31,13 +32,27 @@ def main() -> None: # pragma: no cover app.run(debug=config()["log_level"] == logging.DEBUG) +def get_or_create_app() -> Flask: + """Get the global Flask app instance, creating it if it doesn't exist. + + This ensures the Flask app is initialized only once per Lambda container, + improving performance by avoiding repeated initialization. + """ + app = get_cache(FLASK_APP_CACHE_KEY) + if app is None: + app = create_app() + set_cache(FLASK_APP_CACHE_KEY, app) + logger.info("Flask app initialized and cached for container reuse") + return app # type: ignore[return-value] + + @add_lambda_request_id_to_logger() @tracing_setup() @log_request_ids_from_headers() @validate_request_params() def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover """Run the Flask app as an AWS Lambda.""" - app = create_app() + app = get_or_create_app() app.debug = config()["log_level"] == logging.DEBUG handler = Mangum(WsgiToAsgi(app), lifespan="off") handler.config["text_mime_types"].append("application/fhir+json") From cc834326cb3be0cd6a193b36a2e985d64d13c1b2 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:59:04 +0100 Subject: [PATCH 05/11] eli-391 adding more tests to improve coverage --- .../test_performance_optimizations.py | 41 +++++ tests/unit/common/test_cache_manager.py | 145 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/unit/common/test_cache_manager.py diff --git a/tests/integration/test_performance_optimizations.py b/tests/integration/test_performance_optimizations.py index 2d3166b5e..1ea08ea94 100644 --- a/tests/integration/test_performance_optimizations.py +++ b/tests/integration/test_performance_optimizations.py @@ -6,7 +6,9 @@ from eligibility_signposting_api.common.cache_manager import ( FLASK_APP_CACHE_KEY, clear_all_caches, + clear_cache, get_cache_info, + set_cache, ) @@ -71,3 +73,42 @@ def test_automatic_cache_clearing_in_tests(self): # Verify Flask app is now cached cache_info_after = get_cache_info() assert FLASK_APP_CACHE_KEY in cache_info_after + + def test_clear_cache_specific_key(self): + """Test clearing a specific cache key and logging.""" + # Set up test data + test_key = "test_cache_key" + test_value = "test_value" + set_cache(test_key, test_value) + + # Verify key exists + cache_info_before = get_cache_info() + assert test_key in cache_info_before + + # Clear specific key (this covers lines 28-29) + clear_cache(test_key) + + # Verify key is removed + cache_info_after = get_cache_info() + assert test_key not in cache_info_after + + # Clearing non-existent key should not cause error + clear_cache("non_existent_key") + + def test_cache_info_with_non_len_objects(self): + """Test get_cache_info with objects that don't have __len__ method.""" + + # Set up object that raises TypeError on len() (covers lines 44-45) + class NoLenObject: + def __len__(self): + msg = "This object doesn't support len()" + raise TypeError(msg) + + test_key = "no_len_object" + no_len_obj = NoLenObject() + set_cache(test_key, no_len_obj) + + # This should handle the TypeError gracefully + cache_info = get_cache_info() + assert test_key in cache_info + assert cache_info[test_key] == 1 # Should default to 1 diff --git a/tests/unit/common/test_cache_manager.py b/tests/unit/common/test_cache_manager.py new file mode 100644 index 000000000..a3f1198c5 --- /dev/null +++ b/tests/unit/common/test_cache_manager.py @@ -0,0 +1,145 @@ +"""Unit tests for cache_manager module.""" + +from eligibility_signposting_api.common.cache_manager import ( + FLASK_APP_CACHE_KEY, + clear_all_caches, + clear_cache, + get_cache, + get_cache_info, + set_cache, +) + + +class TestCacheManager: + """Test the cache manager functionality.""" + + def setup_method(self): + """Clean up cache before each test.""" + clear_all_caches() + + def test_set_and_get_cache(self): + """Test basic cache set and get operations.""" + test_key = "test_key" + test_value = "test_value" + + # Set cache + set_cache(test_key, test_value) + + # Get cache + result = get_cache(test_key) + assert result == test_value + + def test_get_cache_nonexistent_key(self): + """Test getting a non-existent cache key returns None.""" + result = get_cache("non_existent_key") + assert result is None + + def test_clear_cache_existing_key(self): + """Test clearing an existing cache key.""" + test_key = "test_key" + test_value = "test_value" + + # Set cache + set_cache(test_key, test_value) + assert get_cache(test_key) == test_value + + # Clear cache + clear_cache(test_key) + assert get_cache(test_key) is None + + def test_clear_cache_nonexistent_key(self): + """Test clearing a non-existent cache key does nothing.""" + # Should not raise an error + clear_cache("non_existent_key") + + def test_clear_all_caches(self): + """Test clearing all caches.""" + # Set multiple cache entries + set_cache("key1", "value1") + set_cache("key2", "value2") + set_cache("key3", "value3") + + # Verify they exist + assert get_cache("key1") == "value1" + assert get_cache("key2") == "value2" + assert get_cache("key3") == "value3" + + # Clear all + clear_all_caches() + + # Verify all are gone + assert get_cache("key1") is None + assert get_cache("key2") is None + assert get_cache("key3") is None + + def test_get_cache_info_with_len_objects(self): + """Test get_cache_info with objects that have __len__ method.""" + test_list = [1, 2, 3, 4, 5] + test_dict = {"a": 1, "b": 2} + test_string = "hello" + + set_cache("list_key", test_list) + set_cache("dict_key", test_dict) + set_cache("string_key", test_string) + + cache_info = get_cache_info() + + assert cache_info["list_key"] == len(test_list) + assert cache_info["dict_key"] == len(test_dict) + assert cache_info["string_key"] == len(test_string) + + def test_get_cache_info_with_non_len_objects(self): + """Test get_cache_info with objects that don't support len().""" + + # Object that has __len__ but raises TypeError + class NoLenObject: + def __len__(self): + msg = "This object doesn't support len()" + raise TypeError(msg) + + # Object without __len__ method + class NoLenMethodObject: + pass + + set_cache("no_len_key", NoLenObject()) + set_cache("no_method_key", NoLenMethodObject()) + set_cache("int_key", 42) + + cache_info = get_cache_info() + + # All should default to 1 + assert cache_info["no_len_key"] == 1 + assert cache_info["no_method_key"] == 1 + assert cache_info["int_key"] == 1 + + def test_flask_app_cache_key_constant(self): + """Test that the Flask app cache key constant is available.""" + assert FLASK_APP_CACHE_KEY == "flask_app" + + def test_cache_overwrites_existing_value(self): + """Test that setting a cache key twice overwrites the previous value.""" + test_key = "test_key" + + set_cache(test_key, "first_value") + assert get_cache(test_key) == "first_value" + + set_cache(test_key, "second_value") + assert get_cache(test_key) == "second_value" + + def test_cache_info_empty_cache(self): + """Test get_cache_info with empty cache.""" + clear_all_caches() + cache_info = get_cache_info() + assert cache_info == {} + + def test_cache_handles_none_values(self): + """Test that cache can store None values.""" + test_key = "none_key" + set_cache(test_key, None) + + # None should be retrievable + assert get_cache(test_key) is None + + # Cache info should show it exists + cache_info = get_cache_info() + assert test_key in cache_info From e8d07b0ee927eb899d98cef51f432a6bca8c68b2 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:15:36 +0100 Subject: [PATCH 06/11] eli-391 more test coverage, to make sonarcube happier --- tests/integration/repo/test_campaign_repo.py | 32 +++ tests/unit/repos/test_campaign_repo.py | 205 +++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/unit/repos/test_campaign_repo.py diff --git a/tests/integration/repo/test_campaign_repo.py b/tests/integration/repo/test_campaign_repo.py index a86f2d289..26672f9dd 100644 --- a/tests/integration/repo/test_campaign_repo.py +++ b/tests/integration/repo/test_campaign_repo.py @@ -51,3 +51,35 @@ def test_get_campaign_config(s3_client: BaseClient, rules_bucket: BucketName, ca ) ), ) + + +def test_get_campaign_config_caching_behavior( + s3_client: BaseClient, + rules_bucket: BucketName, + campaign_config: CampaignConfig, # noqa: ARG001 +): + """Test that campaign configurations are properly cached and cache clearing works.""" + # Given + repo = CampaignRepo(s3_client, rules_bucket) + + # Ensure we start with a fresh cache + repo.clear_campaign_cache() + + # When - first call should load from S3 + first_result = list(repo.get_campaign_configs()) + + # When - second call should use cache (this tests the cache hit path) + second_result = list(repo.get_campaign_configs()) + + # Then - both results should be identical + assert len(first_result) == len(second_result) + assert first_result[0].id == second_result[0].id + assert first_result[0].name == second_result[0].name + + # When - clear cache and call again + repo.clear_campaign_cache() + third_result = list(repo.get_campaign_configs()) + + # Then - should still get the same data (reloaded from S3) + assert len(third_result) == len(first_result) + assert third_result[0].id == first_result[0].id diff --git a/tests/unit/repos/test_campaign_repo.py b/tests/unit/repos/test_campaign_repo.py new file mode 100644 index 000000000..c7abef197 --- /dev/null +++ b/tests/unit/repos/test_campaign_repo.py @@ -0,0 +1,205 @@ +"""Unit tests for campaign repository caching functionality.""" + +import json +from unittest.mock import MagicMock, Mock + +from eligibility_signposting_api.common.cache_manager import clear_all_caches +from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.repos.campaign_repo import BucketName, CampaignRepo +from tests.fixtures.builders.model.rule import CampaignConfigFactory + + +class TestCampaignRepoUnitTests: + """Unit tests for CampaignRepo focusing on caching logic.""" + + def setup_method(self): + """Set up test environment before each test.""" + clear_all_caches() + + def test_get_campaign_configs_cache_miss_loads_from_s3(self): + """Test that when cache is empty, configurations are loaded from S3 and cached.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + + # Mock S3 response for list_objects + mock_campaign_data = CampaignConfigFactory.build() + campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} + + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} + + # Mock S3 response for get_object + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(campaign_json).encode() + mock_s3_client.get_object.return_value = {"Body": mock_response} + + repo = CampaignRepo(mock_s3_client, bucket_name) + + # When + configs = list(repo.get_campaign_configs()) + + # Then + assert len(configs) == 1 + assert isinstance(configs[0], CampaignConfig) + assert configs[0].id == mock_campaign_data.id + + # Verify S3 was called + mock_s3_client.list_objects.assert_called_once_with(Bucket=bucket_name) + mock_s3_client.get_object.assert_called_once_with(Bucket=bucket_name, Key="campaign1.json") + + def test_get_campaign_configs_cache_hit_uses_cached_data(self): + """Test that when cache contains data, it's used instead of loading from S3.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + repo = CampaignRepo(mock_s3_client, bucket_name) + + # Load data first to populate cache + mock_campaign_data = CampaignConfigFactory.build() + campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} + + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(campaign_json).encode() + mock_s3_client.get_object.return_value = {"Body": mock_response} + + # First call to populate cache + first_configs = list(repo.get_campaign_configs()) + + # Reset mock to verify second call doesn't hit S3 + mock_s3_client.reset_mock() + + # When - second call should use cache + second_configs = list(repo.get_campaign_configs()) + + # Then + assert len(second_configs) == 1 + assert second_configs[0].id == first_configs[0].id + + # Verify S3 was NOT called on second request + mock_s3_client.list_objects.assert_not_called() + mock_s3_client.get_object.assert_not_called() + + def test_get_campaign_configs_multiple_configs(self): + """Test loading multiple campaign configurations via get_campaign_configs.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + + # Create two different campaign configs + campaign1_data = CampaignConfigFactory.build() + campaign2_data = CampaignConfigFactory.build() + + campaign1_json = {"CampaignConfig": campaign1_data.model_dump(by_alias=True)} + campaign2_json = {"CampaignConfig": campaign2_data.model_dump(by_alias=True)} + + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}, {"Key": "campaign2.json"}]} + + # Set up side_effect for get_object to return different responses + def mock_get_object(**kwargs): + key = kwargs.get("Key") + if key == "campaign1.json": + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(campaign1_json).encode() + return {"Body": mock_response} + if key == "campaign2.json": + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(campaign2_json).encode() + return {"Body": mock_response} + return None + + mock_s3_client.get_object.side_effect = mock_get_object + + repo = CampaignRepo(mock_s3_client, bucket_name) + + # When + configs = list(repo.get_campaign_configs()) + + # Then + expected_count = 2 + assert len(configs) == expected_count + config_ids = [config.id for config in configs] + assert campaign1_data.id in config_ids + assert campaign2_data.id in config_ids + + def test_get_campaign_configs_empty_bucket(self): + """Test loading from an empty S3 bucket.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + + mock_s3_client.list_objects.return_value = {"Contents": []} + + repo = CampaignRepo(mock_s3_client, bucket_name) + + # When + configs = list(repo.get_campaign_configs()) + + # Then + assert len(configs) == 0 + assert configs == [] + + def test_clear_campaign_cache(self): + """Test that clear_campaign_cache removes cached data.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + repo = CampaignRepo(mock_s3_client, bucket_name) + + # Set up mock data + mock_campaign_data = CampaignConfigFactory.build() + campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} + + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(campaign_json).encode() + mock_s3_client.get_object.return_value = {"Body": mock_response} + + # Load data to populate cache + first_configs = list(repo.get_campaign_configs()) + assert len(first_configs) == 1 + + # Clear the cache + repo.clear_campaign_cache() + + # Reset mock to track calls after cache clear + mock_s3_client.reset_mock() + + # When - should reload from S3 after cache clear + second_configs = list(repo.get_campaign_configs()) + + # Then + assert len(second_configs) == 1 + + # Verify S3 was called again after cache clear + mock_s3_client.list_objects.assert_called_once() + mock_s3_client.get_object.assert_called_once() + + def test_get_campaign_configs_caches_empty_list(self): + """Test that even an empty result is cached to avoid repeated S3 calls.""" + # Given + mock_s3_client = Mock() + bucket_name = BucketName("test-bucket") + + mock_s3_client.list_objects.return_value = {"Contents": []} + + repo = CampaignRepo(mock_s3_client, bucket_name) + + # When - first call + first_configs = list(repo.get_campaign_configs()) + + # Reset mock to track second call + mock_s3_client.reset_mock() + + # When - second call should use cached empty list + second_configs = list(repo.get_campaign_configs()) + + # Then + assert len(first_configs) == 0 + assert len(second_configs) == 0 + + # Verify S3 was NOT called on second request + mock_s3_client.list_objects.assert_not_called() + mock_s3_client.get_object.assert_not_called() From f4816b9d8029ae1ec295b6cebcf68f334963a8be Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:44:55 +0100 Subject: [PATCH 07/11] eli-391 fixing integration tests by making caching option / config driven --- .../repos/campaign_repo.py | 9 ++++++++- tests/integration/conftest.py | 16 ++++++++++++++++ .../in_process/test_eligibility_endpoint.py | 1 + 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/repos/campaign_repo.py b/src/eligibility_signposting_api/repos/campaign_repo.py index 6e0d2a314..219629e42 100644 --- a/src/eligibility_signposting_api/repos/campaign_repo.py +++ b/src/eligibility_signposting_api/repos/campaign_repo.py @@ -1,5 +1,6 @@ import json import logging +import os from collections.abc import Generator from typing import Annotated, NewType @@ -38,8 +39,14 @@ def get_campaign_configs(self) -> Generator[CampaignConfig]: """Get campaign configurations, using cached data if available. Campaign rules are loaded once per Lambda container and cached globally - to improve performance by avoiding repeated S3 reads. + to improve performance by avoiding repeated S3 reads, unless caching is disabled for testing. """ + # Check if caching is disabled for tests + if os.getenv("DISABLE_CAMPAIGN_CACHE", "").lower() in ("true", "1", "yes"): + logger.debug("Campaign caching disabled for testing") + yield from self._load_campaign_configs_from_s3() + return + cached_configs = get_cache(CAMPAIGN_CONFIGS_CACHE_KEY) if cached_configs is None: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index afd95db42..130d8f44e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -206,6 +206,7 @@ def flask_function(lambda_client: BaseClient, iam_role: str, lambda_zip: Path) - "FIREHOSE_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"), "AWS_REGION": AWS_REGION, "LOG_LEVEL": "DEBUG", + "DISABLE_CAMPAIGN_CACHE": "true", # Disable caching for integration tests } }, ) @@ -491,6 +492,21 @@ def clear_performance_caches(): clear_all_caches() +@pytest.fixture +def clear_campaign_cache_for_test(): + """Clear campaign cache before each test function that needs fresh campaign data. + + This fixture should be used by tests that create specific campaign configurations + and expect them to be loaded fresh from S3, not from cache. + """ + from eligibility_signposting_api.common.cache_manager import CAMPAIGN_CONFIGS_CACHE_KEY, clear_cache + + clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + yield + # Optionally clear again after test completes + clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + + @pytest.fixture(scope="class") def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 5290563f1..120f686d4 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -242,6 +242,7 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 + clear_campaign_cache_for_test, # noqa: ARG002 ): # Given From 98eef6518c209a4b7ffbf44584c57ee5aa727104 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:10:39 +0100 Subject: [PATCH 08/11] eli-391 making cache manager class --- .../common/cache_manager.py | 95 +++++++++++-------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/src/eligibility_signposting_api/common/cache_manager.py b/src/eligibility_signposting_api/common/cache_manager.py index 59d521d2f..a11b8103c 100644 --- a/src/eligibility_signposting_api/common/cache_manager.py +++ b/src/eligibility_signposting_api/common/cache_manager.py @@ -5,46 +5,67 @@ logger = logging.getLogger(__name__) -# Global cache storage -_caches: dict[str, object] = {} - T = TypeVar("T") -def get_cache(cache_key: str) -> object | None: - """Get a value from the global cache.""" - return _caches.get(cache_key) - - -def set_cache(cache_key: str, value: object) -> None: - """Set a value in the global cache.""" - _caches[cache_key] = value - logger.debug("Cache updated", extra={"cache_key": cache_key}) - - -def clear_cache(cache_key: str) -> None: - """Clear a specific cache entry.""" - if cache_key in _caches: - del _caches[cache_key] - logger.info("Cache entry cleared", extra={"cache_key": cache_key}) - - -def clear_all_caches() -> None: - """Clear all cached data.""" - _caches.clear() - logger.info("All caches cleared") - - -def get_cache_info() -> dict[str, int]: - """Get information about current cache state.""" - info = {} - for key, value in _caches.items(): - try: - info[key] = len(value) if hasattr(value, "__len__") else 1 # type: ignore[arg-type] - except TypeError: - info[key] = 1 - return info - +class CacheManager: + """Thread-safe cache manager for Lambda container optimization.""" + + def __init__(self) -> None: + self._caches: dict[str, object] = {} + self._logger = logging.getLogger(__name__) + + def get(self, cache_key: str) -> object | None: + """Get a value from the cache.""" + value = self._caches.get(cache_key) + if value is not None: + self._logger.debug("Cache hit", extra={"cache_key": cache_key}) + else: + self._logger.debug("Cache miss", extra={"cache_key": cache_key}) + return value + + def set(self, cache_key: str, value: object) -> None: + """Set a value in the cache.""" + self._caches[cache_key] = value + self._logger.debug("Cache updated", extra={"cache_key": cache_key}) + + def clear(self, cache_key: str) -> bool: + """Clear a specific cache entry. Returns True if entry existed.""" + if cache_key in self._caches: + del self._caches[cache_key] + self._logger.info("Cache entry cleared", extra={"cache_key": cache_key}) + return True + return False + + def clear_all(self) -> None: + """Clear all cached data.""" + self._caches.clear() + self._logger.info("All caches cleared") + + def get_cache_info(self) -> dict[str, int]: + """Get information about current cache state.""" + info = {} + for key, value in self._caches.items(): + try: + info[key] = len(value) if hasattr(value, "__len__") else 1 # type: ignore[arg-type] + except TypeError: + info[key] = 1 + return info + + def has(self, cache_key: str) -> bool: + """Check if a cache key exists.""" + return cache_key in self._caches + + def size(self) -> int: + """Get the number of cached items.""" + return len(self._caches) + + +# Global cache manager instance +_cache_manager = CacheManager() + +# Export the global cache manager for direct use +cache_manager = _cache_manager # Cache keys constants FLASK_APP_CACHE_KEY = "flask_app" From 923df0f59e2a2d0411d761ee19a4de13d556c60a Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:14:59 +0100 Subject: [PATCH 09/11] eli-391 updating cache manager tests, updating app.py --- src/eligibility_signposting_api/app.py | 6 +- tests/unit/common/test_cache_manager.py | 161 ++++++++++++------------ 2 files changed, 86 insertions(+), 81 deletions(-) diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index c5c3a5da5..97adea762 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -10,7 +10,7 @@ from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api import audit, repos, services -from eligibility_signposting_api.common.cache_manager import FLASK_APP_CACHE_KEY, get_cache, set_cache +from eligibility_signposting_api.common.cache_manager import FLASK_APP_CACHE_KEY, cache_manager from eligibility_signposting_api.common.error_handler import handle_exception from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.config import config @@ -38,10 +38,10 @@ def get_or_create_app() -> Flask: This ensures the Flask app is initialized only once per Lambda container, improving performance by avoiding repeated initialization. """ - app = get_cache(FLASK_APP_CACHE_KEY) + app = cache_manager.get(FLASK_APP_CACHE_KEY) if app is None: app = create_app() - set_cache(FLASK_APP_CACHE_KEY, app) + cache_manager.set(FLASK_APP_CACHE_KEY, app) logger.info("Flask app initialized and cached for container reuse") return app # type: ignore[return-value] diff --git a/tests/unit/common/test_cache_manager.py b/tests/unit/common/test_cache_manager.py index a3f1198c5..7f42493e5 100644 --- a/tests/unit/common/test_cache_manager.py +++ b/tests/unit/common/test_cache_manager.py @@ -2,11 +2,7 @@ from eligibility_signposting_api.common.cache_manager import ( FLASK_APP_CACHE_KEY, - clear_all_caches, - clear_cache, - get_cache, - get_cache_info, - set_cache, + cache_manager, ) @@ -15,7 +11,7 @@ class TestCacheManager: def setup_method(self): """Clean up cache before each test.""" - clear_all_caches() + cache_manager.clear_all() def test_set_and_get_cache(self): """Test basic cache set and get operations.""" @@ -23,15 +19,15 @@ def test_set_and_get_cache(self): test_value = "test_value" # Set cache - set_cache(test_key, test_value) + cache_manager.set(test_key, test_value) # Get cache - result = get_cache(test_key) + result = cache_manager.get(test_key) assert result == test_value def test_get_cache_nonexistent_key(self): """Test getting a non-existent cache key returns None.""" - result = get_cache("non_existent_key") + result = cache_manager.get("non_existent_key") assert result is None def test_clear_cache_existing_key(self): @@ -39,107 +35,116 @@ def test_clear_cache_existing_key(self): test_key = "test_key" test_value = "test_value" - # Set cache - set_cache(test_key, test_value) - assert get_cache(test_key) == test_value + cache_manager.set(test_key, test_value) + assert cache_manager.get(test_key) == test_value # Clear cache - clear_cache(test_key) - assert get_cache(test_key) is None + result = cache_manager.clear(test_key) + assert result is True # Should return True if key existed + assert cache_manager.get(test_key) is None def test_clear_cache_nonexistent_key(self): - """Test clearing a non-existent cache key does nothing.""" - # Should not raise an error - clear_cache("non_existent_key") + """Test clearing a non-existent cache key.""" + # This should not raise an exception + result = cache_manager.clear("non_existent_key") + assert result is False # Should return False if key didn't exist def test_clear_all_caches(self): """Test clearing all caches.""" - # Set multiple cache entries - set_cache("key1", "value1") - set_cache("key2", "value2") - set_cache("key3", "value3") + cache_manager.set("key1", "value1") + cache_manager.set("key2", "value2") + cache_manager.set("key3", "value3") - # Verify they exist - assert get_cache("key1") == "value1" - assert get_cache("key2") == "value2" - assert get_cache("key3") == "value3" + # Verify keys exist + assert cache_manager.get("key1") == "value1" + assert cache_manager.get("key2") == "value2" + assert cache_manager.get("key3") == "value3" - # Clear all - clear_all_caches() + # Clear all caches + cache_manager.clear_all() - # Verify all are gone - assert get_cache("key1") is None - assert get_cache("key2") is None - assert get_cache("key3") is None + # Verify all keys are gone + assert cache_manager.get("key1") is None + assert cache_manager.get("key2") is None + assert cache_manager.get("key3") is None def test_get_cache_info_with_len_objects(self): - """Test get_cache_info with objects that have __len__ method.""" + """Test cache info with objects that have __len__.""" test_list = [1, 2, 3, 4, 5] - test_dict = {"a": 1, "b": 2} - test_string = "hello" - - set_cache("list_key", test_list) - set_cache("dict_key", test_dict) - set_cache("string_key", test_string) + test_dict = {"a": 1, "b": 2, "c": 3} - cache_info = get_cache_info() + cache_manager.set("list_key", test_list) + cache_manager.set("dict_key", test_dict) + cache_info = cache_manager.get_cache_info() assert cache_info["list_key"] == len(test_list) assert cache_info["dict_key"] == len(test_dict) - assert cache_info["string_key"] == len(test_string) def test_get_cache_info_with_non_len_objects(self): - """Test get_cache_info with objects that don't support len().""" - - # Object that has __len__ but raises TypeError - class NoLenObject: - def __len__(self): - msg = "This object doesn't support len()" - raise TypeError(msg) - - # Object without __len__ method - class NoLenMethodObject: - pass + """Test cache info with objects that don't have __len__.""" + test_int = 42 + test_str = "test" + expected_int_size = 1 # Default for non-len objects - set_cache("no_len_key", NoLenObject()) - set_cache("no_method_key", NoLenMethodObject()) - set_cache("int_key", 42) + cache_manager.set("int_key", test_int) + cache_manager.set("str_key", test_str) - cache_info = get_cache_info() - - # All should default to 1 - assert cache_info["no_len_key"] == 1 - assert cache_info["no_method_key"] == 1 - assert cache_info["int_key"] == 1 + cache_info = cache_manager.get_cache_info() + assert cache_info["int_key"] == expected_int_size # Default for non-len objects + assert cache_info["str_key"] == len(test_str) def test_flask_app_cache_key_constant(self): """Test that the Flask app cache key constant is available.""" assert FLASK_APP_CACHE_KEY == "flask_app" def test_cache_overwrites_existing_value(self): - """Test that setting a cache key twice overwrites the previous value.""" - test_key = "test_key" + """Test that setting a cache key overwrites existing value.""" + cache_manager.set("key", "original_value") + assert cache_manager.get("key") == "original_value" - set_cache(test_key, "first_value") - assert get_cache(test_key) == "first_value" - - set_cache(test_key, "second_value") - assert get_cache(test_key) == "second_value" + cache_manager.set("key", "new_value") + assert cache_manager.get("key") == "new_value" def test_cache_info_empty_cache(self): - """Test get_cache_info with empty cache.""" - clear_all_caches() - cache_info = get_cache_info() + """Test cache info when cache is empty.""" + cache_info = cache_manager.get_cache_info() assert cache_info == {} def test_cache_handles_none_values(self): - """Test that cache can store None values.""" - test_key = "none_key" - set_cache(test_key, None) + """Test that cache can handle None values.""" + cache_manager.set("none_key", None) + result = cache_manager.get("none_key") + assert result is None + + # Verify the key actually exists (not just returning None for missing key) + assert cache_manager.has("none_key") is True + + def test_has_method(self): + """Test the has() method.""" + assert cache_manager.has("nonexistent") is False + + cache_manager.set("test_key", "test_value") + assert cache_manager.has("test_key") is True + + cache_manager.clear("test_key") + assert cache_manager.has("test_key") is False + + def test_size_method(self): + """Test the size() method.""" + empty_cache_size = 0 + single_item_size = 1 + two_items_size = 2 + + assert cache_manager.size() == empty_cache_size + + cache_manager.set("key1", "value1") + assert cache_manager.size() == single_item_size + + cache_manager.set("key2", "value2") + assert cache_manager.size() == two_items_size - # None should be retrievable - assert get_cache(test_key) is None + cache_manager.clear("key1") + assert cache_manager.size() == single_item_size - # Cache info should show it exists - cache_info = get_cache_info() - assert test_key in cache_info + cache_manager.clear_all() + assert cache_manager.size() == empty_cache_size From f34826091afbe0897ba4c92d02a127052210b0c3 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:16:12 +0100 Subject: [PATCH 10/11] eli-391 updating campaign repo and remaining tests --- .../repos/campaign_repo.py | 10 +++--- tests/integration/conftest.py | 12 +++---- .../test_performance_optimizations.py | 35 +++++++++---------- tests/unit/repos/test_campaign_repo.py | 4 +-- 4 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/eligibility_signposting_api/repos/campaign_repo.py b/src/eligibility_signposting_api/repos/campaign_repo.py index 219629e42..2f8af779d 100644 --- a/src/eligibility_signposting_api/repos/campaign_repo.py +++ b/src/eligibility_signposting_api/repos/campaign_repo.py @@ -9,9 +9,7 @@ from eligibility_signposting_api.common.cache_manager import ( CAMPAIGN_CONFIGS_CACHE_KEY, - clear_cache, - get_cache, - set_cache, + cache_manager, ) from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules @@ -47,12 +45,12 @@ def get_campaign_configs(self) -> Generator[CampaignConfig]: yield from self._load_campaign_configs_from_s3() return - cached_configs = get_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + cached_configs = cache_manager.get(CAMPAIGN_CONFIGS_CACHE_KEY) if cached_configs is None: logger.info("Loading campaign configurations from S3 and caching for container reuse") configs = self._load_campaign_configs_from_s3() - set_cache(CAMPAIGN_CONFIGS_CACHE_KEY, configs) + cache_manager.set(CAMPAIGN_CONFIGS_CACHE_KEY, configs) logger.info("Cached campaign configurations", extra={"campaign_count": len(configs)}) yield from configs else: @@ -78,5 +76,5 @@ def clear_campaign_cache(self) -> None: This forces the next call to get_campaign_configs() to reload from S3. Useful for testing or when you need to refresh the data. """ - clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) logger.info("Campaign configurations cache cleared") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 130d8f44e..7545d1db6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,7 +17,7 @@ from httpx import RequestError from yarl import URL -from eligibility_signposting_api.common.cache_manager import clear_all_caches +from eligibility_signposting_api.common.cache_manager import cache_manager from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( CampaignConfig, @@ -486,10 +486,10 @@ def clear_performance_caches(): This ensures test isolation when using the performance-optimized Flask app and campaign configuration caching. """ - clear_all_caches() + cache_manager.clear_all() yield # Optionally clear again after tests complete - clear_all_caches() + cache_manager.clear_all() @pytest.fixture @@ -499,12 +499,12 @@ def clear_campaign_cache_for_test(): This fixture should be used by tests that create specific campaign configurations and expect them to be loaded fresh from S3, not from cache. """ - from eligibility_signposting_api.common.cache_manager import CAMPAIGN_CONFIGS_CACHE_KEY, clear_cache + from eligibility_signposting_api.common.cache_manager import CAMPAIGN_CONFIGS_CACHE_KEY - clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) yield # Optionally clear again after test completes - clear_cache(CAMPAIGN_CONFIGS_CACHE_KEY) + cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) @pytest.fixture(scope="class") diff --git a/tests/integration/test_performance_optimizations.py b/tests/integration/test_performance_optimizations.py index 1ea08ea94..8b9434d4f 100644 --- a/tests/integration/test_performance_optimizations.py +++ b/tests/integration/test_performance_optimizations.py @@ -5,10 +5,7 @@ from eligibility_signposting_api.app import get_or_create_app from eligibility_signposting_api.common.cache_manager import ( FLASK_APP_CACHE_KEY, - clear_all_caches, - clear_cache, - get_cache_info, - set_cache, + cache_manager, ) @@ -18,15 +15,15 @@ class TestPerformanceOptimizations: def test_flask_app_caching(self): """Test that Flask app is cached and reused.""" # Clear all caches first - clear_all_caches() + cache_manager.clear_all() # First call should create and cache the app app1 = get_or_create_app() - cache_info_after_first = get_cache_info() + cache_info_after_first = cache_manager.get_cache_info() # Second call should reuse the cached app app2 = get_or_create_app() - cache_info_after_second = get_cache_info() + cache_info_after_second = cache_manager.get_cache_info() # Verify same instance is returned assert app1 is app2, "Flask app should be reused from cache" @@ -42,14 +39,14 @@ def test_cache_clearing_works(self): """Test that cache clearing functionality works.""" # Set up some cached data app1 = get_or_create_app() - cache_info_before = get_cache_info() + cache_info_before = cache_manager.get_cache_info() # Verify cache has data assert len(cache_info_before) > 0, "Cache should contain Flask app" # Clear all caches - clear_all_caches() - cache_info_after = get_cache_info() + cache_manager.clear_all() + cache_info_after = cache_manager.get_cache_info() # Verify cache is empty assert len(cache_info_after) == 0, "Cache should be empty after clearing" @@ -71,7 +68,7 @@ def test_automatic_cache_clearing_in_tests(self): assert app is not None # Verify Flask app is now cached - cache_info_after = get_cache_info() + cache_info_after = cache_manager.get_cache_info() assert FLASK_APP_CACHE_KEY in cache_info_after def test_clear_cache_specific_key(self): @@ -79,21 +76,23 @@ def test_clear_cache_specific_key(self): # Set up test data test_key = "test_cache_key" test_value = "test_value" - set_cache(test_key, test_value) + cache_manager.set(test_key, test_value) # Verify key exists - cache_info_before = get_cache_info() + cache_info_before = cache_manager.get_cache_info() assert test_key in cache_info_before # Clear specific key (this covers lines 28-29) - clear_cache(test_key) + result = cache_manager.clear(test_key) + assert result is True # Should return True for existing key # Verify key is removed - cache_info_after = get_cache_info() + cache_info_after = cache_manager.get_cache_info() assert test_key not in cache_info_after # Clearing non-existent key should not cause error - clear_cache("non_existent_key") + result = cache_manager.clear("non_existent_key") + assert result is False # Should return False for non-existent key def test_cache_info_with_non_len_objects(self): """Test get_cache_info with objects that don't have __len__ method.""" @@ -106,9 +105,9 @@ def __len__(self): test_key = "no_len_object" no_len_obj = NoLenObject() - set_cache(test_key, no_len_obj) + cache_manager.set(test_key, no_len_obj) # This should handle the TypeError gracefully - cache_info = get_cache_info() + cache_info = cache_manager.get_cache_info() assert test_key in cache_info assert cache_info[test_key] == 1 # Should default to 1 diff --git a/tests/unit/repos/test_campaign_repo.py b/tests/unit/repos/test_campaign_repo.py index c7abef197..27ff61a03 100644 --- a/tests/unit/repos/test_campaign_repo.py +++ b/tests/unit/repos/test_campaign_repo.py @@ -3,7 +3,7 @@ import json from unittest.mock import MagicMock, Mock -from eligibility_signposting_api.common.cache_manager import clear_all_caches +from eligibility_signposting_api.common.cache_manager import cache_manager from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.repos.campaign_repo import BucketName, CampaignRepo from tests.fixtures.builders.model.rule import CampaignConfigFactory @@ -14,7 +14,7 @@ class TestCampaignRepoUnitTests: def setup_method(self): """Set up test environment before each test.""" - clear_all_caches() + cache_manager.clear_all() def test_get_campaign_configs_cache_miss_loads_from_s3(self): """Test that when cache is empty, configurations are loaded from S3 and cached.""" From 60c896be4eefe5a75506b12fdd24c88e892ca2dd Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:01:44 +0100 Subject: [PATCH 11/11] eli-391 reverting caching of campaign config --- .../repos/campaign_repo.py | 49 +---- tests/integration/conftest.py | 30 --- .../in_process/test_eligibility_endpoint.py | 1 - tests/integration/repo/test_campaign_repo.py | 39 +--- tests/unit/repos/test_campaign_repo.py | 205 ------------------ 5 files changed, 4 insertions(+), 320 deletions(-) delete mode 100644 tests/unit/repos/test_campaign_repo.py diff --git a/src/eligibility_signposting_api/repos/campaign_repo.py b/src/eligibility_signposting_api/repos/campaign_repo.py index 2f8af779d..26b701962 100644 --- a/src/eligibility_signposting_api/repos/campaign_repo.py +++ b/src/eligibility_signposting_api/repos/campaign_repo.py @@ -1,22 +1,14 @@ import json -import logging -import os from collections.abc import Generator from typing import Annotated, NewType from botocore.client import BaseClient from wireup import Inject, service -from eligibility_signposting_api.common.cache_manager import ( - CAMPAIGN_CONFIGS_CACHE_KEY, - cache_manager, -) from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules BucketName = NewType("BucketName", str) -logger = logging.getLogger(__name__) - @service class CampaignRepo: @@ -34,47 +26,8 @@ def __init__( self.bucket_name = bucket_name def get_campaign_configs(self) -> Generator[CampaignConfig]: - """Get campaign configurations, using cached data if available. - - Campaign rules are loaded once per Lambda container and cached globally - to improve performance by avoiding repeated S3 reads, unless caching is disabled for testing. - """ - # Check if caching is disabled for tests - if os.getenv("DISABLE_CAMPAIGN_CACHE", "").lower() in ("true", "1", "yes"): - logger.debug("Campaign caching disabled for testing") - yield from self._load_campaign_configs_from_s3() - return - - cached_configs = cache_manager.get(CAMPAIGN_CONFIGS_CACHE_KEY) - - if cached_configs is None: - logger.info("Loading campaign configurations from S3 and caching for container reuse") - configs = self._load_campaign_configs_from_s3() - cache_manager.set(CAMPAIGN_CONFIGS_CACHE_KEY, configs) - logger.info("Cached campaign configurations", extra={"campaign_count": len(configs)}) - yield from configs - else: - logger.debug("Using cached campaign configurations") - yield from cached_configs # type: ignore[misc] - - def _load_campaign_configs_from_s3(self) -> list[CampaignConfig]: - """Load campaign configurations from S3.""" - configs = [] campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name) - for campaign_object in campaign_objects["Contents"]: response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign_object['Key']}") body = response["Body"].read() - config = Rules.model_validate(json.loads(body)).campaign_config - configs.append(config) - - return configs - - def clear_campaign_cache(self) -> None: - """Clear the campaign configurations cache. - - This forces the next call to get_campaign_configs() to reload from S3. - Useful for testing or when you need to refresh the data. - """ - cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) - logger.info("Campaign configurations cache cleared") + yield Rules.model_validate(json.loads(body)).campaign_config diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7545d1db6..b7ee18261 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,7 +17,6 @@ from httpx import RequestError from yarl import URL -from eligibility_signposting_api.common.cache_manager import cache_manager from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( CampaignConfig, @@ -206,7 +205,6 @@ def flask_function(lambda_client: BaseClient, iam_role: str, lambda_zip: Path) - "FIREHOSE_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"), "AWS_REGION": AWS_REGION, "LOG_LEVEL": "DEBUG", - "DISABLE_CAMPAIGN_CACHE": "true", # Disable caching for integration tests } }, ) @@ -479,34 +477,6 @@ def firehose_delivery_stream(firehose_client: BaseClient, audit_bucket: BucketNa ) -@pytest.fixture(scope="class", autouse=True) -def clear_performance_caches(): - """Clear all performance caches before each test class. - - This ensures test isolation when using the performance-optimized - Flask app and campaign configuration caching. - """ - cache_manager.clear_all() - yield - # Optionally clear again after tests complete - cache_manager.clear_all() - - -@pytest.fixture -def clear_campaign_cache_for_test(): - """Clear campaign cache before each test function that needs fresh campaign data. - - This fixture should be used by tests that create specific campaign configurations - and expect them to be loaded fresh from S3, not from cache. - """ - from eligibility_signposting_api.common.cache_manager import CAMPAIGN_CONFIGS_CACHE_KEY - - cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) - yield - # Optionally clear again after test completes - cache_manager.clear(CAMPAIGN_CONFIGS_CACHE_KEY) - - @pytest.fixture(scope="class") def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 120f686d4..5290563f1 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -242,7 +242,6 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 - clear_campaign_cache_for_test, # noqa: ARG002 ): # Given diff --git a/tests/integration/repo/test_campaign_repo.py b/tests/integration/repo/test_campaign_repo.py index 26672f9dd..96742d38a 100644 --- a/tests/integration/repo/test_campaign_repo.py +++ b/tests/integration/repo/test_campaign_repo.py @@ -26,11 +26,10 @@ def test_get_campaign_config(s3_client: BaseClient, rules_bucket: BucketName, ca # Given repo = CampaignRepo(s3_client, rules_bucket) - # Ensure we start with a fresh cache for this test - repo.clear_campaign_cache() - # When - actual = list(repo.get_campaign_configs()) # Then + actual = list(repo.get_campaign_configs()) + + # Then assert_that( actual, has_item( @@ -51,35 +50,3 @@ def test_get_campaign_config(s3_client: BaseClient, rules_bucket: BucketName, ca ) ), ) - - -def test_get_campaign_config_caching_behavior( - s3_client: BaseClient, - rules_bucket: BucketName, - campaign_config: CampaignConfig, # noqa: ARG001 -): - """Test that campaign configurations are properly cached and cache clearing works.""" - # Given - repo = CampaignRepo(s3_client, rules_bucket) - - # Ensure we start with a fresh cache - repo.clear_campaign_cache() - - # When - first call should load from S3 - first_result = list(repo.get_campaign_configs()) - - # When - second call should use cache (this tests the cache hit path) - second_result = list(repo.get_campaign_configs()) - - # Then - both results should be identical - assert len(first_result) == len(second_result) - assert first_result[0].id == second_result[0].id - assert first_result[0].name == second_result[0].name - - # When - clear cache and call again - repo.clear_campaign_cache() - third_result = list(repo.get_campaign_configs()) - - # Then - should still get the same data (reloaded from S3) - assert len(third_result) == len(first_result) - assert third_result[0].id == first_result[0].id diff --git a/tests/unit/repos/test_campaign_repo.py b/tests/unit/repos/test_campaign_repo.py deleted file mode 100644 index 27ff61a03..000000000 --- a/tests/unit/repos/test_campaign_repo.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Unit tests for campaign repository caching functionality.""" - -import json -from unittest.mock import MagicMock, Mock - -from eligibility_signposting_api.common.cache_manager import cache_manager -from eligibility_signposting_api.model.campaign_config import CampaignConfig -from eligibility_signposting_api.repos.campaign_repo import BucketName, CampaignRepo -from tests.fixtures.builders.model.rule import CampaignConfigFactory - - -class TestCampaignRepoUnitTests: - """Unit tests for CampaignRepo focusing on caching logic.""" - - def setup_method(self): - """Set up test environment before each test.""" - cache_manager.clear_all() - - def test_get_campaign_configs_cache_miss_loads_from_s3(self): - """Test that when cache is empty, configurations are loaded from S3 and cached.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - - # Mock S3 response for list_objects - mock_campaign_data = CampaignConfigFactory.build() - campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} - - mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} - - # Mock S3 response for get_object - mock_response = MagicMock() - mock_response.read.return_value = json.dumps(campaign_json).encode() - mock_s3_client.get_object.return_value = {"Body": mock_response} - - repo = CampaignRepo(mock_s3_client, bucket_name) - - # When - configs = list(repo.get_campaign_configs()) - - # Then - assert len(configs) == 1 - assert isinstance(configs[0], CampaignConfig) - assert configs[0].id == mock_campaign_data.id - - # Verify S3 was called - mock_s3_client.list_objects.assert_called_once_with(Bucket=bucket_name) - mock_s3_client.get_object.assert_called_once_with(Bucket=bucket_name, Key="campaign1.json") - - def test_get_campaign_configs_cache_hit_uses_cached_data(self): - """Test that when cache contains data, it's used instead of loading from S3.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - repo = CampaignRepo(mock_s3_client, bucket_name) - - # Load data first to populate cache - mock_campaign_data = CampaignConfigFactory.build() - campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} - - mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps(campaign_json).encode() - mock_s3_client.get_object.return_value = {"Body": mock_response} - - # First call to populate cache - first_configs = list(repo.get_campaign_configs()) - - # Reset mock to verify second call doesn't hit S3 - mock_s3_client.reset_mock() - - # When - second call should use cache - second_configs = list(repo.get_campaign_configs()) - - # Then - assert len(second_configs) == 1 - assert second_configs[0].id == first_configs[0].id - - # Verify S3 was NOT called on second request - mock_s3_client.list_objects.assert_not_called() - mock_s3_client.get_object.assert_not_called() - - def test_get_campaign_configs_multiple_configs(self): - """Test loading multiple campaign configurations via get_campaign_configs.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - - # Create two different campaign configs - campaign1_data = CampaignConfigFactory.build() - campaign2_data = CampaignConfigFactory.build() - - campaign1_json = {"CampaignConfig": campaign1_data.model_dump(by_alias=True)} - campaign2_json = {"CampaignConfig": campaign2_data.model_dump(by_alias=True)} - - mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}, {"Key": "campaign2.json"}]} - - # Set up side_effect for get_object to return different responses - def mock_get_object(**kwargs): - key = kwargs.get("Key") - if key == "campaign1.json": - mock_response = MagicMock() - mock_response.read.return_value = json.dumps(campaign1_json).encode() - return {"Body": mock_response} - if key == "campaign2.json": - mock_response = MagicMock() - mock_response.read.return_value = json.dumps(campaign2_json).encode() - return {"Body": mock_response} - return None - - mock_s3_client.get_object.side_effect = mock_get_object - - repo = CampaignRepo(mock_s3_client, bucket_name) - - # When - configs = list(repo.get_campaign_configs()) - - # Then - expected_count = 2 - assert len(configs) == expected_count - config_ids = [config.id for config in configs] - assert campaign1_data.id in config_ids - assert campaign2_data.id in config_ids - - def test_get_campaign_configs_empty_bucket(self): - """Test loading from an empty S3 bucket.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - - mock_s3_client.list_objects.return_value = {"Contents": []} - - repo = CampaignRepo(mock_s3_client, bucket_name) - - # When - configs = list(repo.get_campaign_configs()) - - # Then - assert len(configs) == 0 - assert configs == [] - - def test_clear_campaign_cache(self): - """Test that clear_campaign_cache removes cached data.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - repo = CampaignRepo(mock_s3_client, bucket_name) - - # Set up mock data - mock_campaign_data = CampaignConfigFactory.build() - campaign_json = {"CampaignConfig": mock_campaign_data.model_dump(by_alias=True)} - - mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "campaign1.json"}]} - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps(campaign_json).encode() - mock_s3_client.get_object.return_value = {"Body": mock_response} - - # Load data to populate cache - first_configs = list(repo.get_campaign_configs()) - assert len(first_configs) == 1 - - # Clear the cache - repo.clear_campaign_cache() - - # Reset mock to track calls after cache clear - mock_s3_client.reset_mock() - - # When - should reload from S3 after cache clear - second_configs = list(repo.get_campaign_configs()) - - # Then - assert len(second_configs) == 1 - - # Verify S3 was called again after cache clear - mock_s3_client.list_objects.assert_called_once() - mock_s3_client.get_object.assert_called_once() - - def test_get_campaign_configs_caches_empty_list(self): - """Test that even an empty result is cached to avoid repeated S3 calls.""" - # Given - mock_s3_client = Mock() - bucket_name = BucketName("test-bucket") - - mock_s3_client.list_objects.return_value = {"Contents": []} - - repo = CampaignRepo(mock_s3_client, bucket_name) - - # When - first call - first_configs = list(repo.get_campaign_configs()) - - # Reset mock to track second call - mock_s3_client.reset_mock() - - # When - second call should use cached empty list - second_configs = list(repo.get_campaign_configs()) - - # Then - assert len(first_configs) == 0 - assert len(second_configs) == 0 - - # Verify S3 was NOT called on second request - mock_s3_client.list_objects.assert_not_called() - mock_s3_client.get_object.assert_not_called()