Luis Tavarez · Noticias

Propuesta de arquitectura: reservas idempotentes y un outbox local

Una propuesta verificable con Python y SQLite: conserva una sola reserva, rechaza claves conflictivas y revierte una escritura incompleta.

Luis Tavarez ·

Contexto y criterio de aceptación

Un cliente puede repetir una solicitud tras perder la respuesta. Queremos conservar una reserva por clave, devolver el resultado de una repetición equivalente y rechazar una clave usada con otro importe. Se propone persistir la reserva y el evento pendiente en una misma transacción local.

Decisión propuesta

reservations tiene una clave primaria request_key. Un insert con conflicto no crea otra reserva; después se lee el importe guardado y se compara con el solicitado. outbox usa la misma clave para registrar un único evento de creación. Un constraint, no un diccionario en memoria, sostiene la deduplicación.

Por qué probar también el fallo

El ejemplo repite una petición, rechaza un importe distinto e inyecta un error al insertar en outbox. Comprueba que la reserva de esa operación fallida no queda guardada. Después reabre el archivo para verificar que la deduplicación sobrevivió al cierre de la conexión.

Alternativas y consecuencias

Un caché de claves en el proceso pierde su estado al reiniciar. Separar ambas escrituras deja una ventana de inconsistencia. Una transacción local limita esa ventana dentro de SQLite, pero no entrega el evento a un broker. El consumidor externo, sus reintentos y su deduplicación son trabajo posterior.

Estado de la propuesta

Es un prototipo local y secuencial. No reserva fondos reales, no implementa autenticación, no demuestra rendimiento concurrente y no sustituye una evaluación operativa. Antes de adoptarlo define alcance y expiración de las claves, política de respuesta, carga esperada, copias de seguridad, recuperación y pruebas de concurrencia. El siguiente paso es validar estos criterios con el equipo, no multiplicar servicios sin una necesidad medida.

Python · Laboratorio para desarrolladores

Arquitectura, código y práctica

LT Architecture StudioDiagrama creado con nuestra herramienta. Explóralo y adáptalo a tu proyecto.
Propuesta de arquitectura: reservas idempotentes y un outbox local

Ejecutar el ejemplo

Python 3.9 o superior con sqlite3 de la biblioteca estándar. Descarga main.py. Se usa un archivo temporal que se elimina al terminar.

python main.py

Salida esperada: OK: replay persisted once; conflicting request rejected

Código del ejemplo
python
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory


def reserve(connection, key, amount):
    if not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0:
        raise ValueError("amount must be a positive integer")
    with connection:
        connection.execute(
            "INSERT INTO reservations (request_key, amount_cents) VALUES (?, ?) "
            "ON CONFLICT(request_key) DO NOTHING", (key, amount)
        )
        saved = connection.execute(
            "SELECT amount_cents FROM reservations WHERE request_key = ?", (key,)
        ).fetchone()[0]
        if saved != amount:
            raise ValueError("idempotency key reused with a different amount")
        connection.execute(
            "INSERT INTO outbox (request_key, event_type) VALUES (?, ?) "
            "ON CONFLICT(request_key) DO NOTHING", (key, "reservation.created")
        )
    return saved


with TemporaryDirectory() as directory:
    path = Path(directory) / "proposal.sqlite"
    with sqlite3.connect(path) as connection:
        connection.executescript("""
            CREATE TABLE reservations (
                request_key TEXT PRIMARY KEY, amount_cents INTEGER NOT NULL
            );
            CREATE TABLE outbox (
                request_key TEXT PRIMARY KEY, event_type TEXT NOT NULL,
                FOREIGN KEY(request_key) REFERENCES reservations(request_key)
            );
        """)
        connection.execute("PRAGMA foreign_keys = ON")
        assert reserve(connection, "request-1", 1200) == 1200
        assert reserve(connection, "request-1", 1200) == 1200
        try:
            reserve(connection, "request-1", 9999)
            raise AssertionError("conflicting request accepted")
        except ValueError:
            pass
        connection.executescript("""
            CREATE TRIGGER reject_demo BEFORE INSERT ON outbox
            WHEN NEW.request_key = 'failed-request'
            BEGIN SELECT RAISE(ABORT, 'injected failure'); END;
        """)
        try:
            reserve(connection, "failed-request", 800)
            raise AssertionError("injected failure did not abort")
        except sqlite3.IntegrityError:
            pass
        assert connection.execute(
            "SELECT count(*) FROM reservations WHERE request_key = ?", ("failed-request",)
        ).fetchone()[0] == 0
    connection.close()
    # Reopen to verify that deduplication is persisted, not held in a process cache.
    with sqlite3.connect(path) as reopened:
        assert reserve(reopened, "request-1", 1200) == 1200
        assert reopened.execute("SELECT count(*) FROM reservations").fetchone()[0] == 1
        assert reopened.execute("SELECT count(*) FROM outbox").fetchone()[0] == 1
    reopened.close()
print("OK: replay persisted once; conflicting request rejected")

Ejercicio para continuar

Escribe un ADR con contexto, alternativas, decisión, consecuencias y criterios de rechazo. Añade un test con dos conexiones que compitan por la misma clave.