16
Transacciones y distribuidos

Bases de datos distribuidas, NoSQL y cierre

Transacciones y sistemas distribuidos

Semana 16 — Bases de datos distribuidas, NoSQL y cierre

Objetivos

  • Comparar las arquitecturas de servidores de bases de datos (shared memory, shared disk, shared nothing) y distinguir un sistema paralelo de uno distribuido.
  • Particionar una tabla por rango y por hash, reconocer el skew y elegir entre replicación síncrona y asíncrona.
  • Explicar two-phase commit (2PC), por qué puede bloquearse y cómo ayuda el consensus; distinguir la “C” de CAP de la “C” de ACID.
  • ✂️ Describir key-value stores, document stores y MapReduce, y decidir cuándo convienen frente a una base relacional.
  • Repasar el curso completo y presentar P5.

Sesión A — Bases de datos distribuidas: particionamiento, replicación y 2PC

Lecturas:

  • Silberschatz, secs. 20.1–20.5 (Overview, Centralized Database Systems, Server System Architectures, Parallel Systems, Distributed Systems), como panorama.
  • Silberschatz, secs. 21.2–21.4 (Data Partitioning, Dealing with Skew in Partitioning, Replication).
  • Silberschatz, secs. 23.1–23.2 (Distributed Transactions, Commit Protocols) y, en breve, sec. 23.8 (Consensus in Distributed Systems).
  • CS186 (UC Berkeley), Distributed Transactions: locking distribuido, 2PC y su recuperación, presumed abort.

Lecturas complementarias:

Temas:

  • Intuición: Chasqui crece. Tiene agencias en Lima, Arequipa, Cusco e Iquitos, millones de eventos de seguimiento y empleados que no pueden quedarse sin sistema si se cae un servidor. Distribuir sirve para tres cosas distintas: más capacidad (los datos o la carga no caben en una máquina), disponibilidad (otra máquina sigue si una cae) y latencia (datos cerca de quien los usa).
  • Arquitecturas (cap. 20): centralizada y cliente-servidor; sistemas paralelos con memoria compartida (shared memory), disco compartido (shared disk) o sin nada compartido (shared nothing, la que escala más); sistemas distribuidos, con sitios separados geográficamente y una red más lenta y menos confiable.
  • Particionamiento (21.2): round-robin (reparte parejo, pero toda consulta va a todas las particiones), hash sobre una clave (bueno para buscar por igualdad), por rango (bueno para rangos, como los envíos de un mes). Particionar shipment y shipment_event por la misma clave (shipment_id) permite hacer el join en cada partición sin mover datos. En PostgreSQL, el particionamiento declarativo (PARTITION BY RANGE (created_at)) divide una tabla dentro de un mismo servidor; repartir las particiones en varios servidores es sharding.
  • Skew (21.3): si se particiona por región, la partición de Lima tiene la mayor parte de los envíos (data skew); si se particiona por mes, todas las inserciones caen en la partición del mes actual (execution skew). Remedios: rangos elegidos con histogramas, muchas particiones pequeñas (virtual nodes) repartidas entre pocos servidores, consistent hashing.
  • Replicación (21.4): copias de cada partición en varios servidores. Con una sola copia principal (primary-backup), las escrituras van a la principal y las réplicas leen el log. Síncrona: el commit espera a la réplica (no se pierde nada al fallar, pero cada commit es más lento). Asíncrona: la réplica va unos milisegundos atrás y una lectura en ella puede ver datos viejos. PostgreSQL: streaming replication del WAL (semana 15) a un hot standby, asíncrona por defecto y síncrona con synchronous_standby_names.
  • Transacciones distribuidas (23.1): un envío de Cusco a Iquitos con los datos partidos por región. La transacción inserta el envío en el servidor del sur y descuenta capacidad del viaje en el servidor del oriente. Las dos partes hacen commit o ninguna. Cada servidor tiene su propio log y su propio lock manager; falta un protocolo que los ponga de acuerdo.
  • 2PC (23.2). Un coordinador y varios participantes. Fase 1: el coordinador escribe <prepare T> y pregunta; cada participante que puede hacer commit escribe y fuerza <ready T> en su log y responde que sí; desde ese momento ya no puede abortar por su cuenta. Fase 2: si todos dijeron que sí, el coordinador fuerza <commit T> (ese es el instante de la decisión); si alguno dijo que no o no respondió, <abort T>. Envía la decisión, los participantes la anotan y hacen commit o abort, y responden; el coordinador escribe <end T>.
  • Fallos en 2PC: si un participante cae antes de <ready T>, se aborta; si cae después, al volver pregunta al coordinador. Si el coordinador cae cuando los participantes ya están en ready, ellos no pueden decidir solos: quedan bloqueados, con sus locks tomados, hasta que vuelva. Ese es el gran problema de 2PC. Presumed abort: si el coordinador no tiene registro de T, la respuesta es abort, y se ahorran escrituras al log.
  • En PostgreSQL, la fase 1 es PREPARE TRANSACTION 'id' y la fase 2 es COMMIT PREPARED 'id' o ROLLBACK PREPARED 'id'. El coordinador es la aplicación o un gestor externo. Viene desactivado (max_prepared_transactions = 0) porque una transacción preparada y olvidada retiene sus locks indefinidamente; se ven en pg_prepared_xacts.
  • Consensus (23.8), en breve: Paxos y Raft ponen de acuerdo a un grupo de servidores sobre una secuencia de valores (un log replicado) mientras siga activa una mayoría: con 2f + 1 servidores se toleran f caídas. Si el coordinador y cada participante de 2PC son grupos replicados con consensus, la caída de una máquina ya no bloquea la transacción. Es lo que hacen sistemas como Spanner y CockroachDB.
  • CAP: si la red se parte en dos, un sistema replicado tiene que elegir entre responder en los dos lados (disponibilidad) o mantener una sola versión de los datos (consistencia, en el sentido de linearizable). Sin partición de red no hay que elegir, pero sí hay que pagar latencia por la consistencia. Jepsen ordena los modelos: linearizable habla de un objeto y del tiempo real; serializable habla de transacciones y no del tiempo real; strict serializable es las dos cosas.
  • Error común: confundir 2PL con 2PC. 2PL (semana 14) decide en qué orden se ven las transacciones dentro de un servidor; 2PC decide si una transacción repartida en varios servidores hace commit en todos o en ninguno. Un sistema distribuido suele usar los dos a la vez.
  • Error común: creer que la “C” de CAP es la “C” de ACID. La de ACID es respetar las restricciones de la base; la de CAP es que todas las réplicas respondan como si hubiera una sola copia.
  • Error común: tomar una réplica como backup. Un DELETE equivocado se replica en milisegundos; para eso están los backups y el point-in-time recovery de la semana 15.

Sesión B — ✂️ NoSQL y MapReduce · Cierre y repaso

Lecturas:

  • ✂️ Silberschatz, secs. 10.2–10.3 (Big Data Storage Systems, The MapReduce Paradigm).
  • ✂️ CS186 (UC Berkeley), NoSQL (particionamiento, replicación, CAP, key-value stores, document stores y JSON frente al modelo relacional) y MapReduce and Spark.
  • Para el repaso: los resúmenes de los capítulos de las partes 3 y 4 en Silberschatz (secs. 12.7, 13.8, 14.11, 15.9, 16.7, 17.11, 18.11 y 19.11, todas Summary).

Lecturas complementarias (✂️ NoSQL y MapReduce):

