Low-code Flerta com Busca e Recomendação

Daltro Gama
Grupo OLX Tech
Published in
13 min readMar 21, 2021

Em um passado não muito distante, a OLX Brasil era uma startup onde talentosas pessoas desenvolvedoras evoluíram toda a base tecnológica a partir de uma plataforma já sofisticada para seu tempo, conforme as necessidades do negócio e ajudando a alavancar o crescimento da companhia.

Agora, quando falamos em OLX Brasil, estamos falando de uma empresa em rápido crescimento em número de pessoas desenvolvedoras e produtos entregues com alto grau de paralelismo em potencial. Antes de fecharmos a aquisição com o Grupo ZAP (que agora é ZAP+), a empresa já tinha crescido em 65% o time de desenvolvimento em dois anos e com expectativa de muito mais. Temos então uma boa dor de crescimento para tratar e precisamos colocar em ação algumas decisões mais estratégicas na engenharia.

Este artigo traz à luz uma de nossas apostas em dimensionar melhor alguns serviços de back-end críticos para que mais pessoas desenvolvedoras possam alterar regras de negócio ou realizar testes AB no ambiente de produção com esforço mínimo e risco quase zero de incidentes de infraestrutura.

Mas atenção! Este não é um artigo sobre algoritmos de recomendação e modelos de ranqueamento sob a perspectiva de machine learning. Outros artigos abordando a ciência de dados por trás das nossas soluções ainda virão no OLX Tech ;-)

Introdução

Nosso backend de busca é responsável por todas as listagens, pesquisas e filtros nos anúncios do site da OLX, em todas as plataformas. Além disso, ele suporta alguns algoritmos de recomendação com quase 350 milhões de solicitações por dia e aproximadamente seis mil solicitações de pesquisa ou recuperação de dados por segundo em pico de demanda. Assim, impõe baixa latência e alto rendimento com requisitos de baixo custo, o que pede uma engenharia mais sofisticada e processos de desenvolvimento mais cuidadosos (mais resiliência e mais testes em vários níveis).

É empolgante para nós criar uma camada de abstração boa o suficiente que ofereça produtividade para o negócio trabalhando na construção de uma plataforma. Cientistas de dados, desenvolvedores de algoritmos de pesquisa e recomendação devem se concentrar em sua missão com um mínimo ou zero de sobrecarga nos aspectos de engenharia de mais baixo nível. A empreitada de construção de plataforma é sempre uma busca pela melhor correspondência em latência, segurança, rendimento, custo e agilidade de desenvolvimento.

Nossa solução atual começa com o design de uma DSL para parametrização. Porém, nos domínios de recomendação e busca uma DSL completa o suficiente poderá ser demasiada extensa e sempre um desafio. Cientistas de dados, desenvolvedores de algoritmos de pesquisa e recomendação sempre precisarão de alguma customização não prevista e a fricção será inevitável. Assim, mirar em uma DSL muito completa e focada no domínio de negócio seria uma armadilha que traria mais complicações do que simplificações aos nossos processos e design para agilidade e manutenibilidade de busca e recomendação.

Com isto em vista, optamos por permitir recursos de scripts em espaços delimitados da parametrização de forma que as mais diversas customizações sobre o domínio possam ser feitas com restrições e de maneira controlada. Na balança sobre o que será mais rigoroso e o que será mais flexível, preferimos concentrar o rigor da plataforma em um aspecto que não poderá ser corrompido por alterações de regras de negócio: O framework para planos de execuções.

Inspirados inicialmente por bancos de dados relacionais, entendemos que qualquer instrução SQL se torna uma estrutura de árvore que representa o que chamamos de “plano de execução”. Nesta árvore, cada nó representa um algoritmo para gerar, recuperar, combinar, inferir ou modificar dados. Um nó do plano sempre retorna dados ao nó pai e o nó raiz retorna o resultado final da consulta ao usuário.

Entendemos que qualquer solicitação de pesquisa ou recomendação pode ser traduzida em um plano de execução interno. Dessa forma, algoritmos padrão para mesclagem, multiplexação, fallback, cache e RPC podem compor uma estratégia com blocos de construção móveis e flexíveis, podendo resultar em uma abordagem mais sofisticada com bom reúso e menor custo. Assim, algoritmos de pesquisa e recomendação específicos de especialistas podem ser mapeados como blocos de construção únicos no plano e até mesmo combinados com outros blocos usando apenas nosso DSL para criar uma maneira quase imediata de parametrizar o serviço de pesquisa ou recomendação. Qualquer semelhança com Scratch não é mera coincidência.

Concretizando a Idéia

