Skip to content

Commit 936f338

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
feat: Add Redis caching for navigation
Implement Redis caching to support proper pagination navigation in STAC FastAPI. - Adds Redis configuration for both Sentinel and standalone Redis setups - Caches pagination tokens - Enables prev/next links in paginated responses Environment variables provided for flexible deployment configurations.
1 parent 18185f3 commit 936f338

File tree

5 files changed

+212
-1
lines changed

5 files changed

+212
-1
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ repos:
3131
]
3232
additional_dependencies: [
3333
"types-attrs",
34-
"types-requests"
34+
"types-requests",
35+
"types-redis"
3536
]
3637
- repo: https://github.com/PyCQA/pydocstyle
3738
rev: 6.1.1

dockerfiles/Dockerfile.dev.es

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ COPY . /app
1818
RUN pip install --no-cache-dir -e ./stac_fastapi/core
1919
RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers
2020
RUN pip install --no-cache-dir -e ./stac_fastapi/elasticsearch[dev,server]
21+
RUN pip install --no-cache-dir redis types-redis

stac_fastapi/core/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"pygeofilter~=0.3.1",
2020
"jsonschema~=4.0.0",
2121
"slowapi~=0.1.9",
22+
"redis==6.4.0",
2223
]
2324

2425
setup(

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
from stac_fastapi.core.base_settings import ApiBaseSettings
2525
from stac_fastapi.core.datetime_utils import format_datetime_range
2626
from stac_fastapi.core.models.links import PagingLinks
27+
from stac_fastapi.core.redis_utils import (
28+
connect_redis_sentinel,
29+
get_prev_link,
30+
save_self_link,
31+
)
2732
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
2833
from stac_fastapi.core.session import Session
2934
from stac_fastapi.core.utilities import filter_fields
@@ -268,6 +273,7 @@ async def all_collections(
268273
Returns:
269274
A Collections object containing all the collections in the database and links to various resources.
270275
"""
276+
request = kwargs["request"]
271277
base_url = str(request.base_url)
272278

273279
# Get the global limit from environment variable
@@ -333,6 +339,12 @@ async def all_collections(
333339
if q is not None:
334340
q_list = [q] if isinstance(q, str) else q
335341

342+
current_url = str(request.url)
343+
redis = None
344+
try:
345+
redis = await connect_redis_sentinel()
346+
except Exception:
347+
redis = None
336348
# Parse the query parameter if provided
337349
parsed_query = None
338350
if query is not None:
@@ -426,6 +438,21 @@ async def all_collections(
426438
},
427439
]
428440

441+
if redis:
442+
if next_token:
443+
await save_self_link(redis, next_token, current_url)
444+
445+
prev_link = await get_prev_link(redis, token)
446+
if prev_link:
447+
links.insert(
448+
0,
449+
{
450+
"rel": "previous",
451+
"type": "application/json",
452+
"method": "GET",
453+
"href": prev_link,
454+
},
455+
)
429456
if next_token:
430457
next_link = PagingLinks(next=next_token, request=request).link_next()
431458
links.append(next_link)
@@ -744,6 +771,10 @@ async def post_search(
744771
HTTPException: If there is an error with the cql2_json filter.
745772
"""
746773
base_url = str(request.base_url)
774+
try:
775+
redis = await connect_redis_sentinel()
776+
except Exception:
777+
redis = None
747778

748779
search = self.database.make_search()
749780

@@ -850,6 +881,60 @@ async def post_search(
850881
]
851882
links = await PagingLinks(request=request, next=next_token).get_links()
852883

884+
collection_links = []
885+
if (
886+
items
887+
and search_request.collections
888+
and len(search_request.collections) == 1
889+
):
890+
collection_id = search_request.collections[0]
891+
collection_links.extend(
892+
[
893+
{
894+
"rel": "collection",
895+
"type": "application/json",
896+
"href": urljoin(base_url, f"collections/{collection_id}"),
897+
},
898+
{
899+
"rel": "parent",
900+
"type": "application/json",
901+
"href": urljoin(base_url, f"collections/{collection_id}"),
902+
},
903+
]
904+
)
905+
links.extend(collection_links)
906+
907+
if redis:
908+
self_link = str(request.url)
909+
await save_self_link(redis, next_token, self_link)
910+
911+
prev_link = await get_prev_link(redis, token_param)
912+
if prev_link:
913+
method = "GET"
914+
body = None
915+
for link in links:
916+
if link.get("rel") == "next":
917+
if "method" in link:
918+
method = link["method"]
919+
if "body" in link:
920+
body = {**link["body"]}
921+
body.pop("token", None)
922+
break
923+
else:
924+
method = request.method
925+
926+
prev_link_data = {
927+
"rel": "previous",
928+
"type": "application/json",
929+
"method": method,
930+
"href": prev_link,
931+
}
932+
933+
if body:
934+
prev_link_data["body"] = body
935+
936+
links.insert(0, prev_link_data)
937+
853938
return stac_types.ItemCollection(
854939
type="FeatureCollection",
855940
features=items,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Utilities for connecting to and managing Redis connections."""
2+
3+
from typing import Optional
4+
5+
from pydantic_settings import BaseSettings
6+
from redis import asyncio as aioredis
7+
from redis.asyncio.sentinel import Sentinel
8+
9+
redis_pool: Optional[aioredis.Redis] = None
10+
11+
12+
class RedisSentinelSettings(BaseSettings):
13+
"""Configuration for connecting to Redis Sentinel."""
14+
15+
REDIS_SENTINEL_HOSTS: str = ""
16+
REDIS_SENTINEL_PORTS: str = "26379"
17+
REDIS_SENTINEL_MASTER_NAME: str = "master"
18+
REDIS_DB: int = 0
19+
20+
REDIS_MAX_CONNECTIONS: int = 10
21+
REDIS_RETRY_TIMEOUT: bool = True
22+
REDIS_DECODE_RESPONSES: bool = True
23+
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
24+
REDIS_HEALTH_CHECK_INTERVAL: int = 30
25+
26+
27+
class RedisSettings(BaseSettings):
28+
"""Configuration for connecting Redis."""
29+
30+
REDIS_HOST: str = ""
31+
REDIS_PORT: int = 6379
32+
REDIS_DB: int = 0
33+
34+
REDIS_MAX_CONNECTIONS: int = 10
35+
REDIS_RETRY_TIMEOUT: bool = True
36+
REDIS_DECODE_RESPONSES: bool = True
37+
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
38+
REDIS_HEALTH_CHECK_INTERVAL: int = 30
39+
40+
41+
# Select the Redis or Redis Sentinel configuration
42+
redis_settings: BaseSettings = RedisSentinelSettings()
43+
44+
45+
async def connect_redis_sentinel(
46+
settings: Optional[RedisSentinelSettings] = None,
47+
) -> Optional[aioredis.Redis]:
48+
"""Return Redis Sentinel connection."""
49+
global redis_pool
50+
settings = settings or redis_settings
51+
52+
if (
53+
not settings.REDIS_SENTINEL_HOSTS
54+
or not settings.REDIS_SENTINEL_PORTS
55+
or not settings.REDIS_SENTINEL_MASTER_NAME
56+
):
57+
return None
58+
59+
hosts = [h.strip() for h in settings.REDIS_SENTINEL_HOSTS.split(",") if h.strip()]
60+
ports = [
61+
int(p.strip()) for p in settings.REDIS_SENTINEL_PORTS.split(",") if p.strip()
62+
]
63+
64+
if redis_pool is None:
65+
try:
66+
sentinel = Sentinel(
67+
[(host, port) for host, port in zip(hosts, ports)],
68+
decode_responses=settings.REDIS_DECODE_RESPONSES,
69+
)
70+
master = sentinel.master_for(
71+
service_name=settings.REDIS_SENTINEL_MASTER_NAME,
72+
db=settings.REDIS_DB,
73+
decode_responses=settings.REDIS_DECODE_RESPONSES,
74+
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
75+
client_name=settings.REDIS_CLIENT_NAME,
76+
max_connections=settings.REDIS_MAX_CONNECTIONS,
77+
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
78+
)
79+
redis_pool = master
80+
81+
except Exception:
82+
return None
83+
84+
return redis_pool
85+
86+
87+
async def connect_redis(settings: Optional[RedisSettings] = None) -> aioredis.Redis:
88+
"""Return Redis connection."""
89+
global redis_pool
90+
settings = settings or redis_settings
91+
92+
if not settings.REDIS_HOST or not settings.REDIS_PORT:
93+
return None
94+
95+
if redis_pool is None:
96+
pool = aioredis.ConnectionPool(
97+
host=settings.REDIS_HOST,
98+
port=settings.REDIS_PORT,
99+
db=settings.REDIS_DB,
100+
max_connections=settings.REDIS_MAX_CONNECTIONS,
101+
decode_responses=settings.REDIS_DECODE_RESPONSES,
102+
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
103+
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
104+
)
105+
redis_pool = aioredis.Redis(
106+
connection_pool=pool, client_name=settings.REDIS_CLIENT_NAME
107+
)
108+
return redis_pool
109+
110+
111+
async def save_self_link(
112+
redis: aioredis.Redis, token: Optional[str], self_href: str
113+
) -> None:
114+
"""Add the self link for next page as prev link for the current token."""
115+
if token:
116+
await redis.setex(f"nav:self:{token}", 1800, self_href)
117+
118+
119+
async def get_prev_link(redis: aioredis.Redis, token: Optional[str]) -> Optional[str]:
120+
"""Pull the prev page link for the current token."""
121+
if not token:
122+
return None
123+
return await redis.get(f"nav:self:{token}")

0 commit comments

Comments
 (0)