Skip to content

Latest commit

 

History

History
465 lines (359 loc) · 13.9 KB

ASYNC_PATTERNS.md

File metadata and controls

465 lines (359 loc) · 13.9 KB

AutoBot Async Patterns & Best Practices

Document Status: ✅ Active Last Updated: 2025-10-10 Applies To: All async operations in AutoBot backend


📋 Overview

This document describes the async programming patterns, timeout protection strategies, and best practices implemented across AutoBot's backend infrastructure. These patterns prevent event loop blocking, ensure system responsiveness, and provide predictable performance characteristics.

🎯 Core Principles

1. Non-Blocking Operations

All I/O operations (Redis, LlamaIndex, file system, network) MUST be async to prevent blocking the FastAPI event loop.

2. Timeout Protection

Every async operation MUST have explicit timeout protection to prevent indefinite hangs.

3. Connection Pooling

Use centralized connection managers (AsyncRedisManager) instead of ad-hoc connections.

4. Graceful Degradation

Always provide fallback behavior when async operations timeout or fail.


🔧 AsyncRedisManager Pattern

Status: ✅ MANDATORY for all Redis operations

Purpose

Provides connection pooling, circuit breaker protection, automatic timeouts, and health monitoring for Redis operations.

Implementation Example

from backend.utils.async_redis_manager import get_redis_manager
import asyncio

class YourService:
    def __init__(self):
        self.redis_manager = None
        self.aioredis_client = None
        self._redis_initialized = False

    async def _init_redis(self):
        """Initialize Redis connection with AsyncRedisManager"""
        try:
            # Get singleton AsyncRedisManager instance
            self.redis_manager = await get_redis_manager()

            # Get async Redis client from manager
            self.aioredis_client = await self.redis_manager.main()

            # Test connection with timeout protection (2s)
            await asyncio.wait_for(
                self.aioredis_client.ping(),
                timeout=2.0
            )

            logging.info("✅ Redis connection established with connection pooling")

        except asyncio.TimeoutError:
            logging.error("Redis connection timeout after 2s")
            self.redis_manager = None
            self.aioredis_client = None
        except Exception as e:
            logging.error(f"Failed to initialize Redis: {e}")
            self.redis_manager = None
            self.aioredis_client = None

    async def _ensure_redis_initialized(self):
        """Lazy initialization pattern - only connect when needed"""
        if not self._redis_initialized:
            await self._init_redis()
            self._redis_initialized = True

Key Features

  • Connection Pooling: Reuses connections across requests (improves throughput by ~30%)
  • Circuit Breaker: Automatically stops retry attempts after consecutive failures
  • Health Monitoring: Periodic health checks ensure connection validity
  • Automatic Timeouts: All operations have default timeouts preventing hangs
  • Graceful Degradation: Returns None on failure instead of crashing

⏱️ Timeout Protection Patterns

Standard Timeout Values

Operation Type Timeout Rationale
Redis GET/SET 2 seconds Simple key-value operations should be instant
Redis SCAN 2 seconds Key scanning is fast, timeout prevents infinite loops
LlamaIndex Operations 10 seconds Embedding generation and vector operations are slower
Document Indexing 30 seconds Complex processing with multiple steps
Search Operations 10 seconds Semantic search with vector similarity

Timeout Pattern Implementation

async def redis_operation_with_timeout(self, key: str) -> Optional[str]:
    """Example: Redis GET with timeout protection"""
    try:
        # Wrap async operation with asyncio.wait_for
        result = await asyncio.wait_for(
            self.aioredis_client.get(key),
            timeout=2.0  # 2 second timeout
        )
        return result

    except asyncio.TimeoutError:
        logging.error(f"Redis GET timeout for key: {key}")
        return None  # Graceful degradation

    except Exception as e:
        logging.error(f"Redis GET error for key {key}: {e}")
        return None

Nested Timeout Pattern (Async Thread Execution)

async def llamaindex_operation_with_timeout(self, query: str):
    """Example: LlamaIndex query with nested timeout protection"""
    try:
        # Outer timeout: Overall operation limit (10s)
        response = await asyncio.wait_for(
            # Inner: Run blocking LlamaIndex in thread
            asyncio.to_thread(
                self.query_engine.query,
                query
            ),
            timeout=10.0  # 10 second overall timeout
        )
        return response

    except asyncio.TimeoutError:
        logging.warning(f"LlamaIndex query timeout: {query[:50]}...")
        return None

    except Exception as e:
        logging.error(f"LlamaIndex query error: {e}")
        return None

