Skip to content

Commit fb56e51

Browse files
authored
Merge pull request #299 from NHSDigital/feature/eja-eli-391-lambda-performance-improvements
Feature/eja eli 391 lambda performance improvements
2 parents c781442 + 60c896b commit fb56e51

File tree

4 files changed

+351
-1
lines changed

4 files changed

+351
-1
lines changed

src/eligibility_signposting_api/app.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mangum.types import LambdaContext, LambdaEvent
1111

1212
from eligibility_signposting_api import audit, repos, services
13+
from eligibility_signposting_api.common.cache_manager import FLASK_APP_CACHE_KEY, cache_manager
1314
from eligibility_signposting_api.common.error_handler import handle_exception
1415
from eligibility_signposting_api.common.request_validator import validate_request_params
1516
from eligibility_signposting_api.config.config import config
@@ -31,13 +32,27 @@ def main() -> None: # pragma: no cover
3132
app.run(debug=config()["log_level"] == logging.DEBUG)
3233

3334

35+
def get_or_create_app() -> Flask:
36+
"""Get the global Flask app instance, creating it if it doesn't exist.
37+
38+
This ensures the Flask app is initialized only once per Lambda container,
39+
improving performance by avoiding repeated initialization.
40+
"""
41+
app = cache_manager.get(FLASK_APP_CACHE_KEY)
42+
if app is None:
43+
app = create_app()
44+
cache_manager.set(FLASK_APP_CACHE_KEY, app)
45+
logger.info("Flask app initialized and cached for container reuse")
46+
return app # type: ignore[return-value]
47+
48+
3449
@add_lambda_request_id_to_logger()
3550
@tracing_setup()
3651
@log_request_ids_from_headers()
3752
@validate_request_params()
3853
def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover
3954
"""Run the Flask app as an AWS Lambda."""
40-
app = create_app()
55+
app = get_or_create_app()
4156
app.debug = config()["log_level"] == logging.DEBUG
4257
handler = Mangum(WsgiToAsgi(app), lifespan="off")
4358
handler.config["text_mime_types"].append("application/fhir+json")
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Cache management utilities for Lambda container optimization."""
2+
3+
import logging
4+
from typing import TypeVar
5+
6+
logger = logging.getLogger(__name__)
7+
8+
T = TypeVar("T")
9+
10+
11+
class CacheManager:
12+
"""Thread-safe cache manager for Lambda container optimization."""
13+
14+
def __init__(self) -> None:
15+
self._caches: dict[str, object] = {}
16+
self._logger = logging.getLogger(__name__)
17+
18+
def get(self, cache_key: str) -> object | None:
19+
"""Get a value from the cache."""
20+
value = self._caches.get(cache_key)
21+
if value is not None:
22+
self._logger.debug("Cache hit", extra={"cache_key": cache_key})
23+
else:
24+
self._logger.debug("Cache miss", extra={"cache_key": cache_key})
25+
return value
26+
27+
def set(self, cache_key: str, value: object) -> None:
28+
"""Set a value in the cache."""
29+
self._caches[cache_key] = value
30+
self._logger.debug("Cache updated", extra={"cache_key": cache_key})
31+
32+
def clear(self, cache_key: str) -> bool:
33+
"""Clear a specific cache entry. Returns True if entry existed."""
34+
if cache_key in self._caches:
35+
del self._caches[cache_key]
36+
self._logger.info("Cache entry cleared", extra={"cache_key": cache_key})
37+
return True
38+
return False
39+
40+
def clear_all(self) -> None:
41+
"""Clear all cached data."""
42+
self._caches.clear()
43+
self._logger.info("All caches cleared")
44+
45+
def get_cache_info(self) -> dict[str, int]:
46+
"""Get information about current cache state."""
47+
info = {}
48+
for key, value in self._caches.items():
49+
try:
50+
info[key] = len(value) if hasattr(value, "__len__") else 1 # type: ignore[arg-type]
51+
except TypeError:
52+
info[key] = 1
53+
return info
54+
55+
def has(self, cache_key: str) -> bool:
56+
"""Check if a cache key exists."""
57+
return cache_key in self._caches
58+
59+
def size(self) -> int:
60+
"""Get the number of cached items."""
61+
return len(self._caches)
62+
63+
64+
# Global cache manager instance
65+
_cache_manager = CacheManager()
66+
67+
# Export the global cache manager for direct use
68+
cache_manager = _cache_manager
69+
70+
# Cache keys constants
71+
FLASK_APP_CACHE_KEY = "flask_app"
72+
CAMPAIGN_CONFIGS_CACHE_KEY = "campaign_configs"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Test to verify the performance optimization caching is working correctly.
3+
"""
4+
5+
from eligibility_signposting_api.app import get_or_create_app
6+
from eligibility_signposting_api.common.cache_manager import (
7+
FLASK_APP_CACHE_KEY,
8+
cache_manager,
9+
)
10+
11+
12+
class TestPerformanceOptimizations:
13+
"""Tests to verify caching optimizations work correctly."""
14+
15+
def test_flask_app_caching(self):
16+
"""Test that Flask app is cached and reused."""
17+
# Clear all caches first
18+
cache_manager.clear_all()
19+
20+
# First call should create and cache the app
21+
app1 = get_or_create_app()
22+
cache_info_after_first = cache_manager.get_cache_info()
23+
24+
# Second call should reuse the cached app
25+
app2 = get_or_create_app()
26+
cache_info_after_second = cache_manager.get_cache_info()
27+
28+
# Verify same instance is returned
29+
assert app1 is app2, "Flask app should be reused from cache"
30+
31+
# Verify cache contains the Flask app
32+
assert FLASK_APP_CACHE_KEY in cache_info_after_first
33+
assert FLASK_APP_CACHE_KEY in cache_info_after_second
34+
35+
# Cache info should be the same after second call (no new caching)
36+
assert cache_info_after_first == cache_info_after_second
37+
38+
def test_cache_clearing_works(self):
39+
"""Test that cache clearing functionality works."""
40+
# Set up some cached data
41+
app1 = get_or_create_app()
42+
cache_info_before = cache_manager.get_cache_info()
43+
44+
# Verify cache has data
45+
assert len(cache_info_before) > 0, "Cache should contain Flask app"
46+
47+
# Clear all caches
48+
cache_manager.clear_all()
49+
cache_info_after = cache_manager.get_cache_info()
50+
51+
# Verify cache is empty
52+
assert len(cache_info_after) == 0, "Cache should be empty after clearing"
53+
54+
# New app should be different instance
55+
app2 = get_or_create_app()
56+
assert app1 is not app2, "New Flask app should be created after cache clear"
57+
58+
def test_automatic_cache_clearing_in_tests(self):
59+
"""Test that the automatic cache clearing fixture works."""
60+
# This test relies on the clear_performance_caches fixture
61+
# which should clear caches before each test class
62+
63+
# We can't guarantee completely empty cache because of test setup,
64+
# but we can verify the cache clearing mechanism exists
65+
66+
# Create some cached data
67+
app = get_or_create_app()
68+
assert app is not None
69+
70+
# Verify Flask app is now cached
71+
cache_info_after = cache_manager.get_cache_info()
72+
assert FLASK_APP_CACHE_KEY in cache_info_after
73+
74+
def test_clear_cache_specific_key(self):
75+
"""Test clearing a specific cache key and logging."""
76+
# Set up test data
77+
test_key = "test_cache_key"
78+
test_value = "test_value"
79+
cache_manager.set(test_key, test_value)
80+
81+
# Verify key exists
82+
cache_info_before = cache_manager.get_cache_info()
83+
assert test_key in cache_info_before
84+
85+
# Clear specific key (this covers lines 28-29)
86+
result = cache_manager.clear(test_key)
87+
assert result is True # Should return True for existing key
88+
89+
# Verify key is removed
90+
cache_info_after = cache_manager.get_cache_info()
91+
assert test_key not in cache_info_after
92+
93+
# Clearing non-existent key should not cause error
94+
result = cache_manager.clear("non_existent_key")
95+
assert result is False # Should return False for non-existent key
96+
97+
def test_cache_info_with_non_len_objects(self):
98+
"""Test get_cache_info with objects that don't have __len__ method."""
99+
100+
# Set up object that raises TypeError on len() (covers lines 44-45)
101+
class NoLenObject:
102+
def __len__(self):
103+
msg = "This object doesn't support len()"
104+
raise TypeError(msg)
105+
106+
test_key = "no_len_object"
107+
no_len_obj = NoLenObject()
108+
cache_manager.set(test_key, no_len_obj)
109+
110+
# This should handle the TypeError gracefully
111+
cache_info = cache_manager.get_cache_info()
112+
assert test_key in cache_info
113+
assert cache_info[test_key] == 1 # Should default to 1
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Unit tests for cache_manager module."""
2+
3+
from eligibility_signposting_api.common.cache_manager import (
4+
FLASK_APP_CACHE_KEY,
5+
cache_manager,
6+
)
7+
8+
9+
class TestCacheManager:
10+
"""Test the cache manager functionality."""
11+
12+
def setup_method(self):
13+
"""Clean up cache before each test."""
14+
cache_manager.clear_all()
15+
16+
def test_set_and_get_cache(self):
17+
"""Test basic cache set and get operations."""
18+
test_key = "test_key"
19+
test_value = "test_value"
20+
21+
# Set cache
22+
cache_manager.set(test_key, test_value)
23+
24+
# Get cache
25+
result = cache_manager.get(test_key)
26+
assert result == test_value
27+
28+
def test_get_cache_nonexistent_key(self):
29+
"""Test getting a non-existent cache key returns None."""
30+
result = cache_manager.get("non_existent_key")
31+
assert result is None
32+
33+
def test_clear_cache_existing_key(self):
34+
"""Test clearing an existing cache key."""
35+
test_key = "test_key"
36+
test_value = "test_value"
37+
38+
cache_manager.set(test_key, test_value)
39+
assert cache_manager.get(test_key) == test_value
40+
41+
# Clear cache
42+
result = cache_manager.clear(test_key)
43+
assert result is True # Should return True if key existed
44+
assert cache_manager.get(test_key) is None
45+
46+
def test_clear_cache_nonexistent_key(self):
47+
"""Test clearing a non-existent cache key."""
48+
# This should not raise an exception
49+
result = cache_manager.clear("non_existent_key")
50+
assert result is False # Should return False if key didn't exist
51+
52+
def test_clear_all_caches(self):
53+
"""Test clearing all caches."""
54+
cache_manager.set("key1", "value1")
55+
cache_manager.set("key2", "value2")
56+
cache_manager.set("key3", "value3")
57+
58+
# Verify keys exist
59+
assert cache_manager.get("key1") == "value1"
60+
assert cache_manager.get("key2") == "value2"
61+
assert cache_manager.get("key3") == "value3"
62+
63+
# Clear all caches
64+
cache_manager.clear_all()
65+
66+
# Verify all keys are gone
67+
assert cache_manager.get("key1") is None
68+
assert cache_manager.get("key2") is None
69+
assert cache_manager.get("key3") is None
70+
71+
def test_get_cache_info_with_len_objects(self):
72+
"""Test cache info with objects that have __len__."""
73+
test_list = [1, 2, 3, 4, 5]
74+
test_dict = {"a": 1, "b": 2, "c": 3}
75+
76+
cache_manager.set("list_key", test_list)
77+
cache_manager.set("dict_key", test_dict)
78+
79+
cache_info = cache_manager.get_cache_info()
80+
assert cache_info["list_key"] == len(test_list)
81+
assert cache_info["dict_key"] == len(test_dict)
82+
83+
def test_get_cache_info_with_non_len_objects(self):
84+
"""Test cache info with objects that don't have __len__."""
85+
test_int = 42
86+
test_str = "test"
87+
expected_int_size = 1 # Default for non-len objects
88+
89+
cache_manager.set("int_key", test_int)
90+
cache_manager.set("str_key", test_str)
91+
92+
cache_info = cache_manager.get_cache_info()
93+
assert cache_info["int_key"] == expected_int_size # Default for non-len objects
94+
assert cache_info["str_key"] == len(test_str)
95+
96+
def test_flask_app_cache_key_constant(self):
97+
"""Test that the Flask app cache key constant is available."""
98+
assert FLASK_APP_CACHE_KEY == "flask_app"
99+
100+
def test_cache_overwrites_existing_value(self):
101+
"""Test that setting a cache key overwrites existing value."""
102+
cache_manager.set("key", "original_value")
103+
assert cache_manager.get("key") == "original_value"
104+
105+
cache_manager.set("key", "new_value")
106+
assert cache_manager.get("key") == "new_value"
107+
108+
def test_cache_info_empty_cache(self):
109+
"""Test cache info when cache is empty."""
110+
cache_info = cache_manager.get_cache_info()
111+
assert cache_info == {}
112+
113+
def test_cache_handles_none_values(self):
114+
"""Test that cache can handle None values."""
115+
cache_manager.set("none_key", None)
116+
result = cache_manager.get("none_key")
117+
assert result is None
118+
119+
# Verify the key actually exists (not just returning None for missing key)
120+
assert cache_manager.has("none_key") is True
121+
122+
def test_has_method(self):
123+
"""Test the has() method."""
124+
assert cache_manager.has("nonexistent") is False
125+
126+
cache_manager.set("test_key", "test_value")
127+
assert cache_manager.has("test_key") is True
128+
129+
cache_manager.clear("test_key")
130+
assert cache_manager.has("test_key") is False
131+
132+
def test_size_method(self):
133+
"""Test the size() method."""
134+
empty_cache_size = 0
135+
single_item_size = 1
136+
two_items_size = 2
137+
138+
assert cache_manager.size() == empty_cache_size
139+
140+
cache_manager.set("key1", "value1")
141+
assert cache_manager.size() == single_item_size
142+
143+
cache_manager.set("key2", "value2")
144+
assert cache_manager.size() == two_items_size
145+
146+
cache_manager.clear("key1")
147+
assert cache_manager.size() == single_item_size
148+
149+
cache_manager.clear_all()
150+
assert cache_manager.size() == empty_cache_size

0 commit comments

Comments
 (0)