Cloudflare Workflows این امکان را می‌دهد که برنامه‌های چندمرحله‌ای و پایدار بسازید که retry داخلی و persistence برای فرآیندهای طولانی‌مدت دارند. وقتی یک Workflow اجرا می‌شود، هر مرحله می‌تواند با سیستم‌های خارجی تماس بگیرد، خطاها را retry کند و وضعیت را بین راه‌اندازی مجدد حفظ کند. اما اگر یک مرحله شکست بخورد، ممکن است کارهای قبلی در وضعیتی ناقص یا ناسازگار باقی بمانند — برای همین امروز قابلیت saga rollbacks برای Workflows معرفی شده است تا بتوانید منطق rollback را مستقیماً داخل هر قدم تعریف کنید. 🔁

مثال ساده: انتقال پول بین دو بانک که سه مرحله دارد:
Debit از حساب در Bank A
Credit به حساب در Bank B
ارسال ایمیل تأیید به هر دو صاحب حساب

اگر Step 2 (credit به Bank B) شکست بخورد چه باید کرد؟ وقتی debit در Bank A موفق بوده و تراکنش کامیت شده، شما نمی‌توانید صرفاً آن عملیات را undo کنید؛ باید عملیات جدیدی اجرا کنید که از نظر معنایی معکوس عملیات اول باشد — همین الگوی عملیات و منطق جبرانی، همان saga pattern است. قبل از این قابلیت، توسعه‌دهندگان باید منطق compensation را بیرون از تعریف مستقیم مراحل و به شکل دستی پیاده‌سازی می‌کردند؛ اما حالا می‌توانید منطق rollback را به‌عنوان آرگومان در step.do() تعریف کنید و دوام (durability) workflow را برای rollback هم حفظ کنید.

نمونه‌ای از کدی که قبلاً با try/catch و unwind دستی نوشته می‌شد:

 // track what completed so we know what to undo
 let debitA;
 let creditB;
 try {
   debitA = await step.do("debit-bank-a", () => bankA.debit(from, amount));
   creditB = await step.do("credit-bank-b", () => bankB.credit(to, amount));
   await step.do("notify", () => notifyBoth(from, to, amount));
 } catch (error) {
   // unwind in reverse. each undo is its own durable step,
   // must be idempotent, and must keep going if one fails.
   if (creditB) {
     try {
       await step.do("reverse-credit-b", () => bankB.debit(to, amount, creditB.id));
     } catch (e) {
       await alertOnCall("reverse-credit-b failed", e);
     }
   }
   if (debitA) {
     try {
       await step.do("refund-debit-a", () => bankA.credit(from, amount, debitA.id));
     } catch (e) {
       await alertOnCall("refund-debit-a failed", e);
     }
   }
   throw error;
 }

حالا با rollback در خود step، کد خواناتر و کم‌خطاتری خواهید داشت؛ هر مرحله می‌تواند rollback خودش را همراه داشته باشد—دیگر نیازی به بزرگ شدن بلوک catch یا نگهداری دستی ترتیب undoها نیست:

 // each step ships with its own undo. add a step,
 // add its rollback right here. no growing catch
 // block, no manual ordering, no replay logic.
 await step.do("debit-bank-a", () => bankA.debit(from, amount), {
   rollback: async ({ output }) => bankA.credit(from, amount, output.id),
 });
 await step.do("credit-bank-b", () => bankB.credit(to, amount), {
   rollback: async ({ output }) => bankB.debit(to, amount, output.id),
 });
 await step.do("notify", () => notifyBoth(from, to, amount));

مثال کامل‌تر با استفاده از idempotency keys که عملیاتی امن برای retry می‌سازد:

const debit = await step.do(
  "debit-account-a",
  async () => {
    return await bankA.debit({
      accountId: fromAccountId,
      amount,
      idempotencyKey: `${transferId}:debit-account-a`,
    });
  },
  {
    rollback: async () => {
      await bankA.credit({
        accountId: fromAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-debit-account-a`,
      });
    },
  }
);

// The idempotency keys make both the forward operations and rollback operations safe to retry without duplicating the transfer
const credit = await step.do(
  "credit-account-b",
  async () => {
    return await bankB.credit({
      accountId: toAccountId,
      amount,
      idempotencyKey: `${transferId}:credit-account-b`,
    });
  },
  {
    rollback: async ({ output }) => {
      if (output === undefined) {
        return;
      }
      await bankB.debit({
        accountId: toAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-credit-account-b`,
      });
    },
  }
);

// If we fail here, we may want to revert all previous payments. Users should not have to wrap their code in complex try-catch logic just to revert two small payments (see below)
await step.do("send-confirmation", async () => {
  await sendTransferConfirmation({ ... });
});

نکات عملیاتی و راهنمایی برای SRE/DevOps:

