Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
4c62146
·
1 Parent(s): 0e2bb5c

[NOTICKET][AI] perf(chat): PR-DB1 - memoizing catalog reader + shared ChatHandler

Browse files

Cut 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 CHANGED
@@ -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/
PROGRESS.md CHANGED
@@ -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 (tool layer complete + Langfuse tracing + gated slow-path wiring)
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
src/agents/chat_handler.py CHANGED
@@ -156,10 +156,17 @@ class ChatHandler:
156
  # ---- 2. Route ------------------------------------------------
157
  if decision.source_hint == "structured":
158
  try:
159
- catalog = await self._get_catalog_reader().read(user_id, "structured")
 
 
 
 
 
 
 
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
src/api/v1/chat.py CHANGED
@@ -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 = ChatHandler(enable_tracing=True)
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)
src/catalog/reader.py CHANGED
@@ -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