Obcinanie znacznika czasu: opowieść o Ruby on Rails ActiveRecord
Opublikowany: 2022-03-11Testy mają pomóc w zapobieganiu łamliwości aplikacji. Ale od czasu do czasu same testy mogą stać się niestabilne — nawet te najprostsze. Oto jak zagłębiliśmy się w problematyczny test aplikacji Ruby on Rails wspieranej przez PostgreSQL i co odkryliśmy.
Chcieliśmy sprawdzić, czy pewna logika biznesowa (wywoływana przez metodę perform
) nie zmienia modelu calendar
(instancja Calendar
, klasa modelu Ruby on Rails ActiveRecord), więc napisaliśmy:
let(:calendar) { create(:calendar) } specify do expect do perform # call the business action calendar.reload end .not_to change(calendar, :attributes) end
To przechodziło w jednym środowisku programistycznym (MacOS), ale prawie zawsze zawodziło w CI (Linux).
Na szczęście udało nam się go odtworzyć na innym środowisku programistycznym (Linux), gdzie zawiódł z komunikatem:
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}
Widzisz coś podejrzanego?
Dochodzenie
Przy bliższym przyjrzeniu się zauważyliśmy, że znaczniki czasu created_at
i updated_at
zostały nieznacznie zmienione wewnątrz bloku 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}
Część ułamkowa sekund została obcięta tak, że 13:36:22.459149334
stało się 13:36:22.459149000
.
Byliśmy pewni, że perform
nie aktualizuje obiektu calendar
, więc postawiliśmy hipotezę, że sygnatury czasowe są obcinane przez bazę danych. Aby to przetestować, wykorzystaliśmy najbardziej zaawansowaną znaną technikę debugowania, tj. debugowanie puts:
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
Ale obcięcie nie było widoczne w danych wyjściowych:
before perform: 1577983568.550754 after perform: 1577983568.550754 after reload: 1577983568.550754
To było dość zaskakujące — akcesor #created_at
powinien mieć taką samą wartość, jak wartość skrótu attributes['created_at']
. Aby mieć pewność, że wyświetlamy tę samą wartość, co w asercji, zmieniliśmy sposób uzyskiwania dostępu do created_at
.
Zamiast używać akcesora calendar.created_at.to_f
, przeszliśmy na pobieranie go bezpośrednio z hasha atrybutów: calendar.attributes['created_at'].to_f
. Nasze podejrzenia co do calendar.reload
potwierdziły się!
before perform: 1577985089.0547702 after perform: 1577985089.0547702 after reload: 1577985089.05477
Jak widać, wywołanie perform
nie zmieniło created_at
, ale reload
tak.
Aby mieć pewność, że zmiana nie nastąpi w innym wystąpieniu calendar
, a następnie nie zostanie zapisana, przeprowadziliśmy jeszcze jeden eksperyment. Przeładowaliśmy calendar
przed testem:
let(:calendar) { create(:calendar).reload } specify do expect do perform calendar.reload end .not_to change(calendar, :attributes) end
To sprawiło, że test był zielony.

Poprawka
Wiedząc, że to baza danych obcina nasze znaczniki czasu i nie przechodzi naszych testów, postanowiliśmy zapobiec obcinaniu. Wygenerowaliśmy obiekt DateTime
i zaokrągliliśmy go do pełnych sekund. Następnie użyliśmy tego obiektu do jawnego ustawienia znaczników czasu Active Record w Railsach. Ta zmiana naprawiła i ustabilizowała testy:
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
Powód
Dlaczego się to stało? Znaczniki czasu Active Record są ustawiane przez moduł ActiveRecord::Timestamp
Railsów przy użyciu Time.now
. Dokładność Time
jest zależna od systemu operacyjnego i zgodnie ze stanem w dokumentacji może obejmować ułamki sekund .
Przetestowaliśmy rozdzielczość Time.now
na MacOS i Linux za pomocą skryptu, który zlicza częstotliwości ułamkowej długości części:
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}
Jak widać, około 70% znaczników czasu w systemie Linux miało siedem cyfr precyzji po przecinku, podczas gdy w systemie MacOS miało to tylko 25%. To jest powód, dla którego testy w większości przypadków kończyły się niepowodzeniem w systemie MacOS i w większości przypadków nie w systemie Linux. Być może zauważyłeś, że wynik testu ma dziewięciocyfrową precyzję — to dlatego, że RSpec używa Time#nsec
do formatowania czasu.
Kiedy modele Railsów są zapisywane w bazie danych, wszelkie posiadane przez nie znaczniki czasu są przechowywane przy użyciu typu w PostgreSQL zwanego timestamp without time zone
, który ma rozdzielczość w mikrosekundach, tj. sześć cyfr po przecinku. Więc kiedy 1577987974.6472975
jest wysyłany do PostgreSQL, obcina ostatnią cyfrę części ułamkowej i zamiast tego zapisuje 1577987974.647297
.
Pytania
Nadal pozostaje pytanie, dlaczego calendar.created_at
nie został ponownie załadowany, gdy wywołaliśmy calendar.reload
, mimo że calendar.attributes['created_at']
został ponownie załadowany.
Nieco zaskakujące są również wyniki testu precyzji Time
. Spodziewaliśmy się, że na MacOS maksymalna precyzja to sześć. Nie wiemy, dlaczego czasami ma siedem cyfr. Bardziej zaskoczył nas rozkład wartości ostatnich cyfr:
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}
Jak widać, siódma cyfra w systemie MacOS to zawsze 1, 2, 8 lub 9.
Jeśli znasz odpowiedź na którekolwiek z tych pytań, podziel się z nami wyjaśnieniem.
Przyszłość
Fakt, że znaczniki czasu Active Record Ruby on Rails są generowane po stronie aplikacji, może również zaszkodzić, gdy te znaczniki czasu są używane do niezawodnego i precyzyjnego porządkowania zdarzeń zapisanych w bazie danych. Ponieważ zegary serwera aplikacji mogą być zdesynchronizowane, zdarzenia uporządkowane przez created_at
mogą pojawiać się w innej kolejności niż w rzeczywistości. Aby uzyskać bardziej niezawodne zachowanie, lepiej byłoby pozwolić serwerowi bazy danych na obsługę znaczników czasu (np. now()
w PostgreSQL).
To jednak historia warta kolejnego artykułu.
Specjalne podziękowania dla Gabriele Renzi za pomoc w tworzeniu tego artykułu.