Mihael Costa/diário de migração
← índice
MIGRAÇÃO DE LEGADO

De .NET Framework 4.8 a .NET 8, decisão por decisão

Um back office de fretes construído em 2011: MVC 5, umWeb.config de 304 linhas, uma action de controller com 272 linhas e quatro dependências sem sucessor. Estrangulado uma rota por vez, com as falhas mantidas no registro.

  • .NET 8
  • EF Core
  • YARP
  • Strangler fig
00Sistema inteiro

A aplicação que herdamos

O que a Meridian Despatch realmente era, medida em vez de caracterizada, antes de qualquer coisa se mover.

A Meridian Despatch registra fretes. Um atendente cadastra uma coleta, o sistema precifica, uma etiqueta é impressa num depósito, um manifesto sai durante a noite e uma fatura vem no fim do mês. Está em serviço desde 2011 e funciona, que é a primeira coisa que vale dizer sobre ela: ninguém está migrando isto porque está quebrado.

Antes de descrevê-la, aqui está o que ela mede. Adjetivos sobre código legado são baratos; os números não são.

Meridian.Web no commit em que a herdamos
C#
1.331 linhas em 20 arquivos
Web.config
304 linhas
Binding redirects
18
Chaves em appSettings
22, das quais 12 são regras de negócio
Pacotes NuGet
22, via packages.config
Entradas Compile
18, mantidas à mão no .csproj
Método mais longo
ConsignmentsController.Create: 272 linhas
Testes
0

O método que é a migração

ConsignmentsController.Create tem 272 linhas. Numa única action ele deriva o peso taxável, descobre a zona de entrega a partir de uma cadeia de prefixos de CEP, abre uma SqlConnection para consultar uma faixa tarifária, aplica adicionais de combustível, área remota, entrega agendada e carga perigosa, aplica um desconto contratual que se comporta de forma diferente para três clientes, calcula o imposto, roda uma checagem de duplicidade concatenando os valores do formulário num SELECT COUNT(*), salva via EF6, envia uma string de largura fixa para o MSMQ, chama um serviço SOAP e escreve duas linhas de auditoria.

Não existe camada de serviço para onde movê-lo. Não existe costura. Não é tanto mal escrito quanto indistinto: nove anos de pequenas decisões corretas, cada uma fazendo sentido no dia, nenhuma delas jamais consolidada.

Esse método é a migração. Todo o resto é preparação para conseguir tocá-lo.

Quatro dependências sem sucessor

Esta é a parte que decide se uma migração é um projeto ou uma aventura. Diferenças de framework e de linguagem são trabalho; uma dependência que simplesmente não existe do outro lado é uma decisão.

Sem equivalente em .NET 8
System.Messaging
MSMQ, a entrega para o depósito. Nunca foi portado para o .NET Core e nunca será. Sem shim, sem pacote de compatibilidade.
Microsoft.ReportViewer
Vinte relatórios RDLC escritos no designer de relatórios do Visual Studio entre 2011 e 2017. O formato não tem outro renderizador.
MachineKey
A autenticação por formulário criptografa o cookie dela com isso. O ASP.NET Core tem Data Protection no lugar, que é um algoritmo diferente.
System.Drawing
Etiquetas de despacho, incluindo um código de barras desenhado à mão a partir de uma tabela Code 39 porque o componente licenciado se perdeu em 2012.

Dois defeitos deixados de propósito

A caixa de busca concatena o conteúdo dela numa cláusula WHERE. Foi reportado internamente em 2019 como MER-2213 e continua aberto. A alocação de referência é MAX(...) + 1 sem lock nenhum, então dois atendentes registrando no mesmo segundo colidem e o segundo recebe uma tela amarela.

Os dois foram deixados exatamente como encontrados neste primeiro commit. Corrigi-los aqui significaria que a linha de base deixou de ser a coisa que está sendo migrada, e o harness de paridade que chega no estágio 03 estaria comparando o código novo contra um oráculo que ninguém nunca rodou em produção.

Eles são corrigidos depois, nos estágios em que aquele código estava sendo reescrito de qualquer jeito. Isso não é uma virtude: é como tickets abertos há muito tempo de fato são fechados.

O que isto é, e o que não é

Este é um sistema construído para o propósito. O código de um ex-cliente não é meu para publicar, então a Meridian foi escrita para carregar as patologias específicas que tornam essas migrações caras, e depois foi genuinamente migrada.

As armadilhas são reais. System.Messaging realmente não tem sucessor. MachineKey realmente não pode ser lido pelo Data Protection. O EF Core realmente assume (18,2) para decimal onde o EF6 tinha sido instruído a usar (18,4), e realmente faz isso em silêncio. Cada uma dessas coisas está escrita abaixo porque mordeu esta base de código, neste repositório, com um commit que a corrigiu.

O que não é alegado em lugar nenhum deste diário é um incidente de produção. Nada aqui diz que um cliente ligou. Onde um estágio dá um custo, é o custo de encontrar aquilo aqui.

01Roteamento

Um gateway na frente de tudo, roteando nada

Um proxy reverso entrou primeiro, mandando 100% do tráfego para a aplicação que ele deveria substituir.

O primeiro commit da migração não migra nada. Ele coloca o YARP na frente da aplicação com uma única rota catch-all apontando para a mesma máquina IIS que já servia todas as requisições.

Nenhum usuário viu diferença. Nenhum código se moveu. É uma semana difícil de justificar numa daily, e é o primeiro movimento certo.

Caminho da requisição, estágio 01
Antes
  1. Navegadorcliente
  2. IISmeridian-app01
  3. Meridian.Web.NET 4.8
  4. SQL Serverbanco de dados
Depois
  1. Navegadorcliente
  2. GatewayYARP · .NET 8
  3. IISmeridian-app01
  4. Meridian.Web.NET 4.8
  5. SQL Serverbanco de dados
inalteradonovo

Uma caixa nova, e toda rota ainda termina no mesmo lugar. A única coisa que mudou é que agora existe onde colocar uma decisão.

Por que isto é o primeiro e não o terceiro

O padrão strangler é uma frase: mova uma rota por vez para a aplicação nova. Essa frase exige algo que seja dono da tabela de roteamento.

Sem isso, o primeiro módulo a se mover tem que se mover por DNS, por uma regra de rewrite do IIS, ou por uma janela de deploy, e cada um desses move tudo de uma vez para alguma classe de usuário. Uma migração que não consegue direcionar tráfego por rota não tem estágios. Tem um cutover, fantasiado.

Fazer isso primeiro tem um segundo benefício que importa mais na prática. É a mudança de infraestrutura mais arriscada do projeto inteiro (um salto novo em toda requisição, com timeouts, tratamento de headers e pool de conexões próprios), e ela chega enquanto exatamente nada depende dela. Se o proxy vai estragar alguma coisa, a semana de descobrir é a semana em que reverter significa apagar um contêiner.

