Truncamento de carimbo de data/hora: um conto do ActiveRecord do Ruby on Rails
Publicados: 2022-03-11Os testes devem ajudar a evitar que os aplicativos sejam esquisitos. Mas, de vez em quando, os próprios testes podem se tornar esquisitos – mesmo os mais diretos. Veja como mergulhamos em um teste problemático em um aplicativo Ruby on Rails apoiado pelo PostgreSQL e o que descobrimos.
Queríamos verificar se certa lógica de negócios (chamada por um método perform
) não altera um modelo de calendar
(uma instância de Calendar
, uma classe de modelo ActiveRecord do Ruby on Rails), então escrevemos:
let(:calendar) { create(:calendar) } specify do expect do perform # call the business action calendar.reload end .not_to change(calendar, :attributes) end
Isso estava passando em um ambiente de desenvolvimento (MacOS), mas quase sempre falhava no CI (Linux).
Felizmente, conseguimos reproduzi-lo em outro ambiente de desenvolvimento (Linux), onde falhou com uma mensagem:
expected `Calendar#attributes` not to have changed, but did change from {"calendar_auth_id"=>8, "created_at"=>2020-01-02 13:36:22.459149334 +0000, "enabled"=>false, "events_...t_sync_token"=>nil, "title"=>nil, "updated_at"=>2020-01-02 13:36:22.459149334 +0000, "user_id"=>100} to { "calendar_auth_id"=>8, "created_at"=>2020-01-02 13:36:22.459149000 +0000, "enabled"=>false, "events_...t_sync_token"=>nil, "title"=>nil, "updated_at"=>2020-01-02 13:36:22.459149000 +0000, "user_id"=>100}
Vê algo suspeito?
A investigação
Em um exame mais detalhado, notamos que os timestamps created_at
e updated_at
foram ligeiramente alterados dentro do bloco expect
:
{"created_at"=>2020-01-02 13:36:22.459149334 +0000, "updated_at"=>2020-01-02 13:36:22.459149334 +0000} {"created_at"=>2020-01-02 13:36:22.459149000 +0000, "updated_at"=>2020-01-02 13:36:22.459149000 +0000}
A parte fracionária dos segundos foi truncada para que 13:36:22.459149334
se tornasse 13:36:22.459149000
.
Estávamos confiantes de que perform
não estava atualizando o objeto calendar
, então formamos uma hipótese de que os timestamps estavam sendo truncados pelo banco de dados. Para testar isso, usamos a técnica de depuração mais avançada conhecida, ou seja, puts debugging:
let(:calendar) { create(:calendar) } specify do expect do puts "before perform: #{calendar.created_at.to_f}" perform puts "after perform: #{calendar.created_at.to_f}" calendar.reload puts "after reload: #{calendar.created_at.to_f}" end .not_to change(calendar, :attributes) end
Mas o truncamento não era visível na saída:
before perform: 1577983568.550754 after perform: 1577983568.550754 after reload: 1577983568.550754
Isso foi bastante surpreendente - o acessador #created_at
deveria ter o mesmo valor que o valor de hash do atributo de attributes['created_at']
. Para ter certeza de que estávamos imprimindo o mesmo valor usado na asserção, alteramos a maneira como created_at
.
Em vez de usar o acessador calendar.created_at.to_f
, mudamos para buscá-lo diretamente do hash de atributos: calendar.attributes['created_at'].to_f
. Nossas suspeitas em relação ao calendar.reload
foram confirmadas!
before perform: 1577985089.0547702 after perform: 1577985089.0547702 after reload: 1577985089.05477
Como você pode ver, chamar perform
não mudou created_at
, mas reload
sim.
Para garantir que a alteração não estivesse acontecendo em outra instância da calendar
e fosse salva, realizamos mais uma experiência. Recarregamos o calendar
antes do teste:
let(:calendar) { create(:calendar).reload } specify do expect do perform calendar.reload end .not_to change(calendar, :attributes) end
Isso tornou o teste verde.

