Al terminar esta lección
- Recorrer los incidentes de una mañana de Nexo y decidir, en cada escena, qué garantía tiene que sobrevivir al fallo.
- Diseñar el reclamo atómico de
requestIdy sus tres respuestas posibles: resultado original, conflicto o espera. - Escribir CU-01 como transacción local con outbox, sacando del camino crítico toda llamada externa.
- Leer y redactar ADR-004, ADR-005 y ADR-006 con alternativas descartadas y consecuencias negativas reales.
- Decidir qué efectos necesitan consistencia fuerte y cuáles pueden ser eventuales, y nombrar la inconsistencia que Nexo acepta.
7.2 Caso · Una mañana con cuatro incidentes
En la semana de implantación del piloto, Nexo tuvo una mañana que resume este capítulo. A las 9:14 dos vendedores pelean por la última unidad de un SKU. A las 9:24 un cliente platino supera el umbral automático de crédito. A las 10:40 se pierde una respuesta en la red y el vendedor reintenta. A las 11:03 el proveedor fiscal no responde a un pedido que ya está confirmado.
Ninguno de esos episodios es exótico. Lo que separa un equipo que sobrevive de uno que apaga incendios es si la arquitectura tiene una respuesta escrita para cada uno, y si esa respuesta sale de una garantía y no de un patrón de moda.
Los protagonistas son los de siempre: Marta (dueña), Laura (crédito y administración), Sergio (depósito) y el Responsable del Desarrollo. Los vendedores aparecen como vendedor A y vendedor B.
Escena 1 · 9:14, una unidad y dos pantallas que dicen 1
El SKU es COR-118 y el depósito es Centro (CEN). La pantalla de cada vendedor muestra disponible = 1. Los dos cargan un pedido, los dos pasan la validación, los dos confirman. La sala de arquitectura tenía tres propuestas sobre la mesa antes de que alguien abriera el código:
- Un contador de reservas en Redis, con un lock distribuido. El Responsable del Desarrollo preguntó qué invariante protegía. La respuesta fue «que no se venda más de lo que hay», sin más precisión, y la propuesta se cayó sola.
- Un
SELECT ... FOR UPDATEseguido de unUPDATE. Funciona, pero deja dos viajes y una fila retenida entre ambos. - Un
UPDATEcondicional dentro de la transacción del caso de uso, que es la decisión de ADR-002.
La discusión que cerró el tema no fue de tecnología. Sergio preguntó quién más escribe stock. La respuesta fue incómoda: el ajuste por inventario físico del viernes corre un script SQL directo contra la tabla stock_level, sin pasar por la aplicación. Esa pregunta explica por qué la protección vive en la base y no en el proceso de la API. Un lock en memoria, o un contador en otro sistema, no protege a quien escribe por otro camino. La sentencia condicional sí, y el CHECK también.
-- Un ajuste manual sin la condición de disponibilidad es rechazado por la base:
UPDATE stock_level SET reserved = reserved + 5
WHERE product_id = $1 AND warehouse_id = 'CEN';
-- ERROR: new row for relation "stock_level" violates check constraint "disponible_no_negativo"
Hay algo que esta decisión no resuelve, y el equipo lo anotó en el registro de desconocidos antes de cerrar la reunión. La reserva evita la sobreventa lógica, pero no corrige el inventario físico. Si en el estante hay seis unidades y el sistema dice ocho, la base va a proteger el número equivocado con mucha precisión. Eso es un problema de operación de depósito, no de arquitectura, y se debe nombrar para no vender la solución como algo más grande de lo que es.
Escena 2 · 9:24, el platino y la reserva que no es firme
Laura recibe la alerta: un cliente platino acaba de superar el umbral automático. La regla RN-02 dice que un platino puede exceder el límite dentro de una tolerancia definida o con autorización, pero la tolerancia exacta está BLOCKED. El sistema no tiene derecho a inventar ese número, y el arquitecto tampoco.
El diseño que el equipo aprobó en la mesa separa tres cosas: el pedido queda en PENDIENTE_AUTORIZACION, la reserva de stock es blanda mientras espera, y nadie puede creer que el pedido está confirmado. Esa decisión tiene un costo: una reserva blanda puede inmovilizar la última unidad durante horas, y el canon no define cuánto. La regla RN-07 existe, pero su plazo sigue BLOCKED.
El punto que más se discute es la columna expires_at. La tentación es poner 24 horas, porque suena razonable y porque un agente de IA lo propuso en una sesión de prueba. El equipo lo dejó en NULL con un comentario explícito en la migración. Un valor inventado se convierte en comportamiento de producción, y después nadie se acuerda de que fue una suposición.
CREATE TABLE stock_reservation (
order_id uuid NOT NULL REFERENCES orders(id),
product_id uuid NOT NULL REFERENCES product(id),
warehouse_id text NOT NULL REFERENCES warehouse(id),
quantity integer NOT NULL CHECK (quantity > 0),
kind text NOT NULL CHECK (kind IN ('BLANDA', 'FIRME')),
-- RN-07 BLOCKED: el plazo de la reserva blanda todavía no existe.
-- No se completa con 24 h ni con otro número. Ver registro de desconocidos.
expires_at timestamptz,
PRIMARY KEY (order_id, product_id, warehouse_id)
);
Laura pide que el platino pase ya
Reunión en la oficina de Nexo. Laura (crédito) y vos. Marta todavía no llegó. El pedido del cliente platino está en PENDIENTE_AUTORIZACION con reserva blanda sobre la última unidad. Laura quiere cerrarlo antes del almuerzo.
Laura (crédito)
Ya hablé con el cliente, es de los de siempre. Confirmalo ahora y lo autorizo yo. ¿Cuánto tarda en pasar a firme?
¿Qué respondés?
Escena 3 · 10:40, la respuesta que se pierde
Un vendedor carga un pedido de cuarenta unidades. El servidor confirma, escribe el pedido y las reservas, y hace COMMIT. La respuesta vuelve por la red y se corta. El navegador muestra un timeout. El vendedor vuelve a presionar Confirmar, y el cliente manda otra vez el mismo requestId, el mismo cliente, el mismo depósito y las mismas líneas.
Sin idempotencia, esto crea un segundo pedido y una segunda reserva. Con la idempotencia de 7.1, el segundo request devuelve el pedido original sin efectos. El detalle que pide este caso es qué pasa cuando los dos requests llegan al mismo tiempo, algo que sucede más de lo que parece cuando el vendedor hace doble clic y el proxy reintenta.
La secuencia muestra algo que el diagrama de 7.1 no mostraba: el segundo request no falla ni crea nada. Espera. La base lo hace esperar por la fila de la clave, y cuando el primero confirma, el segundo lee el resultado. Esa espera es lo que hace barato el reintento simultáneo, y también es lo que hay que medir: si el primero tarda 30 segundos en su transacción, el segundo espera 30 segundos.
Antes de mostrar la solución, revisá una versión que se ve razonable en un code review rápido. Es la primera implementación del equipo.
¿Qué falla en la primera versión de la idempotencia?
Hacé clic en las líneas que rompen RN-06 o la garantía de un solo pedido por intención. Son tres.
Ahora el código de la solución. Esta es la migración que agrega la clave y la cola de salida. Las dos tablas se crean en la misma base que el pedido, y es esa cercanía la que permite que el commit las incluya a todas juntas.
CREATE TABLE idempotency_keys (
request_id uuid PRIMARY KEY,
request_hash text NOT NULL, -- huella de ADR-006
fingerprint_version smallint NOT NULL DEFAULT 1, -- para migrar la función de huella sin invalidar historia
order_id uuid REFERENCES orders(id), -- se completa en la misma transacción del pedido
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox_event (
id uuid PRIMARY KEY, -- también es la Idempotency-Key del destino
aggregate_id uuid NOT NULL REFERENCES orders(id),
event_type text NOT NULL
CHECK (event_type IN ('order.handoff_requested', 'billing.intent_recorded')),
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'PENDIENTE'
CHECK (status IN ('PENDIENTE', 'ENTREGADO', 'FALLIDO')),
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz, -- lease corto del worker
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz
);
CREATE INDEX outbox_pendientes_idx
ON outbox_event (next_attempt_at)
WHERE status = 'PENDIENTE';
ALTER TABLE orders
ADD COLUMN billing_status text NOT NULL DEFAULT 'NO_APLICA'
CHECK (billing_status IN ('NO_APLICA', 'PENDIENTE_FACTURACION', 'FACTURADO'));
El caso de uso que usa estas tablas es la pieza central de la lección. Lo leemos con atención, porque cada línea responde a una garantía de la tabla de 7.1. El orden importa: primero se reclama la intención, después se resuelven precio y crédito, después se reserva en orden de SKU, y el outbox se escribe antes del COMMIT.
import { createHash, randomUUID } from "node:crypto";
export interface Tx {
query<T = unknown>(sql: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>;
}
export interface Db extends Tx {
// Si la función lanza, la transacción hace ROLLBACK y no queda ningún efecto.
transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
}
export type CreateOrderCommand = {
requestId: string;
clientId: string;
preferredDepositId: string;
items: { sku: string; quantity: number }[];
};
export type CreateOrderResult =
| { kind: "confirmed"; orderId: string; replayed: boolean }
| { kind: "pending_authorization"; orderId: string }
| { kind: "conflict"; reason: "request_id_reused" }
| { kind: "rejected"; reason: "invalid_sku" | "insufficient_stock" | "price_source_unavailable" };
class BusinessRejection extends Error {
constructor(readonly reason: Extract<CreateOrderResult, { kind: "rejected" }>["reason"]) {
super(reason);
}
}
// Implementadas en los módulos pricing y credit (detrás de puertos).
declare function resolveServerPrices(
tx: Tx,
cmd: CreateOrderCommand,
): Promise<{ totalCents: number; lines: { productId: string; quantity: number }[] }>;
declare function evaluateCredit(tx: Tx, clientId: string, totalCents: number): Promise<"approved" | "requires_authorization">;
// ADR-006: cliente, depósito y líneas normalizadas por SKU y cantidad. Sin metadata de tracing.
export function fingerprint(cmd: CreateOrderCommand): string {
const lines = cmd.items
.map((i) => ({ sku: i.sku.trim().toUpperCase(), quantity: i.quantity }))
.sort((a, b) => (a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0));
const canonical = JSON.stringify({
clientId: cmd.clientId,
preferredDepositId: cmd.preferredDepositId,
lines,
});
return createHash("sha256").update(canonical).digest("hex");
}
// Reserva condicional: la decisión y la escritura son una sola sentencia (ADR-002).
const RESERVE_SQL = `
UPDATE stock_level
SET reserved = reserved + $3
WHERE product_id = $1 AND warehouse_id = $2 AND on_hand - reserved >= $3`;
export async function createOrder(db: Db, cmd: CreateOrderCommand): Promise<CreateOrderResult> {
const hash = fingerprint(cmd);
try {
return await db.transaction(async (tx) => {
// 1. Reclamar la intención. Si otra transacción ya la tiene, este INSERT espera
// a que termine y devuelve su fila. Nunca hay un SELECT previo fuera de la transacción.
const claim = await tx.query<{ inserted: boolean; request_hash: string; order_id: string | null }>(
`INSERT INTO idempotency_keys (request_id, request_hash)
VALUES ($1, $2)
ON CONFLICT (request_id) DO UPDATE SET request_id = EXCLUDED.request_id
RETURNING (xmax = 0) AS inserted, request_hash, order_id`,
[cmd.requestId, hash],
);
const row = claim.rows[0];
if (!row.inserted) {
if (row.request_hash !== hash) return { kind: "conflict", reason: "request_id_reused" } as const;
return { kind: "confirmed", orderId: row.order_id!, replayed: true } as const;
}
// 2. Precio desde fuentes autoritativas (RN-03). El request no aporta precios.
const priced = await resolveServerPrices(tx, cmd); // lines viene ordenado por SKU
// 3. Crédito (RN-01 y RN-02 BLOCKED). Sin decisión de crédito no hay pedido.
const credit = await evaluateCredit(tx, cmd.clientId, priced.totalCents);
const approved = credit === "approved";
const status = approved ? "CONFIRMADO" : "PENDIENTE_AUTORIZACION";
const reservationKind = approved ? "FIRME" : "BLANDA";
const orderId = randomUUID();
await tx.query(
`INSERT INTO orders (id, client_id, status, total_cents, billing_status)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, cmd.clientId, status, priced.totalCents, approved ? "PENDIENTE_FACTURACION" : "NO_APLICA"],
);
await tx.query(`UPDATE idempotency_keys SET order_id = $2 WHERE request_id = $1`, [cmd.requestId, orderId]);
// 4. Reservar línea por línea, en orden de SKU. Cero filas es stock insuficiente, no un error técnico.
for (const line of priced.lines) {
const reserved = await tx.query(RESERVE_SQL, [line.productId, cmd.preferredDepositId, line.quantity]);
if (reserved.rowCount === 0) throw new BusinessRejection("insufficient_stock");
await tx.query(
`INSERT INTO stock_reservation (order_id, product_id, warehouse_id, quantity, kind, expires_at)
VALUES ($1, $2, $3, $4, $5, NULL)`,
[orderId, line.productId, cmd.preferredDepositId, line.quantity, reservationKind],
);
}
// 5. Efectos posteriores al commit: solo se escriben en el outbox. Ninguna llamada de red acá.
// Si el pedido espera autorización, el handoff se encola recién al autorizar (CU-02).
if (approved) {
await enqueue(tx, orderId, "order.handoff_requested", { orderId });
await enqueue(tx, orderId, "billing.intent_recorded", { orderId });
return { kind: "confirmed", orderId, replayed: false } as const;
}
return { kind: "pending_authorization", orderId } as const;
});
} catch (err) {
// Rechazo de negocio: la transacción ya hizo ROLLBACK. No quedan clave, pedido ni reservas,
// así que un reintento se reevalúa con el stock actual.
if (err instanceof BusinessRejection) return { kind: "rejected", reason: err.reason };
throw err;
}
}
async function enqueue(tx: Tx, aggregateId: string, eventType: string, payload: unknown): Promise<void> {
await tx.query(
`INSERT INTO outbox_event (id, aggregate_id, event_type, payload) VALUES ($1, $2, $3, $4)`,
[randomUUID(), aggregateId, eventType, JSON.stringify(payload)],
);
}
Hay dos decisiones de este código que merecen una pausa. La primera es que un rechazo por stock no persiste la clave: el BusinessRejection deshace todo. Eso significa que un reintento con el mismo requestId se vuelve a evaluar. Es la decisión correcta si el vendedor espera que el stock nuevo se considere, y es incorrecta si se quiere congelar el rechazo. El equipo lo eligió a propósito y lo escribió en ADR-005. La segunda es que el handoff y la intención fiscal se encolan dentro de la transacción. Si el COMMIT falla, no existe el evento. Si el COMMIT se completa, el evento existe aunque el proceso muera un segundo después.
Escena 4 · 11:03, el proveedor fiscal después de preparar
El piloto corre con el adaptador fiscal conectado a un sandbox del proveedor. La emisión real del comprobante queda para una integración posterior, según el alcance del primer release, pero el contrato de fallo ya se diseña y se prueba hoy. A las 11:03 el proveedor no responde a un pedido que ya está confirmado y preparado.
La primera versión del equipo era esta: el caso de uso llama al proveedor dentro del request y, si hay timeout, repite toda la operación. La revisión lo rechazó por tres razones concretas. Primero, el timeout retiene la conexión y las filas de stock mientras el proveedor decide cuánto tarda. Segundo, repetir toda la operación puede reservar o confirmar dos veces si la primera llamada ya había llegado. Tercero, la respuesta del vendedor depende de un tercero que no controlamos.
La versión aceptada cambia la pregunta. El pedido ya es válido y ya está en el depósito, así que la intención fiscal se registra en la base, y un worker la entrega después. El vendedor ve un estado, no un error.
El worker reclama con un lease corto y no mantiene ninguna transacción abierta mientras espera al destino. Esa es la diferencia con un worker ingenuo que hace SELECT ... FOR UPDATE y después llama a la red con la transacción todavía abierta, que es el mismo antipatrón de 7.1 aplicado al proceso asíncrono.
import { PermanentDeliveryError } from "../integrations/errors";
const MAX_ATTEMPTS = 8; // propuesta a validar con operaciones, no es un requisito medido
const DELIVERY_TIMEOUT_MS = 5_000; // propuesta: el contrato del proveedor la confirma o la cambia
type OutboxRow = { id: string; event_type: string; payload: unknown; attempts: number };
type Deliver = (type: string, payload: unknown, opts: { idempotencyKey: string; timeoutMs: number }) => Promise<void>;
export async function relayBatch(db: Db, deliver: Deliver, batchSize = 20): Promise<void> {
// 1. Reclamar: una transacción corta que solo marca un lease. No hay red adentro.
const claimed = await db.transaction(async (tx) => {
const { rows } = await tx.query<OutboxRow>(
`UPDATE outbox_event
SET locked_until = now() + interval '30 seconds'
WHERE id IN (
SELECT id FROM outbox_event
WHERE status = 'PENDIENTE'
AND next_attempt_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED)
RETURNING id, event_type, payload, attempts`,
[batchSize],
);
return rows;
});
// 2. Entregar fuera de cualquier transacción. Si el proceso muere acá, el lease vence y otro worker reintenta.
for (const ev of claimed) {
try {
await deliver(ev.event_type, ev.payload, { idempotencyKey: ev.id, timeoutMs: DELIVERY_TIMEOUT_MS });
await db.query(
`UPDATE outbox_event
SET status = 'ENTREGADO', delivered_at = now(), locked_until = NULL, attempts = attempts + 1
WHERE id = $1`,
[ev.id],
);
} catch (err) {
const attempts = ev.attempts + 1;
const terminal = err instanceof PermanentDeliveryError || attempts >= MAX_ATTEMPTS;
await db.query(
`UPDATE outbox_event
SET attempts = $2,
last_error = $3,
locked_until = NULL,
status = CASE WHEN $4 THEN 'FALLIDO' ELSE 'PENDIENTE' END,
next_attempt_at = now() + make_interval(secs => LEAST(3600, POWER(2, $2)))
WHERE id = $1`,
[ev.id, attempts, String(err), terminal],
);
}
}
}
El destino recibe Idempotency-Key igual al id del evento. Eso permite que el proveedor deduplique si la primera entrega llegó pero la respuesta se perdió, que es exactamente el caso del vendedor de la escena 3 trasladado al proveedor. El worker es at-least-once: puede entregar dos veces. Lo que evita el duplicado es la clave del destino, no la suerte.
Ordená un ciclo de entrega del outbox
Usá las flechas para ordenar lo que hace el worker con cada evento, desde que lo toma hasta que deja de intentarlo.
- Posición 1 de 5: Entregar el evento al destino con Idempotency-Key igual al id del evento y timeout
- Posición 2 de 5: Reclamar un lote con lease corto en una transacción que no hace red
- Posición 3 de 5: Si el destino confirma, marcar ENTREGADO y cerrar el lease
- Posición 4 de 5: Si el error es permanente o se agotan los intentos, marcar FALLIDO y mostrarlo en operaciones
- Posición 5 de 5: Si falla de forma reintentable, sumar un intento y fijar next_attempt_at con backoff
Con este diseño, lo que ve el vendedor a las 11:03 es un pedido confirmado con billing_status = PENDIENTE_FACTURACION. No hay pedido fantasma, no hay comprobante sin pedido y no hay reserva que se repita. Si el proveedor sigue caído dos horas, el evento sigue en PENDIENTE con un backoff de hasta una hora, y el panel de operaciones muestra la antigüedad del evento más viejo. Un fallo externo prolongado se convierte en una métrica, no en una sorpresa al cierre del día.
Las decisiones, en ADR
Esta lección completa los tres ADR que faltan del primer release. ADR-004 y ADR-005 salen directamente de las escenas 3 y 4. ADR-006 define la huella que usa el código de createOrder. Los tres siguen el formato de 7.1, con una diferencia: la sección de consecuencias negativas es obligatoria, y la de alternativas nombra por qué se descartó cada una.
# ADR-004 · Outbox transaccional para efectos posteriores al commit
Estado: aceptada
Decide: Responsable del Desarrollo. Revisa: Laura, por el impacto en estados visibles de pedido.
Relacionada: ADR-003 (worker), ADR-005 (clave de intención), ADR-006 (huella).
## Contexto
- El pedido confirmado debe sobrevivir a la caída de preparación o del proveedor fiscal (NFR-04, ASR-03).
- Dos destinos fallan de forma independiente: el handoff a preparación (AC-11) y la intención fiscal (AC-12).
- Una llamada de red dentro de la transacción retiene locks de stock y bloquea al vendedor.
- Una llamada después del commit, sin persistencia, pierde el evento si el proceso reinicia entre ambos pasos.
## Decisión
Cada efecto externo se escribe como fila en `outbox_event` dentro de la misma transacción que
cambia el estado de negocio. Un worker reclama filas con un lease corto, entrega cada una con
`Idempotency-Key` igual a su `id`, y registra el resultado: ENTREGADO, PENDIENTE con backoff o
FALLIDO. La entrega es at-least-once. Los destinos deben deduplicar con esa clave.
## Alternativas consideradas
1. Llamada síncrona dentro de la transacción. Rechazada: retiene locks mientras el tercero decide
cuánto tarda, y si el tercero actúa y la transacción falla, queda un efecto sin pedido.
2. Llamada síncrona después del commit, en el mismo request, con reintentos en memoria. Rechazada:
si el proceso cae entre el commit y la llamada, el evento se pierde sin rastro.
3. Publicar en una cola externa después del commit. Rechazada: es la doble escritura otra vez, ahora
con dos sistemas que pueden quedar desincronizados (ver ADR-003).
4. Captura de cambios desde el log de la base hacia un bus. Rechazada para el primer release: suma
un componente que operar sin un volumen de eventos que lo justifique. Se revisa si aparecen más
consumidores.
## Consecuencias
Positivas
- Un pedido confirmado no se pierde por una caída externa, y el commit es el único punto de decisión.
- Los fallos externos quedan visibles como estado y como métrica (antigüedad, FALLIDO).
- El vendedor nunca espera a un tercero para terminar su operación.
Negativas
- Entrega at-least-once: todo destino tiene que deduplicar por clave, y eso es un contrato a probar
con un fake que duplique respuestas.
- La latencia de entrega depende del intervalo de polling y del backoff, no de la red.
- La tabla crece sin límite si no se purga. La política de retención queda como desconocido en el registro, con dueño y fecha.
- Un evento en FALLIDO necesita a alguien que lo mire. Sin alerta, es un estado bonito que nadie lee.
## Disparador de revisión
Aparece un tercer o cuarto consumidor que necesita orden estricto entre eventos, o la antigüedad
del evento más viejo supera el umbral operativo que defina Operaciones, o el polling se vuelve una
carga medible sobre la base.
## Verificación
- T12: el fake del fiscal responde timeout antes de procesar, timeout después de procesar y error permanente.
- Comprobar que el pedido sigue válido, el evento queda PENDIENTE y el reintento no duplica el efecto.
# ADR-005 · Idempotencia por requestId
Estado: aceptada
Decide: Responsable del Desarrollo. Revisa: Marta, por el efecto sobre la experiencia del vendedor.
Relacionada: ADR-002 (reserva condicional), ADR-004 (outbox), ADR-006 (huella).
## Contexto
- Un reintento después de un corte de red no debe producir otro pedido (ASR-02, RN-06, AC-07).
- Un reintento con contenido distinto bajo el mismo identificador no debe ejecutarse en silencio (AC-08 y T08).
- Los clientes reintentan por timeout, doble clic y proxies; el servidor no sabe cuál respuesta llegó.
## Decisión
Cada alta de pedido lleva `requestId`, un UUID que el cliente genera al abrir el formulario y reutiliza
en todos los reintentos. La clave se reclama con `INSERT ... ON CONFLICT DO UPDATE` dentro de la
misma transacción del pedido. Si la clave ya está confirmada y la huella coincide, se devuelve el
resultado original sin efectos. Si la huella difiere, se devuelve conflicto. Los rechazos de negocio
no dejan clave: la transacción se revierte completa.
## Alternativas consideradas
1. Guardar la clave y devolver el pedido asociado, sin comparar contenido. Rechazada: el vendedor
que reutiliza el identificador con otras líneas recibe el pedido viejo como si fuera el nuevo.
2. Clave en Redis con TTL. Rechazada: no participa de la transacción del pedido, así que puede
quedar una clave apuntando a un pedido que no se confirmó, y es una segunda fuente de verdad.
3. Deduplicar por contenido, sin `requestId`. Rechazada: dos pedidos legítimos e idénticos (dos
camiones iguales el mismo día) se colapsarían en uno.
4. Identificador en un header HTTP, validado en el middleware. Rechazada como fuente de la semántica:
el transporte puede cambiar, y la regla tiene que vivir en el caso de uso para poder probarse sin
levantar la API.
## Consecuencias
Positivas
- Un pedido por intención, aun con concurrencia real.
- La semántica de conflicto está en el código y en el test, no en la memoria de alguien.
Negativas
- El frontend tiene que generar el `requestId` una sola vez por intención. Un cliente que genera uno
nuevo en cada reintento rompe la garantía sin que nadie lo note.
- Dos requests simultáneos con la misma clave esperan entre sí. Con una transacción lenta, la espera
se nota en el vendedor.
- Los rechazos no se congelan: un reintento vuelve a evaluar el stock. Es una decisión, no un descuido,
pero el vendedor puede esperar lo contrario.
## Disparador de revisión
Aparece un canal de alta de pedidos que no pasa por el caso de uso, o se acepta un alta sin
`requestId`, o el equipo decide que los rechazos también deben ser idempotentes.
## Verificación
- T07: mismo `requestId` y mismo contenido producen un solo pedido y el mismo número.
- T08: mismo `requestId` con contenido distinto produce conflicto y ningún efecto nuevo.
- Prueba concurrente: dos requests con la misma clave a la vez producen un solo pedido.
# ADR-006 · Huella (fingerprint) del request para detectar conflicto
Estado: aceptada
Decide: Responsable del Desarrollo.
Relacionada: ADR-005 (idempotencia por requestId).
## Contexto
- Para distinguir un reintento de un conflicto hace falta comparar el contenido de la intención.
- El mismo pedido puede llegar con otro orden de propiedades, otros headers de tracing o timestamps del
cliente, y eso no debe contar como conflicto.
- La función de huella va a cambiar en algún momento. Las huellas guardadas tienen que seguir siendo
interpretables.
## Decisión
La huella es el SHA-256 de un JSON canónico que contiene: `clientId`, `preferredDepositId` y las líneas
normalizadas por SKU (en mayúsculas, sin espacios) y cantidad, ordenadas por SKU. No entra la metadata de
tracing. La huella se guarda con `fingerprint_version` para poder migrarla sin reinterpretar historia.
## Alternativas consideradas
1. Hash de todo el cuerpo del request. Rechazada: el orden de propiedades o un campo de tracing convierten
un reintento legítimo en un falso conflicto.
2. Comparar campo por campo contra la fila guardada. Rechazada: duplica la lógica de normalización en el
momento de comparar y obliga a guardar el payload completo.
3. No comparar y devolver siempre el pedido original. Rechazada: es la versión ingenua de 7.1, y produce el
vendedor que cargó cuarenta unidades y ve diez.
## Consecuencias
Positivas
- Reintentos legítimos siempre coinciden; contenidos distintos siempre difieren.
- Es una función pura, probable sin base de datos.
Negativas
- Cualquier cambio en la normalización (por ejemplo, redondeo de cantidades) cambia la huella. Sin
`fingerprint_version`, un cambio así rompe reintentos antiguos.
- La huella no incluye el método de entrega. Si el vendedor cambia solo el método con el mismo
`requestId`, el sistema no lo detecta como conflicto. Está abierto en el registro de decisiones:
decidir si `deliveryMethod` entra en la huella requiere una revisión de contrato.
## Disparador de revisión
Aparece una normalización nueva (unidades de venta, múltiplos o equivalencias de SKU) que cambie el
significado de una línea, o se decide que el método de entrega forma parte de la intención.
## Verificación
- Test unitario: dos payloads con el mismo contenido y propiedades en otro orden producen la misma huella.
- Test unitario: cambiar una cantidad o el depósito cambia la huella.
- T08 usa esta función a través del caso de uso.
El ADR-006 deja una pregunta abierta a propósito. El canon fija la huella con cliente, depósito y líneas, y el método de entrega (deliveryMethod) aparece en el contrato del comando. Un ADR no se corrige por intuición, así que la pregunta queda escrita en el ADR y el orquestador del caso la resuelve. Esa es la forma de trabajar con una decisión que todavía no está cerrada.
Consistencia: qué es fuerte y qué es eventual
La pregunta de la lección no es "¿consistencia fuerte o eventual?", como si fuera una propiedad del sistema. Es una propiedad de cada efecto, y se responde con una sola pregunta: si este efecto falla o se retrasa, ¿qué inconsistencia queda visible y cuál es inaceptable?
Consistencia que se dice vs consistencia que se diseña
Toda la operación, incluyendo el aviso al vendedor, el envío a preparación y la emisión fiscal, va dentro de la misma transacción. Cuando el proveedor fiscal tarda, la transacción de stock queda abierta y los demás vendedores esperan por la última unidad de COR-118.
Pedido, reserva, decisión de crédito, auditoría de autorización e intención fiscal se confirman juntos, en una sola base. El envío a preparación y la emisión fiscal son eventuales, pero tienen estado visible y reintento. El vendedor sabe en qué punto está su pedido.
Esta distinción permite responder preguntas que parecen incómodas. ¿Qué pasa si el ERP maestro no responde mientras se valida un SKU? Para el precio, la política de Nexo es no aceptar: si no hay una fuente de precio autoritativa, el pedido se rechaza con el motivo price_source_unavailable. Operar con un precio viejo o con una réplica sin límite de antigüedad sería aceptar una inconsistencia que el negocio no definió. Para la consulta de catálogo sí se puede tolerar una réplica, pero el umbral de antigüedad que Marta acepte todavía es un desconocido, y se registra como tal.
¿Qué pasa si falla el envío a preparación a las 10:30 y el pedido ya está confirmado? El pedido sigue válido y la preparación lo recibe cuando el worker lo entrega. Lo que no se acepta es que el depósito nunca reciba el pedido. Por eso el estado ENTREGADO importa tanto como el PENDIENTE: uno de los dos tiene que ser visible en el panel de operaciones en todo momento.
Y ¿qué pasa si alguien ajusta el stock físico a mano mientras se confirma un pedido? La reserva condicional impide la sobreventa lógica, pero no puede saber que el depósito encontró dos unidades más o menos. Esa inconsistencia es física y se resuelve en el proceso de inventario, con un ajuste auditado. El sistema no la oculta, la deja ver como diferencia entre disponible y estante.
Clasificá la consistencia de cada efecto de la mañana
Para cada efecto de CU-01 y CU-02 (reserva, pedido, auditoría de la autorización, handoff a preparación, intención fiscal, respuesta al vendedor y métrica de volumen), indicá si va en la transacción local, en el outbox o es best-effort. Escribí una línea por efecto con el motivo y la inconsistencia que aceptarías si falla.
Qué puede hacer el agente: revisar un caso de uso buscando carreras entre requests, proponer escenarios A/B, redactar la primera versión de un ADR a partir de tus notas y listar alternativas con su costo operativo.
Qué no debe decidir: si el reintento devuelve conflicto o el resultado original, si un rechazo congela su clave, si una reserva blanda expira y en cuánto tiempo, ni qué inconsistencia acepta Nexo. Esas decisiones tienen dueño (Marta, Laura, el Responsable del Desarrollo) y quedan escritas en el ADR.
Contexto mínimo útil: el caso de uso y su transacción, el esquema de idempotencia y outbox, RN-01 a RN-07 con su estado (BLOCKED incluido), T06 a T08 y T12, y los ADR vigentes.
Cómo verificar: cada hallazgo debe traer una línea de código, un escenario de dos requests paso a paso y el test que lo detectaría. Un hallazgo sin escenario es opinión.
Prompt de trabajo:
Actuá como revisor de concurrencia, no como arquitecto. No propongas Redis, colas ni tecnología nueva.
Adjunto: src/orders/create-order.ts, db/migrations/0003_idempotencia_outbox.sql, ADR-002, ADR-005.
Tarea: buscá carreras entre dos requests con el mismo requestId y entre una reserva y un ajuste
manual de stock_level. Para cada hallazgo entregá:
1) la línea o la sentencia involucrada,
2) el entrelazado A/B paso a paso que produce el daño,
3) el test (T06, T07, T08 o uno nuevo) que lo detectaría.
Si para decidir algo falta un dato (plazo de reserva blanda, método de entrega en la huella),
escribilo como pregunta. No completes valores.
Cómo revisar la salida: descartá todo hallazgo sin escenario paso a paso. Buscá números que no están en el contexto (un TTL de 24 horas aparecido de la nada es señal de alarma). Verificá que el agente no haya propuesto un lock en memoria ni un contador externo sin justificar por qué la base no alcanza.
Operación: qué mirar en un día malo
Un diseño defendible también dice qué observar. Para esta arquitectura, el tablero de operaciones necesita cinco señales, y ninguna es decorativa:
- Antigüedad del evento
PENDIENTEmás viejo del outbox. Mide si el worker está vivo y si un destino está caído. - Cantidad de eventos
FALLIDOpor destino. Cada uno necesita una decisión humana, no un reintento silencioso. - Conflictos
request_id_reusedpor hora. Un número alto suele indicar un frontend que regenera el identificador. - Reservas
BLANDAsin resolver y pedidos enPENDIENTE_AUTORIZACIONpor encima del tiempo que defina el negocio. - Duración de las transacciones de reserva. Si sube, la última unidad de un SKU caliente empieza a serializar a todo el depósito.
El umbral de cada señal lo define la operación, no el equipo de desarrollo. El equipo solo garantiza que la señal exista y que alguien la mire.
Errores frecuentes
- Reintento que devuelve el pedido sin comparar el contenido. Síntoma: un vendedor cargó cuarenta unidades y el pedido muestra diez. Causa: el
SELECTporrequestIddevuelve la fila y nadie compara la huella. Corrección: compararrequest_hashy devolver conflicto cuando no coincide. - Lookup de la clave fuera de la transacción. Síntoma: en hora pico aparecen errores de clave duplicada o dos pedidos con el mismo identificador. Causa: el
SELECTprevio alBEGINdeja una ventana en la que dos requests pasan ambos. Corrección: reclamar conINSERT ... ON CONFLICTdentro de la misma transacción. - Reintento del caso de uso completo ante timeout del fiscal. Síntoma: el mismo pedido aparece con dos reservas o dos intenciones de facturar. Causa: el reintento repite la reserva y la confirmación, no solo la llamada que falló. Corrección: sacar el efecto externo al outbox y dejar que solo el worker reintente, con la clave del evento.
- Reserva blanda con plazo inventado. Síntoma: unidades que vuelven a estar disponibles a las 24 horas, sin que nadie haya decidido nada. Causa: se completó un hueco del canon (RN-07 BLOCKED) con un número razonable. Corrección: dejar
expires_aten NULL, nombrar la pregunta a Marta y mostrar en la pantalla que la disponibilidad puede cambiar. - Worker que hace red con la transacción abierta. Síntoma: conexiones agotadas y filas del outbox bloqueadas durante minutos cuando un destino está lento. Causa:
SELECT ... FOR UPDATEseguido de la llamada HTTP dentro del mismoBEGIN. Corrección: reclamar con lease corto en una transacción que no hace red, entregar fuera y registrar el resultado en otra.
Para llevarte
- Cada efecto de CU-01 tiene una clasificación: transacción local si es parte de la garantía mínima, outbox si depende de una red externa, best-effort si se puede recalcular.
- El reclamo de
requestIdocurre dentro de la transacción del pedido. Sus tres respuestas son resultado original, conflicto o espera, y las tres se prueban. - ADR-004, ADR-005 y ADR-006 tienen alternativas descartadas y consecuencias negativas. Si un ADR no tiene ninguna consecuencia negativa, no está terminado.
- Los plazos que el negocio no definió (RN-07, umbral de antigüedad de catálogo) se dejan como preguntas explícitas, nunca como constantes en el código.
- El worker entrega at-least-once y no mantiene transacciones abiertas mientras espera al destino. La clave del evento es lo que evita el duplicado.
Comprobá tu criterio
A las 11:03 el proveedor fiscal no responde a un pedido ya confirmado. ¿Qué diseño es el defendible?
¿Cómo valoras el contenido técnico de esta lección?
Tu feedback nos ayuda a mejorar los laboratorios prácticos de Eje Z.