A tabela de rotas é a barra de progresso

estágio 01estágio 09−1  +14

O mesmo arquivo, oito estágios depois. Toda rota que nomeia o cluster moderno é um pedaço da aplicação que se moveu; o catch-all na ordem 100 é tudo o que não se moveu.


          
          "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
        
          
          }
        
          
          }
        

Números de ordem baixos são as rotas migradas, então as novas são adicionadas no topo e o catch-all nunca precisa se mexer. Ler este arquivo de cima para baixo é ler a migração em ordem.

O que quebrou

O que quebrouDois dias, a maior parte olhando para o proxy
Sintoma
Faça login, envie qualquer formulário, e o navegador cai numa URL contendo a porta interna (meridian-app01:52341), inalcançável de fora. Todo GET funcionava.
Causa
A Meridian.Web monta URLs absolutas a partir de Request.Url. Atrás de um proxy, esse é o endereço do salto interno, não o endereço que o cliente pediu. Só o redirect-depois-do-POST expõe isso, porque só redirects colocam uma URL absoluta montada pelo servidor num header Location.
Pego por
Na mão, usando a aplicação. Nenhum teste cobria um destino de redirect, e nenhuma linha de log estava errada: a aplicação se comportava exatamente como escrita.

O instinto foi que um proxy novo tinha quebrado alguma coisa, então dois dias foram para o encaminhamento de headers do YARP antes de alguém ler Request.Url. O proxy esteve correto o tempo todo. A aplicação sempre esteve errada sobre o próprio endereço; nunca ninguém tinha perguntado a ela.

A correção do lado 4.8 é uma URL base ciente de X-Forwarded-*. Do lado .NET 8 é UseForwardedHeaders, que está no commit do estágio 04 e não custou nada, porque a essa altura era um problema conhecido em vez de descoberto.

Custo

Toda requisição agora tem um salto de rede a mais, um conjunto a mais de timeouts para ajustar, e um segundo lugar para olhar quando algo está lento. O gateway também vira um ponto único de falha para uma aplicação que antes tinha um a menos.

Comprou

O tráfego pode ser direcionado por rota. Todo estágio posterior é uma mudança de configuração em um arquivo, reversível em segundos, em vez de uma janela de deploy.

02Precificação

As regras de tarifação, tiradas de dentro do controller

Um projeto multi-target que as duas metades referenciam, para que as regras de preço existam uma vez enquanto duas aplicações estão no ar.

Uma migração strangler significa duas aplicações rodando ao mesmo tempo, por meses. As duas precificam frete. Isso deixa três opções, e só uma delas é sobrevivível.

Copiar as regras para a aplicação nova e manter as duas: toda mudança de tarifa agora tem que ser feita duas vezes, corretamente, por quem pegar o ticket. Chamar de volta a aplicação antiga por HTTP: o código novo passa a depender da coisa que ele está substituindo, e a dependência aponta para o lado errado durante a migração inteira.

Ou colocar as regras num projeto só, que os dois runtimes possam referenciar.

Meridian.Web.csproj (fragmento)Meridian.Domain.csproj (arquivo inteiro)−18  +7

O projeto antigo lista cada arquivo-fonte à mão e cada referência por HintPath para ..\packages\. O novo tem dezenove linhas e faz multi-target nos dois runtimes.


          
          <Project ToolsVersion="15.0" DefaultTargets="Build"
        
          
                   xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
        
          
            </PropertyGroup>
        
          
            <ItemGroup>
        
          
              <Reference Include="EntityFramework, Version=6.0.0.0, Culture=neutral, ...">
        
          
                <HintPath>..\packages\EntityFramework.6.4.4\lib\net45\EntityFramework.dll</HintPath>
        
          
              </Reference>
        
          
              <Reference Include="System.Web.Mvc, Version=5.2.9.0, ...">
        
          
                <HintPath>..\packages\Microsoft.AspNet.Mvc.5.2.9\lib\net45\System.Web.Mvc.dll</HintPath>
        
          
              </Reference>
        
          
            </ItemGroup>
        
          
            <ItemGroup>
        
          
              <Compile Include="Controllers\ConsignmentsController.cs" />
        
          
              <Compile Include="Data\ConsignmentRepository.cs" />
        
          
            </ItemGroup>
        
          
          </Project>
        
          
          <Project Sdk="Microsoft.NET.Sdk">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworks>net48;net8.0</TargetFrameworks>
        
          
              <LangVersion>latest</LangVersion>
        
          
              <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        
          
            </PropertyGroup>
        
          
          </Project>
        

Um .csproj, dois targets. A aplicação 4.8 o referencia em net48, a API .NET 8 referencia o mesmo arquivo em net8.0. Não é uma cópia, nem um pacote NuGet recortado dele: é o mesmo projeto.

O preço da ponte

Tudo nesse projeto tem que compilar nos dois. Sem anotações de referência anulável, sem records, sem System.Text.Json, sem setters init-only, sem namespaces de escopo de arquivo. Lê-se como C# de 2015 porque precisa.

Isso é um custo real e vale nomear: o projeto compartilhado é o código menos moderno da aplicação nova, e é o código fazendo o trabalho mais importante. Todo revisor que o lê pergunta por que ele é assim.

Ainda assim é a troca certa. A alternativa são duas cópias da aritmética da tabela de tarifas divergindo em silêncio pela duração da migração, e o modo de falha disso não é um erro de compilação: são duas faturas para o mesmo carregamento com totais diferentes.

Movido ao pé da letra, depois deixado apresentável

A primeira versão do RatingEngine era mais feia que o controller de onde veio: um método estático com onze parâmetros e os mesmos condicionais aninhados, porque ao pé da letra era o requisito e formato não era.

Só quando o harness do estágio 03 conseguiu provar que ele concordava com o original em quatro mil entradas é que ele teve permissão de virar uma classe com métodos privados nomeados. A refatoração é segura porque o teste é real; o teste é real porque o código foi movido sem ser melhorado antes.

Fazer essas duas coisas num passo só é a forma mais comum de uma migração introduzir um defeito que ninguém consegue achar, porque quando a saída muda não há como saber se foi a mudança de lugar ou a arrumação.

As esquisitices que sobreviveram

Três regras parecem bugs e não são. Cada uma é um preço que alguém paga hoje.

