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:
Ryan Malloy 2025-11-15 10:07:25 -07:00
parent 126625dcc8
commit 1d4df45d98
5 changed files with 133 additions and 127 deletions

View File

@ -31,13 +31,13 @@ FROM base AS development
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-editable
# Create non-root user
RUN groupadd -r rentcache && useradd -r -g rentcache rentcache
RUN chown -R rentcache:rentcache /app
USER rentcache
# Create non-root user with home directory
RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache
# Create data directories
RUN mkdir -p /app/data /app/logs
# Create data directories and set permissions
RUN mkdir -p /app/data /app/logs /home/rentcache/.cache
RUN chown -R rentcache:rentcache /app /home/rentcache
USER rentcache
EXPOSE 8000
@ -47,13 +47,13 @@ CMD ["uv", "run", "rentcache", "server", "--host", "0.0.0.0", "--port", "8000",
# Production stage
FROM base AS production
# Create non-root user
RUN groupadd -r rentcache && useradd -r -g rentcache rentcache
RUN chown -R rentcache:rentcache /app
USER rentcache
# Create non-root user with home directory
RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache
# Create data directories
RUN mkdir -p /app/data /app/logs
# Create data directories and set permissions
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
ENV UV_COMPILE_BYTECODE=1

View File

@ -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.
**🌐 Live Demo**: [https://rentcache.l.supported.systems](https://rentcache.l.supported.systems)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-00a393.svg)](https://fastapi.tiangolo.com)
@ -74,7 +73,6 @@ docker network create caddy 2>/dev/null || true
docker compose up --build -d
```
**✅ Ready!** RentCache is now available at: **https://rentcache.l.supported.systems**
### Local Development
@ -102,11 +100,7 @@ uv run rentcache server --reload
2. **Make API calls**:
```bash
# Production endpoint
curl -H "Authorization: Bearer your_rentcast_api_key" \
"https://rentcache.l.supported.systems/api/v1/properties?city=Austin&state=TX"
# Local development
# Make API call
curl -H "Authorization: Bearer your_rentcast_api_key" \
"http://localhost:8000/api/v1/properties?city=Austin&state=TX"
```
@ -114,10 +108,10 @@ uv run rentcache server --reload
3. **Monitor performance**:
```bash
# View metrics
curl "https://rentcache.l.supported.systems/metrics"
curl "http://localhost:8000/metrics"
# Health check
curl "https://rentcache.l.supported.systems/health"
curl "http://localhost:8000/health"
```
## 📚 Documentation

View File

@ -51,49 +51,50 @@ class CacheBackend:
class SQLiteCacheBackend(CacheBackend):
"""SQLite-based cache backend for persistent storage."""
def __init__(self, db_session: AsyncSession):
self.db = db_session
def __init__(self, session_factory):
self.SessionLocal = session_factory
async def get(self, key: str) -> Optional[Dict[str, Any]]:
"""Get cached data from SQLite."""
try:
stmt = select(CacheEntry).where(
and_(
CacheEntry.cache_key == key,
CacheEntry.is_valid == True
async with self.SessionLocal() as db:
try:
stmt = select(CacheEntry).where(
and_(
CacheEntry.cache_key == key,
CacheEntry.is_valid == True
)
)
)
result = await self.db.execute(stmt)
entry = result.scalar_one_or_none()
result = await db.execute(stmt)
entry = result.scalar_one_or_none()
if not entry:
if not entry:
return None
# Check expiration
if entry.is_expired():
logger.debug(f"Cache entry expired: {key}")
# Mark as invalid but don't delete (soft delete)
await self._mark_invalid(entry.id, db)
return None
# Update access statistics
entry.increment_hit()
await db.commit()
logger.debug(f"Cache hit: {key}")
return {
'data': entry.get_response_data(),
'status_code': entry.status_code,
'headers': json.loads(entry.headers_json) if entry.headers_json else {},
'cached_at': entry.created_at,
'expires_at': entry.expires_at,
'hit_count': entry.hit_count
}
except Exception as e:
logger.error(f"Error getting cache entry {key}: {e}")
return None
# Check expiration
if entry.is_expired():
logger.debug(f"Cache entry expired: {key}")
# Mark as invalid but don't delete (soft delete)
await self._mark_invalid(entry.id)
return None
# Update access statistics
entry.increment_hit()
await self.db.commit()
logger.debug(f"Cache hit: {key}")
return {
'data': entry.get_response_data(),
'status_code': entry.status_code,
'headers': json.loads(entry.headers_json) if entry.headers_json else {},
'cached_at': entry.created_at,
'expires_at': entry.expires_at,
'hit_count': entry.hit_count
}
except Exception as e:
logger.error(f"Error getting cache entry {key}: {e}")
return None
async def set(
self,
key: str,
@ -101,50 +102,51 @@ class SQLiteCacheBackend(CacheBackend):
ttl: Optional[int] = None
) -> bool:
"""Store data in SQLite cache."""
try:
ttl = ttl or 3600 # Default 1 hour
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
async with self.SessionLocal() as db:
try:
ttl = ttl or 3600 # Default 1 hour
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
# Check if entry exists
stmt = select(CacheEntry).where(CacheEntry.cache_key == key)
result = await self.db.execute(stmt)
existing_entry = result.scalar_one_or_none()
# Check if entry exists
stmt = select(CacheEntry).where(CacheEntry.cache_key == key)
result = await db.execute(stmt)
existing_entry = result.scalar_one_or_none()
if existing_entry:
# Update existing entry
existing_entry.response_data = json.dumps(data.get('data', {}))
existing_entry.status_code = data.get('status_code', 200)
existing_entry.headers_json = json.dumps(data.get('headers', {}))
existing_entry.is_valid = True
existing_entry.expires_at = expires_at
existing_entry.ttl_seconds = ttl
existing_entry.updated_at = datetime.now(timezone.utc)
existing_entry.estimated_cost = data.get('estimated_cost', 0.0)
else:
# Create new entry
new_entry = CacheEntry(
cache_key=key,
endpoint=data.get('endpoint', ''),
method=data.get('method', 'GET'),
params_hash=data.get('params_hash', ''),
params_json=json.dumps(data.get('params', {})),
response_data=json.dumps(data.get('data', {})),
status_code=data.get('status_code', 200),
headers_json=json.dumps(data.get('headers', {})),
expires_at=expires_at,
ttl_seconds=ttl,
estimated_cost=data.get('estimated_cost', 0.0)
)
self.db.add(new_entry)
if existing_entry:
# Update existing entry
existing_entry.response_data = json.dumps(data.get('data', {}))
existing_entry.status_code = data.get('status_code', 200)
existing_entry.headers_json = json.dumps(data.get('headers', {}))
existing_entry.is_valid = True
existing_entry.expires_at = expires_at
existing_entry.ttl_seconds = ttl
existing_entry.updated_at = datetime.now(timezone.utc)
existing_entry.estimated_cost = data.get('estimated_cost', 0.0)
else:
# Create new entry
new_entry = CacheEntry(
cache_key=key,
endpoint=data.get('endpoint', ''),
method=data.get('method', 'GET'),
params_hash=data.get('params_hash', ''),
params_json=json.dumps(data.get('params', {})),
response_data=json.dumps(data.get('data', {})),
status_code=data.get('status_code', 200),
headers_json=json.dumps(data.get('headers', {})),
expires_at=expires_at,
ttl_seconds=ttl,
estimated_cost=data.get('estimated_cost', 0.0)
)
db.add(new_entry)
await self.db.commit()
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
return True
await db.commit()
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
return True
except Exception as e:
logger.error(f"Error setting cache entry {key}: {e}")
await self.db.rollback()
return False
except Exception as e:
logger.error(f"Error setting cache entry {key}: {e}")
await db.rollback()
return False
async def delete(self, key: str) -> bool:
"""Soft delete cache entry."""
@ -189,12 +191,12 @@ class SQLiteCacheBackend(CacheBackend):
logger.error(f"SQLite health check failed: {e}")
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."""
stmt = update(CacheEntry).where(
CacheEntry.id == entry_id
).values(is_valid=False)
await self.db.execute(stmt)
await db.execute(stmt)
async def _mark_invalid_by_key(self, key: str):
"""Mark entry invalid by cache key."""

View File

@ -67,7 +67,16 @@ class CacheEntry(Base, TimestampMixin):
"""Check if cache entry is expired."""
if not self.expires_at:
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):
"""Increment hit counter and update last accessed."""

View File

@ -22,7 +22,7 @@ from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
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 .models import (
@ -94,27 +94,25 @@ async def lifespan(app: FastAPI):
)
# Initialize cache manager
session = SessionLocal()
cache_backend = SQLiteCacheBackend(SessionLocal)
cache_manager = CacheManager(
backend=cache_backend,
default_ttl=DEFAULT_TTL_SECONDS,
stale_while_revalidate=True
)
# HTTP client for upstream requests
http_client = httpx.AsyncClient(
timeout=30.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
logger.info("RentCache server started successfully")
try:
cache_backend = SQLiteCacheBackend(session)
cache_manager = CacheManager(
backend=cache_backend,
default_ttl=DEFAULT_TTL_SECONDS,
stale_while_revalidate=True
)
# HTTP client for upstream requests
http_client = httpx.AsyncClient(
timeout=30.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
logger.info("RentCache server started successfully")
yield
finally:
# Cleanup
await session.close()
await http_client.aclose()
await engine.dispose()
logger.info("RentCache server shut down")
@ -418,10 +416,13 @@ async def proxy_to_rentcast(
)
# Make request to Rentcast API
# Note: We need to store the original Rentcast API key separately from our hash
# For now, this is a placeholder - in production, you'd need to decrypt/retrieve the original key
# Extract the original Rentcast API key from the request headers
# 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 = {
"X-Api-Key": "RENTCAST_API_KEY_PLACEHOLDER", # Replace with actual key retrieval
"X-Api-Key": rentcast_key,
"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)
hit_ratio_stmt = select(
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)
hit_ratio_result = await db.execute(hit_ratio_stmt)