Un diagrama enseña qué servicios hay. Lo que separa un pipeline de un script con suerte son las decisiones de debajo. Estas son las de las fases ya cerradas.
Fase 1 · Arquitectura
Elegir la fuente que no te bloquea
Se descartó ESIOS a propósito. Ofrece los mismos datos de Red Eléctrica, pero exige un token que se solicita por correo y tarda días. apidatos.ree.es es pública, sin autenticación y responde al instante. Elegir la fuente que no te bloquea también es arquitectura.
Step Functions en vez de Airflow. Amazon MWAA cuesta del orden de 300 USD al mes aunque no se ejecute un solo DAG, porque mantiene el entorno levantado. Con tres pasos encadenados no se justifica: Step Functions se paga por transición.
Parquet particionado por fecha. Athena factura por terabyte escaneado. Particionar convierte un escaneo completo de la tabla en leer solo los días que la consulta pregunta.
Fase 2 · Repositorio
Un repositorio público solo se cruza en un sentido
El .gitignore va antes del primer commit. Si subes un fichero con credenciales y lo borras al día siguiente, no lo has borrado: sigue en el commit donde entró, y los buscadores ya lo han visto. Sacarlo de verdad obliga a reescribir el historial completo.
El caso clásico en infraestructura como código es el *.tfstate. Terraform guarda ahí el estado real de la infraestructura y puede incluir valores sensibles en texto plano. Es la filtración más habitual en repos de IaC.
Correo privado en los commits. GitHub facilita una dirección @users.noreply. Sin ella, el correo real queda escrito en cada commit de un repositorio público, y hay bots rastreando exactamente eso.
Fase 3 · Infraestructura
El estado nunca en local, y el presupuesto antes que nada
Estado remoto en S3 con bloqueo nativo. Terraform 1.10 introdujo use_lockfile, que implementa el bloqueo sobre el propio S3. Ya no hace falta una tabla de DynamoDB solo para eso: un recurso menos que crear, pagar y recordar destruir.
El huevo y la gallina del bucket de estado. El bucket que guarda el estado no puede guardar su propio estado. Se crea una vez desde un módulo bootstrap con estado local, lleva prevent_destroy y sobrevive al teardown final.
IAM sin políticas gestionadas de AWS. Las políticas gestionadas son cómodas y casi siempre conceden de más. Aquí cada rol lleva una política propia acotada a los ARN concretos con los que trabaja.
El presupuesto con alerta se crea antes que el primer recurso. Una alerta de coste que se configura después de la factura no sirve de nada.
Fase 4 · Ingesta
Reintentar ante un error 400, que es justo lo que no hay que hacer
La API de Red Eléctrica devuelve 400 Error Interno de forma intermitente para peticiones perfectamente formadas, y a la siguiente responde 200 con los mismos parámetros. Un 400 normalmente significa que la petición está mal y reintentar es un antipatrón; aquí, no reintentar produce un pipeline que funciona unos días sí y otros no.
Espera exponencial con jitter. 1,5s, 3s, 6s, 12s, 24s, más un aleatorio de hasta un segundo. El jitter no es estético: sin él, varias ejecuciones que fallan a la vez reintentan sincronizadas y golpean el origen en el mismo instante.
Cero dependencias externas. requests no viene en el runtime de Lambda y empaquetarlo obliga a montar un paso de build. urllib de la biblioteca estándar hace lo mismo para este caso.
Clave determinista en S3. Reprocesar un día sobrescribe su fichero en vez de duplicarlo.
Fase 5 · Transformación
El bug silencioso era el arreglo
Open-Meteo entrega las horas sin desfase y, en otro campo, el desfase que ha aplicado. Consultada en septiembre, para el 1 de marzo declara utc_offset_seconds: 7200, cuando Madrid ese día está en UTC+1. La primera lectura fue que el campo estaba mal y había que ignorarlo, resolviendo la hora local con las reglas de Europe/Madrid.
Esa lectura era falsa. La API es coherente consigo misma: construye la serie aplicando el desfase que declara, así que etiqueta − utc_offset_seconds da el UTC correcto siempre. Reinterpretar la etiqueta como hora de Madrid era lo que desplazaba el dato una hora: 21 horas mal de 24 el 1 de marzo, 19 de 24 el 15 de enero, y ninguna en julio, cuando Madrid sí está en UTC+2.
La solución no fue corregir el desfase, fue no tenerlo. La ingesta pide los datos en timezone=UTC y la marca ya es un instante. El parseo rechaza en alto cualquier payload que no venga en UTC: el problema nunca fue equivocarse de huso, fue que equivocarse no producía ningún síntoma.
Y 28 tests en verde no lo vieron. Comparaban la implementación en PySpark contra la implementación de referencia en Python, y las dos aplicaban la misma regla. Si la regla es falsa, las dos fallan igual. Ahora hay una comprobación que contrasta la salida contra el JSON crudo de la fuente, y se ha verificado que falla si se reintroduce la conversión anterior.
Un día no siempre tiene 24 horas. El domingo que adelanta tiene 23 y el que atrasa tiene 25. Validar contra 24 fijo marca como incompletos dos días al año que están perfectos.
Proyección de particiones en vez de crawler. Un crawler de Glue programado factura DPU cada vez que se ejecuta, lo mire alguien o no. La proyección deduce las particiones del patrón de la ruta: coste cero y nada que mantener.