Preservadas de propósito, com o motivo anexado
Arredondamento de meio quilo para cima
O peso taxável arredonda para o próximo meio quilo. Introduzido em 2014 depois de uma disputa com um cliente por 0,3kg. O financeiro aprova toda mudança de tarifa, e isso é uma mudança de tarifa.
Adicional de carga perigosa triplica para a Irlanda
£15 viram £42,50. É a taxa do despachante aduaneiro repassada, não a nossa margem, e foi adicionada em 2018 quando o despachante começou a cobrá-la.
Desconto sobre o líquido para três contas
Um bug de 2017 aplicava o desconto contratual sobre o líquido inteiro em vez de sobre o frete. Os clientes notaram a correção e a área comercial concordou em honrá-la. Hoje é uma cláusula de contrato.

Uma migração é o momento errado de corrigir um preço. Corrigir um aqui significa que o harness de paridade falha, e aí não há como distinguir “mudamos isto de propósito” de “quebramos alguma coisa” pelo resto do projeto.

A única coisa que mudou de formato: as três contas eram um if no customer.Id. Agora são um booleano na requisição, e de onde esse booleano vem é problema do estágio 05.

Custo

O código mais importante da aplicação nova está escrito num padrão de linguagem de 2015, e vai ficar assim até o lado 4.8 ser desligado.

Comprou

Uma única implementação das regras de preço por toda a duração da migração. Uma mudança de tarifa é uma edição, num lugar, testada uma vez.

03Harness de teste

Uma transcrição da aritmética antiga, mantida de propósito

A lógica de preço do 4.8, copiada sem melhorias para dentro de uma fixture de teste, para que todo estágio posterior tenha contra o que estar errado.

A linha de base tinha zero testes. Isso é normal e é justamente o problema: não existe definição de comportamento correto em lugar nenhum, exceto o próprio comportamento.

Então a definição é escrita. LegacyRating.cs é uma transcrição da aritmética que está dentro de ConsignmentsController.Create, com o banco, o HttpContext e o MSMQ recortados fora e nada mais tocado. Mesmo aninhamento, mesma ordem, mesmos dois pontos separados de arredondamento.

Não é uma versão mais arrumada. O valor inteiro dela é não ser uma melhoria.

Varrer, não amostrar

Quatro mil entradas geradas, comparadas campo a campo, falhando por um centavo. A semente é fixa: um harness que gera entradas diferentes a cada execução não consegue dizer se você consertou alguma coisa.

Exemplos escolhidos a dedo teriam perdido as três divergências interessantes encontradas até agora, porque cada uma delas estava numa fronteira para a qual ninguém pensaria em escrever um teste.

Encontrados varrendo, não revisando
Um CEP de Dublin começando com PA
O país tem que ganhar do prefixo do CEP. No original ele ganhava, mas só porque a checagem de país rodava por último e sobrescrevia o que as checagens de prefixo já tinham definido. Reescrito como uma tabela de consulta, a leitura natural inverte isso e Dublin é precificada como Terras Altas da Escócia.
Exatamente 10kg numa tabela cujas faixas se encontram em 10
A aplicação antiga tinha duas consultas de tarifa que discordavam na fronteira: SQL inline com BETWEEN na tela de reserva, uma consulta LINQ na tela da tabela de tarifas. Nove anos, ninguém sabia.
Um líquido que arredonda para cima enquanto o imposto dele arredonda para baixo
O assunto do segundo commit deste estágio, abaixo.

O commit que corrigiu um bug que os testes não conseguiam ver

O engine tirava o imposto do líquido já arredondado. O controller 4.8 tira o imposto do líquido não arredondado e arredonda o líquido separadamente, na linha seguinte.

Todos os quatro mil casos de paridade passavam dos dois jeitos.

RatingEngine.cs (como escrito)RatingEngine.cs (a227e30)−2  +2

Duas linhas trocadas, nenhuma diferença observável em todo o corpus, e digno de um commit próprio.


          
          var roundedNet = Round(net);
        
          
          var vat = Round(roundedNet * vatRate);
        
          
          var vat = Round(net * vatRate);
        
          
          var roundedNet = Round(net);
        

Passar não é equivalência. Distinguir esses dois exige um líquido cuja terceira casa decimal leve o imposto dele através de uma fronteira de meio centavo, e um corpus que nunca produz um não provou nada: ele falhou em refutar.

Fui procurar uma entrada divergente com uma varredura direcionada de quatro milhões de valores e também não encontrei. Isso ainda não é equivalência; é uma busca mais estreita com o mesmo formato de resposta.

O que quebrouVinte minutos para achar, cinco para corrigir, e teria sido uma tarde muito longa mais tarde
Sintoma
Nada. Todo teste passou, e uma varredura de quatro milhões de valores atrás de um contraexemplo também voltou vazia.
Causa
O engine e o original tiram o imposto de quantidades diferentes: um arredonda antes de multiplicar, o outro depois. A diferença é estrutural e o corpus de teste simplesmente nunca produziu uma entrada que a expusesse.
Pego por
Lendo as duas implementações lado a lado enquanto escrevia esta entrada. Nenhum teste teria pego.

A correção é acompanhar a estrutura do original em vez da saída observada dele. Custo de fazer isso: nada. Custo de estar errado: um centavo sistemático em 41.000 carregamentos, e uma conversa com o financeiro, que reconcilia os totais novos contra os antigos.

Isso virou a regra para o resto da migração. Onde o formato do código antigo é arbitrário, melhore. Onde o formato é estrutural e o corpus de teste não consegue distinguir, copie o formato.

O teste que estava errado

Um teste afirmava que meio quilo é cobrado como meio quilo. Ele falha: meio quilo é cobrado como um, porque MinimumChargeableWeightKg se aplica antes do arredondamento.

O engine estava certo e o teste estava errado. Ele foi mantido, com a expectativa corrigida e um comentário explicando a armadilha, porque a próxima pessoa que ler a fórmula volumétrica vai fazer exatamente a mesma suposição.

Custo

Um arquivo no projeto de testes que é uma cópia deliberada de código que está sendo apagado, que todo revisor aponta como duplicação, e que tem que ser mantido se as regras antigas mudarem durante a migração.

Comprou

Todo estágio posterior pode ser checado contra alguma coisa em vez de revisado por plausibilidade. Dois dos três defeitos acima teriam ido para produção.

LegacyRating.cs é apagado no dia em que a aplicação 4.8 for desligada, e nem um commit antes.

04Autenticação

O cookie que precisava ser legível pelas duas stacks

A autenticação por formulário criptografa com MachineKey e o ASP.NET Core não consegue ler isso. Nada podia subir até resolver.

Este é o estágio que decide se uma migração strangler é possível, e é por isso que ele vem antes de qualquer módulo se mover.

A autenticação por formulário criptografa o ticket dela com MachineKey. O ASP.NET Core não tem MachineKey: ele tem Data Protection, um algoritmo diferente, com derivação de chave diferente e formato de payload diferente. Sem configuração, as duas stacks não conseguem ler o cookie uma da outra.

