Skip to content

[ELI-619] - cache campaign configs unless we send a reserved consumer id#610

Open
TOEL2 wants to merge 7 commits intomainfrom
spike/ELI-619-caching
Open

[ELI-619] - cache campaign configs unless we send a reserved consumer id#610
TOEL2 wants to merge 7 commits intomainfrom
spike/ELI-619-caching

Conversation

@TOEL2
Copy link
Contributor

@TOEL2 TOEL2 commented Mar 19, 2026

Description

I've implemented an idea that looks like it would be a nice easy fit to grab some performance back.

Added some unit tests to show the cache being used and then expiring

Difficult to fully integration test without deploying but I've showed that it works at least (with a little fudging). This involves setting the env variable to "dev" in the docker set up for local runs, just for that commented out int test.

Context

stops fetching from s3 every single request

Type of changes

  • Refactoring (non-breaking change)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would change existing functionality)
  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I am familiar with the contributing guidelines
  • I have followed the code style of the project
  • I have added tests to cover my changes
  • I have updated the documentation accordingly
  • This PR is a result of pair or mob programming

Sensitive Information Declaration

To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including PII (Personal Identifiable Information) / PID (Personal Identifiable Data) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter.

  • I confirm that neither PII/PID nor sensitive data are included in this PR and the codebase changes.

@TOEL2 TOEL2 marked this pull request as ready for review March 19, 2026 02:05
@TOEL2 TOEL2 closed this Mar 19, 2026
@TOEL2 TOEL2 reopened this Mar 19, 2026
@TOEL2 TOEL2 marked this pull request as draft March 19, 2026 02:10
@TOEL2
Copy link
Contributor Author

TOEL2 commented Mar 19, 2026

This is just a poc, will wait to add test coverage to see if people like it

@ayeshalshukri1-nhs
Copy link
Contributor

ayeshalshukri1-nhs commented Mar 19, 2026

I check the updated PR, looks good. Nice solution.

(previous solution used headers for cache enable/disable)

I think, we might was a bit of thought around how we control the test consumer ids.
Right now, you have a hard coded list. which works fine.
Obviously we would need a release to update, but I dont imagine this to change to often to be honest.

When you originally mentioned this idea, before I looked at the PR,
I thought you where going to modify the actual consumer mapping file, with a flag on the consumer which would say cache enabled or not.

Maybe just needs a bit of thought around this hard coded test consumer ids, see if that is what we want. If yes, then all look great to me, nice solution.

@TOEL2 TOEL2 changed the title [ELI-619] - cache campaign configs unless we send a bypass header and not in local [ELI-619] - cache campaign configs unless we send a reserved consumer id Mar 20, 2026
@TOEL2 TOEL2 marked this pull request as ready for review March 20, 2026 11:14
@TOEL2 TOEL2 closed this Mar 20, 2026
@TOEL2 TOEL2 reopened this Mar 20, 2026
@TOEL2
Copy link
Contributor Author

TOEL2 commented Mar 20, 2026

I check the updated PR, looks good. Nice solution.

(previous solution used headers for cache enable/disable)

I think, we might was a bit of thought around how we control the test consumer ids. Right now, you have a hard coded list. which works fine. Obviously we would need a release to update, but I dont imagine this to change to often to be honest.

When you originally mentioned this idea, before I looked at the PR, I thought you where going to modify the actual consumer mapping file, with a flag on the consumer which would say cache enabled or not.

Maybe just needs a bit of thought around this hard coded test consumer ids, see if that is what we want. If yes, then all look great to me, nice solution.

so yes you are right, in the sense that we will have to make sure that the conusmer mapping file uploaded by the regression tests uses consumer ids that match what we have in the reserved ids list.

OR another idea would be to scrap that list and simply do it based on a prefix like "test-{consumer_id}" and then just make sure regression tests use that prefix too

@eddalmond1
Copy link
Collaborator

Worth using cachetools like with src/eligibility_signposting_api/feature_toggle/feature_toggle.py

