DVT — reconciliación real fuente/destino

Deprecado (2026-08-10): reemplazado por Check diario de frescura de raw.* — mismo propósito (detectar cuando raw.* se queda atrás de .207) con un script mucho más chico (sin contenedor Docker propio, sin mapping de columnas, solo conteo de filas de los últimos 30 días). El cron de DVT ya se quitó y la imagen Docker se borró en una limpieza de disco anterior; el código en ~/trivasa-bi-dev/dvt-checks/ queda en el host sin usarse, sin plan de reactivarlo — esta página se conserva por el detalle técnico real que documenta (el problema genuino de columnas *_UserDef_N, el bug de Python 3.14/pyarrow, etc.), útil si algún día hace falta volver a un check de columna+schema completo.

Reemplaza a Soda Core (ver “Por qué se reemplazó” abajo). Data Validation Tool (Google, paquete google-pso-data-validator) corre column validation (count + sum por tabla) y schema validation (columnas/tipos) comparando en vivo raw.* en Postgres contra las tablas reales en .207 (SQL Server, TRIVASADB) — sin tabla puente, sin aproximaciones: DVT sabe conectarse a los dos motores a la vez y comparar directamente.

Nota sobre el origen: se usa .207 (base operativa en vivo), no .200/TRIVASADB3 — .200 es la copia de respaldo, solo para backfills iniciales (ver Conexiones a TRIVASADB). Es la misma fuente que ya usan los 4 crons de dlt.

Por qué se reemplazó Soda Core

Soda Core (versión gratuita/local, sin Soda Cloud) no soporta comparar dos motores distintos en un mismo check — confirmado leyendo el parser de SodaCL (soda-core 3.5.6) directamente, ningún tipo de check tiene un campo de datasource destino. La reconciliación cross-engine (“reconciliation checks”) existe pero es una feature de Soda v4 + Soda Cloud (de pago). El primer intento usó un rodeo (tabla puente monitoring.source_row_counts, poblada por un script propio que leía .207 y dejaba el conteo en Postgres, para que Soda comparara todo dentro de una sola conexión) — ese setup completo se desmontó al migrar a DVT, que hace la comparación real sin rodeos.

Bloqueo real durante la instalación: Python 3.14 vs pyarrow

pip install google-pso-data-validator en el host (Python 3.14) falla: DVT fija ibis-framework==7.1.0 exacto (todas las versiones de DVT, hasta la 8.9.1, fijan alguna versión vieja de ibis sin flexibilidad), que a su vez exige pyarrow<15 — y no existe wheel de pyarrow anterior a 15 para Python 3.14 (salió después). Compilar pyarrow 14.x desde fuente en 3.14 requiere Cython, SETUPTOOLS_SCM_PRETEND_VERSION, y finalmente compilar Arrow C++ completo (cmake, toolchain C++17) — un hoyo sin fondo para una dependencia que ni siquiera es el proposito del paquete.

Solución: DVT corre en un contenedor Docker con Python 3.11 (ubuntu:22.04 + deadsnakes/ppa) — pyarrow 14.x sí tiene wheel para 3.11, instalación limpia sin compilar nada. network_mode: host para que el contenedor llegue a .207 (LAN) y a Postgres/Loki (localhost) exactamente igual que si corriera nativo. Mismo patrón que ya documenta Docker (personal-hub) para “contenedor necesita alcanzar algo del host”.

Gotcha aparte del build de la imagen: el driver ODBC de Microsoft para ubuntu:22.04 falla la verificación de firma si se hace gpg --dearmor a la key antes de usarla — el fix fue copiar microsoft.asc tal cual (sin dearmor) a /etc/apt/trusted.gpg.d/, igual que ya lo tiene resuelto el host mismo (/etc/apt/trusted.gpg.d/microsoft.asc).

Estructura del proyecto (~/trivasa-bi-dev/dvt-checks/)

