Skip to content

Decisión: cuándo usar merge incremental y cuándo replace

Fecha: 2026-07-29 · Ampliada: 2026-08-10 · Estado: Confirmada

Contexto

Los pipelines de dlt pueden cargar una tabla de dos formas: merge con cursor incremental sobre Fecha_Ult_Modif, o replace (recarga completa). La primera parece siempre mejor por eficiencia, pero tiene un costo que no es obvio.

El problema con merge

merge nunca borra en destino. Si en el origen se elimina un registro, la fila queda en Postgres para siempre.

Se detectó con reorden: Postgres tenía 2,053 filas contra 2,022 en el origen — 31 huérfanas. Al cambiar a replace quedó en 2,022/2,022 exacto.

Segundo caso confirmado: comprobante_digital (690 k filas, no se puede resolver igual)

Diagnosticado 2026-08-26 y re-confirmado 2026-09-03: check_raw_freshness.py reporta FAIL/WARN recurrente para comprobante_digital — diff negativo (destino > fuente), estable en el tiempo. Causa idéntica a reorden: documentos (GASTO_REGISTRO, CHEQUE, ...) que se cancelan/borran en .207 — merge no los borra en Postgres, quedan huérfanos para siempre. Confirmado 2026-09-03 con 3 llaves al azar: no existen en .207 sin ningún filtro de fecha, no es un tema de ventana — están borradas de verdad.

A diferencia de reorden (2 k filas), comprobante_digital tiene 690,418 filas — recarga completa (replace) no es una opción barata, así que la resolución no es cambiar de modo de carga.

Resolución: limpieza manual periódica, ya construida. dlt/maintenance.py (factorizado 2026-08-27 de 5 load_*.py casi idénticos) expone diff_keys_vs_raw()/delete_orphan_keys() genéricos — comparan llaves origen/destino en una ventana, y antes de borrar reconfirman cada candidata contra .207 sin filtro de fecha (doble verificación, no se borra solo por estar fuera de la ventana). load_comprobante_digital.py ya expone wrappers delgados:

from load_comprobante_digital import diff_keys_207_vs_raw, delete_orphan_keys
diff_keys_207_vs_raw()   # reporta, no borra
delete_orphan_keys()     # borra solo lo reconfirmado ausente

Actualizado 2026-09-03 — ahora se auto-limpia sola, con red de seguridad. Antes era deliberadamente manual (efecto práctico: las huérfanas se volvían a acumular entre corridas — 59 confirmadas 2026-09-03, una semana después de la limpieza aprobada 2026-08-26 — porque nadie las corría salvo que alguien revisara el check a mano). check_raw_freshness.py ahora, dentro de su misma corrida de cron (07:00), llama delete_orphan_keys() automático cuando comprobante_digital no da pass — diccionario AUTOHEAL explícito en el script, alcance angosto a propósito (no cualquier FAIL dispara un DELETE, solo tablas con su propio wrapper ya aprobado). Reusa el mismo doble-check de siempre (reconfirma contra .207 sin filtro de fecha antes de borrar) y agrega un tope (AUTOHEAL_MAX_ORPHANS=500): si aparecen más candidatas que eso de golpe, no borra solo, avisa — un salto así ya no es cancelación normal. Cada auto-limpieza se postea aparte a Loki (job=raw_freshness_autoheal), nunca se mezcla con un PASS silencioso, y el resultado se recalcula después de limpiar — si el hueco real era otra cosa (carga atrasada, no huérfanas), el check sigue en FAIL, no se tapa.

Las funciones manuales (diff_keys_207_vs_raw()/delete_orphan_keys()) siguen ahí para correr a mano en cualquier otra tabla. Mismo patrón ya replicado para ztrv_solicitud_material_detalle — cualquier tabla merge nueva con el mismo síntoma reusa maintenance.py, y si se le agrega su propio wrapper puede sumarse al diccionario AUTOHEAL.

Decisión

Modo Cuándo
merge + incremental Tabla grande, con PK única verificada y cursor poblado
replace Tablas chicas (hasta cientos de miles de filas), sin cursor usable, o sin PK única

Para tablas chicas, replace es más simple y más correcto. reorden (2 k filas) recarga completa en ~18 segundos junto con producto y catálogos.

Pre-flight obligatorio antes de elegir merge

Tres comprobaciones que cuestan un minuto y evitan pérdidas silenciosas. Las tres salieron de fallos reales.

1. ¿La PK es realmente única en el origen?

SELECT COUNT(*) FROM (SELECT <cols_pk> FROM <tabla> GROUP BY <cols_pk> HAVING COUNT(*)>1) x;

Debe dar 0. ZTRV_SOLICITUD_MATERIA_DOCUMENTO da 31,583 — no tiene clave natural, solo admite replace.

2. ¿La columna cursor está realmente poblada? Que exista Fecha_Ult_Modif no basta: dlt descarta las filas cuyo cursor es NULL.

SELECT COUNT(*) total, SUM(CASE WHEN Fecha_Ult_Modif IS NULL THEN 1 ELSE 0 END) nulos FROM <tabla>;

ZTRV_Solicitud_Agenda_Logistica tiene la columna pero 96 % de sus filas la traen NULL — el incremental cargaba 6 de 142 filas.

3. ¿.200 escribe esa tabla por su cuenta? Ver Fuente de datos.

Gotchas del cursor

  • initial_value debe ser datetime.datetime(1900, 1, 1), no el string "1900-01-01". Si la columna es datetime en SQL Server, dlt compara str > datetime y falla con IncrementalCursorInvalidCoercion.
  • Hacer el backfill con dlt.sources.incremental ya activo, para que el cursor quede persistido. Si el backfill se hace con una query a pelo, el estado queda vacío y la primera corrida incremental intenta re-traer la tabla completa. Le pasó a movimiento (4.4 M filas, >20 min, proceso muerto).
  • Tablas muy grandes: trocear el backfill por año. movimiento moría sin traceback cargando 4.4 M filas de una (sin memoria, VM de 5.2 GB con swap al límite).