Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NUT-XX: Cached Requests and Responses #624

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9314af5
fast-api-cache setup
lollerfirst Sep 23, 2024
c8ce417
testing the cache
lollerfirst Sep 24, 2024
b713235
fix
lollerfirst Sep 24, 2024
32096d7
Merge remote-tracking branch 'upstream/main' into fast-api-cache
lollerfirst Sep 24, 2024
5e8da30
still not working
lollerfirst Sep 30, 2024
659c60f
asynccontextmanager
lollerfirst Sep 30, 2024
e8ebb84
move test
callebtc Oct 2, 2024
d5ce035
use redis & custom caching setup (like CDK)
lollerfirst Oct 15, 2024
abba428
make format
lollerfirst Oct 15, 2024
bee4823
Merge remote-tracking branch 'origin' into fast-api-cache
lollerfirst Oct 15, 2024
1e98022
poetry lock
lollerfirst Oct 15, 2024
f8ee569
fix format string + log when a cached response is found
lollerfirst Oct 15, 2024
1f5e906
log when a cahced response is found
lollerfirst Oct 15, 2024
5b2217a
Merge remote-tracking branch 'upstream/main' into fast-api-cache
lollerfirst Nov 8, 2024
739ca3c
fix tests
lollerfirst Nov 8, 2024
31804cc
Merge branch 'main' into fast-api-cache
callebtc Nov 24, 2024
8aff496
poetry lock
callebtc Nov 24, 2024
fa1c111
try tests on github
callebtc Nov 24, 2024
1573ce1
use docker compose
callebtc Nov 24, 2024
ac9e3c1
maybe we dont need docker
callebtc Nov 24, 2024
a55165e
Merge branch 'main' into fast-api-cache
callebtc Nov 24, 2024
248b77d
fix types
callebtc Nov 24, 2024
b1e184a
create_task instead of run
callebtc Nov 24, 2024
3289ada
how about we start postgres
callebtc Nov 24, 2024
2907f85
mint features
lollerfirst Nov 24, 2024
1fcd723
format
lollerfirst Nov 24, 2024
aa51f14
remove deprecated setex call
callebtc Nov 24, 2024
2797a32
use global expiry for all cached routes
callebtc Nov 24, 2024
78a03b3
refactor feature map and set default to 1 week
callebtc Nov 24, 2024
31337dc
refactor feature construction
callebtc Nov 24, 2024
431a89b
Merge branch 'main' into fast-api-cache
callebtc Nov 25, 2024
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
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 = 20
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 @@ -78,6 +96,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 @@ -106,3 +129,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
Loading