webhook-gateway — دریافت و تحویل بادوامِ وبهوک

دروازه‌ای که وبهوک‌های ورودی را امضاسنجی، حذف‌تکراری و ذخیره می‌کند و با تلاش مجدد، مدارشکن و صف مرده تحویلشان می‌دهد — با پستگرس به‌عنوان صف.

نتیجه

۱۵۴ تست، همه روی یک پستگرس واقعی — و یک بنچمارک که نامطلوب‌ترین سطرش همان سطری است که توضیح داده شده.

مسئله

وبهوکِ یک سرویس بیرونی فقط یک بار می‌آید. اگر بین سوکت و دیتابیس گم شود، هیچ‌کس نمی‌تواند از Stripe یا GitHub بخواهد دوباره بفرستدش — و خرابی هم بی‌صداست، چون فرستنده 200 خودش را گرفته است. آن‌طرف ماجرا هم مشترکی ایستاده که دست تو نیست: تایم‌اوت می‌دهد، 500 برمی‌گرداند، یک ساعت پایین می‌رود، یا 400 می‌دهد چون قرارداد عوض شده. هر کدامِ اینها جواب متفاوتی می‌خواهد، و «همه را با backoff نمایی دوباره بفرست» برای بیشترشان جواب غلطی است.

تضمین صادقانه نوشته شده، چون فقط نسخه‌ی صادقانه به درد می‌خورد: دقیقاً یک بار روی مرز ماندگاری، دست‌کم یک بار به مشترک. نیمه‌ی اول را یک unique constraint تحمیل می‌کند و یک تست هم‌روندی اثباتش می‌کند. نیمه‌ی دوم را هیچ‌کس نمی‌تواند تضمین کند — آخرین قدم یک درخواست HTTP به ماشین دیگری است، و کارگری که بین 200 آن‌ها و commit خودمان کشته شود باید دوباره تلاش کند، نه حدس بزند. پس هر درخواست خروجی یک Idempotency-Key پایدار و شماره‌ی تلاش را با خودش می‌برد؛ دقیقاً همان چیزی که مشترک لازم دارد تا شکاف را سمت خودش ببندد.

پستگرس خودِ صف است، تا صف و داده‌ای که به آن اشاره می‌کند در یک تراکنش باشند. فن‌اوت نمی‌تواند رویدادی را commit کند که کارِ مربوط به آن گم شده، یک claim از تلاشِ rollback‌شده جان به در نمی‌برد، و هیچ job تطبیقی بین دو انبارِ ناموافق لازم نیست. یک broker فن‌اوت چندزبانه می‌داد و دقیقاً همان خاصیتی را می‌گرفت که این سرویس برای آن ساخته شده. FOR UPDATE SKIP LOCKED اجازه می‌دهد N کارگر دسته‌های مجزا بردارند بدون هیچ هماهنگ‌کننده‌ای، و lease یعنی سطرهای کارگرِ مرده برمی‌گردند به‌جای اینکه گیر کنند.

هر حالت خرابی جواب سنجیده‌ی خودش را دارد، نه یک retry عمومی. تکراری یعنی 200 با duplicate: true، نه 409 که فرستنده را وادار به تلاش شدیدتر کند و کسی را نصف شب بیدار کند. 400 از سمت مشترک بلافاصله dead-letter می‌شود، چون هشت بار ردِ یکسان به هیچ‌کس چیزی یاد نمی‌دهد. 429 با Retry-After رعایت می‌شود، چون مشترکی که می‌گوید کِی برگرد، بهتر از منحنی ما می‌داند. backoff از نوع full jitter است نه «نمایی به‌علاوه‌ی نویز» — که کل گله را روی یک لحظه هم‌زمان می‌کند و همان سروری را که تازه بلند شده دوباره می‌خواباند. مدار بعد از N خطا باز می‌شود و تحویل‌ها را بدون خرج‌کردن یک تلاش جابه‌جا می‌کند، تا یک قطعی سی‌ثانیه‌ای بودجه‌ی هشت‌تایی را روی درخواست‌هایی که اصلاً فرستاده نشدند تمام نکند؛ و در حالت نیمه‌باز فقط یک درخواست آزمایشی رد می‌شود، وگرنه کل صفِ عقب‌افتاده همان لحظه‌ی برگشتن به سرور هجوم می‌برد.

تحویل خروجی جایی است که یک دروازه ذاتاً «معاونِ گیج» است: هر آدرسِ resolve‌شده بررسی می‌شود و بعد اتصال به همان آدرسی که قبول شد سنجاق می‌شود، با حفظ Host و SNI — چون بررسی یک پاسخ DNS و بعد اجازه‌دادن به کلاینت که دوباره resolve کند یک پنجره‌ی TOCTOU است، و 169.254.169.254 کلید ابر را دستی تحویل می‌دهد.

تست محوری ۲۴ وبهوکِ واقعاً هم‌زمان و یکسان شلیک می‌کند — نشست جدا، اتصال جدا، بک‌اند جدا، همه روی یک constraint در مسابقه — و می‌سنجد که یک رویداد، یک برنده و یک تحویل بماند. همه‌ی تست‌ها روی یک پستگرسِ واقعی اجرا می‌شوند، چون تضمین‌ها مالِ پستگرس‌اند و سوییتی که روی SQLite سبز شود دارد یک افسانه را تست می‌کند. ابزار بارگذاری دو باگ واقعی پیدا کرد، یکی‌شان مسیر fail-open که به‌ازای هر درخواست تایم‌اوتِ اتصال به Redis را می‌پرداخت؛ جدول بنچمارک هم مشخصات ماشین و سطرهای نامطلوب را کنار بقیه گزارش می‌کند.