Skip to content

Commit

Permalink
NUT-19: Cached Requests and Responses (#624)
Browse files Browse the repository at this point in the history
* fast-api-cache setup

* testing the cache

* fix

* still not working

* asynccontextmanager

* move test

* use redis & custom caching setup (like CDK)

* make format

* poetry lock

* fix format string + log when a cached response is found

* log when a cahced response is found

* fix tests

* poetry lock

* try tests on github

* use docker compose

* maybe we dont need docker

* fix types

* create_task instead of run

* how about we start postgres

* mint features

* format

* remove deprecated setex call

* use global expiry for all cached routes

* refactor feature map and set default to 1 week

* refactor feature construction

* Cache NUT-19

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
  • Loading branch information
lollerfirst and callebtc authored Dec 3, 2024
1 parent ee90d84 commit 399c201
Show file tree
Hide file tree
Showing 14 changed files with 804 additions and 391 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
jobs:
checks:
uses: ./.github/workflows/checks.yml

tests:
strategy:
fail-fast: false
Expand All @@ -25,6 +26,22 @@ jobs:
poetry-version: ${{ matrix.poetry-version }}
mint-only-deprecated: ${{ matrix.mint-only-deprecated }}
mint-database: ${{ matrix.mint-database }}

tests_redis_cache:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.10"]
poetry-version: ["1.7.1"]
mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"]
uses: ./.github/workflows/tests_redis_cache.yml
with:
os: ${{ matrix.os }}
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
mint-database: ${{ matrix.mint-database }}

regtest:
uses: ./.github/workflows/regtest.yml
strategy:
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/tests_redis_cache.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: tests_redis_cache

on:
workflow_call:
inputs:
python-version:
default: "3.10.4"
type: string
poetry-version:
default: "1.7.1"
type: string
mint-database:
default: ""
type: string
os:
default: "ubuntu-latest"
type: string
mint-only-deprecated:
default: "false"
type: string

jobs:
poetry:
name: Run (db ${{ inputs.mint-database }}, deprecated api ${{ inputs.mint-only-deprecated }})
runs-on: ${{ inputs.os }}
steps:
- name: Start PostgreSQL service
if: contains(inputs.mint-database, 'postgres')
run: |
docker run -d --name postgres -e POSTGRES_USER=cashu -e POSTGRES_PASSWORD=cashu -e POSTGRES_DB=cashu -p 5432:5432 postgres:latest
until docker exec postgres pg_isready; do sleep 1; done
- name: Checkout repository
uses: actions/checkout@v2

- name: Prepare environment
uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
poetry-version: ${{ inputs.poetry-version }}

- name: Start Redis service
run: |
docker compose -f docker/docker-compose.yml up -d redis
- name: Run tests
env:
MINT_BACKEND_BOLT11_SAT: FakeWallet
WALLET_NAME: test_wallet
MINT_HOST: localhost
MINT_PORT: 3337
MINT_TEST_DATABASE: ${{ inputs.mint-database }}
TOR: false
MINT_REDIS_CACHE_ENABLED: true
MINT_REDIS_CACHE_URL: redis://localhost:6379
run: |
poetry run pytest tests/test_mint_api_cache.py -v --cov=mint --cov-report=xml
- name: Stop and clean up Docker Compose
run: |
docker compose -f docker/docker-compose.yml down
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
6 changes: 5 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ repos:
rev: v1.6.0
hooks:
- id: mypy
args: [--ignore-missing, --check-untyped-defs]
args:
- --ignore-missing
- --check-untyped-defs
additional_dependencies:
- types-redis
1 change: 1 addition & 0 deletions cashu/core/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
HTLC_NUT = 14
MPP_NUT = 15
WEBSOCKETS_NUT = 17
CACHE_NUT = 19
7 changes: 7 additions & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ class CoreLightningRestFundingSource(MintSettings):
mint_corelightning_rest_cert: Optional[str] = Field(default=None)


class MintRedisCache(MintSettings):
mint_redis_cache_enabled: bool = Field(default=False)
mint_redis_cache_url: Optional[str] = Field(default=None)
mint_redis_cache_ttl: Optional[int] = Field(default=60 * 60 * 24 * 7) # 1 week


class Settings(
EnvSettings,
LndRPCFundingSource,
Expand All @@ -240,6 +246,7 @@ class Settings(
FakeWalletSettings,
MintLimits,
MintBackends,
MintRedisCache,
MintDeprecationFlags,
MintSettings,
MintInformation,
Expand Down
43 changes: 22 additions & 21 deletions cashu/mint/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import asyncio
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from traceback import print_exception

from fastapi import FastAPI, status
Expand All @@ -10,7 +13,7 @@
from ..core.errors import CashuError
from ..core.logging import configure_logger
from ..core.settings import settings
from .router import router
from .router import redis, router
from .router_deprecated import router_deprecated
from .startup import shutdown_mint as shutdown_mint_init
from .startup import start_mint_init
Expand All @@ -23,27 +26,35 @@

from .middleware import add_middlewares, request_validation_exception_handler

# this errors with the tests but is the appropriate way to handle startup and shutdown
# until then, we use @app.on_event("startup")
# @asynccontextmanager
# async def lifespan(app: FastAPI):
# # startup routines here
# await start_mint_init()
# yield
# # shutdown routines here

@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await start_mint_init()
try:
yield
except asyncio.CancelledError:
# Handle the cancellation gracefully
logger.info("Shutdown process interrupted by CancelledError")
finally:
try:
await redis.disconnect()
await shutdown_mint_init()
except asyncio.CancelledError:
logger.info("CancelledError during shutdown, shutting down forcefully")


def create_app(config_object="core.settings") -> FastAPI:
configure_logger()

app = FastAPI(
title="Nutshell Cashu Mint",
description="Ecash wallet and mint based on the Cashu protocol.",
title="Nutshell Mint",
description="Ecash mint based on the Cashu protocol.",
version=settings.version,
license_info={
"name": "MIT License",
"url": "https://raw.githubusercontent.com/cashubtc/cashu/main/LICENSE",
},
lifespan=lifespan,
)

return app
Expand Down Expand Up @@ -99,13 +110,3 @@ async def catch_exceptions(request: Request, call_next):
else:
app.include_router(router=router, tags=["Mint"])
app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True)


@app.on_event("startup")
async def startup_mint():
await start_mint_init()


@app.on_event("shutdown")
async def shutdown_mint():
await shutdown_mint_init()
67 changes: 67 additions & 0 deletions cashu/mint/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import asyncio
import functools
import json

from fastapi import Request
from loguru import logger
from pydantic import BaseModel
from redis.asyncio import from_url
from redis.exceptions import ConnectionError

from ..core.errors import CashuError
from ..core.settings import settings


class RedisCache:
expiry = settings.mint_redis_cache_ttl

def __init__(self):
if settings.mint_redis_cache_enabled:
if settings.mint_redis_cache_url is None:
raise CashuError("Redis cache url not provided")
self.redis = from_url(settings.mint_redis_cache_url)
asyncio.create_task(self.test_connection())

async def test_connection(self):
# PING
try:
await self.redis.ping()
logger.success("Connected to Redis caching server.")
except ConnectionError as e:
logger.error("Redis connection error.")
raise e

def cache(self):
def passthrough(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logger.trace(f"cache wrapper on route {func.__name__}")
result = await func(*args, **kwargs)
return result

return wrapper

def decorator(func):
@functools.wraps(func)
async def wrapper(request: Request, payload: BaseModel):
logger.trace(f"cache wrapper on route {func.__name__}")
key = request.url.path + payload.json()
logger.trace(f"KEY: {key}")
# Check if we have a value under this key
if await self.redis.exists(key):
logger.trace("Returning a cached response...")
resp = await self.redis.get(key)
if resp:
return json.loads(resp)
else:
raise Exception(f"Found no cached response for key {key}")
result = await func(request, payload)
await self.redis.set(name=key, value=result.json(), ex=self.expiry)
return result

return wrapper

return passthrough if not settings.mint_redis_cache_enabled else decorator

async def disconnect(self):
await self.redis.close()
67 changes: 58 additions & 9 deletions cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
MintMethodSetting,
)
from ..core.nuts import (
CACHE_NUT,
DLEQ_NUT,
FEE_RETURN_NUT,
HTLC_NUT,
Expand All @@ -24,6 +25,15 @@

class LedgerFeatures(SupportsBackends):
def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
mint_features = self.create_mint_features()
mint_features = self.add_supported_features(mint_features)
mint_features = self.add_mpp_features(mint_features)
mint_features = self.add_websocket_features(mint_features)
mint_features = self.add_cache_features(mint_features)

return mint_features

def create_mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
mint_method_settings: List[MintMethodSetting] = []
for method, unit_dict in self.backends.items():
for unit in unit_dict.keys():
Expand All @@ -42,8 +52,6 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
melt_setting.min_amount = 0
melt_method_settings.append(melt_setting)

supported_dict = dict(supported=True)

mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] = {
MINT_NUT: dict(
methods=mint_method_settings,
Expand All @@ -53,15 +61,25 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
methods=melt_method_settings,
disabled=False,
),
STATE_NUT: supported_dict,
FEE_RETURN_NUT: supported_dict,
RESTORE_NUT: supported_dict,
SCRIPT_NUT: supported_dict,
P2PK_NUT: supported_dict,
DLEQ_NUT: supported_dict,
HTLC_NUT: supported_dict,
}
return mint_features

def add_supported_features(
self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]]
):
supported_dict = dict(supported=True)
mint_features[STATE_NUT] = supported_dict
mint_features[FEE_RETURN_NUT] = supported_dict
mint_features[RESTORE_NUT] = supported_dict
mint_features[SCRIPT_NUT] = supported_dict
mint_features[P2PK_NUT] = supported_dict
mint_features[DLEQ_NUT] = supported_dict
mint_features[HTLC_NUT] = supported_dict
return mint_features

def add_mpp_features(
self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]]
):
# signal which method-unit pairs support MPP
mpp_features = []
for method, unit_dict in self.backends.items():
Expand All @@ -72,6 +90,11 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
if mpp_features:
mint_features[MPP_NUT] = dict(methods=mpp_features)

return mint_features

def add_websocket_features(
self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]]
):
# specify which websocket features are supported
# these two are supported by default
websocket_features: Dict[str, List[Dict[str, Union[str, List[str]]]]] = {
Expand Down Expand Up @@ -100,3 +123,29 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
mint_features[WEBSOCKETS_NUT] = websocket_features

return mint_features

def add_cache_features(
self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]]
):
if settings.mint_redis_cache_enabled:
cache_features: dict[str, list[dict[str, str]] | int] = {
"cached_endpoints": [
{
"method": "POST",
"path": "/v1/mint/bolt11",
},
{
"method": "POST",
"path": "/v1/melt/bolt11",
},
{
"method": "POST",
"path": "/v1/swap",
},
]
}
if settings.mint_redis_cache_ttl:
cache_features["ttl"] = settings.mint_redis_cache_ttl

mint_features[CACHE_NUT] = cache_features
return mint_features
Loading

0 comments on commit 399c201

Please sign in to comment.