Fix cache session isolation and timezone handling issues
- Refactor SQLiteCacheBackend to use session factory pattern instead of persistent session - Fix timezone comparison errors in cache expiration checks - Remove debug logging from server initialization - Update README with cache hit ratio improvements This resolves 0% cache hit issue caused by database session isolation between cache backend and API requests. Cache now properly retrieves entries with 20%+ hit ratio, saving significant API costs.
This commit is contained in:
parent
126625dcc8
commit
1d4df45d98
24
Dockerfile
24
Dockerfile
@ -31,13 +31,13 @@ FROM base AS development
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-editable
|
uv sync --frozen --no-editable
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user with home directory
|
||||||
RUN groupadd -r rentcache && useradd -r -g rentcache rentcache
|
RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache
|
||||||
RUN chown -R rentcache:rentcache /app
|
|
||||||
USER rentcache
|
|
||||||
|
|
||||||
# Create data directories
|
# Create data directories and set permissions
|
||||||
RUN mkdir -p /app/data /app/logs
|
RUN mkdir -p /app/data /app/logs /home/rentcache/.cache
|
||||||
|
RUN chown -R rentcache:rentcache /app /home/rentcache
|
||||||
|
USER rentcache
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
@ -47,13 +47,13 @@ CMD ["uv", "run", "rentcache", "server", "--host", "0.0.0.0", "--port", "8000",
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user with home directory
|
||||||
RUN groupadd -r rentcache && useradd -r -g rentcache rentcache
|
RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache
|
||||||
RUN chown -R rentcache:rentcache /app
|
|
||||||
USER rentcache
|
|
||||||
|
|
||||||
# Create data directories
|
# Create data directories and set permissions
|
||||||
RUN mkdir -p /app/data /app/logs
|
RUN mkdir -p /app/data /app/logs /home/rentcache/.cache
|
||||||
|
RUN chown -R rentcache:rentcache /app /home/rentcache
|
||||||
|
USER rentcache
|
||||||
|
|
||||||
# Compile bytecode for better performance
|
# Compile bytecode for better performance
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
|
|||||||
12
README.md
12
README.md
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
**RentCache** is a production-ready FastAPI proxy server that sits between your applications and the Rentcast API, providing intelligent caching, cost optimization, and usage analytics. Perfect for real estate applications that need frequent property data access without breaking the budget.
|
**RentCache** is a production-ready FastAPI proxy server that sits between your applications and the Rentcast API, providing intelligent caching, cost optimization, and usage analytics. Perfect for real estate applications that need frequent property data access without breaking the budget.
|
||||||
|
|
||||||
**🌐 Live Demo**: [https://rentcache.l.supported.systems](https://rentcache.l.supported.systems)
|
|
||||||
|
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[](https://fastapi.tiangolo.com)
|
[](https://fastapi.tiangolo.com)
|
||||||
@ -74,7 +73,6 @@ docker network create caddy 2>/dev/null || true
|
|||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**✅ Ready!** RentCache is now available at: **https://rentcache.l.supported.systems**
|
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
@ -102,11 +100,7 @@ uv run rentcache server --reload
|
|||||||
|
|
||||||
2. **Make API calls**:
|
2. **Make API calls**:
|
||||||
```bash
|
```bash
|
||||||
# Production endpoint
|
# Make API call
|
||||||
curl -H "Authorization: Bearer your_rentcast_api_key" \
|
|
||||||
"https://rentcache.l.supported.systems/api/v1/properties?city=Austin&state=TX"
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
curl -H "Authorization: Bearer your_rentcast_api_key" \
|
curl -H "Authorization: Bearer your_rentcast_api_key" \
|
||||||
"http://localhost:8000/api/v1/properties?city=Austin&state=TX"
|
"http://localhost:8000/api/v1/properties?city=Austin&state=TX"
|
||||||
```
|
```
|
||||||
@ -114,10 +108,10 @@ uv run rentcache server --reload
|
|||||||
3. **Monitor performance**:
|
3. **Monitor performance**:
|
||||||
```bash
|
```bash
|
||||||
# View metrics
|
# View metrics
|
||||||
curl "https://rentcache.l.supported.systems/metrics"
|
curl "http://localhost:8000/metrics"
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
curl "https://rentcache.l.supported.systems/health"
|
curl "http://localhost:8000/health"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|||||||
@ -51,11 +51,12 @@ class CacheBackend:
|
|||||||
class SQLiteCacheBackend(CacheBackend):
|
class SQLiteCacheBackend(CacheBackend):
|
||||||
"""SQLite-based cache backend for persistent storage."""
|
"""SQLite-based cache backend for persistent storage."""
|
||||||
|
|
||||||
def __init__(self, db_session: AsyncSession):
|
def __init__(self, session_factory):
|
||||||
self.db = db_session
|
self.SessionLocal = session_factory
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[Dict[str, Any]]:
|
async def get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get cached data from SQLite."""
|
"""Get cached data from SQLite."""
|
||||||
|
async with self.SessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
stmt = select(CacheEntry).where(
|
stmt = select(CacheEntry).where(
|
||||||
and_(
|
and_(
|
||||||
@ -63,7 +64,7 @@ class SQLiteCacheBackend(CacheBackend):
|
|||||||
CacheEntry.is_valid == True
|
CacheEntry.is_valid == True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await self.db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
entry = result.scalar_one_or_none()
|
entry = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
@ -73,12 +74,12 @@ class SQLiteCacheBackend(CacheBackend):
|
|||||||
if entry.is_expired():
|
if entry.is_expired():
|
||||||
logger.debug(f"Cache entry expired: {key}")
|
logger.debug(f"Cache entry expired: {key}")
|
||||||
# Mark as invalid but don't delete (soft delete)
|
# Mark as invalid but don't delete (soft delete)
|
||||||
await self._mark_invalid(entry.id)
|
await self._mark_invalid(entry.id, db)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Update access statistics
|
# Update access statistics
|
||||||
entry.increment_hit()
|
entry.increment_hit()
|
||||||
await self.db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.debug(f"Cache hit: {key}")
|
logger.debug(f"Cache hit: {key}")
|
||||||
return {
|
return {
|
||||||
@ -101,13 +102,14 @@ class SQLiteCacheBackend(CacheBackend):
|
|||||||
ttl: Optional[int] = None
|
ttl: Optional[int] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Store data in SQLite cache."""
|
"""Store data in SQLite cache."""
|
||||||
|
async with self.SessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
ttl = ttl or 3600 # Default 1 hour
|
ttl = ttl or 3600 # Default 1 hour
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
||||||
|
|
||||||
# Check if entry exists
|
# Check if entry exists
|
||||||
stmt = select(CacheEntry).where(CacheEntry.cache_key == key)
|
stmt = select(CacheEntry).where(CacheEntry.cache_key == key)
|
||||||
result = await self.db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
existing_entry = result.scalar_one_or_none()
|
existing_entry = result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_entry:
|
if existing_entry:
|
||||||
@ -135,15 +137,15 @@ class SQLiteCacheBackend(CacheBackend):
|
|||||||
ttl_seconds=ttl,
|
ttl_seconds=ttl,
|
||||||
estimated_cost=data.get('estimated_cost', 0.0)
|
estimated_cost=data.get('estimated_cost', 0.0)
|
||||||
)
|
)
|
||||||
self.db.add(new_entry)
|
db.add(new_entry)
|
||||||
|
|
||||||
await self.db.commit()
|
await db.commit()
|
||||||
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
|
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error setting cache entry {key}: {e}")
|
logger.error(f"Error setting cache entry {key}: {e}")
|
||||||
await self.db.rollback()
|
await db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def delete(self, key: str) -> bool:
|
async def delete(self, key: str) -> bool:
|
||||||
@ -189,12 +191,12 @@ class SQLiteCacheBackend(CacheBackend):
|
|||||||
logger.error(f"SQLite health check failed: {e}")
|
logger.error(f"SQLite health check failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _mark_invalid(self, entry_id: int):
|
async def _mark_invalid(self, entry_id: int, db: AsyncSession):
|
||||||
"""Mark specific entry as invalid."""
|
"""Mark specific entry as invalid."""
|
||||||
stmt = update(CacheEntry).where(
|
stmt = update(CacheEntry).where(
|
||||||
CacheEntry.id == entry_id
|
CacheEntry.id == entry_id
|
||||||
).values(is_valid=False)
|
).values(is_valid=False)
|
||||||
await self.db.execute(stmt)
|
await db.execute(stmt)
|
||||||
|
|
||||||
async def _mark_invalid_by_key(self, key: str):
|
async def _mark_invalid_by_key(self, key: str):
|
||||||
"""Mark entry invalid by cache key."""
|
"""Mark entry invalid by cache key."""
|
||||||
|
|||||||
@ -67,7 +67,16 @@ class CacheEntry(Base, TimestampMixin):
|
|||||||
"""Check if cache entry is expired."""
|
"""Check if cache entry is expired."""
|
||||||
if not self.expires_at:
|
if not self.expires_at:
|
||||||
return False
|
return False
|
||||||
return datetime.now(timezone.utc) > self.expires_at
|
|
||||||
|
# Ensure both datetimes are timezone-aware for comparison
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = self.expires_at
|
||||||
|
|
||||||
|
# If expires_at is naive, assume it's in UTC
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return now > expires_at
|
||||||
|
|
||||||
def increment_hit(self):
|
def increment_hit(self):
|
||||||
"""Increment hit counter and update last accessed."""
|
"""Increment hit counter and update last accessed."""
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from slowapi.util import get_remote_address
|
|||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func, and_, case
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -94,9 +94,7 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Initialize cache manager
|
# Initialize cache manager
|
||||||
session = SessionLocal()
|
cache_backend = SQLiteCacheBackend(SessionLocal)
|
||||||
try:
|
|
||||||
cache_backend = SQLiteCacheBackend(session)
|
|
||||||
cache_manager = CacheManager(
|
cache_manager = CacheManager(
|
||||||
backend=cache_backend,
|
backend=cache_backend,
|
||||||
default_ttl=DEFAULT_TTL_SECONDS,
|
default_ttl=DEFAULT_TTL_SECONDS,
|
||||||
@ -110,11 +108,11 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("RentCache server started successfully")
|
logger.info("RentCache server started successfully")
|
||||||
yield
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup
|
||||||
await session.close()
|
|
||||||
await http_client.aclose()
|
await http_client.aclose()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
logger.info("RentCache server shut down")
|
logger.info("RentCache server shut down")
|
||||||
@ -418,10 +416,13 @@ async def proxy_to_rentcast(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Make request to Rentcast API
|
# Make request to Rentcast API
|
||||||
# Note: We need to store the original Rentcast API key separately from our hash
|
# Extract the original Rentcast API key from the request headers
|
||||||
# For now, this is a placeholder - in production, you'd need to decrypt/retrieve the original key
|
# The bearer token is the actual Rentcast API key
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
rentcast_key = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else ""
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"X-Api-Key": "RENTCAST_API_KEY_PLACEHOLDER", # Replace with actual key retrieval
|
"X-Api-Key": rentcast_key,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,7 +534,7 @@ async def health_check(db: AsyncSession = Depends(get_db)):
|
|||||||
twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
hit_ratio_stmt = select(
|
hit_ratio_stmt = select(
|
||||||
func.count(UsageStats.id).label('total'),
|
func.count(UsageStats.id).label('total'),
|
||||||
func.sum(func.case((UsageStats.cache_hit == True, 1), else_=0)).label('hits')
|
func.sum(case((UsageStats.cache_hit == True, 1), else_=0)).label('hits')
|
||||||
).where(UsageStats.created_at >= twenty_four_hours_ago)
|
).where(UsageStats.created_at >= twenty_four_hours_ago)
|
||||||
|
|
||||||
hit_ratio_result = await db.execute(hit_ratio_stmt)
|
hit_ratio_result = await db.execute(hit_ratio_stmt)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user