Temas:

  • ✂️ Por qué aparecieron los sistemas NoSQL: aplicaciones web con millones de usuarios, cargas simples (leer y escribir por clave) y la necesidad de escalar agregando máquinas baratas. Se renunció a joins, a transacciones entre muchas filas y, a veces, a la consistencia fuerte.
  • ✂️ Key-value stores: get(clave), put(clave, valor), delete(clave). Particionados por hash o rango de la clave y replicados. En los diseños tipo Dynamo, cada clave tiene N réplicas; una escritura espera W confirmaciones y una lectura consulta R réplicas. Si R + W > N, toda lectura encuentra al menos una réplica con la última escritura. Ejemplo: el estado de un envío por su código de seguimiento, consultado millones de veces al día.
  • ✂️ Document stores: cada valor es un documento JSON con estructura anidada. El envío 501 con su lista de eventos dentro, en lugar de una tabla shipment_event aparte: se lee entero en un solo acceso, pero un dato repetido en muchos documentos (el nombre de una agencia) hay que actualizarlo en todos, la misma anomalía que la normalización evitaba (semana 7). Wide-column stores (Bigtable, Cassandra), en una línea: filas con columnas agrupadas en familias, ordenadas por clave.
  • ✂️ MapReduce: el programador escribe dos funciones. map(clave, valor) emite pares intermedios; el sistema los agrupa por clave (shuffle) y llama a reduce(clave, lista de valores). Ejemplo: contar entregas por ciudad de destino a partir de archivos con los eventos de seguimiento; map emite (ciudad, 1) por cada evento de entrega y reduce suma. Es un GROUP BY con count(*). Un combiner suma localmente antes del shuffle. Tolerancia a fallos: si un worker cae, se vuelve a ejecutar su tarea, porque los datos intermedios quedan en disco.
  • ✂️ Más allá de MapReduce (10.4, en una línea): Spark mantiene los datos intermedios en memoria y ofrece operadores relacionales (select, join, group by) sobre DataFrames. La historia termina donde empezó el curso: álgebra relacional y un optimizador.
  • Error común: decir que NoSQL “no tiene esquema” o “no tiene transacciones”. El esquema existe igual, pero en el código que lee los datos (schema-on-read); muchos sistemas garantizan atomicidad por documento o por clave, y varios ya ofrecen transacciones entre varios documentos.
  • Cierre: el recorrido completo con Chasqui. Parte 1: relaciones, claves y SQL. Parte 2: E-R y formas normales. Parte 3: páginas, buffer pool, B+ trees, joins y optimizador, que quipu implementó en P3 y P4. Parte 4: transacciones, locks y MVCC, WAL y ARIES, que quipu implementó en P5, y lo que cambia al distribuir.
  • Lo que no vimos y viene después: column stores y analítica (Silberschatz, cap. 11), streaming, bases de datos de grafos, seguridad y control de acceso, y la administración de PostgreSQL en producción.
  • Repaso para el examen final: todo el curso, con énfasis en las partes 3 y 4. Tipos de ejercicio: insertar en un B+ tree, estimar el costo de un join, leer un plan con EXPLAIN ANALYZE, decidir si un schedule es serializable, aplicar 2PL y detectar un deadlock, recuperar un log con ARIES.

Si el examen final se toma en esta sesión (ver la nota del calendario en el SYLLABUS), el cierre se da en versión corta durante el laboratorio y los bloques ✂️ pasan a lectura autónoma.

Laboratorio — Presentación de proyectos

  1. Cada equipo presenta P5 en 10 minutos, con una demostración por línea de comandos de cuatro escenarios: (a) dos transacciones que comparten un lock S y una tercera que pide X y queda en la cola de espera; (b) un lock upgrade de S a X, con y sin otra transacción que también tenga S; (c) un deadlock que el waits-for graph detecta, la víctima que se elige y por qué; (d) una caída simulada con una transacción confirmada y otra en curso, y la recuperación con redo y undo que deja solo la confirmada.
  2. Correr las pruebas de P3, P4 y P5 en el contenedor (node --test) delante de todos. Las pruebas de las entregas anteriores también deben pasar.
  3. Preguntas del docente y de otro equipo sobre el código: dónde se cumple la regla WAL (qué se escribe en disco antes de qué), qué pasa si el sistema cae durante la recuperación, dónde se liberan los locks y por qué ahí, cómo agregarían locks de intención sobre las páginas.
  4. Comparar con PostgreSQL: reproducir en dos sesiones de psql chasqui el escenario (c) y contrastar el mensaje 40P01 con lo que hace quipu.
  5. Revisión cruzada breve: cada equipo lee las pruebas del lock manager de otro y anota un caso que falta probar y una decisión de diseño bien resuelta.

Entregas

  • P5 entrega.

Preguntas de repaso

  1. Chasqui reparte shipment en cuatro servidores. Comparen particionar por hash de shipment_id, por rango de created_at y por región de la agencia de origen: ¿qué consultas atiende un solo servidor en cada caso y dónde aparece el skew?
  2. En 2PC, el coordinador recibe ready de todos los participantes y cae antes de escribir su decisión en el log. ¿En qué estado quedan los participantes? ¿Por qué no pueden abortar por su cuenta? ¿Qué pasa con los locks que tienen?
  3. Expliquen la diferencia entre 2PL y 2PC: qué problema resuelve cada uno, en qué semana del curso lo vimos y por qué un sistema distribuido puede usar los dos en la misma transacción.
  4. Un key-value store tiene N = 3 réplicas por clave. Con W = 2 y R = 1, ¿una lectura siempre ve la última escritura? ¿Y con W = 2 y R = 2? ¿Qué se gana y qué se pierde al subir W?
  5. ✂️ Escriban map y reduce para calcular el peso total enviado por ruta (origen y destino) a partir de archivos con los envíos de Chasqui, y la consulta SQL equivalente. ¿Qué hace el shuffle y a qué operación del álgebra relacional corresponde?

Código de la semana

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

Ejemplos (9)Pruebas (4)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

documents.sqlDescargar documents.sql
-- Semana 16: el envío 501 como un documento JSON, al estilo de un document
-- store, armado desde las tablas de Chasqui con jsonb (semana 8):
--   psql -d chasqui_w16 -f documents.sql
-- Solo lee.

-- El documento: el envío con su lista de eventos adentro, y el nombre de la
-- agencia copiado en cada evento. \a y \t muestran el texto tal cual.
\a
\t
-- name: document_501
SELECT jsonb_pretty(jsonb_build_object(
  'shipment_id', s.shipment_id,
  'status', s.status,
  'weight_kg', s.weight_kg,
  'origin', o.name,
  'destination', d.name,
  'events', (SELECT jsonb_agg(jsonb_build_object(
                      'seq', e.seq, 'kind', e.kind, 'agency', a.name)
                    ORDER BY e.seq)
             FROM shipment_event e JOIN agency a ON a.agency_id = e.agency_id
             WHERE e.shipment_id = s.shipment_id))) AS doc
FROM shipment s
  JOIN agency o ON o.agency_id = s.origin_agency_id
  JOIN agency d ON d.agency_id = s.dest_agency_id
WHERE s.shipment_id = 501;
\a
\t

-- La copia tiene un precio: si la agencia 1 cambia de nombre, ¿cuántos
-- documentos habría que reescribir? Todos los envíos que pasaron por ella.
-- name: docs_to_rewrite
SELECT count(DISTINCT shipment_id) AS documents, count(*) AS copies
FROM shipment_event
WHERE agency_id = 1;
mapreduce.mjsDescargar mapreduce.mjs
// Un MapReduce de juguete, en un solo proceso, para ver sus fases: la entrada
// se parte en splits, cada split pasa por map (y por el combiner, si hay),
// el shuffle agrupa los pares por clave y los reparte entre los reducers por
// hash de la clave, y cada reducer llama a reduce(clave, valores).
//
//   PGDATABASE=chasqui_w16 node mapreduce.mjs
// cuenta las entregas por ciudad de destino a partir de los eventos de
// seguimiento, con y sin combiner, y las compara con un GROUP BY.

