Skip to content

Commit 4eab9bf

Browse files
committed
[ELI-619] - adding a cache with a function to bypass it with headers
1 parent e2493b7 commit 4eab9bf

6 files changed

Lines changed: 111 additions & 5 deletions

File tree

src/eligibility_signposting_api/config/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,11 @@
66
CONSUMER_ID = "NHSE-Product-ID"
77
ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"]
88
CONSUMER_MAPPING_FILE_NAME = "consumer_mapping_config.json"
9+
10+
ttl = {
11+
"local": 0,
12+
"test": 300,
13+
"dev": 300,
14+
"preprod": 300,
15+
"prod": 300,
16+
}

src/eligibility_signposting_api/repos/campaign_repo.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import json
2+
import logging
3+
import os
4+
import time
25
from collections.abc import Generator
36
from typing import Annotated, NewType
47

@@ -7,9 +10,11 @@
710
from wireup import Inject, service
811

912
from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules
13+
from eligibility_signposting_api.config.constants import ttl
1014

1115
BucketName = NewType("BucketName", str)
1216

17+
logger = logging.getLogger(__name__)
1318

1419
@service
1520
class CampaignRepo:
@@ -25,13 +30,55 @@ def __init__(
2530
super().__init__()
2631
self.s3_client = s3_client
2732
self.bucket_name = bucket_name
33+
self._campaign_configs_cache: list[CampaignConfig] | None = None
34+
self._cache_expiry_epoch: float = 0.0
35+
self._cache_ttl_seconds: int = int(ttl.get(os.getenv("ENVIRONMENT"), 0))
36+
37+
def get_campaign_configs(self, bypass_cache: bool = False) -> Generator[CampaignConfig, None, None]:
38+
now = time.time()
39+
cache_enabled = self._cache_ttl_seconds > 0
40+
cache_valid = (
41+
cache_enabled
42+
and not bypass_cache
43+
and self._campaign_configs_cache is not None
44+
and now < self._cache_expiry_epoch
45+
)
2846

29-
def get_campaign_configs(self) -> Generator[CampaignConfig]:
3047
with xray_recorder.in_subsegment("CampaignRepo.get_campaign_configs"):
48+
if cache_valid:
49+
logger.info("Using cached campaign configs")
50+
yield from self._campaign_configs_cache
51+
return
52+
53+
logger.info(
54+
"Refreshing campaign configs from S3 (bypass_cache=%s, ttl_seconds=%s)",
55+
bypass_cache,
56+
self._cache_ttl_seconds,
57+
)
58+
campaign_configs = self._load_campaign_configs_from_s3()
59+
60+
if cache_enabled and not bypass_cache:
61+
self._campaign_configs_cache = campaign_configs
62+
self._cache_expiry_epoch = now + self._cache_ttl_seconds
63+
64+
yield from campaign_configs
65+
66+
def _load_campaign_configs_from_s3(self) -> list[CampaignConfig]:
67+
campaign_configs: list[CampaignConfig] = []
68+
69+
with xray_recorder.in_subsegment("CampaignRepo.load_campaign_configs_from_s3"):
3170
with xray_recorder.in_subsegment("list_objects"):
3271
campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name)
72+
3373
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']}")
74+
for campaign_object in campaign_objects.get("Contents", []):
75+
response = self.s3_client.get_object(
76+
Bucket=self.bucket_name,
77+
Key=f"{campaign_object['Key']}",
78+
)
3679
body = response["Body"].read()
37-
yield Rules.model_validate(json.loads(body)).campaign_config
80+
campaign_configs.append(
81+
Rules.model_validate(json.loads(body)).campaign_config
82+
)
83+
84+
return campaign_configs

src/eligibility_signposting_api/services/eligibility_services.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def get_eligibility_status(
4242
conditions: list[str],
4343
category: str,
4444
consumer_id: str,
45+
bypass_campaign_config_cache: bool = False,
4546
) -> eligibility_status.EligibilityStatus:
4647
"""Calculate a person's eligibility for vaccination given an NHS number."""
4748
if nhs_number:
@@ -50,7 +51,11 @@ def get_eligibility_status(
5051
except NotFoundError as e:
5152
raise UnknownPersonError from e
5253
else:
53-
campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs())
54+
campaign_configs: list[CampaignConfig] = list(
55+
self.campaign_repo.get_campaign_configs(
56+
bypass_cache=bypass_campaign_config_cache
57+
)
58+
)
5459
permitted_campaign_configs = self.__collect_permitted_campaign_configs(
5560
campaign_configs, ConsumerId(consumer_id)
5661
)

