db connection pooling and rag limit transparency

This commit is contained in:
ValueOn AG 2026-05-17 20:38:33 +02:00
parent dbf176ac97
commit 656cf3eacb
2 changed files with 244 additions and 0 deletions

View file

@ -0,0 +1,237 @@
<!-- status: build -->
<!-- started: 2026-05-17 -->
<!-- component: gateway -->
# PostgreSQL Connection-Pool — Beseitigung der Shared-Connection-Race
## Beschreibung und Kontext
Der zentrale `DatabaseConnector` in `gateway/modules/connectors/connectorDbPostgre.py` hält **genau eine psycopg2-Connection pro Instanz**. Die Modul-Cache-Funktion `getCachedConnector(host, db, user, port)` gibt für denselben Schlüssel immer denselben Connector zurück. In `DatabaseConnector.__init__` wird zwar ein `self._lock = threading.Lock()` deklariert, im ganzen 1789-zeiligen File aber **kein einziges Mal genutzt** (`with self._lock:` → 0 Treffer).
Folge: Sobald zwei FastAPI-Threadpool-Worker (für `def`-Routes) oder zwei async-Tasks (für `async def`) gleichzeitig auf dieselbe gecachte Connection zugreifen — `connection.cursor()` / `cursor.execute()` —, gibt psycopg2 entweder `OperationalError: another command is already in progress` oder hängt im `recv()`-Syscall **ohne Timeout**. Ein einziger hängender Call vergiftet die Connection; alle weiteren Calls auf demselben Cache-Eintrag hängen ebenfalls; uvicorn-Worker stauen sich; das Backend ist tot.
Das ist **am 2026-05-17 produktiv eingetreten:** Backend antwortete bis 00:19:13 sauber, danach 50+ Minuten komplette Stille. CloseWait-Connections stapelten sich, Login-Seite konnte nicht mal mehr `/api/i18n/...` laden, weil `routeI18n.py` denselben Cache nutzt. Auslöser: gestrige Umstellung von `mainBackgroundJobService._getDb()` auf `getCachedConnector` (zur Fix des DB-Init-Log-Spams), kombiniert mit dem 5-Sekunden-Polling der `RagInventoryPage` + `RagRunningBadge` (zwei Tabs offen ⇒ ~50 Calls/Min auf einer Connection) plus dem `killZombieJobs`-Cron und dem `_runJob`-Worker selber.
Das Risiko ohne Fix: Das System ist nicht produktionstauglich. Jeder Tag mit moderatem Polling-Load + Background-Jobs killt das Backend nach ≤ 30 Min. Stop-the-bleeding mit `self._lock` würde alle DB-Calls auf einer Connection serialisieren (ungeniessbar langsam) und das eigentliche Problem nur verstecken.
## Fokus und kritische Details
- **psycopg2-Connections sind nicht thread-safe** — egal ob im Threadpool oder im asyncio-Loop. Pro Logik-Einheit eine eigene Connection oder geliehene Pool-Connection.
- **`getCachedConnector` wird von vielen Hot-Path-Stellen genutzt:** `routeI18n.py`, `i18nRegistry.py`, `aiAuditLogger.py`, `mainBackgroundJobService.py`, `interfaceDbApp.py`, `interfaceDbManagement.py`, `interfaceDbKnowledge.py`, `interfaceFeatureChatbot.py`, plus die `XxxObjects`-Klassen via `interfaceDbApp/Management/Knowledge`. Sie ist Core-Infrastruktur — eine kaputte Umstellung legt **alles** lahm.
- **40 Stellen** in `connectorDbPostgre.py` referenzieren `self.connection.` direkt (cursor, commit, rollback, closed). Jede muss auf ein Pool-Borrow-Pattern umgestellt werden.
- **Transaktionsgrenzen:** Der bestehende Code mischt Auto-Commit-Style (`connection.commit()` am Ende jeder Methode) mit Multi-Statement-Operationen (`recordCreateBulk`, `_save_record`, `recordDeleteWhere`). Beim Pool-Refactor MUSS jede Operation ihre Connection borrow → execute → commit/rollback → return-to-pool in einem klar abgegrenzten Block durchlaufen. Sonst geht eine Connection mit offener Transaktion zurück in den Pool und der nächste Borrower erbt sie.
- **`setUserContext()` und der RBAC-`updateContext(userId)` Pfad:** Aktuell wird der User per-Instance gehalten. Bei Pool ist die "Instance" nur noch ein dünner Wrapper um den Pool — userId muss über `contextvars` request-scoped fliessen (das ist mit `_current_user_id` schon halb-implementiert, muss konsequent gezogen werden).
- **`_create_database_if_not_exists` und `_create_tables`** brauchen autocommit-Mode auf einer **separaten** Connection (CREATE DATABASE darf nicht in einer Transaction laufen). Aktuell wird dafür schon eine kurze separate `psycopg2.connect()` geöffnet — das bleibt so, **nicht über den Pool**.
- **App-Shutdown:** Pool muss bei FastAPI-Shutdown sauber geschlossen werden, sonst leakt psycopg2 Server-side prepared statements bei jedem Hot-Reload.
- **Statement-Timeout** als Safety-Net: `statement_timeout=30000` (30s) per `options` beim Connect, damit ein hängender Call die Connection nicht für immer vergiftet.
- **Tests müssen Concurrency aktiv provozieren** — sonst landet derselbe Bug in 6 Monaten wieder im Code. Unit-Test mit `ThreadPoolExecutor(50)` der parallel `getRecordset()` auf derselben DB feuert.
## Ziel und Nicht-Ziele
### Ziel
- `DatabaseConnector` benutzt `psycopg2.pool.ThreadedConnectionPool` (eine Pool-Instanz pro `(host, db, user, port)`).
- Jede DB-Operation borrowed eine Connection aus dem Pool, führt ihre SQL-Operation in einer klar abgegrenzten Transaktion aus, gibt die Connection wieder zurück — auch im Fehlerfall.
- `getCachedConnector` bleibt API-kompatibel (Aufrufer ändern sich nicht), gibt aber unter der Haube einen pool-basierten Connector zurück.
- Statement-Timeout (30s) und `connect_timeout` (10s) sind als Safety-Net konfiguriert.
- Concurrency-Test in `tests/unit/connectors/` beweist mit ≥ 50 parallelen Threads auf einer DB, dass kein "another command in progress" Error auftritt und alle Calls innerhalb < 10s antworten.
- Smoke-Test: Backend hält 1 h unter dem reproduzierbaren Polling-Load (RagInventoryPage offen + manueller Sync) ohne hängen.
### Explizit NICHT
- **Kein** Umstieg auf `asyncpg` / `psycopg3-async`. Der Code ist durchgängig sync, asyncpg würde alle 40+ Aufrufstellen ändern. Asyncpg-Migration ist ein separater, viel grösserer Refactor.
- **Kein** ORM-Umstieg (SQLAlchemy/Tortoise). Das ist Out-of-Scope für diesen Bugfix.
- **Keine** Änderung an Interface-Pattern (`XxxObjects`, `interfaceDb*.py`), an RBAC-WhereClause-Logik oder an `DatabaseConnector`'s Public-API. Aufrufer dürfen nicht angefasst werden müssen.
- **Keine** generelle Performance-Optimierung der Queries. Wir fixen die Race, nicht das Schema.
## Betroffene Module
- **Gateway:**
- `gateway/modules/connectors/connectorDbPostgre.py` — Refactor (Pool-Helper, alle 40 `self.connection.*` Stellen)
- `gateway/app.py` — Pool-Shutdown-Hook bei FastAPI-Shutdown
- `gateway/tests/unit/connectors/test_connectorDbPostgre_failLoud.py` — neue Concurrency-Tests
- **Keine Änderung** an: `mainBackgroundJobService.py`, `routeI18n.py`, `aiAuditLogger.py`, `interfaceDb*.py`, `i18nRegistry.py`, `XxxObjects`-Klassen — die Public-API von `DatabaseConnector` und `getCachedConnector` bleibt 1:1
- **Frontend:** keine
- **DB-Migration:** nein (keine Schema-Änderung)
- **Andere Komponenten:** keine (private-llm, teams-bot, frontend nicht betroffen)
## Entscheidungen
| Datum | Entscheidung | Begründung |
|-------|--------------|------------|
| 2026-05-17 | `psycopg2.pool.ThreadedConnectionPool` statt `psycopg_pool` oder `asyncpg` | Bereits Teil von `psycopg2-binary`, keine neue Dependency, kompatibel mit sync Codebase, Drop-in-ready. `psycopg_pool` würde psycopg3 voraussetzen (anderer Driver). `asyncpg` würde alle 40 Aufrufstellen async machen. |
| 2026-05-17 | `getCachedConnector` API beibehalten | 11+ Konsumenten greifen drauf zu. API-Break = grosser Blast-Radius. Wir tauschen nur die Implementierung. |
| 2026-05-17 | Pool-Größe `minconn=2, maxconn=20` pro DB initial | uvicorn default workers=1, FastAPI threadpool default=40. Konservativ starten, im Lasttest justieren. Wert in `APP_CONFIG` exposen als `DB_POOL_MAX_CONN` für späteres Tuning ohne Code-Change. |
| 2026-05-17 | Statement-Timeout 30s (`options='-c statement_timeout=30000'`) | Schützt vor unendlichem `recv()`-Hang. 30s ist grosszügig für Reports/Embeddings, restriktiv genug um ein Schauerszenario zu beenden. |
| 2026-05-17 | `_create_database_if_not_exists` / `_create_tables` bleiben auf **eigener** Connection (nicht Pool) | CREATE DATABASE darf nicht in Transaction laufen, ist ein einmaliger Boot-Pfad, lohnt sich nicht im Pool. |
## Umsetzungs-Checkliste
- [x] Modul-Helper `_PoolRegistry` mit `getPool(host, db, user, password, port) → ThreadedConnectionPool` (Lazy-Init, Thread-safe via Modul-Lock) **(S1, done 2026-05-17)**
- [x] `DatabaseConnector` Refactor: `self.connection` ersetzen durch Pool-Borrow-Pattern **(S2, done 2026-05-17)**
- [x] Public Context-Manager `db.borrowConn()` + `db.borrowCursor()` für alle Aufrufstellen — interne (18) und externe (~30) Stellen migriert **(S2, done 2026-05-17)**
- [x] Jede Methode mit `connection.commit()` / `connection.rollback()` auf Borrow-Block-Lifecycle umgestellt (auto-commit-on-success, auto-rollback-on-exception, immer putconn im finally) **(S2, done 2026-05-17)**
- [x] `_ensure_connection()` zu No-Op gemacht (Pool re-connectet selbständig) — bleibt als Backward-Compat-Hook für externe Aufrufer **(S2, done 2026-05-17)**
- [x] `_initializeSystemTable` und `_create_database_if_not_exists`/`_create_tables` korrekt aufgeteilt: Init nutzt eigene autocommit-Connection (CREATE DATABASE), Rest läuft via Pool **(S2, done 2026-05-17)**
- [x] `getCachedConnector` umgebaut: Cache enthält **leichtgewichtige** Wrapper, jeder Wrapper teilt sich den Pool über `_PoolRegistry`. API unverändert **(S2, done 2026-05-17)**
- [x] Statement-Timeout (30 s) + `connect_timeout` (10 s) per `options=`/`connect_timeout=` in Pool-Erzeugung gesetzt **(S1, done 2026-05-17)**
- [x] Backward-Compat-Shim `db.connection` (no-op `commit`/`rollback`/`closed`/`close`, RuntimeError auf `cursor`) damit kein legacy Aufruf still bricht **(S2, done 2026-05-17)**
- [x] FastAPI `lifespan` / `app.on_event("shutdown")`: alle Pools schliessen (`closeAllPools()`) — gewirelt in `gateway/app.py` als letzter Shutdown-Schritt nach den Feature-`onStop` Hooks **(S4, done 2026-05-17)**
- [x] Concurrency-Tests in neuem `test_connectorDbPostgre_pool.py`: 6 Tests (50 Threads Stress, 20 Threads Latency-Budget, interleaved load/save, statement_timeout-Release, pool-identity, closeAllPools) — alle 6 grün gegen lokales Postgres; auto-skipping wenn keine DB erreichbar; `borrowConn()` um bounded Wait-Retry erweitert weil psycopg2-Pool exhaustion sofort raised statt blockt **(S5, done 2026-05-17)**
- [x] Regression-Run unit+integration: 639/656 unit grün (1 von uns gefixt: `test_folder_crud._FakeDb` brauchte `borrowCursor`-Stub; 17 pre-existing Failures sind RAG-Logik-Drift + Adapter-Drift, kein Pool-Bezug); 76/79 integration grün (3 pre-existing Trustee-Workflow-Failures, kein Pool-Bezug) **(S6, done 2026-05-17)**
- [x] Smoke-Test 17.05.2026 11:3311:39 (`local/logs/log_app_20260517.log`): 11 Pools je 1× lazy erzeugt, **kein** `another command is already in progress`, **kein** `pool exhausted`, **kein** `recv() hang`, **kein** Pool-Retry. Frontend laut User ohne Delays/Freezes. Einziger ERROR: `Decryption rate limit exceeded for user 'system' key 'DB_PASSWORD_SECRET'` in `routeRagInventory._getInventoryPlatform` — unabhängiges Problem (kein Pool-Bezug), hot-path `mainBackgroundJobService._getDb()` triggert pro RAG-Inventory-Poll + Walker-Call >10 Decrypts/s. Separater Fix unterhalb. **(S7, done 2026-05-17)**
- [x] Folge-Fix Secrets-Decryption-TTL-Cache (`configuration.py:decryptValue`, 60 s, thread-safe, Cache-Hit umgeht Rate-Limit, Cache-Miss bleibt geschützt). Beseitigt den letzten ERROR aus dem S7-Lauf, profitieren alle `_SECRET`-Reader (auditLogger, routeI18n, alle Feature-Interfaces). **(2026-05-17)**
- [ ] DB-Init-Log-Spam-Regression-Test: `logger.debug` darf nicht mehr in `INFO`-Pfad eskalieren (war Auslöser der gestrigen Cache-Umstellung — neue Lösung darf das nicht zurückbringen)
## S7 — Manueller Smoke-Test (Auftrag an Patrick)
**Ziel:** beweisen dass das Backend unter realem Polling-Last 1 h stabil bleibt — also nicht den Bug reproduziert, den der Pool-Refactor beseitigt hat.
**Setup:**
1. Gateway neu starten: `uvicorn app:app --host 0.0.0.0 --port 8000` (siehe Terminal 2).
2. Optional in `.env` setzen: `DB_POOL_MAX_CONN=20` (Default reicht).
3. Browser-Setup:
- **Tab 1:** `Start > Nutzung > RAG` (RagInventoryPage) — pollt aktive Jobs alle 5 s.
- **Tab 2:** zweite Sitzung mit `Start > Nutzung > RAG` offen lassen (Multi-Tab-Last).
- **Tab 3 (optional):** Workflow-Dashboard oder ein anderes Polling-Frontend offen lassen.
4. Optional: 1 manuellen RAG-Sync auslösen (`Jetzt synchronisieren` auf einer Connection mit indexierten DataSources) — das pumpt Background-Jobs durch den Pool.
**Beobachten (1 h):**
- Frontend reagiert konsistent unter 500 ms je Polling-Call (Browser DevTools → Network).
- Im Log kein `another command is already in progress`, kein `connection pool exhausted` (Retries sind OK solange sie unter 30 s bleiben), kein `recv() hang`.
- `pg_stat_activity` zeigt höchstens `_DEFAULT_POOL_MAX_CONN` (=20) Backends pro DB im Zustand `idle` / `idle in transaction`; **nicht** anwachsend.
- RAM des `python.exe` für Gateway stabil (nicht monoton wachsend > 100 MB/h).
- Login-Seite (Inkognito-Tab) lädt die i18n-Strings sauber (war im Bug-Szenario tot).
**Konkret prüfbar via PowerShell während des Tests:**
```powershell
# Pro DB Connection-Zahl (verlangt psql im PATH, sonst pgAdmin)
psql -h localhost -U poweron_dev -d postgres -c "SELECT datname, count(*) FROM pg_stat_activity WHERE datname LIKE 'poweron%' GROUP BY datname ORDER BY count DESC;"
# Backend-RAM (in PS)
Get-Process python | Sort-Object WS -Descending | Select-Object -First 3 ProcessName,Id,@{N='RAM_MB';E={[math]::Round($_.WS/1MB,1)}}
```
**Abschluss:**
- Notiz unter `local/notes/2026-05-pool-smoke-1h.md` mit:
- Start/Endzeit
- Auffälligkeiten in den Logs (sollten keine sein) — `grep -E "another command|pool exhausted|connection pool" gateway/local/logs/log_app_<datum>.log`
- `pg_stat_activity` Snapshots zu Beginn / Mitte / Ende
- RAM-Verlauf 3 Werte
- Bei Erfolg: Diese Plan-Doc nach `c-work/3-validate/` verschieben, dann S8 (Doku-Sync) anstossen.
- Bei Auffälligkeit: Issue im Log notieren, Plan-Doc bleibt in `2-build/`.
- [ ] RBAC / Permissions — n.a. (keine API-Änderung)
- [ ] Neutralisierung betroffen? — nein
- [ ] Navigation / Routing — nein
- [ ] Billing-Impact? — nein
## Akzeptanzkriterien
| # | Kriterium (Given-When-Then) | Prio |
|---|-----------------------------|------|
| 1 | Given Backend mit RagInventoryPage + RagRunningBadge offen (2 Tabs) When 1 h gepollt Then keine hängenden Routes, alle Polls < 500 ms, kein `another command in progress` in Logs | must |
| 2 | Given 50 Threads im Unit-Test When parallel `getRecordset(BackgroundJob)` aufrufen Then 0 Errors, alle Calls < 2 s | must |
| 3 | Given Walker macht `_loadJob` + Route macht `listJobs` parallel When beide auf derselben DB Then beide bekommen valide Resultate, keine Cursor-Vermischung | must |
| 4 | Given Statement-Timeout 30 s When eine Query > 30 s läuft Then `psycopg2.errors.QueryCanceled`, Connection geht sauber zurück in den Pool, keine Vergiftung | must |
| 5 | Given FastAPI Shutdown When uvicorn `Ctrl+C` Then alle Pools `closeall()`, keine Open-Connections im PG `pg_stat_activity` | should |
| 6 | Given bestehender Code (40 Aufrufstellen, 11 Konsumenten von `getCachedConnector`) When Pool-Refactor When Tests laufen Then **alle bisherigen** Unit-Tests grün (keine Regression) | must |
## Testplan
| ID | AC | Art | Automatisiert | Repo-Pfad | Status |
|----|----|-----|---------------|-----------|--------|
| T1 | 2 | unit (concurrency) | ja | `gateway/tests/unit/connectors/test_connectorDbPostgre_failLoud.py::test_concurrent_getRecordset_50threads` | pending |
| T2 | 3 | unit (interleaved cursor) | ja | `gateway/tests/unit/connectors/test_connectorDbPostgre_failLoud.py::test_interleaved_load_update_no_collision` | pending |
| T3 | 4 | unit (timeout) | ja | `gateway/tests/unit/connectors/test_connectorDbPostgre_failLoud.py::test_statement_timeout_returns_conn_to_pool` | pending |
| T4 | 5 | manual+integration | teilweise | `gateway/tests/integration/test_pool_shutdown.py` + `pg_stat_activity` check | pending |
| T5 | 6 | regression | ja | `gateway/tests/unit/connectors/`, `gateway/tests/integration/` (alle bestehenden) | pending |
| T6 | 1 | smoke (manuell) | nein | `local/notes/2026-05-pool-smoke-1h.md` mit Polling-Log + Memory-Verlauf | pending |
## Schritt-für-Schritt-Umsetzung mit LLM-Empfehlung
> **Faustregel:** `composer-2-fast` für mechanische Refactors mit klarem Pattern (Boilerplate, Datei-Edits nach Schablone). `claude-opus-4-7-thinking-xhigh` (oder claude-4.6-opus) für Architekturentscheidungen, kritischen Pool-Lifecycle-Code, Concurrency-Tests und Doku.
### S1 — Pool-Helper-Modul `_PoolRegistry` (NEU)
**LLM:** `claude-opus-4-7-thinking-xhigh` (kritischer Lifecycle-Code, ein Fehler hier killt alles)
Neue Datei oder Modul-Section in `connectorDbPostgre.py`:
- Lazy-Init `ThreadedConnectionPool(minconn=2, maxconn=APP_CONFIG.DB_POOL_MAX_CONN ?? 20, ...)` pro DB-Key
- Thread-safe Registry (`threading.Lock`)
- `getPool(host, db, user, password, port)` → Pool oder erstellt einen
- `closeAll()` für Shutdown
- Statement-Timeout + connect-Timeout in `options=` setzen
### S2 — `DatabaseConnector` Refactor: Borrow-Pattern einführen
**LLM:** `claude-opus-4-7-thinking-xhigh` für den Context-Manager-Skelett, danach die 40 Aufrufstellen mit `composer-2-fast`
- `self.connection` → entfernt
- Neu: `@contextmanager def _borrowConn(self)` → borrowed aus Pool, gibt Conn yield, putconn im `finally`, mit Exception-handling rollback
- `_connect`, `_ensure_connection` → entfernt (Pool macht das)
- Jede der 40 Stellen `with self.connection.cursor() as cursor:``with self._borrowConn() as conn:` und darin `with conn.cursor() as cursor:`
- Pro Methode klares Transaktions-Lifecycle: am Ende `conn.commit()`, im Except-Block `conn.rollback()`. Im `_borrowConn` Cleanup: bei unfertiger Transaktion `rollback()` bevor putconn (sonst geht poisoned conn zurück).
### S3 — `getCachedConnector` umbauen
**LLM:** `composer-2-fast` (klein, mechanisch)
- Cache enthält nur noch leichtgewichtige `DatabaseConnector`-Wrapper, die alle dieselbe `Pool`-Referenz nutzen
- `_MAX_CACHED_CONNECTORS` und FIFO-Eviction können bleiben oder weg (Pool hat eigene Connection-Verwaltung; Wrapper sind billig). Empfehlung: Cache komplett raus, jeder Call erzeugt einen frischen Wrapper. Begründung im PR.
### S4 — App-Lifecycle: Shutdown-Hook
**LLM:** `composer-2-fast`
- In `gateway/app.py` FastAPI `lifespan`-Handler (oder `on_event("shutdown")` falls Codebase noch das alte Pattern nutzt) → `closeAll()` aufrufen
- Bei `lifespan` zusätzlich Startup-Hook für initialen Pool-Warmup (optional)
### S5 — Concurrency-Tests
**LLM:** `claude-opus-4-7-thinking-xhigh` (Test-Design für Race-Bedingungen ist anspruchsvoll)
- `test_concurrent_getRecordset_50threads`: `ThreadPoolExecutor(max_workers=50)` × 100 `getRecordset` Calls auf demselben Connector, assert 0 Exceptions, p99 < 2 s
- `test_interleaved_load_update_no_collision`: ein Thread `_loadJob(jobId)` in Schleife, parallel anderer Thread `_updateJob(jobId, {...})` in Schleife, assert keine `OperationalError`, keine Cursor-Datenvermischung
- `test_statement_timeout_returns_conn_to_pool`: triggere `SELECT pg_sleep(60)` mit `statement_timeout=30000`, assert `QueryCanceled` nach ~30 s, assert Pool-Conn ist wieder verfügbar (next call < 100 ms)
- Tests brauchen **echte** PostgreSQL-Verbindung (kein Mock — Mock würde den Bug nicht zeigen). Skip wenn `PG_TEST_DSN` env nicht gesetzt.
### S6 — Regression-Run: alle bestehenden Tests
**LLM:** `composer-2-fast` (nur Run + Fix-Loop)
- `pytest gateway/tests/unit/connectors/ gateway/tests/integration/` → grün
- Jede Regression-Failure: einzeln analysieren, Fix entweder im Pool-Code oder im Test (falls Test-Assumption hard-coded auf `self.connection`)
### S7 — Smoke-Test 1 h
**LLM:** keiner (manueller Test durch Patrick)
- Backend starten, RagInventoryPage + ein zweites Tab offen halten, optional einen RAG-Sync auslösen, 1 h beobachten
- Notiz in `local/notes/2026-05-pool-smoke-1h.md`: alle Routes < 500 ms, kein `another command`, RAM stabil, `pg_stat_activity` zeigt `idle`-Connections im Pool-Bereich
- Bei Erfolg: Plan-Doc nach `c-work/3-validate/` verschieben
### S8 — Doku-Sync (am Ende)
**LLM:** `claude-opus-4-7-thinking-xhigh` (richtig formulieren, lastReviewed setzen)
- `wiki/b-reference/platform/database-architecture.md`:
- Abschnitt "Connector-Caching" umbenennen zu "Connection-Pool", Pool-Pattern, min/max-conn, Statement-Timeout, Borrow-Lifecycle beschreiben
- `lastReviewed` und `verifiedAgainst` setzen
- `wiki/c-work/_CHANGELOG.md`: 1 Zeile
- `wiki/TOPICS.md`: prüfen ob neues Topic "Database Connection Pool" rein muss (nein — fällt unter `database-architecture.md`, nur Link prüfen)
- Plan-Doc `c-work/2-build/``c-work/3-validate/``c-work/4-done/`
## Links
- Root-Cause-Diskussion: dieser Chat (siehe ([Diagnose: API blockiert nach RAG-Sync](e611c024-d6a9-414b-86a1-57d35cd0fe2a)))
- Auslöser-Commit: gestrige Umstellung `_getDb()` in `mainBackgroundJobService.py` auf `getCachedConnector` (DB-Init-Log-Spam-Fix)
- PR: tbd
- Issue: tbd
## Abschluss
- [ ] `b-reference/platform/database-architecture.md` aktualisiert (Pool, Lifecycle, Timeouts, lastReviewed, verifiedAgainst)
- [ ] `TOPICS.md` geprüft (kein neuer Eintrag nötig, Link in `database-architecture.md` ggf. aktualisieren)
- [ ] `c-work/_CHANGELOG.md` 1-Zeilen-Eintrag
- [ ] Dieses Dokument durch Kanban: `1-plan``2-build``3-validate``4-done``z-archive/`