Como somos péssimos em batizar códigos, chamamos o nosso planejador de Async-Vertx-Planner, uma vez que é baseado na biblioteca para IO assíncrono Vert.x, para JVM. Vert.x é uma excelente escolha para implementar serviços com IO assíncrono de alto rendimento com muitas conexões de diferentes clientes em paralelo provendo baixa latência. O lado bom é que é uma boa combinação para nossos requisitos. O lado ruim é que é IO assíncrono ou loop de eventos, o que implica em “promises”, “callback hell” e dificuldades de debug quando as regras de negócios se tornam extensas e complexas. Um dos objetivos da DSL é ocultar todos os aspectos de assincronismo para que o desenvolvedor da parametrização usufrua dos benefícios de se trabalhar com loop de eventos e trabalhe no negócio em mais alto nível.

Para a sintaxe DSL, usamos HOCON, uma sintaxe de configuração humanamente amigável que resulta internamente em uma estrutura JSON. A HOCON apresenta muitos recursos interessantes que nos ajudam a parametrizar o motor de forma mais legível. Todos os exemplos aqui estarão utilizando-a.

Um bloco de construção para nosso motor é chamado de phase. Cada phase recebe os parâmetros de solicitação, uma pilha, e retorna dados para a phase pai ou para o usuário se for a phase raiz. Cada phase pode ter zero ou mais subphases e tratá-las de acordo com sua implementação.

Plano de execução

Chamamos a implementação da phase de classe de phase. Cada uma dessas é um código baseado em Vert.x de baixo nível que faz o trabalho de acordo com seus parâmetros informados na configuração do plano de execução. Portanto, as phases têm parâmetros padrão e específicos de classe. Dado o exemplo abaixo:

Definição de uma phase no plano de execução usando sintaxe HOCON.

Esta phase usando a classe "redis" procura o cache_key em um servidor Redis e retorna o resultado como o resultado da phase sem chamar a subsubphase (parâmetro "phase"). A subphase é chamada apenas em caso de perda de cache, e a classe preenche corretamente o cache após a execução bem-sucedida da subphase para solicitações futuras considerando o TTL. Para resolver alguns parâmetros (como "${stack.final_query}") dinamicamente, usamos o motor de template Apache Freemarker.

Plano de execução com uma phase de classe "redis"
Diagrama de plano de execução usando uma phase de classe "Redis"

Você pode ter observado que todos os parâmetros de conexão são necessários. Em uma configuração mais elaborada, a parametrização se tornaria redundante e difícil de manter. Além disso, note que a subphase é um subobjeto de configuração. Recursivamente, isso fará com que a DSL tenha muitos níveis, o que também não é bom.

Para tornar as phases mais legíveis, seus parâmetros reutilizáveis e uma configuração mais complexa mais organizada, introduzimos o conceito de alias de phases. Eles funcionam assim:

Exemplo de configuração usando alias
Configuração de "aliases" para reuso no plano de execução usando sintaxe HOCON

… e assim a configuração anterior se torna:

Definição de “phase” que herda parâmetros de um “alias” usando sintaxe HOCON

Usando o inherit_alias em vez de class, estamos reusando tudo o que foi declarado no alias especificado. Nesse caso, o alias “redis_conn” define a classe “redis” mais o objeto “conn” com seus atributos. Tudo será mesclado nos parâmetros da phase final antes de ser passado para a implementação concreta. Este mecanismo abre todo tipo de possibilidades, como a seguir:

Definição de “phase” que referencia um “alias” como “subphase” usando sintaxe HOCON
Plano de execução usando herança através de “alias”

Em vez de especificar subobjetos para subphases, podemos apontar para aliases e deixar o motor resolver a configuração final com todos os vários níveis automaticamente. Agora podemos ter todos os nossos blocos de construção modelados como aliases!

Com essas informações, agora podemos organizar nossos planos de algumas maneiras como esta:

  • Aliases para parâmetros básicos de conexão (cache, bancos de dados, RPCs, etc.);
  • Todos os nossos blocos de construção como aliases, vinculando-se uns aos outros como subfases usando seus nomes de alias;

Como a biblioteca de configuração do Vert.x (vertx-config) permite troca a quente de configuração usando HTTP, diretório local ou repositório Git, todo o motor é projetado para permitir a troca de todos os aliases e planos de execução também a quente e com mínimo sobrecarga, sem vazamento de recursos. Junto a isto, o motor oferece alguns recursos de resiliência para evitar tempo de inatividade em produção relacionado à troca de configuração. Isso significa implementações mais rápidas de testes AB, rollbacks instantâneos em caso de problema e resultados mais ágeis sem depender sempre de republicar por completo a plataforma.

