Guía de respuestas y soluciones razonadas: Módulo 02 — Backend#
Esta guía de referencia técnica presenta las soluciones canónicas, el diseño formal de contratos HTTP, los esquemas de base de datos relacionales en PostgreSQL, la mecánica de concurrencia y máquinas de estados asíncronas, y las respuestas completas y exhaustivas a las preguntas de diagnóstico socrático para las lecciones 05 a 08 del módulo de backend.
Intenta resolver primero cada ejercicio de diseño, modelado de esquemas y trazabilidad concurrente por tu cuenta antes de consultar esta guía. El aprendizaje profundo en ingeniería de backend proviene de razonar sobre la falibilidad de la red, los estados intermedios no confirmados y la concurrencia a nivel de almacenamiento.
Para consultar definiciones formales y etimología de cualquier término técnico, visita el glosario técnico.
Lección 05 — Contratos HTTP y ciclo de una solicitud#
1. Matriz formal de contratos para el endpoint POST /tasks#
El siguiente contrato define la interfaz pública exhaustiva del endpoint POST /tasks. El sistema acepta un cuerpo JSON con un campo obligatorio title (cadena no vacía) y un campo opcional due_date (cadena con formato ISO 8601 UTC).
Tabla de contratos de entrada y salida:#
| Caso | Escenario | Método y Ruta | Cabeceras de Entrada | Cuerpo de Entrada (Request Body) | Código HTTP | Cabeceras de Respuesta | Cuerpo de Respuesta (Response Body) | Justificación de Arquitectura |
|---|---|---|---|---|---|---|---|---|
| 1 | Sintaxis JSON rota | POST /tasks | Content-Type: application/json | {"title": "Broken (incompleto) | 400 Bad Request | Content-Type: application/problem+json | {"type":"about:blank","title":"Bad Request","status":400,"detail":"Syntax error in JSON payload"} | Falla en la deserialización sintáctica antes de llegar al dominio. |
| 2 | Tipo de medio no admitido | POST /tasks | Content-Type: text/plain | title=Plan | 415 Unsupported Media Type | Accept: application/json | {"type":"about:blank","title":"Unsupported Media Type","status":415,"detail":"Expected application/json"} | El servidor rechaza representaciones no soportadas antes de leer el cuerpo. |
| 3 | Carga útil excesiva | POST /tasks | Content-Type: application/json | Payload > 2 MB | 413 Payload Too Large | Connection: close | {"type":"about:blank","title":"Payload Too Large","status":413,"detail":"Max payload size is 2MB"} | El lector del socket drena bytes hasta el límite y aborta para evitar DoS por memoria. |
| 4 | Campo requerido ausente | POST /tasks | Content-Type: application/json | {"due_date":"2026-12-31T23:59:59Z"} | 422 Unprocessable Content | Content-Type: application/problem+json | {"type":"https://api.example.com/errors/validation","title":"Validation Error","status":422,"detail":"Missing required field: title"} | Sintaxis válida; semántica de dominio inválida por falta de invariante esencial. |
| 5 | Campo vacío tras trim | POST /tasks | Content-Type: application/json | {"title":" "} | 422 Unprocessable Content | Content-Type: application/problem+json | {"type":"https://api.example.com/errors/validation","title":"Validation Error","status":422,"detail":"Title cannot be empty"} | La regla de dominio prohíbe títulos de tareas compuestos solo de espacios. |
| 6 | Creación exitosa | POST /tasks | Content-Type: application/jsonIdempotency-Key: K1 | {"title":"Deploy API","due_date":"2026-12-31T23:59:59Z"} | 201 Created | Location: /tasks/101Content-Type: application/json | {"id":101,"title":"Deploy API","due_date":"2026-12-31T23:59:59Z","status":"PENDING"} | Entidad creada; se expone el URI canónico del nuevo recurso en la cabecera Location. |
| 7 | Reintento idéntico idempotente | POST /tasks | Content-Type: application/jsonIdempotency-Key: K1 | {"title":"Deploy API","due_date":"2026-12-31T23:59:59Z"} | 201 Created | Location: /tasks/101Content-Type: application/json | {"id":101,"title":"Deploy API","due_date":"2026-12-31T23:59:59Z","status":"PENDING"} | Misma clave y misma huella SHA-256: devuelve la respuesta almacenada sin duplicar filas. |
| 8 | Conflicto por clave reutilizada | POST /tasks | Content-Type: application/jsonIdempotency-Key: K1 | {"title":"Another Operation"} | 409 Conflict | Content-Type: application/problem+json | {"type":"https://api.example.com/errors/idempotency-conflict","title":"Conflict","status":409,"detail":"Idempotency-Key already used for a different payload"} | Violación de integridad: intento de asociar un payload contradictorio a una clave existente. |
2. Máquina de estados de idempotencia a nivel de base de datos#
stateDiagram-v2
[*] --> CheckRecord: Llega solicitud con Idempotency-Key (K) y Payload (P)
CheckRecord --> InsertLock: Clave K no existe en DB
InsertLock --> DomainExec: Inserción atómica exitosa (Status = IN_PROGRESS, Hash = SHA256(P))
DomainExec --> PersistResult: Lógica de negocio y creación de tarea exitosa
PersistResult --> Completed: UPDATE status = COMPLETED, response_code = 201, response_body = JSON
Completed --> ReturnOriginal: Retorna HTTP 201 al cliente
CheckRecord --> CompareHash: Clave K ya existe en DB
CompareHash --> ConflictPayload: SHA256(P) != stored_hash
ConflictPayload --> Error409: Retorna HTTP 409 Conflict (Reutilización ilegal de clave)
CompareHash --> CheckStatus: SHA256(P) == stored_hash
CheckStatus --> InProgress: stored_status == 'IN_PROGRESS'
InProgress --> Error425: Retorna HTTP 409 o 425 Too Early (Operación concurrente en curso)
CheckStatus --> ReturnCached: stored_status == 'COMPLETED'
ReturnCached --> ReturnOriginal: Retorna respuesta almacenada (código 201 + cuerpo original)
3. Respuestas a las preguntas de diagnóstico socrático (Lección 05)#
Pregunta 1: La falacia del código 200 OK con carga útil de error ("Soft 200")#
- Impacto en balanceadores y proxies: Los balanceadores de carga (HAProxy, AWS ALB, Envoy) y sistemas de entrega perimetral (Cloudflare) utilizan códigos de estado HTTP para decidir si una solicitud fue exitosa, si debe reintentarse en otro nodo del clúster o si el nodo está enfermo. Si el servidor devuelve
200 OK, el proxy asume éxito rotundo; jamás activará reintentos automáticos ni desviará el tráfico ante un fallo de backend. - Impacto en métricas y observabilidad: Sistemas de observabilidad como Prometheus, Datadog o OpenTelemetry calculan los SLI/SLO (Service Level Indicators/Objectives) y la tasa de error del sistema mediante la proporción de códigos
5xxy4xxsobre el total. Devolver200con{"success": false}camufla los fallos reales como disponibilidad perfecta (100% de éxito aparente), impidiendo que los equipos de guardia reciban alertas cuando los pagos están colapsando. - Razón arquitectónica: HTTP es un protocolo en capas. El código de estado pertenece a la capa de transporte semántica; el cuerpo pertenece a la representación del dato. Ocultar el fallo en el cuerpo viola la separación de capas y fuerza a todos los intermediarios a inspeccionar y parsear el JSON interno.
Pregunta 2: Análisis formal de idempotencia (f(f(x)) = f(x))#
- Desmontando la afirmación sobre
DELETE: El RFC 9110 define idempotencia en función del efecto sobre el estado del servidor, no sobre la identidad byte a byte de los paquetes de red. Tras la primera llamada aDELETE /tasks/42, la tarea desaparece. Tras la segunda llamada aDELETE /tasks/42, la tarea sigue sin existir. El estado residual del sistema es exactamente el mismo: f(f(estado)) = f(estado). Por tanto, devolver204 No Contentla primera vez y404 Not Founden las subsiguientes es 100% conforme con la idempotencia. GETy los contadores de visitas: Los métodos seguros (Safe) están definidos como operaciones de lectura cuya semántica principal no altera el recurso de origen. Un efecto secundario menor de infraestructura o telemetría (como un contador de accesos en una base de datos analítica o una línea en los registros de acceso) no invalida la seguridad deGET, siempre que el cliente no utilice esa lectura para disparar mutaciones de negocio deliberadas.
Pregunta 3: Ventana de colisión en comprobaciones de idempotencia (TOCTOU)#
- Escenario de carrera: Si dos hilos reciben solicitudes con la misma clave
Ken el mismo instante, ambos ejecutandb.exists(&K). Si la base de datos reportafalsepara ambos antes de que ninguno haya completado la orden, ambos hilos procederán a cobrar o crear el recurso dos veces. - Solución atómica preliminar: La inserción de la clave en la tabla
idempotency_recordscon estadoIN_PROGRESSdebe realizarse antes de ejecutar la lógica de negocio. Gracias a la restricciónPRIMARY KEY (key)en la base de datos, el primer hilo adquiere el registro y el segundo hilo es bloqueado o recibe inmediatamente un error de clave duplicada (23505), impidiendo la ejecución concurrente de la orden.
Pregunta 4: Deserialización vs Tipos Refinados de Dominio#
- Fallo del deserializador abierto: Si
titlese declara como un simpleString, Serde aceptará felizmente una cadena de 20 megabytes o caracteres no imprimibles (\0), porque sintácticamente es un string JSON válido. - Protección del Newtype Pattern: El tipo
TaskTitle(String)con constructor privadoparse(raw: &str) -> Result<TaskTitle, DomainError>actúa como una aduana matemática. Ningún código dentro de la aplicación puede construir una instancia deTaskTitlesin pasar por las validaciones de longitud máxima, sanitización UTF-8 y eliminación de espacios en blanco. Si la persistencia solo aceptaTaskTitle, es imposible por diseño del compilador insertar un título inválido en la base de datos.
Pregunta 5: La anatomía del delimitador \r\n\r\n y el ataque Slowloris#
- Función en el parser: En el protocolo HTTP/1.1 basado en flujo de bytes TCP (stream-based), el servidor no sabe cuántas cabeceras enviará el cliente hasta que encuentra la secuencia de control
0x0D 0x0A 0x0D 0x0A(\r\n\r\n). Este delimitador marca formalmente el final de los metadatos y el inicio de la carga útil del cuerpo. - Ataque Slowloris: Si un cliente malicioso abre una conexión TCP, envía cabeceras a cuentagotas (un byte cada varios segundos) y deliberadamente jamás envía el segundo
\r\n, el hilo o socket del servidor permanece suspendido esperando que el mensaje se complete. Miles de conexiones de este tipo agotan rápidamente la tabla de descriptores de archivos del servidor, provocando una denegación de servicio total con un ancho de banda insignificante. La mitigación consiste en timeouts estrictos de lectura de cabeceras en el servidor web.
Lección 06 — Actix: límites entre HTTP y dominio#
1. Implementación canónica autónoma del dominio (Rust Standard Library)#
El siguiente módulo es 100% Rust estándar, no depende de ningún framework externo y puede compilarse y validarse directamente con rustc --test:
// Contract: Domain entity representing a validated, non-empty note title.
// Invariants:
// 1. Title must not be empty after trimming leading and trailing whitespace.
// 2. Title length must not exceed 100 characters.
#[derive(Debug, PartialEq, Eq)]
pub enum TitleError {
Empty,
TooLong { max: usize, actual: usize },
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct ValidatedTitle(String);
impl ValidatedTitle {
pub fn parse(raw: &str) -> Result<Self, TitleError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(TitleError::Empty);
}
if trimmed.len() > 100 {
return Err(TitleError::TooLong {
max: 100,
actual: trimmed.len(),
});
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_or_whitespace_strings() {
assert_eq!(ValidatedTitle::parse(""), Err(TitleError::Empty));
assert_eq!(ValidatedTitle::parse(" "), Err(TitleError::Empty));
assert_eq!(ValidatedTitle::parse("\t\n"), Err(TitleError::Empty));
}
#[test]
fn accepts_valid_trimmed_titles() {
let result = ValidatedTitle::parse(" Clean Architecture in Rust ");
assert_eq!(
result,
Ok(ValidatedTitle("Clean Architecture in Rust".to_string()))
);
assert_eq!(result.unwrap().as_str(), "Clean Architecture in Rust");
}
#[test]
fn enforces_maximum_length_boundary() {
let max_valid = "x".repeat(100);
assert!(ValidatedTitle::parse(&max_valid).is_ok());
let invalid_oversized = "x".repeat(101);
assert_eq!(
ValidatedTitle::parse(&invalid_oversized),
Err(TitleError::TooLong { max: 100, actual: 101 })
);
}
}
2. Implementación de la frontera HTTP con Actix Web 4 y pruebas en memoria#
El siguiente código corresponde al archivo src/main.rs en un proyecto Cargo con actix-web = "4" y serde = { version = "1.0", features = ["derive"] }:
// Framework boundary: Actix Web 4
use actix_web::{web, App, HttpResponse, test, http::StatusCode};
use serde::{Deserialize, Serialize};
// Import domain logic
// use crate::domain::{ValidatedTitle, TitleError};
#[derive(Deserialize)]
pub struct ValidateTitleRequest {
pub title: String,
}
#[derive(Serialize)]
pub struct ValidationSuccessResponse {
pub status: &'static str,
pub sanitized_title: String,
}
#[derive(Serialize)]
pub struct ValidationErrorResponse {
pub error: &'static str,
pub detail: String,
}
pub async fn validate_title_handler(
payload: web::Json<ValidateTitleRequest>,
) -> HttpResponse {
match ValidatedTitle::parse(&payload.title) {
Ok(valid) => HttpResponse::Ok().json(ValidationSuccessResponse {
status: "title valid",
sanitized_title: valid.as_str().to_string(),
}),
Err(TitleError::Empty) => HttpResponse::BadRequest().json(ValidationErrorResponse {
error: "validation_error",
detail: "Title must not be empty or contain only whitespace".to_string(),
}),
Err(TitleError::TooLong { max, actual }) => {
HttpResponse::BadRequest().json(ValidationErrorResponse {
error: "validation_error",
detail: format!("Title length ({actual}) exceeds maximum allowed ({max})"),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn test_service_accepts_valid_title() {
let app = test::init_service(
App::new().service(
web::resource("/notes/validate").route(web::post().to(validate_title_handler))
)
).await;
let req = test::TestRequest::post()
.uri("/notes/validate")
.set_json(serde_json::json!({ "title": "Production Rust" }))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_service_rejects_empty_whitespace_title_with_400() {
let app = test::init_service(
App::new().service(
web::resource("/notes/validate").route(web::post().to(validate_title_handler))
)
).await;
let req = test::TestRequest::post()
.uri("/notes/validate")
.set_json(serde_json::json!({ "title": " " }))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[actix_web::test]
async fn test_service_rejects_malformed_json_at_extractor_level() {
let app = test::init_service(
App::new().service(
web::resource("/notes/validate").route(web::post().to(validate_title_handler))
)
).await;
// Malformed payload: field "title" is a number instead of string
let req = test::TestRequest::post()
.uri("/notes/validate")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"title": 12345}"#)
.to_request();
let resp = test::call_service(&app, req).await;
// The extractor halts the request BEFORE handler execution
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}
3. Respuestas a las preguntas de diagnóstico socrático (Lección 06)#
Pregunta 1: Mecánica de interrupción en el trait FromRequest#
- Flujo de bytes interrumpido: Si el cliente corta la conexión a la mitad del objeto JSON, el stream de bytes falla con error de lectura en el socket.
FromRequestdetecta el cierre inesperado del canal y emite unactix_web::Error, interrumpiendo el pipeline de la solicitud sin invocar el handler. - Protección contra ataques de memoria: Si no se limita el tamaño del extractor, un atacante podría enviar 500 MB en una sola petición JSON, provocando agotamiento de memoria (OOM crash). En Actix Web, esto se mitiga configurando
web::JsonConfig::default().limit(2 * 1024 * 1024)(límite de 2 MB). Si los bytes recibidos exceden el límite, Actix aborta la lectura inmediatamente y emite un413 Payload Too Large.
Pregunta 2: Aislamiento arquitectónico y tipos de framework#
- Imposibilidad de reutilización: Si una función de dominio devuelve
actix_web::HttpResponse, queda acoplada para siempre a un framework web específico. Si la empresa decide utilizar la misma lógica en una herramienta CLI, un consumidor gRPC de alto rendimiento o una función serverless en AWS Lambda, se verá forzada a emular peticiones HTTP ficticias o reescribir la lógica. - Impacto en pruebas: Las pruebas de una función acoplada a
HttpResponseobligan a compilar todo el árbol de dependencias de Actix Web, aumentando drásticamente los tiempos de compilación y haciendo imposible ejecutar pruebas unitarias puras y ultrarrápidas conrustc --test.
Pregunta 3: Alcance y límites de test::init_service#
- Capas de red omitidas:
test::init_servicecrea el grafo de servicios en la memoria interna del proceso. Omite por completo:
- El enlace del socket con el kernel (
bindylisten). - El handshake de tres vías de TCP y la negociación de ventanas de congestión.
- El cifrado TLS / HTTPS y la validación de certificados X.509.
- Los encabezados reales insertados por balanceadores perimetrales (Nginx, Cloudflare) y las reglas de inspección CORS del navegador en solicitudes
OPTIONSpreflight.
- Permisos de puerto privilegiado: Si el servidor está configurado para escuchar en el puerto
80o443pero el proceso corre sin privilegios administrativos (root),test::init_servicepasará todas las pruebas con éxito total, pero el servidor real colapsará con error de sistema operativoEACCES (Permission denied)al arrancar en producción.
Pregunta 4: Deserialización vs Invariantes de Tipo Nuevo (Newtype Pattern)#
- Riesgo del
Stringgenérico: Si las funciones de negocio y de base de datos reciben unStringabierto, cualquier programador puede pasar accidentalmente un texto arbitrario, un email sin arroba o una cadena vacía. - Protección del compilador: Con el patrón Newtype (
pub struct Email(String)), el campo interior es privado. La única forma de obtener una instancia deEmailes invocandoEmail::parse(raw). Al tipar la firma de persistencia comofn save_user(email: Email), el compilador de Rust garantiza estáticamente que es imposible compilar un código que intente guardar un email no validado.
Pregunta 5: La semántica de web::Data<T> y el estado compartido#
- Requisito de
Arc/Clone: Actix Web crea múltiples instancias de tu aplicación (App), una por cada hilo de trabajo (worker thread). Por defecto, crea tantos workers como núcleos físicos tenga la CPU. Para que todas las instancias compartan acceso a recursos globales (como un pool de conexiones a bases de datos),web::Dataenvuelve el recurso en un puntero atómico compartidoArc<T>.
Lección 07 — PostgreSQL: invariantes y transacciones#
1. Esquema DDL canónico para tasks y task_events en PostgreSQL 14+#
-- DDL para el sistema de tareas y auditoría concurrente
-- Tabla principal de tareas
CREATE TABLE tasks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
external_key TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Invariante 1: Unicidad para prevenir colisiones concurrentes (arbitraje atómico)
CONSTRAINT tasks_external_key_unique UNIQUE (external_key),
-- Invariante 2: Validación semántica de título no vacío tras trim
CONSTRAINT tasks_title_not_empty CHECK (length(trim(title)) > 0),
-- Invariante 3: Estados de ciclo de vida válidos
CONSTRAINT tasks_status_valid CHECK (status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'ARCHIVED'))
);
-- Tabla subordinada de eventos de auditoría (Integridad referencial estricta)
CREATE TABLE task_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Invariante 4: Clave foránea que impide eventos huérfanos y prohíbe borrado de tareas con historia
CONSTRAINT fk_task_events_task_id
FOREIGN KEY (task_id)
REFERENCES tasks(id)
ON DELETE RESTRICT,
-- Invariante 5: Eventos tipados
CONSTRAINT task_events_type_valid CHECK (event_type IN ('CREATED', 'STATUS_CHANGED', 'TITLE_UPDATED'))
);
-- Índice secundario para optimizar la consulta de eventos por tarea
CREATE INDEX idx_task_events_task_id ON task_events(task_id);
-- Índice compuesto para optimizar colas de trabajo pendientes ordenadas por fecha
CREATE INDEX idx_tasks_pending_created ON tasks(created_at ASC) WHERE status = 'PENDING';
2. Script de transacción atómica y simulación de reversión (Rollback)#
-- Transacción atómica: Creación de tarea y registro de auditoría subordinado
BEGIN;
-- 1. Insertar tarea parametrizada
INSERT INTO tasks (external_key, title)
VALUES ('TASK-2026-001', 'Migrate database to PostgreSQL 16')
RETURNING id; -- Supongamos que retorna ID 42
-- 2. Insertar evento de auditoría usando el ID 42
INSERT INTO task_events (task_id, event_type, payload)
VALUES (42, 'CREATED', '{"actor": "admin_service"}'::jsonb);
-- 3. Confirmación atómica definitiva
COMMIT;
Simulación deliberada de fallo referencial:#
BEGIN;
INSERT INTO tasks (external_key, title)
VALUES ('TASK-FAIL-001', 'Task that will not persist');
-- Intentar insertar evento con ID inexistente (-999):
INSERT INTO task_events (task_id, event_type)
VALUES (-999, 'CREATED');
-- PostgreSQL genera inmediatamente: ERROR 23503: insert or update on table "task_events" violates foreign key constraint "fk_task_events_task_id"
-- Si se ejecuta COMMIT tras el error, PostgreSQL emite: WARNING: there is no transaction in progress / ROLLBACK.
COMMIT;
Resultado observable: Al ejecutar SELECT * FROM tasks WHERE external_key = 'TASK-FAIL-001';, la consulta devuelve cero filas. La atomicidad garantizó que la primera inserción jamás quedara visible ni confirmada en el disco.
3. Respuestas a las preguntas de diagnóstico socrático (Lección 07)#
Pregunta 1: La ilusión del SELECT COUNT(*) bajo aislamiento READ COMMITTED#
- Por qué falla en
READ COMMITTED: En el nivelREAD COMMITTED, cada consulta individual opera sobre una nueva instantánea del estado confirmado en ese milisegundo exacto. Si dos transacciones A y B corren en paralelo, en el momento en que ambas ejecutanSELECT COUNT(*), ninguna ha confirmado aún su inserción. Por tanto, ambas leen0. Ambas proceden a ejecutarINSERT. Si no existe una restricciónUNIQUE, ambas inserciones se completan con éxito y la base de datos queda corrompida con claves duplicadas. - Comportamiento en
SERIALIZABLE: Si ambas transacciones se ejecutan en nivelSERIALIZABLE, el motor detecta una anti-dependencia de lectura y escritura (rw-antidependency cycle). Cuando la segunda transacción intenta confirmar, PostgreSQL la aborta inmediatamente con el código de error SQLSTATE40001(serialization_failure). El cliente de la aplicación tiene la obligación de capturar este error específico y ejecutar una política de reintento con retroceso exponencial (exponential backoff).
Pregunta 2: Mecánica del Árbol B-Tree ante inserciones concurrentes#
- *Bloqueos físicos de página (Page Latches): Al descender por el árbol B-Tree para insertar una nueva clave, PostgreSQL adquiere un cerrojo exclusivo a nivel de página física de memoria (exclusive buffer latch*).
- Por qué el segundo hilo espera en lugar de fallar de inmediato: Cuando la transacción B intenta insertar una clave que ya fue escrita por la transacción A (pero que aún no ha hecho
COMMIT), PostgreSQL no sabe si la transacción A completará con éxito o si fallará y haráROLLBACK. Por ende, el hilo B se suspende esperando sobre el identificador de transacción (XID) de A. Si A ejecutaCOMMIT, el hilo B se despierta y emite el error23505 (unique_violation). Si A ejecutaROLLBACK, la clave queda libre y la inserción de B procede con éxito.
Pregunta 3: Inyección SQL en nombres de identificadores (Tablas y Columnas)#
- Por qué no funcionan los parámetros
$1en identificadores: Los parámetros tipados del protocolo binario de PostgreSQL están diseñados exclusivamente para sustituir literales de datos en el plan de ejecución compilado. El analizador léxico y el parser sintáctico necesitan conocer los nombres exactos de tablas y columnas para compilar el Árbol de Sintaxis Abstracta (AST), verificar los permisos de usuario y construir los nodos del optimizador. EscribirSELECT * FROM tasks ORDER BY $1intentará ordenar por el valor literal del string, no por la columna referenciada. - Prevención segura en identificadores dinámicos: Si una API permite ordenar dinámicamente, debe utilizarse una *lista blanca estricta (whitelisting)* basada en un
enumde Rust (por ejemplo,enum SortColumn { CreatedAt, Title }) o la funciónformat_ident()en PostgreSQL, garantizando que el usuario jamás pueda inyectar identificadores arbitrarios.
Pregunta 4: Compensación de rendimiento de los índices B-Tree (Write Amplification)#
- Costo en 50,000 escrituras por segundo: En una tabla con 8 índices secundarios, cada sentencia
INSERTse convierte en 9 operaciones de escritura físicas: escribir la tupla en la tabla (heap page) y descender e insertar la nueva entrada en los 8 árboles B-Tree independientes. Esto satura el buffer pool de la memoria RAM, provoca contención de cerrojos y dispara exponencialmente la tasa de generación de bytes en el log de transacciones (WAL). - *Divisiones de página (Page Splits) y claves aleatorias:* Un nodo de B-Tree es una página de 8 KB que almacena claves ordenadas. Si la página está llena y se inserta una clave intermedia (típico de identificadores aleatorios como UUID v4), el motor debe dividir la página en dos páginas nuevas, mover el 50% de las claves y actualizar los punteros de los nodos superiores. Esto genera fragmentación masiva. El uso de claves secuenciales o cronológicamente ordenadas (como enteros autoincrementales o UUID v7) hace que las inserciones siempre ocurran al final del árbol en la página más a la derecha, evitando por completo las divisiones de página intermedias.
Pregunta 5: Integridad referencial (FOREIGN KEY) vs Eliminaciones en cascada#
- Peligro regulatorio de
ON DELETE CASCADE: Si un usuario solicita borrar su perfil en una plataforma bancaria o de comercio electrónico y la relación entre usuarios y transacciones monetarias tiene configuradoON DELETE CASCADE, la eliminación del usuario borrará automáticamente todo el historial de depósitos, pagos y transferencias contables. Esto constituye una violación legal grave de las normativas de auditoría fiscal y financiera (SOX, PCI-DSS, regulaciones antifraude). La política correcta en tablas transaccionales esON DELETE RESTRICTo la implementación de borrado lógico (soft delete) con archivado inmutable.
Lección 08 — Async y concurrencia#
1. Trazabilidad de tiempos: Solicitudes A y B en un worker unihilo#
Analicemos la línea de tiempo milisegundo a milisegundo de dos solicitudes concurrentes, Solicitud A y Solicitud B, gestionadas por un planificador asíncrono cooperativo con un único hilo de trabajo (single-worker thread):
Línea de tiempo del Worker Thread (CPU vs Espera de I/O):
[t=0ms] Solicitud A inicia: CPU ejecuta deserialización JSON y validación de contrato.
[t=1ms] Solicitud B llega al socket del servidor y se coloca en la cola de tareas listas (Run Queue).
[t=2ms] Solicitud A emite consulta a PostgreSQL por el socket y encuentra un punto .await.
Solicitud A retorna Poll::Pending y cede cooperativamente el control del Worker Thread.
[t=2ms] Worker Thread toma la Solicitud B de la Run Queue.
[t=2ms a t=4ms] CPU ejecuta deserialización JSON y validación de Solicitud B.
[t=4ms] Solicitud B emite consulta a PostgreSQL y encuentra su punto .await.
Solicitud B retorna Poll::Pending y cede el Worker Thread.
[t=4ms a t=10ms] El Worker Thread queda desocupado (Idle / listo para aceptar nuevas tareas).
Ambas solicitudes A y B están esperando pasivamente los paquetes de red de PostgreSQL.
[t=10ms] La tarjeta de red recibe los bytes de respuesta de PostgreSQL para la Solicitud A.
El reactor de E/S invoca waker.wake() para A. Solicitud A entra a la Run Queue.
[t=10ms a t=12ms] Worker Thread despierta a A mediante poll. CPU procesa la respuesta HTTP 201 de A.
[t=12ms] Solicitud A completa y se envía al cliente.
[t=12ms] (Supongamos que PostgreSQL respondió a B en t=11ms).
[t=12ms a t=14ms] Worker Thread despierta a B. CPU procesa la respuesta HTTP de B.
[t=14ms] Solicitud B completa definitivamente.
Conclusión fundamental: Aunque existe un solo hilo físico de CPU, ambas solicitudes se completaron en 14 ms en lugar de los 24 ms que habrían requerido en un modelo síncrono bloqueante secuencial. La concurrencia maximiza el uso del hilo aprovechando los tiempos muertos de espera de red.
2. Implementación de prueba autónoma de máquina de estados con sondeos (poll)#
El siguiente código es 100% Rust estándar, modela un futuro manual de cuenta regresiva y demuestra cómo el método poll interactúa con el Waker. Compila y verifica con rustc --test:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct CountdownFuture {
remaining_steps: u32,
}
impl CountdownFuture {
pub fn new(steps: u32) -> Self {
Self { remaining_steps: steps }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining_steps == 0 {
Poll::Ready("countdown completed")
} else {
self.remaining_steps -= 1;
// Notify the executor that this task is ready to make further progress
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::task::{Wake, Waker};
struct TestWaker;
impl Wake for TestWaker {
fn wake(self: Arc<Self>) {}
}
#[test]
fn simulates_executor_polling_cycle() {
let waker = Waker::from(Arc::new(TestWaker));
let mut cx = Context::from_waker(&waker);
let mut future = CountdownFuture::new(2);
let mut pinned = Pin::new(&mut future);
// Step 1: Decrements to 1, yields control
assert_eq!(pinned.as_mut().poll(&mut cx), Poll::Pending);
// Step 2: Decrements to 0, yields control
assert_eq!(pinned.as_mut().poll(&mut cx), Poll::Pending);
// Step 3: Threshold reached, completes
assert_eq!(
pinned.as_mut().poll(&mut cx),
Poll::Ready("countdown completed")
);
}
}
3. Patrón de refactorización de cerrojos: Eliminación de MutexGuard vivo en .await#
// Crate-backed conceptual pattern: Scoping synchronization in memory
use std::sync::Mutex;
use std::collections::HashMap;
pub struct ServiceState {
pub cache: Mutex<HashMap<String, String>>,
}
pub async fn handle_request(state: &ServiceState, key: String) -> Result<String, String> {
// 1. FAST CRITICAL SECTION: Read under lock and drop guard immediately
let cached_value = {
let guard = state.cache.lock().unwrap();
guard.get(&key).cloned()
}; // <--- MutexGuard is dropped here! Lock is completely released!
if let Some(val) = cached_value {
return Ok(val);
}
// 2. ASYNC I/O: Remote database call holding ZERO locks
let fresh_data = fetch_from_remote_database(&key).await?;
// 3. FAST CRITICAL SECTION: Write under lock and drop guard immediately
{
let mut guard = state.cache.lock().unwrap();
guard.insert(key, fresh_data.clone());
} // <--- MutexGuard is dropped here! Lock is completely released!
Ok(fresh_data)
}
async fn fetch_from_remote_database(key: &str) -> Result<String, String> {
// Simulates network query
Ok(format!("data_for_{key}"))
}
4. Respuestas a las preguntas de diagnóstico socrático (Lección 08)#
Pregunta 1: La anatomía del bucle cerrado de CPU#
- Por qué el health check falla con timeout: Si los 4 worker threads de Tokio quedan atrapados en bucles recursivos puros de cálculo de Fibonacci, ningún hilo alcanza jamás un punto de suspensión
.await. Como la multitarea en Rust es cooperativa, el planificador no puede arrebatarles la CPU. Cuando llega la quinta solicitud paraGET /health, se encola en la Run Queue esperando un worker libre. Tras expirar el tiempo de espera del cliente o del proxy inverso (ej. 5 segundos), la conexión se cierra con timeout, a pesar de que el código del health check es trivial y tomaría 0.1 ms en ejecutarse. - Solución con
spawn_blocking: Delegar la función recursiva atokio::task::spawn_blockingtraslada la computación a un pool de hilos independiente administrado por el sistema operativo. Los hilos de trabajo asíncronos quedan completamente libres para procesar las solicitudes web de E/S.
Pregunta 2: El costo en memoria del tamaño del enum del futuro#
- Tamaño del enum del futuro: Mientras el futuro esté suspendido esperando a
some_async_operation(), el enum generado por el compilador debe mantener vivo en memoria el buffer de 1 MB (large_buffer). Por tanto, la estructura del futuro ocupará al menos 1 MB de memoria RAM. - Consumo bajo 100,000 conexiones concurrentes: Si existen 100,000 tareas suspendidas simultáneamente en ese punto, la aplicación consumirá más de 100 Gigabytes de memoria RAM, provocando que el sistema operativo mate el proceso por falta de memoria (OOM Killer). Delimitar la variable dentro de un bloque léxico
{ ... }que finalice antes del.awaitpermite que el compilador descarte el buffer antes de la suspensión, reduciendo el tamaño del futuro a unos pocos bytes.
Pregunta 3: El misterio de la cancelación asíncrona (Drop) y tokio::select!#
- Comportamiento ante timeout: Si la transferencia bancaria toma 150 ms y el timeout está fijado en 100 ms, la rama del timeout gana la carrera en
tokio::select!. En ese milisegundo exacto, la macroselect!destruye (drops) el futuro desend_money_transfer. - Efecto en el banco remoto: Los paquetes de red con la orden de transferencia ya salieron de la tarjeta de red del servidor y viajaron por Internet hacia el banco remoto. El banco remoto procesa la transferencia y descuenta el dinero con total normalidad. El servidor local asume erróneamente que "la operación no ocurrió", dejando al usuario con dinero debitado pero sin confirmación en pantalla. Por esta razón, las operaciones financieras requieren obligatoriamente una arquitectura basada en claves de idempotencia durables.
Pregunta 4: Por qué std::sync::MutexGuard no es Send#
- Comportamiento indefinido en el SO: Los estándares POSIX (
pthread_mutex_unlock) y las primitivas de sincronización de Windows estipulan que un mutex del sistema operativo debe ser desbloqueado obligatoriamente por el mismo hilo nativo que lo bloqueó. Si un hilo diferente intenta liberar el mutex, el comportamiento es formalmente indefinido (Undefined Behavior), pudiendo causar corrupción de memoria o fallos catastróficos en el kernel. - Protección mediante el sistema de tipos: Como Tokio puede reanudar una tarea suspendida en un hilo de trabajo del sistema operativo diferente al original, el compilador de Rust marca a
std::sync::MutexGuardcomo!Send. Si intentas cruzar un.awaitreteniendo el guard, el compilador genera el errorE0277, impidiendo que el código defectuoso llegue a compilarse.
Pregunta 5: Multitarea cooperativa vs recolección de basura (Garbage Collection)#
- Ventajas del modelo de cero costo: Al no tener recolector de basura ni puntos de interrupción forzados inyectados por el runtime, Rust ofrece una latencia predecible al microsegundo, un consumo de memoria mínimo y un control total sobre el hardware, ideal para sistemas de alta frecuencia y servicios de misión crítica.
- Responsabilidad del ingeniero: El desarrollador asume la responsabilidad estricta de asegurar que ninguna sección de código entre llamadas sucesivas a
.awaitrealice cálculos prolongados de CPU (más de unos pocos microsegundos). Cualquier cálculo intensivo debe dividirse o delegarse explícitamente a hilos de cómputo dedicados.
Comprobación transversal del backend#
Al evaluar una arquitectura de backend en Rust, verifica siempre qué capa del sistema proporciona cada garantía:
flowchart TD
subgraph Client["Cliente / Navegador"]
C1["Envía Idempotency-Key"]
C2["Maneja reintentos con Backoff"]
end
subgraph Transport["Frontera HTTP (Actix Web)"]
T1["Valida Sintaxis JSON (FromRequest)"]
T2["Mapea Errores a Códigos HTTP (RFC 9110)"]
T3["Oculta Trazas Internas (RFC 9457)"]
end
subgraph Domain["Núcleo de Dominio (Rust Puro)"]
D1["Invariantes de Negocio (Newtypes)"]
D2["Funciones Puras Deterministas"]
D3["Libre de Dependencias de Red"]
end
subgraph Storage["Persistencia (PostgreSQL)"]
S1["Integridad Física (UNIQUE, CHECK, FK)"]
S2["Arbitraje de Carreras Concurrentes"]
S3["Atomicidad Transaccional (ACID / WAL)"]
end
Client --> Transport
Transport --> Domain
Domain --> Storage
- La red es inherentemente falible: Una clave de idempotencia mitiga las pérdidas de paquetes, pero requiere coordinación durable en base de datos.
- El compilador protege la memoria local: Rust previene condiciones de carrera de datos en memoria RAM (
Send/Sync), pero las condiciones de carrera relacionales entre procesos (TOCTOU) solo pueden ser resueltas por las restricciones del motor de base de datos (UNIQUE). - Separación estricta de responsabilidades: La frontera HTTP habla códigos y cabeceras; el dominio habla entidades e invariantes; la base de datos asegura la durabilidad física en disco.
Navegación y recursos del módulo#
- Regresar a 05 · Contratos HTTP y ciclo de una solicitud
- Regresar a 06 · Actix: límites entre HTTP y dominio
- Regresar a 07 · PostgreSQL: invariantes y transacciones
- Regresar a 08 · Async y concurrencia
- Consultar el glosario técnico
- Avanzar a 09 · HTML, CSS y accesibilidad