src/eligibility_signposting_api/views/eligibility.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
Status.not_eligible: eligibility_response.Status.not_eligible,
2929
}
3030

31+
BYPASS_CAMPAIGN_CONFIG_CACHE_HEADER = "X-Bypass-Campaign-Config-Cache"
32+
3133
logger = logging.getLogger(__name__)
3234

3335
eligibility_blueprint = Blueprint("eligibility", __name__)
@@ -53,6 +55,7 @@ def check_eligibility(
5355

5456
query_params = _get_or_default_query_params()
5557
consumer_id = _get_consumer_id_from_headers()
58+
bypass_campaign_config_cache = _should_bypass_campaign_config_cache()
5659

5760
try:
5861
eligibility_status = eligibility_service.get_eligibility_status(
@@ -61,6 +64,7 @@ def check_eligibility(
6164
query_params["conditions"],
6265
query_params["category"],
6366
consumer_id,
67+
bypass_campaign_config_cache=bypass_campaign_config_cache,
6468
)
6569
except UnknownPersonError:
6670
return handle_unknown_person_error(nhs_number)
@@ -77,6 +81,11 @@ def _get_consumer_id_from_headers() -> ConsumerId:
7781
return ConsumerId(request.headers.get(CONSUMER_ID, ""))
7882

7983

84+
def _should_bypass_campaign_config_cache() -> bool:
85+
value = request.headers.get(BYPASS_CAMPAIGN_CONFIG_CACHE_HEADER, "")
86+
return value.lower() == "true"
87+
88+
8089
def _get_or_default_query_params() -> dict[str, Any]:
8190
default_query_params = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"}
8291

tests/integration/lambda/test_app_running_as_lambda.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,36 @@ def test_status_end_point(invoke_with_mock_apigw_request):
726726
)
727727
),
728728
)
729+
730+
def test_cache_bypass( # noqa: PLR0913
731+
lambda_client: BaseClient, # noqa:ARG001
732+
persisted_person: NHSNumber,
733+
rsv_campaign_config: CampaignConfig,
734+
consumer_to_active_rsv_campaign_mapping: ConsumerMapping, # noqa: ARG001
735+
consumer_id: ConsumerId,
736+
s3_client: BaseClient,
737+
audit_bucket: BucketName,
738+
invoke_with_mock_apigw_request,
739+
lambda_logs: Callable[[], list[str]],
740+
secretsmanager_client: BaseClient, # noqa:ARG001
741+
):
742+
# Given
743+
invoke_path = f"/patient-check/{persisted_person}"
744+
headers = {
745+
"nhs-login-nhs-number": str(persisted_person),
746+
"x_request_id": "x_request_id",
747+
"x_correlation_id": "x_correlation_id",
748+
"nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods",
749+
"nhsd-application-id": "nhsd-application-id",
750+
"NHSE-Product-ID": consumer_id,
751+
}
752+
params = {"includeActions": "Y"}
753+
754+
# When
755+
response = invoke_with_mock_apigw_request(path=invoke_path, headers=headers, params=params)
756+
757+
# Then
758+
assert_that(
759+
response,
760+
is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))),
761+
)

tests/unit/views/test_eligibility.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def get_eligibility_status(
6262
_conditions: list[str],
6363
_category: str,
6464
_consumer_id: str,
65+
bypass_campaign_config_cache: bool = False,
6566
) -> EligibilityStatus:
6667
return EligibilityStatusFactory.build()
6768

@@ -77,6 +78,7 @@ def get_eligibility_status(
7778
_conditions: list[str],
7879
_category: str,
7980
_consumer_id: str,
81+
bypass_campaign_config_cache: bool = False,
8082
) -> EligibilityStatus:
8183
raise UnknownPersonError
8284

@@ -91,6 +93,8 @@ def get_eligibility_status(
9193
_include_actions: str,
9294
_conditions: list[str],
9395
_category: str,
96+
_consumer_id: str,
97+
bypass_campaign_config_cache: bool = False,
9498
) -> EligibilityStatus:
9599
raise ValueError
96100

0 commit comments

Comments
 (0)