14
Transacciones y distribuidos

Control de concurrencia: locks, deadlocks y MVCC

Transacciones y sistemas distribuidos

Semana 14 — Control de concurrencia: locks, deadlocks y MVCC

Objetivos

  • Explicar two-phase locking (2PL) y sus variantes strict y rigurosa, y por qué garantizan conflict serializability.
  • Prevenir deadlocks con wait-die y wound-wait, detectarlos con un waits-for graph y elegir una víctima.
  • Usar locks de intención (intention locks) para trabajar a varias granularidades, y explicar qué es un phantom y cómo se evita.
  • Explicar cómo implementa PostgreSQL MVCC (xmin, xmax, snapshots, VACUUM) y por qué snapshot isolation permite write skew. ✂️ Comparar con los protocolos basados en timestamps.
  • Observar en PostgreSQL los locks, un deadlock, las versiones de una fila y el write skew.

Sesión A — Control de concurrencia: 2PL, deadlocks y granularidad

Lecturas:

  • Silberschatz, secs. 18.1–18.4 (Lock-Based Protocols, Deadlock Handling, Multiple Granularity, Insert Operations, Delete Operations, and Predicate Reads).
  • CS186 (UC Berkeley), Transactions & Concurrency II: 2PL, el lock manager, deadlocks (prevención y detección) y multiple granularity locking.

Lecturas complementarias:

Temas:

  • Intuición: un lock es un permiso para tocar un ítem. Shared lock (S) para leer, exclusive lock (X) para escribir. S es compatible con S; X no es compatible con nada. Si el lock pedido no es compatible con los que ya se dieron, la transacción espera.

  • El lock manager por dentro: una hash table indexada por el identificador del ítem; en cada entrada, la lista de locks concedidos y una cola de espera FIFO. Un pedido se concede si es compatible con los concedidos y no hay nadie esperando antes (si no, un flujo de lectores podría dejar esperando para siempre a un escritor: starvation). Al liberar, se despierta a los de la cola. Lock upgrade: pasar de S a X sin soltar el S. Es exactamente lo que van a implementar en P5.

  • Tener locks no basta. Si T1 suelta el lock de A apenas lo lee y luego toma el de B, otra transacción puede meterse en medio y el schedule deja de ser serializable.

  • 2PL: cada transacción tiene una fase de crecimiento (solo pide locks) y una de contracción (solo los suelta). El momento en que tiene todos sus locks es su lock point. Si hay una arista Ti → Tj en el grafo de precedencia, el lock point de Ti va antes que el de Tj; por eso el grafo no puede tener ciclos y el schedule es conflict serializable.

  • 2PL solo no evita rollbacks en cascada: T1 suelta el X de A en la fase de contracción, T2 lee A y después T1 aborta. Strict 2PL guarda los locks X hasta el commit o el abort, así que los schedules son cascadeless. Rigorous 2PL guarda todos los locks hasta el final; las transacciones quedan serializadas en el orden de sus commits. Los DBMS usan strict o rigorous: PostgreSQL guarda los locks de fila hasta el fin de la transacción.

  • Con Sistemas Operativos: un mutex es un lock sobre un objeto; 2PL es una disciplina para usar muchos. Las cuatro condiciones de deadlock que vieron (exclusión mutua, hold and wait, no preemption, espera circular) se cumplen en 2PL por diseño: hold and wait es la fase de crecimiento.

  • Deadlock en Chasqui: T1 actualiza el envío 501 y luego el 502; T2 actualiza el 502 y luego el 501. Otro caso, más traicionero: T1 y T2 leen el envío 501 con S y las dos quieren subirlo a X. Ninguna puede: deadlock por upgrade. Por eso existen SELECT ... FOR UPDATE y, en otros motores, los update locks.

  • Prevención: (a) pedir los locks en un orden fijo, como el orden de locks en SO; (b) con timestamps, donde la transacción más antigua tiene el timestamp menor. Wait-die (no preemptiva): si la que pide es más antigua, espera; si es más joven, muere (rollback). Wound-wait (preemptiva): si la que pide es más antigua, hiere a la joven (la aborta); si es más joven, espera. En los dos esquemas la transacción reiniciada conserva su timestamp original, así que algún día es la más antigua y no sufre starvation. (c) Timeouts: simples, pero adivinan.

  • Detección: el waits-for graph tiene una arista Ti → Tj si Ti espera un lock que tiene Tj. Hay deadlock si y solo si hay un ciclo. El sistema elige una víctima (la que hizo menos trabajo, la que tiene menos locks, la que menos veces fue víctima) y le hace rollback total o hasta un savepoint. PostgreSQL busca el ciclo solo después de que una transacción lleva deadlock_timeout esperando (1 s por defecto) y no elige por costo: la sesión que hace la búsqueda y encuentra el ciclo se aborta a sí misma con el error 40P01.

  • Granularidad múltiple: la base, las tablas, las páginas y las filas forman un árbol. Bloquear una tabla entera con un lock por fila es caro; bloquear la tabla impide que otros trabajen. Los locks de intención resuelven las dos cosas: IS (voy a leer algo más abajo), IX (voy a escribir algo más abajo) y SIX (leo todo el nodo y escribo algo más abajo).

  • Matriz de compatibilidad (✓ = compatible):

    IS IX S SIX X
    IS ✓ ✓ ✓ ✓
    IX ✓ ✓
    S ✓ ✓
    SIX ✓
    X
  • Protocolo: se bloquea de la raíz hacia las hojas y se libera de las hojas hacia la raíz. Para pedir S o IS en un nodo hay que tener IS o IX en el padre; para pedir X, SIX o IX, hay que tener IX o SIX en el padre. Lock escalation: si una transacción acumula demasiados locks de fila, el sistema los cambia por uno de tabla.

  • En PostgreSQL, un UPDATE toma ROW EXCLUSIVE en la tabla, que cumple el papel de IX, y un SELECT toma ACCESS SHARE. Los locks de fila no se guardan en la tabla de locks: se marcan en la cabecera de la tupla (en xmax), y en pg_locks solo aparecen cuando alguien espera.

  • Phantoms: T1 suma weight_kg de los envíos del viaje 12 para ver si cabe uno más; T2 inserta un envío nuevo para el viaje 12. Bloquear las filas que T1 leyó no sirve: la fila de T2 no existía. El conflicto es sobre un predicado (trip_id = 12), no sobre una fila. Soluciones: lock de tabla (demasiado grueso), index locking (bloquear las hojas del índice sobre trip_id que cubren ese valor; next-key locking es una variante) o predicate locking.

  • Error común: pensar que 2PL evita deadlocks. Al revés, los provoca: retener locks mientras se piden otros es una de las condiciones del deadlock. Solo la variante conservadora (pedir todos los locks al empezar) los evita, y casi nunca se puede usar porque no se sabe de antemano qué filas se van a tocar.

  • Error común: confundir 2PL (control de concurrencia en un servidor) con 2PC (two-phase commit, commit atómico entre varios servidores; semana 16). Solo comparten el “dos fases”.

Sesión B — MVCC y snapshot isolation · ✂️ protocolos con timestamps

Lecturas:

  • Silberschatz, secs. 18.7–18.8 (Multiversion Schemes, Snapshot Isolation).
  • PostgreSQL, Concurrency Control (introducción a MVCC) y System Columns (xmin, xmax, ctid).
  • ✂️ Silberschatz, secs. 18.5–18.6 (Timestamp-Based Protocols, Validation-Based Protocols).

Lecturas complementarias:

  • CMU 15-445 (Fall 2025), clase Multi-Version Concurrency Control: notas y video.
  • PostgreSQL, Routine Vacuuming, hasta la parte de transaction ID wraparound.
  • ✂️ CMU 15-445 (Fall 2025), clase Timestamp Ordering Concurrency Control: notas y video.

