BORME API

Notas de ingeniería · Postgres

La barrera que te salva 364 días al año te rompe la exportación el día 365

2026-08-19 · 3 min · nota de campo

Todas las consultas que ejecuta nuestra API REST llevan un tope. El rol con el que se conecta arrastra un límite de tiempo por sentencia, fijado una sola vez a nivel de rol en lugar de confiarlo a cada punto de llamada:

ALTER ROLE borme_api SET statement_timeout = '10s';

Esa línea se ha ganado el sueldo. Un ILIKE sin acotar sobre 9,5 millones de eventos del boletín es un recorrido secuencial que, de otro modo, se quedaría ahí ocupando una conexión del pool hasta que alguien se diera cuenta. Con el tope muere a los diez segundos y quien llama recibe un error en vez de una petición colgada.

Después publicamos la exportación masiva —la instantánea completa de empresas como un único CSV comprimido— y la barrera se volvió contra nosotros. Un COPY sobre la tabla entera tarda minutos de forma legítima. A los diez segundos, Postgres lo mataba. Y el síntoma ni siquiera era un mensaje de tiempo agotado: el cliente veía un flujo gzip truncado, porque la respuesta HTTP ya había empezado con un 200 antes de que la consulta muriera.

Por qué la solución es una línea, y por qué es segura

conn.execute("SET statement_timeout = '600s'")

ALTER ROLE ... SET no impone un techo. Fija un valor por defecto de sesión, que se aplica al abrir una conexión, y un simple SET dentro de esa sesión lo sobrescribe. Así que esto no es un agujero abierto en la barrera: cualquier otra conexión que abra el proceso sigue arrancando con diez segundos. Solo la sesión que hace la exportación obtiene la correa larga, y solo mientras viva.

Esa última parte importa más de lo que parece, y por eso la exportación no toma prestada una conexión del pool.

Tres cosas que el tope estaba tapando

Una conexión del pool no puede quedar retenida durante minutos. La exportación abre la suya propia. Con el pool, una sola descarga habría bloqueado una plaza durante toda la transferencia mientras las peticiones normales hacían cola — el tope venía impidiendo en silencio que ese escenario durase lo suficiente como para doler.

Si el cliente se desconecta a mitad del COPY, la conexión queda insincronizable. Si quien lee cuelga a mitad de camino, no hay forma limpia de vaciar un COPY consumido a medias y devolver la conexión al pool. La salida honesta es cerrar el socket, y hacerlo en un finally para que ocurra por todos los caminos:

conn = psycopg.connect(os.environ["BORME_DSN"])
try:
    conn.execute("SET statement_timeout = '600s'")
    with conn.cursor() as cur, cur.copy(_DUMP_SQL) as copy:
        for chunk in copy:
            gz.write(bytes(chunk))
            if buf.tell() > 256 * 1024:
                yield buf.getvalue()
                buf.seek(0); buf.truncate()
    gz.close()
    yield buf.getvalue()
finally:
    conn.close()

Comprima y vacíe sobre la marcha. Las filas pasan por un GzipFile hacia un búfer de 256 KB que se emite y se trunca cada vez que se llena. La memoria máxima es el búfer, no la exportación — que es justo la razón por la que esto puede ejecutarse dentro del propio proceso de la API.

Los números

Prueba sobre la instantánea real: 3.278.470 filas, 95 MB comprimidos, 24,6 segundos. Cómodamente por debajo del nuevo techo — y dos veces y media el anterior, en un día bueno. El margen de un día malo es exactamente la razón por la que la sobrescritura es de 600 s y no de 30.

Lo que no construimos

La forma evidente de un endpoint masivo es un proceso nocturno que materializa un fichero en un volumen y un endpoint que lo sirve. Eso son un cron, un PVC, una ventana de regeneración y un fichero que llega a estar hasta un día desfasado.

COPY ... TO STDOUT transmitido directamente a la respuesta HTTP elimina las cuatro cosas. No hay artefacto que conservar, nada que recolectar después, y los bytes están tan frescos como el instante en que llegó la petición. El precio es que ahora una consulta pesada se ejecuta bajo demanda, así que el endpoint lleva su propio freno: plan pro o superior, una descarga por clave y día, contada sobre el registro de peticiones que ya llevábamos.

No toda consulta larga merece un tiempo mayor. Esta sí, porque es un trabajo acotado y de forma conocida —un recorrido completo de tabla cuyo tamaño podemos predecir— y no una consulta que se volvió lenta por accidente. Una sobrescritura por sesión es la herramienta correcta justo cuando se puede decir de antemano por qué esta sentencia es distinta.

Documentación de la API Precios y planes

build 2026.08.19·2ba6b50