ChromaDB streaming response drops under high concurrency in production

Problem – Intermittent Streaming Response Drops in ChromaDB Under High Concurrency

In a production environment that serves dozens of simultaneous query calls with stream=True, operators observed that the response stream terminates prematurely. Clients receive only a subset of the expected result chunks, leading to incomplete search results and downstream processing errors.

Typical symptoms:

  • HTTP/2 or gRPC streaming connection closed after 200 OK with Transfer-Encoding: chunked but before the final 0\r\n\r\n terminator.
  • Client logs such as “Stream closed unexpectedly after 12 chunks”.
  • Server logs show “TaskCancelledError: Streaming response cancelled” or “uvicorn.error: Worker failed to write to socket”.
  • Latency spikes and increased 5xx error rate during peak load.

Root Cause – Thread‑Pool Exhaustion and Back‑Pressure Mis‑management

ChromaDB’s Python server (built on FastAPI + uvicorn) uses a ThreadPoolExecutor for blocking I/O (vector index lookups, disk reads). Under high concurrency the following chain of events occurs:

  1. Concurrent queries saturate the executor. The default max_workers equals the number of CPU cores. With 200+ simultaneous streaming requests the pool queues thousands of blocking tasks.
  2. Back‑pressure propagates to the HTTP/2 stream. When the executor cannot schedule a new task, the coroutine awaiting the result yields control. The underlying ASGI server attempts to write the next chunk, but the socket buffer is full because the client is still reading.
  3. uvicorn’s worker detects a write timeout. By default uvicorn aborts the connection after timeout_keep_alive (5 s) of inactivity, raising TaskCancelledError. The client sees a truncated stream.
  4. Resource limits amplify the problem. System‑wide file‑descriptor limits (ulimit -n) and TCP socket buffers are also exhausted, causing “Too many open files” warnings in the logs.

The root cause is therefore a mismatch between the expected streaming throughput and the capacity of the server’s thread pool and socket buffers under peak load.

Debug – Systematic Investigation Steps

Below is a reproducible debugging workflow that isolates the failure point.

1. Capture Server Logs Around the Failure


2024-09-27 14:32:10,123 uvicorn.error INFO     172.31.12.45:54321 - "GET /v1/query?stream=true HTTP/2" 200
2024-09-27 14:32:10,127 app.handlers DEBUG   Streaming chunk 1/50 for request id=abc123
2024-09-27 14:32:10,135 app.handlers DEBUG   Streaming chunk 2/50 for request id=abc123
...
2024-09-27 14:32:12,401 uvicorn.error WARNING  Worker failed to write to socket: [Errno 32] Broken pipe
2024-09-27 14:32:12,402 app.handlers ERROR    TaskCancelledError: Streaming response cancelled (request id=abc123)

2. Verify Thread‑Pool Saturation


# On the host running the ChromaDB service
ps -eo pid,comm,pcpu,pmem,stat | grep uvicorn
# Check executor queue length via a custom endpoint (if enabled)
curl -s http://localhost:8000/metrics | grep chroma_threadpool_queue_size

Typical output when saturated:


chromadb_threadpool_queue_size 1245

3. Inspect OS‑level Limits


ulimit -n
# Expected >= 65536 for high‑concurrency services
cat /proc/sys/net/core/somaxconn
# Expected >= 1024

4. Capture a Packet Trace (optional)


sudo tcpdump -i eth0 -w /tmp/chroma_stream.pcap 'port 8000 and tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0'

Look for repeated FIN/RST packets shortly after the first few chunks.

5. Reproduce with a Controlled Load Test


# Simple Python client reproducing the issue
import httpx, asyncio

async def stream_query(i):
    async with httpx.AsyncClient(http2=True) as client:
        async with client.stream("GET", "http://localhost:8000/v1/query?stream=true") as resp:
            async for chunk in resp.aiter_bytes():
                print(f"client {i} received {len(chunk)} bytes")
            print(f"client {i} completed")

async def main():
    tasks = [stream_query(i) for i in range(200)]  # high concurrency
    await asyncio.gather(*tasks, return_exceptions=True)

asyncio.run(main())

Observe that many tasks raise httpx.ReadError: [Errno 32] Broken pipe after a few chunks.

Solution – Scale the Executor and Tune Back‑Pressure Controls

The fix consists of three coordinated changes:

1. Increase the Thread‑Pool Capacity

Configure uvicorn to use a larger pool via the --workers flag and replace the default executor with a custom one that has a higher max_workers.

Before (default startup script):


# start.sh
uvicorn chromadb.main:app --host 0.0.0.0 --port 8000

After (scaled configuration):