A consequência não é sutil. Um usuário segue um link de uma página servida pela aplicação antiga para uma página servida pela nova e simplesmente é deslogado. Toda rota que se move leva a sessão do usuário junto.

Autenticação, estágio 04
Antes
  1. Meridian.Web.NET 4.8
  2. Ticket FormsAuthMachineKey
  3. .MERIDIANAUTHcookie
Depois
  1. Meridian.Web.NET 4.8
  2. Meridian.Api.NET 8
  3. Data Protectionkey ring compartilhado
  4. .MERIDIANAUTHum cookie
inalteradoadaptadonovo

As duas aplicações escrevem e leem um cookie só, através de um key ring num caminho compartilhado. O lado 4.8 chega lá por Microsoft.AspNetCore.DataProtection.SystemWeb e pelo formatador de ticket de interop do Owin.

Por que não simplesmente logar todo mundo duas vezes

A alternativa óbvia (emitir um segundo cookie da aplicação nova e deixar os dois existirem) foi tentada e abandonada numa tarde.

Dois cookies significam dois relógios de expiração, duas renovações de expiração deslizante que se afastam uma da outra, e um logout que precisa alcançar as duas aplicações ou deixa uma sessão viva. Também significa que o momento da verdade se desloca para a primeira requisição que precisar do cookie da outra aplicação, o que é imprevisível e portanto não testável.

Um cookie dá mais trabalho no começo e é uma coisa menor para raciocinar depois.

Dois detalhes que não são detalhes

Web.configProgram.cs−9  +15

O elemento machineKey desaparece por completo. O que o substitui não é uma chave de configuração, mas duas linhas de inicialização, e as duas custaram um dia cada.


          
          <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 tem que bater nos dois lados. O Data Protection deriva a cadeia de propósito a partir do nome da aplicação. Duas aplicações com nomes diferentes produzem chaves que não conseguem ler os payloads uma da outra parecendo perfeitamente configuradas: mesmo diretório, mesmos arquivos, mesmas permissões.

KeyRingPath não tem valor padrão e a inicialização estoura sem ele. Isso é deliberado, e é o mais importante dos dois.

O que quebrouUm dia, e teria sido mais sem a listagem do diretório
Sintoma
Um usuário loga com sucesso (as credenciais são verificadas, o ticket é escrito) e é imediatamente redirecionado de volta para a tela de login. Repita para sempre. Sem erro, sem exceção, nada no log de nenhuma das aplicações acima de Information.
Causa
A aplicação nova não achou um caminho de key ring configurado, então o Data Protection fez o que ele foi projetado para fazer e gerou um efêmero em memória. Depois emitiu um cookie que a aplicação 4.8 não conseguia descriptografar, que redirecionava para o login, que emitia um cookie que a aplicação nova não conseguia descriptografar.
Pego por
Ao notar que o diretório do key ring em disco tinha um arquivo XML e um timestamp mais antigo que o deploy.

O modo de falha é o que faz isso valer o parágrafo. Um caminho de key ring ausente é invisível em desenvolvimento, onde só existe uma aplicação e ela conversa alegremente consigo mesma. Ele só falha onde duas aplicações precisam concordar, que é o único ambiente que ninguém roda no próprio laptop.

Então o código estoura na inicialização em vez de assumir um padrão. Um valor de configuração cuja ausência produz uma aplicação que parece funcionar e um sistema quebrado não deveria ter valor padrão.

O que este estágio também consertou de graça

UseForwardedHeaders está neste commit. É o mesmo problema em que o estágio 01 gastou dois dias (uma aplicação montando URLs absolutas a partir do próprio endereço interno), e não custou nada aqui, porque a essa altura era uma propriedade conhecida de rodar atrás do gateway, e não uma descoberta.

Esse é a maior parte do argumento a favor de fazer infraestrutura primeiro.

Custonão é temporário

A autenticação das duas aplicações agora depende de um diretório num sistema de arquivos. É um ponto único de falha novo, precisa de backup, e as permissões dele são estruturais de um jeito que as de nenhum arquivo de configuração eram antes.

Comprou

Um cookie, uma sessão, um logout. Um usuário se movendo entre as metades antiga e nova da aplicação não consegue perceber que a fronteira existe, que é a premissa inteira de uma migração strangler.

05Tabelas de tarifas

Tabelas de tarifas, a menor coisa que valia mover

Uma tela somente leitura com lógica de domínio de verdade e nenhuma capacidade de custar dinheiro a alguém, e o padrão do EF Core que truncava todo decimal em silêncio.

O primeiro módulo a se mover é escolhido quase inteiramente pelo que ele não consegue fazer.

Tabelas de tarifas são somente leitura. Sem escrita, sem fila, sem relatório, sem dinheiro se movendo. O que elas têm é lógica de domínio de verdade (busca de faixa, vigência por data) e um caminho que toca todas as partes da stack nova: roteamento no gateway, o cookie compartilhado, EF Core contra o schema em produção, o projeto compartilhado de tarifação.

Cobertura máxima do caminho novo, raio de explosão mínimo. Esse é o critério de seleção inteiro.

O instinto que estava errado

O instinto era começar pela camada de dados, porque ela parece a fundação e todo o resto se apoia nela.

É a pior primeira escolha possível. A camada de dados é a mais acoplada a APIs que só existem no Framework: ConsignmentRepository lê o tenant dele de HttpContext.Current e não pode ser chamado por nada que não seja uma requisição web, o que o job noturno de manifesto descobriu em 2018 e contorna forjando um contexto de requisição. Começar por ali coloca a peça de trabalho mais difícil antes de alguém ter qualquer confiança na stack nova, e antes de existir qualquer evidência de que a abordagem funciona.

Começar por uma tela que exibe números produz algo implantável em dias. Isso importa mais do que arrumação arquitetural no primeiro mês de uma migração, porque a coisa com maior chance de matá-la não é um problema técnico.

Mesmo banco, sem migração de dados

Mesmas tabelas, mesmas linhas, mesmo schema em que a aplicação 4.8 ainda escreve. A migração move código, não dados.

Nada no novo DbContext cria ou migra qualquer coisa. Dois modelos code-first disputando a posse de um schema é como se perde um fim de semana e depois um sábado restaurando backup.

O que quebrou

O que quebrouMinutos, depois que o harness falhou. Indefinido, sem ele.
Sintoma
Toda tela exibia valores corretos. As leituras eram perfeitas. As escritas descartavam em silêncio a quarta casa decimal de toda coluna de dinheiro, e nada em lugar nenhum reportava erro.
Causa
O EF6 tinha sido explicitamente instruído a usar decimal(18,4). O EF Core assume (18,2) por padrão para decimal. Como a aplicação sempre arredondou para duas casas antes de escrever, todo valor já na tabela terminava em dois zeros, então o truncamento não tinha o que truncar e ficou invisível até uma faixa tarifária com uma tarifa genuína de quatro casas passar por ali.
Pego por
O harness de paridade. Nada dentro da aplicação teria pego: sem exceção, sem aviso, sem número visivelmente errado em nenhuma tela.

