Skip to content

Commit 48a9b96

Browse files
authored
Merge pull request #610 from NHSDigital/spike/ELI-619-caching
[ELI-619] - cache campaign configs unless we send a reserved consumer id
2 parents f1961d8 + 31e277d commit 48a9b96

11 files changed

Lines changed: 154 additions & 12 deletions

File tree

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pyhamcrest = "^2.1.0"
3535
boto3 = "^1.40.57"
3636
botocore = "^1.40.76"
3737
aws-xray-sdk = "2.15.0"
38+
cachetools = "^7.0.1"
3839

3940
[tool.poetry.group.dev.dependencies]
4041
ruff = "^0.14.10"

src/eligibility_signposting_api/config/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from typing import Literal
23

34
URL_PREFIX = "patient-check"
@@ -6,3 +7,5 @@
67
CONSUMER_ID = "NHSE-Product-ID"
78
ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"]
89
CONSUMER_MAPPING_FILE_NAME = "consumer_mapping_config.json"
10+
11+
CACHE_TTL_SECONDS = int(os.getenv("CONFIG_CACHE_TTL_SECONDS", "1800"))

src/eligibility_signposting_api/repos/campaign_repo.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import json
2+
import logging
23
from collections.abc import Generator
34
from typing import Annotated, NewType
45

56
from aws_xray_sdk.core import xray_recorder
67
from botocore.client import BaseClient
8+
from cachetools import TTLCache
79
from wireup import Inject, service
810

11+
from eligibility_signposting_api.config.constants import CACHE_TTL_SECONDS
912
from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules
1013

1114
BucketName = NewType("BucketName", str)
1215

16+
logger = logging.getLogger(__name__)
17+
18+
campaign_config_cache: TTLCache[str, list[CampaignConfig]] = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS)
19+
1320

1421
@service
1522
class CampaignRepo:
@@ -26,12 +33,43 @@ def __init__(
2633
self.s3_client = s3_client
2734
self.bucket_name = bucket_name
2835

29-
def get_campaign_configs(self) -> Generator[CampaignConfig]:
36+
def get_campaign_configs(self, consumer_id: str) -> Generator[CampaignConfig]:
37+
bypass = "test-" in consumer_id
38+
cache_key = "all_campaigns"
39+
cached = None if bypass else campaign_config_cache.get(cache_key)
40+
3041
with xray_recorder.in_subsegment("CampaignRepo.get_campaign_configs"):
42+
if cached is not None:
43+
logger.info("Using cached campaign configs")
44+
yield from cached
45+
return
46+
47+
logger.info(
48+
"Refreshing campaign configs from S3 (consumer_id=%s, ttl_seconds=%s)",
49+
consumer_id,
50+
CACHE_TTL_SECONDS,
51+
)
52+
configs = self._load_campaign_configs_from_s3()
53+
54+
if not bypass:
55+
campaign_config_cache[cache_key] = configs
56+
57+
yield from configs
58+
59+
def _load_campaign_configs_from_s3(self) -> list[CampaignConfig]:
60+
campaign_configs: list[CampaignConfig] = []
61+
62+
with xray_recorder.in_subsegment("CampaignRepo.load_campaign_configs_from_s3"):
3163
with xray_recorder.in_subsegment("list_objects"):
3264
campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name)
65+
3366
with xray_recorder.in_subsegment("get_objects"):
34-
for campaign_object in campaign_objects["Contents"]:
35-
response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign_object['Key']}")
67+
for campaign_object in campaign_objects.get("Contents", []):
68+
response = self.s3_client.get_object(
69+
Bucket=self.bucket_name,
70+
Key=f"{campaign_object['Key']}",
71+
)
3672
body = response["Body"].read()
37-
yield Rules.model_validate(json.loads(body)).campaign_config
73+
campaign_configs.append(Rules.model_validate(json.loads(body)).campaign_config)
74+
75+
return campaign_configs

src/eligibility_signposting_api/services/eligibility_services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def get_eligibility_status(
5050
except NotFoundError as e:
5151
raise UnknownPersonError from e
5252
else:
53-
campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs())
53+
campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs(consumer_id))
5454
permitted_campaign_configs = self.__collect_permitted_campaign_configs(
5555
campaign_configs, ConsumerId(consumer_id)
5656
)

src/eligibility_signposting_api/views/eligibility.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
Status.not_actionable: eligibility_response.Status.not_actionable,
2828
Status.not_eligible: eligibility_response.Status.not_eligible,
2929
}
30-
3130
logger = logging.getLogger(__name__)
3231

3332
eligibility_blueprint = Blueprint("eligibility", __name__)

tests/docker-compose.mock_aws.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ services:
3333
- FIREHOSE_ENDPOINT=http://moto-server:5000
3434
- KINESIS_ENDPOINT=http://moto-server:5000
3535
- LOG_LEVEL=INFO
36+
#- ENVIRONMENT=dev
3637
entrypoint: /bin/sh
3738
command:
3839
- "-c"

