🧱 1️⃣ The Idempotency Key
if (await dbContext.MessageConsumers.AnyAsync(c =>
c.MessageId == context.MessageId &&
c.ConsumerName == nameof(NoteCreatedConsumer)))
{
return;
}
در اینجا از موارد زیر استفاده میکنیم:
MessageId
که از transport (یعنی context.MessageId) گرفته میشود
ConsumerName
تا در صورتی که چند consumer متفاوت یک پیام را پردازش کنند، هرکدام بهصورت ایمن عمل کنند ✅
اگر یک پیام تکراری دریافت شود، پردازش کوتاه میشود و هیچ کاری انجام نمیگیرد. 🚫
نکتهی بسیار مهم این است که باید روی ستونهای (MessageId, ConsumerName) در جدول MessageConsumers یک unique constraint تعریف شود تا از race condition جلوگیری شود ⚙️
به این ترتیب حتی اگر چند پردازش همزمان از یک پیام وجود داشته باشد، فقط یکی از آنها موفق به درج رکورد خواهد شد. 💪
⚡️ 2️⃣ Atomic Side Effects + Idempotency Record
در این الگو، هم پردازش (processing) و هم ذخیرهی رکورد MessageConsumer در یک تراکنش (transaction) انجام میشود:
using var transaction = await dbContext.Database.BeginTransactionAsync();
// write tags
dbContext.Tags.AddRange(tagEntities);
// write message-consumer record
dbContext.MessageConsumers.Add(new MessageConsumer { ... });
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
چرا این مهم است؟ 🤔
اگر پردازش شکست بخورد ❌، هیچ ورودیای در MessageConsumers ثبت نمیشود، بنابراین پیام میتواند مجدداً retry شود.
اگر پردازش موفق باشد ✅، هم دادهها (مثل tags) و هم رکورد مربوط به پیام باهم commit میشوند.
در نتیجه، هیچوقت در وضعیتی قرار نمیگیرید که کار انجام شده باشد اما پیام بهعنوان پردازششده علامتگذاری نشده باشد، یا برعکس.
این اساس idempotency است:
انجام دقیق یکبار عملیات برای هر Message ID — حتی در شرایط retry. 🔁
📬 3️⃣ Handling At-Least-Once Delivery
در بیشتر سناریوهای واقعی، تحویل پیامها از نوع at-least-once است:
1️⃣ Consumer پیام را پردازش میکند
2️⃣ ACK شکست میخورد یا timeout میشود
3️⃣ Broker پیام را مجدداً تحویل میدهد
4️⃣ کد شما دوباره اجرا میشود
اما در این الگو، اجرای دوم با بررسی جدول MessageConsumers مواجه شده و خیلی سریع return میکند. ✅
نتیجه:
هیچ side effect تکراریای اتفاق نمیافتد. 🙌
البته فقط یک استثنا وجود دارد...
🔄 Deterministic vs Non-Deterministic Handlers
وقتی handler شما با سیستمهایی خارج از دیتابیس تماس میگیرد چه میشود؟
مثل:
• یک Email API ✉️
• Payment Gateway 💳
• یا یک Background Job Queue 🧵
اینها همگی side effectهای رایج هستند که باید آنها نیز idempotent باشند.
چون این تماسها خارج از محدودهی تراکنش دیتابیس انجام میشوند، ممکن است دیتابیس commit شود اما بهدلیل اختلال شبکه پاسخ از سرویس بیرونی برنگردد.
در retry بعدی، ممکن است همان ایمیل دوباره ارسال شود یا همان کارت اعتباری دوباره شارژ شود ⚠️
به این ترتیب، وارد قلمروی Non-Deterministic Handlerها میشویم عملیاتی که تکرار آنها ایمن نیست.
دو استراتژی اصلی برای مدیریت این وضعیت وجود دارد:
🧩 1. استفاده از Idempotency Key در فراخوانی خارجی
اگر سرویس خارجی از Idempotency Key پشتیبانی کند، یک شناسهی پایدار مثلاً همان MessageId پیام را در هر درخواست ارسال کنید.
بسیاری از APIها (مثل پردازشگرهای پرداخت یا پلتفرمهای ارسال ایمیل) اجازه میدهند که یک Idempotency-Key Header مشخص کنید.
در این صورت سرویس تضمین میکند که درخواستهای تکراری با کلید یکسان فقط یکبار اجرا شوند. ✅
بهعنوان مثال:
await emailService.SendAsync(new SendEmailRequest
{
To = user.Email,
Subject = "Welcome!",
Body = "Thanks for signing up.",
IdempotencyKey = context.MessageId
});
حتی اگر درخواست مجدداً ارسال شود، provider کلید را تشخیص میدهد و درخواست تکراری را نادیده میگیرد.
این سادهترین و مطمئنترین روش است، اگر وابستگی خارجی شما از آن پشتیبانی کند. 🚀
💾 2. ذخیرهی Intent بهصورت محلی
اگر سرویس خارجی از idempotency key پشتیبانی نکند، میتوانید آن را شبیهسازی کنید.
کافی است پیش از تماس با سرویس بیرونی، رکوردی از اقدام مورد نظر را در دیتابیس ذخیره کنید.
مثلاً جدولی به نام PendingEmails بسازید که نشان دهد کدام پیام باید ارسال شود بر اساس MessageId یا UserId.
سپس یک background process این رکوردها را خوانده و عملیات را تنها یکبار انجام میدهد.
این رویکرد deterministic است ولی پیچیدگی بیشتری دارد (جداول بیشتر و workerهای پسزمینه).