View file

@ -12,6 +12,13 @@ type: `feat` `fix` `refactor` `docs` `test` `chore` `build` · scope: `gateway
Skip: reine Refactors, Formatting, Lint, Dep-Bumps, Test-only, Wiki-Tippfehler.
## 2026-05-17
- 2026-05-17 | feat | gateway+frontend-nyla | **RAG-Inventar: echte Chunks + Limit-Transparenz.** Drei Probleme behoben: (1) `routeRagInventory._buildConnectionInventory` zählte bisher `len(FileContentIndex)` (= indizierte Dateien) und labelte das im UI als "Chunks" — bei einer 99-Seiten-PDF erscheint dort statt der echten ~99 Chunks die Zahl 1. Neue `interfaceDbKnowledge.countChunksByFileIds()` macht eine einzige Aggregat-SQL `SELECT "fileId", COUNT(*) FROM "ContentChunk" WHERE "fileId" = ANY(%s) GROUP BY "fileId"` (kein Vector-Body geladen), die Response trägt jetzt `fileCount` UND `chunkCount` pro DataSource + `totalFiles/totalChunks` pro Connection. (2) `RagInventoryPage.tsx` / `connectionApi.ts` zeigen beide Werte getrennt ("25 Dateien · 1240 Chunks") mit Tooltip-Definition für Chunks (~400 Tokens). (3) **Limit-Transparenz**: SharePoint/kDrive/gDrive-Bootstrap stoppen bei den ersten Limits (`MAX_BYTES_DEFAULT=200 MB`, `MAX_ITEMS_DEFAULT=500`, `MAX_DEPTH_DEFAULT=4`, `MAX_FILE_SIZE_DEFAULT=25 MB`); ClickUp analog (`MAX_TASKS_DEFAULT=500`, `MAX_WORKSPACES_DEFAULT=3`, `MAX_LISTS_PER_WORKSPACE_DEFAULT=20`). Bisher: `return` ohne Log + ohne Marker im Bootstrap-Result → User sah "Sync erfolgreich" obwohl 706 Dateien fehlten. Fix: neuer `_recordLimitStop()`-Helper in allen 4 Connectoren setzt `BootstrapResult.stoppedAtLimit` (1. exhausted Budget), schreibt 1 WARNING in den Log und liefert das Feld + die effektiven `limits` im `_finalizeResult` Dict an `BackgroundJob.result`. `routeRagInventory` reicht `lastSuccess.stoppedAtLimit/limits/bytesProcessed` ans Frontend durch. Neuer amber `partialBanner` auf der RagInventoryPage warnt mit "Limit maxBytes=200 MB (200 MB verarbeitet) erreicht — Weitere Dateien wurden NICHT indexiert" und bietet "Erneut indexieren". Verifiziert anhand `local/logs/log_app_20260517.log`: SharePoint-Sync hat genau bei `bytesProcessed=209_894_527 ≥ MAX_BYTES_DEFAULT (209_715_200)` gestoppt (Kumulative Summe der 25 indizierten Dateigrößen = 200.17 MB). ClickUp hat bei `skippedDup=500 >= maxTasks=500` gestoppt. Outlook/Gmail brauchen das gleiche Pattern noch (haben aktuell keine harten Limits im Code, daher kein Bug, aber wenn welche kommen → gleicher Helper).
- 2026-05-17 | fix | gateway | **Secrets Decryption TTL-Cache** (`gateway/modules/shared/configuration.py`): `decryptValue()` cached jetzt erfolgreich entschlüsselte Plaintexts process-wide für 60 s (Key = Ciphertext, thread-safe, `clearDecryptionCache()` für Rotation/Tests). Root-Cause aus S7-Smoke-Test (`local/logs/log_app_20260517.log:609`): RAG-Inventory-Polling + paralleler Walker-Burst triggerte für `system`/`DB_PASSWORD_SECRET` >10 Decrypts/s, das Brute-Force-Schutz-Rate-Limit warf `ValueError: Decryption rate limit exceeded``routeRagInventory._getInventoryPlatform` HTTP 500. Hot-Path war `mainBackgroundJobService._getDb()`, das pro Call `APP_CONFIG.get("DB_PASSWORD_SECRET")` evaluiert (eager arg eval), bevor `getCachedConnector` überhaupt seinen Wrapper-Cache prüfen kann. Cache-Hit umgeht das Rate-Limit (kein neuer Krypto-Op, nur Re-Read eines bereits autorisierten Plaintexts); Cache-Miss konsumiert weiter Rate-Budget — die Schutzfunktion gegen wiederholt falsche Decrypts bleibt damit erhalten. Wirkt global für alle `_SECRET`-Reader (`auditLogger`, `routeI18n`, alle Feature-Interfaces), nicht nur für den BackgroundJobService.
- 2026-05-17 | refactor | gateway | PostgreSQL Connection Pool — Steps S3S6 abgeschlossen (`c-work/2-build/2026-05-postgres-connection-pool.md`). **S3**: `getCachedConnector` Docstring präzisiert (Cache = Wrapper-Recycling + DB-Init-Spam-Schutz, Pool = echte Connection-Verwaltung). **S4**: Shutdown-Hook `closeAllPools()` in `gateway/app.py` lifespan als letzter Schritt nach Feature-`onStop`-Hooks. **S5**: Neuer Test-File `gateway/tests/unit/connectors/test_connectorDbPostgre_pool.py` mit 6 Concurrency-Tests gegen live-Postgres (auto-skip wenn keine DB erreichbar): 50 Threads × 20 Reads (0 Errors), 20 Threads × 50 Reads (p99 < 5 s), interleaved load/save, `statement_timeout=500ms` triggert `QueryCanceled` und gibt Connection sauber zurück, Pool-Identity pro (host, db, port), `closeAllPools` leert Registry. Beim ersten Lauf entdeckt: psycopg2-Pool wirft `PoolError` sofort bei Exhaustion statt zu blockieren `borrowConn()` um bounded Wait-Retry erweitert (`_BORROW_WAIT_TIMEOUT_S=30s`, `_BORROW_WAIT_BACKOFF_S=50ms`). Alter `test_connectorDbPostgre_failLoud.py` auf das neue `borrowConn`-Mocking umgestellt (alle 6 weiter grün). **S6**: Regression-Run: 639/656 unit grün (vorher 638) der eine durch den Refactor verursachte Fail (`test_folder_crud._FakeDb` brauchte `borrowCursor`-Stub) gefixt, die übrigen 17 Failures sind pre-existing RAG/Adapter/Workflow-Drift ohne Pool-Bezug. 76/79 integration grün (3 pre-existing Trustee-Workflow-Fails). Backward-Compat-Stub `borrowCursor` auch in `test_folderRbac._FakeDb` ergänzt. Offen: **S7** (manueller 1 h Smoke-Test, Anleitung in der Plan-Doc) und **S8** (`b-reference/platform/database-architecture.md`).
- 2026-05-17 | refactor | gateway | **CORE**: PostgreSQL Connection Pooling implementiert (S1+S2 von `c-work/2-build/2026-05-postgres-connection-pool.md`). Root-Cause: `DatabaseConnector` hielt **eine** psycopg2-Connection pro Instanz und teilte sie via `getCachedConnector(...)` über den gesamten FastAPI-Thread-Pool **und** über asyncio-Tasks. psycopg2-Connections sind NICHT thread-safe — paralleler Zugriff aus z.B. RAG-Polling, `routeI18n`, `mainBackgroundJobService` und Hintergrund-Jobs führte zu `another command in progress` und — viel schlimmer — unendlichem Block in `recv()` (kein `statement_timeout` gesetzt). Das `self._lock` in `DatabaseConnector` war zwar deklariert, aber **nirgends** verwendet. Folge: Backend hat sich komplett aufgehängt, sogar die i18n-API für die Login-Seite lieferte nichts mehr. **Lösung**: Neuer `_PoolRegistry` (`gateway/modules/connectors/connectorDbPostgre.py`) mit `psycopg2.pool.ThreadedConnectionPool` pro `(host, db, port)`, lazy-init, thread-safe (`min=2`, `max=20` per `DB_POOL_MAX_CONN`, `statement_timeout=30s`, `connect_timeout=10s`). Jede DB-Operation borrowed eine Connection via neuem `db.borrowConn()`-Context-Manager (auto-commit/rollback/return); `db.borrowCursor()` als 1:1-Ersatz für das alte `with db.connection.cursor() as cursor:` Pattern. `getCachedConnector` bleibt API-kompatibel, Connector-Wrapper sind nun leichtgewichtig (kein per-instance Socket). Backward-Compat-Shim `db.connection` (no-op `commit()`/`rollback()`/`closed`, RuntimeError auf `cursor()` damit kein Stillschweigend-Bruch). 18 interne Cursor-Stellen + ~30 externe (interfaceRbac, interfaceDbManagement, interfaceDbBilling, interfaceFeatureRealEstate, routeWorkflowDashboard, routeHelpers, gdprDeletion, dbMultiTenantOptimizations, _featureSubAgentTools, scripts/stage0) auf das neue Pattern umgestellt. `_ensure_connection` als No-Op gehalten (Pool re-connectet selbständig). Public Shutdown-Hook `closeAllPools()` wartet auf S4 (FastAPI-Lifespan-Integration). Working-Doc: `c-work/2-build/2026-05-postgres-connection-pool.md` (Step S1+S2).
## 2026-05-16
- 2026-05-16 | fix | frontend-nyla | `AddConnectionWizard` Admin-Consent-Button war NoOp: `ConnectionsPage` hat das `onMsftAdminConsent`-Prop nie übergeben, der `?.()`-Aufruf im Wizard wurde schweigend übersprungen und der Wizard sprang einfach zum nächsten Schritt. Im Backend kam kein einziger Request an `/api/msft/adminconsent` an — User aus Multi-Tenants, in denen die App-Registration noch nie admin-consented wurde, hingen unauflöslich im "Anforderung gesendet"-Screen fest. Fix: `ConnectionsPage._handleMsftAdminConsent` öffnet jetzt ein Popup auf `/api/msft/adminconsent`, und das Prop wird an den Wizard durchgereicht. Doc-Sync: `d-guides/microsoft-entra-registration-checklist.md` (Redirect-URI-Tabelle um `/api/msft/adminconsent/callback` ergänzt — Fehlen führte zu `AADSTS50011`; Multi-Tenant-Hinweis: Admin Consent gilt pro Tenant).