tests/integration/conftest.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from eligibility_signposting_api.model.consumer_mapping import ConsumerCampaign, ConsumerId, ConsumerMapping
3434
from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName
3535
from eligibility_signposting_api.repos import SecretRepo
36-
from eligibility_signposting_api.repos.campaign_repo import BucketName
36+
from eligibility_signposting_api.repos.campaign_repo import BucketName, campaign_config_cache
3737
from eligibility_signposting_api.repos.person_repo import TableName
3838
from tests.fixtures.builders.model import rule
3939
from tests.fixtures.builders.model.rule import RulesMapperFactory
@@ -103,6 +103,11 @@ def moto_server(request: pytest.FixtureRequest) -> URL:
103103
return url
104104

105105

106+
@pytest.fixture(autouse=True)
107+
def clear_cache():
108+
campaign_config_cache.clear()
109+
110+
106111
def is_responsive(url: URL) -> bool:
107112
try:
108113
response = httpx.get(str(url))
@@ -1304,7 +1309,7 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -
13041309

13051310
@pytest.fixture(scope="class")
13061311
def consumer_id() -> ConsumerId:
1307-
return ConsumerId("23-mic7heal-jor6don")
1312+
return ConsumerId("test-23-mic7heal-jor6don")
13081313

13091314

13101315
def create_and_put_consumer_mapping_in_s3(

tests/integration/repo/test_campaign_repo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def test_get_campaign_config(s3_client: BaseClient, rules_bucket: BucketName, ca
2727
repo = CampaignRepo(s3_client, rules_bucket)
2828

2929
# When
30-
actual = list(repo.get_campaign_configs())
30+
actual = list(repo.get_campaign_configs("consumer_id"))
3131

3232
# Then
3333
assert_that(
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import io
2+
import json
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
7+
from eligibility_signposting_api.repos.campaign_repo import BucketName, CampaignRepo, campaign_config_cache
8+
from tests.fixtures.builders.model.rule import CampaignConfigFactory
9+
10+
11+
def make_s3_body(payload: dict):
12+
return {"Body": io.BytesIO(json.dumps(payload).encode("utf-8"))}
13+
14+
15+
class TestCampaignRepo:
16+
@pytest.fixture(autouse=True)
17+
def clear_cache(self):
18+
campaign_config_cache.clear()
19+
20+
@pytest.fixture
21+
def mock_s3_client(self):
22+
return MagicMock()
23+
24+
@pytest.fixture
25+
def repo(self, mock_s3_client):
26+
return CampaignRepo(
27+
s3_client=mock_s3_client,
28+
bucket_name=BucketName("test-bucket"),
29+
)
30+
31+
@pytest.fixture
32+
def rules_payload(self):
33+
campaign_config = CampaignConfigFactory.build()
34+
return {"campaign_config": campaign_config.model_dump(mode="json")}
35+
36+
def test_get_campaign_configs_loads_from_s3(self, repo, mock_s3_client, rules_payload):
37+
mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "rsv.json"}]}
38+
mock_s3_client.get_object.return_value = make_s3_body(rules_payload)
39+
40+
result = list(repo.get_campaign_configs("consumer_id"))
41+
42+
assert len(result) == 1
43+
assert result[0].id == rules_payload["campaign_config"]["id"]
44+
45+
mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket")
46+
mock_s3_client.get_object.assert_called_once_with(
47+
Bucket="test-bucket",
48+
Key="rsv.json",
49+
)
50+
51+
def test_get_campaign_configs_uses_cache_within_ttl(
52+
self,
53+
repo,
54+
mock_s3_client,
55+
):
56+
first_config = CampaignConfigFactory.build(version=1)
57+
58+
mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "rsv.json"}]}
59+
mock_s3_client.get_object.return_value = make_s3_body({"campaign_config": first_config.model_dump(mode="json")})
60+
61+
first = list(repo.get_campaign_configs("consumer_id"))
62+
second = list(repo.get_campaign_configs("consumer_id"))
63+
64+
assert first[0].version == 1
65+
assert second[0].version == 1
66+
assert mock_s3_client.list_objects.call_count == 1
67+
assert mock_s3_client.get_object.call_count == 1
68+
69+
def test_get_campaign_configs_refreshes_after_cache_is_cleared(
70+
self,
71+
repo,
72+
mock_s3_client,
73+
):
74+
first_config = CampaignConfigFactory.build(version=1)
75+
second_config = CampaignConfigFactory.build(version=2)
76+
77+
mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "rsv.json"}]}
78+
mock_s3_client.get_object.side_effect = [
79+
make_s3_body({"campaign_config": first_config.model_dump(mode="json")}),
80+
make_s3_body({"campaign_config": second_config.model_dump(mode="json")}),
81+
]
82+
83+
first = list(repo.get_campaign_configs("consumer_id"))
84+
second = list(repo.get_campaign_configs("consumer_id"))
85+
campaign_config_cache.clear()
86+
third = list(repo.get_campaign_configs("test-consumer-1"))
87+
88+
expected_call_count = 2
89+
90+
assert first[0].version == first_config.version
91+
assert second[0].version == first_config.version
92+
assert third[0].version == second_config.version
93+
assert mock_s3_client.list_objects.call_count == expected_call_count
94+
assert mock_s3_client.get_object.call_count == expected_call_count

0 commit comments

Comments
 (0)