Ir al contenido

Transacciones en Odoo: savepoints, UniqueViolation y logs que sobreviven al rollback

Qué pasa con la transacción cuando capturas una excepción y cómo no dejarla abortada
12 de octubre de 2026 por
Transacciones en Odoo: savepoints, UniqueViolation y logs que sobreviven al rollback
ATEMI, Aitor Atencia

Odoo · Transacciones · PostgreSQL

Cada petición HTTP y cada ejecución de cron en Odoo es una única transacción de PostgreSQL: o se confirma todo, o no se confirma nada. Ese modelo es cómodo hasta que necesitas que algo falle un poco: crear un registro que quizá ya existe, seguir procesando un lote aunque un elemento falle, o guardar un log de error que no desaparezca con el rollback.

Logo Odoo
Un savepoint es una transacción pequeña dentro de la grande: si falla, solo se deshace ella.

El síntoma

«current transaction is aborted»

Cuando PostgreSQL rechaza una sentencia (una clave duplicada, un CHECK que no se cumple, una FK rota), marca la transacción entera como abortada. A partir de ahí rechaza cualquier consulta hasta que se haga rollback. Si tu código captura la excepción y sigue como si nada, la siguiente consulta, que puede estar en otro módulo, falla con un error que no tiene nada que ver con la causa real.

# ❌ Captura el error pero deja la transacción inservible
try:
    self.env["product.tag"].create({"name": name})
except Exception:
    pass  # "ya existe, no pasa nada"

# La siguiente consulta revienta:
# psycopg2.errors.InFailedSqlTransaction:
#   current transaction is aborted, commands ignored until end of transaction block
self.env["product.tag"].search([("name", "=", name)])

La solución

cr.savepoint() alrededor de lo que puede fallar

Un savepoint delimita un bloque: si dentro salta una excepción, PostgreSQL deshace solo ese bloque y la transacción principal sigue sana. En Odoo, self.env.cr.savepoint() además vacía las escrituras pendientes del ORM al entrar y al salir, así que el error salta dentro del bloque y no más tarde.

from psycopg2.errors import UniqueViolation


def _get_or_create_tag(self, name):
    Tag = self.env["product.tag"]
    try:
        with self.env.cr.savepoint():
            return Tag.create({"name": name})
    except UniqueViolation:
        # Otro proceso la creó entre medias: la transacción sigue válida
        return Tag.search([("name", "=", name)], limit=1)

Este patrón «intenta crear y, si existe, búscalo» es más robusto que «busca y, si no existe, créalo» cuando hay concurrencia real (webhooks que llegan a la vez, varios workers procesando la misma cola): entre el search y el create otro proceso puede adelantarse, y solo la constraint de unicidad lo detecta de verdad.

Lotes

Procesar 500 registros aunque fallen 3

En un cron que procesa un lote, un error en un elemento no debería tirar los otros 499. Un savepoint por elemento aísla los fallos; el registro que falla se marca para revisarlo y el resto se confirma al final del cron.

def _cron_sync_pending(self):
    for record in self.search([("sync_state", "=", "pending")], limit=500):
        try:
            with self.env.cr.savepoint():
                record._sync_one()
        except (UserError, ValidationError, requests.RequestException) as exc:
            record.write({"sync_state": "error", "sync_error": str(exc)})

Logs

Un log de error que sobreviva al rollback

Si guardas el log de una llamada a una API en la misma transacción y luego la operación falla, el rollback se lleva también el log: justo el registro que necesitabas para depurar. La salida es escribirlo con un cursor propio, que se confirma de forma independiente.

def _log_api_call(self, endpoint, status, body):
    with self.env.registry.cursor() as cr:
        env = self.env(cr=cr)
        env["my.api.log"].create({
            "endpoint": endpoint,
            "status": status,
            "body": body[:10000],
        })
    # al salir del with, el cursor propio hace commit

Úsalo con moderación: un cursor propio es una conexión más al pool y no ve los datos sin confirmar de la transacción principal.

SituaciónHerramienta
Crear algo que puede estar duplicadosavepoint() + except UniqueViolation
Lote donde un fallo no debe parar el restoUn savepoint() por elemento
Log que debe sobrevivir al errorregistry.cursor() propio
Test que espera un errorself.assertRaises de Odoo (ya abre su savepoint)
Confirmar a mitad de un cron muy largoself.env.cr.commit(), con cuidado e idempotencia

Resumen

Un error de PostgreSQL aborta toda la transacción. Si quieres recuperarte de él, envuelve la operación en self.env.cr.savepoint() y captura la excepción concreta; si necesitas que un log sobreviva al fallo, escríbelo con un cursor propio. Capturar sin savepoint solo mueve el error a otra línea.

en Odoo
Fechas y zonas horarias en Odoo: UTC, context_timestamp y trampas
Por qué tu cron dice que hoy es ayer y cómo calcular «hoy» en la zona del usuario