Acima de alguns exemplos de classes de phase:

  • Classe Fallback: Possui uma lista de subphases com mais de um elemento e executa apenas o primeiro. Apenas em caso de falha de execução ou nenhum resultado (parametrizado), a subphase seguinte é executada anexando os resultados ao conjunto final e assim por diante.
  • Classe Interleaving: Possui uma lista de subphases que podem ser chamadas em paralelo. Todos os resultados serão mesclados no resultado final usando um algoritmo de intercalação.
  • Classe Multiplex: Funciona como um “switch / case” na DSL. Chama uma subphase de acordo com um teste parametrizado (que pode ser um pequeno script). É útil para alterar o plano de acordo com testes AB ou especialização da busca ou galeria de recomendação.
  • Classe de cache L1: Cache específico de JVM (baseado em Google Guava). Apenas chama sua subphase em caso de perda de cache, preenchendo o cache.
  • Classe de cache L2: O mesmo que o cache L1, mas usando uma fonte externa de cache, como Redis.
  • Classe Elasticsearch: Chama um cluster Elasticsearch.
  • Classe de banco de dados SQL: Executa uma consulta em um banco de dados.
  • Classe HTTP: Faz uma chamada de serviço HTTP, gRPC, etc.
  • Um código mais específico para inferência relacionada a machine learning ou enriquecimento da busca.

Planos de execução mais complexos (e reais) tornam-se viáveis como abaixo:

Um plano de execução um pouco mais realista

O céu é o limite na elaboração de classes de phases. O desafio é desenvolver um conjunto mínimo de classes que resolva o conjunto mais amplo de problemas, inclusive ainda desconhecidos. Dessa forma, todos os códigos de baixo nível relacionados às implementações de classes de phases podem receber um maior cuidado e valorizar mais boas práticas de engenharia de software, tais como boa cobertura de testes, circuit-breaker, boa observabilidade com métricas granulares e log adequado, bom tratamento de exceção e assim por diante. Como as implementações de classes de phases são menos voláteis, todo o sistema se beneficia de sua estabilidade. Por outro lado, aspectos mais voláteis como regras de negócios, variações de teste AB e produção de features se aproveitam dos benefícios de reutilizar esses blocos de construção na DSL apenas reorganizando-os nas configurações e pequenos scripts com menos risco de impactar todo o sistema.

E os Testes?

Mas como testamos nossas configurações de plano de execução feitas dentro da DSL? Temos duas abordagens para esse problema: O warmup e modo debug.

Para testar regras de negócio, a DSL fornece uma rotina de warmup em que o desenvolvedor registra exemplos de buscas e validações sobre os resultados na própria DSL. Sempre que o serviço é iniciado ou alguma alteração de configuração é feita a quente, todas as requisições de warmup são executadas e os seus resultados são validados. Caso alguma das validações do warmup falhe, o motor sinaliza por meio de métricas e logs de forma que um alarme possa ser facilmente configurado e outros níveis de testes automatizados com integração contínua possam ser implementados. Por exemplo, viabiliza-se ter um ambiente de homologação onde a configuração vem de um repositório Git usando o ramo “develop”.

Cada mudança de configuração deverá ser feita no ramo “develop” e, somente após toda a execução do warmup sem sinalização de falha, a mesclagem poderá ser feita no ramo “master”, onde opera o cluster de produção. Alguns fluxos de trabalho semelhantes podem surgir dessa ferramenta de warmup, uma vez que a configuração não é o núcleo do sistema em si, mas merece algum nível de teste especializado.

É claro que não podemos deixar de lado o benefício do warmup que o batiza: O aquecimento da JVM para melhor desempenho nas consultas. :-)

No caso do modo debug, cada implementação de classe de phase é preparada para receber um bit de modo debug e, com isto, retornar um objeto JSON de “dados de debug” em seu resultado principal, análogo à instrução SQL explain. Todas as implementações de classes devem lidar com esse parâmetro de modo debug adequadamente e passá-lo para todas as subphases recursivamente, despejando todos os parâmetros resolvidos dinamicamente e demais aspectos internos relevantes no resultado dentro do objeto de “dados de debug”.

O mecanismo de orquestração também lida com o bit de modo debug, indicando todo o caminho de execução da solicitação, resultados intermediários e tempos de execução individuais para cada phase. Dessa forma, podemos fazer uma solicitação no ambiente de produção passando o bit de modo debug para receber um relatório completo das execuções das phases internas com detalhes. Como esperado, o volume de dados retornados pelo serviço em modo debug é muito maior e, portanto, a latência e o custo computacional também o é. Toda a lógica relacionada para garantir essa separação entre modo debug e “não debug” é de responsabilidade da implementação de cada classe devido às suas especificidades.

Desta forma, temos camadas de teste bem separadas. No núcleo do sistema, as implementações de classes de phases genéricas e de baixo nível atendendo a uma boa cobertura de testes de unidade menos voláteis. Na DSL com lógica de negócios mais voláteis, temos as parametrizações de warmup e recursos para inspecionar execuções em modo debug.

Resultados Até Agora

