Merge pull request 'perf/cache-hub-dashboard' (#2) from perf/cache-hub-dashboard into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 2s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

Reviewed-on: #2
This commit is contained in:
2026-07-21 21:33:43 +00:00
7 changed files with 177 additions and 22 deletions

View File

@@ -6,12 +6,20 @@ from typing import Callable, Optional
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from cachetools import TTLCache
from .config import settings from .config import settings
from .security import get_tenant_from_token, verify_token, get_active_system from .security import get_tenant_from_token, verify_token, get_active_system
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache de licencia VÁLIDA por tenant (evita golpear verify-license del Hub en cada
# request). Solo el camino "válida" se cachea — inválida/expirada/error nunca se
# cachean y siempre revalidan contra el Hub (fail-closed: lo peor que puede pasar es
# que un tenant recién revocado siga pasando hasta 5 min más, nunca al revés).
_LICENSE_OK_TTL_SECONDS = 300
_license_ok_cache: TTLCache = TTLCache(maxsize=1000, ttl=_LICENSE_OK_TTL_SECONDS)
def _normalize_text(value: str | None) -> str: def _normalize_text(value: str | None) -> str:
if not value: if not value:
@@ -160,6 +168,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
if tid is not None and str(tid).strip() != "": if tid is not None and str(tid).strip() != "":
tenant_override = str(tid) tenant_override = str(tid)
# Licencia ya confirmada válida hace poco para este tenant — no volver a
# golpear al Hub (ver definición de _license_ok_cache arriba).
if tenant_override and tenant_override in _license_ok_cache:
return await call_next(request)
hub_headers = {"Authorization": f"Bearer {token}"} hub_headers = {"Authorization": f"Bearer {token}"}
if tenant_override: if tenant_override:
hub_headers["X-Tenant-Override"] = str(tenant_override) hub_headers["X-Tenant-Override"] = str(tenant_override)
@@ -274,6 +287,8 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
request.state.license_info = data request.state.license_info = data
if tenant_override:
_license_ok_cache[tenant_override] = True
return await call_next(request) return await call_next(request)
if response.status_code == 401: if response.status_code == 401:

View File

@@ -18,8 +18,10 @@ from .database import get_core_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens) # Cache para tokens verificados (5 minutos de TTL, máximo 1000 tokens).
token_cache = TTLCache(maxsize=1000, ttl=60) # El access token vive más que este TTL, así que cachear su verificación es seguro
# y evita golpear /auth/me del Hub en cada request de navegación.
token_cache = TTLCache(maxsize=1000, ttl=300)
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas) # IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
_synced_tenant_ids: Set[int] = set() _synced_tenant_ids: Set[int] = set()

View File

@@ -60,12 +60,17 @@ async def on_startup():
# Agregar middlewares personalizados # Agregar middlewares personalizados
app.add_middleware(LicenseValidationMiddleware)
app.add_middleware(TenantMiddleware)
# RequestLoggingMiddleware va después de Tenant/License (add_middleware() invierte
# el orden de ejecución: el último en registrarse corre primero) para que su cronómetro
# envuelva TODO, incluyendo las llamadas al Hub — si no, "Duration:" en los logs
# nunca reflejó el costo real de verify-license/auth-me, dando una falsa sensación
# de que todo respondía en 2-5ms.
if settings.DEBUG: if settings.DEBUG:
app.add_middleware(RequestLoggingMiddleware) app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(TenantMiddleware)
app.add_middleware(LicenseValidationMiddleware)
# CORS debe ser el último en añadirse para que sea el más externo # CORS debe ser el último en añadirse para que sea el más externo
# y cubra todas las respuestas, incluyendo las de los middlewares internos # y cubra todas las respuestas, incluyendo las de los middlewares internos
app.add_middleware( app.add_middleware(

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getDashboardShellCache,
setDashboardShellCache,
DASHBOARD_SHELL_CACHE_TTL_MS
} from './dashboard-shell-cache';
const SAMPLE_DATA = {
userTenants: [{ id: 1, name: 'Tenant A', slug: 'tenant-a' }],
myApps: { apps: [{ id: 'app-1' }], routing: 'default' }
};
describe('dashboard-shell-cache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('devuelve null si no hay nada cacheado para esa llave', () => {
expect(getDashboardShellCache('sesion-inexistente')).toBeNull();
});
it('devuelve lo cacheado dentro del TTL', () => {
setDashboardShellCache('sesion-1', SAMPLE_DATA);
expect(getDashboardShellCache('sesion-1')).toEqual(SAMPLE_DATA);
});
it('sigue acertando aunque el "access token" hubiera rotado — la llave es el session key, no el token', () => {
// Simula dos navegaciones con tokens de acceso distintos (rotación cada ~60s)
// pero el mismo session key estable (sid/sub) — como haría getJwtSessionKey().
setDashboardShellCache('mismo-session-key', SAMPLE_DATA);
vi.advanceTimersByTime(5_000);
expect(getDashboardShellCache('mismo-session-key')).toEqual(SAMPLE_DATA);
});
it('expira después del TTL', () => {
setDashboardShellCache('sesion-2', SAMPLE_DATA);
vi.advanceTimersByTime(DASHBOARD_SHELL_CACHE_TTL_MS + 1);
expect(getDashboardShellCache('sesion-2')).toBeNull();
});
it('no acierta entre llaves distintas (sesiones distintas)', () => {
setDashboardShellCache('sesion-usuario-a', SAMPLE_DATA);
expect(getDashboardShellCache('sesion-usuario-b')).toBeNull();
});
});