🔄 Async Conversion Patterns

Pattern 1: Simple Sync → Async Conversion

Before (Blocking):

def store_fact(self, text: str, metadata: Dict[str, Any] = None) -> Dict[str, Any]:
    """BLOCKING: Sync Redis operation blocks event loop"""
    fact_id = str(uuid.uuid4())
    fact_key = f"fact:{fact_id}"

    # ❌ BLOCKS event loop during Redis operation
    self.redis_client.hset(fact_key, mapping=fact_data)

    return {"status": "success", "fact_id": fact_id}

After (Non-Blocking):

async def store_fact(self, text: str, metadata: Dict[str, Any] = None) -> Dict[str, Any]:
    """NON-BLOCKING: Async Redis with timeout protection"""
    await self._ensure_redis_initialized()

    if not self.aioredis_client:
        return {"status": "error", "message": "Redis not available"}

    fact_id = str(uuid.uuid4())
    fact_key = f"fact:{fact_id}"

    try:
        # ✅ Non-blocking with 2s timeout protection
        await asyncio.wait_for(
            self.aioredis_client.hset(fact_key, mapping=fact_data),
            timeout=2.0
        )

        return {"status": "success", "fact_id": fact_id}

    except asyncio.TimeoutError:
        logging.error("Redis timeout storing fact")
        return {"status": "error", "message": "Redis operation timeout"}

    except Exception as e:
        logging.error(f"Failed to store fact: {e}")
        return {"status": "error", "message": str(e)}

Pattern 2: Blocking Library → Async Thread Execution

Before (Blocking):

def search_knowledge(self, query: str) -> List[Dict]:
    """BLOCKING: LlamaIndex query blocks event loop"""
    # ❌ LlamaIndex is sync library - blocks for 5-10 seconds
    response = self.query_engine.query(query)

    results = []
    for node in response.source_nodes:
        results.append({
            "content": node.node.text,
            "score": node.score
        })
    return results

After (Non-Blocking):

async def search_knowledge(self, query: str) -> List[Dict]:
    """NON-BLOCKING: LlamaIndex in thread with timeout"""
    try:
        # ✅ Run blocking LlamaIndex in thread with 10s timeout
        response = await asyncio.wait_for(
            asyncio.to_thread(self.query_engine.query, query),
            timeout=10.0
        )

        results = []
        for node in response.source_nodes:
            results.append({
                "content": node.node.text,
                "score": node.score
            })
        return results

    except asyncio.TimeoutError:
        logging.warning(f"Search timeout for query: {query[:50]}...")
        return []

    except Exception as e:
        logging.error(f"Search error: {e}")
        return []

Pattern 3: Lazy Async Initialization

Purpose: Initialize expensive resources only when first needed, not during object construction.

class KnowledgeBase:
    def __init__(self):
        """Lightweight constructor - no I/O operations"""
        self.redis_manager = None
        self.aioredis_client = None
        self.vector_index = None
        self._redis_initialized = False

    async def _init_redis_and_vector_store(self):
        """Heavy initialization - called on first use"""
        # Initialize Redis connection
        self.redis_manager = await get_redis_manager()
        self.aioredis_client = await self.redis_manager.main()

        # Initialize vector store
        await self._init_vector_store()

    async def _ensure_redis_initialized(self):
        """Ensure Redis initialized before operations"""
        if not self._redis_initialized:
            await self._init_redis_and_vector_store()
            self._redis_initialized = True

    async def store_fact(self, text: str, metadata: Dict = None) -> Dict:
        """Lazy initialization - Redis initialized on first call"""
        await self._ensure_redis_initialized()  # ✅ Initialize if needed

        # Proceed with operation
        # ...

📊 Performance Characteristics

Before Async Conversion (Blocking)

Redis Operation:       50-200ms (blocks event loop)
LlamaIndex Query:      5-10s (blocks entire backend)
Document Indexing:     10-30s (backend unresponsive)
Concurrent Requests:   Sequential (one at a time)
Throughput:           ~2-5 req/sec

After Async Conversion (Non-Blocking)