docker/Dockerfile              # ubuntu:22.04 + python3.11 + msodbcsql18 + DVT
docker-compose.yml             # container_name: dvt-checks, network_mode: host
scripts/tables.py              # mapeo dataset -> (tabla origen, tabla destino, columna sum)
scripts/generate_dlt_mapping.py  # mapping columna-origen -> columna-destino real (naming convention de dlt) -- corre FUERA del contenedor, con el venv de dlt-pipelines
scripts/build_column_config.py   # arma el YAML de validate column con source_column/target_column explicitos
scripts/schema_check.py        # comparador de schema propio, usa el mismo mapping (validate schema de DVT no acepta mapping)
scripts/setup_connections.py   # registra conexiones DVT (sqlserver_207, postgres_warehouse)
scripts/run_validations.py     # orquesta: column validation (mapping) + schema_check.py sobre las 15 tablas, guarda JSON
scripts/push_to_loki.py        # postea el resumen a Loki (job=dvt)
run_dvt_pipeline.sh            # wrapper: docker compose up -d + los scripts, para cron
mapping/columns.json           # generado por generate_dlt_mapping.py, se regenera si cambia el schema de origen
results/                       # JSON crudo por tabla (montado como volumen)

Las conexiones DVT (data-validation connections add) se guardan dentro del contenedor, no en un volumen persistente — setup_connections.py se re-corre en cada invocación del pipeline (idempotente, barato) en vez de sincronizar un volumen extra solo para esto. Password de Postgres se lee de dlt-pipelines/.dlt/secrets.toml (bind mount de solo lectura); password de .207 está hardcodeada en setup_connections.py — mismo patrón ya establecido en dlt-pipelines/load_reorden.py y compañía.

Las 15 tablas

Las mismas que sincronizan los 4 crons de dlt (raw.*), todas desde .207 desde que se corrigió el sourcing de los catálogos (ver commit del mismo día). Columna de sum elegida por tabla cuando hay una columna de negocio obvia (saldo, costo, importe, cantidad) — los 7 catálogos chicos (familia, sub_familia, categoria, departamento, almacen, sucursal) solo llevan count.

Umbral de tolerancia: --threshold 0.01

Sin threshold, cualquier diferencia de una sola fila entre .207 (vivo) y raw.* marca fail — y .207 sigue recibiendo escrituras entre que corre el último sync de dlt y el momento en que corre DVT. Confirmado empíricamente: movimiento (4.5M filas) mostraba fail por una diferencia de 8 filas (0.0002%) sin threshold. Con --threshold 0.01 (1%), ese tipo de lag normal pasa a success; una diferencia real de sincronización (cientos/miles de filas, o la tabla vacía) sigue marcando fail.

Gotcha de formato, no de datos: en movimiento, sum(Mv_Cantidad_1) marca fail con difference: null aunque el valor es idéntico — la fuente lo devuelve como string con coma decimal ("7266129,084") y el destino con punto ("7266129.084"), un artefacto de locale en cómo el backend MSSQL de DVT/Ibis serializa el valor a texto, no una diferencia real de datos. Documentado aquí para no perder tiempo reinvestigándolo.

De resultado DVT (success/fail) a pass/warn/fail

DVT solo tiene dos estados (success/fail) — el dashboard usa tres, igual que el dummy de Soda. Derivación (run_validations.py::derive_outcome):

  • column: si el count (fila agregada, sin columna) falla → fail (no cuadran las filas, problema de sync real). Si el count está bien pero algún count-por-columna o el sum fallan → warn (las filas sí están, un valor difiere). Todo bien → pass.
  • schema: pass si schema_check.py no encuentra hallazgos reales (ver mapping, abajo); fail si encuentra al menos uno. No hay heurística de warn razonable para schema — o la columna está donde el mapping dice que debería estar, o no.

El mapping real: por qué no basta con ignorar el ruido

Primer intento: validate schema marcaba fail en las 15 tablas. Causa: columnas como Fm_UserDef_1 en .207 se convierten a fm_user_def_1 en Postgres (dlt inserta un guión bajo en el límite de mayúscula User+Def), pero data-validation validate schema compara nombres solo por .casefold() (confirmado leyendo schema_validation.py del paquete) — no encuentra fm_userdef_1 (su propia minusculización, sin el guión) contra fm_user_def_1 (el nombre real) y marca la columna como faltante en ambos lados. Aparece en casi todas las tablas porque *_UserDef_N es una convención recurrente del ERP.