# start.sh
export CHROMADB_MAX_WORKERS=32   # 4× CPU cores on a 8‑core node
uvicorn chromadb.main:app \
    --host 0.0.0.0 \
    --port 8000 \
    --workers 4 \
    --loop uvloop \
    --http h11 \
    --timeout-keep-alive 30

Inside chromadb/main.py replace the executor initialization:


# chromadb/main.py – before
executor = ThreadPoolExecutor()

# after – use environment variable
import os
max_workers = int(os.getenv("CHROMADB_MAX_WORKERS", "8"))
executor = ThreadPoolExecutor(max_workers=max_workers)

2. Enable Asynchronous Index Access

If the underlying vector store supports async I/O (e.g., faiss with aiofiles), refactor the blocking lookup into an async coroutine. This removes the need for a large thread pool.


# Before – blocking call
def query_vectors(...):
    results = index.search(...)

# After – async wrapper
async def query_vectors_async(...):
    loop = asyncio.get_running_loop()
    results = await loop.run_in_executor(None, index.search, ...)
    return results

3. Raise uvicorn Keep‑Alive and Write Timeouts

Increase the socket write timeout to give the server more breathing room when the client processes chunks slowly.


# start.sh – additional flags
uvicorn chromadb.main:app \
    --host 0.0.0.0 \
    --port 8000 \
    --workers 4 \
    --timeout-keep-alive 30 \
    --ws ping_interval 20 \
    --ws ping_timeout 60

4. Raise OS Limits

Apply system‑wide limits in the service unit (systemd) or Docker container.


# systemd service snippet
[Service]
LimitNOFILE=200000
LimitNPROC=50000

Verify – Confirming the Fix Works

After redeploying with the new configuration, repeat the load test and observe the metrics.

1. Log Inspection


2024-09-27 15:01:03,112 uvicorn.error INFO     172.31.12.45:54321 - "GET /v1/query?stream=true HTTP/2" 200
2024-09-27 15:01:03,115 app.handlers DEBUG   Streaming chunk 1/50 for request id=def456
...
2024-09-27 15:01:08,432 app.handlers DEBUG   Streaming chunk 50/50 for request id=def456
2024-09-27 15:01:08,435 app.handlers INFO    Completed streaming for request id=def456

2. Metrics Check


# Prometheus query
chromadb_threadpool_queue_size{job="chroma"} == 0

3. Client‑side Confirmation


client 73 received 1024 bytes
...
client 73 completed

All 200 concurrent clients finish without errors.

Prevent – Operational Guardrails for Future Deployments

  • Capacity Planning: Align CHROMADB_MAX_WORKERS with expected query concurrency. Use load‑testing tools (e.g., locust) to establish a safe ceiling.
  • Back‑Pressure Monitoring: Export chromadb_threadpool_queue_size and uvicorn_worker_write_timeouts_total to a monitoring system; alert when queue size exceeds 80 % of the configured limit.
  • Resource Limits: Enforce LimitNOFILE and net.core.somaxconn values in production containers. Document required values in the deployment README.
  • Graceful Degradation: Implement a fallback endpoint that returns a non‑streaming result when the executor queue length exceeds a threshold, preventing socket timeouts.
  • Version Pinning: The issue was introduced in ChromaDB v0.4.2 where the default executor size was reduced. Pin to v0.4.3 or later, where the executor size is configurable.

Related Topic Hub: Vector Databases Troubleshooting Hub

FAQ

  1. Why does the problem appear only under high concurrency and not in staging?
    Staging typically runs a single‑worker instance with low request volume, so the default thread pool never saturates. Production scales horizontally but also concentrates many streaming requests on each worker, exposing the executor bottleneck.
  2. Can I keep the default max_workers and just increase timeout-keep-alive?
    Increasing the timeout reduces premature termination but does not address the underlying queue buildup. The executor will still block new tasks, eventually causing socket buffer exhaustion and timeouts.
  3. Is switching to uvloop sufficient?
    uvloop improves event‑loop throughput but does not affect the blocking thread pool used for index lookups. It is a complementary optimization, not a replacement for scaling the executor.
  4. How do I know if my vector store library is the bottleneck?
    Instrument the index lookup duration (e.g., using time.perf_counter()) and export it as a Prometheus histogram. If the 95th‑percentile latency spikes before the thread‑pool queue does, the store itself may need sharding or caching.
  5. Will increasing the number of uvicorn workers solve the issue?
    Adding workers distributes load across processes, which helps, but each worker still has its own thread pool. If the total concurrency exceeds the combined capacity, you must also increase CHROMADB_MAX_WORKERS or make the index access fully asynchronous.