Temas:

  • Intuición: con locks, quien lee el envío 501 espera a quien lo está modificando. Si guardamos la versión anterior de la fila, el lector puede leer esa y nadie espera. Multiversion concurrency control (MVCC): cada escritura crea una versión nueva y cada transacción lee las versiones que corresponden a su momento.
  • Esquemas del libro (18.7): multiversion timestamp ordering y multiversion 2PL. Los motores actuales usan sobre todo la variante siguiente.
  • Snapshot isolation (SI): cada transacción lee de un snapshot, una foto de lo que había hecho commit cuando empezó; sus escrituras son privadas hasta el commit. Si dos transacciones concurrentes escriben la misma fila, solo una puede hacer commit (first-committer-wins; PostgreSQL usa first-updater-wins: la segunda espera y, si la primera hace commit, recibe 40001). Los lectores nunca bloquean a los escritores ni al revés.
  • MVCC en PostgreSQL: cada versión de una fila (tupla) guarda en su cabecera xmin, el id de la transacción que la creó, y xmax, el de la que la borró o reemplazó (o la que tiene un lock sobre ella). Un UPDATE no modifica la tupla: escribe una versión nueva con otro ctid y pone xmax en la vieja. El estado de cada transacción (en curso, commit, abortada) está en pg_xact.
  • Un snapshot en PostgreSQL es casi solo una lista de transacciones: las que estaban en curso cuando se tomó (pg_current_snapshot()). Regla de visibilidad, simplificada: una versión es visible si su xmin hizo commit antes del snapshot y su xmax está vacío, abortó o no había hecho commit en el snapshot. READ COMMITTED toma un snapshot por sentencia; REPEATABLE READ y SERIALIZABLE, uno por transacción.
  • VACUUM: las versiones viejas que ningún snapshot puede ver son tuplas muertas (dead tuples) y ocupan espacio hasta que VACUUM (o autovacuum) las limpia. Una transacción abierta durante horas impide limpiar todo lo que ella todavía podría ver, y las tablas se inflan (bloat). Los ids de transacción son de 32 bits; VACUUM también congela (freeze) las tuplas viejas para que la cuenta pueda dar la vuelta (wraparound) sin problemas.
  • Write skew con la capacidad de un vehículo. Supongan que el viaje 12 lleva un vehículo de 1000 kg y ya tiene 940 kg asignados (en el laboratorio lo preparamos así con un UPDATE). Dos empleados, a la vez, revisan la carga: la empleada A ve 940 + 50 ≤ 1000 y asigna un envío de 50 kg; el empleado B, con el mismo snapshot, ve 940 + 45 ≤ 1000 y asigna otro de 45 kg. Escriben filas distintas, así que no hay conflicto de escritura y SI deja que las dos hagan commit. Resultado: 1035 kg. Ningún orden serial da ese resultado: cada una leyó algo que la otra escribió (dos dependencias de lectura-escritura en ciclo).
  • Arreglos: (a) SERIALIZABLE en PostgreSQL es serializable snapshot isolation (SSI): sobre SI, registra qué leyó cada transacción con locks de predicado que no bloquean (SIReadLock en pg_locks) y aborta una transacción con 40001 cuando encuentra dos dependencias lectura-escritura seguidas (la “estructura peligrosa”). Puede abortar de más (falsos positivos) y la aplicación debe reintentar. (b) Materializar el conflicto: las dos transacciones hacen SELECT ... FROM trip WHERE trip_id = 12 FOR UPDATE antes de revisar la carga, en READ COMMITTED. (c) Una restricción o un trigger que verifique la capacidad (con cuidado: el trigger también lee con un snapshot).
  • Error común: creer que snapshot isolation es serializable. No lo es, aunque durante años Oracle llamó SERIALIZABLE a su snapshot isolation.
  • Error común: decir que REPEATABLE READ en PostgreSQL permite phantoms. No los permite (el snapshot es fijo), pero sí permite write skew.
  • ✂️ Timestamp ordering (18.5): cada transacción recibe un timestamp TS(Ti) al empezar; cada ítem Q guarda W-timestamp(Q) y R-timestamp(Q). Ti no puede leer Q si TS(Ti) < W-timestamp(Q) (ya lo sobrescribió alguien más nuevo), ni escribir Q si TS(Ti) < R-timestamp(Q) o TS(Ti) < W-timestamp(Q). Si viola una regla, se le hace rollback y se reinicia con un timestamp nuevo. Garantiza conflict serializability y no tiene deadlocks porque nadie espera; sin reglas extra, los schedules pueden no ser recuperables. La Thomas write rule ignora una escritura obsoleta en lugar de abortar, y acepta algunos schedules view serializable.
  • ✂️ Validación u optimistic concurrency control (18.6): fase de lectura (trabaja en copias locales), validación (¿alguien escribió lo que leí?) y escritura. Conviene cuando los conflictos son raros. Si no alcanza el tiempo, este bloque queda como lectura.

Laboratorio — Locks y MVCC en PostgreSQL

Abran tres terminales con ./env/run.sh. En A y B, psql chasqui con los prompts de la semana 13; C es para observar. En C guarden estas dos consultas en un archivo (por ejemplo, locks.sql) y córranlas con \i locks.sql cada vez que quieran mirar. La primera muestra los locks; la segunda, quién bloquea a quién:

SELECT pid, locktype, relation::regclass, transactionid, mode, granted
FROM pg_locks
WHERE pid <> pg_backend_pid()
  AND (database = (SELECT oid FROM pg_database WHERE datname = current_database())
       OR database IS NULL)
ORDER BY pid, granted;

SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, query
FROM pg_stat_activity
WHERE datname = 'chasqui';
  1. Locks de una escritura. En A: BEGIN; SELECT pg_backend_pid(), pg_current_xact_id(); UPDATE shipment SET price = price + 1 WHERE shipment_id = 501;. En C, encuentren el RowExclusiveLock sobre shipment y sus índices, y el ExclusiveLock de A sobre su propio transactionid. No hay un lock de fila: está en la tupla.

  2. Una espera. En B: UPDATE shipment SET price = price + 1 WHERE shipment_id = 501;. B se queda esperando. En C, vean el ShareLock de B sobre el transactionid de A con granted = false (B espera a que A termine) y blocked_by en pg_stat_activity. En A: ROLLBACK; y vean que B continúa. ROLLBACK; en B.

  3. Granularidad. En A: BEGIN; y un UPDATE de una fila de shipment (toma ROW EXCLUSIVE, como IX). En B: BEGIN; LOCK TABLE shipment IN SHARE MODE; (como S): B espera. Busquen en la tabla de conflictos de Explicit Locking por qué, y relaciónenlo con la matriz IS/IX/S/SIX/X. ROLLBACK; en las dos.

  4. Un deadlock. En A y B: SET deadlock_timeout = '10s'; (pueden porque son superusuario) para tener tiempo de mirar. A: BEGIN; UPDATE shipment SET price = price + 1 WHERE shipment_id = 501;. B: BEGIN; UPDATE shipment SET price = price + 1 WHERE shipment_id = 502;. A: lo mismo con 502 (espera). B: lo mismo con 501 (espera). Antes de que pasen 10 s, dibujen el waits-for graph desde C. Luego una de las dos recibe ERROR: deadlock detected (40P01); lean el DETAIL, que nombra los procesos y las transacciones del ciclo. ¿Cuál fue la víctima y cuál empezó a esperar primero? Terminen las dos transacciones.

  5. Deadlock por upgrade. Repitan con SELECT price FROM shipment WHERE shipment_id = 501 FOR SHARE; en las dos y después un UPDATE de esa fila en las dos. Expliquen por qué P5 tiene que manejar este caso.

  6. Versiones de una fila. En A: SELECT ctid, xmin, xmax, price FROM shipment WHERE shipment_id = 501;. Luego BEGIN; SELECT pg_current_xact_id();, el UPDATE del precio y repitan el SELECT en A: ctid nuevo y xmin igual al id de A. En B, el mismo SELECT: ve la versión vieja con xmax igual al id de A. COMMIT; en A y repitan en B. Prueben también un SELECT ... FOR UPDATE sin UPDATE: xmax cambia aunque no haya versión nueva. Vean SELECT pg_current_snapshot(); desde B mientras A está abierta.

  7. Tuplas muertas. Hagan unas cuantas actualizaciones de precio y miren SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'shipment'; (las estadísticas se actualizan con unos segundos de retraso). Corran VACUUM (VERBOSE) shipment; y vuelvan a mirar. Repitan con una transacción abierta en B en REPEATABLE READ que ya haya leído algo: actualicen más filas desde A, corran VACUUM (VERBOSE) otra vez y expliquen por qué ahora quedan tuplas muertas que no se pueden quitar.

  8. Preparar el write skew. Elijan un viaje (aquí, 12) y calculen su carga:

    SELECT v.vehicle_id, v.capacity_kg, coalesce(sum(s.weight_kg), 0) AS load_kg
    FROM trip t
      JOIN vehicle v ON v.vehicle_id = t.vehicle_id
      LEFT JOIN shipment s ON s.trip_id = t.trip_id
    WHERE t.trip_id = 12
    GROUP BY v.vehicle_id, v.capacity_kg;

    Anoten la capacidad original y déjenla en la carga actual más 60 kg con un UPDATE vehicle. Elijan dos envíos con trip_id IS NULL de entre 35 y 60 kg cada uno: cada uno cabe solo, los dos juntos no.

  9. Write skew en REPEATABLE READ. En A y B: BEGIN ISOLATION LEVEL REPEATABLE READ; y la consulta de carga. Cada uno asigna su envío (UPDATE shipment SET trip_id = 12 WHERE shipment_id = ...) y hace COMMIT;. Las dos terminan bien y el viaje queda sobrecargado. Deshagan las asignaciones.

  10. El mismo caso en SERIALIZABLE y con FOR UPDATE. Repitan con BEGIN ISOLATION LEVEL SERIALIZABLE;: una recibe could not serialize access due to read/write dependencies among transactions (40001). Antes del COMMIT, busquen los SIReadLock en C. Después, en READ COMMITTED, agreguen SELECT * FROM trip WHERE trip_id = 12 FOR UPDATE; al principio de las dos: B espera y, cuando A termina, ve la carga nueva. Pregunta para el informe: ¿por qué este arreglo funciona en READ COMMITTED pero no en REPEATABLE READ? Al final, devuelvan la capacidad original al vehículo y quiten las asignaciones.

