Cuando el sistema nuevo tiene que hablarle al sistema viejo en su idioma
- Mauricio ECR
- Arquitectura
- 31 May, 2026
Imagina que llevas meses construyendo un sistema moderno sobre PostgreSQL, desplegado en contenedores sobre una infraestructura en la nube. Los datos están bien estructurados, las relaciones son claras, las consultas son rápidas. Un día descubres que el sistema con el que tienes que integrarte no sabe lo que es una API. Su protocolo de integración es un archivo CSV que aparece en una carpeta a las 2 de la mañana. Si el archivo llega, el sistema funciona. Si no llega, el negocio se detiene.
No es un escenario hipotético. Es la realidad operativa de una cantidad enorme de empresas que modernizaron una parte de su infraestructura sin poder modernizar todo al mismo tiempo. El sistema legado sigue ahí, inamovible, consumiendo archivos como siempre lo hizo, y el sistema nuevo tiene que aprender a hablarle en ese idioma.
La pregunta que surge de inmediato parece simple: ¿cómo tomas los datos que viven en tu base de datos y los conviertes en un archivo CSV que llega a S3 de forma confiable? Pero debajo de esa pregunta hay varias más que definen la complejidad real del problema, y entenderlas bien es lo que separa una integración sólida de una que falla en silencio cuando más importa.
El problema que se esconde detrás del problema
La primera reacción cuando te enfrentas a este reto suele ser optimista. Tienes los datos en la base de datos, sabes el formato que necesita el sistema legado, y S3 es solo una carpeta en la nube. La solución parece obvia: consultas los registros, los transformas y los subes. Tres pasos. Una tarde de trabajo.
Esa imagen se complica en cuanto empiezas a hacerte las preguntas correctas.
¿Cómo sabes qué registros procesar en cada ciclo? El proceso corre periódicamente y cada ejecución debe tomar exactamente los registros que le corresponden, sin repetir los del ciclo anterior ni perderse ninguno del actual. Necesitas algún mecanismo para rastrear qué ya se procesó y qué no. Luego está la pregunta de dónde viven realmente los datos que necesitas exportar: raramente en una sola tabla. Generalmente hay información distribuida en varias tablas relacionadas que hay que unir, transformar y formatear según la estructura exacta que el sistema legado espera, y esa transformación tiene un costo que no siempre es trivial. La más incómoda llega al final: ¿qué pasa si algo sale mal a mitad del proceso? ¿Los registros quedan marcados como procesados aunque el archivo nunca haya llegado a S3? ¿O el archivo llega pero la base de datos queda inconsistente porque el proceso murió antes de confirmar? ¿Y si el volumen crece y un solo proceso ya no alcanza a terminar a tiempo?
Cada una de esas preguntas revela una decisión de arquitectura que no puedes ignorar. Y hay una más que puede o no aplicar a tu caso: ¿el sistema legado espera un solo archivo por ciclo o varios, cada uno con un formato distinto? Si tu integración es simple y homogénea, la respuesta es uno. Pero si los datos que exportas representan entidades distintas con estructuras distintas, el sistema legado puede esperar un archivo por cada tipo. Vale la pena saberlo desde el principio, porque esa variable aparece en ambos enfoques y cambia algunas decisiones de implementación.
El flujo a grandes rasgos
Antes de entrar en los detalles, vale la pena tener clara la imagen completa de lo que estamos construyendo, porque esa imagen es la que le da sentido a cada decisión que viene después.
El sistema legado espera sus archivos a una hora determinada. No los solicita, no los consulta, no tiene una API a la que llamar: simplemente los recoge de una ubicación conocida en el momento que tiene programado. Eso impone una restricción que define toda la arquitectura: el proceso de exportación tiene que ejecutarse de forma programada, en un horario fijo, con suficiente anticipación para que los archivos estén listos cuando el sistema legado los busque.
El flujo general es el siguiente: a la hora programada se activa un proceso que extrae los registros pendientes de la base de datos, los transforma siguiendo el formato exacto que el sistema legado espera, genera los archivos CSV correspondientes y los deposita en S3. A partir de ahí el sistema legado toma esos archivos y los incorpora a su propio flujo de procesamiento.
Hora programada
↓
Proceso extrae registros pendientes de PostgreSQL
↓
Transforma y formatea según plantilla del sistema legado
↓
Deposita archivos CSV en S3
↓
Sistema legado recoge los archivos y los procesa
Simple en apariencia. Pero garantizar que ese flujo sea confiable, que cada registro se procese exactamente una vez y que los archivos lleguen siempre en un estado consistente, es donde está el verdadero reto.
Dos caminos, un mismo destino
Para resolver este problema existen dos enfoques que cubren la mayoría de los escenarios reales. Cuál usar depende principalmente del volumen de datos, de si el procesamiento por registro requiere lógica fuera de la base de datos y de qué tan detallada necesitas que sea la trazabilidad cuando algo falla.
El primero delega casi todo el trabajo a la base de datos. PostgreSQL tiene una instrucción llamada COPY TO STDOUT que puede leer registros, aplicar transformaciones y devolver el resultado en formato CSV directamente al proceso que hizo la consulta, todo en una sola operación. Es elegante, directo y eficiente cuando el dataset es acotado y predecible, y cuando un solo proceso tiene el tiempo y la memoria suficientes para manejarlo completo.
El segundo reconoce que hay situaciones donde esa elegancia no alcanza. Cuando el volumen es grande, cuando cada registro requiere procesamiento costoso fuera de la base de datos, o cuando un fallo no puede tirar todo el trabajo sino solo la parte afectada, necesitas un patrón más sofisticado: múltiples procesos trabajando en paralelo, cada uno tomando lotes pequeños, coordinándose a través de la misma base de datos y contribuyendo su parte a un archivo final que S3 ensambla mediante su mecanismo de carga en partes. Este mecanismo, sin embargo, exige que cada fragmento que se sube pese al menos 5MB, lo que convierte ese umbral en una condición técnica que determina si este enfoque es viable o no.
Ninguno de los dos es mejor en abstracto. La elección depende de tu escenario concreto, y para que esa decisión sea más fácil de tomar, aquí está el comparativo completo:
| Característica | Enfoque A: COPY TO STDOUT | Enfoque B: Procesos en paralelo |
|---|---|---|
| Volumen de datos | Acotado y predecible | Grande o impredecible |
| Tamaño del CSV por ciclo | Menor a 5MB | Mayor a 5MB |
| Procesamiento por registro | Vive en SQL | Requiere lógica fuera de la DB |
| Número de procesos | Un solo proceso | Múltiples procesos en paralelo |
| Trazabilidad por registro | No requerida | Requerida |
| Errores parciales | No se toleran | Se toleran y gestionan |
| Reintentos individuales | Reintentos individuales | No necesarios |
| Tipos de archivo | Pocos y fijos | Dinámicos según los datos |
| Escalabilidad futura | No se contempla | Se contempla o es necesaria |
Con la decisión tomada, entremos en los detalles de cada uno.
Enfoque A: dejar que la base de datos haga el trabajo pesado
Hay algo intuitivamente correcto en la idea de que quien tiene los datos es quien mejor puede procesarlos. PostgreSQL no es solo un lugar donde guardar información: es un motor de procesamiento con capacidades que muchos equipos subutilizan, y COPY TO STDOUT es una de esas capacidades.
Cuando ejecutas COPY TO STDOUT con una consulta SQL, PostgreSQL lee los registros, aplica las transformaciones que definas, los marca como procesados dentro de la misma operación y devuelve el resultado en formato CSV directamente al proceso a través de la conexión. El proceso que recibe ese stream solo tiene que subirlo a S3. El trabajo pesado de transformación y formateo ocurre dentro de la base de datos, no en el proceso que la llama.
Al recibir el resultado en un único stream, el archivo completo se sube a S3 en un solo envío sin restricciones de tamaño mínimo. Este enfoque es especialmente adecuado cuando el CSV resultante del ciclo no supera los 5MB, lo que en la práctica cubre la gran mayoría de integraciones con volúmenes diarios moderados. A partir de ese umbral, las opciones de consolidación disponibles cambian y vale la pena evaluar si este enfoque sigue siendo el más apropiado.
Una preparación que vale la pena
Antes de ejecutar esa consulta, hay una decisión de diseño que puede marcar la diferencia entre un proceso ágil y uno que pone la base de datos bajo presión innecesaria. Los registros que necesitas exportar raramente viven en una sola tabla con el formato exacto que necesita el CSV. Generalmente hay que unir varias tablas, transformar algunos campos y ordenar las columnas de cierta manera. Hacer toda esa lógica en el momento de la exportación, sobre todos los registros de un ciclo, puede ser costoso cuando el volumen es considerable.
La alternativa es preparar el terreno antes: mantener una tabla auxiliar cuya estructura sea idéntica al CSV destino. La lógica de transformación ocurre cuando los datos llegan al sistema, no cuando salen. Si el sistema que alimenta la base de datos puede escribir directamente en esa tabla con el formato correcto, perfecto. Si no puede, un disparador en PostgreSQL puede hacer esa transformación automáticamente cada vez que se inserta un registro nuevo, sin que el proceso de exportación tenga que preocuparse por eso.
En cualquier caso el resultado es el mismo: en el momento de exportar tienes una tabla limpia y lista donde la consulta del COPY TO STDOUT es un SELECT simple sin transformaciones costosas. Para este enfoque esa tabla no es una recomendación opcional: es prácticamente un requisito. Una consulta compleja sobre decenas de miles de registros con múltiples uniones puede tardar lo suficiente como para alcanzar los límites de tiempo de la conexión, y el impacto sobre la base de datos durante esa operación puede afectar otros procesos que corren al mismo tiempo.
El flujo completo
Con la tabla de exportación lista, el proceso arranca consultando qué tipos de registros existen con trabajo pendiente. Si solo se tiene un tipo de formato de CSV, este paso es trivial: siempre hay un único archivo que generar. Si tienes varios, cada tipo corresponde a una plantilla distinta y puede haber uno o varios archivos por ciclo. En cualquier caso el proceso abre una transacción por cada tipo, ejecuta el COPY TO STDOUT que lee los registros y los marca como procesados en la misma operación, recibe el stream CSV completo, lo sube a S3 y confirma la transacción si S3 respondió con éxito. Si S3 falla, hace rollback y ese tipo queda pendiente para el siguiente ciclo.
Proceso arranca
↓
Consulta qué tipos tienen registros pendientes
↓
Por cada tipo:
Abre transacción
↓
COPY TO STDOUT:
SELECT sobre tabla de exportación
+ marca registros como procesados
+ devuelve CSV listo
↓
Sube CSV a S3
↓
S3 exitoso → confirma transacción ✅
S3 falla → rollback → registros vuelven a pendiente 🔄
Aquí aparece una decisión de diseño que vale la pena tomar conscientemente antes de implementar: ¿el fallo de un tipo debe afectar a los demás o cada uno es independiente?
Cuando cada tipo vive su propia historia
Si los tipos son independientes entre sí, cada uno tiene su propia transacción. El sistema legado puede recibir los archivos de los tipos que funcionaron mientras el tipo fallido se reintenta en el siguiente ciclo. Es la opción más resiliente y la más simple de implementar porque los fallos están contenidos: un problema con un tipo no contamina a los demás.
Tipo A → transacción propia → éxito ✅
Tipo B → transacción propia → falla → rollback → pendiente 🔄
Tipo C → transacción propia → éxito ✅
El siguiente ciclo solo tiene trabajo pendiente del Tipo B. Los demás ya están procesados y no se vuelven a tocar.
Cuando todos los tipos son parte de un todo
Hay casos donde el sistema legado espera todos los archivos juntos o ninguno. Recibir una parte y no la otra puede generar inconsistencias en el proceso de negocio del otro lado. En esos casos necesitas que la subida a S3 sea atómica: o llegan todos los archivos o no llega ninguno.
S3 no ofrece esa atomicidad de forma nativa para múltiples archivos independientes. Pero hay una forma de conseguirla: comprimir todos los CSVs en un único archivo y hacer un solo envío a S3. O el archivo comprimido llega completo o no llega nada. No hay estado intermedio posible.
Abre una sola transacción
↓
Por cada tipo ejecuta COPY TO STDOUT
y acumula los CSVs en memoria
↓
Comprime todos los CSVs en un único archivo
↓
Un solo envío a S3
↓
Éxito → confirma transacción ✅
Falla → rollback → todos los registros vuelven a pendiente 🔄
Esta variante tiene un requisito adicional del lado del sistema legado: necesita poder descomprimir el archivo antes de procesarlo. Si el sistema legado no tiene esa capacidad —y muchos no la tienen precisamente porque son legados— la solución es una función Lambda en S3 que se dispara automáticamente cuando llega el archivo comprimido, lo descomprime y deja los CSVs individuales en la ubicación que el sistema legado espera. El sistema legado nunca sabe que hubo un archivo comprimido de por medio.
Lo que necesitas para implementarlo
Para que este enfoque funcione, la tabla de exportación debe tener al menos dos elementos además de las columnas del CSV: un campo de estado que indique si el registro está pendiente o ya fue procesado, y la estructura debe estar alimentada por el sistema origen o por un disparador según la capacidad disponible.
| Elemento | Detalle |
|---|---|
| Tabla de exportación | Estructura idéntica al CSV destino, con campo de estado |
| Campo de estado | PENDING, COMPLETED |
| Alimentación | Sistema origen o disparador en PostgreSQL |
| Lambda en S3 | Solo para la variante de tipos dependientes |
Enfoque B: cuando el trabajo es demasiado para uno solo
Hay un punto en el crecimiento de cualquier sistema donde un solo proceso ya no es suficiente. El volumen supera lo que puede procesarse en el tiempo disponible, o simplemente la infraestructura escala horizontalmente y levantar múltiples instancias del mismo proceso es la forma natural de responder a la demanda. Pero este enfoque tiene dos condiciones que deben cumplirse para que tenga sentido aplicarlo.
La primera es que haya procesamiento real y costoso por registro fuera de la base de datos: llamadas a APIs externas, validaciones complejas en código o transformaciones que no pueden vivir en SQL. Si todo el procesamiento puede ocurrir en la base de datos, el Enfoque A resuelve el problema con mucha menos complejidad.
La segunda es que el volumen de datos por ciclo supere los 5MB. Este número no es arbitrario: es el tamaño mínimo que S3 exige por cada parte en su mecanismo de carga en partes, que es el que permite consolidar el trabajo de múltiples pods en un único archivo final. Por debajo de ese umbral, el mecanismo de consolidación no es aplicable y el Enfoque A sigue siendo la opción correcta.
Si ambas condiciones se cumplen, este enfoque ofrece algo que el Enfoque A no puede dar: escala horizontal, trazabilidad por registro y manejo de errores individuales sin detener el ciclo completo.
El problema central: coordinación y consolidación
Cuando múltiples pods consultan la base de datos al mismo tiempo y encuentran los mismos registros pendientes, ambos intentarán procesarlos y terminarás con duplicados. Evitar eso sin introducir un componente externo de coordinación es precisamente lo que hace interesante este enfoque.
La solución para la coordinación está en PostgreSQL mismo. La instrucción SELECT FOR UPDATE SKIP LOCKED permite que un pod tome un conjunto de registros y los bloquee de forma que otros pods que ejecuten la misma consulta simplemente los salten y tomen registros distintos. El bloqueo dura solo el tiempo necesario para que el pod reclame esos registros como suyos, no durante todo el procesamiento. Así múltiples pods pueden trabajar en paralelo sobre el mismo conjunto de datos sin coordinación externa y sin duplicados.
El segundo problema es la consolidación: ¿cómo unen su trabajo múltiples pods en un único archivo final? La respuesta está en una tabla temporal en la misma base de datos. Cada pod, al terminar de procesar un registro, deposita la línea CSV ya formateada junto con su peso en bytes en esa tabla. Cualquier pod puede consultar el peso acumulado en esa tabla y cuando detecta que hay suficiente para una parte válida de 5MB, toma esas líneas y las sube al mecanismo de carga en partes de S3. El peso pre-calculado en bytes permite saber con precisión cuándo se tiene suficiente para una parte válida sin estimaciones ni aproximaciones.
La sesión como punto de coordinación
Para que múltiples pods trabajen sobre el mismo conjunto de registros de forma ordenada, necesitan compartir cierta información: qué registros les corresponde procesar en este ciclo y a qué carga en partes de S3 deben contribuir. Esa información vive en lo que llamamos una sesión de exportación.
Una sesión representa un ciclo completo de exportación. Contiene la ventana de registros que se van a procesar —definida por el identificador mínimo y máximo de los registros pendientes al inicio del ciclo— y los tipos de registro que existen dentro de esa ventana. Todos los pods del ciclo leen esa sesión para saber qué les toca hacer.
La sesión se crea una sola vez al inicio del ciclo y cierra una sola vez al final. Ambas operaciones son bloqueantes: cuando múltiples pods arrancan al mismo tiempo, el primero que llega crea la sesión mientras los demás esperan. El mecanismo que garantiza que solo un pod hace cada una de esas operaciones es el mismo bloqueo pesimista de PostgreSQL: SELECT FOR UPDATE sobre el registro de control de la sesión.
El ciclo de vida completo
El flujo completo tiene tres fases que se ejecutan en orden estricto.
Inicio de la sesión. Cuando el cron dispara los pods, todos intentan iniciar o unirse a una sesión activa. El primero que llega no encuentra sesión activa, bloquea el registro de control y asume la responsabilidad de inicializar el ciclo.
Lo primero que hace ese pod —antes de definir qué registros procesará— es revisar si quedaron registros atascados del ciclo anterior. Un pod puede morir en medio del procesamiento por razones fuera de su control: un fallo de infraestructura, un timeout, una excepción no manejada. Cuando eso ocurre, los registros que ese pod había tomado quedan marcados como en procesamiento pero nunca llegan a completarse. El pod que inicia la sesión los detecta buscando registros que lleven más tiempo del razonable en ese estado y los devuelve a pendiente antes de continuar.
Con los registros huérfanos recuperados, el pod determina la ventana del ciclo: consulta el identificador mínimo y máximo de los registros pendientes y fija esos valores como los límites del ciclo. Ningún registro que llegue después de ese momento entra en este ciclo. Luego consulta qué tipos de registros existen dentro de esa ventana y registra esa información en la sesión sin iniciar aún ninguna carga en S3. El Multipart Upload no se inicia en este momento porque todavía no se sabe si habrá suficiente volumen para justificarlo. Finalmente libera el bloqueo y los demás pods pueden empezar a trabajar.
Primer pod llega
↓
No encuentra sesión activa → bloquea registro de control
↓
Recupera registros huérfanos del ciclo anterior
↓
Determina ventana: min_id y max_id de registros pendientes
↓
Consulta tipos distintos dentro de la ventana
↓
Por cada tipo registra en DB:
- el tipo
- estado OPEN
- sin uploadId aún
↓
Marca sesión como activa y libera bloqueo
↓
Los demás pods leen la sesión y empiezan a trabajar
Procesamiento en lotes. Cada pod entra en un ciclo continuo donde toma lotes de registros, los procesa y deposita las líneas resultantes en la tabla temporal, hasta que no queden registros pendientes dentro de la ventana.
Por cada lote, el pod ejecuta SELECT FOR UPDATE SKIP LOCKED sobre los registros pendientes dentro de la ventana. Inmediatamente los marca como en procesamiento y cierra la transacción, liberando el bloqueo para que otros pods puedan seguir tomando registros distintos. Luego procesa cada registro consultando sus tablas relacionadas, validando los datos y formateando la línea CSV según la plantilla del tipo correspondiente.
Al terminar cada registro, el pod deposita en la tabla temporal la línea CSV formateada, su peso en bytes y el tipo al que pertenece, y marca el registro como completado. Si el procesamiento de un registro falla, registra el error en la tabla de auditoría. Si el registro lleva menos de tres intentos, vuelve a pendiente. Si ya acumula tres intentos fallidos, se marca como descartado y no vuelve a procesarse: sigue visible en la tabla de auditoría para revisión manual pero no bloquea el avance del ciclo.
En paralelo al procesamiento, cualquier pod consulta el peso acumulado en la tabla temporal por tipo. Cuando detecta que hay suficiente para una parte válida de 5MB, toma esas líneas mediante SELECT FOR UPDATE SKIP LOCKED, las marca como en subida y ejecuta el siguiente flujo:
Si es la primera parte que se sube para ese tipo, el pod inicia el Multipart Upload en S3, sube los headers del CSV junto con las líneas acumuladas como primera parte, y registra el uploadId en la tabla de partes por tipo. Incluir los headers en la primera parte real de datos es necesario porque S3 Multipart no permite subir una parte vacía o con solo encabezados: la primera parte debe tener contenido suficiente para alcanzar el mínimo de 5MB. Si el Multipart ya fue iniciado por otro pod, simplemente sube las líneas como la siguiente parte disponible.
Pod consulta peso acumulado en tabla temporal por tipo
↓
¿Hay 5MB pendientes de subir?
├── NO → sigue procesando registros
└── SÍ → toma líneas via SELECT FOR UPDATE SKIP LOCKED
marca líneas como en subida
↓
¿Existe uploadId para este tipo?
├── NO → inicia Multipart Upload en S3
sube headers + líneas acumuladas
como primera parte
registra uploadId en DB
└── SÍ → usa uploadId existente
sube líneas como siguiente parte
↓
Marca líneas como subidas
Registra número de parte en DB
Proceso entra en loop
↓
SELECT FOR UPDATE SKIP LOCKED
registros pendientes dentro de la ventana
↓
¿Hay registros?
├── No → sale del loop
└── Sí → marca como en procesamiento
cierra transacción → libera bloqueo
↓
Por cada registro:
consulta tablas relacionadas
valida y formatea línea CSV
deposita en tabla temporal con peso en bytes
marca registro como completado
↓
Para fallidos:
registra error en auditoría
< 3 intentos → vuelve a pendiente
≥ 3 intentos → marca como descartado
Cierre de la sesión. Al terminar cada lote, el pod verifica si quedan registros pendientes o con menos de tres intentos dentro de la ventana. Los registros descartados se consideran procesados porque ya superaron el límite de reintentos y no volverán a intentarse.
Si no quedan registros pendientes, el pod verifica si quedan líneas en la tabla temporal que no hayan sido subidas a S3, independientemente de si superan o no los 5MB. Estas son las líneas residuales del ciclo: los últimos registros procesados que no alcanzaron a formar una parte completa. El pod que detecta esta condición intenta ser el que cierra la sesión usando el mismo mecanismo bloqueante del inicio.
Bloquea el registro de control y verifica el estado de la sesión. Si otro pod ya la cerró, simplemente libera el bloqueo y termina. Si la sesión sigue activa, este pod es el responsable del cierre: toma las líneas residuales de la tabla temporal y las sube como última parte del Multipart Upload de cada tipo. S3 permite que la última parte sea menor a 5MB, por lo que no hay restricción de tamaño en este paso. Luego llama a completeMultipartUpload para consolidar todas las partes en el archivo final, marca la sesión como cerrada y libera el bloqueo.
Al terminar cada lote:
↓
¿Quedan registros pendientes o con menos de 3 intentos
dentro de la ventana?
├── Sí → siguiente lote
└── No → ¿Quedan líneas sin subir en tabla temporal?
↓
Bloquea registro de control
↓
¿Sesión ya cerrada?
├── Sí → libera bloqueo y termina
└── No → toma líneas residuales de tabla temporal
sube como última parte de cada tipo
llama completeMultipartUpload por cada tipo
marca sesión como cerrada
libera bloqueo
Lo que necesitas para implementarlo
Este enfoque requiere más elementos que el primero, pero cada uno tiene una razón de ser clara.
| Elemento | Detalle |
|---|---|
| Campo de estado en tabla de jobs | PENDING, PROCESSING, COMPLETED, FAILED, DEAD |
Campo locked_at en tabla de jobs | Timestamp para detectar registros huérfanos |
| Tabla temporal de líneas CSV | Línea formateada, peso en bytes, tipo y estado de subida |
| Tabla de auditoría | Referencia al registro, número de intento, error y timestamp |
| Tabla de sesión | Ventana de procesamiento (min_id, max_id) y estado (OPEN, DONE) |
| Tabla de partes por tipo | Tipo, uploadId de S3 y estado (OPEN, DONE) |
| Tabla de exportación | Opcional, recomendada como buena práctica para simplificar el procesamiento por lote |
Lo que ninguno de los dos te dice hasta que fallas en producción
Después de ver los dos enfoques en detalle puede parecer que la decisión está tomada y la implementación es directa. Pero hay un punto que ninguno de los dos resuelve por sí solo y que, si no se atiende, puede hacer que cualquiera de las dos soluciones falle de una manera silenciosa y difícil de detectar.
PostgreSQL y S3 no viven en el mismo mundo transaccional. Cuando haces un cambio en la base de datos dentro de una transacción, puedes deshacerlo si algo falla: eso es precisamente lo que hace una transacción. Pero cuando subes un archivo a S3, esa operación no participa en ninguna transacción de base de datos. Es independiente, definitiva e irreversible desde el punto de vista de PostgreSQL. Si el archivo llega a S3 y luego la transacción de base de datos falla, el archivo ya está ahí y no hay forma de retirarlo automáticamente.
Esto crea una ventana de inconsistencia que puede ser devastadora si no se maneja. Imagina que marcas los registros como procesados, confirmas la transacción y luego intentas subir a S3. Si S3 falla, los registros ya están marcados como procesados en la base de datos pero el archivo nunca llegó. El sistema legado no recibe nada, pero el siguiente ciclo tampoco reintenta porque los registros ya no están pendientes. Los datos simplemente desaparecen del proceso sin que nadie lo detecte fácilmente.
La solución está en el orden de las operaciones, y ese orden debe ser disciplinado y consistente en toda la implementación: primero marcas los registros como procesados dentro de una transacción que aún no has confirmado, luego subes el archivo a S3, y solo si S3 confirma el éxito confirmas la transacción en PostgreSQL. Si S3 falla, haces rollback y los registros vuelven a su estado anterior, listos para reintentarse en el siguiente ciclo.
Marcar registros como procesados en DB (sin confirmar aún)
↓
Subir archivo a S3
↓
S3 exitoso → confirmar transacción en PostgreSQL ✅
S3 falla → rollback → registros vuelven a pendiente 🔄
Este orden no es una preferencia de implementación: es la única secuencia que garantiza consistencia entre los dos sistemas en todos los escenarios posibles. Con él, un fallo en S3 siempre deja la base de datos en un estado coherente y el siguiente ciclo retoma el trabajo sin intervención manual. Sin él, cualquier fallo entre la confirmación de la transacción y la subida a S3 crea un estado inconsistente que requiere corrección manual para detectar y resolver.
El sistema legado no va a cambiar. Pero tu integración sí puede ser confiable.
Volvamos al punto de partida. Tienes un sistema moderno que genera datos y un sistema legado que consume archivos. Entre los dos hay una brecha que no vas a poder cerrar cambiando el sistema legado, porque ese no es el juego. El juego es construir un puente confiable entre los dos mundos, y la complejidad de ese puente debe estar justificada por los problemas que resuelve, no por los que imaginas que podrían aparecer.
Desde aquí hay líneas naturales hacia las que vale la pena mirar. La observabilidad es la más inmediata: métricas de ciclo integradas en una herramienta de monitoreo permiten detectar degradaciones antes de que se conviertan en incidentes. La gestión operativa de registros descartados es la que más se subestima: una interfaz mínima para que un operador los inspeccione, corrija y reintroduzca al flujo es lo que transforma este sistema en algo verdaderamente autónomo. Y para quienes están en el extremo de mayor volumen, vale explorar si COPY TO STDOUT puede coexistir con el patrón distribuido, usando la capacidad de PostgreSQL para exportar directamente incluso en un escenario de múltiples procesos.