Esta é a lição para levar do diário inteiro. É invisível, é silenciosa, afeta dinheiro, e é causada por um padrão e não por algo que alguém escreveu.

MeridianContext.cs (EF6)MeridianDbContext.cs (EF Core)−6  +6

A correção é um loop sobre o modelo em vez de uma annotation por propriedade, porque o modo de falha da abordagem com annotation é esquecer uma.


          
          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 linhas escritas à mão viram um loop, e a propriedade adicionada ano que vem fica coberta sem ninguém precisar lembrar.

O comportamento que mudou de propósito

A aplicação antiga tinha duas consultas de tarifa. O SQL inline na tela de reserva usava BETWEEN e ordenava por FromKg decrescente. Uma consulta LINQ na tela de tabela de tarifas usava >= e <= e ordenava do mesmo jeito. Num peso exatamente na fronteira (10,0kg numa tabela cujas faixas são 0-10 e 10-25) elas retornavam faixas diferentes.

As duas estavam no ar havia nove anos. Ninguém sabia.

Escolher entre elas é uma pergunta comercial vestida de técnica, e foi resolvida nessa base: a versão em SQL ganha, porque é a que precificou nove anos de faturas. A versão LINQ nunca foi mais do que uma prévia numa tela da qual ninguém fatura.

Uma mudança de schema

Os três ids de cliente hard-coded em ConsignmentsController.Create viraram uma coluna.

ConsignmentsController.csdb/001_discount_applies_to_net.sql−4  +5

          
          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);
        

É a única mudança que esta migração fez numa tabela existente. Ela ganha o lugar dela duas vezes: o engine de tarifação pode continuar puro, e uma quarta conta na mesma cláusula contratual vira uma linha em vez de um deploy.

Custo

Agora existem duas implementações da tela de tabela de tarifas e as duas são alcançáveis: a 4.8 diretamente, a .NET 8 pelo gateway. Até a rota antiga ser apagada, um report de bug precisa estabelecer qual delas quem reportou estava olhando.

Comprou

Um caminho completo e implantável pela stack nova, provado ponta a ponta, num módulo em que estar errado não custa nada. Tudo depois disto é uma variação de uma rota que já funciona.

06Entrega ao depósito

MSMQ, um outbox, e um processo que fica em 4.8 para sempre

A dependência que não pode ser migrada de jeito nenhum, então foi encolhida ao máximo e colocada em quarentena.

System.Messaging nunca foi portado para o .NET Core e não será. Não há shim, não há pacote de compatibilidade, não há port da comunidade, não há jeito suportado de colocar uma mensagem numa fila MSMQ a partir do .NET 8.

Então a pergunta não é como migramos o MSMQ. É o que fazemos com o fato de que não podemos.

O que foi rejeitado

Reescrever a integração com o depósito sobre RabbitMQ ou Service Bus. A fila é lida por um software de propriedade de um terceiro que foi adquirido em 2020. Mudar o transporte significa uma negociação com outra empresa sobre uma interface que carrega contrato, no cronograma deles, com os testes deles.

Isso é um projeto de verdade com um orçamento de verdade, e não é este. Uma migração que absorve em silêncio a reescrita de uma integração de terceiros é uma migração que não termina, e o jeito como ela falha é que deixa de ser possível dizer o que falta.

Manter todo o fluxo de reserva no 4.8 até o depósito mudar. Isso transforma a única coisa que não pode ser migrada num bloqueador para tudo o que pode. O caminho de escrita de carregamento é o núcleo da aplicação; congelá-lo congela a migração.

O que foi escolhido

A dependência não vai embora. Ela é encolhida ao máximo possível, e uma linha é desenhada em volta dela.

Entrega ao depósito, estágio 06
Antes
  1. Action Create.NET 4.8
  2. SaveChangesEF6 · commitado
  3. MessageQueue.SendSystem.Messaging
  4. Depósitoterceiro
Depois
  1. POST /consignments.NET 8
  2. SaveChangesEF Core · uma transação
  3. DespatchOutboxtabela
  4. Sidecar.NET 4.8
  5. Depósitoterceiro
inalteradoadaptadonovoremovido

A dependência 4.8 continua lá. Agora ela é um processo de 200 linhas sem superfície web e sem lógica de negócio, cujo trabalho inteiro é ler uma tabela e escrever numa fila.

O sidecar é o menor refém que a migração conseguiu deixar para trás. Quando o sistema do depósito for eventualmente substituído, apagar sidecar/ é a mudança inteira.

Uma correção de corretude que não era o ponto

O código antigo enviava para o MSMQ depois de o SaveChanges() já ter commitado, fora de qualquer transação:

ConsignmentsController.csConsignmentEndpoints.cs + DespatchOutbox.cs−7  +3

Duas escritas num banco não podem acontecer pela metade. Uma escrita no banco e um envio para fila podem, e aconteciam.


          
          _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);
        

Quando o envio estourava, o carregamento existia e o depósito nunca ficava sabendo. O único jeito de alguém descobrir era um cliente perguntando onde o palete dele tinha ido parar.

O despacho agora é assíncrono por alguns segundos, com o que o depósito não se importa, e não pode ser pulado em silêncio, com o que todo mundo se importa.

O que quebrou

O que quebrouQuarenta minutos para reproduzir, depois que a falha foi injetada
Sintoma
O depósito registrou a mesma coleta duas vezes. Duas vans, um palete, e um contato muito pouco impressionado do lado de quem recebia.
Causa
O drenador retenta quando um envio falha. Um envio que na verdade tinha dado certo e depois estourou o tempo esperando pela confirmação foi retentado, e o MSMQ já tinha aceitado o primeiro. Não havia como o depósito distinguir os dois.
Pego por
Uma rodada de estresse contra uma fila com 5% de timeout de confirmação artificial. Não reproduz de jeito nenhum em condições normais, que é por que teria chegado em produção.

A correção é IdempotencyKey e um índice único. A chave é derivada do corpo da mensagem, então um reenvio genuíno de conteúdo alterado é outra linha e um retry cego não é.

A ordem que estava errada primeiro

OutboxDrain reivindica um lote, envia cada mensagem, e depois marca como enviada. A primeira versão marcava e depois enviava, o que lê mais naturalmente e nunca deixa uma linha parecendo não enviada quando foi.

O que ela faz, em vez disso, é perder mensagens quando o envio estoura.

