perf: ES 클라이언트와 httpx 커넥션을 프로세스 단위로 재사용한다
AgentKnowledgeEsClient 를 잡·요청마다 만들고 그 안에서 다시 ES 오퍼레이션마다
httpx.AsyncClient 를 열고 닫고 있었다. 오퍼레이션 수만큼 TCP 연결 + TLS 핸드셰이크가
발생하고 소켓 FD/포트도 그만큼 churn 한다. 인덱싱은 batch_size=32 단위로 bulk 를
반복하므로 청크 2,000개짜리 파일 하나에 bulk 만 63회다. CA 번들 파싱과 기동 INFO
로그도 생성 때마다 반복돼 /search 요청 수만큼 쌓였다.
- service.get_es_client(): lru_cache(maxsize=1) 싱글턴. storage/runtime/genai 클라이언트와 같은 패턴이다.
- service.aclose_es_client(): lifespan 종료 훅. 한 번도 만들어지지 않았으면 만들지 않는다 — 여기서 get_es_client() 를 부르면 ES 미설정 파드가 종료 중 RuntimeError 를 맞는다.
- es._client(): 지연 생성 + 재사용.
async with self._client()9곳을 대입으로 바꿨다. - es.aclose(): 멱등. main.py lifespan 에서 컨슈머·워커 정지 뒤에 호출한다.
풀 설정(limits/keepalive_expiry)은 httpx 기본값을 그대로 둔다. 기본값으로도 매번 새로 만드는 것보다 무조건 낫고 더 나빠지는 경우가 없는 반면, keepalive_expiry 를 늘리면 유휴 커넥션을 재사용할 창이 넓어지는 만큼 경로 중간에서 조용히 끊긴 연결을 집어들 확률도 같이 오른다. 실측 없이 미리 건드리지 않는다.
app/storage.py 의 httpx 는 손대지 않았다. SupabaseStorageClient 전용인데 dev/stg/prod 모두 storage.provider=gcs 라 운영 경로가 아니다.
테스트: 739 → 750 passed (tests/test_agent_knowledge_es_client_reuse.py 11건 신규). ES 페이크에 남은 aenter/aexit 는 더 이상 쓰이지 않아 제거했다.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com