CONSUMER_MAPPING_FILE_NAME = "consumer_mapping_config.json"
RESERVED_TEST_CONSUMER_IDS = {"test-consumer-1", "test-consumer-2", "test-consumer-3"}

TTL = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could just be env vars

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g.

import os
from typing import Literal

URL_PREFIX = "patient-check"
RULE_STOP_DEFAULT = False
NHS_NUMBER_HEADER = "nhs-login-nhs-number"
CONSUMER_ID = "NHSE-Product-ID"
ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"]
CONSUMER_MAPPING_FILE_NAME = "consumer_mapping_config.json"
RESERVED_TEST_CONSUMER_IDS = {"test-consumer-1", "test-consumer-2", "test-consumer-3"}

CACHE_TTL_SECONDS = int(os.getenv("CONFIG_CACHE_TTL_SECONDS", "1800"))

@@ -1,4 +1,7 @@
import json
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TTLCache would simplify this a bit e.g.

import json
import logging
from collections.abc import Generator
from typing import Annotated, NewType

from aws_xray_sdk.core import xray_recorder
from botocore.client import BaseClient
from cachetools import TTLCache
from wireup import Inject, service

from eligibility_signposting_api.config.constants import CACHE_TTL_SECONDS, RESERVED_TEST_CONSUMER_IDS
from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules

BucketName = NewType("BucketName", str)

logger = logging.getLogger(name)

campaign_config_cache: TTLCache[str, list[CampaignConfig]] = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS)

@service
class CampaignRepo:
"""Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination.

These rules are stored as JSON files in AWS S3."""

def __init__(
    self,
    s3_client: Annotated[BaseClient, Inject(qualifier="s3")],
    bucket_name: Annotated[BucketName, Inject(param="rules_bucket_name")],
) -> None:
    super().__init__()
    self.s3_client = s3_client
    self.bucket_name = bucket_name

def get_campaign_configs(self, consumer_id: str) -> Generator[CampaignConfig, None, None]:
    bypass = consumer_id in RESERVED_TEST_CONSUMER_IDS
    cache_key = "all_campaigns"
    cached = None if bypass else campaign_config_cache.get(cache_key)

    with xray_recorder.in_subsegment("CampaignRepo.get_campaign_configs"):
        if cached is not None:
            logger.info("Using cached campaign configs")
            yield from cached
            return

        logger.info(
            "Refreshing campaign configs from S3 (consumer_id=%s, ttl_seconds=%s)",
            consumer_id,
            CACHE_TTL_SECONDS,
        )
        configs = self._load_campaign_configs_from_s3()

        if not bypass:
            campaign_config_cache[cache_key] = configs

        yield from configs

def _load_campaign_configs_from_s3(self) -> list[CampaignConfig]:
    campaign_configs: list[CampaignConfig] = []

    with xray_recorder.in_subsegment("CampaignRepo.load_campaign_configs_from_s3"):
        with xray_recorder.in_subsegment("list_objects"):
            campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name)

        with xray_recorder.in_subsegment("get_objects"):
            for campaign_object in campaign_objects.get("Contents", []):
                response = self.s3_client.get_object(
                    Bucket=self.bucket_name,
                    Key=f"{campaign_object['Key']}",
                )
                body = response["Body"].read()
                campaign_configs.append(
                    Rules.model_validate(json.loads(body)).campaign_config
                )

    return campaign_configs


import pytest

from eligibility_signposting_api.repos.campaign_repo import CampaignRepo, BucketName
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'd then import campaign_config_cache here

return {"Body": io.BytesIO(json.dumps(payload).encode("utf-8"))}


class TestCampaignRepo:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and can have a fixture to clear the cache e.g.

@pytest.fixture(autouse=True)
def clear_cache(self):
    campaign_config_cache.clear()

self,
repo,
mock_s3_client,
monkeypatch,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... wouldn't need monkeypatch as could then call the cache.clear to force the refresh (later on)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants