بناء محرّك مزامنة، الجزء الأول: الـ queue
افتتاح السلسلة. شكل الـ queue الصادر، ليش الترتيب مهم، والـ test harness اللي بيمسك كل شي.
جزء منsync-engineجاري تحميل المقال…
افتتاح السلسلة. شكل الـ queue الصادر، ليش الترتيب مهم، والـ test harness اللي بيمسك كل شي.
جزء منsync-engineجاري تحميل المقال…
محرّك المزامنة هو queue صغير، نقطتا API، والكثير من الحالات الحدّية. الـ queue هو حيث يفشل معظم المحرّكات في شهرها الأوّل، لأنّ التطبيق البديهيّ يعمل بشكل ممتاز لأوّل مئة مستخدم ثمّ يفقد البيانات بصمت. هذا هو الجزء الأوّل من خمسة عن المحرّك الذي يُبقي عميل Flutter وخلفيّة Laravel متّسقَين. نبدأ بالـ queue لأنّ كلّ قطعة أخرى من المحرّك تفترض أنّه يعمل.
كلّ كتابة قابلة للعمل بدون اتّصال (إنشاء فاتورة، تعديل جهة اتصال، حذف صفّ) تنزل في جدول محلّيّ على الجهاز أوّلاً، ثمّ تنضمّ إلى queue ينتظر الدفع إلى الخادم. الـ 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 تعمل كلّ بضع ثوانٍ بينما التطبيق في الواجهة:
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());
}
}سطران من تلك الحلقة غير بديهيَّين:
الآخر هو استعلام الـ claimBatch: يجب أن يفلتر next_attempt_at داخل
نفس الـ transaction التي تقلب الـ status، وإلّا فإنّ حلقتَي push
متزامنتَين قد تطالبان بنفس العنصر مرّتين.
هذا هو السيناريو الذي يكسر المحرّكات الساذجة:
local_id = uuid-A.contact_id = uuid-A في حمولة الفاتورة.server_id = 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، ويستطيع الابن المرور.
هذا الجزء من المحرّك بسيط بنيويّاً. الجزء التالي، الترتيب، هو حيث تبدأ التجريدات في كسب قيمتها: حين يختلف عميلان حول نفس الصفّ، من يفوز، وكيف تجعل الإجابة صحيحة ومفهومة للمستخدم في آن واحد. هذا هو الجزء الثاني.
صفحة السلسلة تتابع ما شحنّاه وما لا يزال يُكتب.