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 OKwithTransfer-Encoding: chunkedbut before the final0\r\n\r\nterminator. - 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:
- Concurrent queries saturate the executor. The default
max_workersequals the number of CPU cores. With 200+ simultaneous streaming requests the pool queues thousands of blocking tasks. - 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.
- uvicorn’s worker detects a write timeout. By default uvicorn aborts the connection after
timeout_keep_alive(5 s) of inactivity, raisingTaskCancelledError. The client sees a truncated stream. - 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_WORKERSwith expected query concurrency. Use load‑testing tools (e.g.,locust) to establish a safe ceiling. - Back‑Pressure Monitoring: Export
chromadb_threadpool_queue_sizeanduvicorn_worker_write_timeouts_totalto a monitoring system; alert when queue size exceeds 80 % of the configured limit. - Resource Limits: Enforce
LimitNOFILEandnet.core.somaxconnvalues 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.2where the default executor size was reduced. Pin tov0.4.3or later, where the executor size is configurable.
Related Topic Hub: Vector Databases Troubleshooting Hub
FAQ
- 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. - Can I keep the default
max_workersand just increasetimeout-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. - Is switching to
uvloopsufficient?
uvloopimproves 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. - How do I know if my vector store library is the bottleneck?
Instrument the index lookup duration (e.g., usingtime.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. - 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 increaseCHROMADB_MAX_WORKERSor make the index access fully asynchronous.