Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ jobs:
ports:
- 9202:9202

redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379

strategy:
matrix:
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"]
Expand Down Expand Up @@ -126,3 +136,6 @@ jobs:
DATABASE_REFRESH: true
ES_VERIFY_CERTS: false
BACKEND: ${{ matrix.backend == 'elasticsearch7' && 'elasticsearch' || matrix.backend == 'elasticsearch8' && 'elasticsearch' || 'opensearch' }}
REDIS_ENABLE: true
REDIS_HOST: localhost
REDIS_PORT: 6379
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ repos:
]
additional_dependencies: [
"types-attrs",
"types-requests"
"types-requests",
"types-redis"
]
- repo: https://github.com/PyCQA/pydocstyle
rev: 6.1.1
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Moved SFEOS Tools to its own repository at [Healy-Hyperspatial/sfeos-tools](https://github.com/Healy-Hyperspatial/sfeos-tools). The CLI package is now maintained separately. [#PR_NUMBER]
- CloudFerro logo to sponsors and supporters list [#485](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/485)
- Latest news section to README [#485](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/485)
- Added Redis caching configuration for navigation pagination support, enabling proper `prev` and `next` links in paginated responses. [#488](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/488)

### Changed

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ docs-image:
.PHONY: docs
docs: docs-image
docker compose -f compose.docs.yml \
run docs
run docs
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,31 @@ You can customize additional settings in your `.env` file:
> [!NOTE]
> The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.

**Redis for Navigation:**
These Redis configuration variables enable proper navigation functionality in STAC FastAPI. The Redis cache stores navigation state for paginated results, allowing the system to maintain previous page links using tokens. The configuration supports either Redis Sentinel or standalone Redis setups.

| Variable | Description | Default | Required |
|-------------------------------|----------------------------------------------------------------------------------------------|--------------------------|---------------------------------------------------------------------------------------------|
| **General** | | | |
| `REDIS_ENABLE` | Enables or disables Redis caching for navigation. Set to `true` to use Redis, or `false` to disable. | `false` | **Required** (determines whether Redis is used at all) |
| **Redis Sentinel** | | | |
| `REDIS_SENTINEL_HOSTS` | Comma-separated list of Redis Sentinel hostnames/IP addresses. | `""` | Conditional (required if using Sentinel) |
| `REDIS_SENTINEL_PORTS` | Comma-separated list of Redis Sentinel ports (must match order). | `"26379"` | Conditional (required if using Sentinel) |
| `REDIS_SENTINEL_MASTER_NAME` | Name of the Redis master node in Sentinel configuration. | `"master"` | Conditional (required if using Sentinel) |
| **Redis** | | | |
| `REDIS_HOST` | Redis server hostname or IP address for Redis configuration. | `""` | Conditional (required for standalone Redis) |
| `REDIS_PORT` | Redis server port for Redis configuration. | `6379` | Conditional (required for standalone Redis) |
| **Both** | | | |
| `REDIS_DB` | Redis database number to use for caching. | `0` (Sentinel) / `0` (Standalone) | Optional |
| `REDIS_MAX_CONNECTIONS` | Maximum number of connections in the Redis connection pool. | `10` | Optional |
| `REDIS_RETRY_TIMEOUT` | Enable retry on timeout for Redis operations. | `true` | Optional |
| `REDIS_DECODE_RESPONSES` | Automatically decode Redis responses to strings. | `true` | Optional |
| `REDIS_CLIENT_NAME` | Client name identifier for Redis connections. | `"stac-fastapi-app"` | Optional |
| `REDIS_HEALTH_CHECK_INTERVAL` | Interval in seconds for Redis health checks. | `30` | Optional |

> [!NOTE]
> Use either the Sentinel configuration (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_PORTS`, `REDIS_SENTINEL_MASTER_NAME`) OR the Redis configuration (`REDIS_HOST`, `REDIS_PORT`), but not both.

## Datetime-Based Index Management

### Overview
Expand Down
19 changes: 19 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ services:
- BACKEND=elasticsearch
- DATABASE_REFRESH=true
- ENABLE_COLLECTIONS_SEARCH_ROUTE=true
- REDIS_ENABLE=true
- REDIS_HOST=redis
- REDIS_PORT=6379
ports:
- "8080:8080"
volumes:
Expand All @@ -31,6 +34,7 @@ services:
- ./esdata:/usr/share/elasticsearch/data
depends_on:
- elasticsearch
- redis
command:
bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app"

Expand Down Expand Up @@ -58,6 +62,9 @@ services:
- BACKEND=opensearch
- STAC_FASTAPI_RATE_LIMIT=200/minute
- ENABLE_COLLECTIONS_SEARCH_ROUTE=true
- REDIS_ENABLE=true
- REDIS_HOST=redis
- REDIS_PORT=6379
ports:
- "8082:8082"
volumes:
Expand All @@ -66,6 +73,7 @@ services:
- ./osdata:/usr/share/opensearch/data
depends_on:
- opensearch
- redis
command:
bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app"

Expand Down Expand Up @@ -96,3 +104,14 @@ services:
- ./opensearch/snapshots:/usr/share/opensearch/snapshots
ports:
- "9202:9202"

redis:
image: redis:7-alpine
hostname: redis
ports:
- "6379:6379"
volumes:
- redis_test_data:/data
command: redis-server
volumes:
redis_test_data:
2 changes: 2 additions & 0 deletions stac_fastapi/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"jsonschema~=4.0.0",
"slowapi~=0.1.9",
]
extra_reqs = {"redis": ["redis~=6.4.0"]}

setup(
name="stac_fastapi_core",
Expand All @@ -43,4 +44,5 @@
packages=find_namespace_packages(),
zip_safe=False,
install_requires=install_requires,
extras_require=extra_reqs,
)
41 changes: 39 additions & 2 deletions stac_fastapi/core/stac_fastapi/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
from stac_fastapi.core.base_settings import ApiBaseSettings
from stac_fastapi.core.datetime_utils import format_datetime_range
from stac_fastapi.core.models.links import PagingLinks
from stac_fastapi.core.redis_utils import redis_pagination_links
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
from stac_fastapi.core.session import Session
from stac_fastapi.core.utilities import filter_fields
from stac_fastapi.core.utilities import filter_fields, get_bool_env
from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient
from stac_fastapi.extensions.core.transaction.request import (
PartialCollection,
Expand Down Expand Up @@ -273,6 +274,7 @@ async def all_collections(
A Collections object containing all the collections in the database and links to various resources.
"""
base_url = str(request.base_url)
redis_enable = get_bool_env("REDIS_ENABLE", default=False)

global_max_limit = (
int(os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT"))
Expand Down Expand Up @@ -428,6 +430,14 @@ async def all_collections(
},
]

if redis_enable:
await redis_pagination_links(
current_url=str(request.url),
token=token,
next_token=next_token,
links=links,
)

Copy link
Collaborator

Choose a reason for hiding this comment

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

this code into a function

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jonhealy1 I have moved this code into handle_pagination_links func that can be reused where needed.

if next_token:
next_link = PagingLinks(next=next_token, request=request).link_next()
links.append(next_link)
Expand Down Expand Up @@ -772,8 +782,8 @@ async def post_search(
search_request.limit = limit

base_url = str(request.base_url)

search = self.database.make_search()
redis_enable = get_bool_env("REDIS_ENABLE", default=False)

if search_request.ids:
search = self.database.apply_ids_filter(
Expand Down Expand Up @@ -877,6 +887,33 @@ async def post_search(
]
links = await PagingLinks(request=request, next=next_token).get_links()

collection_links = []
if search_request.collections:
for collection_id in search_request.collections:
collection_links.extend(
[
{
"rel": "collection",
"type": "application/json",
"href": urljoin(base_url, f"collections/{collection_id}"),
},
{
"rel": "parent",
"type": "application/json",
"href": urljoin(base_url, f"collections/{collection_id}"),
},
]
)
links.extend(collection_links)

if redis_enable:
await redis_pagination_links(
current_url=str(request.url),
token=token_param,
next_token=next_token,
links=links,
)

Copy link
Collaborator

@jonhealy1 jonhealy1 Oct 10, 2025

Choose a reason for hiding this comment

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

can this code - the redis_enabled block - be put into a function? It is used with all_collections too

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The code block was replaced here with func as well. Thank you!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's change the function name for handle_pagination_links - also, mentioned below - and let's check the REDIS IS ENABLED here before we go to the function.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done, thank you!

return stac_types.ItemCollection(
type="FeatureCollection",
features=items,
Expand Down
152 changes: 152 additions & 0 deletions stac_fastapi/core/stac_fastapi/core/redis_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Utilities for connecting to and managing Redis connections."""

import logging
from typing import Optional

from pydantic_settings import BaseSettings
from redis import asyncio as aioredis
from redis.asyncio.sentinel import Sentinel

redis_pool: Optional[aioredis.Redis] = None

logger = logging.getLogger(__name__)


class RedisSentinelSettings(BaseSettings):
"""Configuration for connecting to Redis Sentinel."""

REDIS_SENTINEL_HOSTS: str = ""
REDIS_SENTINEL_PORTS: str = "26379"
Copy link
Collaborator

Choose a reason for hiding this comment

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

These should be lists of strings, along with validators. Validation should be in this class, not in the functions where we use this class.

REDIS_SENTINEL_MASTER_NAME: str = "master"
REDIS_DB: int = 15

REDIS_MAX_CONNECTIONS: int = 10
REDIS_RETRY_TIMEOUT: bool = True
REDIS_DECODE_RESPONSES: bool = True
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
REDIS_HEALTH_CHECK_INTERVAL: int = 30


class RedisSettings(BaseSettings):
"""Configuration for connecting Redis."""

REDIS_HOST: str = ""
REDIS_PORT: int = 6379
REDIS_DB: int = 0

REDIS_MAX_CONNECTIONS: int = 10
REDIS_RETRY_TIMEOUT: bool = True
REDIS_DECODE_RESPONSES: bool = True
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
REDIS_HEALTH_CHECK_INTERVAL: int = 30


# Configure only one Redis configuration
sentinel_settings = RedisSentinelSettings()
standalone_settings = RedisSettings()


async def connect_redis() -> Optional[aioredis.Redis]:
"""Return a Redis connection Redis or Redis Sentinel."""
global redis_pool
Copy link
Collaborator

Choose a reason for hiding this comment

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

Using global variables is an anti-pattern


if redis_pool is not None:
return redis_pool

try:
if sentinel_settings.REDIS_SENTINEL_HOSTS:
hosts = [
h.strip()
for h in sentinel_settings.REDIS_SENTINEL_HOSTS.split(",")
if h.strip()
]
ports = [
int(p.strip())
for p in sentinel_settings.REDIS_SENTINEL_PORTS.split(",")
if p.strip()
]

sentinel = Sentinel(
[(h, p) for h, p in zip(hosts, ports)],
decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
)

redis_pool = sentinel.master_for(
service_name=sentinel_settings.REDIS_SENTINEL_MASTER_NAME,
db=sentinel_settings.REDIS_DB,
decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
retry_on_timeout=sentinel_settings.REDIS_RETRY_TIMEOUT,
client_name=sentinel_settings.REDIS_CLIENT_NAME,
max_connections=sentinel_settings.REDIS_MAX_CONNECTIONS,
health_check_interval=sentinel_settings.REDIS_HEALTH_CHECK_INTERVAL,
)
logger.info("Connected to Redis Sentinel")

elif standalone_settings.REDIS_HOST:
pool = aioredis.ConnectionPool(
host=standalone_settings.REDIS_HOST,
port=standalone_settings.REDIS_PORT,
db=standalone_settings.REDIS_DB,
max_connections=standalone_settings.REDIS_MAX_CONNECTIONS,
decode_responses=standalone_settings.REDIS_DECODE_RESPONSES,
retry_on_timeout=standalone_settings.REDIS_RETRY_TIMEOUT,
health_check_interval=standalone_settings.REDIS_HEALTH_CHECK_INTERVAL,
)
redis_pool = aioredis.Redis(
connection_pool=pool, client_name=standalone_settings.REDIS_CLIENT_NAME
)
logger.info("Connected to Redis")
else:
logger.warning("No Redis configuration found")
return None

return redis_pool

except Exception as e:
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should catch exceptions a bit more narrowly. Catching such general exceptions, you don't know what happened. t might not have been a connection error at all, but something else entirely.

logger.error(f"Failed to connect to Redis: {e}")
redis_pool = None
return None


async def save_self_link(
redis: aioredis.Redis, token: Optional[str], self_href: str
) -> None:
"""Save the self link for the current token with 30 min TTL."""
if token:
await redis.setex(f"nav:self:{token}", 1800, self_href)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't hardcode such variables. They should be set through an environment variable.



async def get_prev_link(redis: aioredis.Redis, token: Optional[str]) -> Optional[str]:
"""Get the previous page link for the current token (if exists)."""
if not token:
return None
return await redis.get(f"nav:self:{token}")


async def redis_pagination_links(
current_url: str, token: str, next_token: str, links: list
) -> None:
"""Handle Redis pagination."""
redis = None
try:
redis = await connect_redis()
logger.info("Redis connection established successfully")
except Exception as e:
redis = None
logger.warning(f"Redis connection failed: {e}")

if redis:
if next_token:
await save_self_link(redis, next_token, current_url)

prev_link = await get_prev_link(redis, token)
if prev_link:
links.insert(
0,
{
"rel": "prev",
"type": "application/json",
"method": "GET",
"href": prev_link,
},
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's change the name of this function to something like redis_pagination_links.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have re named the function to redis_pagination_links, though I preferred the previous name as it was more descriptive. Or evne handle_pagination_links_redis, handle_pagination_links_redis_cache could work, though they might be a too long.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Handle_prev_pagination_links?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I would not restrict the name to prev as potentially, the other missing navigation rels might be handled by this func, and it will need to be renamed again. I think redis_pagination_links is a good option.

2 changes: 2 additions & 0 deletions stac_fastapi/elasticsearch/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
"pre-commit~=3.0.0",
"ciso8601~=2.3.0",
"httpx>=0.24.0,<0.28.0",
"stac-fastapi-core[redis]==6.5.1",
],
"docs": ["mkdocs~=1.4.0", "mkdocs-material~=9.0.0", "pdocs~=1.2.0"],
"server": ["uvicorn[standard]~=0.23.0"],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks good. Here don't change anything. just add in "redis": ["stac-fastapi-core[redis]==6.5.1"] to the extra_reqs. Then someone can go pip install stac-fastapi-elasticsearch[redis] ---- same thing in opensearch setup

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for elaborating @jonhealy1 I have moved "redis": ["stac-fastapi-core[redis]==6.5.1"] into extra_reqs.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks @YuriZmytrakov. I think you forgot to add it to opensearch setup?

Copy link
Collaborator

Choose a reason for hiding this comment

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

This: "stac-fastapi-core[redis]==6.5.1" can be in the "dev" section and the "redis" section if we want.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think there’s no harm in adding it to dev only, so that developers have Redis installed and ready to work with locally. Otherwise, missing Redis dependency error will pop up. A developer might install it manually in their environment, possibly incompatible version. The Redis dependency is small, only about 4.6 MB, so there’s no harm to including it in the dev dependencies. Let me know your thoughts on how to proceed so we can complete this PR.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You can leave it in dev but there needs to be a redis section as well in extra reqs in both es and os

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jonhealy1 added in es/os in extra_reqs and in redis section, thank you!

"redis": ["stac-fastapi-core[redis]==6.5.1"],
}

setup(
Expand Down
Loading