O motor que impulsiona milhões de Okta Workflows execuções por dia foi um dos primeiros a adotar o ecossistema Rust assíncrono. Embora o Tokio seja a escolha natural para um runtime assíncrono hoje, lançamos o Workflows antes do lançamento do Tokio 1.0, e muito antes que as melhores práticas e padrões para Rust assíncrono realmente se consolidassem.

Este artigo detalha como migramos o mecanismo do Workflows, uma base de código de mais de 100.000 linhas de código fonte, de Tokio 0.1 baseado em continuações para async / await Tokio 1.0, sem interrupções de serviço ou pausa no desenvolvimento de recursos.

Futuros baseados em continuações

No início, a execução assíncrona de Rust futures era feita passando funções que representam valores eventuais, conhecidas como continuações. Essas continuações seriam encadeadas e cada uma executada de forma assíncrona por um runtime como o Tokio, e eventualmente seriam resolvidas para o valor desejado. Antes da migração, tínhamos centenas de milhares de linhas de código que se pareciam com isto:
 

Código antes da migração

 

Este era o estado do Rust assíncrono até que a sintaxe async / await foi estabilizada. Para o Workflows, ser um dos primeiros a adotar significava que tínhamos que lançar muito antes da estabilização de async / await, e embora este tipo de código seja difícil de ler e manter, nós o levamos o mais longe que pôde ir.

Ele nos serviu bem, permitindo-nos expandir o Okta Workflows para onde está hoje. Avançando para cerca de um ano atrás, quando as rachaduras estavam começando a se tornar insuportáveis, e tornou-se necessário finalmente iniciar a migração para a sintaxe async / await moderna e Tokio 1.0.

Futures baseados em Async / await

Com o Tokio 1.0, o fluxo de controle assíncrono podia ser escrito com a sintaxe (agora estável) async / await, o que simplifica muito o código e reduz o boilerplate. O exemplo acima poderia ser escrito assim.

 

Mesmo código com Tokio 1.0

 

Mesmo que você não tenha muita familiaridade com Rust, os benefícios de legibilidade e simplicidade de usar async / await são claros. Para aqueles que estão familiarizados com Rust, a capacidade de async / await de emprestar entre pontos de espera nos permitiu não precisar mais passar a propriedade do objeto Flow, então não precisamos mais armazená-lo no FlowError. Isso foi crucial para transmitir erros aos nossos clientes e uma fonte de muitos bugs quando um caminho de código inevitavelmente se esquece de armazenar o Flow em um erro. Esta seria apenas uma classe de bugs que a migração para async / await nos ajudaria a eliminar.

A grande mudança 

Embora os benefícios da migração para o Tokio 1.0 fossem claros, seria uma tarefa assustadora fazê-lo. A migração não é trivial e exigiria uma reescrita completa do Workflows Engine. Uma reescrita completa e interrompida do mundo não é prática e arriscada, tanto do ponto de vista dos negócios quanto da confiabilidade do cliente. Não poderíamos pausar o desenvolvimento do produto para passar por uma reescrita completa; de fato, isso teria sido um erro fatal para qualquer produto de software

Para complicar ainda mais as coisas, por volta da época de nossa investigação sobre a reescrita, tínhamos novos recursos chegando. Por exemplo, lançaríamos a capacidade de cancelar um Workflow apenas alguns meses depois, simultaneamente a este esforço para migrar para o Tokio 1.0. Uma reescrita desta magnitude precisaria ser feita aos poucos, como se estivesse substituindo as rodas de um trem em movimento. 

Camadas de compatibilidade e flags de funcionalidades

O Okta Workflows usa a biblioteca hyper HTTP para se comunicar com a web e fazer chamadas para serviços externos, como Google Drive ou Amazon S3 — tudo parte do que torna o Workflows tão poderoso. Esta biblioteca fundamental foi uma das primeiras a ser substituídas: trocamos o hyper 0.12 alimentado por Tokio 0.1 pelo hyper 0.14, que era baseado em Tokio 1.0 e async / await. Esta foi uma das primeiras partes do Workflows Engine a ser portada de Tokio 0.1 para Tokio 1.0, e a experiência ajudou a informar muitas de nossas abordagens e decisões arquitetônicas durante o processo de migração.

A biblioteca de futures que sustenta a maior parte do ecossistema Rust assíncrono tem um conjunto de shims de compatibilidade para ajudar a migrar de futures baseados em continuações para async / await futures baseados. No entanto, esses shims são incapazes de levar em conta as diferenças de tempo de execução. Nosso código legado baseado em continuações só poderia ser executado no tempo de execução Tokio 0.1, mas este novo código de tratamento de HTTP precisaria ser executado no tempo de execução Tokio 1.0, e nunca os dois se encontrarão: o Tokio 1.0 travará para sempre quando passado um future de compatibilidade destinado a ser executado em um executor Tokio 0.1.

