feat: add caching (redis) (#265)

* feat: init redis cache

* feat: initialize redis client

* fix: add fakeredis client

* fix: update docker compose

* fix: various coderabbit comments

* fix: update docker-compose.yml.example

* fix: CR comments 2

* fix: clean up client

* fix: cache key class; workspace LRU; constants -> utils

* fix: CodeRabbit comments

* feat: add redis to harness

* fix: use cashews

* fix: add locking for cache stampedes

* fix: clean up PR

* f: unify cache key templates and stop caching None

* fix: alter prefixes + prime cache on get_or_create()

* fix: re-add fakeredis

* chore: Code Rabbit nits

* CR comments; make client more resilient

* fix: BPR

* fix: set redis once but clear cache in between every test

* fix: attempt fix contextvar error

* fix: make TestClient rely on redis patch

* fix: potential working fix

* fix: namespace inheritance; address comments

* fix: namespace inheritance; address comments

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
Rajat Ahuja 2025-11-07 13:04:28 -05:00 committed by GitHub
parent 641eb9991e
commit 8e3f24d4f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1007 additions and 171 deletions

View File

@ -21,6 +21,8 @@ LOG_LEVEL=INFO
# LANGFUSE_HOST=
# LANGFUSE_PUBLIC_KEY=
# NAMESPACE="honcho"
# =============================================================================
# Database Settings (REQUIRED)
# =============================================================================
@ -144,5 +146,14 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# =============================================================================
# Metrics (Optional)
# =============================================================================
# ENABLED=false
# NAMESPACE=honcho
# METRICS_ENABLED=false
# METRICS_NAMESPACE=honcho
# =============================================================================
# Cache
# =============================================================================
# CACHE_ENABLED=false
# CACHE_URL="redis://localhost:6379/0"
# CACHE_NAMESPACE="honcho"
# CACHE_DEFAULT_TTL_SECONDS=300
# CACHE_DEFAULT_LOCK_TTL_SECONDS=5

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ api/docker-compose.yml
*.db
data
redis-data
docker-compose.yml
compose.yml

View File

@ -396,13 +396,18 @@ cp config.toml.example config.toml
Then modify the values as needed. The TOML file is organized into sections:
- `[app]` - Application-level settings (log level, host, port, embedding settings)
- `[app]` - Application-level settings (log level, session limits, embedding settings, metrics)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[cache]` - Redis cache configuration
- `[llm]` - LLM provider API keys and general settings
- `[dialectic]` - Dialectic API configuration (provider, model, search settings)
- `[deriver]` - Background worker settings and theory of mind configuration
- `[peer_card]` - Peer card generation settings
- `[summary]` - Session summarization settings
- `[dream]` - Dream processing configuration
- `[webhook]` - Webhook configuration
- `[metrics]` - Metrics collection settings
- `[sentry]` - Error tracking and monitoring settings
### Using Environment Variables

View File

@ -15,7 +15,7 @@ MAX_EMBEDDING_TOKENS = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
# LANGFUSE_HOST = "https://api.langfuse.com"
# LANGFUSE_PUBLIC_KEY = "your-public-key-here"
NAMESPACE="honcho"
# Database settings
[db]
@ -111,3 +111,11 @@ MAX_WORKSPACE_LIMIT = 10
[metrics]
ENABLED = false
NAMESPACE = "honcho"
# Cache settings
[cache]
ENABLED = false
URL = "redis://localhost:6379/0"
NAMESPACE="honcho"
DEFAULT_TTL_SECONDS = 300
DEFAULT_LOCK_TTL_SECONDS = 5

View File

@ -45,3 +45,15 @@ services:
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8.2
restart: always
ports:
- 6379:6379
volumes:
- ./redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping"]
interval: 5s
timeout: 5s
retries: 5

View File

@ -46,14 +46,19 @@ cp config.toml.example config.toml
Then modify the values as needed. The TOML file is organized into sections:
- `[app]` - Application-level settings (log level, host, port, embedding settings)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[llm]` - LLM provider API keys and general settings
- `[dialectic]` - Dialectic API configuration (provider, model, search settings)
- `[deriver]` - Background worker settings and theory of mind configuration
- `[summary]` - Session summarization settings
- `[sentry]` - Error tracking and monitoring settings
- `[app]` - Application-level settings (log level, session limits, embedding settings, Langfuse integration, local metrics collection)
- `[db]` - Database connection and pool settings (connection URI, pool size, timeouts, connection recycling)
- `[auth]` - Authentication configuration (enable/disable auth, JWT secret)
- `[cache]` - Redis cache configuration (enable/disable caching, Redis URL, TTL settings, lock configuration for cache stampede prevention)
- `[llm]` - LLM provider API keys (Anthropic, OpenAI, Gemini, Groq, OpenAI-compatible endpoints) and general LLM settings
- `[dialectic]` - Dialectic API configuration (provider, model, query generation settings, semantic search parameters, context window size)
- `[deriver]` - Background worker settings (worker count, polling intervals, queue management) and theory of mind configuration (model, tokens, observation limits)
- `[peer_card]` - Peer card generation settings (provider, model, token limits)
- `[summary]` - Session summarization settings (frequency thresholds, provider, model, token limits for short and long summaries)
- `[dream]` - Dream processing configuration (enable/disable, thresholds, idle timeouts, dream types, LLM settings)
- `[webhook]` - Webhook configuration (webhook secret, workspace limits)
- `[metrics]` - Metrics collection settings (enable/disable metrics, namespace)
- `[sentry]` - Error tracking and monitoring settings (enable/disable, DSN, environment, sample rates)
### Using Environment Variables
@ -107,28 +112,33 @@ The application will use the production connection URI while keeping the pool si
### Application Settings
Application-level settings control core behavior of the Honcho server including logging, session limits, message handling, and optional integrations.
**Basic Application Configuration:**
```bash
# Logging and server settings
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
SESSION_PEERS_LIMIT=10
GET_CONTEXT_MAX_TOKENS=100000
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
# Embedding settings (optional)
EMBED_MESSAGES=false
MAX_EMBEDDING_TOKENS=8192
MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
# Session and context limits
SESSION_OBSERVERS_LIMIT=10 # Maximum number of observers per session
GET_CONTEXT_MAX_TOKENS=100000 # Maximum tokens for context retrieval
MAX_MESSAGE_SIZE=25000 # Maximum message size in characters
# Embedding settings
EMBED_MESSAGES=true # Enable vector embeddings for messages
MAX_EMBEDDING_TOKENS=8192 # Maximum tokens per embedding
MAX_EMBEDDING_TOKENS_PER_REQUEST=300000 # Batch embedding limit
```
**Environment-specific settings:**
**Optional Integrations:**
```bash
# Development
LOG_LEVEL=DEBUG
FASTAPI_HOST=127.0.0.1
# Langfuse integration for LLM observability
LANGFUSE_HOST=https://cloud.langfuse.com
LANGFUSE_PUBLIC_KEY=your-langfuse-public-key
# Production
LOG_LEVEL=WARNING
FASTAPI_HOST=0.0.0.0
# Local metrics collection
COLLECT_METRICS_LOCAL=false
LOCAL_METRICS_FILE=metrics.jsonl
```
### Database Configuration
@ -196,6 +206,36 @@ AUTH_JWT_SECRET=your-super-secret-jwt-key
python scripts/generate_jwt_secret.py
```
### Cache Configuration
Honcho supports Redis caching to improve performance by caching frequently accessed data like peers, sessions, and working representations. Caching also includes lock mechanisms to prevent cache stampede scenarios.
**Redis Cache Settings:**
```bash
# Enable/disable Redis caching
CACHE_ENABLED=false # Set to true to enable caching
# Redis connection
CACHE_URL=redis://localhost:6379/0?suppress=false
# Cache namespace and TTL
CACHE_NAMESPACE=honcho # Prefix for all cache keys
CACHE_DEFAULT_TTL_SECONDS=300 # How long items stay in cache (5 minutes)
# Lock settings for preventing cache stampede
CACHE_DEFAULT_LOCK_TTL_SECONDS=5 # Lock duration when fetching from DB on cache miss
```
**When to Enable Caching:**
- High-traffic production environments
- Applications with many repeated reads of the same data
- When you need to reduce database load
**Note:** Caching requires a Redis instance. You can run Redis locally with Docker:
```bash
docker run -d -p 6379:6379 redis:latest
```
## LLM Provider Configuration
Honcho supports multiple LLM providers for different tasks. API keys are configured in the `[llm]` section, while specific features use their own configuration sections.
@ -221,6 +261,9 @@ LLM_OPENAI_COMPATIBLE_BASE_URL=https://your-openai-compatible-endpoint.com
```bash
# Default settings for all LLM calls
LLM_DEFAULT_MAX_TOKENS=2500
# Embedding provider (used when EMBED_MESSAGES=true)
LLM_EMBEDDING_PROVIDER=openai # Options: openai, gemini
```
### Feature-Specific Model Configuration
@ -228,67 +271,147 @@ LLM_DEFAULT_MAX_TOKENS=2500
Different features can use different providers and models:
**Dialectic API:**
The Dialectic API provides theory-of-mind informed responses by integrating long-term facts with current context.
```bash
# Main dialectic model (default: Anthropic)
DIALECTIC_PROVIDER=anthropic
DIALECTIC_MODEL=claude-sonnet-4-20250514
DIALECTIC_MAX_OUTPUT_TOKENS=2500
DIALECTIC_THINKING_BUDGET_TOKENS=1024
DIALECTIC_THINKING_BUDGET_TOKENS=1024 # Only used with Anthropic provider
DIALECTIC_CONTEXT_WINDOW_SIZE=100000 # Maximum context window tokens
# Query generation for dialectic (default: Groq)
# Query generation for dialectic searches
DIALECTIC_PERFORM_QUERY_GENERATION=false # Enable query generation for semantic search
DIALECTIC_QUERY_GENERATION_PROVIDER=groq
DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# Semantic search settings
DIALECTIC_SEMANTIC_SEARCH_TOP_K=10
DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
DIALECTIC_SEMANTIC_SEARCH_TOP_K=10 # Number of results to retrieve
DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85 # Maximum distance for relevance
```
**Deriver:**
**Deriver (Theory of Mind):**
The Deriver is a background processing system that extracts facts from messages and builds theory-of-mind representations of peers.
```bash
# Deriver model (default: Google)
# LLM settings for deriver
DERIVER_PROVIDER=google
DERIVER_MODEL=gemini-2.0-flash-lite
DERIVER_MODEL=gemini-2.5-flash-lite
DERIVER_MAX_OUTPUT_TOKENS=10000
DERIVER_THINKING_BUDGET_TOKENS=1024 # Only used with Anthropic provider
DERIVER_MAX_INPUT_TOKENS=23000 # Maximum input tokens for deriver
# Worker settings
DERIVER_WORKERS=1
DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
DERIVER_WORKERS=1 # Number of background worker processes
DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0 # Time between queue checks
DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # Timeout for stale sessions
# Peer card settings
DERIVER_PEER_CARD_PROVIDER=openai
DERIVER_PEER_CARD_MODEL=gpt-5-nano-2025-08-07
DERIVER_PEER_CARD_MAX_OUTPUT_TOKENS=2000
# Queue management
DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # Keep errored items for 30 days
# Maximum number of observations to store in working representation
# This is applied to both explicit and deductive observations
DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
# Working representation settings
DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=50 # Max observations stored
DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=4096 # Max tokens per batch
```
**Peer Card:**
Peer cards are short, structured summaries of peer identity and characteristics.
```bash
# Enable/disable peer card generation
PEER_CARD_ENABLED=true
# LLM settings for peer card generation
PEER_CARD_PROVIDER=openai
PEER_CARD_MODEL=gpt-5-nano-2025-08-07
PEER_CARD_MAX_OUTPUT_TOKENS=4000 # Includes thinking tokens for GPT-5 models
```
**Summary Generation:**
```bash
# Summary model (default: Google)
SUMMARY_PROVIDER=google
SUMMARY_MODEL=gemini-1.5-flash-latest
SUMMARY_MAX_TOKENS_SHORT=1000
SUMMARY_MAX_TOKENS_LONG=2000
SUMMARY_THINKING_BUDGET_TOKENS=512
# Summary frequency
SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
Session summaries provide compressed context for long conversations. Honcho creates two types: short summaries (frequent) and long summaries (comprehensive).
```bash
# Enable/disable summarization
SUMMARY_ENABLED=true
# LLM settings for summary generation
SUMMARY_PROVIDER=openai
SUMMARY_MODEL=gpt-4o-mini-2024-07-18
SUMMARY_MAX_TOKENS_SHORT=1000 # Max tokens for short summaries
SUMMARY_MAX_TOKENS_LONG=4000 # Max tokens for long summaries
SUMMARY_THINKING_BUDGET_TOKENS=512 # Only used with Anthropic provider
# Summary frequency thresholds
SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20 # Create short summary every N messages
SUMMARY_MESSAGES_PER_LONG_SUMMARY=60 # Create long summary every N messages
```
### Default Provider Usage
By default, Honcho uses:
- **Anthropic** for dialectic API responses
- **Groq** for query generation
- **Google** for deriving theory of mind and summarization
- **Anthropic** (Claude) for dialectic API responses
- **Groq** for query generation (fast, cost-effective)
- **Google** (Gemini) for theory of mind derivation
- **OpenAI** (GPT) for peer cards and summarization
- **OpenAI** for embeddings (if `EMBED_MESSAGES=true`)
You only need to set the API keys for the providers you plan to use.
You only need to set the API keys for the providers you plan to use. All providers are configurable per feature.
## Additional Features Configuration
### Dream Processing
Dream processing consolidates and refines peer representations during idle periods, similar to how human memory consolidation works during sleep.
**Dream Settings:**
```bash
# Enable/disable dream processing
DREAM_ENABLED=true
# Trigger thresholds
DREAM_DOCUMENT_THRESHOLD=50 # Minimum documents to trigger a dream
DREAM_IDLE_TIMEOUT_MINUTES=60 # Minutes of inactivity before dream can start
DREAM_MIN_HOURS_BETWEEN_DREAMS=8 # Minimum hours between dreams for a peer
# Dream types to enable
DREAM_ENABLED_TYPES=["consolidate"] # Currently supported: consolidate
# LLM settings for dream processing
DREAM_PROVIDER=openai
DREAM_MODEL=gpt-4o-mini-2024-07-18
DREAM_MAX_OUTPUT_TOKENS=2000
```
### Webhook Configuration
Webhooks allow you to receive real-time notifications when events occur in Honcho (e.g., new messages, session updates).
**Webhook Settings:**
```bash
# Webhook secret for signing payloads (optional but recommended)
WEBHOOK_SECRET=your-webhook-signing-secret
# Limit on webhooks per workspace
WEBHOOK_MAX_WORKSPACE_LIMIT=10
```
### Metrics Collection
Enable metrics collection for monitoring Honcho performance and usage.
**Metrics Settings:**
```bash
# Enable/disable metrics collection
METRICS_ENABLED=false
# Namespace for metrics (used in metric names)
METRICS_NAMESPACE=honcho
```
## Monitoring Configuration
@ -296,13 +419,17 @@ You only need to set the API keys for the providers you plan to use.
**Sentry Settings:**
```bash
# Enable/disable Sentry
# Enable/disable Sentry error tracking
SENTRY_ENABLED=false
# Sentry configuration
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_PROFILES_SAMPLE_RATE=0.1
SENTRY_RELEASE=2.4.0 # Optional: track which version errors come from
SENTRY_ENVIRONMENT=production # Environment name (development, staging, production)
# Sampling rates (0.0 to 1.0)
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10% of transactions tracked
SENTRY_PROFILES_SAMPLE_RATE=0.1 # 10% of transactions profiled
```
## Environment-Specific Examples
@ -313,7 +440,7 @@ SENTRY_PROFILES_SAMPLE_RATE=0.1
```toml
[app]
LOG_LEVEL = "DEBUG"
SESSION_PEERS_LIMIT = 10
SESSION_OBSERVERS_LIMIT = 10
EMBED_MESSAGES = false
[db]
@ -323,21 +450,40 @@ POOL_SIZE = 5
[auth]
USE_AUTH = false
[cache]
ENABLED = false
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
PERFORM_QUERY_GENERATION = false
MAX_OUTPUT_TOKENS = 2500
[summary]
PROVIDER = "google"
MODEL = "gemini-1.5-flash-latest"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 2000
[deriver]
WORKERS = 1
PROVIDER = "google"
MODEL = "gemini-2.5-flash-lite"
[peer_card]
ENABLED = true
PROVIDER = "openai"
MODEL = "gpt-5-nano-2025-08-07"
[summary]
ENABLED = true
PROVIDER = "openai"
MODEL = "gpt-4o-mini-2024-07-18"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 4000
[dream]
ENABLED = true
[webhook]
MAX_WORKSPACE_LIMIT = 10
[metrics]
ENABLED = false
[sentry]
ENABLED = false
@ -349,7 +495,12 @@ ENABLED = false
LOG_LEVEL=DEBUG
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho_dev
AUTH_USE_AUTH=false
ANTHROPIC_API_KEY=your-dev-anthropic-key
CACHE_ENABLED=false
# LLM Provider API Keys
LLM_ANTHROPIC_API_KEY=your-dev-anthropic-key
LLM_OPENAI_API_KEY=your-dev-openai-key
LLM_GEMINI_API_KEY=your-dev-gemini-key
```
### Production Configuration
@ -358,7 +509,7 @@ ANTHROPIC_API_KEY=your-dev-anthropic-key
```toml
[app]
LOG_LEVEL = "WARNING"
SESSION_PEERS_LIMIT = 10
SESSION_OBSERVERS_LIMIT = 10
EMBED_MESSAGES = true
[db]
@ -369,27 +520,50 @@ MAX_OVERFLOW = 40
[auth]
USE_AUTH = true
[cache]
ENABLED = true
URL = "redis://redis:6379/0"
DEFAULT_TTL_SECONDS = 300
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
PERFORM_QUERY_GENERATION = false
MAX_OUTPUT_TOKENS = 2500
[summary]
PROVIDER = "google"
MODEL = "gemini-1.5-flash-latest"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 2000
[deriver]
WORKERS = 4
PROVIDER = "google"
MODEL = "gemini-2.0-flash-lite"
MODEL = "gemini-2.5-flash-lite"
[peer_card]
ENABLED = true
PROVIDER = "openai"
MODEL = "gpt-5-nano-2025-08-07"
[summary]
ENABLED = true
PROVIDER = "openai"
MODEL = "gpt-4o-mini-2024-07-18"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 4000
[dream]
ENABLED = true
PROVIDER = "openai"
MODEL = "gpt-4o-mini-2024-07-18"
[webhook]
MAX_WORKSPACE_LIMIT = 10
[metrics]
ENABLED = true
[sentry]
ENABLED = true
ENVIRONMENT = "production"
TRACES_SAMPLE_RATE = 0.1
PROFILES_SAMPLE_RATE = 0.1
```
**Environment variables for production:**
@ -397,11 +571,27 @@ TRACES_SAMPLE_RATE = 0.1
# .env.production
LOG_LEVEL=WARNING
DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@prod-db:5432/honcho_prod
# Authentication
AUTH_USE_AUTH=true
AUTH_JWT_SECRET=your-super-secret-jwt-key
ANTHROPIC_API_KEY=your-prod-anthropic-key
GEMINI_API_KEY=your-prod-gemini-key
# Cache
CACHE_ENABLED=true
CACHE_URL=redis://redis:6379/0
# LLM Provider API Keys
LLM_ANTHROPIC_API_KEY=your-prod-anthropic-key
LLM_OPENAI_API_KEY=your-prod-openai-key
LLM_GEMINI_API_KEY=your-prod-gemini-key
LLM_GROQ_API_KEY=your-prod-groq-key
# Webhooks
WEBHOOK_SECRET=your-webhook-signing-secret
# Monitoring
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
SENTRY_ENVIRONMENT=production
```
## Migration Management

View File

@ -33,6 +33,8 @@ dependencies = [
"typing-extensions>=4.11.0",
"prometheus-client>=0.20.0",
"json-repair>=0.49.0",
"redis>=6.0.0",
"cashews[redis]>=7.4.3",
]
[tool.uv]
dev-dependencies = [
@ -47,6 +49,7 @@ dev-dependencies = [
"pre-commit>=4.2.0",
"pytest-cov>=6.2.1",
"honcho-ai",
"fakeredis>=2.32.0",
]
[tool.uv.workspace]

11
src/cache/__init__.py vendored Normal file
View File

@ -0,0 +1,11 @@
"""Cache module for Honcho."""
from src.cache.client import (
close_cache,
init_cache,
)
__all__ = [
"close_cache",
"init_cache",
]

123
src/cache/client.py vendored Normal file
View File

@ -0,0 +1,123 @@
from __future__ import annotations
import asyncio
import logging
from typing import cast
import sentry_sdk
from cashews import cache
from cashews.picklers import PicklerType
from redis import exceptions as redis_exc
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_delay,
wait_exponential_jitter,
)
from src.config import settings
logger = logging.getLogger(__name__)
_cache_lock = asyncio.Lock()
def is_cache_enabled() -> bool:
return settings.CACHE.ENABLED
def get_cache_namespace() -> str:
# CACHE.NAMESPACE is guaranteed to be non-None by AppSettings.propagate_namespace validator
return cast(str, settings.CACHE.NAMESPACE)
async def init_cache() -> None:
"""Initialize and verify cache connection if enabled."""
async with _cache_lock:
# Close existing backends to force recreation with new ContextVars
await cache.close()
if not is_cache_enabled():
# Use in-memory cache when caching is disabled
logger.info("Cache disabled, using in-memory cache")
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY) # pyright: ignore[reportUnknownMemberType]
return
# Setup cache with Redis backend
try:
cache.setup( # pyright: ignore[reportUnknownMemberType]
settings.CACHE.URL,
pickle_type=PicklerType.SQLALCHEMY,
)
except Exception as setup_err:
logger.warning(
"Cache setup failed for %s: %s. Falling back to in-memory cache",
settings.CACHE.URL,
setup_err,
)
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(setup_err)
# Fallback to in-memory cache
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY) # pyright: ignore[reportUnknownMemberType]
return
cache.enable()
# Retry Redis ping with exponential backoff
try:
async for attempt in AsyncRetrying(
wait=wait_exponential_jitter(initial=0.2, max=2.0),
stop=stop_after_delay(5), # give it a bit more headroom
retry=retry_if_exception_type(
(
redis_exc.TimeoutError,
redis_exc.ConnectionError,
asyncio.TimeoutError,
TimeoutError,
)
),
reraise=True,
):
with attempt:
async with asyncio.timeout(2):
await cache.ping()
logger.info("Connected to cache at %s", settings.CACHE.URL)
except (
redis_exc.TimeoutError,
redis_exc.ConnectionError,
asyncio.TimeoutError,
TimeoutError,
) as e:
logger.warning(
"Failed to connect to cache at %s: %s. Falling back to in-memory cache",
settings.CACHE.URL,
e,
)
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Fallback to in-memory cache
await cache.close()
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY) # pyright: ignore[reportUnknownMemberType]
except Exception as e:
logger.warning(
"Unexpected cache error at %s: %s. Falling back to in-memory cache",
settings.CACHE.URL,
e,
)
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Fallback to in-memory cache
await cache.close()
cache.setup("mem://", pickle_type=PicklerType.SQLALCHEMY) # pyright: ignore[reportUnknownMemberType]
async def close_cache() -> None:
await cache.close()
__all__ = [
"init_cache",
"close_cache",
"cache",
]

View File

@ -50,6 +50,7 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource):
"DB": "db",
"AUTH": "auth",
"SENTRY": "sentry",
"CACHE": "cache",
"LLM": "llm",
"DERIVER": "deriver",
"PEER_CARD": "peer_card",
@ -290,7 +291,22 @@ class MetricsSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="METRICS_", extra="ignore") # pyright: ignore
ENABLED: bool = False
NAMESPACE: str = "honcho"
NAMESPACE: str | None = None
class CacheSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore
ENABLED: bool = False
URL: str = "redis://localhost:6379/0?suppress=false"
NAMESPACE: str | None = None
DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = (
300 # how long to keep items in cache
)
DEFAULT_LOCK_TTL_SECONDS: Annotated[int, Field(default=5, ge=1, le=86_400)] = (
5 # how long to hold a lock on a resource when fetching DB after cache miss
)
class DreamSettings(HonchoSettings):
@ -334,6 +350,8 @@ class AppSettings(HonchoSettings):
COLLECT_METRICS_LOCAL: bool = False
LOCAL_METRICS_FILE: str = "metrics.jsonl"
NAMESPACE: str = "honcho" # Top-level namespace for all settings, can be overridden by nested-model settings
# Nested settings models
DB: DBSettings = Field(default_factory=DBSettings)
AUTH: AuthSettings = Field(default_factory=AuthSettings)
@ -345,6 +363,7 @@ class AppSettings(HonchoSettings):
SUMMARY: SummarySettings = Field(default_factory=SummarySettings)
WEBHOOK: WebhookSettings = Field(default_factory=WebhookSettings)
METRICS: MetricsSettings = Field(default_factory=MetricsSettings)
CACHE: CacheSettings = Field(default_factory=CacheSettings)
DREAM: DreamSettings = Field(default_factory=DreamSettings)
@field_validator("LOG_LEVEL")
@ -354,6 +373,19 @@ class AppSettings(HonchoSettings):
raise ValueError(f"Invalid log level: {v}")
return log_level
@model_validator(mode="after")
def propagate_namespace(self) -> "AppSettings":
"""Propagate top-level NAMESPACE to nested settings if not explicitly set.
After this validator runs, CACHE.NAMESPACE and METRICS.NAMESPACE are guaranteed
to exist.
"""
if self.CACHE.NAMESPACE is None:
self.CACHE.NAMESPACE = self.NAMESPACE
if self.METRICS.NAMESPACE is None:
self.METRICS.NAMESPACE = self.NAMESPACE
return self
# Create a single global instance of the settings
settings: AppSettings = AppSettings()

View File

@ -1,16 +1,35 @@
from logging import getLogger
from typing import Any
from cashews import NOT_NONE
from sqlalchemy import Select, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.cache.client import cache, get_cache_namespace
from src.config import settings
from src.crud.workspace import get_or_create_workspace
from src.exceptions import ConflictException, ResourceNotFoundException
from src.utils.filter import apply_filter
logger = getLogger(__name__)
PEER_CACHE_KEY_TEMPLATE = "workspace:{workspace_name}:peer:{peer_name}"
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock"
def peer_cache_key(workspace_name: str, peer_name: str) -> str:
"""Generate cache key for peer."""
return (
get_cache_namespace()
+ ":"
+ PEER_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
peer_name=peer_name,
)
)
async def get_or_create_peers(
db: AsyncSession,
@ -35,6 +54,8 @@ async def get_or_create_peers(
Raises:
ConflictException: If we fail to get or create the peers
"""
await get_or_create_workspace(db, schemas.WorkspaceCreate(name=workspace_name))
peer_names = [p.name for p in peers]
stmt = (
select(models.Peer)
@ -75,8 +96,6 @@ async def get_or_create_peers(
try:
db.add_all(new_peers)
await db.commit()
# Return combined list of existing and new peers
return existing_peers + new_peers
except IntegrityError:
await db.rollback()
if _retry:
@ -85,6 +104,37 @@ async def get_or_create_peers(
) from None
return await get_or_create_peers(db, workspace_name, peers, _retry=True)
# Invalidate cache for all updated/created peers - read-through pattern
for peer_obj in existing_peers + new_peers:
cache_key = peer_cache_key(workspace_name, peer_obj.name)
await cache.delete(cache_key)
# Return combined list of existing and new peers
return existing_peers + new_peers
@cache(
key=PEER_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
condition=NOT_NONE,
)
@cache.locked(
key=PEER_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
prefix=PEER_LOCK_PREFIX,
)
async def _fetch_peer(
db: AsyncSession,
workspace_name: str,
peer_name: str,
) -> models.Peer | None:
return await db.scalar(
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == peer_name)
)
async def get_peer(
db: AsyncSession,
@ -105,21 +155,16 @@ async def get_peer(
Raises:
ResourceNotFoundException: If the peer does not exist
"""
# Try to get the existing peer
stmt = (
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == peer.name)
)
result = await db.execute(stmt)
existing_peer = result.scalar_one_or_none()
existing_peer = await _fetch_peer(db, workspace_name, peer.name)
if existing_peer is None:
raise ResourceNotFoundException(
f"Peer {peer.name} not found in workspace {workspace_name}"
)
if existing_peer is not None:
return existing_peer
# Merge cached object into session (cached objects are detached)
existing_peer = await db.merge(existing_peer, load=False)
raise ResourceNotFoundException(
f"Peer {peer.name} not found in workspace {workspace_name}"
)
return existing_peer
async def get_peers(
@ -166,7 +211,13 @@ async def update_peer(
honcho_peer.configuration = peer.configuration
await db.commit()
logger.debug(f"Peer {peer_name} updated successfully")
await db.refresh(honcho_peer)
# Invalidate cache - read-through pattern
cache_key = peer_cache_key(workspace_name, honcho_peer.name)
await cache.delete(cache_key)
logger.debug("Peer %s updated successfully", peer_name)
return honcho_peer

View File

@ -7,7 +7,8 @@ from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from src import exceptions, models, schemas
from src.crud.peer import get_peer
from src.cache.client import cache
from src.crud.peer import get_peer, peer_cache_key
logger = logging.getLogger(__name__)
@ -81,14 +82,20 @@ async def set_peer_card(
}
)
)
.returning(models.Peer)
)
result = await db.execute(stmt)
if result.rowcount == 0:
updated_peer = result.scalar_one_or_none()
if updated_peer is None:
raise exceptions.ResourceNotFoundException(
f"Peer {observer} not found in workspace {workspace_name}"
)
await db.commit()
# Invalidate cache - read-through pattern
cache_key = peer_cache_key(workspace_name, observer)
await cache.delete(cache_key)
def construct_peer_card_label(*, observer: str, observed: str) -> str:
if observer == observed:

View File

@ -1,6 +1,7 @@
from logging import getLogger
from typing import Any
from cashews import NOT_NONE
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, case, cast, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger, Boolean
from src import models, schemas
from src.cache.client import cache, get_cache_namespace
from src.config import settings
from src.exceptions import (
ConflictException,
@ -18,12 +20,48 @@ from src.exceptions import (
from src.utils.filter import apply_filter
from .peer import get_or_create_peers, get_peer
# Import workspace and peer functions that are needed
from .workspace import get_or_create_workspace
logger = getLogger(__name__)
SESSION_CACHE_KEY_TEMPLATE = "workspace:{workspace_name}:session:{session_name}"
SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock"
def session_cache_key(workspace_name: str, session_name: str) -> str:
"""Generate cache key for session."""
return (
get_cache_namespace()
+ ":"
+ SESSION_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
session_name=session_name,
)
)
@cache(
key=SESSION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
condition=NOT_NONE,
)
@cache.locked(
key=SESSION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
prefix=SESSION_LOCK_PREFIX,
)
async def _fetch_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
) -> models.Session | None:
return await db.scalar(
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
def count_observers_in_config(
peer_configs: dict[str, schemas.SessionPeerConfig],
@ -79,15 +117,14 @@ async def get_or_create_session(
ConflictException: If we fail to get or create the session
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session.name)
)
if not session.name:
raise ValueError("Session name must be provided")
result = await db.execute(stmt)
honcho_session = await _fetch_session(db, workspace_name, session.name)
honcho_session = result.scalar_one_or_none()
# Merge cached object into session if it exists (cached objects are detached)
if honcho_session is not None:
honcho_session = await db.merge(honcho_session, load=False)
# Check if session already exists
if honcho_session is None:
@ -148,6 +185,12 @@ async def get_or_create_session(
)
await db.commit()
await db.refresh(honcho_session)
cache_key = session_cache_key(workspace_name, session.name)
await cache.set(
cache_key, honcho_session, expire=settings.CACHE.DEFAULT_TTL_SECONDS
)
return honcho_session
@ -170,22 +213,17 @@ async def get_session(
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
session = await _fetch_session(db, workspace_name, session_name)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
return honcho_session
# Merge cached object into session (cached objects are detached)
session = await db.merge(session, load=False)
return session
async def update_session(
@ -220,6 +258,12 @@ async def update_session(
honcho_session.configuration = session.configuration
await db.commit()
await db.refresh(honcho_session)
# Invalidate cache - read-through pattern
cache_key = session_cache_key(workspace_name, session_name)
await cache.delete(cache_key)
logger.debug("Session %s updated successfully", session_name)
return honcho_session
@ -241,22 +285,16 @@ async def delete_session(
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
logger.warning(
f"Session {session_name} not found in workspace {workspace_name}"
)
raise ResourceNotFoundException("Session not found")
honcho_session = await get_session(db, session_name, workspace_name)
honcho_session.is_active = False
await db.commit()
await db.refresh(honcho_session)
# Invalidate cache - read-through pattern
cache_key = session_cache_key(workspace_name, session_name)
await cache.delete(cache_key)
logger.debug("Session %s marked as inactive", session_name)
return True
@ -359,7 +397,10 @@ async def clone_session(
db.add(new_session_peer)
await db.commit()
await db.refresh(new_session)
logger.debug("Session %s cloned successfully", original_session_name)
# Cache will be populated on next read - read-through pattern
return new_session
@ -385,18 +426,7 @@ async def remove_peers_from_session(
ResourceNotFoundException: If the session does not exist
"""
# Verify session exists
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
await get_session(db, session_name, workspace_name)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
@ -409,7 +439,7 @@ async def remove_peers_from_session(
)
.values(left_at=func.now())
)
result = await db.execute(update_stmt)
await db.execute(update_stmt)
await db.commit()
return True

View File

@ -1,16 +1,51 @@
from logging import getLogger
from typing import Any
from cashews import NOT_NONE
from sqlalchemy import Select, delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.cache.client import cache, get_cache_namespace
from src.config import settings
from src.exceptions import ConflictException, ResourceNotFoundException
from src.utils.filter import apply_filter
logger = getLogger(__name__)
WORKSPACE_CACHE_KEY_TEMPLATE = "workspace:{workspace_name}"
WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock"
def workspace_cache_key(workspace_name: str) -> str:
"""Generate cache key for workspace."""
return (
get_cache_namespace()
+ ":"
+ WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name)
)
@cache(
key=WORKSPACE_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
condition=NOT_NONE,
)
@cache.locked(
key=WORKSPACE_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
prefix=WORKSPACE_LOCK_PREFIX,
)
async def _fetch_workspace(
db: AsyncSession, workspace_name: str
) -> models.Workspace | None:
"""Fetch a workspace from the database."""
return await db.scalar(
select(models.Workspace).where(models.Workspace.name == workspace_name)
)
async def get_or_create_workspace(
db: AsyncSession,
@ -31,14 +66,17 @@ async def get_or_create_workspace(
Raises:
ConflictException: If we fail to get or create the workspace
"""
# Try to get the existing workspace
stmt = select(models.Workspace).where(models.Workspace.name == workspace.name)
result = await db.execute(stmt)
existing_workspace = result.scalar_one_or_none()
if not workspace.name:
raise ValueError("Workspace name must be provided")
# Check if workspace already exists
existing_workspace = await _fetch_workspace(db, workspace.name)
if existing_workspace is not None:
# Workspace already exists
logger.debug("Found existing workspace: %s", workspace.name)
# Merge cached object into session (cached objects are detached)
existing_workspace = await db.merge(existing_workspace, load=False)
return existing_workspace
# Workspace doesn't exist, create a new one
@ -50,7 +88,14 @@ async def get_or_create_workspace(
try:
db.add(honcho_workspace)
await db.commit()
await db.refresh(honcho_workspace)
logger.debug("Workspace created successfully: %s", workspace.name)
cache_key = workspace_cache_key(workspace.name)
await cache.set(
cache_key, honcho_workspace, expire=settings.CACHE.DEFAULT_TTL_SECONDS
)
return honcho_workspace
except IntegrityError:
await db.rollback()
@ -94,15 +139,15 @@ async def get_workspace(
Raises:
ResourceNotFoundException: If the workspace does not exist
"""
# Try to get the existing peer
stmt = select(models.Workspace).where(models.Workspace.name == workspace_name)
result = await db.execute(stmt)
existing_workspace = result.scalar_one_or_none()
existing_workspace = await _fetch_workspace(db, workspace_name)
if existing_workspace is not None:
return existing_workspace
if existing_workspace is None:
raise ResourceNotFoundException(f"Workspace {workspace_name} not found")
raise ResourceNotFoundException(f"Workspace {workspace_name} not found")
# Merge cached object into session (cached objects are detached)
existing_workspace = await db.merge(existing_workspace, load=False)
return existing_workspace
async def update_workspace(
@ -134,6 +179,12 @@ async def update_workspace(
honcho_workspace.configuration = workspace.configuration
await db.commit()
await db.refresh(honcho_workspace)
# Invalidate cache
cache_key = workspace_cache_key(workspace_name)
await cache.delete(cache_key)
logger.debug("Workspace with id %s updated successfully", honcho_workspace.id)
return honcho_workspace
@ -241,10 +292,18 @@ async def delete_workspace(db: AsyncSession, workspace_name: str) -> schemas.Wor
)
await db.delete(honcho_workspace)
await db.commit()
cache_key = workspace_cache_key(workspace_name)
workspace_pattern = f"{cache_key}*"
await cache.delete_match(workspace_pattern)
logger.debug("Workspace %s deleted", workspace_name)
except Exception as e:
logger.error("Failed to delete workspace %s: %s", workspace_name, e)
except Exception:
logger.exception(
"Failed to delete workspace %s",
workspace_name,
)
await db.rollback()
raise e
raise
return workspace_snapshot

View File

@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from src import models, prometheus
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.dependencies import tracked_db
from src.deriver.consumer import (
@ -766,6 +767,13 @@ class QueueManager:
async def main():
logger.debug("Starting queue manager")
try:
await init_cache()
except Exception as e:
logger.warning(
"Error initializing cache in queue manager; proceeding without cache: %s", e
)
manager = QueueManager()
try:
await manager.initialize()
@ -773,4 +781,5 @@ async def main():
logger.error(f"Error in main: {str(e)}")
sentry_sdk.capture_exception(e)
finally:
await close_cache()
logger.debug("Main function exiting")

View File

@ -16,6 +16,7 @@ from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from src import prometheus
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.db import engine, request_context
from src.exceptions import HonchoException
@ -109,8 +110,18 @@ if SENTRY_ENABLED:
@asynccontextmanager
async def lifespan(_: FastAPI):
yield
await engine.dispose()
try:
await init_cache()
except Exception as e:
logger.warning(
"Error initializing cache in api process; proceeding without cache: %s", e
)
try:
yield
finally:
await close_cache()
await engine.dispose()
app = FastAPI(

View File

@ -5,6 +5,7 @@ This module defines all Prometheus metrics for all Honcho processes and exposes
"""
import logging
from typing import cast
from prometheus_client import (
CONTENT_TYPE_LATEST,
@ -28,7 +29,8 @@ class NamespacedCounter(Counter):
def labels(self, **kwargs: str) -> "NamespacedCounter":
"""Override labels to automatically appends namespace label"""
kwargs["namespace"] = settings.METRICS.NAMESPACE
# METRICS.NAMESPACE is guaranteed to be non-None by AppSettings.propagate_namespace validator
kwargs["namespace"] = cast(str, settings.METRICS.NAMESPACE)
return super().labels(**kwargs)

View File

@ -11,6 +11,7 @@ This script:
"""
import argparse
import asyncio
import os
import shutil
import subprocess
@ -22,6 +23,8 @@ from pathlib import Path
import yaml
from src.cache.client import close_cache, init_cache
class HonchoHarness:
"""
@ -65,6 +68,9 @@ class HonchoHarness:
# Update the database port
compose_data["services"]["database"]["ports"] = [f"{self.db_port}:5432"]
# Update the Redis port
compose_data["services"]["redis"]["ports"] = ["6379:6379"]
# Add a unique project name to avoid conflicts
compose_data["name"] = f"honcho_harness_{self.db_port}"
@ -111,13 +117,15 @@ class HonchoHarness:
def get_database_env_vars(self) -> dict[str, str]:
"""
Get environment variables for database configuration and required API keys.
Get environment variables for database configuration, cache configuration, and required API keys.
Returns:
Dictionary of environment variables for database connection and API keys
Dictionary of environment variables for database connection, cache, and API keys
"""
return {
"DB_CONNECTION_URI": f"postgresql+psycopg://testuser:testpwd@localhost:{self.db_port}/honcho",
"CACHE_ENABLED": "true",
"CACHE_URL": "redis://localhost:6379/0",
}
def start_database(self) -> None:
@ -149,6 +157,99 @@ class HonchoHarness:
print("Database started successfully")
def start_redis(self) -> None:
"""
Start the Redis cache server using Docker Compose.
"""
print("Starting Redis cache server on port 6379...")
# Change to the temp directory and start the redis service
result = subprocess.run(
[
"docker-compose",
"-f",
str(self.docker_compose_file),
"-p",
f"honcho_harness_{self.db_port}",
"up",
"-d",
"redis",
],
cwd=self.temp_dir,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to start Redis: {result.stderr}")
sys.exit(1)
print("Redis started successfully")
def wait_for_redis(self, timeout: int = 30) -> bool:
"""
Wait for Redis to be ready.
Args:
timeout: Maximum time to wait in seconds
Returns:
True if Redis is ready, False otherwise
"""
print("Waiting for Redis to be ready...")
start_time = time.time()
redis_port = 6379
while time.time() - start_time < timeout:
try:
import redis
# Test Redis connection
r = redis.Redis(
host="localhost", port=redis_port, decode_responses=True
)
r.ping() # pyright: ignore[reportUnknownMemberType]
print("Redis is ready!")
return True
except Exception:
pass
time.sleep(1) # Check every second
print("Redis failed to become ready within timeout")
return False
async def init_cache(self) -> None:
"""
Initialize the Redis cache connection.
"""
try:
# Add the project root to the path so we can import Honcho modules
sys.path.insert(0, str(self.project_root))
# Set environment variables for cache configuration
env = self.get_database_env_vars()
for key, value in env.items():
os.environ[key] = value
await init_cache()
print(f"[Instance {self.instance_id}] Cache initialized successfully")
except Exception as e:
print(f"[Instance {self.instance_id}] Failed to initialize cache: {e}")
async def close_cache(self) -> None:
"""
Close the Redis cache connection.
"""
try:
# Add the project root to the path so we can import Honcho modules
sys.path.insert(0, str(self.project_root))
await close_cache()
print(f"[Instance {self.instance_id}] Cache closed successfully")
except Exception as e:
print(f"[Instance {self.instance_id}] Failed to close cache: {e}")
def wait_for_database(self, timeout: int = 60) -> bool:
"""
Wait for the database to be ready.
@ -576,10 +677,16 @@ except Exception as e:
except Exception as e:
print(f"Error removing temp directory: {e}")
# Close cache
try:
asyncio.run(self.close_cache())
except Exception as e:
print(f"Error closing cache: {e}")
# Restore .env file
self.restore_env_file()
def run(self) -> None:
async def run(self) -> None:
"""
Run the complete Honcho harness.
"""
@ -606,11 +713,22 @@ except Exception as e:
# Start database
self.start_database()
# Start Redis
self.start_redis()
# Wait for database to be ready
if not self.wait_for_database():
print("Database failed to start. Exiting.")
sys.exit(1)
# Wait for Redis to be ready
if not self.wait_for_redis():
print("Redis failed to start. Exiting.")
sys.exit(1)
# Initialize cache
await self.init_cache()
# Provision database
self.provision_database()
@ -700,7 +818,7 @@ class HonchoHarnessPool:
)
self.harnesses.append(harness)
def run(self) -> None:
async def run(self) -> None:
"""
Run all Honcho harnesses in the pool.
"""
@ -748,6 +866,9 @@ class HonchoHarnessPool:
# Start database
harness.start_database()
# Start Redis
harness.start_redis()
# Wait for database to be ready
if not harness.wait_for_database():
print(
@ -755,6 +876,16 @@ class HonchoHarnessPool:
)
sys.exit(1)
# Wait for Redis to be ready
if not harness.wait_for_redis():
print(
f"Redis failed to start for instance {harness.instance_id}. Exiting."
)
sys.exit(1)
# Initialize cache
await harness.init_cache()
# Provision database
harness.provision_database()
@ -903,7 +1034,7 @@ Examples:
base_api_port=args.api_port,
project_root=args.project_root,
)
pool.run()
asyncio.run(pool.run())
else:
harness = HonchoHarness(
db_port=args.port,
@ -911,7 +1042,7 @@ Examples:
project_root=args.project_root,
instance_id=0,
)
harness.run()
asyncio.run(harness.run())
if __name__ == "__main__":

View File

@ -6,6 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
import pytest_asyncio
from cashews.backends.interface import ControlMixin
from cashews.picklers import PicklerType
from fakeredis import FakeAsyncRedis
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
@ -26,6 +29,7 @@ from sqlalchemy_utils import (
)
from src import models
from src.cache.client import cache
from src.config import settings
from src.db import Base
from src.dependencies import get_db
@ -164,8 +168,79 @@ async def db_session(db_engine: AsyncEngine):
await session.rollback()
@pytest_asyncio.fixture(scope="session")
async def fake_cache_session():
"""Set up fakeredis for caching once per test session."""
# Store original settings
original_enabled = settings.CACHE.ENABLED
original_url = settings.CACHE.URL
# Create a fake redis instance that persists for the session
fake_redis = FakeAsyncRedis(decode_responses=True)
# Patch redis creation to use fakeredis
# Cashews uses redis.asyncio.from_url to create connections
def fake_redis_from_url(*_args: Any, **_kwargs: Any):
return fake_redis
# Patch the cashews backend's _disable property to avoid ContextVar issues
# This works around cashews' ContextVar not being properly initialized in TestClient context
original_disable_property = ControlMixin._disable # pyright: ignore[reportPrivateUsage]
@property # type: ignore
def patched_disable_property(self): # pyright: ignore
try:
return original_disable_property.fget(self) # pyright: ignore[reportOptionalCall]
except LookupError:
# Return empty set as default if ContextVar not set in current context
return set() # pyright: ignore
# Start patching
redis_patch = patch("redis.asyncio.from_url", fake_redis_from_url)
redis_patch.start()
ControlMixin._disable = patched_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
try:
# Enable caching and set URL for tests
settings.CACHE.ENABLED = True
settings.CACHE.URL = "redis://fake-redis:6379/0"
# Setup cache for tests that don't use TestClient (direct CRUD tests)
# For TestClient tests, the app's lifespan handler will also call cache.setup()
# The ContextVar patch above handles any context issues
cache.setup( # pyright: ignore[reportUnknownMemberType]
"redis://fake-redis:6379/0", pickle_type=PicklerType.SQLALCHEMY, enable=True
)
yield fake_redis
finally:
# Stop the patches
redis_patch.stop()
ControlMixin._disable = original_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
# Restore original settings
settings.CACHE.ENABLED = original_enabled
settings.CACHE.URL = original_url
@pytest_asyncio.fixture(scope="function", autouse=True)
async def fake_cache(fake_cache_session: FakeAsyncRedis):
"""Clear cache between tests."""
# Clear cache before each test
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
yield cache
# Clear cache after each test
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession):
async def client(
db_session: AsyncSession,
fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter]
) -> AsyncGenerator[TestClient, Any]:
"""Create a FastAPI TestClient for the scope of a single test function"""
# Register exception handlers for tests
@ -491,6 +566,7 @@ def mock_tracked_db(db_session: AsyncSession):
patch("src.dependencies.tracked_db", mock_tracked_db_context),
patch("src.deriver.queue_manager.tracked_db", mock_tracked_db_context),
patch("src.routers.sessions.tracked_db", mock_tracked_db_context),
patch("src.routers.peers.tracked_db", mock_tracked_db_context),
patch("src.crud.representation.tracked_db", mock_tracked_db_context),
patch("src.routers.peers.tracked_db", mock_tracked_db_context),
patch("src.dreamer.dreamer.tracked_db", mock_tracked_db_context),

64
uv.lock
View File

@ -70,6 +70,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]
[[package]]
name = "attrs"
version = "25.3.0"
@ -118,6 +127,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" },
]
[[package]]
name = "cashews"
version = "7.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/d9/e7b3daee814680e9a4a02835beb8c13b489a4422e902a7f39371c811ed2b/cashews-7.4.3.tar.gz", hash = "sha256:72cc1931b558e1bbe1e80395f9be6707131ffe21768f55e359d3e760c802fbb1", size = 92426, upload-time = "2025-10-11T21:08:41.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/6b/40059c0d5fe32b6434aae14b55afe6a7e6d158826582a5a44101bb5daaed/cashews-7.4.3-py3-none-any.whl", hash = "sha256:33449b26b44c36d3ab6e73f91f71ade1e29e918e85a820238f1947beaae26322", size = 79729, upload-time = "2025-10-11T21:08:40.236Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
@ -464,6 +487,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "fakeredis"
version = "2.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/2e/94ca3f2ff35f086d7d3eeb924054e328b2ac851f0a20302d942c8d29726c/fakeredis-2.32.0.tar.gz", hash = "sha256:63d745b40eb6c8be4899cf2a53187c097ccca3afbca04fdbc5edc8b936cd1d59", size = 171097, upload-time = "2025-10-07T10:46:58.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/1b/84ab7fd197eba5243b6625c78fbcffaa4cf6ac7dda42f95d22165f52187e/fakeredis-2.32.0-py3-none-any.whl", hash = "sha256:c9da8228de84060cfdb72c3cf4555c18c59ba7a5ae4d273f75e4822d6f01ecf8", size = 118422, upload-time = "2025-10-07T10:46:57.643Z" },
]
[[package]]
name = "fastapi"
version = "0.116.1"
@ -677,6 +714,7 @@ version = "2.4.2"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "cashews", extra = ["redis"] },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-pagination" },
{ name = "google-genai" },
@ -695,6 +733,7 @@ dependencies = [
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" },
{ name = "redis" },
{ name = "rich" },
{ name = "sentry-sdk", extra = ["anthropic", "fastapi", "sqlalchemy"] },
{ name = "sqlalchemy" },
@ -707,6 +746,7 @@ dependencies = [
dev = [
{ name = "basedpyright" },
{ name = "coverage" },
{ name = "fakeredis" },
{ name = "honcho-ai" },
{ name = "interrogate" },
{ name = "pre-commit" },
@ -721,6 +761,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "cashews", extras = ["redis"], specifier = ">=7.4.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.111.0" },
{ name = "fastapi-pagination", specifier = ">=0.12.24" },
{ name = "google-genai", specifier = ">=1.32.0" },
@ -739,6 +780,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "pyjwt", specifier = ">=2.10.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "redis", specifier = ">=6.0.0" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "sentry-sdk", extras = ["anthropic", "fastapi", "sqlalchemy"], specifier = ">=2.3.1" },
{ name = "sqlalchemy", specifier = ">=2.0.30" },
@ -751,6 +793,7 @@ requires-dist = [
dev = [
{ name = "basedpyright", specifier = ">=1.29.4" },
{ name = "coverage", specifier = ">=7.6.0" },
{ name = "fakeredis", specifier = ">=2.32.0" },
{ name = "honcho-ai", editable = "sdks/python" },
{ name = "interrogate", specifier = ">=1.7.0" },
{ name = "pre-commit", specifier = ">=4.2.0" },
@ -1967,6 +2010,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "redis"
version = "6.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
]
[[package]]
name = "regex"
version = "2025.7.34"
@ -2261,6 +2316,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.42"