Poner un threshold o desactivar el check habría ocultado el problema real que motivó todo esto: detectar cuando aparece una columna de verdad nueva en la fuente. La solución correcta fue generar el mapping real y dárselo a las dos piezas:

  1. scripts/generate_dlt_mapping.py (corre con el venv de dlt-pipelines, que ya tiene dlt instalado): para cada tabla, lee las columnas reales de .207 (INFORMATION_SCHEMA.COLUMNS) y les aplica dlt.common.normalizers.naming.snake_case.NamingConvention().normalize_identifier() — la misma función que dlt usa internamente para nombrar columnas en el destino, no una regla reinventada a mano. normalize_identifier("Fm_UserDef_1") da "fm_user_def_1", exacto. El resultado (mapping/columns.json) también marca countable: false en columnas ntext/text/image (SQL Server no permite COUNT() sobre esos tipos — error 8117, otro hallazgo real durante la corrida) y usa una lista explícita de columnas para comprobante_digital y movimiento, que no usan reflection completa sino un SELECT a mano con un subconjunto de columnas (ver load_comprobante_digital.py/load_movimiento.py) — el resto de la tabla no está “faltante”, nunca se intentó sincronizar.
  2. scripts/build_column_config.py: arma el YAML de validate column de DVT con source_column/target_column explícitos por cada par (Fm_UserDef_1 → fm_user_def_1), tomados del mapping — DVT sí soporta esto nativamente en su config YAML (data-validation configs run -c archivo.yaml), solo no lo expone como flag de la CLI (--count '*' compara por casefold igual que schema y por eso saltaba estas columnas en silencio, sin avisar). También filtra columnas no-countable y las que el mapping predice pero no existen de verdad en el destino (ver siguiente punto).
  3. scripts/schema_check.py: validate schema de DVT no tiene ningún gancho para aceptar un mapping (confirmado en su código — compara por casefold, sin excepción), así que el schema check real es un comparador propio: para cada columna del mapping, ¿existe target_column de verdad en Postgres? Si no → hallazgo real. Para cada columna del destino que no es _dlt_* (bookkeeping esperado) ni está en el mapping esperado → también hallazgo real.

Los 2 hallazgos reales que quedaron (de 15 falsos positivos a 2 reales)

  • reorden.Fecha_Baja y orden_compra.Oc_Fecha_Autorizacion: existen en .207 pero dlt nunca las materializó en Postgres — llegaron 100% NULL durante la carga, así que dlt no pudo inferir su tipo (warning visto en su momento al correr load_reorden.py/load_compras_inventario.py: “the following columns… did not receive any data during this load… will not be materialized”). Si algún día esas columnas empiezan a recibir datos reales en .207, seguirán sin aparecer en Postgres hasta que se agregue un columns={...} hint explícito en el resource de dlt — el schema check seguirá marcándolo como fail hasta entonces, correctamente.

Las otras 13 tablas: pass limpio, sin ruido de naming.

Cron y ejecución manual

~/trivasa-bi-dev/dvt-checks/run_dvt_pipeline.sh

Cron diario 7:00 AM (después de que terminan los 4 syncs de dlt a las 6:00/6:15/6:30/ 6:45): 0 7 * * * .../run_dvt_pipeline.sh >> .../logs/dvt.log 2>&1. El script regenera mapping/columns.json en cada corrida (usa el venv de dlt-pipelines, fuera del contenedor) antes de validar — si aparece una columna nueva de verdad en .207, el mapping la marca como missing_in_target la próxima corrida, sin intervención manual.

Dashboards en Perses (data.frento.com.mx)

Tres tableros hoy, mismo proyecto Perses soda:

DashboardDatosQué muestra
soda-data-qualityFicticios (job="soda")El dummy original, look de Soda Cloud — sin tocar
dvt-data-qualityReales (job="dvt")3 stats + gauge + barras apiladas pass/warn/fail, más un panel extra de fails por tipo de validación (column vs schema)
data-health-statusReales (job="dvt")Vista “de un vistazo” — un solo gauge grande (% de tablas sin ningún check en fail) + 2 stats de apoyo. Patrón “Current Status Dashboard” (ver DQOps, tipos de dashboard de calidad de datos) — un indicador dominante en vez de detalle, para responder “¿todo bien?” en un vistazo

push_to_loki.py mantiene los mismos nombres de campo que el dataset dummy de Soda (check_name, dataset, outcome, timestamp) y el mismo patrón de streams (job/dataset/outcome como labels), agregando validation_type (column|schema) como label extra — así las queries LogQL del dashboard dummy sirvieron de base sin reescribirlas.

Véase también