Skip to content

Commit b0047f8

Browse files
committed
Cache fixes for version changes
1 parent 223d1d5 commit b0047f8

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

src/test/test_solr_cache_failover.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
from datetime import datetime, timedelta
13
import unittest
24
from unittest.mock import MagicMock, patch
35

@@ -38,6 +40,77 @@ def test_disable_and_reenable_on_solr_failure(self):
3840
# One call for the health check, one for the cache query itself
3941
self.assertGreaterEqual(get.call_count, 1)
4042

43+
def test_cache_invalidated_on_major_version_change(self):
44+
cache = SolrResultCache()
45+
cache._solr_available = MagicMock(return_value=True)
46+
cache._package_version = "1.8.1"
47+
48+
cached_data = {
49+
"result": {"foo": "bar"},
50+
"cached_at": datetime.now().isoformat(),
51+
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat(),
52+
"params": {"limit": -1},
53+
"hit_count": 0,
54+
"cache_version": "1.0",
55+
"package_version": "1.8.0",
56+
"ttl_hours": 2160,
57+
}
58+
59+
response_doc = {"response": {"docs": [{"cache_data": json.dumps(cached_data)}]}}
60+
61+
with patch("vfbquery.solr_result_cache.requests.get") as get:
62+
get.return_value = MagicMock(status_code=200, json=lambda: response_doc)
63+
result = cache.get_cached_result("term_info", "FBbt_00000000")
64+
self.assertIsNone(result)
65+
self.assertEqual(get.call_count, 1)
66+
67+
def test_cache_retained_on_patch_version_change(self):
68+
cache = SolrResultCache()
69+
cache._solr_available = MagicMock(return_value=True)
70+
cache._package_version = "1.8.2"
71+
72+
cached_data = {
73+
"result": {"foo": "bar"},
74+
"cached_at": datetime.now().isoformat(),
75+
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat(),
76+
"params": {"limit": -1},
77+
"hit_count": 0,
78+
"cache_version": "1.0",
79+
"package_version": "1.8.1",
80+
"ttl_hours": 2160,
81+
}
82+
83+
response_doc = {"response": {"docs": [{"cache_data": json.dumps(cached_data)}]}}
84+
85+
with patch("vfbquery.solr_result_cache.requests.get") as get:
86+
get.return_value = MagicMock(status_code=200, json=lambda: response_doc)
87+
result = cache.get_cached_result("term_info", "FBbt_00000000")
88+
self.assertEqual(result, {"foo": "bar"})
89+
self.assertEqual(get.call_count, 1)
90+
91+
def test_cache_invalidated_when_cached_version_missing(self):
92+
cache = SolrResultCache()
93+
cache._solr_available = MagicMock(return_value=True)
94+
cache._package_version = "1.8.2"
95+
96+
cached_data = {
97+
"result": {"foo": "bar"},
98+
"cached_at": datetime.now().isoformat(),
99+
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat(),
100+
"params": {"limit": -1},
101+
"hit_count": 0,
102+
"cache_version": "1.0",
103+
"ttl_hours": 2160,
104+
}
105+
106+
response_doc = {"response": {"docs": [{"cache_data": json.dumps(cached_data)}]}}
107+
108+
with patch("vfbquery.solr_result_cache.requests.get") as get:
109+
get.return_value = MagicMock(status_code=200, json=lambda: response_doc)
110+
result = cache.get_cached_result("term_info", "FBbt_00000000")
111+
self.assertIsNone(result)
112+
self.assertEqual(get.call_count, 1)
113+
41114

42115
if __name__ == "__main__":
43116
unittest.main(verbosity=2)

src/vfbquery/solr_result_cache.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json
1414
import os
15+
import re
1516
import requests
1617
import hashlib
1718
import time
@@ -23,6 +24,15 @@
2324
import pandas as pd
2425
from vfbquery.term_info_queries import NumpyEncoder
2526

27+
try:
28+
from importlib.metadata import version as get_distribution_version, PackageNotFoundError
29+
except ImportError:
30+
try:
31+
from importlib_metadata import version as get_distribution_version, PackageNotFoundError
32+
except ImportError:
33+
get_distribution_version = None
34+
PackageNotFoundError = Exception
35+
2636
logger = logging.getLogger(__name__)
2737

2838
@dataclass
@@ -105,9 +115,49 @@ def _create_cache_metadata(self, result: Any, **params) -> Optional[Dict[str, An
105115
"params": params, # Store the parameters used for this query
106116
"hit_count": 0,
107117
"cache_version": "1.0", # For future compatibility
118+
"package_version": self._get_cache_package_version(),
108119
"ttl_hours": self.ttl_hours # Store TTL for debugging
109120
}
110121

122+
def _normalize_version(self, version: Optional[str]) -> Optional[str]:
123+
"""Normalize a version string to major.minor only."""
124+
if not version or not isinstance(version, str):
125+
return None
126+
match = re.match(r'^\s*(\d+)(?:\.(\d+))?', version)
127+
if not match:
128+
return None
129+
major = match.group(1)
130+
minor = match.group(2) or '0'
131+
return f"{major}.{minor}"
132+
133+
def _get_package_version(self) -> Optional[str]:
134+
"""Return the VFBquery package version for cache validation."""
135+
if hasattr(self, '_package_version') and self._package_version is not None:
136+
return self._package_version
137+
138+
version_value = os.getenv('VFBQUERY_VERSION')
139+
if version_value:
140+
self._package_version = version_value
141+
return version_value
142+
143+
if get_distribution_version is None:
144+
self._package_version = None
145+
return None
146+
147+
try:
148+
self._package_version = get_distribution_version('vfbquery')
149+
except PackageNotFoundError:
150+
self._package_version = None
151+
except Exception as e:
152+
logger.warning(f"Could not determine package version for cache validation: {e}")
153+
self._package_version = None
154+
155+
return self._package_version
156+
157+
def _get_cache_package_version(self) -> Optional[str]:
158+
"""Return the normalized package version used for cache invalidation."""
159+
return self._normalize_version(self._get_package_version())
160+
111161
def _solr_available(self) -> bool:
112162
"""Return True if Solr caching looks operational.
113163
@@ -198,6 +248,17 @@ def get_cached_result(self, query_type: str, term_id: str, **params) -> Optional
198248
# Parse the cached metadata and result
199249
cached_data = json.loads(cached_field)
200250

251+
# Check package version before anything else so stale cache is rejected early
252+
current_version = self._get_cache_package_version()
253+
cached_version = self._normalize_version(cached_data.get("package_version") or cached_data.get("version"))
254+
if current_version and cached_version != current_version:
255+
logger.info(
256+
f"Cache invalidated for {query_type}({term_id}) because package major.minor version changed "
257+
f"(cached={cached_version}, current={current_version})"
258+
)
259+
self._clear_expired_cache_document(cache_doc_id)
260+
return None
261+
201262
# Check expiration (3-month max age)
202263
try:
203264
expires_at = datetime.fromisoformat(cached_data["expires_at"].replace('Z', '+00:00'))

0 commit comments

Comments
 (0)