View File

@@ -0,0 +1,33 @@
/**
* Caché en memoria del "shell" del dashboard (tenants y apps del Workspace) para no
* golpear al Hub en cada navegación — esas dos llamadas no tienen caché propia en el
* Hub y antes se repetían en cada `+layout.server.ts` load.
*
* Llave = session key estable (ver jwt.ts), NUNCA el access token: el token rota cada
* ~60s, así que usarlo como llave produce 0% de aciertos entre navegaciones.
*/
export const DASHBOARD_SHELL_CACHE_TTL_MS = 30_000;
export interface DashboardShellData {
userTenants: { id: number; name: string; slug: string }[];
myApps: { apps: unknown[]; routing: unknown };
}
const cache = new Map<string, { data: DashboardShellData; expiresAt: number }>();
export function getDashboardShellCache(sessionKey: string): DashboardShellData | null {
const entry = cache.get(sessionKey);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(sessionKey);
return null;
}
return entry.data;
}
export function setDashboardShellCache(sessionKey: string, data: DashboardShellData): void {
cache.set(sessionKey, { data, expiresAt: Date.now() + DASHBOARD_SHELL_CACHE_TTL_MS });
}

View File

@@ -0,0 +1,35 @@
/**
* Decodifica el payload de un JWT sin verificar firma (el backend ya lo valida contra
* el Hub) para extraer un identificador de sesión estable.
*/
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const payload = token.split('.')[1];
if (!payload) return null;
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
} catch {
return null;
}
}
/**
* ID de sesión estable que sobrevive al refresh del access token: `sid` de Keycloak,
* o `sub` (usuario) como respaldo.
*
* El access token rota cada ~60s (ver token_cache en backend/core/security.py) —
* usar el propio token (o parte de él) como llave de caché hace que cada refresh
* invalide la caché sin motivo real, dejándola con 0% de aciertos.
*/
export function getJwtSessionKey(token: string): string | null {
const payload = decodeJwtPayload(token);
if (!payload) return null;
const sid = payload['sid'];
if (typeof sid === 'string' && sid) return sid;
const sub = payload['sub'];
if (typeof sub === 'string' && sub) return sub;
return null;
}

View File

@@ -9,6 +9,8 @@ import {
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { resolveActiveCompanyId } from '$lib/server/system-gate'; import { resolveActiveCompanyId } from '$lib/server/system-gate';
import { fetchMyApps } from '$lib/server/workspace-apps'; import { fetchMyApps } from '$lib/server/workspace-apps';
import { getJwtSessionKey } from '$lib/server/jwt';
import { getDashboardShellCache, setDashboardShellCache } from '$lib/server/dashboard-shell-cache';
const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true'; const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true';
@@ -30,24 +32,38 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
if (!DEV_LOCAL_AUTH) { if (!DEV_LOCAL_AUTH) {
try { // my-tenants y my-apps no tienen caché propia en el Hub — sin esto, cada
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); // navegación al dashboard las repetía (ver dashboard-shell-cache.ts).
const tenantOverride = cookies.get('sso_tenant_id'); const sessionKey = getJwtSessionKey(accessToken);
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { const cachedShell = sessionKey ? getDashboardShellCache(sessionKey) : null;
headers: {
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
}
});
if (tenantsRes.ok) {
userTenants = await tenantsRes.json();
}
} catch {
// No bloquear el dashboard si falla la carga de tenants
}
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; if (cachedShell) {
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); userTenants = cachedShell.userTenants;
myApps = cachedShell.myApps;
} else {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
}
});
if (tenantsRes.ok) {
userTenants = await tenantsRes.json();
}
} catch {
// No bloquear el dashboard si falla la carga de tenants
}
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
if (sessionKey) {
setDashboardShellCache(sessionKey, { userTenants, myApps });
}
}
} }
return { return {