لماذا لم تمنع مفاتيح idempotency تكرار سند واحد 105 مرات؟
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
ست طبقات تعمل في الإنتاج، كل واحدة مبنية ليوم تسقط فيه الطبقة التي فوقها: RLS مُجبَر، وحسابات لا تتجاوزه، وحاويات مقفلة، وشهادات للهواتف، ونسخة احتياطية تُستعاد كل أسبوع.
الساعة 22:02 بتوقيت عدن، 18 أكتوبر. في محطة كهرباء في اليمن، يبدأ هاتفان بإرسال السند نفسه إلى الـ API. يجيب الـ API بـ 500. فيرسله الهاتفان من جديد، ثم من جديد. وعند 22:06 يكون السند قد وصل 105 مرات، وشاشة صحة المنصة تقرأ 9% أخطاء خادم.
لم يتحرك أي رصيد بريال واحد، فقاعدة البيانات رفضت كل محاولة إدخال منها.
وكل طلب من تلك الطلبات مرّ عبر طبقة idempotency بُنيت لهذا بالضبط: هواتف تعيد الإرسال.
هذه طريقة FinyxWatt في فوترة مشتركي الكهرباء من هواتف تفقد الشبكة: المعرّف الذي يرافق الدفعة من الهاتف حتى الإيصال، والسبب الذي يمنع جمع إجماليات الفواتير، والطابور الصادر الذي يقرر ما يُعاد إرساله، والقاعدة التي كانت تنقصني تلك الليلة. وحين تنتهي ستستطيع أن تبني طابورًا للمال يعمل دون اتصال، لا تستطيع فيه إعادة المحاولة أن تحصّل مرتين، ولا يستطيع فيه الرفض أن يدور في حلقة.
تعمل محطة الكهرباء الخاصة عبر سلسلة خطوات صغيرة: المحصّل يقرأ العداد، ثم تصدر الفاتورة، ثم تصل المشترك رسالة SMS، ثم يُحصَّل المبلغ، ثم ينتقل النقد إلى المدير. وFinyxWatt هي المنصة التي بنيتها لهذه السلسلة: أربعة تطبيقات Flutter على API واحد بـ NestJS فوق PostgreSQL. وتطبيق المحطة هو الذي في أيدي المحصّلين، والمحطات تفقد الشبكة كثيرًا.
لذلك لا ينتظر التطبيق الشبكة أبدًا. يعرض النسخ المحفوظة أولًا، وكل عملية كتابة تدخل طابورًا صادرًا (outbox) على الهاتف. وحين يعود الاتصال، يرفعها منسّق واحد بترتيب الاعتماد: المشتركون الجدد أولًا، ثم الفواتير والسندات التي قد تشير إليهم، ثم المحادثات. والسند الذي يخص فاتورة لا توجد بعد إلا على الهاتف ينتظر دوره. وبعد جولة فاشلة ينتظر المنسّق 30 ثانية، ويضاعفها حتى خمس دقائق، كي لا يُثقل على خادم متعثر.
كانت النسخة الأولى على Firebase، ونقلتها إلى خلفيتي الخاصة والمحطات تعمل، فخرجت من النقل بقاعدة واحدة: الهاتف يقترح، والخادم يقرر.
إعادة المحاولة هي التصميم كله، وهذا يفرض سؤالين. إذا كانت إعادة المحاولة أمرًا عاديًا، فما الذي يمنعها من التحصيل مرتين؟ وما الذي يمنعها من أن تستمر إلى ما لا نهاية؟ للسؤال الأول جواب نظيف. أما الثاني فهو ليلة 18 أكتوبر.
كل عملية تأخذ UUID عشوائيًا لحظة حفظها أول مرة على الهاتف، وتحتفظ به عبر كل إعادة محاولة، وكل إعادة تشغيل للتطبيق، وكل رد ضائع. والمعرّف هو أيضًا هوية ما تنشئه العملية: الفاتورة المنتظرة في الطابور تُنشأ على الخادم بهذا المعرّف، وكذلك الدفعة.
dart
/// معرّف عشوائي جديد (الإصدار 4)، يُنشأ حين تدخل العملية الطابور أول مرة.String newOperationId() { final random = Random.secure(); final bytes = List<int>.generate(16, (_) => random.nextInt(256)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; return _format(bytes);}/// المعرّف المحفوظ مع العملية، أو معرّف مشتق للعناصر الأقدم.String operationIdFor(Map<String, dynamic> payload, String queueItemId) { final stored = payload['operationId']; if (stored is String && isCanonicalUuid(stored)) return stored.toLowerCase(); return derivedOperationId(queueItemId);}
الدالة الثانية تعالج فخًّا يظهر بعد الترقية. قد يصل إصدار جديد من التطبيق إلى هاتف ما زال يحمل عمليات وضعها في الطابور إصدار أقدم لم يكن يحفظ معرّفًا. ولو ولّدت معرّفًا جديدًا مع كل إعادة محاولة، لبنيت بيدك التحصيل المزدوج الذي أردت منعه. لذلك تأخذ العملية القديمة معرّفًا مشتقًا من معرّفها الدائم في الطابور، فيبقى القيمة نفسها في كل إعادة، ويُعلَّم بالإصدار 8 من UUID ليعرف من يقرؤه أنه مشتق.
وداخل هذا الاشتقاق فخ ثانٍ. فهو يستعمل FNV-1a بطول 32 بت، والضرب في عدد FNV الأولي مكتوب إزاحاتٍ:
السبب أن كود Dart نفسه يُبنى للمتصفح أيضًا، والأعداد الصحيحة هناك أعداد عشرية مزدوجة (doubles). فالضرب العادي قد يتجاوز 2^53 ويفقد بتاته الدنيا، فيشتق المتصفح من عنصر الطابور نفسه معرّفًا غير الذي يشتقه الهاتف.
نسختان من الطلب نفسه تصلان معًا لا تُنفَّذان معًا. فـ PostgreSQL يجعل ON CONFLICT الثانية تنتظر معاملة الأولى، ثم يقرأ الخاسر الإيصال المكتمل.
الإيصال الموجود الذي تتعذر قراءته يفشل مغلقًا (fail closed). فتنفيذ العملية مرة ثانية هو الشيء الوحيد الذي يجب ألا يفعله هذا الكود أبدًا.
المفتاح المعروف إذا جاء بجسم مختلف فذلك خطأ في العميل، فيأخذ 409 بدل الإعادة. والجسم يُبصم بعد تحويله إلى نص بترتيب ثابت للمفاتيح، فتعطي العملية نفسها البصمة نفسها دائمًا.
المفتاح الذي ما زال في حالة processing يجيب بـ OPERATION_IN_PROGRESS، والهاتف يضعه تحت «حاول لاحقًا».
وفخ هنا احتاج ترحيلًا كاملًا لإصلاحه. كان مفتاح جدول الإيصالات الأول هو المحطة والمستخدم والعملية والمفتاح معًا. بدا ذلك مرتبًا، لكنه سمح باستعمال المفتاح الواحد مرة لكل endpoint. فلو وُجّهت إعادة محاولة يومًا إلى endpoint خاطئ، لأمكن للمفتاح نفسه أن يصنع أثرًا ثانيًا. فجعل الترحيل 0015 كل مفتاح صالحًا لمرة واحدة لكل مستخدم، عبر كل أنواع العمليات.
دون اتصالالهاتف يحفظ الدفعة 7f3… في الطابور الصادر
عاد الاتصاليرسل الدفعة مع Idempotency-Key 7f3…
الـ API يلتزميُسجَّل الإيصال وتُحفظ استجابته
ضاعت الاستجابةلا يصل الرد إلى الهاتف
إعادة بالمفتاح نفسهيعيد الـ API الاستجابة المحفوظة، معلَّمة بأنها مُعادة
معرّف واحد من الهاتف إلى الإيصال
وهكذا صار الرد الضائع بلا ضرر. لكن تذكّر تفصيلًا واحدًا من ذلك الكود: الاستجابة تُحفظ في المعاملة نفسها التي يجري فيها العمل. سنحتاجه بعد قليل.
الطلب الذي لا يتكرر أثره لا يكون آمنًا إلا إذا لم يحمل مالًا اخترعه الهاتف. الهاتف يرسل قراءة العداد، والخادم يتولى الباقي.
قيمة الفاتورة = (القراءة الحالية − القراءة السابقة) × سعر الكيلوواط، مقرّبة إلى منزلتين عشريتين بنوع numeric في PostgreSQL. والقراءة السابقة تأتي من سجل العداد، ويقفله الخادم طوال المعاملة. والسعر هو سعر المشترك الخاص ما دام ضمن تواريخه، وإلا فسعر المحطة. والسعر الذي يرسله الهاتف لا يُعتدّ به إلا في محطة لم تحفظ سعرًا قط، فلا يستطيع هاتف بقي دون اتصال أسابيع أن يفوتر بسعر قديم.
والآثار الجانبية تحتاج الانضباط نفسه. رسالة SMS للمشترك تُنشأ في المعاملة نفسها التي تصدر فيها فاتورته، بالمفتاح invoice:<id>:issued:v1، مع فهرس فريد على المصدر والقالب والمستلم. فلا تستطيع فاتورة مُعادة أن تضيف رسالة ثانية إلى الطابور.
كل مبلغ على المشترك يعيش في جدول واحد: ledger_entries. قيم الفواتير وعكس الدفعات قيود مدينة، والدفعات وإلغاء الفواتير قيود دائنة، والرصيد هو المجموع. ودور الـ API في قاعدة البيانات لا يملك على هذا الجدول إلا SELECT وINSERT، وtrigger يرفض ما سواهما:
sql
CREATE TRIGGER ledger_entries_append_onlyBEFORE UPDATE OR DELETE ON ledger_entriesFOR EACH ROW EXECUTE FUNCTION app_private.prevent_ledger_mutation();-- يرفع: 'ledger entries are append-only; post a reversal entry instead'
ومع ذلك مرّ من هذا التصميم خطأ واحد، وكشفته مراجعة محاسبية. إجمالي الفاتورة يتضمن رصيد المشترك كله متأخراتٍ، وهذا ما يحتاج المشترك أن يراه على فاتورته. غير أن كل فاتورة أقدم غير مسددة بقيت قابلة للدفع، وكل دفعة كانت تُقارن بفاتورتها وحدها.
الفاتورة أ: 1,000 غير مسددة. الفاتورة ب: 500 + 1,000 متأخرات = 1,500. فقُبلت دفعة 1,000 على أ ودفعة 1,500 على ب، والدين الحقيقي 1,500 فقط. وكان العدادان للمشترك الواحد، أو الفاتورة الملغاة، يفتحان الثغرة نفسها.
قبل الترحيل 0080بعده
الفاتورة أ: 1,000، مفتوحة للدفعالفاتورة أ مرحّلة إلى الفاتورة ب
غيّر الترحيل 0080 ثلاث قواعد. إصدار فاتورة جديدة يُرحّل إليها كل فاتورة مفتوحة للمشترك، والفاتورة المرحّلة لا تقبل دفعًا. والدفعة محدودة بالمتبقي من الفاتورة وبرصيد المشترك في السجل. وبعد أي تغيير مالي، تضبط قاعدة البيانات حالة كل فاتورة من السجل، بدءًا بأقدم دين، فلا تُستنتج حالة السداد أبدًا من مقارنة paid_amount بـ total_amount.
وكان الخطأ نفسه مختبئًا في لوحة الإدارة. لوحة الموقع كانت تجمع صفحة من 250 صفًا لتعرض ما فُوتر وما بقي. الآن تجمع قاعدة البيانات كل صف يحق للعضو أن يراه، واللوحة تقرأ ذلك الرقم الواحد.
نعود إلى الطابور الصادر. حين يفشل الرفع، على الهاتف أن يقرر معنى هذا الفشل، وهو يصنّف كل خطأ في أربعة أنواع:
النوع
أمثلة
ماذا يفعل الطابور
مؤجَّل
لا شبكة، أو خروج من الحساب، أو هاتف ينتظر الاعتماد
يوقف هذه الجولة ويحتفظ بكل شيء لوقت لاحق
قابل للإعادة
انتهاء المهلة، و408 و429 و500 و502 و503 و504، وOPERATION_IN_PROGRESS
يحتفظ بالعملية ويعيدها في جولة لاحقة
محجوب
400 و403 و404 و409 و410 و412 و422 و501
يتوقف عن إرسالها ويعرضها على شخص ليراجعها
مجهول
أي شيء آخر
يوقف هذه الجولة ويحاول لاحقًا
اقرأ الـ 500 في هذا الجدول مرة أخرى. والآن، ليلة 18 أكتوبر.
كان السند يحمل رقم عملية من مزوّد دفع، أي مرجع حوالة. وهذا الرقم كان موجودًا من قبل على سند آخر ساري من المزوّد نفسه. ففعل الفهرس الفريد payments_provider_reference_unique ما صُمّم له ورفض الإدخال. ولم يكن في الـ API ما يترجم هذا الخطأ، فخرج 500. والهواتف تقرأ الـ 500 فشلًا عابرًا، فأعادت إرسال السند.
الهاتف يرسل السندمن الطابور الصادر، بمفتاحه
الفهرس يرفضهرقم العملية موجود على سند ساري
الـ API يجيب بـ 500والحجز يتراجع مع كل شيء آخر
الهاتف يعيد المحاولةفالـ 500 عنده فشل عابر
حلقة 18 أكتوبر. كل خطوة فيها كانت صحيحة وحدها.
والآن سؤال البداية: لماذا لم توقفه طبقة الـ idempotency؟
لأن المفتاح يعيد النجاح فقط. الحجز يعيش في المعاملة نفسها التي يجري فيها العمل، وهذا بالضبط ما يجعله آمنًا: العمل واستجابته المحفوظة يُلتزم بهما معًا أو لا يُلتزم بشيء. فحين فشل الإدخال ألغت PostgreSQL المعاملة، واختفى الحجز معها. لم يبقَ ما يتذكر الفشل، فكانت كل إعادة إرسال محاولةً أولى تفشل بالطريقة نفسها. وحفظ الفشل كان سيكون أسوأ، لأن الفشل العابر، كتعارض أقفال (deadlock) أو إعادة تشغيل، كان سيُعاد عندها إلى الأبد.
المفتاح أدّى عمله، ورمز الحالة كان هو الخطأ. والإصلاح دالة واحدة: صار هذا الخرق للفهرس الفريد 409، يشرحه تطبيق المحطة والموقع بعربية واضحة. وعلى الهاتف يُحجب الـ 409، ويُعرض للمراجعة، ولا يُعاد إرساله أبدًا.
ts
function duplicateReference(error: unknown): never { const failure = error as { code?: unknown; constraint?: unknown } | null if (failure?.code === '23505' && failure.constraint === 'payments_provider_reference_unique') { throw new ConflictException({ code: 'PAYMENT_REFERENCE_DUPLICATE' }) } throw error}
والالتزام (commit) الذي أصلحه مؤرخ بالساعة 22:29 من الليلة نفسها. واختبار التكامل فيه يرسل العملية نفسها مرتين بالمفتاح نفسه، وينتظر الـ 409 نفسه في المرتين. ويتحقق كذلك من أن مزوّدًا آخر يجوز أن يحمل الرقم نفسه.
والهواتف التي تعمل دون اتصال تطرح سؤالًا آخر: ماذا لو رفع هاتف سندًا تاريخه داخل فترة أقفلتها المحطة من قبل؟ إقفال الفترة يكتب لكل مشترك قيد إقفال وقيد افتتاح مجموعهما صفر، ويجب أن يبقى مجموع الفترة المقفلة صفرًا. لذلك ينقل trigger القيد المتأخر إلى بداية الفترة المفتوحة:
sql
CREATE FUNCTION app_private.ledger_entry_into_open_period() RETURNS trigger AS $$DECLARE open_from timestamptz;BEGIN PERFORM pg_advisory_xact_lock_shared(hashtextextended('archive:' || NEW.tenant_id::text, 0)); open_from := app_private.archive_boundary(NEW.tenant_id); IF NEW.effective_at < open_from AND NEW.entry_kind::text <> 'period_close' THEN NEW.effective_at := open_from; END IF; RETURN NEW;END $$ LANGUAGE plpgsql;
والإقفال يأخذ القفل الاستشاري (advisory lock) نفسه حصريًا، لكل محطة على حدة. فينتظر الإقفال الكتابات الجارية، وتنتظر الكتابات الجديدة الإقفال.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.