انتقل للمحتوى
علي هيثم·TECH·يردّ خلال أقل من ساعة
  • الرئيسيةابدأ من هنا
  • الأعمالدراسات حالة، مشاريع
  • الخدماتما الذي سأبنيه لك
  • المدونةمقالات، سلاسل
  • التدريبدورات، مختبرات، دورات
  • عنّيالمهندس وراء هذا الموقع
تسجيل الدخولابدأ مشروع
علي هيثم · TECH
  • الرئيسية↗
  • الأعمال↗
  • الخدمات↗
  • المدونة↗
  • التدريب↗
  • عنّي↗
تسجيل الدخولابدأ مشروع
متاح
علي هيثم·TECH

استوديو هندسي. دمشق، GMT+3.

aliyosef.online

الاستوديو

  • الأعمال
  • المدونة
  • التدريب
  • عنّي
  • تواصل معي

البوابة

  • تسجيل الدخول
  • فتح تذكرة دعم
  • متابعة مشروع
  • حسابي

مصادر

  • الوثائق
  • حالة الأنظمة
  • سجلّ التغييرات
  • حزمة العلامة
  • الخصوصية
  • الشروط

النشرة

ملاحظات تقنية وأخبار، أسبوعياً. بدون حشو.

مجاني. إلغاء الاشتراك متى ما حبيت.

تابعني على
© 2026 علي هيثم يوسف. جميع الحقوق محفوظة.مبنيّ يدويًا بـ React 19.آخر نشر · 2026-05-08كل الأنظمة تعمل
دورةمحرّكات المزامنة، من طرف لطرف↗

أيضاً في:Flutter offline-first

  1. الرئيسية/
  2. المدوّنة/
  3. محرّكات المزامنة
محرّكات المزامنة

بناء محرّك مزامنة، الجزء الأول: الـ queue

افتتاح السلسلة. شكل الـ queue الصادر، ليش الترتيب مهم، والـ test harness اللي بيمسك كل شي.

٢٠ نيسان ٢٠٢٦·14 دقيقة·بقلم علي يوسف
جزء منsync-engine→

جاري تحميل المقال…

شاركXLinkedInRSS
في هذه السلسلةالجزء 2 من 2
←السابقبناء محرّك مزامنة، الجزء الثاني: الترتيبنظرة عامة على السلسلة→
استلم المقال التالي في بريدك.مقال جديد كل أحد. هندسة بلغة واضحة. مجاني، يمكنك إلغاء الاشتراك في أي وقت.اشترك→
AY
علي هيثم يوسف

مهندس وكاتب. أبني منصات متعدّدة المستأجرين، محرّكات مزامنة، وأنظمة موثوقة بهدوء. أكثر من ٦٠٠ مشروع مُسلَّم منذ ٢٠١٥.

اشتركابدأ مشروعاً→

اقرأ بعدها

  • S2
    Series
    ٢٨ تشرين الثاني ٢٠٢٥·محرّكات المزامنة

    بناء محرّك مزامنة، الجزء الثاني: الترتيب

    Vector clocks للواقع offline. ليش بترجع تكلفتها، والنموذج الأبسط اللي ما بيرجع.

    13 دقيقة
  • PB
    ٥ أيار ٢٠٢٦·محرّكات المزامنة

    متى الـ polling أفضل من الـ websockets

    سنة من الـ trade-offs بمحاسب. ليش الجواب الواضح طلع غلط على ظروف الشبكة عندنا.

    8 دقائق
  • CR
    ٢٨ نيسان ٢٠٢٦·محرّكات المزامنة

    حلّ التعارضات في تطبيقات offline-first

    Last-write-wins غالباً غلط. هاد اللي بيشتغل لما اثنين clients يختلفوا.

    10 دقائق
←العودة إلى كل المقالات

محرّك المزامنة هو queue صغير، نقطتا API، والكثير من الحالات الحدّية. الـ queue هو حيث يفشل معظم المحرّكات في شهرها الأوّل، لأنّ التطبيق البديهيّ يعمل بشكل ممتاز لأوّل مئة مستخدم ثمّ يفقد البيانات بصمت. هذا هو الجزء الأوّل من خمسة عن المحرّك الذي يُبقي عميل Flutter وخلفيّة Laravel متّسقَين. نبدأ بالـ queue لأنّ كلّ قطعة أخرى من المحرّك تفترض أنّه يعمل.