// Un hash de texto pequeño y determinista (FNV-1a de 32 bits).
function hash(text) {
  let h = 0x811c9dc5;
  for (const ch of text) {
    h ^= ch.codePointAt(0);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h;
}

// records: arreglo de entradas. map(record, emit) llama a emit(clave, valor)
// las veces que quiera. reduce(clave, valores) devuelve un valor.
// Devuelve { result: Map(clave → valor), stats }.
export function mapReduce(records, map, reduce, { splits = 4, reducers = 3, combiner } = {}) {
  const stats = { splits, reducers, mapped: 0, shuffled: 0, perReducer: Array(reducers).fill(0) };

  // Fase map: cada split produce sus pares (clave, valor).
  const size = Math.ceil(records.length / splits);
  const mapOutputs = [];
  for (let s = 0; s < splits; s++) {
    let pairs = [];
    for (const record of records.slice(s * size, (s + 1) * size)) {
      map(record, (key, value) => pairs.push([key, value]));
    }
    stats.mapped += pairs.length;
    // Combiner: un reduce local, en la máquina del map, antes de mandar nada.
    if (combiner) pairs = [...groupByKey(pairs)].map(([k, vs]) => [k, combiner(k, vs)]);
    mapOutputs.push(pairs);
  }

  // Shuffle: cada par viaja al reducer hash(clave) mod reducers.
  const inboxes = Array.from({ length: reducers }, () => []);
  for (const pairs of mapOutputs) {
    for (const [key, value] of pairs) {
      const r = hash(String(key)) % reducers;
      inboxes[r].push([key, value]);
      stats.shuffled++;
      stats.perReducer[r]++;
    }
  }

  // Fase reduce: cada reducer agrupa lo que recibió y reduce cada clave.
  const result = new Map();
  for (const inbox of inboxes) {
    for (const [key, values] of groupByKey(inbox)) result.set(key, reduce(key, values));
  }
  return { result, stats };
}

function groupByKey(pairs) {
  const groups = new Map();
  for (const [key, value] of pairs) {
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(value);
  }
  return groups;
}

// El ejemplo de clase: map emite (ciudad, 1) por cada evento de entrega;
// reduce suma. El mismo reduce sirve de combiner porque la suma es
// asociativa y conmutativa.
export const deliveriesMap = (event, emit) => {
  if (event.kind === 'delivered') emit(event.city, 1);
};
export const sumReduce = (_key, values) => values.reduce((a, b) => a + b, 0);

// Los "archivos" de eventos: una línea por evento de seguimiento, con la
// ciudad de la agencia donde ocurrió.
export const EVENTS_SQL = `
  SELECT e.shipment_id, e.seq, e.kind, a.city
  FROM shipment_event e JOIN agency a ON a.agency_id = e.agency_id
  ORDER BY e.shipment_id, e.seq`;

// La consulta SQL equivalente.
export const DELIVERIES_SQL = `
  SELECT a.city, count(*)::int AS n
  FROM shipment_event e JOIN agency a ON a.agency_id = e.agency_id
  WHERE e.kind = 'delivered'
  GROUP BY a.city`;

if (import.meta.url === `file://${process.argv[1]}`) {
  const { default: pg } = await import('pg');
  const pool = new pg.Pool();
  try {
    const events = (await pool.query(EVENTS_SQL)).rows;
    console.log(`${events.length} eventos`);
    for (const combiner of [undefined, sumReduce]) {
      const { result, stats } = mapReduce(events, deliveriesMap, sumReduce, { combiner });
      console.log(`\n${combiner ? 'con' : 'sin'} combiner: ${stats.mapped} pares emitidos por map, `
        + `${stats.shuffled} enviados en el shuffle (${stats.perReducer.join(' + ')})`);
      const top = [...result].sort((a, b) => b[1] - a[1]).slice(0, 5);
      console.log(top.map(([city, n]) => `  ${city.padEnd(10)} ${n}`).join('\n'));
    }
    const sql = (await pool.query(`${DELIVERIES_SQL} ORDER BY n DESC LIMIT 5`)).rows;
    console.log('\nGROUP BY en PostgreSQL:');
    console.log(sql.map(({ city, n }) => `  ${city.padEnd(10)} ${n}`).join('\n'));
  } finally {
    await pool.end();
  }
}
partitions.sqlDescargar partitions.sql
-- Semana 16: tres copias particionadas de shipment (y una de shipment_event)
-- dentro de chasqui_w16. `make` corre este archivo; se puede volver a correr:
--   psql -d chasqui_w16 -f partitions.sql
-- Las tablas originales no se tocan.

SET client_min_messages = warning;
DROP TABLE IF EXISTS shipment_by_month, shipment_by_hash, shipment_event_by_hash,
  shipment_by_zone;

-- 1. Por rango: una partición por mes de created_at. La clave primaria de una
-- tabla particionada tiene que incluir la clave de particionamiento.
CREATE TABLE shipment_by_month (
  LIKE shipment INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
  PRIMARY KEY (shipment_id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE shipment_2026_01 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE shipment_2026_02 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE shipment_2026_03 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE shipment_2026_04 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE shipment_2026_05 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE shipment_2026_06 PARTITION OF shipment_by_month
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- Lo que no cae en ningún mes va aquí (sin ella, el INSERT falla).
CREATE TABLE shipment_month_default PARTITION OF shipment_by_month DEFAULT;

INSERT INTO shipment_by_month SELECT * FROM shipment;

-- 2. Por hash de shipment_id, en 4 particiones; shipment_event, con la misma
-- clave y el mismo número de particiones, para hacer el join por partes.
CREATE TABLE shipment_by_hash (
  LIKE shipment INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
  PRIMARY KEY (shipment_id)
) PARTITION BY HASH (shipment_id);

CREATE TABLE shipment_h0 PARTITION OF shipment_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE shipment_h1 PARTITION OF shipment_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE shipment_h2 PARTITION OF shipment_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE shipment_h3 PARTITION OF shipment_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 3);

CREATE TABLE shipment_event_by_hash (
  LIKE shipment_event INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
  PRIMARY KEY (shipment_id, seq)
) PARTITION BY HASH (shipment_id);

CREATE TABLE shipment_event_h0 PARTITION OF shipment_event_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE shipment_event_h1 PARTITION OF shipment_event_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE shipment_event_h2 PARTITION OF shipment_event_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE shipment_event_h3 PARTITION OF shipment_event_by_hash FOR VALUES WITH (MODULUS 4, REMAINDER 3);

INSERT INTO shipment_by_hash SELECT * FROM shipment;
INSERT INTO shipment_event_by_hash SELECT * FROM shipment_event;

-- 3. Por lista de agencias de origen: cuatro zonas, como cuatro servidores.
-- Sin partición DEFAULT: una agencia nueva que no está en ninguna lista falla.
CREATE TABLE shipment_by_zone (
  LIKE shipment INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
  PRIMARY KEY (shipment_id, origin_agency_id)
) PARTITION BY LIST (origin_agency_id);

CREATE TABLE shipment_lima PARTITION OF shipment_by_zone
  FOR VALUES IN (1, 2, 3);
CREATE TABLE shipment_norte PARTITION OF shipment_by_zone
  FOR VALUES IN (7, 8, 9, 10, 11, 12, 22);
CREATE TABLE shipment_sur PARTITION OF shipment_by_zone
  FOR VALUES IN (4, 5, 6, 13, 17, 18, 19, 20, 21, 28);
CREATE TABLE shipment_centro_oriente PARTITION OF shipment_by_zone
  FOR VALUES IN (14, 15, 16, 23, 24, 25, 26, 27, 29);

INSERT INTO shipment_by_zone SELECT * FROM shipment;

ANALYZE shipment_by_month, shipment_by_hash, shipment_event_by_hash, shipment_by_zone;
prepared.sqlDescargar prepared.sql
-- Semana 16: PREPARE TRANSACTION a mano, en la base oriente del cluster
-- desechable (make cluster):
--   psql -h /tmp -p 5416 -U postgres -d oriente -f prepared.sql
-- Una transacción preparada sobrevive al fin de la sesión y a una caída del
-- servidor, y conserva sus locks hasta COMMIT PREPARED o ROLLBACK PREPARED.

BEGIN;
UPDATE trip SET booked_kg = booked_kg + 5 WHERE trip_id = 7001;
PREPARE TRANSACTION 'manual-1@oriente';

-- La sesión ya no tiene una transacción abierta, pero la transacción existe.
-- name: prepared_xacts
SELECT gid, database, owner, transaction AS xid
FROM pg_prepared_xacts;

-- Sus locks siguen ahí, sin un proceso que sea su owner (pid vacío).
-- name: prepared_locks
SELECT locktype, relation::regclass AS relation, transactionid, mode, granted, pid
FROM pg_locks
WHERE pid IS NULL
ORDER BY locktype, relation;

-- Lo que ve cualquier otra transacción (también una nueva de esta sesión):
-- el cambio todavía no está confirmado.
-- name: before_commit
SELECT booked_kg FROM trip WHERE trip_id = 7001;

-- Fase 2. Puede ejecutarla cualquier sesión en la misma base de datos.
COMMIT PREPARED 'manual-1@oriente';

-- name: after_commit
SELECT booked_kg FROM trip WHERE trip_id = 7001;

-- Para dejarlo como estaba:
UPDATE trip SET booked_kg = booked_kg - 5 WHERE trip_id = 7001;
pruning.sqlDescargar pruning.sql
-- Semana 16: particiones, partition pruning y join por particiones, sobre las
-- tablas que crea partitions.sql (make las crea). Solo lee:
--   psql -d chasqui_w16 -f pruning.sql
-- Cada consulta empieza con una marca "-- name:" que usan las pruebas.

-- Cuántas filas quedaron en cada partición. tableoid es la tabla física de
-- cada fila; ::regclass la muestra con su nombre.
-- name: by_month
SELECT tableoid::regclass AS partition, count(*) AS n
FROM shipment_by_month
GROUP BY tableoid
ORDER BY tableoid::regclass::text;

-- name: by_hash
SELECT tableoid::regclass AS partition, count(*) AS n
FROM shipment_by_hash
GROUP BY tableoid
ORDER BY tableoid::regclass::text;

-- name: by_zone
SELECT tableoid::regclass AS partition, count(*) AS n,
  round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct
FROM shipment_by_zone
GROUP BY tableoid
ORDER BY n DESC;

-- Los límites de cada partición, como los guarda el catálogo.
-- name: bounds
SELECT c.relname AS partition, pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_inherits i
  JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'shipment_by_month'::regclass
ORDER BY c.relname;

-- Pruning en la planificación: la condición sobre created_at descarta todas
-- las particiones menos la de marzo.
-- name: prune_march
EXPLAIN (COSTS OFF)
SELECT count(*)
FROM shipment_by_month
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';

-- Sin condición sobre la clave de particionamiento, hay que leer todas.
-- name: no_prune
EXPLAIN (COSTS OFF)
SELECT count(*)
FROM shipment_by_month
WHERE sender_id = 777;

-- Con hash, solo sirve la igualdad: un rango de shipment_id lee las cuatro.
-- name: hash_eq
EXPLAIN (COSTS OFF)
SELECT * FROM shipment_by_hash WHERE shipment_id = 501;

-- name: hash_range
EXPLAIN (COSTS OFF)
SELECT count(*) FROM shipment_by_hash WHERE shipment_id BETWEEN 500 AND 510;

-- Pruning en la ejecución: con un parámetro, el plan genérico no sabe qué
-- mes es; al ejecutarlo, descarta las particiones que sobran.
SET plan_cache_mode = force_generic_plan;
PREPARE since_day(timestamptz) AS
  SELECT count(*) FROM shipment_by_month WHERE created_at >= $1;
-- name: prune_runtime
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
EXECUTE since_day('2026-06-15');
DEALLOCATE since_day;
RESET plan_cache_mode;

-- name: since_june15
SELECT count(*) FROM shipment WHERE created_at >= '2026-06-15';

-- Join por particiones: shipment y shipment_event están particionadas por
-- hash de shipment_id con el mismo módulo. Con enable_partitionwise_join, el
-- join se hace partición contra partición, como lo haría cada servidor con
-- sus propios datos.
SET enable_partitionwise_join = on;
-- name: partitionwise
EXPLAIN (COSTS OFF)
SELECT s.status, count(*)
FROM shipment_by_hash s
  JOIN shipment_event_by_hash e ON e.shipment_id = s.shipment_id
GROUP BY s.status;
RESET enable_partitionwise_join;

-- name: events_per_status
SELECT s.status, count(*)
FROM shipment_by_hash s
  JOIN shipment_event_by_hash e ON e.shipment_id = s.shipment_id
GROUP BY s.status
ORDER BY s.status;

-- Execution skew: todos los envíos nuevos caen en la partición del último
-- mes. Se insertan 50 y se cuentan por partición; ROLLBACK los quita.
BEGIN;
-- name: insert_skew
WITH nuevos AS (
  INSERT INTO shipment_by_month
  SELECT shipment_id + 100000, sender_id, recipient_id, origin_agency_id,
    dest_agency_id, NULL, weight_kg, declared_value, price, 'registered',
    timestamptz '2026-06-30 08:00' + shipment_id * interval '1 minute', NULL
  FROM shipment
  WHERE shipment_id <= 50
  RETURNING tableoid::regclass AS partition
)
SELECT partition, count(*) AS n FROM nuevos GROUP BY partition;
ROLLBACK;

-- Una fila que no cae en ninguna lista: no hay partición DEFAULT en zonas.
-- name: no_partition
INSERT INTO shipment_by_zone
SELECT shipment_id + 100000, sender_id, recipient_id, 30, dest_agency_id,
  NULL, weight_kg, declared_value, price, status, created_at, delivered_at
FROM shipment
WHERE shipment_id = 501;
sqlfile.mjsDescargar sqlfile.mjs
// Lee un archivo .sql con consultas marcadas "-- name: nombre" y devuelve un
// objeto { nombre: texto }. Así las pruebas corren el mismo texto que el
// archivo que se muestra en clase. Cada consulta va desde la marca hasta la
// primera línea que termina en punto y coma; lo que sigue (comentarios,
// BEGIN, SET, VACUUM) no es parte de ella.
import { readFileSync } from 'node:fs';

export function namedQueries(file) {
  const text = readFileSync(new URL(file, import.meta.url), 'utf8');
  const parts = text.split(/^-- name: (\w+)\n/m);
  const queries = {};
  for (let i = 1; i < parts.length; i += 2) {
    const end = parts[i + 1].search(/;\s*$/m);
    queries[parts[i]] = parts[i + 1].slice(0, end + 1).trim();
  }
  return queries;
}
twopc-demo.mjsDescargar twopc-demo.mjs
// Muestra el protocolo 2PC paso a paso en cuatro escenarios, sobre el cluster
// desechable (make cluster):
//   node twopc-demo.mjs
// 1. Todo bien: los dos votan que sí y se hace commit.
// 2. El viaje se llena: oriente vota que no en PREPARE; sur hace rollback.
// 3. El coordinador cae después de la fase 1: los participantes quedan en
//    duda, con locks tomados; la recuperación aborta (presumed abort).
// 4. El coordinador cae después de decidir: la recuperación hace commit.
import pg from 'pg';
import { Coordinator, CoordinatorCrash, bookShipment, seedDemo } from './twopc.mjs';

const conn = (database) => new pg.Pool({ host: '/tmp', port: 5416, user: 'postgres', database });
const pools = { sur: conn('sur'), oriente: conn('oriente'), coordinador: conn('coordinador') };
const coordinator = new Coordinator(
  pools.coordinador,
  { sur: pools.sur, oriente: pools.oriente },
  { trace: (step) => console.log(`  ${step}`) },
);

const state = async () => {
  const trip = (await pools.oriente.query('SELECT booked_kg FROM trip WHERE trip_id = 7001')).rows[0];
  const n = (await pools.sur.query('SELECT count(*)::int AS n FROM shipment')).rows[0].n;
  console.log(`  estado: ${n} envíos en sur; viaje 7001 con ${trip.booked_kg} kg de 1000`);
};

async function scenario(title, gid, booking, options) {
  console.log(`\n${title}`);
  try {
    console.log(`  resultado: ${await coordinator.run(gid, bookShipment(booking), options)}`);
  } catch (err) {
    if (!(err instanceof CoordinatorCrash)) throw err;
    console.log(`  ${err.message}`);
  }
}

try {
  await seedDemo(pools);
  await state();

  await scenario('1. Envío de 35 kg', 'chasqui-1',
    { shipmentId: 90002, trackingCode: 'CUS-IQT-0002', tripId: 7001, weightKg: 35 });
  await state();

  await scenario('2. Envío de 40 kg (ya no cabe)', 'chasqui-2',
    { shipmentId: 90003, trackingCode: 'CUS-IQT-0003', tripId: 7001, weightKg: 40 });
  await state();

  await scenario('3. Envío de 10 kg, el coordinador cae después de la fase 1', 'chasqui-3',
    { shipmentId: 90004, trackingCode: 'CUS-IQT-0004', tripId: 7001, weightKg: 10 },
    { crashAt: 'prepared' });
  console.log('  en duda:', JSON.stringify(await coordinator.inDoubt()));
  const blocked = await pools.oriente.connect();
  try {
    await blocked.query("SET lock_timeout = '500ms'");
    await blocked.query('UPDATE trip SET booked_kg = booked_kg WHERE trip_id = 7001');
  } catch (err) {
    console.log(`  otra transacción sobre el viaje 7001: ${err.code} ${err.message}`);
  } finally {
    blocked.release();
  }
  console.log('  recuperación:', JSON.stringify(await coordinator.recover()));
  await state();

  await scenario('4. Envío de 10 kg, el coordinador cae después de decidir', 'chasqui-4',
    { shipmentId: 90005, trackingCode: 'CUS-IQT-0005', tripId: 7001, weightKg: 10 },
    { crashAt: 'decided' });
  console.log('  en duda:', JSON.stringify(await coordinator.inDoubt()));
  console.log('  recuperación:', JSON.stringify(await coordinator.recover()));
  await state();
} finally {
  await Promise.all(Object.values(pools).map((p) => p.end()));
}
twopc-setup.sqlDescargar twopc-setup.sql
-- Semana 16: las bases de datos de la demostración de 2PC, en el cluster
-- desechable de la semana (puerto 5416, con max_prepared_transactions > 0).
-- `make cluster` lo crea y corre este archivo:
--   psql -h /tmp -p 5416 -U postgres -d postgres -f twopc-setup.sql
--
-- Hacen de tres servidores:
--   sur          los envíos registrados en las agencias del sur (Cusco)
--   oriente      los viajes que salen hacia el oriente (Iquitos)
--   coordinador  el log de decisiones del coordinador de 2PC

SET client_min_messages = warning;
DROP DATABASE IF EXISTS sur;
DROP DATABASE IF EXISTS oriente;
DROP DATABASE IF EXISTS coordinador;
CREATE DATABASE sur;
CREATE DATABASE oriente;
CREATE DATABASE coordinador;

\connect sur
SET client_min_messages = warning;
CREATE TABLE shipment (
  shipment_id   integer PRIMARY KEY,
  tracking_code text NOT NULL,
  origin        text NOT NULL,
  destination   text NOT NULL,
  trip_id       integer NOT NULL,
  weight_kg     numeric(7, 2) NOT NULL CHECK (weight_kg > 0),
  CONSTRAINT shipment_tracking_code_key UNIQUE (tracking_code)
);
-- Un envío ya registrado en el viaje 7001 (seedDemo de twopc.mjs vuelve a
-- dejar estos mismos datos).
INSERT INTO shipment VALUES (90001, 'CUS-IQT-0001', 'Cusco', 'Iquitos', 7001, 940);

\connect oriente
SET client_min_messages = warning;
CREATE TABLE trip (
  trip_id     integer PRIMARY KEY,
  route       text NOT NULL,
  capacity_kg integer NOT NULL CHECK (capacity_kg > 0),
  booked_kg   numeric(8, 2) NOT NULL DEFAULT 0 CHECK (booked_kg >= 0)
);

-- La capacidad se verifica con un constraint trigger diferido: corre al final
-- de la transacción, es decir, en PREPARE TRANSACTION. Así este participante
-- puede votar "no" en la fase 1, cuando el otro ya votó que sí.
CREATE FUNCTION check_trip_capacity() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.booked_kg > NEW.capacity_kg THEN
    RAISE EXCEPTION 'el viaje % no tiene capacidad: % kg de %',
      NEW.trip_id, NEW.booked_kg, NEW.capacity_kg
      USING ERRCODE = 'check_violation';
  END IF;
  RETURN NULL;
END;
$$;

CREATE CONSTRAINT TRIGGER trip_capacity_check
  AFTER INSERT OR UPDATE ON trip
  DEFERRABLE INITIALLY DEFERRED
  FOR EACH ROW EXECUTE FUNCTION check_trip_capacity();

INSERT INTO trip (trip_id, route, capacity_kg, booked_kg)
VALUES (7001, 'Cusco - Iquitos', 1000, 940);

\connect coordinador
SET client_min_messages = warning;
-- Una fila por transacción global que llegó a decidirse commit. Con
-- presumed abort, una transacción sin fila aquí se considera abortada.
CREATE TABLE decision (
  gid        text PRIMARY KEY,
  outcome    text NOT NULL CHECK (outcome = 'commit'),
  decided_at timestamptz NOT NULL DEFAULT now(),
  ended      boolean NOT NULL DEFAULT false
);
twopc.mjsDescargar twopc.mjs
// Un coordinador de two-phase commit (2PC) sobre varias bases de datos de
// PostgreSQL. Cada participante es un pg.Pool; la fase 1 es PREPARE
// TRANSACTION y la fase 2, COMMIT PREPARED o ROLLBACK PREPARED. La decisión
// se guarda en la tabla decision de otra base (el log del coordinador), con
// presumed abort: si no hay fila de commit, la transacción se aborta.
//
// Necesita servidores con max_prepared_transactions > 0: el cluster
// desechable que crea `make cluster` (ver twopc-setup.sql).

// Prefijo de los gid de este coordinador: recover() solo toca los suyos.
export const GID_PREFIX = 'chasqui-';

// Una caída simulada del coordinador. at dice en qué punto: 'prepared'
// (todos votaron que sí, no hay decisión) o 'decided' (la decisión está en el
// log, la fase 2 no empezó).
export class CoordinatorCrash extends Error {
  constructor(at) {
    super(`el coordinador cayó (${at})`);
    this.name = 'CoordinatorCrash';
    this.at = at;
  }
}

// En PostgreSQL el gid de una transacción preparada es único en todo el
// servidor, no por base de datos. Aquí sur y oriente están en el mismo
// cluster, así que cada participante prepara su parte con un gid propio:
// el global, '@' y el nombre del participante (como el branch qualifier de
// XA). En servidores distintos bastaría el gid global.
const branchGid = (gid, name) => `${gid}@${name}`;
const globalGid = (branch) => branch.slice(0, branch.lastIndexOf('@'));

// PREPARE TRANSACTION, COMMIT PREPARED y ROLLBACK PREPARED no aceptan
// parámetros ($1): el gid va como literal. Por eso se valida con una lista
// blanca de caracteres y además se escapa.
function gidLiteral(client, gid) {
  if (!/^[a-z0-9-]{1,100}@[a-z]{1,30}$/.test(gid)) throw new Error(`gid inválido: ${gid}`);
  return client.escapeLiteral(gid);
}

export class Coordinator {
  // logPool: la base del coordinador. participants: { nombre: pg.Pool }.
  // trace: función que recibe cada paso, para mostrar el protocolo.
  constructor(logPool, participants, { trace = () => {} } = {}) {
    this.log = logPool;
    this.participants = participants;
    this.trace = trace;
  }

  // Ejecuta work(clients) como una transacción global con identificador gid.
  // clients tiene un pg.Client por participante, con la transacción abierta.
  // Devuelve 'commit' o 'abort'. crashAt simula la caída del coordinador.
  async run(gid, work, { crashAt } = {}) {
    if (!gid.startsWith(GID_PREFIX) || !/^[a-z0-9-]{1,90}$/.test(gid)) throw new Error(`gid inválido: ${gid}`);
    const names = Object.keys(this.participants);
    const clients = {};
    for (const name of names) clients[name] = await this.participants[name].connect();
    const prepared = [];
    try {
      for (const name of names) await clients[name].query('BEGIN');
      try {
        await work(clients);
      } catch (err) {
        // Un participante falló antes de la fase 1: se aborta en todos.
        this.trace(`trabajo falló: ${err.message}`);
        for (const name of names) await clients[name].query('ROLLBACK');
        this.trace('decisión: abort (sin log, presumed abort)');
        return 'abort';
      }

      // Fase 1: cada participante fuerza su <ready T> en su log.
      let allReady = true;
      for (const name of names) {
        try {
          await clients[name].query(`PREPARE TRANSACTION ${gidLiteral(clients[name], branchGid(gid, name))}`);
          prepared.push(name);
          this.trace(`${name}: ready`);
        } catch (err) {
          // PREPARE falló: la transacción de ese participante ya abortó.
          allReady = false;
          this.trace(`${name}: no (${err.message})`);
          break;
        }
      }
      if (!allReady) {
        for (const name of names) {
          if (prepared.includes(name)) {
            await clients[name].query(`ROLLBACK PREPARED ${gidLiteral(clients[name], branchGid(gid, name))}`);
            this.trace(`${name}: rollback prepared`);
          } else {
            await clients[name].query('ROLLBACK'); // no hace nada si ya abortó
          }
        }
        this.trace('decisión: abort (sin log, presumed abort)');
        return 'abort';
      }
      if (crashAt === 'prepared') throw new CoordinatorCrash(crashAt);

      // La decisión: una fila en el log del coordinador. Este INSERT en
      // autocommit es el <commit T> forzado; desde aquí, T hizo commit.
      await this.log.query('INSERT INTO decision (gid, outcome) VALUES ($1, $2)', [gid, 'commit']);
      this.trace('decisión: commit (en el log del coordinador)');
      if (crashAt === 'decided') throw new CoordinatorCrash(crashAt);

      // Fase 2.
      await this.finish(gid, 'commit', names);
      return 'commit';
    } finally {
      // Si el coordinador "cae", las sesiones se cierran; las transacciones
      // preparadas siguen vivas en cada participante, con sus locks.
      for (const name of names) clients[name].release();
    }
  }

  // Fase 2 para gid en los participantes indicados, y <end T> en el log.
  async finish(gid, outcome, names) {
    const verb = outcome === 'commit' ? 'COMMIT PREPARED' : 'ROLLBACK PREPARED';
    for (const name of names) {
      const client = await this.participants[name].connect();
      try {
        await client.query(`${verb} ${gidLiteral(client, branchGid(gid, name))}`);
        this.trace(`${name}: ${verb.toLowerCase()}`);
      } finally {
        client.release();
      }
    }
    if (outcome === 'commit') {
      await this.log.query('UPDATE decision SET ended = true WHERE gid = $1', [gid]);
      this.trace('end');
    }
  }

  // Las transacciones en duda (preparadas y sin terminar) de este
  // coordinador, por participante: { nombre: [gid global, ...] }.
  async inDoubt() {
    const result = {};
    for (const [name, pool] of Object.entries(this.participants)) {
      const { rows } = await pool.query(
        `SELECT gid FROM pg_prepared_xacts
         WHERE database = current_database() AND starts_with(gid, $1)
           AND gid LIKE $2
         ORDER BY gid`,
        [GID_PREFIX, `%@${name}`],
      );
      result[name] = rows.map((r) => globalGid(r.gid));
    }
    return result;
  }

  // Recuperación después de una caída del coordinador: cada transacción en
  // duda se termina según el log. Con commit en el log, COMMIT PREPARED; sin
  // fila, ROLLBACK PREPARED (presumed abort). Devuelve { gid: outcome }.
  async recover() {
    const doubt = await this.inDoubt();
    const gids = [...new Set(Object.values(doubt).flat())];
    const outcomes = {};
    for (const gid of gids) {
      const { rows } = await this.log.query('SELECT outcome FROM decision WHERE gid = $1', [gid]);
      const outcome = rows.length > 0 ? 'commit' : 'abort';
      const names = Object.keys(doubt).filter((n) => doubt[n].includes(gid));
      this.trace(`recuperación de ${gid}: ${rows.length > 0 ? 'commit en el log' : 'sin registro'} → ${outcome}`);
      await this.finish(gid, outcome, names);
      outcomes[gid] = outcome;
    }
    // Decisiones de commit cuya fase 2 ya terminó en todos (por ejemplo, si
    // el coordinador cayó justo antes de anotar el end).
    await this.log.query(
      'UPDATE decision SET ended = true WHERE NOT ended AND NOT (gid = ANY ($1))',
      [gids],
    );
    return outcomes;
  }
}

// La transacción de ejemplo: registrar en el sur el envío y descontar su
// peso de la capacidad del viaje en el oriente. Las dos cosas o ninguna.
export function bookShipment({ shipmentId, trackingCode, tripId, weightKg }) {
  return async ({ sur, oriente }) => {
    await sur.query(
      `INSERT INTO shipment (shipment_id, tracking_code, origin, destination, trip_id, weight_kg)
       VALUES ($1, $2, 'Cusco', 'Iquitos', $3, $4)`,
      [shipmentId, trackingCode, tripId, weightKg],
    );
    const { rowCount } = await oriente.query(
      'UPDATE trip SET booked_kg = booked_kg + $1 WHERE trip_id = $2',
      [weightKg, tripId],
    );
    if (rowCount !== 1) throw new Error(`no existe el viaje ${tripId}`);
  };
}

// Deja las tres bases como al principio de la demostración.
export async function seedDemo({ sur, oriente, coordinador }) {
  await sur.query('TRUNCATE shipment');
  await sur.query(
    `INSERT INTO shipment (shipment_id, tracking_code, origin, destination, trip_id, weight_kg)
     VALUES (90001, 'CUS-IQT-0001', 'Cusco', 'Iquitos', 7001, 940)`,
  );
  await oriente.query('TRUNCATE trip');
  await oriente.query(
    `INSERT INTO trip (trip_id, route, capacity_kg, booked_kg)
     VALUES (7001, 'Cusco - Iquitos', 1000, 940)`,
  );
  await coordinador.query('TRUNCATE decision');
}

Pruebas

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

mapreduce.test.mjsDescargar mapreduce.test.mjs
// Pruebas del MapReduce de juguete: el resultado coincide con el GROUP BY, y
// el combiner reduce lo que viaja en el shuffle sin cambiar el resultado.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { mapReduce, deliveriesMap, sumReduce, EVENTS_SQL, DELIVERIES_SQL } from './mapreduce.mjs';

const pool = new pg.Pool();
after(() => pool.end());

test('un conteo de palabras pequeño', () => {
  const lines = ['lima cusco lima', 'iquitos lima', 'cusco'];
  const { result, stats } = mapReduce(
    lines,
    (line, emit) => line.split(' ').forEach((w) => emit(w, 1)),
    sumReduce,
    { splits: 2, reducers: 2 },
  );
  assert.deepEqual(Object.fromEntries(result), { lima: 3, cusco: 2, iquitos: 1 });
  assert.equal(stats.mapped, 6);
  assert.equal(stats.shuffled, 6);
});

test('entregas por ciudad: igual que GROUP BY, con y sin combiner', async () => {
  const events = (await pool.query(EVENTS_SQL)).rows;
  assert.equal(events.length, 46421);
  const expected = new Map((await pool.query(DELIVERIES_SQL)).rows.map((r) => [r.city, r.n]));

  const plain = mapReduce(events, deliveriesMap, sumReduce);
  const combined = mapReduce(events, deliveriesMap, sumReduce, { combiner: sumReduce });
  for (const { result } of [plain, combined]) {
    assert.deepEqual(new Map([...result].sort()), new Map([...expected].sort()));
  }
  assert.equal(plain.result.get('Lima'), 3632);
  assert.equal(plain.stats.mapped, 11115);
  assert.equal(plain.stats.shuffled, 11115);
  assert.equal(combined.stats.shuffled, 100);
  // El reducer que recibe Lima recibe mucho más que los otros (skew).
  assert.deepEqual(plain.stats.perReducer, [1373, 7407, 2335]);
});
partitions.test.mjsDescargar partitions.test.mjs
// Pruebas de pruning.sql y documents.sql sobre chasqui_w16 (make crea las
// tablas particionadas): las cifras y los planes que muestra el apunte.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { namedQueries } from './sqlfile.mjs';

const q = { ...namedQueries('./pruning.sql'), ...namedQueries('./documents.sql') };
let client;
before(async () => {
  client = new pg.Client();
  await client.connect();
});
after(() => client.end());

const rows = async (name) => (await client.query(q[name])).rows;
const plan = async (name) => (await rows(name)).map((r) => r['QUERY PLAN']).join('\n');
const counts = (list) => Object.fromEntries(list.map((r) => [r.partition, Number(r.n)]));

test('por mes: seis particiones con todos los envíos, la DEFAULT vacía', async () => {
  const byMonth = counts(await rows('by_month'));
  assert.deepEqual(byMonth, {
    shipment_2026_01: 1813, shipment_2026_02: 1938, shipment_2026_03: 2149,
    shipment_2026_04: 2054, shipment_2026_05: 2157, shipment_2026_06: 1889,
  });
  const bounds = await rows('bounds');
  assert.equal(bounds.length, 7);
  assert.equal(bounds.at(-1).bound, 'DEFAULT');
});

test('por hash: cuatro particiones parejas; por zona: Lima concentra un tercio', async () => {
  const byHash = Object.values(counts(await rows('by_hash')));
  assert.equal(byHash.length, 4);
  assert.equal(byHash.reduce((a, b) => a + b), 12000);
  assert.ok(Math.max(...byHash) - Math.min(...byHash) < 150);
  const byZone = await rows('by_zone');
  assert.equal(byZone[0].partition, 'shipment_lima');
  assert.equal(Number(byZone[0].n), 3965);
  assert.equal(byZone[0].pct, '33.0');
});

test('pruning: marzo lee una sola partición; sender_id lee las siete', async () => {
  const march = await plan('prune_march');
  assert.match(march, /Seq Scan on shipment_2026_03/);
  assert.doesNotMatch(march, /Append|shipment_2026_0[124-6]|default/);
  const all = await plan('no_prune');
  assert.equal(all.match(/Seq Scan on/g).length, 7);
});

test('hash: la igualdad poda, el rango no', async () => {
  const eq = await plan('hash_eq');
  assert.equal(eq.match(/ on shipment_h\d/g).length, 1);
  const range = await plan('hash_range');
  assert.equal(range.match(/ on shipment_h\d/g).length, 4);
});

test('pruning en la ejecución: Subplans Removed: 5 y 854 filas', async () => {
  await client.query('SET plan_cache_mode = force_generic_plan');
  await client.query(`PREPARE since_day(timestamptz) AS
    SELECT count(*) FROM shipment_by_month WHERE created_at >= $1`);
  try {
    const p = await plan('prune_runtime');
    assert.match(p, /Subplans Removed: 5/);
    assert.match(p, /Seq Scan on shipment_2026_06 .*actual rows=854/);
  } finally {
    await client.query('DEALLOCATE since_day');
    await client.query('RESET plan_cache_mode');
  }
  assert.equal(Number((await rows('since_june15'))[0].count), 854);
});

test('join por particiones: cuatro hash joins, partición contra partición', async () => {
  await client.query('SET enable_partitionwise_join = on');
  try {
    const p = await plan('partitionwise');
    assert.equal(p.match(/Hash Join/g).length, 4);
    for (const i of [0, 1, 2, 3]) assert.match(p, new RegExp(`shipment_event_h${i}`));
  } finally {
    await client.query('RESET enable_partitionwise_join');
  }
  const total = (await rows('events_per_status')).reduce((a, r) => a + Number(r.count), 0);
  assert.equal(total, 46421);
});

test('execution skew: los 50 envíos nuevos caen en junio', async () => {
  await client.query('BEGIN');
  try {
    assert.deepEqual(counts(await rows('insert_skew')), { shipment_2026_06: 50 });
  } finally {
    await client.query('ROLLBACK');
  }
});

test('sin partición para la agencia 30: error 23514', async () => {
  await assert.rejects(client.query(q.no_partition), (err) => {
    assert.equal(err.code, '23514');
    assert.match(err.message, /no partition of relation "shipment_by_zone" found for row/);
    return true;
  });
});

test('el envío 501 como documento, y cuántas copias tiene el nombre de la agencia 1', async () => {
  const doc = JSON.parse((await rows('document_501'))[0].doc);
  assert.equal(doc.events.length, 4);
  assert.equal(doc.events[0].agency, 'Huánuco - Centro');
  assert.deepEqual(await rows('docs_to_rewrite'), [{ documents: '3531', copies: '7002' }]);
});
replication.test.mjsDescargar replication.test.mjs
// Pruebas de streaming replication en el cluster desechable: primario en el
// puerto 5416 y réplica (hot standby) en el 5417, creada con pg_basebackup.
// Al final la réplica queda detenida; make test borra el cluster.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import pg from 'pg';

const primary = new pg.Pool({ host: '/tmp', port: 5416, user: 'postgres', database: 'postgres' });
const replica = new pg.Pool({ host: '/tmp', port: 5417, user: 'postgres', database: 'postgres' });
// Vuelve a la replicación asíncrona: con la réplica detenida, cualquier
// commit en el primario esperaría para siempre.
async function asyncAgain() {
  await primary.query('ALTER SYSTEM RESET synchronous_standby_names');
  await primary.query('SELECT pg_reload_conf()');
}
after(async () => {
  await asyncAgain();
  await primary.query('DROP TABLE IF EXISTS replica_demo');
  await primary.end();
  await replica.end().catch(() => {});
});

// Espera hasta que check() sea verdadero (la réplica va un poco atrás).
async function eventually(check, ms = 5000) {
  const until = Date.now() + ms;
  while (Date.now() < until) {
    if (await check()) return;
    await sleep(50);
  }
  assert.fail('no ocurrió a tiempo');
}

test('el primario ve a replica1 en streaming asíncrono', async () => {
  const { rows } = await primary.query(
    'SELECT application_name, state, sync_state FROM pg_stat_replication');
  assert.deepEqual(rows, [{ application_name: 'replica1', state: 'streaming', sync_state: 'async' }]);
});

test('la réplica está en recovery y no acepta escrituras (25006)', async () => {
  const { rows } = await replica.query('SELECT pg_is_in_recovery() AS r');
  assert.equal(rows[0].r, true);
  await assert.rejects(replica.query('CREATE TABLE x (a integer)'), (err) => {
    assert.equal(err.code, '25006');
    assert.equal(err.message, 'cannot execute CREATE TABLE in a read-only transaction');
    return true;
  });
});

test('lo que se escribe en el primario llega a la réplica; también un DELETE', async () => {
  await primary.query('CREATE TABLE replica_demo (id integer PRIMARY KEY, note text)');
  await primary.query("INSERT INTO replica_demo VALUES (1, 'envío 501'), (2, 'envío 502')");
  const count = async () => {
    const r = await replica.query("SELECT to_regclass('replica_demo') IS NOT NULL AS t");
    return r.rows[0].t ? Number((await replica.query('SELECT count(*) FROM replica_demo')).rows[0].count) : -1;
  };
  await eventually(async () => (await count()) === 2);
  // Una réplica no es un backup: el DELETE equivocado también se replica.
  await primary.query('DELETE FROM replica_demo');
  await eventually(async () => (await count()) === 0);
});

test('síncrona: sin la réplica, el commit espera; al cancelarlo ya hizo commit local', async () => {
  await primary.query("ALTER SYSTEM SET synchronous_standby_names = 'replica1'");
  await primary.query('SELECT pg_reload_conf()');
  await eventually(async () =>
    (await primary.query('SELECT sync_state FROM pg_stat_replication')).rows[0]?.sync_state === 'sync');

  // Con la réplica activa, un commit espera su confirmación y vuelve enseguida.
  await primary.query("INSERT INTO replica_demo VALUES (3, 'con réplica')");

  // Se detiene la réplica: el próximo commit queda esperando (SyncRep).
  await replica.end();
  const args = ['-D', '/tmp/w16-replica', '-m', 'fast', '-w', '-s', 'stop'];
  if (process.getuid() === 0) execFileSync('gosu', ['postgres', 'pg_ctl', ...args]);
  else execFileSync('pg_ctl', args);

  const client = new pg.Client({ host: '/tmp', port: 5416, user: 'postgres', database: 'postgres' });
  await client.connect();
  const notices = [];
  client.on('notice', (n) => notices.push(n.message));
  const insert = client.query("INSERT INTO replica_demo VALUES (4, 'sin réplica')");
  let waiting;
  await eventually(async () => {
    waiting = (await primary.query(
      "SELECT pid FROM pg_stat_activity WHERE wait_event = 'SyncRep'")).rows;
    return waiting.length === 1;
  });
  await primary.query('SELECT pg_cancel_backend($1)', [waiting[0].pid]);
  await insert; // no falla: el commit ya estaba hecho en el primario
  await client.end();
  await asyncAgain();
  assert.deepEqual(notices, ['canceling wait for synchronous replication due to user request']);
  const { rows } = await primary.query('SELECT id FROM replica_demo ORDER BY id');
  assert.deepEqual(rows.map((r) => r.id), [3, 4]);
});
twopc.test.mjsDescargar twopc.test.mjs
// Pruebas del coordinador de 2PC y de prepared.sql, sobre el cluster
// desechable (make test lo crea y lo borra al final).
import { test, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import pg from 'pg';
import { Coordinator, CoordinatorCrash, bookShipment, seedDemo } from './twopc.mjs';
import { namedQueries } from './sqlfile.mjs';

const PRIMARY = '/tmp/w16-primary';
const conn = (database) => new pg.Pool({ host: '/tmp', port: 5416, user: 'postgres', database });
const pools = { sur: conn('sur'), oriente: conn('oriente'), coordinador: conn('coordinador') };
const steps = [];
const coordinator = new Coordinator(
  pools.coordinador,
  { sur: pools.sur, oriente: pools.oriente },
  { trace: (s) => steps.push(s) },
);
after(() => Promise.all(Object.values(pools).map((p) => p.end())));

beforeEach(async () => {
  steps.length = 0;
  await seedDemo(pools);
});

const booked = async () =>
  Number((await pools.oriente.query('SELECT booked_kg FROM trip WHERE trip_id = 7001')).rows[0].booked_kg);
const shipments = async () =>
  (await pools.sur.query('SELECT shipment_id FROM shipment ORDER BY 1')).rows.map((r) => r.shipment_id);
const booking = (id, weightKg) =>
  bookShipment({ shipmentId: id, trackingCode: `CUS-IQT-${id}`, tripId: 7001, weightKg });

test('commit: los dos votan que sí y los dos hacen commit', async () => {
  assert.equal(await coordinator.run('chasqui-1', booking(90002, 35)), 'commit');
  assert.deepEqual(steps, [
    'sur: ready', 'oriente: ready', 'decisión: commit (en el log del coordinador)',
    'sur: commit prepared', 'oriente: commit prepared', 'end',
  ]);
  assert.equal(await booked(), 975);
  assert.deepEqual(await shipments(), [90001, 90002]);
});

test('voto no en la fase 1: oriente no tiene capacidad y sur hace rollback prepared', async () => {
  assert.equal(await coordinator.run('chasqui-2', booking(90003, 61)), 'abort');
  assert.match(steps[1], /^oriente: no \(el viaje 7001 no tiene capacidad: 1001.00 kg de 1000\)$/);
  assert.equal(steps[2], 'sur: rollback prepared');
  assert.equal(await booked(), 940);
  assert.deepEqual(await shipments(), [90001]);
  const { rows } = await pools.coordinador.query('SELECT count(*)::int AS n FROM decision');
  assert.equal(rows[0].n, 0); // presumed abort: el abort no se anota
});

test('fallo antes de la fase 1: el código de seguimiento repetido aborta todo', async () => {
  const work = bookShipment({ shipmentId: 90004, trackingCode: 'CUS-IQT-0001', tripId: 7001, weightKg: 5 });
  assert.equal(await coordinator.run('chasqui-3', work), 'abort');
  assert.match(steps[0], /duplicate key value violates unique constraint "shipment_tracking_code_key"/);
  assert.equal(await booked(), 940);
});

test('el coordinador cae después de la fase 1: bloqueo y presumed abort', async () => {
  await assert.rejects(coordinator.run('chasqui-4', booking(90005, 10), { crashAt: 'prepared' }), CoordinatorCrash);
  assert.deepEqual(await coordinator.inDoubt(), { sur: ['chasqui-4'], oriente: ['chasqui-4'] });

  // Los participantes no pueden decidir solos y conservan sus locks: otra
  // transacción que quiere el mismo viaje espera (aquí, hasta el lock_timeout).
  const other = await pools.oriente.connect();
  try {
    await other.query("SET lock_timeout = '300ms'");
    await assert.rejects(other.query('UPDATE trip SET booked_kg = booked_kg WHERE trip_id = 7001'),
      (err) => err.code === '55P03');
  } finally {
    other.release();
  }
  // Mientras tanto, el cambio no se ve.
  assert.equal(await booked(), 940);

  assert.deepEqual(await coordinator.recover(), { 'chasqui-4': 'abort' });
  assert.deepEqual(await coordinator.inDoubt(), { sur: [], oriente: [] });
  assert.equal(await booked(), 940);
  assert.deepEqual(await shipments(), [90001]);
});

test('el coordinador cae después de decidir: la recuperación hace commit', async () => {
  await assert.rejects(coordinator.run('chasqui-5', booking(90006, 10), { crashAt: 'decided' }), CoordinatorCrash);
  assert.equal(await booked(), 940); // decidido, pero todavía sin fase 2
  assert.deepEqual(await coordinator.recover(), { 'chasqui-5': 'commit' });
  assert.equal(await booked(), 950);
  assert.deepEqual(await shipments(), [90001, 90006]);
  const { rows } = await pools.coordinador.query('SELECT gid, ended FROM decision');
  assert.deepEqual(rows, [{ gid: 'chasqui-5', ended: true }]);
});

test('gid inválido: no se arma SQL con él', async () => {
  await assert.rejects(coordinator.run("chasqui-x'; DROP TABLE trip; --", booking(90007, 1)), /gid inválido/);
  await assert.rejects(coordinator.run('otro-1', booking(90007, 1)), /gid inválido/);
});

test('prepared.sql: la transacción preparada no tiene proceso y conserva sus locks', async () => {
  const q = namedQueries('./prepared.sql');
  const c = await pools.oriente.connect();
  try {
    await c.query('BEGIN');
    await c.query('UPDATE trip SET booked_kg = booked_kg + 5 WHERE trip_id = 7001');
    await c.query("PREPARE TRANSACTION 'manual-1@oriente'");
    const [xact] = (await c.query(q.prepared_xacts)).rows;
    assert.equal(xact.gid, 'manual-1@oriente');
    const locks = (await c.query(q.prepared_locks)).rows;
    assert.deepEqual(locks.map((l) => [l.locktype, l.relation, l.mode, l.pid]), [
      ['relation', 'trip', 'RowExclusiveLock', null],
      ['relation', 'trip_pkey', 'RowExclusiveLock', null],
      ['transactionid', null, 'ExclusiveLock', null],
    ]);
    assert.equal((await c.query(q.before_commit)).rows[0].booked_kg, '940.00');
    await c.query("COMMIT PREPARED 'manual-1@oriente'");
    assert.equal((await c.query(q.after_commit)).rows[0].booked_kg, '945.00');
  } finally {
    c.release();
  }
});

test('una transacción preparada sobrevive a una caída del servidor', async () => {
  const c = await pools.oriente.connect();
  await c.query('BEGIN');
  await c.query('UPDATE trip SET booked_kg = booked_kg + 5 WHERE trip_id = 7001');
  await c.query("PREPARE TRANSACTION 'caida-1@oriente'");
  c.release();
  // Caída del primario del cluster desechable (nunca del servidor del curso).
  const pgctl = (...args) => process.getuid() === 0
    ? execFileSync('gosu', ['postgres', 'pg_ctl', '-D', PRIMARY, '-w', '-s', ...args])
    : execFileSync('pg_ctl', ['-D', PRIMARY, '-w', '-s', ...args]);
  await Promise.all(Object.values(pools).map((p) => p.end()));
  pgctl('-m', 'immediate', 'stop');
  pgctl('-l', `${PRIMARY}.log`, '-o',
    '-p 5416 -k /tmp -c listen_addresses= -c max_prepared_transactions=10 -c cluster_name=primary', 'start');
  const oriente = conn('oriente');
  try {
    const { rows } = await oriente.query('SELECT gid FROM pg_prepared_xacts');
    assert.deepEqual(rows, [{ gid: 'caida-1@oriente' }]);
    await oriente.query("ROLLBACK PREPARED 'caida-1@oriente'");
    assert.equal((await oriente.query('SELECT booked_kg FROM trip')).rows[0].booked_kg, '940.00');
  } finally {
    await oriente.end();
  }
  for (const name of Object.keys(pools)) pools[name] = conn(name);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 16: particionamiento, replicación y two-phase commit.
# Se usa dentro del contenedor del curso.
#
#   make              crea la base de datos chasqui_w16 con los datos de ejemplo
#                     y las tablas particionadas de partitions.sql
#   make cluster      crea y arranca un cluster desechable de PostgreSQL
#                     (primario en el puerto 5416, réplica en el 5417, sockets
#                     en /tmp), con max_prepared_transactions = 10 y las bases
#                     de twopc-setup.sql
#   make test         chasqui_w16 y el cluster de nuevo, las pruebas
#                     (*.test.mjs) y al final borra el cluster
#   make cluster-rm   detiene y borra el cluster desechable
#   make clean        borra chasqui_w16 y el cluster
#
# El cluster desechable existe porque el servidor del curso tiene
# max_prepared_transactions = 0 y no hay que reiniciarlo ni configurarlo
# para una demostración. Conéctense a él con
#   psql -h /tmp -p 5416 -U postgres -d sur
#
# initdb y pg_ctl no corren como root: si la shell es de root, se usa gosu.

DB = chasqui_w16
PSQL = psql -X -q -v ON_ERROR_STOP=1

PRIMARY = /tmp/w16-primary
REPLICA = /tmp/w16-replica
PRIMARY_PORT = 5416
REPLICA_PORT = 5417
AS_PG = $(if $(filter 0,$(shell id -u)),gosu postgres,)
OPTS = -k /tmp -c listen_addresses= -c max_prepared_transactions=10

.PHONY: all cluster cluster-rm test clean

all:
	chasqui-load $(DB)
	$(PSQL) -d $(DB) -f partitions.sql

cluster: cluster-rm
	$(AS_PG) initdb -D $(PRIMARY) -A trust -U postgres >/dev/null
	$(AS_PG) pg_ctl -D $(PRIMARY) -l $(PRIMARY).log -w -s \
	  -o "-p $(PRIMARY_PORT) $(OPTS) -c cluster_name=primary" start
	$(PSQL) -h /tmp -p $(PRIMARY_PORT) -U postgres -d postgres -f twopc-setup.sql
	$(AS_PG) pg_basebackup -h /tmp -p $(PRIMARY_PORT) -U postgres \
	  -D $(REPLICA) -R -X stream -c fast
	$(AS_PG) pg_ctl -D $(REPLICA) -l $(REPLICA).log -w -s \
	  -o "-p $(REPLICA_PORT) $(OPTS) -c cluster_name=replica1" start
	@echo "Cluster listo: primario en el puerto $(PRIMARY_PORT), réplica en el $(REPLICA_PORT)."

cluster-rm:
	-@[ -f $(REPLICA)/postmaster.pid ] && $(AS_PG) pg_ctl -D $(REPLICA) -m immediate -w -s stop || true
	-@[ -f $(PRIMARY)/postmaster.pid ] && $(AS_PG) pg_ctl -D $(PRIMARY) -m immediate -w -s stop || true
	rm -rf $(PRIMARY) $(REPLICA) $(PRIMARY).log $(REPLICA).log

# Las pruebas cambian datos y detienen la réplica: una después de otra. El
# cluster se borra aunque alguna prueba falle.
test: all cluster
	PGDATABASE=$(DB) node --test --test-concurrency=1 --test-reporter=spec; \
	  status=$$?; $(MAKE) -s cluster-rm; exit $$status

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