ScriptDHLBC.sqlComparación entre el backup de producción del 13 de julio de 2026 y el script de base versionado en el repositorio, generado el 30 de diciembre de 2025. Estructura, datos paramétricos y riesgos de transaccionalidad.
Tres conclusiones, en orden de importancia.
Seis meses y medio de producción y el esquema es idéntico: mismas 36 tablas, 308 columnas, 36 primary keys, 57 foreign keys y 3 defaults. Verificado dos veces por caminos independientes — consultas al catálogo del motor y un diff DDL con sqlpackage, que salió vacío.
Los 12 catálogos (175 filas) coinciden celda por celda con el script. Lo único que se movió son los usuarios: un alta y tres reasignaciones de banco.
Ninguna de las 57 foreign keys tiene índice de soporte, y no existe un solo índice no-clustered en toda la base. Sumado a que el aislamiento por snapshot está apagado y no hay control de concurrencia optimista, esto produce bloqueos y pérdida silenciosa de escrituras a medida que crece el volumen. Es lo que hay que atender.
No se compararon archivos de texto. Se restauró el backup y se cargó el script en dos bases vivas del mismo motor, para comparar contra el catálogo real de SQL Server.
| Producción | ScriptDHLBC.sql | |
|---|---|---|
| Origen | Backup FULL nativo (.bak) | Script generado por SSMS |
| Fecha | 2026-07-13 12:10:27 | 2025-12-30 16:14:37 |
| Servidor | VNALPSQL07 | — |
| Tamaño | 187 MB | 52 MB (UTF-16) |
| Contenido | 439.631 filas | 56.645 INSERTs |
| Integridad | RESTORE VERIFYONLY OK | carga sin errores |
Ambas bases se levantaron en un SQL Server 2022 en Docker. Con las dos vivas y lado a lado, la comparación se hace contra las vistas de sistema (sys.tables, sys.columns, sys.foreign_keys) y con EXCEPT sobre los datos — no a ojo.
El backup llegó con extensión .sql, pero no es un script SQL: es un backup nativo de SQL Server (formato MTF). Se renombró a .bak, que es lo que realmente es. Además, la copia que estaba en el repositorio venía truncada al 77% (faltaban 45 MB) y era irrestaurable; se reemplazó por una copia íntegra verificada por SHA-256.
Cero diferencias. Ni una columna nueva, ni un tipo cambiado, ni un default distinto.
Como verificación independiente se corrió un diff DDL con sqlpackage — la herramienta de Microsoft para comparar esquemas. Extrajo el esquema de producción y lo comparó contra la base del script:
sqlpackage /Action:Script /SourceFile:DHLBC_PROD.dacpac
/TargetConnectionString:"...DHLBC_SCRIPT..."
→ 0 sentencias CREATE / ALTER / DROP
El script de migración que generó está vacío. Sin conocer nada de nuestro análisis, la herramienta llega a la misma conclusión: las dos bases tienen el mismo esquema.
| Hallazgo | Detalle | Severidad |
|---|---|---|
ClaimTreasuryGroupDocument |
Su primary key tiene nombre autogenerado (PK__ClaimTre__3214EC07…), porque se creó sin nombrarla. Es la única tabla del esquema así. El sufijo aleatorio cambia en cada entorno, lo que ensucia cualquier comparación futura. |
menor |
| Usuarios de base de datos | Producción tiene los usuarios DHLBC y UsuarioConsultasSQL, que el script no versiona. Los permisos de producción no están bajo control de versiones. |
a revisar |
Los 12 catálogos coinciden fila por fila y celda por celda con el script de diciembre.
| Catálogo | Filas | Estado | Catálogo | Filas | Estado |
|---|---|---|---|---|---|
| DocumentField | 45 | idéntico | ClaimRejectionType | 13 | idéntico |
| ClaimRequiredDocument | 44 | idéntico | ClaimState | 8 | idéntico |
| ClaimStatus | 22 | idéntico | BankParameter | 5 | idéntico |
| DocumentType | 22 | idéntico | Bank | 4 | idéntico |
| ClaimRequirement | 4 | idéntico | Role | 4 | idéntico |
| ClaimType | 3 | idéntico | SystemSource | 1 | idéntico |
No es parametría pura, pero es el único dato no transaccional que se movió en seis meses.
| Cambio | Usuario | Detalle |
|---|---|---|
| alta | jose.avila | Rol Analyst, asignado a Banco Ecofuturo |
| reasignación | marcela.flores | Banco Fie → Banco Sol · reactivada (0→1) |
| reasignación | analista.extra4 | Banco Sol → Banco Ecofuturo · desactivada (1→0) |
| reasignación | analista.extra5 | Banco Fie → Banco Ecofuturo · reactivada (0→1) |
Los usuarios test y analista.extra1 a analista.extra5 están activos en la base productiva, con logins recientes. Convendría revisar si siguen siendo necesarios.
| Tabla | Dic 2025 | Jul 2026 | Crecimiento | Tamaño |
|---|---|---|---|---|
| DocumentAttributeValue | 22.627 | 246.888 | ×10,9 | 50 MB |
| Document | 10.054 | 106.655 | ×10,6 | 92 MB |
| ClaimHistory | 14.644 | 39.541 | ×2,7 | 17 MB |
| Claim | 1.157 | 3.255 | ×2,8 | 2 MB |
Acá está lo importante. Los hallazgos van ordenados por gravedad.
La base tiene exactamente 36 índices de usuario para 36 tablas. Uno por tabla, y ese uno es la primary key clustered. No existe un solo índice no-clustered en toda la base.
Esto significa que toda consulta que filtre o haga JOIN por una foreign key — que es prácticamente toda la aplicación — recorre la tabla entera.
| Tabla hija | Columna FK | Referencia | Filas escaneadas | ON DELETE |
|---|---|---|---|---|
| DocumentAttributeValue | DocumentId | Document | 246.888 | NO_ACTION |
| DocumentAttributeValue | DocumentFieldId | DocumentField | 246.888 | CASCADE |
| Document | ClaimId | Claim | 106.655 | NO_ACTION |
| Document | DocumentTypeId | DocumentType | 106.655 | CASCADE |
| ClaimHistory | ClaimId | Claim | 39.541 | NO_ACTION |
| …y 52 foreign keys más, todas sin índice. | ||||
Impacto en transaccionalidad. El problema no es solo que sea lento. Un scan de 246.888 filas toma locks de rango sobre toda la tabla mientras dura. Con la configuración actual (ver hallazgo #2), esos locks bloquean a cualquiera que quiera escribir. Cuanto más crecen los datos, más larga la ventana de bloqueo — y ese crecimiento es de ×10 en seis meses.
Es también el escenario clásico de deadlock: dos transacciones que escalan locks sobre tablas grandes en distinto orden terminan matándose entre sí.
Crear un índice no-clustered sobre la columna de cada foreign key. Es la corrección de mayor impacto y menor riesgo que se le puede hacer a esta base. Empezando por las de mayor volumen:
CREATE NONCLUSTERED INDEX IX_DocumentAttributeValue_DocumentId
ON dbo.DocumentAttributeValue (DocumentId);
CREATE NONCLUSTERED INDEX IX_DocumentAttributeValue_DocumentFieldId
ON dbo.DocumentAttributeValue (DocumentFieldId);
CREATE NONCLUSTERED INDEX IX_Document_ClaimId
ON dbo.Document (ClaimId);
CREATE NONCLUSTERED INDEX IX_ClaimHistory_ClaimId
ON dbo.ClaimHistory (ClaimId);
Se pueden crear en caliente. El costo es espacio en disco y una escritura marginalmente más lenta; el beneficio es de órdenes de magnitud en lectura y una reducción drástica de bloqueos.
Ninguna tabla tiene una columna rowversion (ni ningún otro token de concurrencia).
Qué significa en la práctica. Sin token de concurrencia, Entity Framework no puede detectar que un registro cambió entre que lo leyó y lo grabó. Dos analistas que abran el mismo siniestro y guarden: el último pisa al primero, en silencio. Nadie recibe un error, nadie se entera. El trabajo del primero simplemente desaparece.
En un sistema donde se liquidan siniestros de desgravamen, con montos e indemnizaciones, una escritura perdida no es un detalle técnico — es un dato incorrecto que nadie sabe que es incorrecto.
Agregar una columna rowversion a las tablas que edita el usuario (Claim, ClaimAnalysisData, TechnicalSheet, DocumentAttributeValue) y mapearla en EF Core como token de concurrencia:
-- SQL
ALTER TABLE dbo.Claim ADD RowVersion rowversion NOT NULL;
// EF Core
builder.Property(c => c.RowVersion).IsRowVersion();
A partir de ahí, un guardado sobre datos desactualizados lanza DbUpdateConcurrencyException y la aplicación puede resolverlo — avisar al usuario, recargar o mergear. Lo que no puede seguir pasando es que se pierda sin ruido.
Hay ON DELETE CASCADE desde tablas de catálogo — pequeñas, editables desde la administración — hacia las tablas más grandes del sistema.
| Si se borra una fila de… | que tiene | se borran en cascada… | filas |
|---|---|---|---|
| SystemSource | 1 fila | Document → DocumentAttributeValue | 353.543 |
| DocumentType | 22 filas | Document → DocumentAttributeValue | 353.543 |
| DocumentField | 45 filas | DocumentAttributeValue | 246.888 |
| ClaimStatus | 22 filas | Claim → TechnicalSheet, ClaimAnalysisData, … | 12.621 |
El caso extremo. SystemSource tiene una sola fila. Un DELETE sobre ella — un clic equivocado en una pantalla de administración — borraría los 106.655 documentos del sistema y sus 246.888 valores asociados. Sin confirmación, sin vuelta atrás salvo restaurar el backup.
Y como esas foreign keys tampoco tienen índice (hallazgo #1), la cascada además haría un table scan y mantendría la base bloqueada mientras dura.
Los catálogos no deberían cascadear. Cambiar esas FKs a ON DELETE NO ACTION: si alguien intenta borrar un tipo de documento en uso, el motor lo rechaza — que es exactamente el comportamiento que uno quiere. La cascada tiene sentido de Claim hacia sus hijos (borrar un siniestro borra su análisis), no de un catálogo hacia los datos.
| Opción | Valor | Consecuencia |
|---|---|---|
| READ_COMMITTED_SNAPSHOT | OFF | Cada SELECT toma shared locks; los escritores esperan. |
| ALLOW_SNAPSHOT_ISOLATION | OFF | Tampoco hay aislamiento por snapshot disponible. |
| RECOVERY MODEL | FULL | Correcto — requiere backups de log periódicos. |
| PAGE_VERIFY | CHECKSUM | Correcto. |
Por qué importa acá y no en cualquier base. Con RCSI apagado, un reporte que lee Document bloquea a quien esté cargando un documento nuevo. Normalmente eso dura milisegundos. Pero como ninguna consulta tiene índice (hallazgo #1), esas lecturas son scans completos de cientos de miles de filas. Los dos problemas se multiplican: scans largos × locks bloqueantes = usuarios esperando.
ALTER DATABASE DHLBC SET READ_COMMITTED_SNAPSHOT ON
WITH ROLLBACK IMMEDIATE;
Con RCSI, los lectores ven una versión consistente en tempdb en lugar de bloquear. Requiere una ventana breve (desconecta las sesiones activas) y carga adicional sobre tempdb. Ordenar primero los índices — con el hallazgo #1 resuelto, la presión sobre tempdb es mucho menor.
El header del backup declara HasBackupChecksums = 0: se generó sin WITH CHECKSUM.
Sin checksums, nadie puede verificar que el backup sea restaurable hasta el día que intente restaurarlo. Si hay corrupción de página en el archivo, se descubre en el peor momento posible. Notar la inconsistencia: la base sí tiene PAGE_VERIFY CHECKSUM activo, o sea que los checksums existen — simplemente el backup no los está grabando.
BACKUP DATABASE DHLBC TO DISK = '...'
WITH CHECKSUM, COMPRESSION, STATS = 10;
RESTORE VERIFYONLY FROM DISK = '...' WITH CHECKSUM;
| Observación | Detalle |
|---|---|
PK nvarchar(26) |
Las 36 tablas usan ULID en nvarchar(26) = 52 bytes por clave. ULID es ASCII (Crockford base32): char(26) gastaría la mitad. Como es la clave clustered, ese ancho se paga en cada página y se repite en cada FK. A favor: el ULID es monótono, así que los inserts van al final del índice y no lo fragmentan — esa parte está bien elegida. |
| 3 defaults en 308 columnas | Solo Claim.Active, Claim.CreationDate y ClaimTreasuryGroupDocument.UploadedDate tienen valor por defecto. El resto de las columnas de auditoría (CreatedAt, CreationDate, ModificationDate) dependen de que la aplicación siempre las setee. Cualquier INSERT por fuera de la app deja auditoría inconsistente. |
Cero CHECK constraints |
No hay una sola validación de dominio en la base. Toda la integridad más allá de las FKs vive en la aplicación. |
User.BatchRunId / User.InsuredId |
La tabla de usuarios tiene foreign keys hacia BatchRun e Insured. Es una relación llamativa para una tabla de identidad — vale la pena revisar si es intencional o un artefacto del modelado de EF. |
En este orden. El primer paso es el que más rinde y el que menos riesgo tiene.
| # | Acción | Riesgo | Impacto |
|---|---|---|---|
| 1 | Crear índices sobre las 57 foreign keys. Se puede hacer en caliente, no requiere cambios en la aplicación. | bajo | muy alto |
| 2 | Quitar el CASCADE de los catálogos (SystemSource, DocumentType, DocumentField, ClaimStatus…). Elimina el riesgo de borrado masivo accidental. | bajo | muy alto |
| 3 | Agregar WITH CHECKSUM a los backups. Un cambio en el job de backup. | nulo | alto |
| 4 | Agregar rowversion + token de concurrencia en EF. Requiere migración y manejar DbUpdateConcurrencyException en la aplicación. | medio | muy alto |
| 5 | Activar RCSI. Hacerlo después de los índices, en ventana de mantenimiento. | medio | alto |
| 6 | Revisar cuentas de prueba en producción y versionar los usuarios de base de datos. | bajo | medio |
Tres scripts generados desde el backup de producción, en Docs/backup-bd-prod/. Los tres fueron validados ejecutándolos en bases vacías y comparando el resultado contra producción.
| Archivo | Tamaño | Contenido | Verificación |
|---|---|---|---|
| …-schema.sql | 38 KB | Solo estructura: 36 tablas, 57 FKs, defaults. | reproduce el esquema |
| …-parametrica.sql | 73 KB | Estructura + los 12 catálogos (175 filas). Sin datos personales. Es el que sirve para levantar un ambiente DEV/QA limpio. | catálogos idénticos |
| …-full.sql | 156 MB | Estructura + las 429.715 filas. Contiene datos personales reales de asegurados. | 0 diferencias / 36 tablas |
…-full.sql incluye nombres de asegurados, números de documento y causas de fallecimiento en texto plano. La carpeta Docs/backup-bd-prod/ está en .gitignore, así que no entra al repositorio — pero no debe compartirse ni publicarse fuera del equipo. Para ambientes de desarrollo y pruebas, usar el script paramétrico.
No alcanza con que un script "parezca" correcto. Cada uno se ejecutó contra una base vacía y el resultado se comparó con producción usando EXCEPT en ambas direcciones, sobre todas las columnas de las 36 tablas:
-- Para cada tabla, en ambos sentidos:
SELECT ... FROM DHLBC_PROD.dbo.[T]
EXCEPT
SELECT ... FROM DHLBC_RECREADA.dbo.[T]
→ 0 filas de diferencia · 36 tablas · 429.715 filas
En el camino, esa validación encontró cuatro bugs reales en la generación que una revisión visual no habría detectado nunca: un ISNULL que truncaba el literal 'NULL' a 'N', un float que perdía precisión al convertirse a texto, saltos de línea embebidos que partían los INSERT al medio, y un orden de inserción que violaba las foreign keys. Todos corregidos y verificados.