Skip to content

Commit d127cc0

Browse files
authored
feat(cache): add tile cache warming (#87)
1 parent 3714712 commit d127cc0

File tree

3 files changed

+1807
-1534
lines changed

3 files changed

+1807
-1534
lines changed

scripts/register_v1.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,41 @@ def add_thumbnail_asset(item: Item, raster_base: str, collection_id: str) -> Non
261261
logger.debug(f"Added thumbnail asset: {title}")
262262

263263

264+
def warm_thumbnail_cache(item: Item) -> None:
265+
"""Request thumbnail URL to warm the cache.
266+
267+
This makes a single request to the thumbnail asset URL, which triggers
268+
titiler to generate and cache the thumbnail in Redis. Subsequent requests
269+
will get instant responses from the cache.
270+
271+
Failures are logged but don't stop registration - cache warming is best-effort.
272+
"""
273+
thumbnail = item.assets.get("thumbnail")
274+
if not thumbnail or not thumbnail.href:
275+
logger.debug("No thumbnail asset to warm cache")
276+
return
277+
278+
thumbnail_url = thumbnail.href
279+
logger.info(f" 🔥 Warming cache: {thumbnail_url}")
280+
281+
try:
282+
# Make request with generous timeout (first generation can be slow)
283+
with httpx.Client(timeout=60.0, follow_redirects=True) as http:
284+
resp = http.get(thumbnail_url)
285+
resp.raise_for_status()
286+
287+
# Log success with response size
288+
size_kb = len(resp.content) / 1024
289+
logger.info(f" ✅ Cache warmed: {size_kb:.1f} KB thumbnail generated")
290+
291+
except httpx.TimeoutException:
292+
logger.warning(" ⚠️ Cache warming timed out (thumbnail may be very large)")
293+
except httpx.HTTPError as e:
294+
logger.warning(f" ⚠️ Cache warming failed: {e}")
295+
except Exception as e:
296+
logger.warning(f" ⚠️ Cache warming error: {e}")
297+
298+
264299
def add_store_link(item: Item, geozarr_url: str) -> None:
265300
"""Add store link pointing to the root Zarr location.
266301
@@ -658,10 +693,13 @@ def run_registration(
658693
# 9. Add thumbnail asset for STAC browsers
659694
add_thumbnail_asset(item, raster_api_url, collection)
660695

661-
# 10. Add derived_from link to source item
696+
# 10. Warm the thumbnail cache (best-effort, doesn't fail registration)
697+
warm_thumbnail_cache(item)
698+
699+
# 11. Add derived_from link to source item
662700
add_derived_from_link(item, source_url)
663701

664-
# 11. Register to STAC API
702+
# 12. Register to STAC API
665703
client = Client.open(stac_api_url)
666704
upsert_item(client, collection, item)
667705

tests/unit/test_cache_warming.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Unit tests for thumbnail cache warming functionality."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from pystac import Asset, Item
7+
8+
from scripts.register_v1 import warm_thumbnail_cache
9+
10+
11+
class TestWarmThumbnailCache:
12+
"""Tests for warm_thumbnail_cache function."""
13+
14+
@pytest.fixture
15+
def stac_item_with_thumbnail(self):
16+
"""Create STAC item with thumbnail asset."""
17+
item = Item(
18+
id="test-item",
19+
geometry={"type": "Point", "coordinates": [0, 0]},
20+
bbox=[0, 0, 1, 1],
21+
datetime="2025-01-01T00:00:00Z",
22+
properties={},
23+
)
24+
item.add_asset(
25+
"thumbnail",
26+
Asset(
27+
href="https://api.explorer.eopf.copernicus.eu/raster/collections/sentinel-2-l2a/items/test-item/preview?format=png&...",
28+
media_type="image/png",
29+
roles=["thumbnail"],
30+
),
31+
)
32+
return item
33+
34+
@pytest.fixture
35+
def stac_item_without_thumbnail(self):
36+
"""Create STAC item without thumbnail asset."""
37+
item = Item(
38+
id="test-item",
39+
geometry={"type": "Point", "coordinates": [0, 0]},
40+
bbox=[0, 0, 1, 1],
41+
datetime="2025-01-01T00:00:00Z",
42+
properties={},
43+
)
44+
return item
45+
46+
@patch("scripts.register_v1.httpx.Client")
47+
def test_successful_cache_warming(self, mock_client, stac_item_with_thumbnail):
48+
"""Test successful thumbnail cache warming."""
49+
# Setup mock response
50+
mock_response = MagicMock()
51+
mock_response.content = b"fake_image_data" * 1000 # ~13KB
52+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
53+
54+
# Should not raise
55+
warm_thumbnail_cache(stac_item_with_thumbnail)
56+
57+
# Verify HTTP request was made
58+
mock_client.return_value.__enter__.return_value.get.assert_called_once()
59+
call_args = mock_client.return_value.__enter__.return_value.get.call_args
60+
assert "preview" in call_args[0][0]
61+
62+
@patch("scripts.register_v1.httpx.Client")
63+
def test_no_thumbnail_asset(self, mock_client, stac_item_without_thumbnail):
64+
"""Test graceful handling when thumbnail asset doesn't exist."""
65+
warm_thumbnail_cache(stac_item_without_thumbnail)
66+
67+
# Should not make HTTP request
68+
mock_client.return_value.__enter__.return_value.get.assert_not_called()
69+
70+
@patch("scripts.register_v1.httpx.Client")
71+
def test_thumbnail_with_empty_href(self, mock_client):
72+
"""Test handling thumbnail asset with empty href."""
73+
item = Item(
74+
id="test-item",
75+
geometry={"type": "Point", "coordinates": [0, 0]},
76+
bbox=[0, 0, 1, 1],
77+
datetime="2025-01-01T00:00:00Z",
78+
properties={},
79+
)
80+
# Thumbnail with empty href
81+
item.add_asset(
82+
"thumbnail",
83+
Asset(
84+
href="", # Empty string
85+
media_type="image/png",
86+
roles=["thumbnail"],
87+
),
88+
)
89+
90+
warm_thumbnail_cache(item)
91+
92+
# Should not make HTTP request
93+
mock_client.return_value.__enter__.return_value.get.assert_not_called()
94+
95+
@patch("scripts.register_v1.httpx.Client")
96+
def test_http_timeout_handling(self, mock_client, stac_item_with_thumbnail):
97+
"""Test graceful handling of HTTP timeout."""
98+
import httpx
99+
100+
mock_client.return_value.__enter__.return_value.get.side_effect = httpx.TimeoutException(
101+
"Request timed out"
102+
)
103+
104+
# Should not raise - errors are logged and swallowed
105+
warm_thumbnail_cache(stac_item_with_thumbnail)
106+
107+
@patch("scripts.register_v1.httpx.Client")
108+
def test_http_error_handling(self, mock_client, stac_item_with_thumbnail):
109+
"""Test graceful handling of HTTP errors (404, 500, etc.)."""
110+
import httpx
111+
112+
mock_response = MagicMock()
113+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
114+
"404 Not Found", request=MagicMock(), response=MagicMock()
115+
)
116+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
117+
118+
# Should not raise
119+
warm_thumbnail_cache(stac_item_with_thumbnail)
120+
121+
@patch("scripts.register_v1.httpx.Client")
122+
def test_generic_exception_handling(self, mock_client, stac_item_with_thumbnail):
123+
"""Test graceful handling of unexpected exceptions."""
124+
mock_client.return_value.__enter__.return_value.get.side_effect = Exception(
125+
"Unexpected error"
126+
)
127+
128+
# Should not raise
129+
warm_thumbnail_cache(stac_item_with_thumbnail)
130+
131+
@patch("scripts.register_v1.httpx.Client")
132+
def test_uses_correct_timeout(self, mock_client, stac_item_with_thumbnail):
133+
"""Test that HTTP client is configured with 60s timeout."""
134+
warm_thumbnail_cache(stac_item_with_thumbnail)
135+
136+
# Verify Client was instantiated with timeout=60.0
137+
mock_client.assert_called_once_with(timeout=60.0, follow_redirects=True)
138+
139+
@patch("scripts.register_v1.httpx.Client")
140+
def test_follows_redirects(self, mock_client, stac_item_with_thumbnail):
141+
"""Test that HTTP client follows redirects."""
142+
warm_thumbnail_cache(stac_item_with_thumbnail)
143+
144+
# Verify follow_redirects=True
145+
mock_client.assert_called_once_with(timeout=60.0, follow_redirects=True)
146+
147+
148+
class TestCacheWarmingIntegration:
149+
"""Integration tests to verify cache warming is called in registration workflow."""
150+
151+
def test_cache_warming_called_in_correct_order(self):
152+
"""Test that warm_thumbnail_cache is called after add_thumbnail_asset."""
153+
# This test verifies the call order by inspecting the source code structure
154+
# Rather than running the full registration workflow
155+
import inspect
156+
157+
from scripts import register_v1
158+
159+
# Get the source code of run_registration
160+
source = inspect.getsource(register_v1.run_registration)
161+
162+
# Find the positions of the function calls
163+
add_thumbnail_pos = source.find("add_thumbnail_asset(")
164+
warm_cache_pos = source.find("warm_thumbnail_cache(")
165+
166+
# Verify both functions are called
167+
assert add_thumbnail_pos > 0, "add_thumbnail_asset should be called in run_registration"
168+
assert warm_cache_pos > 0, "warm_thumbnail_cache should be called in run_registration"
169+
170+
# Verify cache warming comes after thumbnail creation
171+
assert (
172+
warm_cache_pos > add_thumbnail_pos
173+
), "warm_thumbnail_cache should be called after add_thumbnail_asset"
174+
175+
@patch("scripts.register_v1.logger")
176+
def test_cache_warming_errors_are_logged_not_raised(self, mock_logger):
177+
"""Test that cache warming errors are logged but don't stop execution."""
178+
import httpx
179+
180+
from scripts.register_v1 import warm_thumbnail_cache
181+
182+
# Create item with thumbnail
183+
item = Item(
184+
id="test-item",
185+
geometry={"type": "Point", "coordinates": [0, 0]},
186+
bbox=[0, 0, 1, 1],
187+
datetime="2025-01-01T00:00:00Z",
188+
properties={},
189+
)
190+
item.add_asset(
191+
"thumbnail",
192+
Asset(
193+
href="https://api.example.com/thumbnail.png",
194+
media_type="image/png",
195+
roles=["thumbnail"],
196+
),
197+
)
198+
199+
# Mock httpx to raise an error
200+
with patch("scripts.register_v1.httpx.Client") as mock_client:
201+
mock_client.return_value.__enter__.return_value.get.side_effect = (
202+
httpx.TimeoutException("Timeout")
203+
)
204+
205+
# Should not raise exception
206+
warm_thumbnail_cache(item)
207+
208+
# Should log a warning
209+
assert mock_logger.warning.called, "Should log warning when cache warming fails"
210+
warning_msg = str(mock_logger.warning.call_args)
211+
assert "Cache warming" in warning_msg or "timed out" in warning_msg.lower()
212+
213+
def test_warm_thumbnail_cache_function_signature(self):
214+
"""Test that warm_thumbnail_cache has the expected function signature."""
215+
import inspect
216+
217+
from scripts.register_v1 import warm_thumbnail_cache
218+
219+
sig = inspect.signature(warm_thumbnail_cache)
220+
params = list(sig.parameters.keys())
221+
222+
# Should take exactly one parameter: item
223+
assert len(params) == 1, "warm_thumbnail_cache should take exactly 1 parameter"
224+
assert params[0] == "item", "Parameter should be named 'item'"
225+
226+
# Check return annotation - it can be None, 'None' (string), empty, or NoneType
227+
return_annotation = sig.return_annotation
228+
valid_return_types = [None, inspect.Signature.empty, type(None), "None"]
229+
230+
# For string annotations, also check if it's the string 'None'
231+
assert (
232+
return_annotation in valid_return_types or str(return_annotation) == "None"
233+
), f"Expected return annotation to be None-like, got {return_annotation}"
234+
235+
def test_warm_thumbnail_cache_is_imported(self):
236+
"""Test that warm_thumbnail_cache is available in the module."""
237+
from scripts import register_v1
238+
239+
assert hasattr(
240+
register_v1, "warm_thumbnail_cache"
241+
), "warm_thumbnail_cache should be defined in register_v1"
242+
assert callable(register_v1.warm_thumbnail_cache), "warm_thumbnail_cache should be callable"

0 commit comments

Comments
 (0)