اقتطاع الطابع الزمني: قصة 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() ).

هذه ، مع ذلك ، قصة تستحق مقالة أخرى.


شكر خاص لغابرييل رينزي للمساعدة في إنشاء هذه المقالة.