Una operación grande falla en producción con este error de Prisma, aunque en las pruebas con un solo elemento funcionaba:
PrismaClientKnownRequestError:
Transaction API error: Transaction already closed: A query cannot be executed on an expired transaction. The timeout for this transaction was 5000 ms, however 7413 ms passed since the start of the transaction. Consider increasing the interactive transaction timeout or doing less work in the transaction.
code: 'P2028'
Según la operación que llegue tarde, el texto dice A query o A commit, pero el código es siempre P2028. Este artículo cubre el caso de prisma.$transaction(async (tx) => ...) en Prisma Client 6 con PostgreSQL, y la combinación de causas que lo provoca con más frecuencia: usar el cliente global dentro de la transacción y hacer una consulta por cada elemento.
Por qué pasa
Una transacción interactiva ($transaction con una función asíncrona) permanece abierta mientras la función se ejecuta. La documentación de Prisma define dos límites: timeout, el tiempo máximo que la transacción puede ejecutarse antes de cancelarse y revertirse (5 segundos por defecto), y maxWait, el tiempo máximo que Prisma Client espera para obtener la transacción de la base de datos (2 segundos por defecto). Cuando se supera timeout, las consultas posteriores fallan con P2028, cuya descripción oficial es "Transaction API error".
Con un elemento, la función termina en milisegundos y nunca se nota el límite. Con cientos, se suman dos problemas:
- Cliente global dentro de la transacción. La documentación indica que las llamadas hechas sobre la instancia
txquedan dentro de la transacción; las que se hacen con otro cliente no forman parte de ella. Si un servicio importa elprismaglobal y lo usa en medio del callback, esas consultas se ejecutan fuera de la transacción (no se revierten con el resto) y, mientras tanto, la transacción sigue abierta consumiendo tiempo. - Consultas N+1. Calcular el número de secuencia de cada elemento con un
COUNT(*)completo significa una consulta adicional por elemento, todas en serie. Con cientos de elementos, esos viajes a la base de datos pueden bastar para pasar de los 5 segundos.
La propia documentación advierte que mantener transacciones abiertas durante mucho tiempo perjudica el rendimiento y puede causar bloqueos mutuos, y recomienda evitar peticiones de red y consultas lentas dentro de la función y mantener la transacción lo más corta posible.
Solución
-
Localiza el trabajo innecesario dentro del callback. Este es el patrón problemático, simplificado:
await prisma.$transaction(async (tx) => { for (const linea of lineas) { // N+1: un COUNT(*) completo por cada línea const total = await tx.movimiento.count({ where: { loteId } }); await tx.movimiento.create({ data: { loteId, productoId: linea.productoId, cantidad: linea.cantidad, numero: total + 1 }, }); // este servicio usa el cliente global `prisma` por dentro, no `tx` await servicioStock.ajustar(linea.productoId, linea.cantidad); } }); -
Pasa
txa todo lo que deba ocurrir dentro de la transacción. Las funciones auxiliares reciben el cliente transaccional como parámetro en lugar de importar el global:import { Prisma } from "@prisma/client"; export async function ajustarStock(tx: Prisma.TransactionClient, lineas: Linea[]) { for (const linea of lineas) { await tx.stock.update({ where: { productoId: linea.productoId }, data: { cantidad: { increment: linea.cantidad } }, }); } }Prisma.TransactionClientes el tipo que genera Prisma Client paratx; la documentación oficial de la versión 6 no lo describe, así que confirma que existe en tu versión. -
Calcula una sola vez y deriva el resto en memoria. Cuenta antes del bucle y asigna los números con el índice. Si puedes, inserta en bloque con
createMany:const base = await tx.movimiento.count({ where: { loteId } }); await tx.movimiento.createMany({ data: lineas.map((linea, i) => ({ loteId, productoId: linea.productoId, cantidad: linea.cantidad, numero: base + i + 1, })), });Si el número debe ser único aunque dos procesos reciban a la vez, contar filas no lo garantiza: apóyate en una secuencia de la base de datos o en una restricción única con reintento.
-
Saca de la transacción lo que no necesita ser atómico. Dentro queda lo que debe confirmarse o revertirse en conjunto. Auditoría, notificaciones, llamadas HTTP o invalidación de caché se ejecutan después del
commit, con el valor que devuelve$transaction:const resultado = await prisma.$transaction( async (tx) => { const base = await tx.movimiento.count({ where: { loteId } }); // construirMovimientos arma el arreglo con `numero: base + i + 1`, como en el paso 3 await tx.movimiento.createMany({ data: construirMovimientos(lineas, loteId, base) }); await ajustarStock(tx, lineas); return { loteId, cantidadLineas: lineas.length }; }, { timeout: 30000, maxWait: 10000 }, ); // la transacción ya está confirmada await registrarAuditoria(resultado); await notificarRecepcion(resultado);Si uno de esos pasos posteriores falla, los datos ya están guardados: hazlos reintentables o idempotentes.
-
Añade un margen con
timeoutymaxWaitdespués de optimizar, no en lugar de optimizar. Subir el límite no hace más rápido el código, solo retrasa el fallo, y una transacción larga mantiene bloqueos. Úsalo como colchón. Si lo necesitas en todas las transacciones, el constructor aceptatransactionOptionscon los mismos campos:const prisma = new PrismaClient({ transactionOptions: { timeout: 30000, maxWait: 10000 }, }); -
Prueba con un volumen realista. Un caso con un solo elemento no detecta este problema. Ejecuta la operación con cientos de elementos, igual que en producción.
Cómo verificar que quedó resuelto
Ejecuta la operación con un volumen parecido al real y mide el tiempo:
console.time("recepcion");
await recibirLote(loteId, lineas); // 300-400 elementos
console.timeEnd("recepcion");
No debe aparecer P2028. Comprueba además la integridad de los datos:
SELECT numero, COUNT(*) FROM movimiento WHERE lote_id = 1 GROUP BY numero HAVING COUNT(*) > 1;
SELECT SUM(cantidad) FROM movimiento WHERE lote_id = 1;
La primera consulta no debe devolver filas (números únicos) y la segunda debe coincidir con la suma de las cantidades enviadas. En una prueba local con 350 elementos, la operación terminó en unos 7,4 segundos sin error. Ese tiempo supera los 5 segundos por defecto, lo que confirma que el límite también influía y que el margen del paso 5 era necesario.
Variantes y errores relacionados
Una primera corrección parcial no basta
Si solo cambias el cliente global por tx y pruebas con un elemento, el error seguirá en producción. Revisa ambas causas y prueba con volumen.
maxWait agotado
Si en lugar de superar el tiempo de ejecución no se logra iniciar la transacción a tiempo, el límite que se agota es maxWait. Revisa también el pool de conexiones y que no haya muchas transacciones largas a la vez.
P2024: agotado el pool de conexiones
La descripción oficial de P2024 es "Timed out fetching a new connection from the connection pool". Puede aparecer cuando muchas operaciones concurrentes mantienen conexiones ocupadas, por ejemplo transacciones largas.
Consultas sin await
Una consulta lanzada dentro del callback sin await (por ejemplo en un forEach) puede ejecutarse cuando la transacción ya terminó y producir un error similar. Usa for...of con await, o Promise.all sobre un map.
P2034: conflicto de escritura o bloqueo mutuo
Es otro error de transacciones: "Transaction failed due to a write conflict or a deadlock". Su descripción oficial pide reintentar la transacción.
Versión de la documentación
Este artículo se basa en la documentación de Prisma 6. La documentación publicada para versiones más recientes puede describir una API de transacciones distinta, así que confirma que consultas la versión de tu proyecto.
Resumen
P2028"Transaction already closed" significa que la transacción interactiva superó sutimeout(5 s por defecto) antes de que llegara otra consulta.- Dentro de
$transactionusa siempretx; un servicio que importe el cliente global ejecuta fuera de la transacción. - Elimina el N+1: calcula el conteo base una vez y deriva los números en memoria, con
createManysi es posible. - Mueve a después del
commitlo que no necesita ser atómico y añade{ timeout, maxWait }solo como margen. - Prueba siempre con un volumen realista, no con un único elemento.
Comentarios
Publicar un comentario