Portanto, uma etapa extra de indireção foi necessária. Nos primeiros dias de “asyncificação”, ao usar um cartão de conector HTTP no Workflows, o processamento do fluxo aconteceria inicialmente dentro de um contexto Tokio 0.1. Ao fazer a solicitação HTTP, o Tokio 0.1 chamaria um shim, que geraria a solicitação em um runtime Tokio 1.0 em execução em um thread separado e aguardaria que a solicitação fosse retornada por meio de um canal. Dessa forma, poderíamos evitar misturar futuros destinados a um runtime em outro. Mais tarde, levaríamos essa abordagem aos seus limites, com cada invocação de função Flow, bem como cada chamada ao Redis passando por shims semelhantes, à medida que substituímos gradualmente as implementações e bibliotecas do Tokio 0.1 por outras compatíveis com o Tokio 1.0.

 

Processo de migração do Tokio 0.1 para o Tokio 1.0

 

Os shims de compatibilidade eram unidirecionais; chamar código async / await do código de futuros legado exigia um shim diferente do que chamar o código de futuros legado do código async / await. 

Para ajudar a garantir que os clientes não notassem uma queda na confiabilidade ou uma diferença no comportamento, aproveitamos muito os feature flags para alternar entre as implementações de código legadas e do Tokio 1.0 em tempo de execução. Se algum problema fosse encontrado, poderíamos trocar para a implementação anterior rapidamente enquanto abordávamos o problema com o novo código. Os shims de compatibilidade forneceram uma útil lacuna de incêndio entre os dois mundos, até que estivéssemos prontos para reescrevê-lo na sintaxe async / await. Ao executar caminhos legados e do Tokio 1.0 em paralelo e, em seguida, depreciar o caminho de código legado, fomos capazes de substituir gradualmente a base sobre a qual o Workflows Engine foi construído até que não restasse nada do Tokio 0.1. 

Desafios 

Durante o processo de mudança para o Tokio 1.0, eventualmente construiríamos um pequeno kit de ferramentas para poder escrever rapidamente shims de compatibilidade entre o mundo Tokio 0.1 e o Tokio 1.0. Esta viria a ser uma ferramenta extremamente crucial à medida que continuássemos a lançar novos recursos, apesar do esforço de reescrita. 

Embora as partes principais do Workflows Engine estivessem passando por essa reescrita, os recursos lançados nesse ínterim teriam que ser reescritos duas vezes – uma vez no Tokio 0.1 e novamente no Tokio 1.0. Ter um kit de ferramentas para criar shims de compatibilidade nos permitiu escrever todo o código novo no Tokio 1.0 e conectá-lo à nossa base de código Tokio 0.1 existente. Às vezes, os shims não eram suficientes; por exemplo, no caso do fluxo de cancelamento mencionado acima, partes da implementação tiveram que ser escritas duas vezes para suportar caminhos de código em ambos os tempos de execução.

Havia algumas diferenças fundamentais entre os runtimes legados Tokio 0.1 e Tokio 1.0 que precisávamos lidar. Com o Tokio 0.1, cada future era alocado no heap, mas com async / await, os futures agora seriam alocados na pilha por padrão. Se não tivéssemos cuidado, rapidamente excederíamos a pilha em um thread de trabalho. Ao aumentar o tamanho padrão da pilha de threads de trabalho e escolher cuidadosamente os futures para alocar no heap, conseguimos mitigar esse problema.

Com o caminho de código legado Tokio 0.1, vários executores trabalharam em paralelo para obter uma saída altamente simultânea. Na implementação inicial com Tokio 1.0, usamos apenas um único executor para lidar com todas as execuções de fluxo. Isso acabou sendo aproximadamente 15% mais lento do que ter vários executores, o que é impressionante em si! Embora eventualmente tenhamos voltado a ter vários runtimes Tokio 1.0 para permitir o mesmo nível de throughput que nossos clientes esperam, agora temos alguns novos botões para ajustar no futuro para ultrapassar o que era possível com o Tokio 0.1 e o caminho de código legado.

Uma base de código mais rápida e limpa

Hoje no Workflows, não dependemos mais do legado Tokio 0.1. Nosso código base é mais rápido, mais fácil de ler e mais sustentável do que nunca, permitindo-nos mais oportunidades de otimizar o desempenho da execução do Flow e entregar novos recursos aos nossos clientes mais rapidamente do que nunca. 

 

Gráfico mostrando linhas líquidas de código ao longo do tempo

 

A migração para async / await permitiu-nos remover mais de 20.000 linhas de código do Workflows Engine.

Finalmente, deixamos o mundo há muito negligenciado do Tokio 0.1 e agora podemos estar continuamente atualizados com nossas dependências fundamentais, cruciais em nosso esforço para que a Okta se torne a empresa mais segura do mundo. E, claro, é muito mais agradável para toda a nossa equipe trabalhar em uma base de código limpa e legível todos os dias, do que em uma com milhares de linhas de código boilerplate.

Saiba mais sobre como automatizar tarefas críticas de TI e segurança em escala com o Okta Workflows.

Tem perguntas sobre esta postagem do blog? Entre em contato conosco em eng_blogs@okta.com.

Explore mais Blogs de Engenharia perspicazes da Okta para expandir seu conhecimento.

Pronto para se juntar à nossa equipe apaixonada de engenheiros excepcionais? Visite nossa página de carreira.

Desbloqueie o potencial do gerenciamento de identidade moderno e sofisticado para sua organização.

Entre em contato com a equipe de Vendas para obter mais informações.

Continue sua jornada de identidade