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_valuedebe serdatetime.datetime(1900, 1, 1), no el string"1900-01-01". Si la columna esdatetimeen SQL Server, dlt comparastr > datetimey falla conIncrementalCursorInvalidCoercion.- Hacer el backfill con
dlt.sources.incrementalya 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ó amovimiento(4.4 M filas, >20 min, proceso muerto). - Tablas muy grandes: trocear el backfill por año.
movimientomoría sin traceback cargando 4.4 M filas de una (sin memoria, VM de 5.2 GB con swap al límite).