O formato geral: enviar duas vezes é recuperável, nunca enviar não é. Diante da escolha entre o modo de falha que duplica e o que descarta, pegue o que duplica, e depois gaste o esforço em tornar as duplicatas detectáveis.

Custonão é temporário

Agora existe no parque um processo .NET Framework 4.8 que vai sobreviver à migração. Ele precisa de um host Windows, do próprio deploy, do próprio monitoramento, e é uma segunda coisa que pode estar fora do ar quando os despachos param de andar.

Comprou

A aplicação .NET 8 pode ser dona do caminho de escrita de carregamento (que é o núcleo do sistema) sem conseguir falar com o MSMQ de jeito nenhum. E a entrega virou transacional no caminho.

07Relatórios

O estágio que não produziu código

Vinte relatórios RDLC sem renderizador em .NET 8. A decisão foi não mover nenhum, e escrever a regra de quando isso muda.

Microsoft.ReportViewer.WebForms é um assembly do .NET Framework. Não existe sucessor em .NET Core ou .NET 5+, e o RDLC não tem outro renderizador: o formato está amarrado ao único controle que o lê.

São vinte desses arquivos, escritos no designer de relatórios do Visual Studio entre 2011 e 2017. Um deles tem um sub-relatório que busca as próprias linhas. Vários fazem bind direto num DataTable devolvido por uma stored procedure.

A estimativa que matou a reescrita

Reescrever vinte relatórios em QuestPDF fica entre três e seis semanas, e a estimativa é larga porque relatórios RDLC são diagramados visualmente e o comportamento deles numa quebra de página é emergente, não especificado. Você não sabe o que um relatório faz no pé da página dois até renderizar um.

Então a pergunta útil não é quanto custa. É o que isso compra.

Vinte relatórios, por quem os lê e com que frequência mudam
Dois documentos voltados ao cliente
Fatura e manifesto. Vistos por clientes, impressos em papel timbrado, e os únicos em que alguém notaria uma mudança. Mudaram três vezes entre os dois desde 2017.
Dezoito relatórios operacionais internos
Throughput de depósito, listas de exceção, reconciliação de fechamento de mês. Lidos por quatro pessoas. Dezenove dos vinte arquivos não são editados desde 2017.

Migrar algo que não muda há oito anos não compra nada além da satisfação de ter migrado. Os relatórios funcionam. Eles renderizam o mesmo PDF que renderizavam em 2017, num runtime que tem suporte até 2031.

O que foi de fato decidido

Todos os vinte ficam. Nenhum código de relatório se moveu, e o diff mostra isso: não existe geração de PDF em lugar nenhum de modern/.

A regra acordada no lugar: um relatório é reescrito na próxima vez que alguém pedir uma mudança nele, e não antes. A reescrita então é paga pela mudança que a disparou, num momento em que alguém de fato se importa com a saída e consegue dizer se ela está certa.

Pelas evidências atuais isso é perto de nunca para dezoito deles, e essa é a resposta correta para dezoito deles.

O commit que este estágio produziu

Nenhum. O painel de metadados diz isso em vez de mostrar um zero, porque são fatos diferentes e o site foi construído para não conseguir confundi-los.

O único commit que tocou em relatórios em todo o histórico é ecb860b, e ele não muda relatório nenhum: ele corrige este diário. Um rascunho anterior do README do repositório afirmava que dois dos vinte tinham sido reescritos. Não tinham.

O que quebrouDois commits para corrigir, e é a coisa mais perigosa deste diário
Sintoma
A tabela de dependências do README dizia '18 congelados, 2 reescritos'. O histórico de commits não contém geração de PDF de espécie alguma.
Causa
Escrevi o plano no README como se fosse o desfecho. Foi escrito antes da estimativa, e nunca revisado depois de a decisão ter ido para o outro lado.
Pego por
Relendo o inventário contra o diff real enquanto escrevia esta página.

Essa correção é a razão de este estágio existir, em vez de ser um parágrafo dentro do estágio 06. Se o inventário do que ainda está em 4.8 não é confiável, o resto do diário também não é: toda outra afirmação nele é exatamente do mesmo tipo, e um leitor não tem jeito independente de checar nenhuma delas.

O único ativo real de um diário de migração é que as más notícias dele são confiáveis.

Custonão é temporário

Vinte relatórios RDLC e uma dependência de ReportViewer permanecem em .NET Framework 4.8 por tempo indeterminado, o que significa que a aplicação web 4.8 não pode ser desligada, o que significa que o key ring compartilhado e o parque com dois runtimes também ficam.

Comprou

Três a seis semanas não gastas redesenhando documentos que quatro pessoas leem e ninguém muda desde 2017.

08Carregamentos

Carregamentos, movidos por último

A action de 272 linhas. Tudo o que é difícil converge aqui, que é por que todos os outros estágios vieram antes.

ConsignmentsController.Create tem 272 linhas e faz nove coisas. Dinheiro, a fila, o tenant, a checagem de duplicidade e a trilha de auditoria se encontram todos dentro dela.

Ela se move por último, e a ordem de todos os estágios anteriores foi escolhida para que ela pudesse. Quando este endpoint foi escrito, as regras de tarifação já estavam extraídas e provadas (02, 03), o cookie já era compartilhado (04), a stack nova já tinha um caminho ponta a ponta funcionando (05), e a fila já tinha uma resposta que não envolvia MSMQ (06). O que sobrou foi orquestração.

A mesma reserva, antes e depois
Antes
Um método de action, 272 linhas, 0 testes
Depois
Um endpoint, 215 linhas, delegando a 4 colaboradores nomeados
Tarifação
Meridian.Domain.RatingEngine: 4.011 asserções
Busca de tarifa
IRateLookup: uma implementação, eram duas
Despacho
IDespatchOutbox: transacional, era dispara-e-esquece
Auditoria
Não movida. Ainda é um módulo HTTP do lado 4.8.

Três coisas que aqui ficaram piores

Declaradas em vez de escondidas, porque um estágio em que tudo melhorou é um estágio que não foi olhado direito.

O engine de tarifação é chamado duas vezes. Uma para obter o peso taxável, outra para precificá-lo, porque a busca de tarifa precisa do peso, e o peso é assunto do engine. A alternativa era duplicar a fórmula volumétrica no ponto de chamada, que é exatamente como a aplicação antiga terminou com duas delas. Uma chamada redundante é mais barata do que uma segunda cópia de uma regra, mas ela é genuinamente redundante e o próximo leitor vai se perguntar.

A alocação de referência ainda é MAX + 1. Dois atendentes registrando no mesmo segundo ainda colidem.

ConsignmentsController.NextReferenceConsignmentEndpoints.NextReferenceAsync−4  +7

Portado, não corrigido. O bug foi preservado exatamente, com um comentário dizendo isso.


          
          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}";
        

