Coverage for src/ai_lls_lib/core/cache.py: 100%
65 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-24 12:44 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-24 12:44 +0000
1"""
2DynamoDB cache implementation for phone verifications
3"""
5import threading
6from datetime import UTC, datetime, timedelta
7from typing import Any
9import boto3
10from aws_lambda_powertools import Logger
12from .models import LineType, PhoneVerification, VerificationSource
14logger = Logger()
17class DynamoDBCache:
18 """Cache for phone verification results using DynamoDB with TTL"""
20 def __init__(self, table_name: str, ttl_days: int = 90):
21 self.table_name = table_name
22 self.ttl_days = ttl_days
23 # boto3 resources are not thread-safe. The bulk processor verifies rows from a
24 # worker pool, so each thread gets its own resource and Table, created lazily.
25 self._local = threading.local()
27 @property
28 def dynamodb(self) -> Any:
29 if not hasattr(self._local, "dynamodb"):
30 self._local.dynamodb = boto3.resource("dynamodb")
31 return self._local.dynamodb
33 @property
34 def table(self) -> Any:
35 if not hasattr(self._local, "table"):
36 self._local.table = self.dynamodb.Table(self.table_name)
37 return self._local.table
39 def get(self, phone_number: str) -> PhoneVerification | None:
40 """Get cached verification result"""
41 try:
42 response = self.table.get_item(Key={"phone_number": phone_number})
44 if "Item" not in response:
45 logger.info(f"Cache miss for {phone_number[:6]}***")
46 return None
48 item: dict[str, Any] = response["Item"]
50 if "known_litigator" not in item:
51 # Entry predates litigator tracking - treat as a miss so the
52 # number is re-verified and the entry rewritten with the new schema
53 logger.info(
54 f"Cache entry for {phone_number[:6]}*** predates known_litigator; "
55 "treating as miss"
56 )
57 return None
59 logger.info(f"Cache hit for {phone_number[:6]}***")
61 return PhoneVerification(
62 phone_number=str(item["phone_number"]),
63 line_type=LineType(str(item["line_type"])),
64 dnc=bool(item["dnc"]),
65 known_litigator=bool(item["known_litigator"]),
66 cached=True,
67 verified_at=datetime.fromisoformat(str(item["verified_at"])),
68 source=VerificationSource.CACHE,
69 )
71 except Exception as e:
72 logger.error(f"Cache get error: {str(e)}")
73 return None
75 def set(self, phone_number: str, verification: PhoneVerification) -> None:
76 """Store verification result in cache"""
77 try:
78 ttl = int((datetime.now(UTC) + timedelta(days=self.ttl_days)).timestamp())
80 self.table.put_item(
81 Item={
82 "phone_number": phone_number,
83 "line_type": verification.line_type.value,
84 "dnc": verification.dnc,
85 "known_litigator": verification.known_litigator,
86 "verified_at": verification.verified_at.isoformat(),
87 "source": verification.source.value,
88 "ttl": ttl,
89 }
90 )
92 logger.info(f"Cached result for {phone_number[:6]}***")
94 except Exception as e:
95 logger.error(f"Cache set error: {str(e)}")
96 # Don't fail the request if cache write fails
98 def batch_get(self, phone_numbers: list[str]) -> dict[str, PhoneVerification | None]:
99 """Get multiple cached results"""
100 results: dict[str, PhoneVerification | None] = {}
102 # DynamoDB batch get (max 100 items per request)
103 for i in range(0, len(phone_numbers), 100):
104 batch = phone_numbers[i : i + 100]
106 try:
107 response = self.dynamodb.batch_get_item(
108 RequestItems={
109 self.table_name: {"Keys": [{"phone_number": phone} for phone in batch]}
110 }
111 )
113 legacy_entries = 0
114 for item in response.get("Responses", {}).get(self.table_name, []):
115 if "known_litigator" not in item:
116 # Entry predates litigator tracking - leave as a miss
117 legacy_entries += 1
118 continue
119 phone = str(item["phone_number"])
120 results[phone] = PhoneVerification(
121 phone_number=phone,
122 line_type=LineType(str(item["line_type"])),
123 dnc=bool(item["dnc"]),
124 known_litigator=bool(item["known_litigator"]),
125 cached=True,
126 verified_at=datetime.fromisoformat(str(item["verified_at"])),
127 source=VerificationSource.CACHE,
128 )
130 if legacy_entries:
131 logger.info(
132 f"{legacy_entries} cache entries predate known_litigator; "
133 "treating as misses"
134 )
136 except Exception as e:
137 logger.error(f"Batch cache get error: {str(e)}")
139 # Fill in None for misses
140 for phone in phone_numbers:
141 if phone not in results:
142 results[phone] = None
144 return results