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 elcountestá bien pero algún count-por-columna o elsumfallan →warn(las filas sí están, un valor difiere). Todo bien →pass. - schema:
passsischema_check.pyno encuentra hallazgos reales (ver mapping, abajo);failsi encuentra al menos uno. No hay heurística dewarnrazonable 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:
scripts/generate_dlt_mapping.py(corre con el venv dedlt-pipelines, que ya tienedltinstalado): para cada tabla, lee las columnas reales de.207(INFORMATION_SCHEMA.COLUMNS) y les aplicadlt.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 marcacountable: falseen columnasntext/text/image(SQL Server no permiteCOUNT()sobre esos tipos — error 8117, otro hallazgo real durante la corrida) y usa una lista explícita de columnas paracomprobante_digitalymovimiento, que no usan reflection completa sino unSELECTa mano con un subconjunto de columnas (verload_comprobante_digital.py/load_movimiento.py) — el resto de la tabla no está “faltante”, nunca se intentó sincronizar.scripts/build_column_config.py: arma el YAML devalidate columnde DVT consource_column/target_columnexplí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-countabley las que el mapping predice pero no existen de verdad en el destino (ver siguiente punto).scripts/schema_check.py:validate schemade 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, ¿existetarget_columnde 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_Bajayorden_compra.Oc_Fecha_Autorizacion: existen en.207pero dlt nunca las materializó en Postgres — llegaron 100%NULLdurante la carga, así que dlt no pudo inferir su tipo (warning visto en su momento al correrload_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 uncolumns={...}hint explícito en el resource de dlt — el schema check seguirá marcándolo comofailhasta 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.shCron 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:
| Dashboard | Datos | Qué muestra |
|---|---|---|
soda-data-quality | Ficticios (job="soda") | El dummy original, look de Soda Cloud — sin tocar |
dvt-data-quality | Reales (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-status | Reales (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
- ctunlinux — el host: specs, túnel, todos los contenedores
- Conexiones a TRIVASADB — por qué
.207y no.200/.204 - Pipelines dlt — los 4 crons que sincronizan lo que DVT valida
- Warehouse Postgres —
raw.*, el destino que se valida