Redis Operation:       5-20ms (concurrent, pooled)
LlamaIndex Query:      5-10s (in thread, non-blocking)
Document Indexing:     10-30s (in thread, other requests work)
Concurrent Requests:   Parallel (10-100 simultaneous)
Throughput:           ~50-100 req/sec (+900% improvement)

Measured Performance Improvements

Metric Before After Improvement
Redis Throughput ~100 ops/sec ~150 ops/sec +30%
Concurrent Requests 1-2 50-100 +2400%
Backend Responsiveness Hangs during indexing Always responsive 100% uptime
Event Loop Blocking Common (5-10s hangs) None Eliminated

🧪 Testing Async Code

Unit Test Pattern

import pytest
import asyncio

@pytest.mark.asyncio
async def test_store_fact_async():
    """Test async fact storage with timeout protection"""
    kb = KnowledgeBase()

    # Test successful storage
    result = await kb.store_fact("Test fact", {"source": "test"})
    assert result["status"] == "success"
    assert "fact_id" in result

    # Test timeout handling (mock slow Redis)
    with patch.object(kb.aioredis_client, 'hset', side_effect=asyncio.TimeoutError):
        result = await kb.store_fact("Test fact", {})
        assert result["status"] == "error"
        assert "timeout" in result["message"].lower()

Integration Test Pattern

@pytest.mark.asyncio
async def test_concurrent_redis_operations():
    """Test concurrent Redis operations with connection pooling"""
    kb = KnowledgeBase()
    await kb._ensure_redis_initialized()

    # Launch 10 concurrent operations
    tasks = [
        kb.store_fact(f"Fact {i}", {"index": i})
        for i in range(10)
    ]

    # All should complete without blocking
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # Verify all succeeded
    success_count = sum(1 for r in results if isinstance(r, dict) and r.get("status") == "success")
    assert success_count == 10

⚠️ Common Pitfalls & Solutions

Pitfall 1: Forgetting await

Problem:

# ❌ WRONG: Returns coroutine object, doesn't execute
result = kb.store_fact("test", {})  # <coroutine object>

Solution:

# ✅ CORRECT: Awaits coroutine execution
result = await kb.store_fact("test", {})  # Actual result

Pitfall 2: No Timeout Protection

Problem:

# ❌ WRONG: Can hang indefinitely
result = await self.aioredis_client.get(key)

Solution:

# ✅ CORRECT: Timeout protection prevents hangs
result = await asyncio.wait_for(
    self.aioredis_client.get(key),
    timeout=2.0
)

Pitfall 3: Blocking Calls in Async Functions

Problem:

async def search(self, query: str):
    # ❌ WRONG: Blocks event loop for 5-10 seconds
    response = self.query_engine.query(query)  # Blocking sync call
    return response

Solution:

async def search(self, query: str):
    # ✅ CORRECT: Run blocking call in thread pool
    response = await asyncio.to_thread(
        self.query_engine.query,
        query
    )
    return response

Pitfall 4: Creating New Redis Connections Per Request

Problem:

async def operation(self):
    # ❌ WRONG: Creates new connection every time
    client = await aioredis.from_url("redis://...")
    await client.get("key")
    await client.close()

Solution:

async def operation(self):
    # ✅ CORRECT: Use centralized AsyncRedisManager
    await self._ensure_redis_initialized()  # Reuses pooled connection
    await self.aioredis_client.get("key")

📚 Additional Resources

  • AsyncRedisManager Implementation: backend/utils/async_redis_manager.py
  • Knowledge Base Async Conversion: src/knowledge_base.py
  • Async Migration Guide: docs/developer/ASYNC_MIGRATION_GUIDE.md
  • FastAPI Async Best Practices: https://fastapi.tiangolo.com/async/

🎯 Summary Checklist

When implementing async operations in AutoBot:

  • Use async def for all I/O operations
  • Initialize AsyncRedisManager via get_redis_manager()
  • Wrap all async operations with asyncio.wait_for() and appropriate timeouts
  • Use asyncio.to_thread() for blocking library calls (LlamaIndex, file I/O)
  • Implement lazy initialization pattern for expensive resources
  • Provide graceful degradation on timeout/failure
  • Add comprehensive logging for timeout and error cases
  • Write async unit tests with @pytest.mark.asyncio
  • Test concurrent operations to verify non-blocking behavior

Following these patterns ensures AutoBot remains responsive, scalable, and performant under load.