اقتطاع الطابع الزمني: قصة ActiveRecord من روبي على القضبان
نشرت: 2022-03-11من المفترض أن تساعد الاختبارات في منع حدوث تقشر في التطبيقات. ولكن بين الحين والآخر ، يمكن أن تصبح الاختبارات نفسها غير مستقرة - حتى أكثرها مباشرة. إليك كيفية الخوض في اختبار إشكالي على تطبيق Ruby on Rails المدعوم من PostgreSQL ، وما اكتشفناه.
أردنا التحقق من أن منطق عمل معين (يسمى بواسطة طريقة perform
) لا يغير نموذج calendar
(مثيل Calendar
، فئة نموذج Ruby on Rails ActiveRecord) لذلك كتبنا:
let(:calendar) { create(:calendar) } specify do expect do perform # call the business action calendar.reload end .not_to change(calendar, :attributes) end
كان هذا يمر في بيئة تطوير واحدة (MacOS) ، لكنه كان دائمًا ما يفشل في CI (Linux).
لحسن الحظ ، تمكنا من إعادة إنتاجه في بيئة تطوير أخرى (Linux) ، حيث فشل مع رسالة:
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}
هل ترى شيئًا مريبًا؟
التحقيق
عند الفحص الدقيق ، لاحظنا أنه تم تغيير الطوابع الزمنية التي تم updated_at
created_at
داخل الكتلة 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}
تم قطع الجزء الكسري للثواني بحيث أصبحت 13: 36: 13:36:22.459149334
13:36:22.459149000
.
كنا واثقين من أن perform
لم يكن تحديثًا لكائن calendar
، لذلك قمنا بتشكيل فرضية مفادها أن الطوابع الزمنية يتم اقتطاعها بواسطة قاعدة البيانات. لاختبار ذلك ، استخدمنا تقنية التصحيح الأكثر تقدمًا والمعروفة ، على سبيل المثال ، وضع التصحيح:
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
لكن الاقتطاع لم يكن مرئيًا في الإخراج:
before perform: 1577983568.550754 after perform: 1577983568.550754 after reload: 1577983568.550754
كان هذا مفاجئًا تمامًا - يجب أن يكون #created_at
نفس قيمة قيمة تجزئة attributes['created_at']
. للتأكد من أننا كنا نطبع نفس القيمة المستخدمة في التأكيد ، قمنا بتغيير طريقة وصولنا إلى created_at
.
بدلاً من استخدام accessor calendar.created_at.to_f
، قمنا بالتبديل إلى جلبه من تجزئة السمات مباشرة: calendar.attributes['created_at'].to_f
. تم تأكيد شكوكنا تجاه إعادة تحميل calendar.reload
!
before perform: 1577985089.0547702 after perform: 1577985089.0547702 after reload: 1577985089.05477
كما ترى ، لم يتغير perform
الاتصال الذي created_at
، ولكن تم reload
.
للتأكد من أن التغيير لم يحدث في مثيل آخر من calendar
ثم يتم حفظه ، أجرينا تجربة أخرى. أعدنا تحميل calendar
قبل الاختبار:
let(:calendar) { create(:calendar).reload } specify do expect do perform calendar.reload end .not_to change(calendar, :attributes) end
هذا جعل الاختبار صديقًا للبيئة.

المأزق
مع العلم أن قاعدة البيانات هي التي تقطع الطوابع الزمنية الخاصة بنا وتفشل في اختباراتنا ، قررنا منع حدوث الاقتطاع. قمنا بإنشاء كائن DateTime
وتقريبه إلى ثوانٍ كاملة. بعد ذلك ، استخدمنا هذا الكائن لضبط الطوابع الزمنية لـ Active Record لـ Rails بشكل صريح. هذا التغيير ثابت واستقر الاختبارات:
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
القضية
لماذا حدث هذا؟ يتم تعيين الطوابع الزمنية لـ Active Record بواسطة الوحدة النمطية لـ Rails ' ActiveRecord::Timestamp
باستخدام Time.now
. تعتمد دقة Time
على نظام التشغيل ، وبحسب حالة المستندات ، فقد تتضمن أجزاء من الثواني .
اختبرنا دقة Time.now
على نظامي MacOS و Linux باستخدام برنامج نصي يحسب ترددات أطوال الأجزاء الجزئية:
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}
كما ترى ، فإن حوالي 70٪ من الطوابع الزمنية على Linux بها سبعة أرقام من الدقة بعد العلامة العشرية ، بينما في MacOS 25٪ فقط. هذا هو السبب في أن الاختبارات اجتازت معظم الوقت على نظام التشغيل MacOS وفشلت في معظم الوقت على نظام التشغيل Linux. ربما لاحظت أن إخراج الاختبار يحتوي على دقة مكونة من تسعة أرقام - وذلك لأن RSpec يستخدم Time#nsec
لتنسيق إخراج الوقت.
عندما يتم حفظ نماذج ريلز في قاعدة البيانات ، يتم تخزين أي طوابع زمنية قاموا بتخزينها باستخدام نوع في PostgreSQL يسمى timestamp without time zone
، والذي يحتوي على دقة ميكرو ثانية - أي ستة أرقام بعد العلامة العشرية. لذلك عندما يتم إرسال 1577987974.6472975
إلى PostgreSQL ، فإنه يقتطع الرقم الأخير من الجزء الكسري ويحفظ بدلاً من ذلك 1577987974.647297
.
الأسئلة
لا يزال هناك سؤال حول سبب عدم إعادة تحميل calendar.created_at
عندما استدعينا calendar.reload
، على الرغم من إعادة تحميل calendar.attributes['created_at']
.
أيضًا ، نتائج اختبار دقة Time
مفاجئة بعض الشيء. كنا نتوقع أن الدقة القصوى في نظام MacOS هي ستة. لا نعرف لماذا يتكون أحيانًا من سبعة أرقام. ما أدهشنا أكثر هو توزيع قيمة الأرقام الأخيرة:
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}
كما ترى ، فإن الرقم السابع في نظام التشغيل MacOS هو دائمًا 1 أو 2 أو 8 أو 9.
إذا كنت تعرف الإجابة على أي من هذه الأسئلة ، فيرجى مشاركة شرح لنا.
المستقبل
حقيقة أن الطوابع الزمنية Active Record الخاصة بـ Ruby on Rails يتم إنشاؤها على جانب التطبيق قد تتأذى أيضًا عند استخدام هذه الطوابع الزمنية لترتيب موثوق ودقيق للأحداث المحفوظة في قاعدة البيانات. نظرًا لأن ساعات خادم التطبيق قد تكون غير متزامنة ، فقد تظهر الأحداث التي تم ترتيبها بواسطة created_at
بترتيب مختلف عما حدث بالفعل. للحصول على سلوك أكثر موثوقية ، سيكون من الأفضل السماح لخادم قاعدة البيانات بمعالجة الطوابع الزمنية (على سبيل المثال ، PostgreSQL's now()
).
هذه ، مع ذلك ، قصة تستحق مقالة أخرى.
شكر خاص لغابرييل رينزي للمساعدة في إنشاء هذه المقالة.