O conserto
Sabendo que é o banco de dados que trunca nossos timestamps e falha em nossos testes, decidimos evitar que o truncamento acontecesse. Geramos um objeto DateTime
e o arredondamos para segundos inteiros. Então, usamos este objeto para definir explicitamente os timestamps do Active Record do Rails. Essa mudança corrigiu e estabilizou os testes:
let(:time) { 1.day.ago.round } let(:calendar) { create(:calendar, created_at: time, updated_at: time) } specify do expect do perform calendar.reload end .not_to change(calendar, :attributes) end
A causa
Por quê isso aconteceu? Os timestamps do Active Record são definidos pelo módulo ActiveRecord::Timestamp
do Rails usando Time.now
. A precisão do Time
depende do sistema operacional e, conforme o estado dos documentos, pode incluir segundos fracionários .
Testamos a resolução Time.now
no MacOS e Linux com um script que conta as frequências de comprimentos fracionários:
pry> 10000.times.map { Time.now.to_f.to_s.match(/\.(\d+)/)[1].size }.group_by{|a| a}.map{|k, v| [k, v.count]}.to_h # MacOS => {6=>6581, 7=>2682, 5=>662, 4=>67, 3=>7, 2=>1} # Linux => {6=>2399, 7=>7300, 5=>266, 4=>32, 3=>3}
Como você pode ver, cerca de 70% dos timestamps no Linux tinham sete dígitos de precisão após o decimal, enquanto no MacOS apenas 25% tinham. Esta é a razão pela qual os testes passaram na maioria das vezes no MacOS e falharam na maioria das vezes no Linux. Você deve ter notado que a saída de teste tinha precisão de nove dígitos — isso porque RSpec usa Time#nsec
para formatar a saída de hora.
Quando os modelos Rails são salvos no banco de dados, todos os timestamps que eles possuem são armazenados usando um tipo no PostgreSQL chamado timestamp without time zone
, que tem resolução de microssegundos—ou seja, seis dígitos após o decimal. Assim, quando 1577987974.6472975
é enviado ao PostgreSQL, ele trunca o último dígito da parte fracionária e salva 1577987974.647297
.
As questões
Ainda há a questão de por que calendar.created_at
não foi recarregado quando chamamos calendar.reload
, mesmo que calendar.attributes['created_at']
tenha sido recarregado.
Além disso, os resultados do teste de precisão de Time
são um pouco surpreendentes. Esperávamos que no MacOS, a precisão máxima fosse seis. Não sabemos por que às vezes tem sete dígitos. O que mais nos surpreendeu foi a distribuição do valor dos últimos dígitos:
pry> 10000.times.map { Time.now}.map{|t| t.to_f.to_s.match(/\.(\d+)/)[1] }.select{|s| s.size == 7}.group_by{|e| e[-1]}.map{|k, v| [k, v.size]}.to_h # MacOS => {"9"=>536, "1"=>555, "2"=>778, "8"=>807} # Linux => {"5"=>981, "1"=>311, "3"=>1039, "9"=>309, "8"=>989, "6"=>1031, "2"=>979, "7"=>966, "4"=>978}
Como você pode ver, o sétimo dígito no MacOS é sempre 1, 2, 8 ou 9.
Se você souber a resposta para qualquer uma dessas perguntas, compartilhe uma explicação conosco.
O futuro
O fato de os timestamps do Active Record do Ruby on Rails serem gerados no lado do aplicativo também pode prejudicar quando esses timestamps são usados para ordenação confiável e precisa de eventos salvos no banco de dados. Como os relógios do servidor de aplicativos podem ser dessincronizados, os eventos ordenados por created_at
podem aparecer em uma ordem diferente da que realmente ocorreram. Para obter um comportamento mais confiável, seria melhor deixar o servidor de banco de dados lidar com timestamps (por exemplo, now()
do PostgreSQL).
Isso, no entanto, é uma história que vale outro artigo.
Agradecimentos especiais a Gabriele Renzi por ajudar a criar este artigo.