Corrigir isso direito significa uma sequence, uma mudança de schema e uma conversa com o depósito sobre formatos de referência, porque o formato está no fio naquela mensagem de largura fixa. Isso é um trabalho de verdade e não é desta migração. Fica registrado como dívida num comentário em vez de contrabandeado sob o disfarce de uma migração, que é como migrações adquirem escopo que ninguém aprovou, e como um rollback deixa de ser possível.

A declaração aduaneira em WCF não se moveu. Ela ainda está do lado 4.8, ainda engolindo as exceções dela desde uma quinzena ruim em 2019, e este endpoint não faz a declaração de jeito nenhum. Isso é uma lacuna, não uma decisão. WCF do lado cliente tem sim uma história em .NET 8, então esta simplesmente ainda não foi feita.

O ticket que fechou como efeito colateral

MER-2213, aberto desde 2019: a caixa de busca concatena na cláusula WHERE.

ConsignmentRepository.SearchGET /consignments−3  +3

          
          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}%"));
        

Ele ficou aberto seis anos não por ser difícil, mas porque corrigi-lo significava retestar uma tela que ninguém queria retestar. Reescrever a tela ia acontecer de qualquer jeito, então a correção não custou nada.

Vale ser honesto sobre isso em vez de reivindicar como vitória. A migração não priorizou um defeito de segurança. Ela esbarrou num, de passagem.

O tenant deixa de ser ambiente

ConsignmentRepository lia o tenant de HttpContext.Current.Items, o que significava que só podia ser chamado de dentro de uma requisição web. O job noturno de manifesto descobriu isso em 2018 e contorna construindo um HttpContext falso.

O tenant chega como uma claim agora. Essa única mudança é o que vai permitir ao job de manifesto parar de fingir ser uma requisição web quando ele se mover, o que ainda não aconteceu.

Custo

Reservas agora podem chegar por dois caminhos de código contra um banco: a tela 4.8 para humanos, este endpoint para clientes JSON. Até as telas Razor se moverem, qualquer investigação sobre um preço errado começa estabelecendo qual metade o precificou.

Comprou

O método de 272 linhas não é mais o único lugar onde as regras de preço existem, e as regras que ele costumava possuir agora estão cobertas por 4.011 asserções que rodam em um terço de segundo.

09Roteamento

Roteamento por Accept, não por caminho

Os coletores do depósito e as telas humanas dividem uma URL. Dividir por tipo de conteúdo moveu um sem mover o outro.

O estágio 08 construiu um endpoint JSON de carregamentos. O problema é que a aplicação 4.8 já serve /consignments: como views Razor, para pessoas, num navegador.

As duas plateias querem o mesmo caminho. Os terminais do depósito e o app mobile fazem POST em /consignments querendo JSON; um atendente abre /consignments querendo uma tela. O espaço de URLs não os distingue, e não há prefixo de versão onde se esconder porque esses clientes chamam esse caminho desde 2014.

As opções

Reescrever as telas também. Triplica o tamanho do estágio, e não produz nada para a pessoa segurando um coletor. As views Razor funcionam.

Dar à API um caminho novo: /api/consignments. Limpo, e significa trocar firmware em terminais de depósito que são atualizados visitando os depósitos fisicamente. Isso é uma operação de frota, não um deploy.

Dividir pelo header que os clientes já enviam.

appsettings.json (estágio 01)appsettings.json (estágio 09)−0  +7

Mesmo caminho, mesmos verbos, clientes diferentes. Casar a rota pelo Accept é a única coisa que os separa.


          
          "consignments-api-new": {
        
          
          "ClusterId": "modern",
        
          
          "Match": {
        
          
            "Path": "/consignments/{**catch-all}",
        
          
              "Headers": [
        
          
                {
        
          
                  "Name": "Accept",
        
          
                  "Values": [ "application/json" ],
        
          
                  "Mode": "Contains"
        
          
                }
        
          
              ]
        
          
          },
        
          
          "Order": 2
        
          
          }
        

Os clientes de máquina se mudaram para .NET 8 e ninguém visitou um depósito. As telas ficaram exatamente onde estavam.

Por que isto está escrito duas vezes

Uma rota que casa por header é invisível num stack trace. Uma requisição que cai na metade errada parece um bug na metade que a recebeu, e nenhuma das duas metades sabe que o header existe: a aplicação 4.8 não faz ideia de que está sendo contornada para alguns chamadores, e a .NET 8 não faz ideia de que está vendo só uma parte do tráfego.

Então isso está no README do gateway além de estar na configuração. Configuração não é documentação: ela diz o que acontece, nunca por quê, e esta é uma regra cujo porquê é a única coisa que torna o próximo incidente tratável.

O que quebrouMeio dia, quase todo passado na aplicação errada
Sintoma
Um terminal de depósito que tinha funcionado a manhã inteira começou a receber HTML. As requisições dele estavam chegando na aplicação 4.8, que renderizava uma view Razor dentro de um parser esperando JSON.
Causa
O terminal envia Accept: application/json, */* na maior parte das chamadas, mas Accept: */* num caminho de retry do cliente HTTP dele. Mode: Contains casa a string literal, e */* não contém application/json.
Pego por
Comparando as requisições que funcionavam e as que falhavam. Nenhuma das aplicações logou nada incomum: as duas serviram exatamente o que foi pedido.

A correção foi do lado do cliente, porque a alternativa (tratar */* como JSON no gateway) rotearia a requisição de fallback de um navegador para a API e quebraria as telas em vez disso. Uma divisão por header só funciona quando o header de fato discrimina, e descobrir quais dos seus clientes são relaxados com Accept faz parte do custo de escolher isso.

Custo

Uma URL agora é servida por duas aplicações dependendo de um header, o que é genuinamente surpreendente e vai surpreender alguém de novo. Também significa que as duas metades precisam continuar concordando sobre o que é um carregamento enquanto as duas existirem.

Comprou

Uma classe inteira de cliente mudou de runtime sem mudança no cliente, sem release coordenado, e sem visita a depósito.

10Etiquetas de despacho

Aquele que compila e depois falha em produção

System.Drawing ainda está em 4.8, e o motivo de estar travado é pior do que "sem equivalente em .NET 8".

Label.ashx renderiza a etiqueta de despacho 4x6 que as impressoras do depósito esperam, através do GDI+, incluindo um código de barras desenhado à mão a partir de uma tabela de larguras de barra Code 39, porque o componente licenciado foi abandonado em 2012 quando ninguém conseguiu achar o arquivo de licença.

Ela não se moveu, e vale ser preciso sobre o porquê, porque “System.Drawing não funciona em .NET 8” é só meia verdade e a metade que é falsa é a perigosa.

Ele compila. Esse é o problema.

