0021. Limpieza programada de sesiones y tokens (expiración)¶
Estado: Propuesto Fecha: 2026-09-08 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad (trx_refresh_tokens, trx_pre_tokens, log_sesiones)
Contexto¶
trx_refresh_tokens y log_sesiones (0005) nunca se borran ("se revocan,
no se borran"), y trx_pre_tokens (0018) crece con cada login. Sin purga la
tabla crece sin límite: backups más pesados, índices lentos y la ventana de
replay-detección (0018) pierde sentido si el historial es infinito. El cron
general del proyecto (transversal 0015, APScheduler) se fijó, pero falta el qué
purgar y con qué retención en identidad.
Decisión¶
Un job APScheduler diario (app/jobs/purga_sesiones.py) que ejecuta detección
idiomática en lotes, con retención por tabla y sin usar el rol de la API
(el DELETE masivo global no puede pasar por el RLS de app.current_empresa_id).
1. Retención por tabla¶
| Tabla | Qué se borra | Retención | Volumen esperado en calma |
|---|---|---|---|
trx_refresh_tokens |
revocados o expirados | 30 días después de revocar/expirar | solo la última ventana de sesiones |
trx_pre_tokens |
usados o expirados | 24 h | ~1 día de logins |
log_sesiones |
particionada por mes | 12 meses online; lo viejo se suelta (DETACH/DROP) | historial de auditoría reciente |
Por qué 30 días de retención en refresh: la ventana de replay detection (0011/0018) depende de poder comparar el hash de un token reusado; borrar antes recorta esa capacidad forense.
2. Ejecución segura (RLS y sin bloqueos)¶
- El job corre con un rol de mantenimiento (
identidad_purga) conBYPASSRLSen las tres tablas — nunca expuesto a la API (un segundo pool de conexiones). La política RLS porapp.current_empresa_id(0005) no puede borrar de todas las empresas desde una sola sesión; es lo que justifica el rol dedicado. - Borrado en lotes (loop de
DELETE ... WHERE ctid IN (SELECT ... ORDER BY ... LIMIT 5000)conLOCK_TIMEOUT) para no dejar locks largos ni saturarautovacuumde una sola pasada. - Idempotente y multi-réplica: el arranque del job envuelve el cuerpo en
pg_advisory_lock(p.ej.identidad_purga), de modo que con N réplicas solo una corre (transversal 0015, §4).
# app/jobs/purga_sesiones.py
from sqlalchemy import text
from sqlalchemy.orm import Session
RETENCION_REFRESH_DIAS = 30 # trx_refresh_tokens
RETENCION_PRE_TOKEN_HORAS = 24 # trx_pre_tokens
LOTE = 5000
def purgar_en_lotes(session: Session, sql: str, params: dict) -> int:
total = 0
while True:
n = session.execute(text(sql), params).rowcount
session.commit()
total += n
if n < LOTE:
return total
def purga_sesiones(dbj: Session) -> dict:
"""dbj = pool del rol identidad_purga (BYPASSRLS). El advisory lock se
toma y libera en la misma sesión que borra (con N réplicas solo corre una)."""
conn = dbj.connection()
conn.execute(text("SELECT pg_advisory_lock(731001)"))
try:
borrados = {
"refresh": purgar_en_lotes(
dbj,
"DELETE FROM core.identidad.trx_refresh_tokens t "
"WHERE ctid IN (SELECT ctid FROM core.identidad.trx_refresh_tokens "
" WHERE revocado = TRUE "
" OR expiracion < now() - make_interval(days => :retencion) "
" LIMIT :lote)",
{"retencion": RETENCION_REFRESH_DIAS, "lote": LOTE},
),
"pre_token": purgar_en_lotes(
dbj,
"DELETE FROM core.identidad.trx_pre_tokens t "
"WHERE ctid IN (SELECT ctid FROM core.identidad.trx_pre_tokens "
" WHERE usado_en IS NOT NULL "
" OR expiracion < now() - make_interval(hours => :retencion) "
" LIMIT :lote)",
{"retencion": RETENCION_PRE_TOKEN_HORAS, "lote": LOTE},
),
}
finally:
conn.execute(text("SELECT pg_advisory_unlock(731001)"))
return borrados
Nota:
DELETEconLIMITno es sintaxis válida de PostgreSQL; se borra por lotes víactid IN (SELECT ctid ... LIMIT :lote).log_sesionesno se toca con DELETE masivo: se particiona por mes (transversal 0014/0015, patrónaudit_log) y el job solo suelta particiones más viejas de 12 meses (DETACH/DROP). El advisory lock hace que con N réplicas solo corra una.
3. Programación¶
# app/main.py
scheduler.add_job(
purga_sesiones,
CronTrigger(hour=3, minute=0), # 03:00 America/Guayaquil (transversal 0015)
id="purga-sesiones-identidad",
replace_existing=True,
)
4. Monitoreo¶
- Métrica
identidad_purga_filas{tabla="..."}(lo borrado por corrida) yidentidad_purga_duracion_ms. - Alerta si el job no corre en 48 h: la tabla vuelve a crecer sin que nadie lo note; alerta de fail si el advisory lock no se adquiere.
Alternativas descartadas¶
- Purgar "lazy" en cada request — coste irregular y bloqueos en el hot path del login/refresh; la tabla además se mantiene grande entre accesos.
- Sin purga — trx_refresh_tokens crece sin límite (cada login + rotación); backups y índices se degradan; la detección de replay se ahoga en datos viejos.
- Cron del SO / CronJob de Kubernetes — descartado por el transversal 0015 (portabilidad, lógica no versionada).
- DELETE masivo de una sola pasada — locks Hold fuertes y
autovacuuma ráfagas; los lotes de 5000 lo mantienen negociable. - Usar el rol de la API con RLS — no puede: cada sesión ve una sola empresa
(
app.current_empresa_id); de ahí el rolidentidad_purgaconBYPASSRLS, fuera del alcance HTTP.
Consecuencias¶
- Las tablas volátiles de identidad quedan con tamaño acotado y predecible; los backups dejan de crecer por sesiones fantasmas.
- La ventana forense de replay (0018) queda explícitamente en 30 días para refresh y 24 h para pre_tokens.
log_sesionesconserva 12 meses online y suelta lo viejo por partición, sin tocar los datos con DELETE masivos.- Costo: un rol de base dedicado adicional (
identidad_purga,BYPASSRLS) que nunca aparece en el código HTTP; hay que mantenerlo en la provisión del despliegue (ADR 0016).
Este ADR aplica el patrón del transversal 0015 (APScheduler, advisory lock, particiones del 0014 transversal) a las tablas de sesión del 0005 y del 0018. La caducidad que esta purga barre la fija el 0020.