1) rollbackها باید idempotent باشند — درست مثل مراحل معمولی Workflow. برای بازگرداندن یک charge، از idempotency key ارائه‌دهنده پرداخت استفاده کنید. برای آزادسازی موجودی هم عملیات باید قابل اجرا چندباره باشد تا دوباره فراخوانی شدن موجب خطا نشود. ✅

2) مرحله‌ای که شکست خورده ممکن است هنوز نیاز به rollback داشته باشد؛ یک step.do() حتی اگر fail کند، باز هم می‌تواند rollback ثبت کند. دلیلش این است که ممکن است قبل از خطا، تعامل جزئی‌ای با سیستم خارجی صورت گرفته باشد (مثلاً پرداخت capture شده اما chargeId برگشت داده نشده). بنابراین rollback handlerها می‌توانند با خروجی undefined هم کار کنند.

3) rollback تنها وقتی شروع می‌شود که کل Workflow در آستانه failure نهایی باشد. افزودن یک rollback handler به این معنی نیست که هر خطای محلی بلافاصله به rollback منجر شود؛ اگر کد شما خطا را catch کند و Workflow ادامه یابد، rollback اجرا نخواهد شد مگر اینکه در نهایت Workflow شکست بخورد.

4) ترتیب اجرای rollback باید پیش‌بینی‌پذیر باشد. برای Workflows ترتیبی، ترتیب intuitively معکوس شروع مراحل است، اما برای گام‌های موازی، ترتیب تکمیل ممکن است با ترتیب شروع متفاوت باشد — بنابراین Workflows از reverse step-start order استفاده می‌کند (نه reverse completion order). قواعد عملی: هر started یا completed step که rollback handler ثبت کرده، واجد اجراست. همان‌طور که گفته شد، handlerها بر اساس معکوس ترتیب start اجرا می‌شوند.

چند توضیح درباره طراحی API و انتخاب شکل نهایی:

ابتدا Fluent API مثل step.do(…).rollback(…) بررسی شد چون خوانایی خوبی دارد، اما مشکل این است که step.do() هم‌اکنون معنی مهمی دارد: شروع یک durable step و بازگرداندن یک Promise برای خروجی. در محیط Workers، مقادیر شبیه Promise اهمیت بیشتری دارند چون promise pipelining پشتیبانی می‌شود و اگر rollback به‌صورت فلاونت پس از بازگشت Promise اضافه شود، زمان‌بندی شروع مرحله مبهم می‌شد و ممکن بود step زمانی شروع شود که مصرف‌کننده Promise بعداً .rollback را متصل کند — این رفتار غیرقابل‌پیش‌بینی می‌کرد.

نمونه‌هایی از کدهایی که رفتار promise pipelining را نشان می‌دهند:

const session = api.authenticate(apiKey);
const name = await session.whoami();

همچنین مدل builder (.saga().do().rollback().run()) گزینه دیگری بود که ترتیب و نگهداری گزینه‌های آینده را ساده می‌کرد، اما اضافه کردن مرحله .run() یا شکل جدیدی از API باعث تشویش و ceremony اضافی می‌شد و step.do() را به‌عنوان primitive اصلی تضعیف می‌کرد.

مثال‌های کد درباره نگرانی‌های طراحی API:

 // Fluent ambiguity example:
 step.do("charge-card", chargeCard).rollback(refundCharge);

 // Timing ambiguity example:
 const first = step.do("first", () => serviceA.call());
 await step.do("second", () => serviceB.call());
 await first;

 // Builder example:
 const charge = await step
   .saga("charge")
   .do(() => chargeCard())
   .rollback(() => refundCharge())
   .run();

در نتیجه، انتخاب نهایی این بود که rollback به‌عنوان metadata به step.do اضافه شود: step.do(…, { rollback }). این روش ساده، صریح و سازگار با مدل زمانی فعلی است: rollback قبل از شروع step ثبت می‌شود، Workflows می‌تواند آن را persist کند و در صورت نیاز به‌صورت قابل پیش‌بینی اجرا نماید. هر handler نیز به خطا، context مرحله و output (که ممکن است undefined باشد) دسترسی دارد.

در پایان: این ویژگی باعث می‌شود که پیاده‌سازی sagas در Workflows خیلی مرتب‌تر، durable و قابل‌مشاهده شود — rollbackها lifecycle events منتشر می‌کنند و منطق جبرانی نزدیک به خود قدم تعریف می‌شود، در حالی که همچنان نیازهای SRE مانند idempotency، ordering و رفتار در مواجهه با مراحل موازی را رعایت می‌کند. اگر در طراحی sagaهای خود نیاز به کمک عملیاتی دارید، خوشحال می‌شوم چند نمونه واقعی از سیستم شما ببینم و نکات دقیق‌تری پیشنهاد بدهم. 😊