| name | data-pipeline-backpressure-checkpointing |
| description | Patrón de pipeline de datos con backpressure y checkpointing: procesar flujos de datos sin saturar memoria ni API. Incluye rate limiting, batch processing, tolerancia a fallos y recuperación desde último checkpoint. Basado en Node.js Streams API y patrones de streaming systems. |
| version | 1.0.0 |
| author | Mastermind |
| license | MIT |
| metadata | {"hermes":{"tags":["data","pipeline","backpressure","checkpointing","streaming","rate-limiting","batch"],"related_skills":["fetch-paralelo-fallos-parciales","cache-multicapa-memoria-disco","conversion-unidades-api-externa"]}} |
Pipeline de Datos con Backpressure y Checkpointing
Patrón para procesar grandes volúmenes de datos de forma fiable: sin saturar la memoria, respetando límites de API, y recuperándose de fallos sin perder datos.
¿Qué es y por qué importa?
Cuando procesas datos de APIs externas (como ESIOS, GTFS, OSM), el flujo normal es:
API → Datos en memoria → Transformar → Guardar
Pero esto falla cuando:
- La API devuelve 10.000+ registros (OOM)
- La API tiene rate limiting (429 Too Many Requests)
- El proceso se cae a mitad (pierdes todo lo procesado)
- La red falla (no hay recuperación)
Backpressure es el mecanismo que dice "espera, estoy procesando todavía" cuando el consumidor va más lento que el productor.
Checkpointing es guardar el progreso para poder reanudar desde donde se quedó si hay un fallo.
Datos reales: Node.js Streams API (documentación oficial) usa backpressure nativamente. Apache Kafka usa exactamente-once semantics con checkpointing. Estos patrones son fundamentales en sistemas de streaming.
Arquitectura del pipeline
┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐
│ Source │──▶│ Transformer │──▶│ Sink/Store │──▶│ Log/CKPT │
│ (API) │ │ (validate, │ │ (DB/file) │ │ (progress)│
│ stream │ │ transform) │ │ │ │ │
└──────────┘ └──────────────┘ └─────────────┘ └──────────┘
│ │ │ │
▼ ▼ ▼ ▼
Rate limiter Schema check Batch commit Atomic write
+ jitter + sanitization + dedup + index update
Implementación: Pipeline Class
class DataPipeline {
constructor(options = {}) {
this.maxConcurrency = options.maxConcurrency || 5;
this.batchSize = options.batchSize || 100;
this.rateLimitMs = options.rateLimitMs || 1000;
this.rateLimitJitterMs = options.rateLimitJitterMs || 500;
this.checkpointPath = options.checkpointPath || './.pipeline-checkpoint.json';
this.maxRetries = options.maxRetries || 3;
this.retryBaseDelayMs = options.retryBaseDelayMs || 1000;
this.processedIds = new Set();
this.pending = [];
this.running = 0;
this.checkpoint = this._loadCheckpoint();
}
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
_rateLimit() {
const jitter = Math.random() * this.rateLimitJitterMs;
return this._sleep(this.rateLimitMs + jitter);
}
_loadCheckpoint() {
try {
const fs = require('fs');
if (fs.existsSync(this.checkpointPath)) {
const data = JSON.parse(fs.readFileSync(this.checkpointPath, 'utf8'));
console.log(`Checkpoint cargado: ${data.processedCount} registros`);
return data;
}
} catch (e) {
console.warn('No se pudo cargar checkpoint, empezando desde cero');
}
return { processedIds: [], processedCount: 0, lastRun: null };
}
_saveCheckpoint(processedIds) {
this.checkpoint = {
processedIds: Array.from(processedIds),
processedCount: processedIds.size,
lastRun: new Date().toISOString()
};
try {
const fs = require('fs');
const tmpPath = this.checkpointPath + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify(this.checkpoint, null, 2));
fs.renameSync(tmpPath, this.checkpointPath);
} catch (e) {
console.error('Error guardando checkpoint:', e.message);
}
}
async _withRetry(fn, context) {
let lastError;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await fn.call(context);
} catch (error) {
lastError = error;
if (error.status && error.status >= 400 && error.status < 500) {
console.error(`Error no retryable (${error.status}):`, error.message);
throw error;
}
const delay = this.retryBaseDelayMs * Math.pow(2, attempt - 1);
console.warn(`Intento ${attempt}/${this.maxRetries} falló, reintentando en ${delay}ms`);
await this._sleep(delay);
}
}
throw lastError;
}
async _processItem(item) {
this.running++;
try {
const transformed = await this.transform(item);
await this.save(transformed);
this.processedIds.add(item.id);
return transformed;
} finally {
this.running--;
this._tryDrain();
}
}
async run(source, transform, save) {
this.transform = transform;
this.save = save;
let allItems = [];
let offset = 0;
const limit = this.batchSize;
do {
const chunk = await this._withRetry(
async () => source(offset, limit)
);
if (!chunk || chunk.length === 0) break;
const newItems = chunk.filter(item =>
!this.checkpoint.processedIds.includes(item.id)
);
console.log(`Nuevos datos: ${newItems.length} (ya procesados: ${chunk.length - newItems.length})`);
for (const item of newItems) {
while (this.running >= this.maxConcurrency) {
await this._sleep(50);
}
await this._rateLimit();
this._processItem(item);
}
await this._drain();
this._saveCheckpoint(this.processedIds);
offset += chunk.length;
} while (allItems.length > 0);
console.log(`Pipeline completado: ${this.processedIds.size} registros procesados`);
return this.processedIds.size;
}
_drain() {
return new Promise((resolve) => {
const check = () => {
if (this.running === 0) resolve();
else setTimeout(check, 50);
};
check();
});
}
_tryDrain() {
}
}
module.exports = DataPipeline;
Uso: Pipeline para datos ESIOS
const DataPipeline = require('./DataPipeline');
const pipeline = new DataPipeline({
maxConcurrency: 3,
batchSize: 50,
rateLimitMs: 1000,
rateLimitJitterMs: 500,
checkpointPath: './.checkpoint-esios.json',
maxRetries: 3,
retryBaseDelayMs: 1000,
});
async function esiosSource(offset, limit) {
const response = await fetch(
`https://api.esios.ree.es/indicators/XXX/values?offset=${offset}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${process.env.ESIOS_TOKEN}` } }
);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return response.json();
}
function esiosTransform(item) {
if (!item.value || !item.timestamp) {
throw new Error(`Dato inválido: ${JSON.stringify(item)}`);
}
const value = parseFloat(item.value);
if (isNaN(value)) {
console.warn(`Valor no numérico ignorado: ${item.value}`);
return null;
}
const converted = convertEsiosValue(item.indicator_id, value);
return {
id: item.id,
timestamp: item.timestamp,
value: converted,
unit: item.unit,
processedAt: new Date().toISOString(),
};
}
async function esiosSave(record) {
await db.run(
'INSERT OR IGNORE INTO esios_data (id, timestamp, value, unit, processed_at) VALUES (?, ?, ?, ?, ?)',
[record.id, record.timestamp, record.value, record.unit, record.processedAt]
);
}
pipeline.run(esiosSource, esiosTransform, esiosSave)
.then(count => console.log(`Procesados ${count} registros`))
.catch(err => console.error('Pipeline falló:', err));
Patrones avanzados
1. Pipeline con Web Workers (frontend)
const worker = new Worker('./pipeline-worker.js');
worker.postMessage({
type: 'start',
sourceUrl: '/api/esios/data',
batchSize: 100,
});
worker.onmessage = (e) => {
switch (e.data.type) {
case 'progress':
updateProgressBar(e.data.progress);
break;
case 'checkpoint':
console.log(`Checkpoint: ${e.data.processed} registros`);
break;
case 'error':
showError(e.data.message);
break;
case 'complete':
showComplete(e.data.total);
break;
}
};
2. Pipeline con Node.js Streams (nativo)
const { Transform, PassThrough } = require('stream');
const { pipeline: pipelineStream } = require('stream/promises');
const sourceStream = createApiStream('https://api.esios.ree.es/...');
const transformStream = new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
try {
const validated = validateSchema(chunk);
const converted = convertUnits(validated);
callback(null, converted);
} catch (err) {
callback(err);
}
}
});
const sinkStream = createDbStream();
await pipelineStream(sourceStream, transformStream, sinkStream);
3. Checkpoint con atomicidad
function atomicCheckpoint(data, path) {
const tmp = path + '.tmp';
const fd = fs.openSync(tmp, 'w');
fs.writeSync(fd, JSON.stringify(data));
fs.closeSync(fd);
fs.renameSync(tmp, path);
}
Pitfalls
| Pitfall | Solución |
|---|
| Checkpoint corrupto tras crash | Usar write-then-rename (atomic) en lugar de write-in-place |
| Rate limit en ráfaga | Siempre añadir jitter al delay para evitar sincronización entre múltiples consumers |
| Memory leak con Set gigante | Si procesas millones de IDs, usar un Bloom filter o un SQLite indexado en lugar de un Set |
| Duplicados tras recovery | Usar INSERT OR IGNORE en DB o verificar antes de insertar |
| Backpressure no funciona con async/await | Controlar manualmente la concurrencia con un semaphore (como en el ejemplo) |
| Checkpoint no se guarda | Guardar checkpoint después de cada batch, no solo al final |
Cuándo usar / cuándo NO usar
| Usar ✅ | No usar ❌ |
|---|
| APIs con rate limiting (429s frecuentes) | Procesamiento de < 100 registros |
| Datos que pueden tardar horas en procesarse | Procesamiento en tiempo real (< 100ms latency) |
| Procesos que pueden fallar a mitad | Entornos sin filesystem (serverless puro) |
| Múltiples consumidores del mismo dato | Un solo consumo puntual |
Referencias