System.Drawing.Common ainda existe como pacote. A API está toda lá. Código que o usa compila sem nenhum aviso, e roda perfeitamente na máquina Windows de um desenvolvedor.

Do .NET 6 em diante ele lança PlatformNotSupportedException em tempo de execução em qualquer coisa que não seja Windows. Então a primeira vez que ele falha é dentro de um contêiner Linux, no primeiro ambiente que rodar um, o que é um modo de falha estritamente pior do que não compilar, porque passa por todos os portões que o teriam pego antes.

Por que isto é uma reescrita e não uma troca de pacote
O código de barras é geometria, não uma chamada de biblioteca
As larguras das barras vêm de uma tabela Code 39 e são desenhadas como retângulos preenchidos numa largura de barra estreita calculada. Portar isso significa re-derivar essa geometria contra SkiaSharp ou ImageSharp, não trocar um import.
A saída tem um teste de aceitação físico
Os coletores do depósito são exigentes com DPI e zonas de silêncio. O único jeito de saber se uma etiqueta portada está certa é imprimir uma e escaneá-la, num depósito, com o hardware de verdade.
O endpoint é anônimo
Um bloco <location> no Web.config o permite sem cookie, porque o firmware do coletor não consegue apresentar um. Movê-lo significa resolver isso também, e a resposta atual é que a referência é difícil de adivinhar.

Por que isto está listado

Porque um inventário que só contém problemas resolvidos não é um inventário.

Este estágio produziu um commit e esse commit só edita um README: ele remove uma linha alegando que o renderizador de etiquetas tinha sido migrado. Não tinha. A alegação foi escrita a partir do plano em vez do diff, no mesmo rascunho que exagerou sobre os relatórios.

Duas dessas num documento só é um padrão, não um deslize, e o padrão é que o resumo foi escrito antes do trabalho e nunca reconferido contra o que de fato chegou. A correção é barata. O hábito que a produziu não é, e é a razão de todo estágio deste diário agora carregar os hashes de commit dele no painel à direita: uma alegação com um hash ao lado pode ser checada em uns quatro segundos, e uma sem hash não pode ser checada de jeito nenhum.

Custo

A aplicação web 4.8 tem que continuar rodando para atender requisições de etiqueta, o que contribui para o mesmo custo de parque que os relatórios. Ela também é o único endpoint anônimo do sistema e está do lado antigo, onde as novas políticas de autorização não alcançam.

Comprou

Nada ainda. Isto é trabalho não feito, registrado como trabalho não feito.

11Parque

O que ainda roda em .NET Framework, e por quê

O inventário honesto. Um diário de migração que para no último estágio bem-sucedido é um documento de vendas.

A migração não está terminada, e pelos planos atuais partes dela nunca estarão. Esse é o desfecho normal e vale escrever claramente, porque a versão deste documento que termina no estágio 08 com tudo verde seria inútil para quem estiver decidindo se começa uma.

Ainda em .NET Framework 4.8
O sidecar de despacho
MSMQ não tem sucessor. Limitado a ~200 linhas, sem superfície web, sem lógica de negócio. Apagável no dia em que o sistema do depósito mudar, e não antes. Ver estágio 06.
Vinte relatórios RDLC
Microsoft.ReportViewer só existe no Framework. Congelados por decisão, reescritos um a um quando algum mudar. Dezenove não mudam desde 2017. Ver estágio 07.
Renderização de etiquetas
System.Drawing. Bloqueada numa reescrita de código de barras com teste de aceitação físico. Conhecida, dimensionada, não iniciada. Ver estágio 10.
As telas de carregamento
Views Razor servidas pelo IIS. Os clientes JSON se mudaram no estágio 09; as telas não tinham motivo para seguir nem orçamento para isso.
A declaração aduaneira
Uma chamada WCF, ainda do lado antigo, ainda engolindo as exceções dela. WCF do lado cliente tem sim uma história em .NET 8, então esta genuinamente só não foi feita.
Os módulos de auditoria
Dois IHttpModules escrevendo uma linha por requisição, de forma síncrona, na thread da requisição. Eles cobrem as duas metades hoje só porque toda requisição ainda passa primeiro pelo IIS.

O que piorou e continuou pior

Todo estágio acima tem um painel de custo. Vários deles estão marcados como não é temporário, e é isso que eles somam.

Existem dois runtimes, dois pipelines de deploy, e dois lugares para olhar quando algo está lento. A autenticação depende de um diretório num sistema de arquivos que as duas aplicações precisam conseguir ler, o que é um ponto único de falha que não existia antes. Uma URL é servida por duas aplicações dependendo de um header HTTP. As regras de tarifação estão escritas num padrão de linguagem de 2015 porque precisam compilar em net48.

Nada disso é um imposto de transição que some no fim. É o custo corrente de ter escolhido uma migração strangler, e dura o tempo que o sidecar e os relatórios durarem, ou seja, indefinidamente.

Essa é a coisa a pesar contra uma reescrita big-bang. Não “strangler é mais seguro” no abstrato, mas: você está disposto a rodar dois de cada coisa por anos, em troca de nunca ter um fim de semana em que a empresa inteira não consegue registrar frete?

Para um sistema que recebe reservas o dia inteiro, essa troca vale a pena. Para uma ferramenta interna com quarenta usuários e uma janela de manutenção, muitas vezes não vale, e o padrão strangler acaba aplicado a coisas que teriam sido melhor servidas por uma reescrita e um feriado.

A regra que produziu este formato

Uma regra, mantida em todo commit: nenhum commit pode deixar a aplicação sem condição de subir.

É por isso que o formato acima é desarrumado. Uma migração arrumada (uma em que o inventário no fim está vazio e o diagrama de arquitetura não tem linhas pontilhadas) é uma migração que derrubou o sistema para chegar lá.

Quatorze commits, e em cada um deles a Meridian Despatch conseguia registrar uma reserva.

O que eu faria diferente

Nada sobre a ordem. Gateway primeiro, autenticação segundo, menor módulo terceiro estava certo, e os dois dias perdidos no estágio 01 foram o preço de aprender o que o proxy faz com uma aplicação que acha que sabe o próprio endereço.

O que eu mudaria é a escrita. Duas vezes neste diário (os relatórios no estágio 07, as etiquetas no estágio 10) o resumo foi escrito a partir do plano em vez do diff, e reivindicou trabalho que não tinha acontecido. Os dois foram pegos relendo o inventário contra o git log, o que significa que a checagem funciona, mas era uma checagem aplicada no fim em vez de um hábito aplicado o tempo todo.

A correção é o painel à direita de todo estágio desta página. Uma alegação com um hash de commit ao lado pode ser checada em quatro segundos. Uma sem ele não pode ser checada de jeito nenhum, e num documento cujo único ativo real é que as más notícias dele são confiáveis, essa diferença é tudo.