ما الذي يجب على الـ queue فعله حقيقةً#

كلّ كتابة قابلة للعمل بدون اتّصال (إنشاء فاتورة، تعديل جهة اتصال، حذف صفّ) تنزل في جدول محلّيّ على الجهاز أوّلاً، ثمّ تنضمّ إلى queue ينتظر الدفع إلى الخادم. الـ queue يجب أن:

  1. يصمد عبر إعادة تشغيل التطبيق. المستخدم الذي ينشئ فاتورة في المترو ويُغلق التطبيق قسراً لم يعقد عقداً معنا حول متى يجب أن تُزامَن فاتورته. يجب أن تُزامَن.
  2. يحفظ ترتيب الكتابات المرتبطة. إن أنشأ المستخدم جهة اتصال، ثمّ أنشأ فاتورة لجهة الاتصال هذه، يجب أن تنزل جهة الاتصال على الخادم أوّلاً. وإلّا تصل الفاتورة يتيمة.
  3. يكون idempotent. إعادة المحاولة يجب ألّا تنتج صفّاً مكرّراً. الشبكة ستعيد المحاولة. المستخدم سيعيد المحاولة. نظام التشغيل سيعيد المحاولة.
  4. يكون قابلاً للمراقبة. حين تَعلق قطعة، يجب أن تنظر إلى الـ queue وتفهم السبب بدون تخمين.

إن كان الـ queue لا يفعل الأربعة، فلا يوجد لديك queue. لديك كتابة متفائلة ودعاء.

الشكل الذي ينجو#

بعد ثلاث إعادات كتابة، استقررنا على شكل الصفّ هذا، في جدول Drift على العميل:

@DataClassName('SyncQueueItem')
class SyncQueue extends Table {
  TextColumn get id => text()();                       // UUID محلّي
  TextColumn get entityType => text()();               // 'invoice', 'contact'
  TextColumn get localEntityId => text()();            // الصفّ الذي يُكتَب
  TextColumn get operation => text()();                // 'create' | 'update' | 'delete'
  TextColumn get payload => text()();                  // JSON، الـ diff الفعلي
  TextColumn get idempotencyKey => text().unique()();  // عقد الخادم
  IntColumn  get attempts => integer().withDefault(const Constant(0))();
  TextColumn get status => text()                       // 'pending' | 'in_progress' | 'failed' | 'synced'
    .withDefault(const Constant('pending'))();
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get nextAttemptAt => dateTime().nullable()();
  TextColumn get lastError => text().nullable()();

  @override
  Set<Column> get primaryKey => {id};
}

ثلاث تفاصيل احتاجت تقارير bugs لتعلّمها:

idempotencyKey هو عقد الخادم، لا عقدك#

كلّ طلب push يرسل header اسمه Idempotency-Key. الخادم يخزّنه مرتبطاً بـ id الصفّ الناتج. إن وصل نفس المفتاح مرّتين، يعيد الخادم الردّ الأصليّ بدون إعادة التنفيذ. هذه هي الطريقة الوحيدة للنجاة من هاتف يدفع، يضيع الردّ، فيعيد الهاتف المحاولة.

نولّد المفتاح على العميل لحظة إدراجه في الـ queue، لا لحظة الـ push. توليده لاحقاً يعني أنّ إعادة المحاولة قد تنتج hash مختلفاً، فيعامله الخادم كطلب جديد.

الـ status له أربع حالات، لا حالتان#

ستُغرى باستخدام pending و done. تحتاج in_progress لأنّ الهاتف قد ينهار في منتصف الـ push، و failed لأنّ بعض الأخطاء دائمة (أخطاء تحقّق، آباء محذوفون) ويجب ألّا يُعاد محاولتها إلى الأبد. بدون هاتين الحالتين، إمّا أن يعيد الـ queue محاولة الأخطاء الدائمة في حلقة، أو يُسقط عناصر صحيحة على خطأ مؤقّت.

nextAttemptAt يُضبَط، لا يُحسَب#

