Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
516 changes: 51 additions & 465 deletions .superpowers/sdd/task-1-report.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,30 @@

Monta SOLO el API v2 (Autopilot, autenticado). El RAG v1 legacy (`api.py`,
endpoints /analyze,/sync,/history,/stats,/evaluate sin auth) queda deprecated y
FUERA del arranque. El v2 usa getters perezosos, así que no necesita startup_event.
FUERA del arranque.

El lifespan pre-calienta el pool de BD en el arranque para que las primeras
peticiones no paguen el coste de conexión (~1-8 s). Si la BD no está disponible
al arrancar, el error se absorbe (try/except) para no tumbar el contenedor.
"""
from contextlib import asynccontextmanager

from fastapi import FastAPI

from src.api_v2 import router as v2_router
from src.db.pool import get_pool, close_pool


@asynccontextmanager
async def lifespan(app: FastAPI):
try:
get_pool() # pre-calienta el pool; si la BD no está, no tumbar el arranque
except Exception as exc:
import logging
logging.getLogger(__name__).warning("pool pre-warm failed; lazy init on first request: %s", exc)
yield
close_pool()


app = FastAPI(title="Mnemo Autopilot", version="2.0.0")
app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
app.include_router(v2_router)
207 changes: 207 additions & 0 deletions docs/superpowers/plans/2026-07-01-perf-connpool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Rendimiento · Pool de conexiones + frontend optimista — Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Reusar conexiones a Postgres (pool) para eliminar el connect de 1–8 s por request, y que `/v2/orgs` no bloquee la carga de la app.

**Architecture:** T1 crea un pool global (`src/db/pool.py`). T2 migra los `_connect` de los repos a `pool.connection()`. T3 pre-calienta el pool en el arranque (lifespan). T4 hace el `OrgProvider` optimista.

**Tech Stack:** Python/FastAPI/psycopg 3.3 + psycopg_pool · Next.js/TS · vitest.

## Global Constraints

- **Pool lazy singleton**; `configure` centraliza `row_factory=dict_row` + `register_vector` (idempotente, para todas las conexiones aunque el repo no use pgvector).
- **Mismo API de los repos**: `_connect()` sigue usándose como `with self._connect() as conn:` (ahora devuelve `pool.connection()`: commit al salir + return-to-pool).
- **No tocar** `_set_claims`/`_set_user_claims` (son `set_config(..., is_local=true)` transaction-local; se limpian al commit → no fugan entre checkouts).
- **Multitenancy intacta**: el membership app-layer y el bypass RLS del pooler no cambian.
- `psycopg[binary,pool]==3.3.3` (misma versión, +extra pool). `register_vector` = `from pgvector.psycopg import register_vector`.
- **Verificación local = CI por tarea:** backend pytest **sin `.env`** (`mv .env .env.bak; DATABASE_URL= python3 -m pytest -m "not integration" -q; mv .env.bak .env`); frontend `npm run lint:ci`+`test`+`build`. Si node_modules se corrompe (iCloud) → `npm --prefix frontend ci`. **No git worktree.** Commits con `Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok`.

---

## Task 1: Pool global (`src/db/pool.py`) + dependencia

**Files:** Create `src/db/__init__.py` (vacío si no existe), `src/db/pool.py`; Modify `requirements.txt`; Test `tests/test_db_pool.py`.

**Interfaces:** Produces — `get_pool() -> ConnectionPool`, `close_pool() -> None`. `get_pool().connection()` es un context manager que da una `psycopg.Connection` con `row_factory=dict_row` y vector registrado.

- [ ] **Step 1: Dependency** — en `requirements.txt`, cambiar `psycopg[binary]==3.3.3` por `psycopg[binary,pool]==3.3.3`. Instalar: `python3 -m pip install -q "psycopg[binary,pool]==3.3.3"`.

- [ ] **Step 2: Write the failing test** (`tests/test_db_pool.py`):
```python
import src.db.pool as poolmod

def test_get_pool_is_singleton(monkeypatch):
created = []
class FakePool:
def __init__(self, *a, **k): created.append(k)
def open(self, **k): pass
def close(self): pass
monkeypatch.setattr(poolmod, "ConnectionPool", FakePool)
monkeypatch.setattr(poolmod, "_pool", None)
p1 = poolmod.get_pool(); p2 = poolmod.get_pool()
assert p1 is p2 # singleton
assert len(created) == 1 # se crea una sola vez
poolmod.close_pool()

def test_configure_sets_dict_row_and_vector(monkeypatch):
calls = {"vector": 0}
monkeypatch.setattr(poolmod, "register_vector", lambda conn: calls.__setitem__("vector", calls["vector"]+1))
class Conn: row_factory = None
c = Conn()
poolmod._configure(c)
from psycopg.rows import dict_row
assert c.row_factory is dict_row
assert calls["vector"] == 1
```

- [ ] **Step 3: Run, expect FAIL.** `python3 -m pytest tests/test_db_pool.py -q`

- [ ] **Step 4: Implement** `src/db/pool.py`:
```python
from typing import Optional

from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from pgvector.psycopg import register_vector

from src.config import DATABASE_URL

_pool: Optional[ConnectionPool] = None


def _configure(conn) -> None:
conn.row_factory = dict_row
register_vector(conn)


def get_pool() -> ConnectionPool:
"""Pool global lazy. Pre-abre min_size conexiones (el connect lento se paga aquí)."""
global _pool
if _pool is None:
pool = ConnectionPool(
conninfo=DATABASE_URL,
min_size=2,
max_size=10,
open=False,
timeout=10,
configure=_configure,
kwargs={"row_factory": dict_row},
)
pool.open(wait=True, timeout=15)
_pool = pool
return _pool


def close_pool() -> None:
global _pool
if _pool is not None:
_pool.close()
_pool = None
```
(Si `pgvector.psycopg.register_vector` requiere una conexión ya con el tipo disponible, el `configure` es el sitio correcto — corre una vez por conexión nueva del pool.)

- [ ] **Step 5: Run PASS** + backend-no-`.env` gate (rc=0, `.env` restaurado). **Commit** `feat(db): pool de conexiones global (psycopg_pool)` + trailer.

---

## Task 2: Migrar los repos al pool

**Files:** Modify `src/orgs/repository.py`, `src/certify/repository.py`, `src/jira/integrations_repository.py`, `src/graph/service.py`, `src/defects/repository.py`, `src/knowledge/repository.py`, `src/repo_ingest/repository.py`, `src/graph/gaps.py`; Tests: la suite existente de cada uno.

**Interfaces:** Consumes — `get_pool` de `src/db/pool.py` (Task 1).

- [ ] **Step 1** — En cada repo, importar `from src.db.pool import get_pool` y reemplazar el cuerpo de `_connect`:
- Repos **sin** pgvector (`orgs`, `certify`, `jira/integrations_repository`):
```python
def _connect(self):
return get_pool().connection()
```
(elimina `psycopg.connect(self.db_url, row_factory=dict_row)`).
- Repos **con** pgvector (`graph/service`, `defects`, `knowledge`, `repo_ingest`): igual que arriba, y **eliminar** las 2 líneas inline `conn = psycopg.connect(..., row_factory=dict_row)` + `register_vector(conn)` (ahora en el `configure` del pool). Quitar el import `register_vector` si queda sin uso; **mantener** `from pgvector import Vector` (se usa en las queries).
- `src/graph/gaps.py::_connect(db_url=DATABASE_URL)` (función de módulo):
```python
def _connect(db_url: str = DATABASE_URL):
return get_pool().connection()
```
(el pool usa `DATABASE_URL` global; el parámetro `db_url` queda por compatibilidad de firma pero el pool lo ignora).

- [ ] **Step 2** — `self.db_url` puede quedar sin uso en algunos repos; déjalo (lo usan otros métodos o el `__init__`) salvo que quede claramente muerto. No cambies las queries ni `_set_claims`.

- [ ] **Step 3: Run the backend suite** (sin `.env`): `mv .env .env.bak 2>/dev/null; DATABASE_URL= python3 -m pytest -m "not integration" -q; rc=$?; mv .env.bak .env 2>/dev/null; echo rc=$rc`. **Ajusta los tests que rompan**: los que parcheaban `psycopg.connect` a nivel de módulo del repo ahora deben parchear `<repo>.get_pool` (o el `_connect` del repo) para devolver un context manager fake que dé la conexión mock. Los que ya parchean `_connect` directamente siguen igual. Objetivo: rc=0.

- [ ] **Step 4: Commit** `refactor(repos): _connect usa el pool global (reuso de conexión)` + trailer.

---

## Task 3: Pre-calentar el pool en el arranque (`asgi.py`)

**Files:** Modify `asgi.py`.

- [ ] **Step 1: Implement** — añadir un `lifespan` y pasarlo a `FastAPI`:
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI

from src.api_v2 import router as v2_router
from src.db.pool import get_pool, close_pool


@asynccontextmanager
async def lifespan(app: FastAPI):
try:
get_pool() # pre-calienta el pool; si la BD no está, no tumbar el arranque
except Exception:
pass
yield
close_pool()


app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
app.include_router(v2_router)
```

- [ ] **Step 2: Verify** — `python3 -c "import asgi"` sin error; backend-no-`.env` gate rc=0 (el `try/except` evita que la ausencia de BD en el import rompa). **Commit** `perf(asgi): pre-calienta el pool en el arranque (lifespan)` + trailer.

---

## Task 4: `OrgProvider` optimista (frontend)

**Files:** Modify `frontend/src/components/providers/org-provider.tsx`; Test `frontend/src/components/providers/__tests__/org-provider.test.tsx`.

- [ ] **Step 1: Write the failing test** (vitest): mock `useAuth`→`{accessToken:"t"}` y `getOrganizations`. (a) con `localStorage["mnemo.activeOrgId"]="org-guardada"` y `getOrganizations` pendiente (promesa no resuelta), un componente que consume `useActiveOrg().activeOrgId` muestra `"org-guardada"` **antes** de que resuelva. (b) cuando `getOrganizations` resuelve con `[{id:"org-otra",...}]` (no contiene la guardada), `activeOrgId` pasa a `"org-otra"` (corrección).

- [ ] **Step 2: Implement** — en `org-provider.tsx`, leer el guardado y usarlo optimista + validar al cargar:
```tsx
const orgsQuery = useQuery({
queryKey: ["organizations", accessToken],
queryFn: () => getOrganizations(accessToken!),
enabled: Boolean(accessToken),
staleTime: 5 * 60_000,
});
const orgs = useMemo(() => orgsQuery.data ?? [], [orgsQuery.data]);

const storedOrgId = typeof window !== "undefined"
? window.localStorage.getItem(STORAGE_KEY) ?? ""
: "";

const activeOrgId = useMemo(() => {
if (explicitOrgId) return explicitOrgId;
if (orgs.length) {
return orgs.some((o) => o.id === storedOrgId) ? storedOrgId : orgs[0].id; // validación
}
return storedOrgId; // optimista: aún no cargó la lista
}, [explicitOrgId, orgs, storedOrgId]);
```
Mantener `setActiveOrgId` (escribe en localStorage) y `isLoading: orgsQuery.isLoading`.

- [ ] **Step 3: Verify** — `npm run lint:ci` + `npm test` + `npx tsc --noEmit` + `npm run build`. **Commit** `perf(frontend): OrgProvider optimista (no bloquea la app en /v2/orgs)` + trailer.

---

## Notas de cierre
- **Orden:** T1 (pool) → T2 (repos usan get_pool) → T3 (lifespan usa get_pool) → T4 (frontend, independiente).
- **Riesgo principal (T2):** los tests que parchean `psycopg.connect`. La mitigación es parchear `get_pool`/`_connect` en su lugar; el gate sin `.env` lo caza.
- **Reusa:** `register_vector`/`dict_row` (ahora en el pool), el patrón `with self._connect()`, `useQuery`/localStorage.
- **Impacto esperado:** connect por request 1–8 s → ~0 (reuso) tras el arranque caliente; la app deja de esperar a `/v2/orgs`.
- **Fuera de alcance:** cold start (pinger), hardware, mover de host.
96 changes: 96 additions & 0 deletions docs/superpowers/specs/2026-07-01-perf-connpool-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Rendimiento · Pool de conexiones + frontend optimista — diseño

**Fecha:** 2026-07-01 · **Base:** `main` 9277c4d (con `maxDuration=60`) · **Backend:** Python/FastAPI/psycopg · **Frontend:** Next.js/TS.

## Objetivo

Bajar la latencia por request y que `/v2/orgs` deje de bloquear toda la app. Del análisis de rendimiento: (3) el backend abre una conexión nueva a Supabase en **cada** request (`psycopg.connect` → SSL handshake al pooler de eu-central-1 → 1–8 s/request) y (4) el `OrgProvider` gatea la app entera esperando `/v2/orgs`. Este trabajo ataca ambos, **a 0 €** (sin cambiar de hosting). El cold start (pinger) y el hardware quedan fuera.

## Decisiones (confirmadas en el análisis)

- **Pool de conexiones** en el backend (reusar conexiones en vez de abrir una por request).
- **Frontend optimista**: usar el `activeOrgId` guardado sin esperar a `/v2/orgs`.
- **Sin romper la multitenancy**: el membership app-layer sigue; el pooler de Supabase ya bypassa RLS; los claims son `set_config(..., is_local=true)` (transaction-scoped → no contaminan una conexión reusada).
- **Mismo API de los repos** (cambio mínimo, sin reescribir queries).

## Componentes

### 1. Pool global (`src/db/pool.py`, nuevo)
Un `psycopg_pool.ConnectionPool` singleton, lazy:
```python
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from pgvector.psycopg import register_vector
from src.config import DATABASE_URL

_pool: ConnectionPool | None = None

def _configure(conn):
conn.row_factory = dict_row
register_vector(conn) # pgvector, una vez por conexión del pool

def get_pool() -> ConnectionPool:
global _pool
if _pool is None:
_pool = ConnectionPool(
conninfo=DATABASE_URL, min_size=2, max_size=10,
open=False, configure=_configure, timeout=10,
kwargs={"row_factory": dict_row},
)
_pool.open(wait=True, timeout=15)
return _pool

def close_pool():
global _pool
if _pool is not None:
_pool.close(); _pool = None
```
- `min_size=2` pre-abre 2 conexiones (el connect lento se paga aquí, no por request). `max_size=10` para concurrencia.
- `configure` corre por conexión NUEVA del pool → `row_factory` + `register_vector` centralizados.
- Requiere `DATABASE_URL`; si falta, `get_pool()` lanza (igual que hoy sin BD).

### 2. Repos: `_connect` usa el pool
Cada `_connect` pasa de abrir una conexión a **tomarla del pool**:
```python
def _connect(self):
return get_pool().connection() # context manager: checkout → commit/rollback → return-to-pool
```
`with self._connect() as conn:` **no cambia** (mismo API; antes commit+close, ahora commit+return-to-pool). Se **elimina** el `register_vector(conn)` y el `row_factory=dict_row` inline de los repos (ya en el pool `configure`). `_set_claims`/`_set_user_claims` se mantienen igual (transaction-local). Afecta: `orgs`, `defects`, `knowledge`, `certify`, `jira/integrations_repository`, `graph/service`, `repo_ingest`; y `graph/gaps._connect` (función de módulo → misma sustitución).

### 3. Dependencia + arranque
- `requirements.txt`: `psycopg[binary]==3.3.3` → `psycopg[binary,pool]==3.3.3` (añade `psycopg_pool`).
- `asgi.py`: un **lifespan** que pre-abre el pool al startup y lo cierra al shutdown, para que el contenedor arranque con el pool caliente (no el primer request):
```python
from contextlib import asynccontextmanager
from src.db.pool import get_pool, close_pool

@asynccontextmanager
async def lifespan(app):
try: get_pool() # pre-calienta; si la BD no está, degradar sin tumbar el arranque
except Exception: pass
yield
close_pool()
app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
```

### 4. Frontend: `OrgProvider` optimista (`org-provider.tsx`)
- Leer el `activeOrgId` de `localStorage` en el **primer render** y usarlo aunque `/v2/orgs` aún no haya respondido → las páginas (con `enabled: accessToken && orgId`) arrancan sus queries de inmediato.
- Cuando `orgsQuery` resuelve, **validar**: si el `activeOrgId` guardado no está en la lista, corregir a `orgs[0].id`.
- `staleTime` largo para la query de orgs (`5 * 60_000`; cambian poco).
- Fórmula: `activeOrgId = explicitOrgId || storedOrgId || (orgs[0]?.id ?? "")`, con la validación al cargar `orgs`.

## Garantías
- **Multitenancy intacta**: el pool no cambia el membership app-layer ni los claims transaction-local; el pooler ya bypassa RLS. Sin estado compartido entre checkouts (claims local + `pool.connection()` hace rollback de transacción abierta al devolver).
- **Mismo comportamiento**: `with self._connect() as conn:` sigue haciendo commit al salir; las queries no cambian.
- **Degrada**: si el pool no abre (BD caída), los endpoints fallan igual que hoy; el lifespan no tumba el arranque.
- **Impacto**: connect por request de 1–8 s → ~0 (reuso) → requests <0,3 s una vez caliente.

## Testing
- **Backend** (`tests/test_db_pool.py`, nuevo): el pool devuelve conexiones con `dict_row`; reusa (el mismo pool en 2 `get_pool()`); `configure` aplica register_vector (mock/psycopg fake); dos checkouts consecutivos no comparten claims (el 2º no ve `request.jwt.claim.sub` del 1º). Los tests de repos existentes deben seguir verdes (el API de `_connect` no cambia; mockear `get_pool().connection()` donde haga falta). `pytest -m "not integration"` sin `.env`.
- **Frontend** (vitest): `OrgProvider` expone el `activeOrgId` de localStorage antes de que `getOrganizations` resuelva; tras resolver con una lista que no contiene el guardado, corrige a `orgs[0]`.
- **Verificación local = CI** por tarea (backend sin `.env`; frontend `lint:ci`+`test`+`build`).

## Fuera de alcance
- Cold start (pinger), hardware persistente, mover de host.
- Reescribir los repos a un ORM o cambiar las queries.
- Pool en el proxy de Vercel (es serverless; no aplica).
25 changes: 25 additions & 0 deletions frontend/src/components/providers/__tests__/org-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ describe("OrgProvider", () => {
await waitFor(() => expect(screen.getByTestId("active-org").textContent).toBe(ORG_B.id));
});

// Optimistic: stored id is shown BEFORE the query resolves
it("O1 — muestra el id guardado en localStorage antes de que /v2/orgs resuelva", async () => {
localStorage.setItem("mnemo.activeOrgId", "org-guardada");
let resolveOrgs!: (value: typeof ORG_A[]) => void;
const slowPromise = new Promise<typeof ORG_A[]>((res) => { resolveOrgs = res; });
(getOrganizations as ReturnType<typeof vi.fn>).mockReturnValue(slowPromise);

renderWithClient(
<OrgProvider>
<OrgIdDisplay />
</OrgProvider>,
);

// Before the query resolves, the stored id must already be visible
await waitFor(() =>
expect(screen.getByTestId("active-org").textContent).toBe("org-guardada"),
);

// After resolving with a list that does NOT contain the stored id, correct to orgs[0]
resolveOrgs([ORG_A]); // ORG_A.id = "org-a", not "org-guardada"
await waitFor(() =>
expect(screen.getByTestId("active-org").textContent).toBe(ORG_A.id),
);
});

// I2: isLoading is exposed and transitions false→true→false as query resolves
it("I2 — isLoading es true mientras la query está pendiente y false cuando resuelve", async () => {
let resolveOrgs!: (value: typeof ORG_A[]) => void;
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/components/providers/org-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ export function OrgProvider({ children }: PropsWithChildren) {
queryKey: ["organizations", accessToken],
queryFn: () => getOrganizations(accessToken!),
enabled: Boolean(accessToken),
staleTime: 5 * 60_000,
});
const orgs = useMemo(() => orgsQuery.data ?? [], [orgsQuery.data]);

const storedOrgId =
typeof window !== "undefined"
? (window.localStorage.getItem(STORAGE_KEY) ?? "")
: "";

const activeOrgId = useMemo(() => {
if (explicitOrgId) return explicitOrgId;
if (!orgs.length) return "";
const stored = typeof window !== "undefined" ? window.localStorage.getItem(STORAGE_KEY) : null;
return stored && orgs.some((o) => o.id === stored) ? stored : orgs[0].id;
}, [explicitOrgId, orgs]);
if (orgs.length) {
return orgs.some((o) => o.id === storedOrgId) ? storedOrgId : orgs[0].id;
}
return storedOrgId; // optimistic: list not yet loaded
}, [explicitOrgId, orgs, storedOrgId]);

function setActiveOrgId(id: string) {
setExplicitOrgId(id);
Expand Down
Loading
Loading