queue المزامنة الفعليّة ليست مكتبة وظائف تُثبّتها. هي بنية بيانات تمتلكها، بقرارات idempotency و retries و back-pressure تستطيع الدفاع عنها في مراجعة كود. خلال ساعة ستكتب واحدة في TypeScript، تشغّلها بحزام اختبار صغير، وتحصل على أساس تستطيع إسقاطه في أيّ عميل.
queue المزامنة، في جوهره، آلة حالة صغيرة على قائمة من الوظائف. كلّ وظيفة تتحرّك عبر pending ← in_progress ← done أو failed، ومهمّة الـ queue هي ضمان أنّ هذه الحركة آمنة عند إعادة المحاولة: إن سقطت الشبكة في منتصف flush، يجب ألّا تفقد عملاً، ويجب ألّا تكتب مرّتَين.
حقلان يكسبان مكانهما هنا: idempotencyKey و attempts. الأوّل عقد مع الخادم (نفس المفتاح عند إعادة المحاولة يجب ألّا ينتج كتابة ثانية). الثاني عقد مع نفسك (تحتاج شرط توقّف).
النسخة الأولى تخزّن الوظائف في Map<string, Job>. هذا يكفي لاختبار آلة الحالة وحلقة إعادة المحاولة دون إدخال التخزين في الصورة. التبنّي الفعليّ يستبدل هذا بـ SQLite، IndexedDB، أو ما توفّره المنصّة، لكن واجهة الـ queue لا يجب أن تهتمّ.
شيئان للملاحظة. أوّلاً، الـ queue نفسه لا يفعل I/O. حلقة الـ flush مسؤوليّة منفصلة. ثانياً، enqueue متزامن. enqueue غير متزامن فخّ: يعني أنّ المُتصِل لا يستطيع التفكير ذرّيّاً في "هل دخل هذا أم لا".
حلقة الـ flush تمشي على الوظائف pending، تعطي كلّ واحدة لدالّة pusher تعيد promise، وتُحدّث الحالة من النتيجة. العقد مع الـ pusher صغير: إمّا يحلّ، أو يرفض بخطأ.
الفرع في كتلة الـ catch هو المكان الوحيد الذي تعيش فيه سياسة الـ back-off. خمس محاولات ونُعلِّم الوظيفة failed ونمشي. التطبيق الحقيقيّ سيريد أيضاً تأخيراً أُسّيّاً بين المحاولات؛ أبسط نسخة هي setTimeout(flush, 2 ** attempts * 1000) من المُتصِل، ليس من داخل الـ queue.
دون اختبار، لا تستطيع إثبات أنّ هذا صحيح عند إعادة المحاولة. حزام الاختبار pusher مزيّف يحاكي إخفاقات الشبكة بالـ index. ضع الملفّ في src/queue.test.ts:
import { describe, it, expect, vi } from 'vitest';import { Queue } from './queue';describe('Queue', () => { it('retries failed jobs until they succeed', async () => { const q = new Queue<string>(); q.enqueue('A', 'key-A'); let failures = 2; const pusher = vi.fn(async () => { if (failures-- > 0) throw new Error('network'); }); await q.flush(pusher); await q.flush(pusher); await q.flush(pusher); expect(pusher).toHaveBeenCalledTimes(3); expect(q.pending()).toHaveLength(0); }); it('marks the job failed after 5 attempts', async () => { const q = new Queue<string>(); q.enqueue('B', 'key-B'); const pusher = vi.fn(async () => { throw new Error('always'); }); for (let i = 0; i < 5; i++) await q.flush(pusher); expect(pusher).toHaveBeenCalledTimes(5); expect(q.pending()).toHaveLength(0); });});
شغّله بـ pnpm vitest run. كلا الاختبارَين يجب أن ينجحا.
الآن، الـ idempotencyKey مخزَّن لكن غير مستخدَم. التغيير التالي يجعله يكسب مكانه: الـ pusher يستخدم المفتاح كـ HTTP header، والخادم يستخدمه لإلغاء تكرار الكتابات. أدناه النسخة الإنتاجيّة من pusher.
async function pushToServer(job: Job<{ url: string; body: unknown }>) { const res = await fetch(job.payload.url, { method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': job.idempotencyKey, }, body: JSON.stringify(job.payload.body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`);}
عقد الخادم هو: نفس المفتاح، نفس الـ payload، المرّة الثانية ← نفس الاستجابة، لا كتابة ثانية. هذا ما يجعل "أعِد المحاولة حتّى تعمل" آمناً. بدونه، كلّ إعادة محاولة رمي زهر على تكرار.
هذا الـ queue يفعل الأشياء الصحيحة لأحمال صغيرة. هو أيضاً يكذب حول ثلاثة أشياء ستهتمّ بها في النهاية:
الديمومة. أعد تحميل الصفحة والـ queue ذهب. التطبيقات الحقيقيّة تحتاج طبقة تخزين تنجو من إعادة التحميل.
التوازي. حلقة الـ flush تشغّل الوظائف بالتسلسل. التطبيقات الحقيقيّة تريد توازياً محدوداً، ليس لانهائيّاً، ليس واحداً-في-المرّة.
حلّ التعارض. حين يضع عميلان كتابات متناقضة في الـ queue، الخادم عليه أن يختار. الـ queue لا يستطيع.
الثلاثة بالضبط هي ما تغطّيه دورة محرّكات المزامنة من طرف لطرف الكاملة، بنفس أسلوب الكود ونفس شكل اتّخاذ القرار. هذا المختبر الفصل الأوّل؛ الدورة الباقي.
يجب أن يكون لديك الآن ملفّ queue، ملفّ اختبار، و pusher واعٍ بالـ idempotency. شغّل الاختبارات مرّة أخرى، ثمّ وجّه الـ queue إلى نقطة نهاية حقيقيّة من اختيارك. الساعة انتهت. الـ queue لك.