Entregas

  • P4 entrega.
  • P5 publicado: Motor: transacciones (en quipu, un lock manager con strict 2PL: locks compartidos y exclusivos sobre registros, lock upgrade y cola de espera; detección de deadlocks con un waits-for graph; y un write-ahead log simple con redo y undo para recuperarse de una caída simulada). Entrega en semana 16. Ver projects/README.md.

Preguntas de repaso

  1. T1 hace lock-S(A) r1(A) unlock(A) lock-X(B) w1(B) unlock(B). ¿Cumple 2PL? Reescríbanla para que cumpla strict 2PL y muestren un schedule con otra transacción que la versión original permite y la nueva no.
  2. Tres transacciones tienen timestamps T1 = 5, T2 = 10 y T3 = 15. T2 tiene un lock X sobre el envío 501. Primero T1 y después T3 piden un lock sobre esa fila. ¿Qué le pasa a cada una con wait-die y con wound-wait? ¿Con qué timestamp se reinicia una transacción abortada y por qué?
  3. Estado del lock manager: T1 tiene X sobre A y espera S sobre B; T2 tiene S sobre B y espera X sobre C; T3 tiene S sobre C y espera X sobre A; T4 espera S sobre A. Dibujen el waits-for graph. ¿Hay deadlock? ¿Qué víctima elegirían y qué transacciones pueden avanzar después?
  4. Una transacción recorre toda la tabla shipment para recalcular precios y modifica solo los envíos con más de 100 kg. ¿Qué locks pide en la base, en la tabla y en las filas con multiple granularity locking? ¿Puede otra transacción leer una fila a la vez? ¿Y escribir otra fila?
  5. Expliquen el write skew del viaje 12 con dos dependencias de lectura-escritura. ¿Por qué REPEATABLE READ en PostgreSQL no lo detecta, aunque no permite phantoms? ¿Qué hace SERIALIZABLE para detectarlo y qué costo tiene?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (7)Pruebas (5)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

deadlock.mjsDescargar deadlock.mjs
// Dos deadlocks reales entre dos sesiones, A y B, sobre los envíos 501 y 502.
//
//   node deadlock.mjs          orden cruzado: A toma 501 y pide 502; B toma
//                              502 y pide 501
//   node deadlock.mjs upgrade  upgrade: A y B leen 501 con FOR SHARE y las
//                              dos quieren actualizarlo
//   node deadlock.mjs cross 1s 4s   con otros deadlock_timeout para A y B
//                              (por defecto, 4s y 2s)
//
// Mientras las dos esperan, una tercera sesión (el observador) muestra
// pg_locks, pg_blocking_pids y el waits-for graph armado con findCycle.
// deadlock_timeout es distinto en cada sesión para que se sepa de antemano
// quién encuentra el ciclo: en PostgreSQL, la sesión que lo encuentra es la
// que aborta, con el error 40P01.
import {
  openSession, launch, waitUntilBlocked, lockRows, blockingRows,
  psqlShow, closeAll, LOCKS_SQL, BLOCKING_SQL,
} from './sessions.mjs';
import { findCycle, edgesFromBlocking } from './waitsfor.mjs';

const UPDATE = 'UPDATE shipment SET price = price + 1 WHERE shipment_id = $1';
const FOR_SHARE = 'SELECT price FROM shipment WHERE shipment_id = $1 FOR SHARE';