Boa observabilidade é sem dúvida um dos objetivos deste projeto. Aqui, mostraremos algumas métricas coletadas desse motor funcionando como nosso middleware de busca em cerca de 20 servidores com 40% de uso da CPU, lidando com 400 mil solicitações por minuto e entregando nos mesmos períodos 13 GB de dados JSON transformados por Elasticsearch.

Contanto que declaremos phases e aliases em nossa DSL, podemos ver automaticamente as contagens de execuções em nossos dashboards:

Dashboard de execuções por “phases” em tempo real

Desta forma podemos inspecionar a execução de testes AB (ao bifurcar o plano para diferentes aliases) em tempo real e saber se alguma phase declarada está em uso ou não de acordo com o tráfego de produção.

Cada declaração de phase pode ter um ou mais scripts Jython associados. Temos inclusive uma classe de phase chamada “script” que apenas executa alguns scripts Jython e continua para uma subphase. Este bloco de construção é útil quando queremos fazer um teste AB de alguns dados alternativos ou formatação de consulta, pré-processamento ou ainda aplicar alguma lógica de negócios para compatibilidade de front-ends e BFFs.

Ter scripts na configuração pode levar a sobrecargas significativas, especialmente na conversão das estruturas de dados do núcleo do motor para uma compatível com Jython e vice-versa. Fizemos o nosso melhor para evitar o processamento desnecessário e reduzir ao máximo essa sobrecarga. Rendeu um bom esforço, mas valeu a pena. Para isso, preferimos conversões “lazy”, imutabilidade e composição em vez de clonar dados em memória desnecessariamente.

De qualquer forma, o nosso núcleo mede cada sobrecarga de cada script individualmente para que possamos identificar alguns “outliers” e ver seu desempenho em tempo real. A medição em si possui sobrecarga seguramente desprezível. Seus resultados podem ser vistos abaixo:

Dashboard sobre tempos de execução de scripts dentro de um plano de execução, em tempo real

Este gráfico mostra o tempo de execução de todos os scripts Jython dentro de nossa configuração. Os números estão certos, medimos frações de milissegundos para cada execução propriamente dita ou sobrecarga de script.

Na medição, podemos ver “pre/”, “run/” e “pos/”, que significa “execução de pré-script”, “execução de script propriamente dita” e “execução pós-script”, indicando a sobrecarga central para a preparação a massa de dados de e para estruturas Jython.

Executando muitos níveis de aliases de phases com fallbacks, cache, manipulação de lógica de negócios, testes AB e assim por diante, ainda podemos fornecer uma latência média excelente para todo o tráfego de pesquisa na OLX Brasil. Existem desde a recuperação de um único anúncio até grandes volumes de JSONs por requisição:

Média geral de latência do serviço executando seus planos de execução em produção

Todo o núcleo do sistema alimenta a nossa base de métricas com boa granularidade e dimensões relevantes, podendo nós consultarmos o comportamento das execuções de credenciais específicas com boa riqueza de detalhes. O volume de métricas informadas pelo serviço é grande, mas pré-agregações executadas em cada instância garantem um volume tratável por parte do banco de séries temporais. Com todas essas métricas emitidas automaticamente sem precisar de desenvolvimento específico, estamos sempre aptos a analisar o tráfego em tempo real em busca de padrões de uso inadequado dos serviços clientes.

Próximos Passos

Apresentamos aqui uma dor de crescimento da OLX Brasil e uma aposta para tratá-la estrategicamente no contexto de recomendação e busca indexada. Hoje temos uma versão estável do motor operando e já a algum tempo, o que faz o dedo coçar para a realização de melhorias. Algumas idéias ainda sem previsão para acontecer: Implementar visualizações gráficas do plano de execução e dos relatórios de modo debug (no estilo que os browsers fazem sobre o carregamento de uma página) até tornar este motor open-source, “produtizando-o” em uma implementação extensível por plugins para classes de phases. Certamente essas ações ainda estão por vir.

Conclusão

Entendemos que os conceitos apresentados aqui não são inéditos. Porém, a busca por implementações pré-existentes mais completas que nos satisfizessem com baixo custo de desenvolvimento extra não foram bem sucedidas. Boa parte das motivações iniciais do motor possuía relação com a retrocompatibilidade da plataforma com sua versão anterior, o que tornaria a adaptação de um motor já existente complexa ou não compensaria o risco perante a construção de uma implementação própria sob medida.

Como resultado pudemos entregar um motor consistente, extensível e que permite agilidade na implementação de estratégias sofisticadas de busca e recomendação com baixa latência e reusando gratuitamente toda uma camada de observabilidade granular e resiliência.

Assim fazemos tecnologia e buscamos impactar o ecossistema digital brasileiro.

Nossas vagas estão abertas para quem quiser se aventurar com a gente → https://careers.smartrecruiters.com/OLXBrasil

--

--