UserJourney via task Celery: race com a leitura imediata do daily audio

O que aconteceu

Usuário trial (e, de forma geral, qualquer usuário novo) via o “áudio do dia” genérico na primeira entrada no app, em vez do áudio 1 da jornada/trilha inicial. Na segunda entrada, o áudio correto aparecia.

Causa raiz

O aceite de termos disparava um signal post_save (create_user_journey_on_terms_acceptance, apps/audios/signals.py) que enfileirava a criação da UserJourney como task assíncrona (create_user_journey.delay(user.pk)). Se o cliente chamasse GET /audios/daily antes do worker Celery processar a task, is_in_journey ainda estava False no banco e AudioService.get_daily_audio caía no fallback do áudio diário genérico.

Em config/settings/test.py, CELERY_TASK_ALWAYS_EAGER = True faz .delay() rodar de forma síncrona nos testes — isso mascarou a race durante o desenvolvimento original: qualquer teste que apenas disparasse o signal e checasse o resultado final passava, mesmo com o bug presente, porque só se manifesta com um broker real (RabbitMQ) e latência de fila.

Correção

O signal passou a chamar JourneyService.create_for(instance) diretamente, de forma síncrona, dentro do mesmo post_save do aceite de termos — sem passar por Celery. A task create_user_journey, que ficou sem nenhum outro chamador, foi removida.

Como evitar

  • Quando uma ação do usuário precisa de um efeito colateral que o próximo request imediato do mesmo usuário pode depender (aqui: aceitar termos → ler o daily audio), não delegue esse efeito a uma task assíncrona — a menos que o consumidor seja tolerante ao atraso ou tenha um fallback correto para o estado “ainda não processado”.
  • CELERY_TASK_ALWAYS_EAGER=True em teste é necessário para rodar a suíte sem broker, mas esconde exatamente esse tipo de race. Para provar TDD real nesse cenário, o teste precisa verificar a ausência de .delay()/task (ex: mockar o método de criação e assertar que foi chamado diretamente pelo signal), não apenas o resultado final do fluxo.

Referências