// kind: 'cross' o 'upgrade'. timeouts: deadlock_timeout de A y de B.
// print: función que recibe cada línea de la traza (por defecto, nada).
export async function runDeadlock({
  kind = 'cross', timeoutA = '4s', timeoutB = '2s', print = () => {},
} = {}) {
  let a, b, obs;
  try {
    a = await openSession('A', { deadlockTimeout: timeoutA });
    b = await openSession('B', { deadlockTimeout: timeoutB });
    obs = await openSession('obs');
    const step = async (s, sql, params) => {
      print(`${s.name}: ${sql.replace('$1', params?.[0])}`);
      return s.query(sql, params);
    };

    await step(a, 'BEGIN');
    await step(b, 'BEGIN');
    const [xa, xb] = [
      (await a.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x,
      (await b.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x,
    ];
    print(`   (A es el pid ${a.pid}, xid ${xa}; B es el pid ${b.pid}, xid ${xb})`);

    let waitA, waitB;
    if (kind === 'cross') {
      await step(a, UPDATE, [501]);
      await step(b, UPDATE, [502]);
      print('A: ' + UPDATE.replace('$1', 502) + '   -- espera');
      waitA = launch(a, UPDATE, [502]);
      await waitUntilBlocked(obs, a.pid);
    } else {
      await step(a, FOR_SHARE, [501]);
      await step(b, FOR_SHARE, [501]);
      print('A: ' + UPDATE.replace('$1', 501) + '   -- espera');
      waitA = launch(a, UPDATE, [501]);
      await waitUntilBlocked(obs, a.pid);
    }
    const locksOneWaiting = await lockRows(obs, [a, b]);
    const shownOne = psqlShow(LOCKS_SQL, [a, b]);

    // En los dos casos, B termina pidiendo el envío 501.
    print('B: ' + UPDATE.replace('$1', 501) + '   -- espera');
    waitB = launch(b, UPDATE, [501]);
    await waitUntilBlocked(obs, b.pid);

    // Las dos esperan: todavía no pasó ningún deadlock_timeout.
    const locks = await lockRows(obs, [a, b]);
    const blocking = await blockingRows(obs, [a, b]);
    const shownLocks = psqlShow(LOCKS_SQL, [a, b]);
    const shownBlocking = psqlShow(BLOCKING_SQL, [a, b]);
    const graph = findCycle(edgesFromBlocking(blocking));
    print('');
    print('pg_locks con A esperando:');
    print(shownOne.trimEnd());
    print('');
    print('pg_locks con las dos esperando:');
    print(shownLocks.trimEnd());
    print('');
    print(shownBlocking.trimEnd());
    print('');
    print(`findCycle: ${JSON.stringify(graph)}`);

    const [ra, rb] = await Promise.all([waitA, waitB]);
    const results = { A: ra, B: rb };
    let victim = null;
    // Primero la víctima: su error es lo que deja continuar a la otra.
    print('');
    for (const [name, r] of Object.entries(results).sort(([, r]) => (r.error ? -1 : 1))) {
      if (r.error) {
        victim = name;
        print(`${name}: ERROR:  ${r.error.message}  (código ${r.error.code})`);
        print(`DETAIL:  ${r.error.detail.replaceAll('\n', '\n         ')}`);
      } else {
        print(`${name}: UPDATE ${r.rowCount}   -- continúa`);
      }
    }
    const survivor = victim === 'A' ? b : a;
    const loser = victim === 'A' ? a : b;
    await step(loser, 'ROLLBACK');
    await step(survivor, 'ROLLBACK'); // no cambiamos los precios de verdad

    return {
      pids: { A: a.pid, B: b.pid }, xids: { A: xa, B: xb },
      locksOneWaiting, locks, blocking, graph, victim,
      error: results[victim]?.error ?? null,
      shown: { one: shownOne, locks: shownLocks, blocking: shownBlocking },
    };
  } finally {
    await closeAll(a, b, obs);
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const kind = process.argv[2] === 'upgrade' ? 'upgrade' : 'cross';
  const [timeoutA = '4s', timeoutB = '2s'] = process.argv.slice(3);
  await runDeadlock({ kind, timeoutA, timeoutB, print: (line) => console.log(line) });
}
locks.mjsDescargar locks.mjs
// Los locks que toma PostgreSQL en tres situaciones, vistos desde pg_locks:
//
//   1. A lee el envío 501 y luego lo actualiza: locks de tabla
//      (AccessShareLock, RowExclusiveLock) y el lock de A sobre su propio xid.
//   2. B quiere actualizar la misma fila: espera un ShareLock sobre el xid de
//      A. Cuando A termina, B continúa.
//   3. Granularidad: con A dentro de un UPDATE (ROW EXCLUSIVE en la tabla),
//      B pide LOCK TABLE shipment IN SHARE MODE y espera.
//
//   node locks.mjs           los tres casos
//   node locks.mjs matrix    la matriz de conflictos de los locks de tabla,
//                            medida con LOCK TABLE ... NOWAIT
import {
  openSession, launch, waitUntilBlocked, lockRows, blockingRows,
  psqlShow, closeAll, LOCKS_SQL, BLOCKING_SQL,
} from './sessions.mjs';

const UPDATE = 'UPDATE shipment SET price = price + 1 WHERE shipment_id = 501';

export async function runLocks({ print = () => {} } = {}) {
  let a, b, obs;
  const out = {};
  try {
    a = await openSession('A');
    b = await openSession('B');
    obs = await openSession('obs');
    const say = (s, sql) => print(`${s.name}: ${sql}`);
    const show = (sql) => {
      const text = psqlShow(sql, [a, b]);
      print(text.trimEnd() + '\n');
      return text;
    };

    // 1. Una lectura y una escritura.
    print('-- 1. Una lectura y una escritura');
    say(a, 'BEGIN');
    await a.query('BEGIN');
    say(a, 'SELECT price FROM shipment WHERE shipment_id = 501');
    await a.query('SELECT price FROM shipment WHERE shipment_id = 501');
    out.afterRead = await lockRows(obs, [a]);
    show(LOCKS_SQL);
    say(a, UPDATE);
    await a.query(UPDATE);
    out.xidA = (await a.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x;
    out.afterWrite = await lockRows(obs, [a]);
    out.shownWrite = show(LOCKS_SQL);

    // 2. Una espera.
    print('\n-- 2. Una espera');
    say(b, 'BEGIN');
    await b.query('BEGIN');
    say(b, `${UPDATE}   -- espera`);
    const waiting = launch(b, UPDATE);
    await waitUntilBlocked(obs, b.pid);
    out.waiting = await lockRows(obs, [a, b]);
    out.blocking = await blockingRows(obs, [a, b]);
    out.shownWait = show(LOCKS_SQL);
    show(BLOCKING_SQL);
    say(a, 'ROLLBACK');
    await a.query('ROLLBACK');
    const r = await waiting;
    out.bAfter = r;
    print(`B: UPDATE ${r.rowCount}   -- continúa`);
    say(b, 'ROLLBACK');
    await b.query('ROLLBACK');

    // 3. Granularidad: ROW EXCLUSIVE (como IX) contra SHARE (como S).
    print('\n-- 3. Granularidad');
    say(a, 'BEGIN');
    await a.query('BEGIN');
    say(a, UPDATE);
    await a.query(UPDATE);
    say(b, 'BEGIN');
    await b.query('BEGIN');
    say(b, 'LOCK TABLE shipment IN SHARE MODE   -- espera');
    const tableLock = launch(b, 'LOCK TABLE shipment IN SHARE MODE');
    await waitUntilBlocked(obs, b.pid);
    out.tableWait = await lockRows(obs, [a, b]);
    out.shownTable = show(LOCKS_SQL);
    say(a, 'ROLLBACK');
    await a.query('ROLLBACK');
    out.tableAfter = await tableLock;
    print('B: LOCK TABLE   -- continúa');
    out.tableHeld = await lockRows(obs, [b]);
    show(LOCKS_SQL);
    say(b, 'ROLLBACK');
    await b.query('ROLLBACK');
    return out;
  } finally {
    await closeAll(a, b, obs);
  }
}

// Los ocho modos de lock de tabla de PostgreSQL, del más débil al más fuerte.
export const TABLE_MODES = [
  'ACCESS SHARE', 'ROW SHARE', 'ROW EXCLUSIVE', 'SHARE UPDATE EXCLUSIVE',
  'SHARE', 'SHARE ROW EXCLUSIVE', 'EXCLUSIVE', 'ACCESS EXCLUSIVE',
];

// La matriz de conflictos, medida: A toma un modo sobre agency y B pide otro
// con NOWAIT, que falla con 55P03 en vez de esperar.
export async function conflictMatrix({ print = () => {} } = {}) {
  let a, b;
  const matrix = {};
  try {
    a = await openSession('A');
    b = await openSession('B');
    for (const held of TABLE_MODES) {
      matrix[held] = {};
      await a.query('BEGIN');
      await a.query(`LOCK TABLE agency IN ${held} MODE`);
      for (const asked of TABLE_MODES) {
        await b.query('BEGIN');
        const r = await launch(b, `LOCK TABLE agency IN ${asked} MODE NOWAIT`);
        if (r.error && r.error.code !== '55P03') throw r.error;
        matrix[held][asked] = !r.error; // true = compatible
        await b.query('ROLLBACK');
      }
      await a.query('ROLLBACK');
    }
  } finally {
    await closeAll(a, b);
  }
  const short = (m) => m.split(' ').map((w) => w[0]).join('');
  print(`${'tiene \\ pide'.padEnd(24)}${TABLE_MODES.map((m) => short(m).padStart(5)).join('')}`);
  for (const held of TABLE_MODES) {
    print(`${held.padEnd(24)}${TABLE_MODES.map((m) => (matrix[held][m] ? 'ok' : 'X').padStart(5)).join('')}`);
  }
  return matrix;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const print = (line) => console.log(line);
  if (process.argv[2] === 'matrix') await conflictMatrix({ print });
  else await runLocks({ print });
}
locks.sqlDescargar locks.sql
-- Consultas para mirar los locks desde una tercera sesión de psql, mientras
-- otras dos trabajan. Se corren con \i locks.sql cada vez que quieran mirar.

-- Los locks de las demás sesiones: los concedidos primero y después los que
-- esperan (granted = f).
SELECT pid, locktype, relation::regclass, page, tuple, transactionid, mode, granted
FROM pg_locks
WHERE pid <> pg_backend_pid()
  AND locktype <> 'virtualxid'
ORDER BY pid, granted DESC;

-- Quién bloquea a quién: el waits-for graph, una arista por cada pid de
-- blocked_by.
SELECT pid, application_name, pg_blocking_pids(pid) AS blocked_by, state, query
FROM pg_stat_activity
WHERE datname = current_database()
  AND pid <> pg_backend_pid();
mvcc.mjsDescargar mvcc.mjs
// Las versiones de una fila en PostgreSQL, vistas desde dos sesiones.
//
//   node mvcc.mjs versions   xmin, xmax y ctid del envío 501 antes, durante y
//                            después de un UPDATE de A; un SELECT ... FOR
//                            UPDATE que cambia xmax sin crear versión; el
//                            snapshot de B mientras A está abierta
//   node mvcc.mjs vacuum     tuplas muertas y VACUUM, sin y con una
//                            transacción REPEATABLE READ abierta
//
// Los dos dejan los datos como estaban (el precio vuelve a su valor), pero
// las filas quedan en otras posiciones (otro ctid).
import { openSession, closeAll } from './sessions.mjs';

const VERSION = 'SELECT ctid, xmin, xmax, price FROM shipment WHERE shipment_id = 501';
const UPDATE = 'UPDATE shipment SET price = price + 1 WHERE shipment_id = 501';

export async function runVersions({ print = () => {} } = {}) {
  let a, b;
  const out = {};
  try {
    a = await openSession('A');
    b = await openSession('B');
    const row = async (s, label) => {
      const r = (await s.query(VERSION)).rows[0];
      print(`${s.name}: ${VERSION}`);
      print(`   ctid=${r.ctid} xmin=${r.xmin} xmax=${r.xmax} price=${r.price}${label ? '   ' + label : ''}`);
      return r;
    };
    const run = async (s, sql) => {
      print(`${s.name}: ${sql}`);
      return s.query(sql);
    };

    out.before = await row(a);
    await run(a, 'BEGIN');
    await run(a, UPDATE);
    out.xidA = (await a.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x;
    print(`   (xid de A: ${out.xidA})`);
    out.aSees = await row(a, '-- A ve su versión nueva');
    out.bSees = await row(b, '-- B ve la versión vieja');
    out.snapshot = (await b.query('SELECT pg_current_snapshot()::text AS s')).rows[0].s;
    print(`B: SELECT pg_current_snapshot();\n   ${out.snapshot}`);
    await run(a, 'COMMIT');
    out.bAfter = await row(b, '-- ahora B ve la nueva');

    // Un lock de fila sin versión nueva.
    await run(a, 'BEGIN');
    await run(a, 'SELECT price FROM shipment WHERE shipment_id = 501 FOR UPDATE');
    out.xidLock = (await a.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x;
    print(`   (xid de A: ${out.xidLock})`);
    out.locked = await row(b, '-- mismo ctid, xmax es A');
    await run(a, 'ROLLBACK');
    out.afterLock = await row(b, '-- xmax sigue ahí, pero A abortó');

    // Dejar el precio como estaba.
    await a.query('UPDATE shipment SET price = price - 1 WHERE shipment_id = 501');
    return out;
  } finally {
    await closeAll(a, b);
  }
}

// La línea de VACUUM (VERBOSE) que cuenta las tuplas de la tabla. Llega como
// un aviso (notice), no como el resultado de una consulta.
async function vacuum(client) {
  const notices = [];
  const listen = (n) => notices.push(n.message);
  client.on('notice', listen);
  await client.query('VACUUM (VERBOSE) shipment');
  client.off('notice', listen);
  const report = notices.find((m) => m.startsWith('finished vacuuming') && m.includes('.public.shipment"'));
  const lines = report.split('\n');
  const line = lines.find((l) => l.startsWith('tuples:'));
  const cutoff = lines.find((l) => l.startsWith('removable cutoff:'));
  const [removed, remain, dead] = line
    .match(/tuples: (\d+) removed, (\d+) remain, (\d+) are dead/)
    .slice(1)
    .map(Number);
  return { line, cutoff, removed, remain, dead };
}

const TOUCH = 'UPDATE shipment SET price = price WHERE shipment_id BETWEEN 1 AND 100';

export async function runVacuum({ print = () => {} } = {}) {
  let a, b;
  const out = {};
  try {
    a = await openSession('A');
    b = await openSession('B');
    const run = async (s, sql) => {
      print(`${s.name}: ${sql}`);
      return s.query(sql);
    };
    const vac = async (label) => {
      print('A: VACUUM (VERBOSE) shipment');
      const v = await vacuum(a);
      print(`   ${v.line}\n   ${v.cutoff}`);
      out[label] = v;
    };

    await vac('clean'); // punto de partida: sin tuplas muertas
    const r = await run(a, TOUCH);
    out.touched = r.rowCount;
    print(`   UPDATE ${r.rowCount}`);
    await vac('first');

    await run(b, 'BEGIN ISOLATION LEVEL REPEATABLE READ');
    await run(b, 'SELECT count(*) FROM shipment');
    await run(a, TOUCH);
    await vac('blocked');
    await run(b, 'COMMIT');
    await vac('after');
    return out;
  } finally {
    await closeAll(a, b);
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const print = (line) => console.log(line);
  if (process.argv[2] === 'vacuum') await runVacuum({ print });
  else await runVersions({ print });
}
sessions.mjsDescargar sessions.mjs
// Herramientas para los experimentos con varias sesiones: abrir clientes con
// nombre, lanzar una sentencia que se va a quedar esperando un lock, esperar
// (sin sleep) hasta que de verdad esté esperando, y mirar pg_locks y
// pg_stat_activity solo de nuestras sesiones (el servidor es compartido).
import pg from 'pg';
import { execFileSync } from 'node:child_process';

// Abre un cliente y le pone un nombre visible en pg_stat_activity.
// statement_timeout es una red de seguridad: si algo sale mal, nada se queda
// esperando para siempre.
export async function openSession(name, { deadlockTimeout = '1s' } = {}) {
  const client = new pg.Client({ application_name: name });
  await client.connect();
  await client.query("SELECT set_config('statement_timeout', '20s', false)");
  await client.query("SELECT set_config('deadlock_timeout', $1, false)", [deadlockTimeout]);
  const { rows } = await client.query('SELECT pg_backend_pid() AS pid');
  client.name = name;
  client.pid = rows[0].pid;
  return client;
}

// Lanza una sentencia sin esperarla. Devuelve una promesa que nunca se
// rechaza: se resuelve con { rows, rowCount } o con { error }.
export function launch(client, sql, params = []) {
  return client.query(sql, params).then(
    (res) => ({ rows: res.rows, rowCount: res.rowCount }),
    (error) => ({ error }),
  );
}

// Espera hasta que la sesión con ese pid esté bloqueada esperando un lock.
export async function waitUntilBlocked(observer, pid, timeoutMs = 10000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const { rows } = await observer.query(
      `SELECT wait_event_type, cardinality(pg_blocking_pids(pid)) AS n
       FROM pg_stat_activity WHERE pid = $1`,
      [pid],
    );
    if (rows[0]?.wait_event_type === 'Lock' && rows[0].n > 0) return;
    await new Promise((r) => setTimeout(r, 20));
  }
  throw new Error(`la sesión ${pid} no llegó a esperar un lock`);
}

// Los locks de las sesiones dadas, sin los de las tablas del sistema y sin
// el virtualxid de cada transacción (siempre está y no aporta aquí).
export const LOCKS_SQL = `
SELECT a.application_name AS session, l.locktype,
       coalesce(l.relation::regclass::text, '') AS relation,
       coalesce(l.page::text || ',' || l.tuple::text, '') AS tuple,
       coalesce(l.transactionid::text, '') AS xid,
       l.mode, l.granted
FROM pg_locks l
  JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.pid = ANY ($1::int[])
  AND l.locktype <> 'virtualxid'
  AND (l.relation IS NULL OR l.relation >= 16384)
ORDER BY a.application_name, l.granted DESC, l.locktype, relation, l.mode`;

export async function lockRows(observer, sessions) {
  const { rows } = await observer.query(LOCKS_SQL, [sessions.map((s) => s.pid)]);
  return rows;
}

// Quién espera a quién, según pg_blocking_pids.
export const BLOCKING_SQL = `
SELECT application_name AS session, pid, pg_blocking_pids(pid) AS blocked_by,
       wait_event_type, state
FROM pg_stat_activity
WHERE pid = ANY ($1::int[])
ORDER BY application_name`;

export async function blockingRows(observer, sessions) {
  const { rows } = await observer.query(BLOCKING_SQL, [sessions.map((s) => s.pid)]);
  return rows;
}

// Muestra una consulta con el formato de psql, corriendo el psql de verdad.
// Los pids se pasan como variable de psql (:'pids'), no pegados al texto.
export function psqlShow(sql, sessions) {
  const pids = `{${sessions.map((s) => s.pid).join(',')}}`;
  const text = sql.replace('$1::int[]', ":'pids'::int[]");
  return execFileSync('psql', ['-X', '-q', '-v', `pids=${pids}`], {
    input: text + ';\n',
    encoding: 'utf8',
  });
}

// Cierra todas las sesiones, aunque alguna tenga una transacción abierta.
export async function closeAll(...clients) {
  for (const c of clients) {
    if (!c) continue;
    try {
      await c.query('ROLLBACK');
    } catch {
      // la conexión ya estaba rota o no había transacción
    }
    await c.end();
  }
}
skew.mjsDescargar skew.mjs
// Write skew con la capacidad de un vehículo, y dos maneras de evitarlo.
//
// Preparación (setup): el viaje 12 usa el vehículo 11. Le dejamos una
// capacidad de 60 kg y tomamos dos envíos sin viaje: el 55 (48,20 kg) y el
// 444 (43,50 kg). Cada uno cabe solo; los dos juntos no.
//
// La empleada A quiere asignar el envío 55 y el empleado B, el 444. Cada uno
// revisa la carga del viaje y, si su envío cabe, lo asigna. El schedule es:
//
//   A: BEGIN, (lock del viaje), carga    B: BEGIN, (lock del viaje), carga
//   A: UPDATE, COMMIT                    B: UPDATE, COMMIT
//
//   node skew.mjs rr          REPEATABLE READ: las dos hacen commit (write skew)
//   node skew.mjs serializable SERIALIZABLE: una recibe 40001
//   node skew.mjs rc-lock     READ COMMITTED + SELECT ... FOR UPDATE del viaje
//   node skew.mjs rr-lock     REPEATABLE READ + SELECT ... FOR UPDATE del viaje
//
// Al final se devuelve todo a su estado original.
import { openSession, launch, waitUntilBlocked, psqlShow, closeAll } from './sessions.mjs';

export const TRIP = 12;
export const VEHICLE = 11;
export const CAPACITY = 60;
export const PICKS = { A: { shipment: 55, weight: 48.2 }, B: { shipment: 444, weight: 43.5 } };

export const LOAD_SQL = `
SELECT v.capacity_kg, coalesce(sum(s.weight_kg), 0) AS load_kg
FROM trip t
  JOIN vehicle v ON v.vehicle_id = t.vehicle_id
  LEFT JOIN shipment s ON s.trip_id = t.trip_id
WHERE t.trip_id = $1
GROUP BY v.capacity_kg`;
// Los locks de predicado de SSI: no bloquean a nadie, solo registran qué leyó
// cada transacción.
export const SIREAD_SQL = `
SELECT a.application_name AS session, l.locktype, l.relation::regclass AS relation,
       l.page, l.tuple, l.mode
FROM pg_locks l
  JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.pid = ANY ($1::int[]) AND l.mode = 'SIReadLock'
ORDER BY a.application_name, relation, l.page, l.tuple`;
const LOCK_TRIP = 'SELECT trip_id FROM trip WHERE trip_id = $1 FOR UPDATE';
const ASSIGN = 'UPDATE shipment SET trip_id = $1 WHERE shipment_id = $2';

const MODES = {
  rr: { level: 'REPEATABLE READ', lockTrip: false },
  serializable: { level: 'SERIALIZABLE', lockTrip: false },
  'rc-lock': { level: 'READ COMMITTED', lockTrip: true },
  'rr-lock': { level: 'REPEATABLE READ', lockTrip: true },
};

async function setup(client) {
  const { rows } = await client.query('SELECT capacity_kg FROM vehicle WHERE vehicle_id = $1', [VEHICLE]);
  await client.query('UPDATE vehicle SET capacity_kg = $1 WHERE vehicle_id = $2', [CAPACITY, VEHICLE]);
  await client.query('UPDATE shipment SET trip_id = NULL WHERE shipment_id = ANY ($1::int[])', [
    [PICKS.A.shipment, PICKS.B.shipment],
  ]);
  return rows[0].capacity_kg;
}

async function restore(client, capacity) {
  await client.query('UPDATE vehicle SET capacity_kg = $1 WHERE vehicle_id = $2', [capacity, VEHICLE]);
  await client.query('UPDATE shipment SET trip_id = NULL WHERE shipment_id = ANY ($1::int[])', [
    [PICKS.A.shipment, PICKS.B.shipment],
  ]);
}

export async function runSkew(mode, { print = () => {} } = {}) {
  const { level, lockTrip } = MODES[mode];
  let a, b, obs, original;
  const out = { mode, level, lockTrip, errors: {}, saw: {}, assigned: {} };
  try {
    a = await openSession('A');
    b = await openSession('B');
    obs = await openSession('obs');
    original = await setup(obs);

    // Una sentencia de una sesión; los errores se guardan, no se lanzan.
    const run = async (s, sql, params = [], show = sql) => {
      if (out.errors[s.name]) return null; // la transacción ya falló
      print(`${s.name}: ${show}`);
      const r = await launch(s, sql, params);
      if (r.error) {
        out.errors[s.name] = { code: r.error.code, message: r.error.message, at: show };
        print(`   ERROR:  ${r.error.message}  (código ${r.error.code})`);
      }
      return r;
    };
    const check = async (s) => {
      const r = await run(s, LOAD_SQL, [TRIP], 'carga del viaje 12');
      if (!r || r.error) return;
      const { capacity_kg, load_kg } = r.rows[0];
      const fits = Number(load_kg) + PICKS[s.name].weight <= capacity_kg;
      out.saw[s.name] = Number(load_kg);
      print(`   capacity_kg=${capacity_kg} load_kg=${load_kg} -> ${PICKS[s.name].weight} kg ${fits ? 'cabe' : 'no cabe'}`);
      return fits;
    };
    const assign = async (s, fits) => {
      if (!fits) return;
      const r = await run(s, ASSIGN, [TRIP, PICKS[s.name].shipment],
        `UPDATE shipment SET trip_id = ${TRIP} WHERE shipment_id = ${PICKS[s.name].shipment}`);
      if (r && !r.error) out.assigned[s.name] = true;
    };
    const finish = async (s) => {
      if (out.errors[s.name]) {
        await s.query('ROLLBACK');
        print(`${s.name}: ROLLBACK`);
        out.assigned[s.name] = false;
        return;
      }
      const r = await run(s, 'COMMIT');
      if (r.error) out.assigned[s.name] = false;
    };

    await run(a, `BEGIN ISOLATION LEVEL ${level}`);
    await run(b, `BEGIN ISOLATION LEVEL ${level}`);
    let fitsA, fitsB;
    if (!lockTrip) {
      fitsA = await check(a);
      fitsB = await check(b);
      await assign(a, fitsA);
      await assign(b, fitsB);
      if (level === 'SERIALIZABLE') {
        out.siread = (await obs.query(SIREAD_SQL, [[a.pid, b.pid]])).rows;
        print('');
        print(psqlShow(SIREAD_SQL, [a, b]).trimEnd());
        print('');
      }
      await finish(a);
      await finish(b);
    } else {
      await run(a, LOCK_TRIP, [TRIP], `SELECT trip_id FROM trip WHERE trip_id = ${TRIP} FOR UPDATE`);
      fitsA = await check(a);
      print(`B: SELECT trip_id FROM trip WHERE trip_id = ${TRIP} FOR UPDATE   -- espera`);
      const lockB = launch(b, LOCK_TRIP, [TRIP]);
      await waitUntilBlocked(obs, b.pid);
      out.bWaited = true;
      await assign(a, fitsA);
      await finish(a);
      const r = await lockB;
      if (r.error) {
        out.errors.B = { code: r.error.code, message: r.error.message, at: 'FOR UPDATE' };
        print(`   ERROR:  ${r.error.message}  (código ${r.error.code})`);
      } else {
        print('   -- B continúa');
      }
      fitsB = await check(b);
      await assign(b, fitsB);
      await finish(b);
    }

    const final = (await obs.query(LOAD_SQL, [TRIP])).rows[0];
    out.finalLoad = Number(final.load_kg);
    out.overloaded = out.finalLoad > CAPACITY;
    print('');
    print(psqlShow(LOAD_SQL.replace('$1', String(TRIP)), []).trimEnd());
    return out;
  } finally {
    if (obs && original !== undefined) await restore(obs, original);
    await closeAll(a, b, obs);
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const mode = process.argv[2] ?? 'rr';
  if (!MODES[mode]) {
    console.error(`uso: node skew.mjs ${Object.keys(MODES).join('|')}`);
    process.exit(1);
  }
  await runSkew(mode, { print: (line) => console.log(line) });
}
waitsfor.mjsDescargar waitsfor.mjs
// Búsqueda de ciclos en un grafo dirigido dado como lista de aristas.
//
// Sirve para los dos grafos del curso que tienen una transacción por nodo:
//   - el grafo de precedencia de la semana 13 (Ti -> Tj: un conflicto de Ti
//     va antes que uno de Tj). Sin ciclo, `order` es un orden serial
//     equivalente.
//   - el waits-for graph de esta semana (Ti -> Tj: Ti espera un lock que
//     tiene Tj). Con ciclo, hay deadlock y `cycle` son sus transacciones.
//
//   findCycle([['T1', 'T2'], ['T2', 'T1']])
//     -> { acyclic: false, cycle: ['T1', 'T2'] }
//   findCycle([['T2', 'T1'], ['T1', 'T3']])
//     -> { acyclic: true, order: ['T2', 'T1', 'T3'] }
//
// Es una búsqueda en profundidad con tres colores: blanco (no visitado), gris
// (en la pila actual) y negro (terminado). Una arista hacia un nodo gris
// cierra un ciclo. Los nodos y vecinos se recorren en orden, para que el
// resultado no dependa del orden de las aristas.

const WHITE = 0;
const GRAY = 1;
const BLACK = 2;

const byName = (a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));

export function findCycle(edges, nodes = []) {
  const next = new Map();
  const add = (n) => {
    if (!next.has(n)) next.set(n, new Set());
  };
  for (const n of nodes) add(n);
  for (const [from, to] of edges) {
    add(from);
    add(to);
    next.get(from).add(to);
  }

  const color = new Map([...next.keys()].map((n) => [n, WHITE]));
  const stack = []; // camino actual de la búsqueda
  const finished = []; // nodos en el orden en que se ponen negros

  function visit(n) {
    color.set(n, GRAY);
    stack.push(n);
    for (const m of [...next.get(n)].sort(byName)) {
      if (color.get(m) === GRAY) return stack.slice(stack.indexOf(m));
      if (color.get(m) === WHITE) {
        const cycle = visit(m);
        if (cycle) return cycle;
      }
    }
    stack.pop();
    color.set(n, BLACK);
    finished.push(n);
    return null;
  }

  for (const n of [...next.keys()].sort(byName)) {
    if (color.get(n) !== WHITE) continue;
    const cycle = visit(n);
    if (cycle) return { acyclic: false, cycle };
  }
  // Un nodo termina después de todos los que alcanza: al revés, es un orden
  // topológico (cada arista va de izquierda a derecha).
  return { acyclic: true, order: finished.reverse() };
}

// Aristas del waits-for graph de PostgreSQL a partir de filas con
// { pid, blocked_by }, como las que da pg_blocking_pids(pid).
export function edgesFromBlocking(rows) {
  return rows.flatMap((r) => r.blocked_by.map((holder) => [r.pid, holder]));
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

deadlock.test.mjsDescargar deadlock.test.mjs
// Pruebas de deadlock.mjs: dos deadlocks reales y quién es la víctima.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runDeadlock } from './deadlock.mjs';

const has = (rows, want) => rows.some((r) => Object.entries(want).every(([k, v]) => r[k] === v));

function checkCycle(out) {
  const { A, B } = out.pids;
  // Cada una espera un ShareLock sobre el xid de la otra.
  assert.ok(has(out.locks, { session: 'A', locktype: 'transactionid', xid: out.xids.B, mode: 'ShareLock', granted: false }));
  assert.ok(has(out.locks, { session: 'B', locktype: 'transactionid', xid: out.xids.A, mode: 'ShareLock', granted: false }));
  // El waits-for graph armado con pg_blocking_pids tiene el ciclo A -> B -> A.
  assert.deepEqual(out.blocking.map((r) => [r.session, r.blocked_by]), [['A', [B]], ['B', [A]]]);
  assert.equal(out.graph.acyclic, false);
  assert.deepEqual([...out.graph.cycle].sort(), [A, B].sort());
  // La víctima recibe 40P01, y el DETAIL nombra los dos procesos.
  assert.equal(out.error.code, '40P01');
  assert.equal(out.error.message, 'deadlock detected');
  assert.match(out.error.detail, new RegExp(`Process ${A} waits for ShareLock on transaction ${out.xids.B}; blocked by process ${B}`));
  assert.match(out.error.detail, new RegExp(`Process ${B} waits for ShareLock on transaction ${out.xids.A}; blocked by process ${A}`));
}

test('orden cruzado: B tiene el deadlock_timeout más corto y es la víctima', async () => {
  const out = await runDeadlock({ kind: 'cross', timeoutA: '6s', timeoutB: '2s' });
  checkCycle(out);
  assert.equal(out.victim, 'B');
  // Mientras solo A espera, A tiene el lock de la tupla que quiere.
  assert.ok(has(out.locksOneWaiting, { session: 'A', locktype: 'tuple', mode: 'ExclusiveLock', granted: true }));
});

test('orden cruzado: con los timeouts al revés, la víctima es A', async () => {
  const out = await runDeadlock({ kind: 'cross', timeoutA: '2s', timeoutB: '6s' });
  checkCycle(out);
  assert.equal(out.victim, 'A');
});

test('upgrade: las dos tienen FOR SHARE sobre 501 y las dos quieren escribirla', async () => {
  const out = await runDeadlock({ kind: 'upgrade', timeoutA: '6s', timeoutB: '2s' });
  checkCycle(out);
  assert.equal(out.victim, 'B');
  // FOR SHARE toma ROW SHARE en la tabla; el UPDATE, además, ROW EXCLUSIVE.
  assert.ok(has(out.locks, { session: 'B', relation: 'shipment', mode: 'RowShareLock', granted: true }));
  assert.ok(has(out.locks, { session: 'A', relation: 'shipment', mode: 'RowExclusiveLock', granted: true }));
});
locks.test.mjsDescargar locks.test.mjs
// Pruebas de locks.mjs: lo que el apunte dice de pg_locks.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runLocks, conflictMatrix, TABLE_MODES } from './locks.mjs';

const has = (rows, want) => rows.some((r) => Object.entries(want).every(([k, v]) => r[k] === v));

test('locks de una lectura, una escritura, una espera y un lock de tabla', async () => {
  const out = await runLocks();

  // Una lectura: ACCESS SHARE en la tabla y su índice, y todavía sin xid.
  assert.ok(has(out.afterRead, { relation: 'shipment', mode: 'AccessShareLock', granted: true }));
  assert.ok(has(out.afterRead, { relation: 'shipment_pkey', mode: 'AccessShareLock' }));
  assert.ok(!has(out.afterRead, { locktype: 'transactionid' }));

  // Una escritura: ROW EXCLUSIVE en la tabla, el xid propio en EXCLUSIVE y
  // ningún lock de fila en pg_locks.
  assert.ok(has(out.afterWrite, { relation: 'shipment', mode: 'RowExclusiveLock', granted: true }));
  assert.ok(has(out.afterWrite, { locktype: 'transactionid', xid: out.xidA, mode: 'ExclusiveLock' }));
  assert.ok(!has(out.afterWrite, { locktype: 'tuple' }));

  // Una espera: B pide ShareLock sobre el xid de A y no se lo dan; tiene el
  // lock de la tupla que quiere (su lugar en la fila de espera).
  const bRows = out.waiting.filter((r) => r.session === 'B');
  assert.ok(has(bRows, { locktype: 'transactionid', xid: out.xidA, mode: 'ShareLock', granted: false }));
  assert.ok(has(bRows, { locktype: 'tuple', relation: 'shipment', mode: 'ExclusiveLock', granted: true }));
  const blockedB = out.blocking.find((r) => r.session === 'B');
  const pidA = out.blocking.find((r) => r.session === 'A').pid;
  assert.deepEqual(blockedB.blocked_by, [pidA]);
  assert.equal(blockedB.wait_event_type, 'Lock');
  assert.equal(out.bAfter.rowCount, 1);

  // Granularidad: SHARE en la tabla espera a ROW EXCLUSIVE.
  assert.ok(has(out.tableWait, { session: 'B', relation: 'shipment', mode: 'ShareLock', granted: false }));
  assert.ok(has(out.tableHeld, { session: 'B', relation: 'shipment', mode: 'ShareLock', granted: true }));
});

test('la matriz de conflictos medida es la de la documentación de PostgreSQL', async () => {
  const m = await conflictMatrix();
  // Para cada modo, con qué modos entra en conflicto (la tabla "Conflicting Lock Modes" de Explicit Locking).
  const conflicts = {
    'ACCESS SHARE': ['ACCESS EXCLUSIVE'],
    'ROW SHARE': ['EXCLUSIVE', 'ACCESS EXCLUSIVE'],
    'ROW EXCLUSIVE': ['SHARE', 'SHARE ROW EXCLUSIVE', 'EXCLUSIVE', 'ACCESS EXCLUSIVE'],
    'SHARE UPDATE EXCLUSIVE': ['SHARE UPDATE EXCLUSIVE', 'SHARE', 'SHARE ROW EXCLUSIVE', 'EXCLUSIVE', 'ACCESS EXCLUSIVE'],
    SHARE: ['ROW EXCLUSIVE', 'SHARE UPDATE EXCLUSIVE', 'SHARE ROW EXCLUSIVE', 'EXCLUSIVE', 'ACCESS EXCLUSIVE'],
    'SHARE ROW EXCLUSIVE': TABLE_MODES.slice(2),
    EXCLUSIVE: TABLE_MODES.slice(1),
    'ACCESS EXCLUSIVE': TABLE_MODES,
  };
  for (const held of TABLE_MODES) {
    for (const asked of TABLE_MODES) {
      assert.equal(m[held][asked], !conflicts[held].includes(asked), `${held} / ${asked}`);
      assert.equal(m[held][asked], m[asked][held], 'la matriz es simétrica');
    }
  }
});
mvcc.test.mjsDescargar mvcc.test.mjs
// Pruebas de mvcc.mjs: versiones de una fila, snapshots y VACUUM.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runVersions, runVacuum } from './mvcc.mjs';

// pg_current_snapshot() se escribe xmin:xmax:xip1,xip2,...
function visibleIn(snapshot, xid) {
  const [xmin, xmax, list] = snapshot.split(':');
  const x = BigInt(xid);
  if (x < BigInt(xmin)) return true; // terminó antes del snapshot (si hizo commit)
  if (x >= BigInt(xmax)) return false; // empezó después o seguía en curso
  return !list.split(',').filter(Boolean).map(BigInt).includes(x);
}

test('un UPDATE escribe una versión nueva; cada sesión ve la suya', async () => {
  const out = await runVersions();
  // A ve la versión que creó: otro ctid, xmin = A, sin xmax.
  assert.notEqual(out.aSees.ctid, out.before.ctid);
  assert.equal(out.aSees.xmin, out.xidA);
  assert.equal(out.aSees.xmax, '0');
  assert.equal(Number(out.aSees.price), Number(out.before.price) + 1);
  // B ve la versión vieja, ya marcada con xmax = A.
  assert.equal(out.bSees.ctid, out.before.ctid);
  assert.equal(out.bSees.xmax, out.xidA);
  assert.equal(out.bSees.price, out.before.price);
  // En el snapshot de B, A no es visible.
  assert.equal(visibleIn(out.snapshot, out.xidA), false);
  // Después del commit, B ve la versión nueva.
  assert.equal(out.bAfter.ctid, out.aSees.ctid);
  // FOR UPDATE escribe xmax en la misma versión; el rollback no lo borra.
  assert.equal(out.locked.ctid, out.bAfter.ctid);
  assert.equal(out.locked.xmax, out.xidLock);
  assert.equal(out.locked.xmin, out.xidA);
  assert.equal(out.afterLock.xmax, out.xidLock);
});

test('VACUUM no puede quitar lo que un snapshot abierto todavía puede ver', async () => {
  const out = await runVacuum();
  assert.equal(out.touched, 100);
  assert.equal(out.clean.dead, 0);
  assert.deepEqual([out.first.removed, out.first.dead], [100, 0]);
  assert.deepEqual([out.blocked.removed, out.blocked.dead], [0, 100]);
  assert.deepEqual([out.after.removed, out.after.dead], [100, 0]);
});
skew.test.mjsDescargar skew.test.mjs
// Pruebas de skew.mjs: write skew y sus arreglos.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { runSkew, LOAD_SQL, TRIP, VEHICLE, CAPACITY, PICKS } from './skew.mjs';

let client;
before(async () => {
  client = new pg.Client();
  await client.connect();
});
after(() => client.end());

test('los dos envíos no tienen viaje y cada uno cabe solo en 60 kg', async () => {
  const { rows } = await client.query(
    'SELECT shipment_id, trip_id, weight_kg FROM shipment WHERE shipment_id = ANY ($1::int[]) ORDER BY shipment_id',
    [[PICKS.A.shipment, PICKS.B.shipment]],
  );
  assert.deepEqual(rows.map((r) => [r.shipment_id, r.trip_id, Number(r.weight_kg)]), [
    [PICKS.A.shipment, null, PICKS.A.weight], [PICKS.B.shipment, null, PICKS.B.weight],
  ]);
  assert.ok(PICKS.A.weight <= CAPACITY && PICKS.B.weight <= CAPACITY);
  assert.ok(PICKS.A.weight + PICKS.B.weight > CAPACITY);
  const load = (await client.query(LOAD_SQL, [TRIP])).rows[0];
  assert.equal(Number(load.load_kg), 0);
});

test('REPEATABLE READ: las dos hacen commit y el viaje queda con 91,70 kg', async () => {
  const out = await runSkew('rr');
  assert.deepEqual(out.errors, {});
  assert.deepEqual(out.saw, { A: 0, B: 0 });
  assert.deepEqual(out.assigned, { A: true, B: true });
  assert.equal(out.finalLoad, 91.7);
  assert.equal(out.overloaded, true);
});

test('SERIALIZABLE: una de las dos recibe 40001 y la carga queda bien', async () => {
  const out = await runSkew('serializable');
  const errors = Object.values(out.errors);
  assert.equal(errors.length, 1);
  assert.equal(errors[0].code, '40001');
  assert.equal(errors[0].message, 'could not serialize access due to read/write dependencies among transactions');
  assert.equal(out.overloaded, false);
  // Las dos tienen SIReadLock sobre lo que leyeron, entre ellas la fila del viaje.
  for (const s of ['A', 'B']) {
    assert.ok(out.siread.some((r) => r.session === s && r.relation === 'trip' && r.locktype === 'tuple'));
    assert.ok(out.siread.some((r) => r.session === s && r.relation === 'shipment' && r.locktype === 'relation'));
  }
});

test('READ COMMITTED + FOR UPDATE del viaje: B espera, ve 48,20 kg y no asigna', async () => {
  const out = await runSkew('rc-lock');
  assert.equal(out.bWaited, true);
  assert.deepEqual(out.errors, {});
  assert.deepEqual(out.saw, { A: 0, B: 48.2 });
  assert.equal(out.assigned.B, undefined);
  assert.equal(out.finalLoad, 48.2);
});

test('REPEATABLE READ + FOR UPDATE del viaje: B espera, ve 0 kg y asigna', async () => {
  const out = await runSkew('rr-lock');
  assert.equal(out.bWaited, true);
  assert.deepEqual(out.errors, {});
  assert.deepEqual(out.saw, { A: 0, B: 0 });
  assert.equal(out.overloaded, true);
});

test('al final todo vuelve a su estado original', async () => {
  const { rows } = await client.query('SELECT capacity_kg FROM vehicle WHERE vehicle_id = $1', [VEHICLE]);
  assert.equal(rows[0].capacity_kg, 11300);
  const load = (await client.query(LOAD_SQL, [TRIP])).rows[0];
  assert.equal(Number(load.load_kg), 0);
});
waitsfor.test.mjsDescargar waitsfor.test.mjs
// Pruebas de findCycle con los grafos del apunte: grafos de precedencia de la
// semana 13 y waits-for graphs de esta semana.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { findCycle, edgesFromBlocking } from './waitsfor.mjs';

test('precedencia de la semana 13: sin ciclo, el orden serial es T2, T1, T3', () => {
  // S = r1(A) r2(B) w1(A) r3(A) w2(B) r3(B) w3(A) r1(B)
  const edges = [['T1', 'T3'], ['T2', 'T3'], ['T2', 'T1']];
  assert.deepEqual(findCycle(edges), { acyclic: true, order: ['T2', 'T1', 'T3'] });
});

test('lost update: T1 -> T2 y T2 -> T1 forman un ciclo', () => {
  assert.deepEqual(findCycle([['T1', 'T2'], ['T2', 'T1']]), { acyclic: false, cycle: ['T1', 'T2'] });
});

test('waits-for con un ciclo de tres y una transacción que solo espera', () => {
  // T4 espera a T1, pero no es parte del ciclo T1 -> T2 -> T3 -> T1.
  const edges = [['T4', 'T1'], ['T1', 'T2'], ['T2', 'T3'], ['T3', 'T1']];
  assert.deepEqual(findCycle(edges), { acyclic: false, cycle: ['T1', 'T2', 'T3'] });
});

test('una cadena de esperas no es un deadlock', () => {
  const r = findCycle([['T3', 'T2'], ['T2', 'T1']]);
  assert.equal(r.acyclic, true);
  assert.deepEqual(r.order, ['T3', 'T2', 'T1']);
});

test('el resultado no depende del orden de las aristas', () => {
  const edges = [['T1', 'T2'], ['T2', 'T3'], ['T3', 'T1'], ['T4', 'T1']];
  const reversed = [...edges].reverse();
  assert.deepEqual(findCycle(reversed), findCycle(edges));
});

test('nodos sueltos aparecen en el orden; los números se ordenan como números', () => {
  assert.deepEqual(findCycle([[10, 9]], [100]), { acyclic: true, order: [100, 10, 9] });
});

test('una arista de un nodo a sí mismo es un ciclo', () => {
  assert.deepEqual(findCycle([['T1', 'T1']]), { acyclic: false, cycle: ['T1'] });
});

test('edgesFromBlocking: una arista por cada pid de pg_blocking_pids', () => {
  const rows = [
    { pid: 101, blocked_by: [102] },
    { pid: 102, blocked_by: [101] },
    { pid: 103, blocked_by: [101, 102] },
    { pid: 104, blocked_by: [] },
  ];
  assert.deepEqual(edgesFromBlocking(rows), [[101, 102], [102, 101], [103, 101], [103, 102]]);
  assert.deepEqual(findCycle(edgesFromBlocking(rows)), { acyclic: false, cycle: [101, 102] });
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 14: locks, deadlocks y MVCC en PostgreSQL, con dos sesiones a la vez.
# Se usa dentro del contenedor del curso.
#
#   make          crea la base de datos chasqui_w14 con los datos de ejemplo
#   make test     la vuelve a crear y corre las pruebas (*.test.mjs)
#   make clean    borra chasqui_w14
#
# Los experimentos se corren uno por uno con, por ejemplo,
#   PGDATABASE=chasqui_w14 node deadlock.mjs
# (locks.mjs, deadlock.mjs [upgrade], mvcc.mjs versions|vacuum,
#  skew.mjs rr|serializable|rc-lock|rr-lock). locks.sql tiene las consultas
# para mirar pg_locks desde una tercera terminal de psql.

DB = chasqui_w14

.PHONY: all test clean

all:
	chasqui-load $(DB)

# Las pruebas abren varias sesiones sobre las mismas filas: se corren una
# después de otra.
test: all
	PGDATABASE=$(DB) node --test --test-concurrency=1 --test-reporter=spec

clean:
	dropdb --if-exists $(DB)