[NOTICKET][AI] perf(chat): PR-DB1 - memoizing catalog reader + shared ChatHandler
Browse filesCut redundant catalog-DB round-trips in the slow path and stop rebuilding the
Azure LLM clients per request.
- MemoizingCatalogReader (catalog/reader.py): request-scoped wrapper that caches
each read by source_hint. ChatHandler builds one per request and threads it into
the slow-path DataAccessToolInvoker, so the planner's catalog load is reused by
describe_source / query_structured instead of re-reading from the catalog DB.
Measured live: describe_source 3.5s -> ~2.0s; catalog reads/request ~5 -> ~2.
- chat.py: one module-level ChatHandler singleton instead of per-request, keeping
the Orchestrator/Chatbot Azure clients (and TLS pools) warm across requests.
Does not touch the external query_structured handshake (that is the engine-cache
work, DB2). Also logs the principal-review fix tracker (R1-R12, DB1-DB3) in PROGRESS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .gitignore +1 -0
- PROGRESS.md +55 -1
- src/agents/chat_handler.py +13 -5
- src/api/v1/chat.py +7 -1
- src/catalog/reader.py +27 -0
|
@@ -46,6 +46,7 @@ test_tesseract.py
|
|
| 46 |
# Windows binaries — installed via apt in Docker instead
|
| 47 |
software/
|
| 48 |
|
|
|
|
| 49 |
tests/
|
| 50 |
.claude/
|
| 51 |
migratego/
|
|
|
|
| 46 |
# Windows binaries — installed via apt in Docker instead
|
| 47 |
software/
|
| 48 |
|
| 49 |
+
scripts/
|
| 50 |
tests/
|
| 51 |
.claude/
|
| 52 |
migratego/
|
|
@@ -2,11 +2,65 @@
|
|
| 2 |
|
| 3 |
Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "Team — division of work". Update as PRs land. Future Claude Code sessions read this to know what's already done.
|
| 4 |
|
| 5 |
-
**Last updated**: 2026-06-10 (
|
| 6 |
**Current open PR**: `pr/2` — active.
|
| 7 |
|
| 8 |
---
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
## What just shipped (2026-06-09/10 — tool layer, tracing, slow-path wiring)
|
| 11 |
|
| 12 |
Big stretch since the slow-path workers landed. The tool layer (teammate-owned) is
|
|
|
|
| 2 |
|
| 3 |
Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "Team — division of work". Update as PRs land. Future Claude Code sessions read this to know what's already done.
|
| 4 |
|
| 5 |
+
**Last updated**: 2026-06-10 (principal review findings logged + PR-DB1 latency work started)
|
| 6 |
**Current open PR**: `pr/2` — active.
|
| 7 |
|
| 8 |
---
|
| 9 |
|
| 10 |
+
## Principal architecture review (2026-06-10) — findings + fix tracker
|
| 11 |
+
|
| 12 |
+
A full external review (read the context docs + the slow path, tool layer, query
|
| 13 |
+
spine, catalog plumbing, chat endpoint, config/connection layers) landed. It confirmed
|
| 14 |
+
the DB-latency diagnosis and surfaced several gaps **not previously tracked here**.
|
| 15 |
+
Verified against code before logging. Severity: **critical** / important / nice-to-have.
|
| 16 |
+
|
| 17 |
+
**Runtime / latency (the original problem):**
|
| 18 |
+
- DB connection handling is the anomaly, NOT cold start. `DbExecutor._run_sync`
|
| 19 |
+
(`db.py:192`) → `engine_scope` does `create_engine → connect (TCP+TLS+SCRAM) → 2×SET
|
| 20 |
+
→ dispose` on EVERY query. Measured ~6–8s for 60 rows; a 2nd warm-session query was
|
| 21 |
+
still ~6.6s → per-call handshake, never amortized. `engine_scope`'s connect-once-dispose
|
| 22 |
+
semantics were designed for the ingestion pipeline and wrongly inherited by the query path.
|
| 23 |
+
- `describe_source` ~3.5s is **planner-induced waste**: every few-shot (`examples.py`)
|
| 24 |
+
opens with a `describe_source` task, so the LLM always plans a tool that re-reads from
|
| 25 |
+
the catalog DB the same catalog already rendered into its prompt. Its impl does 2
|
| 26 |
+
sequential full-catalog reads (`data_access.py:127-128`). Total catalog reads/request ~5×.
|
| 27 |
+
- Azure LLM clients rebuilt per request: `ChatHandler(enable_tracing=True)` is constructed
|
| 28 |
+
per request (`chat.py:172`) → fresh Orchestrator/Chatbot → fresh AzureChatOpenAI → fresh
|
| 29 |
+
TLS to Azure each call. Planner/Assembler correctly use module singletons; the other two don't.
|
| 30 |
+
- Tokens (~13k/request) are NORMAL for this design — do not optimize for $.
|
| 31 |
+
- **Reject the scheduled DB-warmer idea**: targets cold start (~1.8s slice) not the per-call
|
| 32 |
+
handshake, keeps serverless user DBs awake 24/7 (their compute bill), and decrypts every
|
| 33 |
+
tenant's creds on a cron (attack surface). Strictly dominated by an engine cache +
|
| 34 |
+
request-scoped pre-connect.
|
| 35 |
+
|
| 36 |
+
**Fix tracker (new):**
|
| 37 |
+
|
| 38 |
+
| # | Fix | Severity | Owner | Status |
|
| 39 |
+
|---|---|---|---|---|
|
| 40 |
+
| R1 | **AuthN/AuthZ** on data endpoints — derive `user_id` from JWT (`security/auth.py` helpers exist), reject body-supplied IDs. `/chat/stream` has none (`chat.py:40,128`); tenant isolation is client honesty. **Gates the engine-cache work.** | **critical** | DB/B | `[ ]` |
|
| 41 |
+
| R2 | **Always compile a LIMIT** — `sql.py:281` emits no LIMIT when `ir.limit is None`; `db.py:93` materializes ALL rows before the 10k cap. Compile `LIMIT min(ir.limit or CAP, CAP)+1`. | **critical** | DB | `[ ]` |
|
| 42 |
+
| R3 | **Commit `tests/` + minimal CI** — `tests/` is gitignored; the 200+ tests cited as done exist only on laptops (already caused rename rot). GitHub origin carries tests; HF Space gets the Docker build (already doesn't COPY tests). | **critical (process)** | shared | `[ ]` |
|
| 43 |
+
| DB1 | **In-memory `describe_source`** (request-scoped `MemoizingCatalogReader`, `reader.py`) + **LLM-client hoist** (shared module-level `ChatHandler` in `chat.py`). Measured live: `describe_source` 3.5s→~2.0s (structured read now served from the planner's cached snapshot; only the unstructured read remains a round-trip), catalog reads/request ~5→~2. External `query_structured` handshake unchanged (DB2's job) so total slow path is ~flat until DB2. Tests: `tests/catalog/test_reader.py`. | important | agent | `[x]` |
|
| 44 |
+
| DB2 | **Keyed engine cache** (LRU, small pool, `pool_recycle`, `pool_pre_ping`) replacing per-call `engine_scope`; read-only + statement_timeout via `connect_args options` (zero SET round-trips, read-only-at-birth); invalidate on client update/delete. Extract to `src/database_client/engine.py`. **Land with/after R1.** | important | DB | `[ ]` |
|
| 45 |
+
| DB3 | **Speculative pre-connect** overlapped with the ~4s Planner call (only if DB2 still shows first-touch handshake). | nice-to-have | DB | `[ ]` |
|
| 46 |
+
| R4 | **Stream the Assembler + emit TaskRunner progress events** — slow path streams nothing for ~20s (`chat_handler.py:321` single chunk); proxy idle-timeout + UX risk. | important | agent | `[ ]` |
|
| 47 |
+
| R5 | **Response cache**: key on `user_id` + catalog version; invalidate on ingest. Today `chat:{room_id}:{message}`, 24h TTL, no user (`chat.py:138`) → cross-room replay + stale answers. | important | B | `[ ]` |
|
| 48 |
+
| R6 | **Hard time budget** — wrap `coordinator.run()` in `asyncio.wait_for` (60–90s). `Constraints.time_budget_seconds` is rendered but not enforced. | important | agent | `[ ]` |
|
| 49 |
+
| R7 | **Root-task-failure short-circuit** before the Assembler (templated/fast-path fallback, NOT replanning) — stops paying ~2k tok to narrate an empty RunState. | important | agent | `[ ]` |
|
| 50 |
+
| R8 | **Catalog upsert race** — per-user advisory lock around read-merge-upsert (`store.py`); concurrent uploads can drop a source. | important | DB | `[ ]` |
|
| 51 |
+
| R9 | **`extra="ignore"`** in `settings.py:15` (currently `allow` → typo'd env vars silently swallowed); require Azure keys in prod. | nice-to-have | B | `[ ]` |
|
| 52 |
+
| R10 | **Read-only enforcement is session-state, not a server role.** `REPO_CONTEXT.md` counts "read-only DB credentials" as a defense layer but nothing requests/verifies a read-only role. Either request read-only creds at registration (verify via `SELECT current_setting(...)`) or drop the claim. | important | DB | `[ ]` |
|
| 53 |
+
| R11 | **De-duplicate** `_PLACEHOLDER_RE` (`task_runner.py:31` vs validator) and `_DATA_ACCESS_TOOLS` (invoker vs planner registry) — import one from the other; comments aren't a sync mechanism. | nice-to-have | agent/tool | `[ ]` |
|
| 54 |
+
| R12 | **Doc/process hygiene** — gitignored canonical docs (`AGENT_ARCHITECTURE_CONTEXT_new.md`, `PROJECT_SUMMARY.md`) are cited by code docstrings but absent on disk; `CLAUDE.md` lists deleted modules (enricher, `pipeline/orchestrator.py`); `main` is 38 commits behind on a dead architecture. | nice-to-have | agent | `[ ]` |
|
| 55 |
+
|
| 56 |
+
**Architecture verdict:** fundamentally sound (catalog-driven IR + deterministic compiler
|
| 57 |
+
+ static plan is the right call). Debt is transitional duplication (two planners/registries/
|
| 58 |
+
contract modules — documented, owned) and `ChatHandler` drifting toward a god object
|
| 59 |
+
(extract the slow-path composition root + the SSE `_build_sources`/`_normalize_chunks`
|
| 60 |
+
mappers when convenient).
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
## What just shipped (2026-06-09/10 — tool layer, tracing, slow-path wiring)
|
| 65 |
|
| 66 |
Big stretch since the slow-path workers landed. The tool layer (teammate-owned) is
|
|
@@ -156,10 +156,17 @@ class ChatHandler:
|
|
| 156 |
# ---- 2. Route ------------------------------------------------
|
| 157 |
if decision.source_hint == "structured":
|
| 158 |
try:
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
if self._enable_slow_path:
|
| 161 |
async for event in self._run_slow_path(
|
| 162 |
-
user_id, rewritten, catalog, tracer
|
| 163 |
):
|
| 164 |
yield event
|
| 165 |
return
|
|
@@ -238,7 +245,7 @@ class ChatHandler:
|
|
| 238 |
return RequestTracer.start(user_id=user_id, question=question)
|
| 239 |
|
| 240 |
def _get_slow_path_coordinator(
|
| 241 |
-
self, user_id: str, tracer: Any = None
|
| 242 |
) -> SlowPathCoordinator:
|
| 243 |
"""Build the per-request slow-path coordinator (composition root).
|
| 244 |
|
|
@@ -260,7 +267,7 @@ class ChatHandler:
|
|
| 260 |
from .slow_path.task_runner import TaskRunner
|
| 261 |
|
| 262 |
invoker: Any = CompositeToolInvoker(
|
| 263 |
-
DataAccessToolInvoker(user_id, self._get_catalog_reader()),
|
| 264 |
AnalyticsToolInvoker(),
|
| 265 |
)
|
| 266 |
if tracer is not None and getattr(tracer, "active", False):
|
|
@@ -285,6 +292,7 @@ class ChatHandler:
|
|
| 285 |
query: str,
|
| 286 |
catalog: Any,
|
| 287 |
tracer: Any = None,
|
|
|
|
| 288 |
) -> AsyncIterator[dict[str, Any]]:
|
| 289 |
"""Run the slow path and stream its assembled answer as SSE events.
|
| 290 |
|
|
@@ -301,7 +309,7 @@ class ChatHandler:
|
|
| 301 |
|
| 302 |
tracer = NullTracer()
|
| 303 |
|
| 304 |
-
coordinator = self._get_slow_path_coordinator(user_id, tracer)
|
| 305 |
context = await get_business_context(user_id)
|
| 306 |
pc = tracer.callbacks() # planner: PII-safe, full capture
|
| 307 |
ac = tracer.callbacks(masked=True) # assembler: sees real rows -> masked
|
|
|
|
| 156 |
# ---- 2. Route ------------------------------------------------
|
| 157 |
if decision.source_hint == "structured":
|
| 158 |
try:
|
| 159 |
+
# One memoizing reader per request: the same catalog is otherwise
|
| 160 |
+
# re-fetched from the catalog DB 4-5x across the slow-path run. This
|
| 161 |
+
# collapses those to one round-trip per source_hint and pins a single
|
| 162 |
+
# consistent snapshot for plan + execution.
|
| 163 |
+
from ..catalog.reader import MemoizingCatalogReader
|
| 164 |
+
|
| 165 |
+
req_reader = MemoizingCatalogReader(self._get_catalog_reader())
|
| 166 |
+
catalog = await req_reader.read(user_id, "structured")
|
| 167 |
if self._enable_slow_path:
|
| 168 |
async for event in self._run_slow_path(
|
| 169 |
+
user_id, rewritten, catalog, tracer, req_reader
|
| 170 |
):
|
| 171 |
yield event
|
| 172 |
return
|
|
|
|
| 245 |
return RequestTracer.start(user_id=user_id, question=question)
|
| 246 |
|
| 247 |
def _get_slow_path_coordinator(
|
| 248 |
+
self, user_id: str, tracer: Any = None, catalog_reader: CatalogReader | None = None
|
| 249 |
) -> SlowPathCoordinator:
|
| 250 |
"""Build the per-request slow-path coordinator (composition root).
|
| 251 |
|
|
|
|
| 267 |
from .slow_path.task_runner import TaskRunner
|
| 268 |
|
| 269 |
invoker: Any = CompositeToolInvoker(
|
| 270 |
+
DataAccessToolInvoker(user_id, catalog_reader or self._get_catalog_reader()),
|
| 271 |
AnalyticsToolInvoker(),
|
| 272 |
)
|
| 273 |
if tracer is not None and getattr(tracer, "active", False):
|
|
|
|
| 292 |
query: str,
|
| 293 |
catalog: Any,
|
| 294 |
tracer: Any = None,
|
| 295 |
+
catalog_reader: CatalogReader | None = None,
|
| 296 |
) -> AsyncIterator[dict[str, Any]]:
|
| 297 |
"""Run the slow path and stream its assembled answer as SSE events.
|
| 298 |
|
|
|
|
| 309 |
|
| 310 |
tracer = NullTracer()
|
| 311 |
|
| 312 |
+
coordinator = self._get_slow_path_coordinator(user_id, tracer, catalog_reader)
|
| 313 |
context = await get_business_context(user_id)
|
| 314 |
pc = tracer.callbacks() # planner: PII-safe, full capture
|
| 315 |
ac = tracer.callbacks(masked=True) # assembler: sees real rows -> masked
|
|
@@ -22,6 +22,12 @@ logger = get_logger("chat_api")
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
_GREETINGS = frozenset(["hi", "hello", "hey", "halo", "hai", "hei"])
|
| 26 |
_GOODBYES = frozenset(["bye", "goodbye", "thanks", "thank you", "terima kasih", "sampai jumpa"])
|
| 27 |
|
|
@@ -169,7 +175,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
|
|
| 169 |
return EventSourceResponse(stream_direct())
|
| 170 |
|
| 171 |
history = await load_history(db, request.room_id, limit=10)
|
| 172 |
-
handler =
|
| 173 |
|
| 174 |
async def stream_response():
|
| 175 |
logger.info("stream_response started", room_id=request.room_id, user_id=request.user_id)
|
|
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
| 24 |
|
| 25 |
+
# One shared ChatHandler for the process. It holds no per-request state (user_id
|
| 26 |
+
# is passed into handle()), and lazily builds + caches the Orchestrator/Chatbot
|
| 27 |
+
# chains — so reusing it keeps the Azure OpenAI clients (and their httpx/TLS pools)
|
| 28 |
+
# warm across requests instead of re-handshaking on the first call of every request.
|
| 29 |
+
_chat_handler = ChatHandler(enable_tracing=True)
|
| 30 |
+
|
| 31 |
_GREETINGS = frozenset(["hi", "hello", "hey", "halo", "hai", "hei"])
|
| 32 |
_GOODBYES = frozenset(["bye", "goodbye", "thanks", "thank you", "terima kasih", "sampai jumpa"])
|
| 33 |
|
|
|
|
| 175 |
return EventSourceResponse(stream_direct())
|
| 176 |
|
| 177 |
history = await load_history(db, request.room_id, limit=10)
|
| 178 |
+
handler = _chat_handler
|
| 179 |
|
| 180 |
async def stream_response():
|
| 181 |
logger.info("stream_response started", room_id=request.room_id, user_id=request.user_id)
|
|
@@ -38,3 +38,30 @@ class CatalogReader:
|
|
| 38 |
filtered = [s for s in catalog.sources if s.source_type == "unstructured"]
|
| 39 |
|
| 40 |
return catalog.model_copy(update={"sources": filtered})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
filtered = [s for s in catalog.sources if s.source_type == "unstructured"]
|
| 39 |
|
| 40 |
return catalog.model_copy(update={"sources": filtered})
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class MemoizingCatalogReader(CatalogReader):
|
| 44 |
+
"""Request-scoped CatalogReader that caches each ``read`` by source_hint.
|
| 45 |
+
|
| 46 |
+
One per request. The same per-user catalog is otherwise fetched from the
|
| 47 |
+
catalog DB 4-5x during a single slow-path run (planner load, then
|
| 48 |
+
describe_source's structured+unstructured reads, then query_structured's
|
| 49 |
+
structured read). Wrapping the base reader collapses those to one round-trip
|
| 50 |
+
per distinct source_hint and pins a single consistent snapshot for the whole
|
| 51 |
+
request (plan-time and execution-time catalogs can no longer diverge).
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(self, inner: CatalogReader) -> None:
|
| 55 |
+
# `read` is fully overridden below and delegates to `inner`, so the parent's
|
| 56 |
+
# `_store` is never used — carry it through only so this stays a real
|
| 57 |
+
# CatalogReader (any inner with a `read` works, including test fakes).
|
| 58 |
+
super().__init__(getattr(inner, "_store", None))
|
| 59 |
+
self._inner = inner
|
| 60 |
+
self._cache: dict[SourceHint, Catalog] = {}
|
| 61 |
+
|
| 62 |
+
async def read(self, user_id: str, source_hint: SourceHint) -> Catalog:
|
| 63 |
+
cached = self._cache.get(source_hint)
|
| 64 |
+
if cached is None:
|
| 65 |
+
cached = await self._inner.read(user_id, source_hint)
|
| 66 |
+
self._cache[source_hint] = cached
|
| 67 |
+
return cached
|