De .NET Framework 4.8 a .NET 8, decisión por decisión
Un back office de fletes construido en 2011: MVC 5, unWeb.config de 304 líneas, una acción de controlador de 272 líneas y cuatro dependencias sin sucesor. Estrangulado una ruta a la vez, con los fallos incluidos.
- .NET 8
- EF Core
- YARP
- Strangler fig
La aplicación que heredamos
Qué era realmente Meridian Despatch, medida en lugar de caracterizada, antes de que nada se moviera.
Meridian Despatch gestiona envíos de mercancía. Un administrativo registra una recogida, el sistema la tarifica, una etiqueta se imprime en un depósito, un manifiesto sale de madrugada y una factura llega a final de mes. Lleva en servicio desde 2011 y funciona, que es lo primero que vale la pena decir de ella: nadie está migrando esto porque esté roto.
Antes de describirla, esto es lo que mide. Los adjetivos sobre código legado son baratos; los números no.
- C#
- 1.331 líneas en 20 archivos
- Web.config
- 304 líneas
- Binding redirects
- 18
- Claves en appSettings
- 22, de las cuales 12 son reglas de negocio
- Paquetes NuGet
- 22, vía packages.config
- Entradas Compile
- 18, mantenidas a mano en el .csproj
- Método más largo
- ConsignmentsController.Create: 272 líneas
- Tests
- 0
El método que es la migración
ConsignmentsController.Create tiene 272 líneas. En una sola acción deriva el
peso facturable, deduce la zona de reparto de una cadena de prefijos postales,
abre una SqlConnection para consultar una banda tarifaria, aplica recargos de
combustible, zona remota, entrega programada y mercancía peligrosa, aplica un
descuento de contrato que se comporta distinto para tres clientes, calcula el
IVA, hace una comprobación de duplicados concatenando los valores del formulario
en un SELECT COUNT(*), guarda vía EF6, manda una cadena de ancho fijo a MSMQ,
llama a un servicio SOAP y escribe dos filas de auditoría.
No hay capa de servicio a la que moverlo. No hay costura. No está tan mal escrito como indistinto: nueve años de decisiones pequeñas y correctas, cada una con sentido el día que se tomó, ninguna consolidada nunca.
Ese método es la migración. Todo lo demás es preparación para poder tocarlo.
Cuatro dependencias sin sucesor
Esta es la parte que decide si una migración es un proyecto o una aventura. Las diferencias de framework y de lenguaje son trabajo; una dependencia que simplemente no existe al otro lado es una decisión.
- System.Messaging
- MSMQ, el traspaso al depósito. Nunca se portó a .NET Core y nunca se hará. Sin shim, sin paquete de compatibilidad.
- Microsoft.ReportViewer
- Veinte informes RDLC escritos en el diseñador de informes de Visual Studio entre 2011 y 2017. El formato no tiene otro renderizador.
- MachineKey
- La autenticación por formularios cifra su cookie con él. ASP.NET Core tiene Data Protection en su lugar, que es un algoritmo distinto.
- System.Drawing
- Etiquetas de expedición, incluido un código de barras dibujado a mano a partir de una tabla Code 39 porque el componente con licencia se perdió en 2012.
Dos defectos dejados a propósito
El buscador concatena su contenido en una cláusula WHERE. Se reportó
internamente en 2019 como MER-2213 y sigue abierto. La asignación de referencia
es MAX(...) + 1 sin ningún bloqueo, así que dos administrativos que registran
en el mismo segundo chocan y el segundo se lleva una pantalla amarilla.
Ambos se dejaron exactamente como se encontraron en este primer commit. Arreglarlos aquí significaría que la línea base ya no es la cosa que se está migrando, y el arnés de paridad que llega en la etapa 03 estaría comparando el código nuevo contra un oráculo que nadie ejecutó nunca en producción.
Se arreglan más adelante, en las etapas en las que ese código se estaba reescribiendo de todas formas. Eso no es una virtud: es como se cierran de verdad los tickets que llevan años abiertos.
Qué es esto, y qué no
Este es un sistema construido a propósito. El código de un antiguo cliente no es mío para publicarlo, así que Meridian se escribió para cargar con las patologías concretas que encarecen estas migraciones, y luego se migró de verdad.
Las trampas son reales. System.Messaging de verdad no tiene sucesor.
MachineKey de verdad no lo puede leer Data Protection. EF Core de verdad asume
(18,2) para decimal donde a EF6 se le había dicho (18,4), y de verdad lo
hace en silencio. Cada una de esas cosas está descrita más abajo porque mordió a
esta base de código, en este repositorio, con un commit que la arregló.
Lo que no se afirma en ningún punto de este diario es un incidente de producción. Nada de aquí dice que llamó un cliente. Donde una etapa da un coste, es el coste de encontrarlo aquí.
Un gateway delante de todo, enrutando nada
Un proxy inverso entró primero, mandando el 100% del tráfico a la aplicación que se suponía que iba a sustituir.
El primer commit de la migración no migra nada. Pone YARP delante de la aplicación con una única ruta catch-all que apunta a la misma máquina IIS que ya servía todas las peticiones.
Ningún usuario vio diferencia. Ningún código se movió. Es una semana difícil de justificar en una daily, y es el primer movimiento correcto.
- Navegadorcliente
- IISmeridian-app01
- Meridian.Web.NET 4.8
- SQL Serverbase de datos
- Navegadorcliente
- GatewayYARP · .NET 8
- IISmeridian-app01
- Meridian.Web.NET 4.8
- SQL Serverbase de datos
Una caja nueva, y todas las rutas siguen terminando en el mismo sitio. Lo único que cambió es que ahora hay dónde poner una decisión.
Por qué esto va primero y no tercero
El patrón strangler es una frase: mueve una ruta cada vez a la aplicación nueva. Esa frase exige algo que sea dueño de la tabla de enrutamiento.
Sin eso, el primer módulo que se mueve tiene que moverse por DNS, por una regla de rewrite de IIS, o por una ventana de despliegue, y cada una de esas mueve todo de golpe para alguna clase de usuario. Una migración que no puede dirigir tráfico por ruta no tiene etapas. Tiene un cutover, disfrazado.
Hacerlo primero tiene una segunda ventaja que en la práctica importa más. Es el cambio de infraestructura más arriesgado de todo el proyecto (un salto nuevo en cada petición, con sus propios timeouts, manejo de cabeceras y pool de conexiones), y aterriza mientras exactamente nada depende de él. Si el proxy va a estropear algo, la semana de descubrirlo es la semana en que revertir significa borrar un contenedor.
La tabla de rutas es la barra de progreso
El mismo archivo, ocho etapas después. Cada ruta que nombra el clúster moderno es un trozo de la aplicación que se ha movido; el catch-all en el orden 100 es todo lo que no.
"Routes": {
"ratecards-new": {
"ClusterId": "modern",
"Match": { "Path": "/ratecards/{**catch-all}" },
"Order": 1
},
"consignments-api-new": {
"ClusterId": "modern",
"Match": {
"Path": "/consignments/{**catch-all}",
"Headers": [ { "Name": "Accept", "Values": [ "application/json" ] } ]
},
"Order": 2
},
"everything-legacy": {
"everything-else-legacy": {
"ClusterId": "legacy",
"Match": { "Path": "{**catch-all}" },
"Order": 100
}
}
Los números de orden bajos son las rutas migradas, así que las nuevas se añaden arriba y el catch-all nunca tiene que moverse. Leer este archivo de arriba abajo es leer la migración en orden.
Qué se rompió
- Síntoma
- Inicia sesión, envía cualquier formulario, y el navegador acaba en una URL que contiene el puerto interno (meridian-app01:52341), inalcanzable desde fuera. Todos los GET funcionaban.
- Causa
- Meridian.Web construye URLs absolutas a partir de Request.Url. Detrás de un proxy esa es la dirección del salto interno, no la que pidió el cliente. Solo el redirect-después-de-POST lo expone, porque solo los redirects meten una URL absoluta construida por el servidor en una cabecera Location.
- Detectado por
- A mano, usando la aplicación. Ningún test cubría un destino de redirect, y ninguna línea de log estaba mal: la aplicación se comportaba exactamente como estaba escrita.
El instinto fue que un proxy nuevo había roto algo, así que se fueron dos días en
el reenvío de cabeceras de YARP antes de que nadie leyera Request.Url. El proxy
estuvo correcto todo el tiempo. La aplicación siempre se había equivocado sobre
su propia dirección; nunca nadie se lo había preguntado.
El arreglo del lado 4.8 es una URL base consciente de X-Forwarded-*. Del lado
.NET 8 es UseForwardedHeaders, que está en el commit de la etapa 04 y no costó
nada, porque para entonces era un problema conocido y no uno descubierto.
Coste
Cada petición tiene ahora un salto de red extra, un conjunto extra de timeouts que ajustar, y un segundo sitio donde mirar cuando algo va lento. El gateway se convierte además en un punto único de fallo para una aplicación que antes tenía uno menos.
Compró
El tráfico se puede dirigir por ruta. Cada etapa posterior es un cambio de configuración en un archivo, reversible en segundos, en lugar de una ventana de despliegue.
Una transcripción de la aritmética vieja, conservada a propósito
La lógica de precios de 4.8, copiada sin mejoras dentro de una fixture de test, para que cada etapa posterior tenga algo contra lo que estar equivocada.
La línea base tenía cero tests. Eso es normal y es justamente el problema: no existe una definición de comportamiento correcto en ninguna parte salvo el propio comportamiento.
Así que la definición se escribe. LegacyRating.cs es una transcripción de la
aritmética que hay dentro de ConsignmentsController.Create, con la base de
datos, el HttpContext y MSMQ recortados y nada más tocado. El mismo
anidamiento, el mismo orden, los mismos dos puntos de redondeo separados.
No es una versión más ordenada. Todo su valor está en no ser una mejora.
Barrer, no muestrear
Cuatro mil entradas generadas, comparadas campo a campo, fallando por un céntimo. La semilla es fija: un arnés que genera entradas distintas en cada ejecución no puede decirte si arreglaste algo.
Ejemplos elegidos a mano se habrían perdido las tres discrepancias interesantes encontradas hasta ahora, porque todas estaban en una frontera para la que nadie habría pensado en escribir un test.
- Un código postal de Dublín que empieza por PA
- El país tiene que ganarle al prefijo postal. En el original le ganaba, pero solo porque la comprobación de país corría la última y sobrescribía lo que las comprobaciones de prefijo ya habían fijado. Reescrito como una tabla de consulta, la lectura natural lo invierte y Dublín tarifica como las Tierras Altas de Escocia.
- Exactamente 10kg en un tarifario cuyas bandas se juntan en 10
- La aplicación vieja tenía dos consultas de tarifa que discrepaban en la frontera: SQL en línea con BETWEEN en la pantalla de reserva, una consulta LINQ en la pantalla del tarifario. Nueve años, nadie lo sabía.
- Un neto que redondea hacia arriba mientras su IVA redondea hacia abajo
- El asunto del segundo commit de esta etapa, más abajo.
El commit que arregló un bug que los tests no podían ver
El motor sacaba el IVA del neto ya redondeado. El controlador 4.8 saca el IVA del neto sin redondear y redondea el neto por separado, en la línea siguiente.
Los cuatro mil casos de paridad pasaban de las dos maneras.
Dos líneas intercambiadas, ninguna diferencia observable en todo el corpus, y merecedoras de un commit propio.
var roundedNet = Round(net);
var vat = Round(roundedNet * vatRate);
var vat = Round(net * vatRate);
var roundedNet = Round(net);
Pasar no es equivalencia. Distinguir esos dos exige un neto cuyo tercer decimal lleve su IVA a través de una frontera de medio céntimo, y un corpus que nunca produce uno no ha demostrado nada: ha fallado en refutar.
Fui a buscar una entrada divergente con un barrido dirigido de cuatro millones de valores y tampoco encontré ninguna. Eso sigue sin ser equivalencia; es una búsqueda más estrecha con la misma forma de respuesta.
- Síntoma
- Nada. Todos los tests pasaron, y un barrido de cuatro millones de valores en busca de un contraejemplo también salió vacío.
- Causa
- El motor y el original sacan el IVA de cantidades distintas: uno redondea antes de multiplicar, el otro después. La diferencia es estructural y el corpus de tests simplemente nunca produjo una entrada que la expusiera.
- Detectado por
- Leyendo las dos implementaciones lado a lado mientras escribía esta entrada. Ningún test lo habría hecho.
El arreglo consiste en igualar la estructura del original en vez de su salida observada. Coste de hacerlo: nada. Coste de equivocarse: un céntimo sistemático en 41.000 envíos, y una conversación con finanzas, que concilia los totales nuevos contra los viejos.
Eso se convirtió en la regla para el resto de la migración. Donde la forma del código viejo es arbitraria, mejórala. Donde la forma es estructural y el corpus de tests no puede distinguir, copia la forma.
El test que estaba mal
Un test afirmaba que medio kilo se factura como medio kilo. Falla: medio kilo se
factura como uno, porque MinimumChargeableWeightKg se aplica antes del redondeo.
El motor tenía razón y el test estaba mal. Se conserva, con la expectativa corregida y un comentario que explica la trampa, porque la siguiente persona que lea la fórmula volumétrica hará exactamente la misma suposición.
Coste
Un archivo en el proyecto de tests que es una copia deliberada de código que se está borrando, que todo revisor marca como duplicación, y que hay que mantener si las reglas viejas cambian durante la migración.
Compró
Cada etapa posterior se puede comprobar contra algo en lugar de revisarse por plausibilidad. Dos de los tres defectos de arriba habrían llegado a producción.
LegacyRating.cs se borra el día que se apague la aplicación 4.8, y ni un commit
antes.
La cookie que tenía que ser legible por las dos stacks
La autenticación por formularios cifra con MachineKey y ASP.NET Core no puede leerlo. Nada podía desplegarse hasta resolver esto.
Esta es la etapa que decide si una migración strangler es posible siquiera, y por eso va antes de que se mueva ningún módulo.
La autenticación por formularios cifra su ticket con MachineKey. ASP.NET Core
no tiene MachineKey: tiene Data Protection, un algoritmo distinto con otra
derivación de clave y otro formato de payload. De fábrica, las dos stacks no
pueden leer la cookie de la otra.
La consecuencia no es sutil. Un usuario sigue un enlace desde una página servida por la aplicación vieja hacia una servida por la nueva y sencillamente se queda sin sesión. Cada ruta que se mueve se lleva la sesión del usuario con ella.
- Meridian.Web.NET 4.8
- Ticket FormsAuthMachineKey
- .MERIDIANAUTHcookie
- Meridian.Web.NET 4.8
- Meridian.Api.NET 8
- Data Protectionkey ring compartido
- .MERIDIANAUTHuna cookie
Las dos aplicaciones escriben y leen una sola cookie, a través de un key ring en una ruta compartida. El lado 4.8 llega ahí con Microsoft.AspNetCore.DataProtection.SystemWeb y el formateador de tickets de interop de Owin.
Por qué no simplemente iniciar sesión dos veces
La alternativa obvia (emitir una segunda cookie desde la aplicación nueva y dejar que existan las dos) se probó y se abandonó en una tarde.
Dos cookies significan dos relojes de expiración, dos renovaciones de expiración deslizante que se separan, y un logout que tiene que llegar a las dos aplicaciones o deja una sesión viva. También significa que el momento de la verdad se traslada a la primera petición que necesite la cookie de la otra aplicación, lo cual es impredecible y por tanto no testeable.
Una cookie da más trabajo al principio y es una cosa más pequeña sobre la que razonar después.
Dos detalles que no son detalles
El elemento machineKey desaparece por completo. Lo que lo sustituye no es una clave de configuración, sino dos líneas de arranque, y cada una costó un día.
<machineKey
validationKey="C1B4E5F3A0D8827E4C6B19A2F70D5E8C3B6A94F1D27E0C58B..."
decryptionKey="8F3A0D5C7E92B4160A8D3F5C7E92B4160A8D3F5C7E92B416"
validation="HMACSHA256"
decryption="AES" />
<authentication mode="Forms">
<forms name=".MERIDIANAUTH" loginUrl="~/Account/LogOn" timeout="480"
slidingExpiration="true" protection="All" path="/" />
</authentication>
builder.Services
.AddDataProtection()
.SetApplicationName("Meridian")
.PersistKeysToFileSystem(new DirectoryInfo(
builder.Configuration["DataProtection:KeyRingPath"]
?? throw new InvalidOperationException("DataProtection:KeyRingPath is required.")));
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
{
o.Cookie.Name = ".MERIDIANAUTH";
o.ExpireTimeSpan = TimeSpan.FromMinutes(480);
o.SlidingExpiration = true;
});
SetApplicationName tiene que coincidir en los dos lados. Data Protection
deriva su cadena de propósito del nombre de la aplicación. Dos aplicaciones con
nombres distintos producen claves que no pueden leer los payloads de la otra
mientras parecen perfectamente configuradas: mismo directorio, mismos archivos,
mismos permisos.
KeyRingPath no tiene valor por defecto y el arranque revienta sin él. Eso
es deliberado, y es el más importante de los dos.
- Síntoma
- Un usuario inicia sesión correctamente (se comprueban las credenciales, se escribe el ticket) y se le redirige inmediatamente de vuelta a la pantalla de login. Repetir para siempre. Sin error, sin excepción, nada en el log de ninguna de las dos aplicaciones por encima de Information.
- Causa
- La aplicación nueva no encontró una ruta de key ring configurada, así que Data Protection hizo aquello para lo que está diseñado y generó una efímera en memoria. Después emitió una cookie que la aplicación 4.8 no podía descifrar, que redirigía al login, que emitía una cookie que la aplicación nueva no podía descifrar.
- Detectado por
- Al notar que el directorio del key ring en disco tenía un archivo XML y una marca de tiempo más antigua que el despliegue.
El modo de fallo es lo que hace que esto merezca el párrafo. Una ruta de key ring ausente es invisible en desarrollo, donde solo hay una aplicación y habla felizmente consigo misma. Solo falla donde dos aplicaciones tienen que ponerse de acuerdo, que es el único entorno que nadie ejecuta en su portátil.
Así que el código revienta en el arranque en vez de asumir un valor por defecto. Un valor de configuración cuya ausencia produce una aplicación que parece funcionar y un sistema roto no debería tener valor por defecto.
Qué arregló además esta etapa, gratis
UseForwardedHeaders está en este commit. Es el mismo problema en el que la etapa
01 gastó dos días (una aplicación construyendo URLs absolutas a partir de su
propia dirección interna), y aquí no costó nada, porque a estas alturas era una
propiedad conocida de correr detrás del gateway y no un descubrimiento.
Ese es la mayor parte del argumento para hacer la infraestructura primero.
Costeno es temporal
La autenticación de las dos aplicaciones depende ahora de un directorio en un sistema de archivos. Es un punto único de fallo nuevo, hay que respaldarlo, y sus permisos son estructurales de una manera que no lo eran los de ningún archivo de configuración.
Compró
Una cookie, una sesión, un logout. Un usuario que se mueve entre las mitades vieja y nueva de la aplicación no puede notar que la frontera existe, que es toda la premisa de una migración strangler.
Tarifarios, la cosa más pequeña que merecía moverse
Una pantalla de solo lectura con lógica de dominio real y ninguna capacidad de costarle dinero a nadie, y el valor por defecto de EF Core que truncaba en silencio cada decimal.
El primer módulo que se mueve se elige casi por completo por lo que no puede hacer.
Los tarifarios son de solo lectura. Sin escrituras, sin cola, sin informes, sin dinero moviéndose. Lo que sí tienen es lógica de dominio real (búsqueda de banda, vigencia por fechas) y un camino que toca todas las partes de la stack nueva: enrutamiento en el gateway, la cookie compartida, EF Core contra el esquema en vivo, el proyecto compartido de tarificación.
Cobertura máxima del camino nuevo, radio de explosión mínimo. Ese es todo el criterio de selección.
El instinto que estaba equivocado
El instinto era empezar por la capa de datos, porque parece el cimiento y todo lo demás se apoya en ella.
Es la peor primera elección posible. La capa de datos es la más acoplada a APIs
que solo existen en Framework: ConsignmentRepository lee su tenant de
HttpContext.Current y no se puede llamar desde nada que no sea una petición
web, cosa que el proceso nocturno de manifiestos descubrió en 2018 y sortea
falsificando un contexto de petición. Empezar ahí pone la pieza de trabajo más
difícil antes de que nadie tenga confianza alguna en la stack nueva, y antes de
que haya evidencia de que el enfoque funciona siquiera.
Empezar por una pantalla que muestra números produce algo desplegable en días. Eso importa más que el orden arquitectónico en el primer mes de una migración, porque lo que más probablemente la mate no es un problema técnico.
Misma base de datos, sin migración de datos
Las mismas tablas, las mismas filas, el mismo esquema en el que la aplicación 4.8 sigue escribiendo. La migración mueve código, no datos.
Nada en el nuevo DbContext crea ni migra nada. Dos modelos code-first
compitiendo por ser dueños de un esquema es como se pierde un fin de semana y
luego un sábado restaurando desde copia de seguridad.
Qué se rompió
- Síntoma
- Todas las pantallas mostraban cifras correctas. Las lecturas eran perfectas. Las escrituras descartaban en silencio el cuarto decimal de cada columna de dinero, y nada en ningún sitio reportaba un error.
- Causa
- A EF6 se le había dicho explícitamente decimal(18,4). EF Core asume (18,2) por defecto para decimal. Como la aplicación siempre ha redondeado a dos decimales antes de escribir, cada valor que ya estaba en la tabla terminaba en dos ceros, así que el truncamiento no tenía nada que truncar y siguió invisible hasta que pasó una banda tarifaria con una tarifa genuina de cuatro decimales.
- Detectado por
- El arnés de paridad. Nada dentro de la aplicación lo habría hecho: sin excepción, sin aviso, sin ningún número visiblemente mal en ninguna pantalla.
Esta es la lección que llevarse de todo el diario. Es invisible, es silenciosa, afecta al dinero, y la causa un valor por defecto y no algo que escribiera nadie.
El arreglo es un bucle sobre el modelo en lugar de una anotación por propiedad, porque el modo de fallo del enfoque con anotaciones es olvidarse de una.
mb.Entity<Consignment>().Property(x => x.Carriage).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.FuelSurcharge).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Net).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Vat).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Gross).HasPrecision(18, 4);
mb.Entity<RateBand>().Property(x => x.RatePerKg).HasPrecision(18, 4);
foreach (var property in b.Model.GetEntityTypes()
.SelectMany(t => t.GetProperties())
.Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
{
property.SetColumnType("decimal(18,4)");
}
Seis líneas escritas a mano se convierten en un bucle, y la propiedad que se añada el año que viene queda cubierta sin que nadie tenga que acordarse.
El comportamiento que cambió a propósito
La aplicación vieja tenía dos búsquedas de tarifa. El SQL en línea de la pantalla
de reserva usaba BETWEEN y ordenaba por FromKg descendente. Una consulta LINQ
en la pantalla del tarifario usaba >= y <= y ordenaba igual. En un peso justo
en la frontera (10,0kg en un tarifario cuyas bandas son 0-10 y 10-25) devolvían
bandas distintas.
Las dos llevaban nueve años en producción. Nadie lo sabía.
Elegir entre ellas es una pregunta comercial disfrazada de técnica, y se resolvió sobre esa base: gana la versión en SQL, porque es la que tarificó nueve años de facturas. La versión LINQ nunca fue más que una vista previa en una pantalla desde la que nadie factura.
Un cambio de esquema
Los tres ids de cliente escritos a fuego en ConsignmentsController.Create se
convirtieron en una columna.
if (customer.Id == 1104 || customer.Id == 1187 || customer.Id == 1203)
net = net - (net * customer.DiscountPercent / 100m);
else
net = net - (carriage * customer.DiscountPercent / 100m);
ALTER TABLE dbo.Customer
ADD DiscountAppliesToNet BIT NOT NULL
CONSTRAINT DF_Customer_DiscountAppliesToNet DEFAULT (0);
UPDATE dbo.Customer SET DiscountAppliesToNet = 1 WHERE Id IN (1104, 1187, 1203);
Es el único cambio que esta migración hizo sobre una tabla existente. Se gana su sitio dos veces: el motor de tarificación puede seguir siendo puro, y una cuarta cuenta con la misma cláusula de contrato pasa a ser una fila en vez de un despliegue.
Coste
Ahora existen dos implementaciones de la pantalla de tarifarios y las dos son alcanzables: la 4.8 directamente, la de .NET 8 por el gateway. Hasta que se borre la ruta vieja, un reporte de bug tiene que establecer cuál de las dos estaba mirando quien lo reportó.
Compró
Un camino completo y desplegable por la stack nueva, probado de punta a punta, sobre un módulo donde equivocarse no cuesta nada. Todo lo que viene después es una variación de una ruta que ya funciona.
MSMQ, un outbox, y un proceso que se queda en 4.8 para siempre
La dependencia que no se puede migrar en absoluto, así que se encogió todo lo posible y se puso en cuarentena.
System.Messaging nunca se portó a .NET Core y no se hará. No hay shim, no hay
paquete de compatibilidad, no hay port de la comunidad, no hay forma soportada de
poner un mensaje en una cola MSMQ desde .NET 8.
Así que la pregunta no es cómo migramos MSMQ. Es qué hacemos con el hecho de que no podemos.
Qué se descartó
Reescribir la integración con el depósito sobre RabbitMQ o Service Bus. La cola la lee software propiedad de un tercero que fue adquirido en 2020. Cambiar el transporte significa una negociación con otra empresa sobre una interfaz que lleva contrato, en su calendario, con sus pruebas.
Eso es un proyecto de verdad con un presupuesto de verdad, y no es este. Una migración que absorbe en silencio la reescritura de una integración de terceros es una migración que no termina, y la forma en que falla es que deja de ser posible decir qué queda.
Dejar todo el flujo de reserva en 4.8 hasta que el depósito cambie. Esto convierte la única cosa que no se puede migrar en un bloqueante para todo lo que sí. El camino de escritura de envíos es el núcleo de la aplicación; congelarlo congela la migración.
Qué se eligió
La dependencia no desaparece. Se encoge todo lo que puede encogerse, y se dibuja una línea a su alrededor.
- Acción Create.NET 4.8
- SaveChangesEF6 · confirmado
- MessageQueue.SendSystem.Messaging
- Depósitotercero
- POST /consignments.NET 8
- SaveChangesEF Core · una transacción
- DespatchOutboxtabla
- Sidecar.NET 4.8
- Depósitotercero
La dependencia 4.8 sigue ahí. Ahora es un proceso de 200 líneas sin superficie web y sin lógica de negocio, cuyo trabajo entero es leer una tabla y escribir en una cola.
El sidecar es el rehén más pequeño que la migración pudo dejar atrás. Cuando el
sistema del depósito se sustituya algún día, borrar sidecar/ es todo el cambio.
Un arreglo de corrección que no era el objetivo
El código viejo enviaba a MSMQ después de que SaveChanges() ya hubiera
confirmado, fuera de cualquier transacción:
Dos escrituras en una base de datos no pueden pasar a medias. Una escritura en la base y un envío a una cola sí pueden, y pasaba.
_db.Consignments.Add(consignment);
_db.SaveChanges();
using (var queue = new MessageQueue(queuePath))
{
// No transaction. If SaveChanges above succeeded and this throws, the
// consignment exists and the depot never hears about it.
queue.Send(msg);
}
db.Consignments.Add(consignment);
outbox.Enqueue(consignment.Reference, DespatchMessage.Format(...));
await db.SaveChangesAsync(ct);
Cuando el envío reventaba, el envío existía y el depósito nunca se enteraba. La única forma en que alguien lo descubría era un cliente preguntando dónde había ido a parar su palé.
La expedición ahora es asíncrona por unos segundos, cosa que al depósito le da igual, y no se puede saltar en silencio, cosa que a todo el mundo no.
Qué se rompió
- Síntoma
- El depósito registró la misma recogida dos veces. Dos furgonetas, un palé, y un contacto muy poco impresionado en el extremo receptor.
- Causa
- El drenador reintenta cuando un envío falla. Un envío que en realidad había funcionado y luego agotó el tiempo esperando la confirmación se reintentó, y MSMQ ya había aceptado el primero. No había manera de que el depósito distinguiera los dos.
- Detectado por
- Una prueba de saturación contra una cola con un 5% de timeout de confirmación artificial. No se reproduce en absoluto en condiciones normales, que es por lo que habría llegado a producción.
El arreglo es IdempotencyKey y un índice único. La clave se deriva del cuerpo
del mensaje, así que un reenvío genuino de contenido cambiado es otra fila y un
reintento ciego no lo es.
El orden que estaba mal al principio
OutboxDrain reclama un lote, envía cada mensaje, y después lo marca como
enviado. La primera versión marcaba y después enviaba, que se lee de forma más
natural y nunca deja una fila pareciendo no enviada cuando sí lo estaba.
Lo que hace en cambio es perder mensajes cuando el envío revienta.
La forma general: enviar dos veces es recuperable, no enviar nunca no lo es. Ante la elección entre el modo de fallo que duplica y el que descarta, coge el que duplica, y luego gasta el esfuerzo en hacer los duplicados detectables.
Costeno es temporal
Ahora hay en el parque un proceso .NET Framework 4.8 que sobrevivirá a la migración. Necesita un host Windows, su propio despliegue, su propia monitorización, y es una segunda cosa que puede estar caída cuando las expediciones dejan de moverse.
Compró
La aplicación .NET 8 puede ser dueña del camino de escritura de envíos (que es el núcleo del sistema) sin poder hablar con MSMQ en absoluto. Y el traspaso se volvió transaccional de paso.
La etapa que no produjo código
Veinte informes RDLC sin renderizador en .NET 8. La decisión fue no mover ninguno, y escribir la regla de cuándo eso cambia.
Microsoft.ReportViewer.WebForms es un ensamblado de .NET Framework. No hay
sucesor en .NET Core ni .NET 5+, y RDLC no tiene otro renderizador: el formato
está atado al único control que lo lee.
Hay veinte de estos archivos, escritos en el diseñador de informes de Visual
Studio entre 2011 y 2017. Uno tiene un subinforme que busca sus propias filas.
Varios enlazan directamente con un DataTable devuelto por un procedimiento
almacenado.
La estimación que mató la reescritura
Reescribir veinte informes en QuestPDF está entre tres y seis semanas, y la estimación es amplia porque los informes RDLC se maquetan visualmente y su comportamiento en un salto de página es emergente, no especificado. No sabes qué hace un informe al pie de la página dos hasta que renderizas uno.
Así que la pregunta útil no es cuánto cuesta. Es qué compra.
- Dos documentos de cara al cliente
- Factura y manifiesto. Los ven clientes, se imprimen en papel con membrete, y son los únicos en los que alguien notaría un cambio. Han cambiado tres veces entre los dos desde 2017.
- Dieciocho informes operativos internos
- Rendimiento de depósito, listas de excepciones, conciliación de cierre de mes. Los leen cuatro personas. Diecinueve de los veinte archivos no se editan desde 2017.
Migrar algo que no ha cambiado en ocho años no compra nada salvo la satisfacción de haberlo migrado. Los informes funcionan. Renderizan el mismo PDF que renderizaban en 2017, sobre un runtime con soporte hasta 2031.
Qué se decidió realmente
Los veinte se quedan. No se movió código de informes, y el diff lo enseña: no hay
generación de PDF por ninguna parte de modern/.
La regla que se acordó en su lugar: un informe se reescribe la próxima vez que alguien pida un cambio en él, y no antes. La reescritura la paga entonces el cambio que la disparó, en un momento en que a alguien de verdad le importa la salida y puede decirte si está bien.
Con la evidencia actual eso es casi nunca para dieciocho de ellos, y esa es la respuesta correcta para dieciocho de ellos.
El commit que produjo esta etapa
Ninguno. El panel de metadatos lo dice en lugar de mostrar un cero, porque son hechos distintos y el sitio está construido para no poder confundirlos.
El único commit que tocó los informes en toda la historia es ecb860b, y no
cambia ningún informe: corrige este diario. Un borrador anterior del README del
repositorio afirmaba que dos de los veinte se habían reescrito. No era así.
- Síntoma
- La tabla de dependencias del README decía '18 congelados, 2 reescritos'. El historial de commits no contiene generación de PDF de ningún tipo.
- Causa
- Escribí el plan en el README como si fuera el desenlace. Se escribió antes de la estimación, y nunca se revisó después de que la decisión fuera en el otro sentido.
- Detectado por
- Releyendo el inventario contra el diff real mientras escribía esta página.
Esa corrección es la razón de que esta etapa exista siquiera, en lugar de ser un párrafo dentro de la etapa 06. Si no se puede confiar en el inventario de lo que sigue en 4.8, tampoco se puede confiar en el resto del diario: cualquier otra afirmación que contiene es exactamente del mismo tipo, y un lector no tiene manera independiente de comprobar ninguna.
El único activo real de un diario de migración es que sus malas noticias son fiables.
Costeno es temporal
Veinte informes RDLC y una dependencia de ReportViewer se quedan en .NET Framework 4.8 indefinidamente, lo que significa que la aplicación web 4.8 no se puede apagar, lo que significa que el key ring compartido y el parque con dos runtimes también se quedan.
Compró
Tres a seis semanas no gastadas en volver a dibujar documentos que leen cuatro personas y que nadie ha cambiado desde 2017.
Envíos, movidos los últimos
La acción de 272 líneas. Todo lo difícil converge aquí, que es por lo que todas las demás etapas fueron antes.
ConsignmentsController.Create tiene 272 líneas y hace nueve cosas. El dinero,
la cola, el tenant, la comprobación de duplicados y el rastro de auditoría se
encuentran todos dentro de ella.
Se mueve la última, y el orden de todas las etapas anteriores se eligió para que pudiera. Para cuando se escribió este endpoint, las reglas de tarificación ya estaban extraídas y demostradas (02, 03), la cookie ya era compartida (04), la stack nueva ya tenía un camino de punta a punta funcionando (05), y la cola ya tenía una respuesta que no pasaba por MSMQ (06). Lo que quedaba era orquestación.
- Antes
- Un método de acción, 272 líneas, 0 tests
- Después
- Un endpoint, 215 líneas, delegando en 4 colaboradores con nombre
- Tarificación
- Meridian.Domain.RatingEngine: 4.011 aserciones
- Búsqueda de tarifa
- IRateLookup: una implementación, eran dos
- Expedición
- IDespatchOutbox: transaccional, era dispara-y-olvida
- Auditoría
- No movida. Sigue siendo un módulo HTTP del lado 4.8.
Tres cosas que aquí empeoraron
Declaradas en lugar de escondidas, porque una etapa en la que todo mejoró es una etapa que no se ha mirado bien.
El motor de tarificación se llama dos veces. Una para obtener el peso facturable, otra para tarificarlo, porque la búsqueda de tarifa necesita el peso, y el peso es asunto del motor. La alternativa era duplicar la fórmula volumétrica en el punto de llamada, que es exactamente cómo la aplicación vieja acabó con dos de ellas. Una llamada redundante es más barata que una segunda copia de una regla, pero es genuinamente redundante y el siguiente lector se lo va a preguntar.
La asignación de referencia sigue siendo MAX + 1. Dos administrativos que
registran en el mismo segundo siguen chocando.
Portado, no arreglado. El bug se conserva exactamente, con un comentario que lo dice.
var cmd = new SqlCommand(
"SELECT ISNULL(MAX(CAST(SUBSTRING(Reference, 4, 10) AS INT)), 0) FROM Consignment", conn);
var next = (int)cmd.ExecuteScalar() + 1;
return "MER" + next.ToString("D8");
var last = await db.Consignments
.OrderByDescending(c => c.Id)
.Select(c => c.Reference)
.FirstOrDefaultAsync(ct);
var next = last is { Length: > 3 } && int.TryParse(last[3..], out var n) ? n + 1 : 1;
return $"MER{next:D8}";
Arreglarlo bien significa una secuencia, un cambio de esquema y una conversación con el depósito sobre formatos de referencia, porque el formato va por el cable en ese mensaje de ancho fijo. Eso es un trabajo de verdad y no es el de esta migración. Queda anotado como deuda en un comentario en lugar de colarse al amparo de una migración, que es como las migraciones adquieren alcance que nadie aprobó, y como un rollback deja de ser posible.
La declaración aduanera en WCF no se movió. Sigue en el lado 4.8, sigue tragándose sus excepciones desde una quincena mala de 2019, y este endpoint no hace la declaración en absoluto. Eso es un hueco, no una decisión. WCF del lado cliente sí tiene una historia en .NET 8, así que esta sencillamente aún no está hecha.
El ticket que se cerró como efecto colateral
MER-2213, abierto desde 2019: el buscador concatena en la cláusula WHERE.
sql += "AND (c.Reference LIKE '%" + q + "%' " +
" OR cu.Name LIKE '%" + q + "%' " +
" OR c.DeliveryPostcode LIKE '%" + q + "%') ";
query = query.Where(c =>
EF.Functions.Like(c.Reference, $"%{q}%") ||
EF.Functions.Like(c.DeliveryPostcode, $"%{q}%"));
Estuvo abierto seis años no porque fuera difícil, sino porque arreglarlo significaba volver a probar una pantalla que nadie quería volver a probar. Reescribir la pantalla iba a pasar de todas formas, así que el arreglo no costó nada.
Vale la pena ser honesto sobre eso en lugar de reclamarlo como una victoria. La migración no priorizó un defecto de seguridad. Se topó con uno de camino.
El tenant deja de ser ambiental
ConsignmentRepository leía su tenant de HttpContext.Current.Items, lo que
significaba que solo se podía llamar desde dentro de una petición web. El proceso
nocturno de manifiestos lo descubrió en 2018 y lo sortea construyendo un
HttpContext falso.
El tenant llega ahora como un claim. Ese único cambio es lo que permitirá al proceso de manifiestos dejar de fingir que es una petición web cuando se mueva, cosa que aún no ha pasado.
Coste
Las reservas pueden llegar ahora por dos caminos de código contra una base de datos: la pantalla 4.8 para humanos, este endpoint para clientes JSON. Hasta que las pantallas Razor se muevan, cualquier investigación sobre un precio equivocado empieza por establecer cuál de las dos mitades lo tarificó.
Compró
El método de 272 líneas ya no es el único sitio donde existen las reglas de precio, y las reglas que solía poseer están ahora cubiertas por 4.011 aserciones que corren en un tercio de segundo.
Enrutar por Accept, no por ruta
Los escáneres del depósito y las pantallas humanas comparten una URL. Dividir por tipo de contenido movió uno sin mover el otro.
La etapa 08 construyó un endpoint JSON de envíos. El problema es que la
aplicación 4.8 ya sirve /consignments: como vistas Razor, a personas, en un
navegador.
Los dos públicos quieren la misma ruta. Las terminales del depósito y la app
móvil hacen POST a /consignments queriendo JSON; un administrativo abre
/consignments queriendo una pantalla. El espacio de URLs no los distingue, y no
hay prefijo de versión tras el que esconderse porque estos clientes llevan
llamando a esa ruta desde 2014.
Las opciones
Reescribir también las pantallas. Triplica el tamaño de la etapa, y no produce nada para la persona que sostiene un escáner. Las vistas Razor funcionan.
Darle a la API una ruta nueva: /api/consignments. Limpio, y significa
cambiar firmware en terminales de depósito que se actualizan visitando
físicamente los depósitos. Eso es una operación de flota, no un despliegue.
Dividir por la cabecera que los clientes ya envían.
Misma ruta, mismos verbos, clientes distintos. Emparejar la ruta por Accept es lo único que los separa.
"consignments-api-new": {
"ClusterId": "modern",
"Match": {
"Path": "/consignments/{**catch-all}",
"Headers": [
{
"Name": "Accept",
"Values": [ "application/json" ],
"Mode": "Contains"
}
]
},
"Order": 2
}
Los clientes máquina se mudaron a .NET 8 y nadie visitó un depósito. Las pantallas se quedaron exactamente donde estaban.
Por qué esto está escrito dos veces
Una ruta que empareja por cabecera es invisible en un stack trace. Una petición que cae en la mitad equivocada parece un bug en la mitad que la recibió, y ninguna de las dos mitades sabe que la cabecera existe: la aplicación 4.8 no tiene ni idea de que la están sorteando para algunos llamantes, y la de .NET 8 no tiene ni idea de que solo está viendo parte del tráfico.
Así que está en el README del gateway además de en la configuración. La configuración no es documentación: dice qué pasa, nunca por qué, y esta es una regla cuyo porqué es lo único que hace tratable el siguiente incidente.
- Síntoma
- Una terminal de depósito que había funcionado toda la mañana empezó a recibir HTML. Sus peticiones llegaban a la aplicación 4.8, que renderizaba una vista Razor dentro de un parser que esperaba JSON.
- Causa
- La terminal envía Accept: application/json, */* en la mayoría de las llamadas, pero Accept: */* en un camino de reintento de su cliente HTTP. Mode: Contains empareja la cadena literal, y */* no contiene application/json.
- Detectado por
- Comparando las peticiones que funcionaban con las que fallaban. Ninguna de las dos aplicaciones registró nada raro: las dos sirvieron exactamente lo que se les pidió.
El arreglo fue del lado del cliente, porque la alternativa (tratar */* como
JSON en el gateway) enrutaría la petición de reserva de un navegador hacia la API
y rompería las pantallas en su lugar. Una división por cabecera solo funciona
cuando la cabecera de verdad discrimina, y averiguar cuáles de tus clientes son
descuidados con Accept es parte del coste de elegirla.
Coste
Una URL la sirven ahora dos aplicaciones según una cabecera, lo cual es genuinamente sorprendente y volverá a sorprender a alguien. También significa que las dos mitades deben seguir de acuerdo sobre qué es un envío mientras ambas existan.
Compró
Una clase entera de cliente cambió de runtime sin ningún cambio en el cliente, sin release coordinado, y sin visitar ningún depósito.
El que compila y luego falla en producción
System.Drawing sigue en 4.8, y el motivo por el que está atascado es peor que "sin equivalente en .NET 8".
Label.ashx renderiza la etiqueta de expedición 4x6 que esperan las impresoras
del depósito, a través de GDI+, incluido un código de barras dibujado a mano a
partir de una tabla de anchos de barra Code 39, porque el componente con licencia
se abandonó en 2012 cuando nadie pudo encontrar el archivo de licencia.
No se ha movido, y vale la pena ser preciso sobre por qué, porque
“System.Drawing no funciona en .NET 8” es solo media verdad y la mitad que es
falsa es la peligrosa.
Compila. Ese es el problema.
System.Drawing.Common sigue existiendo como paquete. La API está toda ahí. El
código que lo usa compila sin un solo aviso, y funciona perfectamente en la
máquina Windows de un desarrollador.
Desde .NET 6 en adelante lanza PlatformNotSupportedException en tiempo de
ejecución sobre cualquier cosa que no sea Windows. Así que la primera vez que
falla es dentro de un contenedor Linux, en el primer entorno que ejecute uno, que
es un modo de fallo estrictamente peor que no compilar, porque pasa todas las
puertas que lo habrían pillado antes.
- El código de barras es geometría, no una llamada a biblioteca
- Los anchos de barra vienen de una tabla Code 39 y se dibujan como rectángulos rellenos con un ancho de barra estrecha calculado. Portarlo significa volver a derivar esa geometría contra SkiaSharp o ImageSharp, no cambiar un import.
- La salida tiene una prueba de aceptación física
- Los escáneres del depósito son quisquillosos con el DPI y las zonas de silencio. La única forma de saber si una etiqueta portada es correcta es imprimir una y escanearla, en un depósito, con el hardware real.
- El endpoint es anónimo
- Un bloque <location> en el Web.config lo permite sin cookie, porque el firmware del escáner no puede presentar una. Moverlo significa resolver eso también, y la respuesta actual es que la referencia es difícil de adivinar.
Por qué está listado siquiera
Porque un inventario que solo contiene problemas resueltos no es un inventario.
Esta etapa produjo un commit y ese commit solo edita un README: quita una línea que afirmaba que el renderizador de etiquetas se había migrado. No era así. La afirmación se escribió desde el plan en lugar de desde el diff, en el mismo borrador que exageró lo de los informes.
Dos de estas en un solo documento son un patrón, no un desliz, y el patrón es que el resumen se escribió antes del trabajo y nunca se volvió a contrastar con lo que realmente aterrizó. La corrección es barata. El hábito que la produjo no lo es, y es la razón de que cada etapa de este diario lleve ahora sus hashes de commit en el panel de la derecha: una afirmación con un hash al lado se puede comprobar en unos cuatro segundos, y una sin él no se puede comprobar en absoluto.
Coste
La aplicación web 4.8 tiene que seguir corriendo para servir peticiones de etiquetas, lo que contribuye al mismo coste de parque que los informes. Además es el único endpoint anónimo del sistema y está en el lado viejo, donde las nuevas políticas de autorización no llegan.
Compró
Nada todavía. Esto es trabajo no hecho, registrado como trabajo no hecho.
Qué sigue corriendo sobre .NET Framework, y por qué
El inventario honesto. Un diario de migración que se detiene en la última etapa exitosa es un documento de ventas.
La migración no está terminada, y con los planes actuales partes de ella nunca lo estarán. Ese es el desenlace normal y vale la pena escribirlo con claridad, porque la versión de este documento que termina en la etapa 08 con todo en verde sería inútil para quien esté decidiendo si empezar una.
- El sidecar de expedición
- MSMQ no tiene sucesor. Acotado a ~200 líneas, sin superficie web, sin lógica de negocio. Borrable el día que cambie el sistema del depósito, y no antes. Ver etapa 06.
- Veinte informes RDLC
- Microsoft.ReportViewer es solo de Framework. Congelados por decisión, reescritos uno a uno cuando alguno cambie. Diecinueve no han cambiado desde 2017. Ver etapa 07.
- El renderizado de etiquetas
- System.Drawing. Bloqueado en una reescritura de código de barras con prueba de aceptación física. Conocido, dimensionado, sin empezar. Ver etapa 10.
- Las pantallas de envíos
- Vistas Razor servidas por IIS. Los clientes JSON se mudaron en la etapa 09; las pantallas no tenían motivo para seguirlos ni presupuesto para hacerlo.
- La declaración aduanera
- Una llamada WCF, aún en el lado viejo, aún tragándose sus excepciones. WCF del lado cliente sí tiene una historia en .NET 8, así que esta genuinamente solo está sin hacer.
- Los módulos de auditoría
- Dos IHttpModules que escriben una fila por petición, de forma síncrona, en el hilo de la petición. Hoy cubren las dos mitades solo porque cada petición sigue pasando primero por IIS.
Qué empeoró y siguió peor
Cada etapa de arriba tiene un panel de coste. Varios están marcados como no es temporal, y esto es lo que suman.
Hay dos runtimes, dos pipelines de despliegue, y dos sitios donde mirar cuando
algo va lento. La autenticación depende de un directorio en un sistema de
archivos que las dos aplicaciones tienen que poder leer, lo que es un punto único
de fallo que antes no existía. Una URL la sirven dos aplicaciones según una
cabecera HTTP. Las reglas de tarificación están escritas con un estándar de
lenguaje de 2015 porque tienen que compilar en net48.
Nada de eso es un impuesto de transición que desaparece al final. Es el coste corriente de haber elegido una migración strangler, y dura lo que duren el sidecar y los informes, es decir, indefinidamente.
Eso es lo que hay que pesar contra una reescritura big-bang. No “strangler es más seguro” en abstracto, sino: ¿estás dispuesto a ejecutar dos de cada cosa durante años, a cambio de no tener nunca un fin de semana en que la empresa entera no pueda registrar envíos?
Para un sistema que recibe reservas todo el día, ese intercambio merece la pena. Para una herramienta interna con cuarenta usuarios y una ventana de mantenimiento, muy a menudo no, y el patrón strangler acaba aplicándose a cosas a las que les habría venido mejor una reescritura y un día festivo.
La regla que produjo esta forma
Una regla, sostenida en cada commit: ningún commit puede dejar la aplicación sin poder desplegarse.
Por eso la forma de arriba queda desordenada. Una migración ordenada (una en la que el inventario final está vacío y el diagrama de arquitectura no tiene líneas punteadas) es una migración que tumbó el sistema para llegar ahí.
Catorce commits, y en todos ellos Meridian Despatch podía registrar una reserva.
Qué haría distinto
Nada del orden. Gateway primero, autenticación segundo, módulo más pequeño tercero estaba bien, y los dos días perdidos en la etapa 01 fueron el precio de aprender qué le hace el proxy a una aplicación que cree conocer su propia dirección.
Lo que sí cambiaría es la escritura. Dos veces en este diario (los informes en la
etapa 07, las etiquetas en la etapa 10) el resumen se escribió desde el plan en
lugar de desde el diff, y reclamó trabajo que no había ocurrido. Los dos se
pillaron releyendo el inventario contra git log, lo que significa que la
comprobación funciona, pero era una comprobación aplicada al final en vez de un
hábito aplicado de principio a fin.
El arreglo es el panel de la derecha de cada etapa de esta página. Una afirmación con un hash de commit al lado se puede comprobar en cuatro segundos. Una sin él no se puede comprobar en absoluto, y en un documento cuyo único activo real es que sus malas noticias son fiables, esa diferencia lo es todo.