تعلّمنا هذا مرّتين. الفاصل بين المحاولات لا يمكن حسابه عند المسح ("إن كان attempts > 0 انتظر N ثانية")؛ يجب أن يُخزَّن لحظة حدوث الفشل، مع backoff عشوائيّ مدمج. وإلّا فإنّ كلّ جهاز على نفس الشبكة المعطوبة سيعيد المحاولة في نفس اللحظة، عند كلّ فاصل، حتّى ينجح أو يسقط الخادم. حدث هذا لنا صباح ثلاثاء. ليس ممتعاً.

حلقة الـ push#

حلقة الـ push تعمل كلّ بضع ثوانٍ بينما التطبيق في الواجهة:

Future<void> pushOnce() async {
  final batch = await db.queueRepo.claimBatch(
    max: 20,
    includeStatuses: const ['pending'],
    where: 'next_attempt_at IS NULL OR next_attempt_at <= ?',
  );
  if (batch.isEmpty) return;

  // علّم كلّ عنصر مُطالَب به in_progress في transaction واحدة كي لا
  // يَعلق بانهيار في حالة half-claimed.
  await db.queueRepo.markInProgress(batch.map((b) => b.id).toList());

  try {
    final response = await api.batchPush(batch);
    await db.queueRepo.applyPushResponse(batch, response);
  } catch (e) {
    // اضطراب شبكة. أعد الـ batch إلى pending مع backoff.
    await db.queueRepo.resetForRetry(batch.map((b) => b.id).toList());
  }
}

سطران من تلك الحلقة غير بديهيَّين:

أعد الضبط دائماً في الـ catch

أكثر bug شائع في الـ queues المحلّية الصنع هو نسيان إعادة ضبط الـ batch عند الخطأ. تبقى العناصر in_progress إلى الأبد، تتجاوزها الحلقة التالية لأنّها ليست pending، وتعلق بيانات المستخدم. نمط الـ finally-reset (أو وظيفة "حصّاد العناصر العالقة") يلتقط هذا.

الآخر هو استعلام الـ claimBatch: يجب أن يفلتر next_attempt_at داخل نفس الـ transaction التي تقلب الـ status، وإلّا فإنّ حلقتَي push متزامنتَين قد تطالبان بنفس العنصر مرّتين.

حلّ الـ FK قبل الـ push#

هذا هو السيناريو الذي يكسر المحرّكات الساذجة:

  1. ينشئ المستخدم جهة اتصال بدون اتّصال. نخصّص لها local_id = uuid-A.
  2. ينشئ المستخدم فاتورة لجهة الاتّصال هذه. نخزّن contact_id = uuid-A في حمولة الفاتورة.
  3. يحصل الهاتف على اتّصال. ندفع جهة الاتّصال، نحصل على server_id = 4729.
  4. ندفع الفاتورة. يقول الخادم "جهة الاتصال 4729 غير موجودة." لأنّنا أرسلنا uuid-A.

الإصلاح هو إعادة كتابة الـ FK لحظة الـ push، بعد أن يُدفع الأب ونعرف server id الخاصّ به. امشِ في الحمولة، اعثر على كلّ حقل ينتهي بـ _id، ابحث عن خريطة local-to-server لذلك الكيان، واستبدله. لدينا قائمة بيضاء صغيرة من حقول FK لكلّ كيان لتجنّب لمس أيّ شيء آخر.

function resolvePayload(payload: any, entityType: string): any {
  const fkFields = FK_MAP[entityType] ?? [];
  const out = { ...payload };
  for (const field of fkFields) {
    const localId = out[field];
    if (typeof localId === 'string' && localId.startsWith('local-')) {
      const serverId = serverIdMap.get(localId);
      if (serverId == null) {
        throw new ParentNotSyncedError(field, localId);
      }
      out[field] = serverId;
    }
  }
  return out;
}

إن لم يُزامَن الأب بعد (يفشل البحث)، نرمي ونتخطّى الصفّ هذه الجولة. الجولة التالية، سيكون للأب server id، ويستطيع الابن المرور.

ما التالي#

هذا الجزء من المحرّك بسيط بنيويّاً. الجزء التالي، الترتيب، هو حيث تبدأ التجريدات في كسب قيمتها: حين يختلف عميلان حول نفس الصفّ، من يفوز، وكيف تجعل الإجابة صحيحة ومفهومة للمستخدم في آن واحد. هذا هو الجزء الثاني.

صفحة السلسلة تتابع ما شحنّاه وما لا يزال يُكتب.