Temporal.io 2026 - Durable Execution cho .NET 10: Khi workflow bất tử trước crash, timeout và deploy giữa chừng

Posted on: 4/16/2026 5:03:41 PM

1. Bài toán của workflow dài hơi trong microservices

Một request đặt khách sạn nghe đơn giản: trừ tiền ở payment service, giữ phòng ở inventory, ghi nhận booking, gửi email xác nhận. Nhưng bốn bước đó trải trên bốn service khác nhau, mỗi bước có thể lỗi mạng, timeout, service redeploy giữa chừng, hay đơn giản là database kia đang lock. Nếu bạn từng viết code xử lý trường hợp "payment đã trừ nhưng inventory chết ngay sau đó", bạn biết tại sao mỗi công ty lại có một đống script retry/refund chạy hàng đêm.

Các giải pháp cổ điển đều để lại sẹo: transactional outbox + saga orchestrator thì phải tự quản state machine, message broker, lịch retry; pipeline step function của cloud provider thì lock-in và viết bằng JSON/YAML khó test; workflow nội bộ bằng Hangfire / Quartz thì thiếu determinism khi worker chạy lại. Từ giữa 2024, hệ sinh thái "durable execution" — dẫn đầu bởi Temporal.io — nổi lên như một lớp abstract riêng, và 2026 là năm pattern này chạm ngưỡng trưởng thành trong stack .NET 10.

99.99%Tỷ lệ workflow hoàn tất đúng mà không cần intervention tay
~0 LOCRetry / timeout logic phải viết tay
Năm/thángĐộ dài workflow khả thi mà không sợ crash server
10xTốc độ giao hàng feature so với saga orchestrator tự viết

Durable execution trong một câu

Durable execution là mô hình lập trình mà mọi bước của một hàm dài hơi được ghi lại vào một event history bền vững, cho phép hàm đó "bất tử" trước crash: khi worker chết, một worker khác replay lại history và tiếp tục chính xác từ bước đang dang dở, không cần bạn viết bất kỳ dòng retry hay checkpoint nào.

2. Kiến trúc Temporal: Workflow, Activity, Task Queue

Để hiểu vì sao Temporal khác với một job scheduler thông thường, phải thấy rõ ba loại đối tượng cốt lõi và cách chúng phân chia trách nhiệm. Đây cũng là tư duy nền cho mọi durable execution engine khác (Restate, DBOS, Azure Durable Functions) chứ không chỉ Temporal.

flowchart LR
    C(["Client / API"]) -->|StartWorkflow| S["Temporal Service
(History + Matching)"] S -. "dispatch task" .-> WQ["Workflow Task Queue"] S -. "dispatch task" .-> AQ["Activity Task Queue"] WQ --> WW["Workflow Worker
(pure function)"] AQ --> AW["Activity Worker
(side effects)"] WW -->|"schedule activity"| S AW -->|"complete"| S S -->|"persist events"| DB[("Event History
Postgres / Cassandra")]

Hình 1: Cluster Temporal tách workflow (logic) khỏi activity (side effect) qua task queue

  • Workflow là một hàm thuần, không được làm bất kỳ I/O nào: không gọi HTTP, không đọc DB, không DateTime.Now, không random. Nó chỉ ra lệnh "hãy chạy activity X với tham số Y, đợi kết quả".
  • Activity là nơi làm side-effect thật: gọi payment gateway, trừ tiền, gửi email. Temporal lo retry, timeout, heartbeat.
  • Task queue là kênh mà worker poll từ cluster để nhận việc. Một worker chỉ poll queue mà nó đăng ký.
  • Cluster Temporal giữ event history cho từng workflow — danh sách chronological của mọi sự kiện (WorkflowStarted, ActivityScheduled, ActivityCompleted, TimerFired, SignalReceived...). Đây là nguồn sự thật duy nhất.

2.1. Vì sao workflow phải là hàm thuần

Khi một workflow worker crash giữa chừng, một worker khác lấy workflow đó lên và replay: chạy lại hàm workflow từ đầu, mỗi lần hàm gọi activity thì Temporal so khớp với history; nếu activity đó đã hoàn tất trong history thì Temporal trả về ngay kết quả cũ, không chạy lại. Hàm tiếp tục cho tới điểm chưa có trong history — đó là điểm dừng hiện tại. Nhờ cơ chế replay này, code bạn viết như synchronous await thông thường, nhưng hàm có thể "ngủ" vài tháng và thức dậy đúng state.

Để replay deterministic, workflow không được phép có I/O hay thời gian thật. Mọi thao tác có state bên ngoài đều phải đi qua activity. Đây là luật đau đớn ban đầu nhưng chính là thứ đổi lại khả năng "bất tử".

3. Temporal .NET SDK trong hệ sinh thái .NET 10

Temporal .NET SDK đã GA từ giữa 2024 và version 2026 tương thích hoàn chỉnh với .NET 10, hỗ trợ Native AOT một phần, source generator cho attribute, và tích hợp sâu với IHostedService của ASP.NET Core 10. Cách nhanh nhất để bootstrap một project minh hoạ workflow đặt khách sạn:

// Program.cs (ASP.NET Core 10 Minimal API + Temporal)
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddTemporalClient(opts =>
    {
        opts.TargetHost = "temporal:7233";
        opts.Namespace  = "booking";
    })
    .AddHostedTemporalWorker(taskQueue: "booking-tq")
    .AddWorkflow<BookingWorkflow>()
    .AddScopedActivities<PaymentActivities>()
    .AddScopedActivities<InventoryActivities>()
    .AddScopedActivities<NotificationActivities>();

var app = builder.Build();

app.MapPost("/bookings", async (
    BookingRequest req,
    ITemporalClient client) =>
{
    var handle = await client.StartWorkflowAsync(
        (BookingWorkflow wf) => wf.RunAsync(req),
        new(id: $"booking-{req.CorrelationId}", taskQueue: "booking-tq"));
    return Results.Accepted($"/bookings/{handle.Id}", new { handle.Id });
});

app.Run();

Workflow thật sự đơn giản hơn bạn tưởng, vì retry / timeout / rollback đều khai báo:

[Workflow]
public class BookingWorkflow
{
    private static readonly ActivityOptions Fast = new()
    {
        StartToCloseTimeout = TimeSpan.FromSeconds(30),
        RetryPolicy = new() { MaximumAttempts = 5 }
    };

    [WorkflowRun]
    public async Task<BookingResult> RunAsync(BookingRequest req)
    {
        var hold = await Workflow.ExecuteActivityAsync(
            (InventoryActivities a) => a.HoldRoomAsync(req.RoomId, req.Nights),
            Fast);

        try
        {
            var charge = await Workflow.ExecuteActivityAsync(
                (PaymentActivities a) => a.ChargeAsync(req.CardToken, req.Amount),
                Fast);

            await Workflow.ExecuteActivityAsync(
                (InventoryActivities a) => a.ConfirmHoldAsync(hold.HoldId),
                Fast);

            await Workflow.ExecuteActivityAsync(
                (NotificationActivities a) => a.SendEmailAsync(req.Email, charge.Receipt),
                Fast);

            return new BookingResult(req.CorrelationId, charge.TransactionId, "CONFIRMED");
        }
        catch (ActivityFailureException)
        {
            // compensation chạy như code thường, vẫn durable
            await Workflow.ExecuteActivityAsync(
                (InventoryActivities a) => a.ReleaseHoldAsync(hold.HoldId), Fast);
            throw;
        }
    }
}

Toàn bộ workflow là một hàm async Task đọc tuyến tính. Không if (retry < 5), không "saga coordinator", không persist state trung gian vào Redis hay DB của bạn — Temporal lo hết.

4. Replay determinism và luật ngầm khi viết workflow

Đa số bug khó của Temporal đến từ vi phạm determinism: workflow replay ra kết quả khác lần đầu. Một ví dụ hay gặp với team .NET:

// BAD - vi pham determinism
var now = DateTime.UtcNow;
if (req.CreatedAt < now.AddDays(-30))
    throw new WorkflowException("Expired");

// GOOD - dung Workflow.UtcNow (gia tri da ghi vao history)
var now = Workflow.UtcNow;

// BAD - random tao boi System.Random
var discountCode = Guid.NewGuid().ToString();

// GOOD
var discountCode = Workflow.NewGuid().ToString();

// BAD - goi HTTP truc tiep trong workflow
var price = await httpClient.GetAsync("...");

// GOOD - wrap vao activity
var price = await Workflow.ExecuteActivityAsync(
    (PricingActivities a) => a.GetPriceAsync(itemId), Fast);

Versioning workflow đang chạy

Workflow có thể sống hàng tháng. Nếu bạn deploy code mới thay đổi logic, một workflow đang replay bằng history cũ sẽ "ngỡ ngàng" vì branch mới chưa có trong history. Temporal cung cấp Workflow.Patched("v2-add-tax") để chia nhánh: replay cũ đi nhánh không có tax, workflow mới đi nhánh có tax. Hãy coi đây là một dạng feature flag bắt buộc cho mỗi breaking change của business logic.

5. Retry, timeout và heartbeat — ba tầng bảo vệ

Temporal tách biệt rõ bốn loại timeout cho activity, và đây là phần nhiều team mới tiếp cận cài đặt sai:

Loại timeoutÝ nghĩaĐặt khi nào
ScheduleToStartTối đa task chờ trong queue trước khi worker pickMuốn phát hiện thiếu worker nhanh (vd: 30s)
StartToCloseTối đa từ lúc worker bắt đầu tới khi hoàn tấtLuôn đặt — bảo vệ activity "treo"
ScheduleToCloseTối đa từ lúc schedule tới kết thúc (kể cả retry)Khi có SLA tổng cho một bước
HeartbeatTối đa giữa hai lần activity heartbeatBật cho activity chạy lâu (>1 phút)

Với activity chạy dài — ví dụ xuất báo cáo PDF 10 triệu dòng — bật heartbeat giúp Temporal phát hiện worker chết sau vài giây thay vì đợi hết StartToClose, và kèm đó bạn có thể resume từ progress đã heartbeat qua ActivityExecutionContext.Current.Info.HeartbeatDetails:

public async Task ExportReportAsync(Guid jobId)
{
    var ctx = ActivityExecutionContext.Current;
    var start = ctx.Info.HeartbeatDetails.Count > 0
        ? ctx.Info.HeartbeatDetails<int>(0)
        : 0;

    for (var i = start; i < totalRows; i += 10_000)
    {
        await WriteChunkAsync(jobId, i, 10_000);
        ctx.Heartbeat(i + 10_000);         // checkpoint progress
    }
}

6. Signals, Queries, Updates — giao tiếp hai chiều với workflow đang chạy

Workflow dài hơi không phải lúc nào cũng chạy tự do: thỉnh thoảng user huỷ đơn, admin đổi địa chỉ giao, hay ứng dụng cần "hỏi thăm" tiến độ. Temporal cung cấp ba cơ chế tương ứng:

  • Signal: gửi message một chiều tới workflow (fire-and-forget). Dùng cho "user huỷ", "admin approve".
  • Query: đọc state hiện tại của workflow không mutate. Dùng cho "workflow đang ở bước nào".
  • Update (ổn định từ 2024): kết hợp hai cái trên — gửi dữ liệu và nhận kết quả đồng bộ, có validation phía workflow.
[Workflow]
public class OrderWorkflow
{
    private bool _cancelled;
    private string _status = "PENDING";

    [WorkflowRun]
    public async Task RunAsync(Order o)
    {
        var chargeTask = Workflow.ExecuteActivityAsync(
            (PaymentActivities a) => a.ChargeAsync(o), Fast);
        var cancelTask = Workflow.WaitConditionAsync(() => _cancelled);

        var finished = await Workflow.WhenAnyAsync(chargeTask, cancelTask);
        if (finished == cancelTask) { _status = "CANCELLED"; return; }
        _status = "PAID";
    }

    [WorkflowSignal]
    public Task CancelAsync() { _cancelled = true; return Task.CompletedTask; }

    [WorkflowQuery]
    public string GetStatus() => _status;
}

Client-side đơn giản:

await handle.SignalAsync(wf => wf.CancelAsync());
var status = await handle.QueryAsync(wf => wf.GetStatus());

7. Saga orchestrator tự viết so với Temporal

Đa số team .NET từng xây saga bằng MassTransit, NServiceBus hoặc tự code trên RabbitMQ + outbox. Cách đó không sai, nhưng chi phí ẩn rất lớn. Bảng sau tổng hợp từ những migration thực tế sang Temporal của các đội vừa và lớn trong 12 tháng qua:

Khía cạnhSaga tự viết (MassTransit / outbox)Temporal
State machineMỗi saga là một class state, tự viết transitionCode async tuyến tính như hàm thường
RetryViết tay policy, idempotency key, circuit breakerKhai báo RetryPolicy, engine lo hết
TimeoutCron reaper quét DB tìm saga quá hạnBuilt-in 4 loại timeout, chính xác tới giây
CompensationViết tay mỗi handlerLà code catch thông thường
VersioningKhó — saga đang chạy không nâng cấp an toànWorkflow.Patched cho code mới/cũ cùng tồn tại
ObservabilityLog rải rác, trace thủ côngTemporal Web UI hiển thị history đầy đủ, replay debug
Time-drivenHangfire + cron, nguy cơ double fireWorkflow.DelayAsync(30.Days()) nguyên thuỷ

8. Luồng thực tế: Order fulfillment pattern

Phần này mô hình hoá một luồng đặt hàng + giao vận có đủ side-effect phức tạp. Mermaid sequence thể hiện độ bền ngay cả khi worker crash giữa chừng:

sequenceDiagram
    participant API as API
    participant T as Temporal
    participant WF as OrderWorkflow
    participant PAY as PaymentActivity
    participant STK as StockActivity
    participant SHP as ShippingActivity
    API->>T: StartWorkflow(order)
    T->>WF: dispatch workflow task
    WF->>T: schedule ChargeCard
    T->>PAY: execute
    PAY-->>T: success
    WF->>T: schedule ReserveStock
    T->>STK: execute
    Note over STK: worker crash here
    T->>STK: retry on new worker
    STK-->>T: success
    WF->>T: start Timer 24h for carrier pickup
    Note over T: timer persists regardless of worker
    T->>WF: timer fired
    WF->>T: schedule CreateShipment
    T->>SHP: execute
    SHP-->>T: tracking number
    WF-->>T: workflow complete
    T-->>API: result via query

Hình 2: Workflow tiếp tục chính xác sau khi worker crash, timer 24h không cần DB riêng

Timer dài 24h, 7 ngày, thậm chí 3 năm đều có thể viết trực tiếp await Workflow.DelayAsync(TimeSpan.FromDays(3*365)). Temporal bền vững hoá timer ở cluster side — bạn có thể tắt toàn bộ worker một tuần, bật lại, workflow tiếp tục đúng chỗ.

9. Child workflow, continue-as-new, fan-out

Khi luồng business phức tạp, một workflow có thể launch child workflow:

var child = await Workflow.StartChildWorkflowAsync(
    (InvoiceWorkflow w) => w.RunAsync(order.Id),
    new ChildWorkflowOptions { Id = $"invoice-{order.Id}" });

await child.GetResultAsync();

Cho fan-out 10k item song song — ví dụ gửi email cho 10k khách — dùng pattern map + WhenAll trong workflow, nhưng cần chú ý event history: mỗi activity là một event, 20k event trở lên sẽ đội kích thước history. Giải pháp kinh điển:

  • Batch activity: gói N item vào một activity, worker activity chính là kẻ song song hoá.
  • Child workflow: fan-out ra child, mỗi child có history riêng, parent chỉ nắm handle.
  • Continue-as-new: khi history workflow tới >50k event, gọi Workflow.ContinueAsNewAsync(newInput) để bắt đầu một workflow execution mới với cùng ID gốc, nhưng history đặt lại, state pass qua input.

Giữ history gọn là luật tồn tại

Mỗi workflow execution bị giới hạn mặc định 51.200 event (có thể tăng) và 50MB total payload. Nếu bạn fan-out 100k activity trong một workflow, bạn đang đi vào vùng nguy hiểm. Quy tắc đơn giản: luồng dài hạn (campaign, subscription, long-running cron) hãy "continue-as-new" theo chu kỳ — thường là mỗi tuần hoặc mỗi 1.000 iteration.

10. Observability: trace, metric và Web UI

Điểm cộng lớn của Temporal là mỗi workflow execution có event history đầy đủ, có thể mở trong Temporal Web UI và xem timeline tất cả event: schedule activity, complete, retry attempts, signal nhận được, timer fire... Bạn không còn phải ghép nối log từ nhiều service để debug tại sao booking bị treo ở bước 3. Kèm theo đó là tích hợp OpenTelemetry — mỗi activity và workflow đều có span tự động, trace đi xuyên service bạn đã quen.

builder.Services.AddOpenTelemetry()
    .WithTracing(b => b
        .AddSource("Temporalio.Workflow")
        .AddSource("Temporalio.Activity")
        .AddOtlpExporter());

Kết quả trên Jaeger/Tempo: một booking workflow xuất hiện như trace gốc, các activity con là span, lồng trong trace là call DB/HTTP do bạn tự instrument. Ít có abstraction nào cho visibility đẹp như vậy mà không phải viết tay pipeline log.

11. Sizing cluster Temporal cho tải thực tế

Temporal tách thành bốn service nội bộ (frontend, history, matching, worker) chạy trên cùng binary. Backend metadata có thể là Postgres 16/17, Cassandra hoặc MySQL. Một số mốc tham chiếu 2026 từ cluster production cỡ vừa:

TảiCluster TemporalDB backend
100 workflow/s, 5k activity/s3 node 4 vCPU 8GBPostgres 1 primary 4 vCPU
1.000 wf/s, 50k act/s5 node 8 vCPU 16GBPostgres primary+replica 16 vCPU
10.000 wf/s, 500k act/sCluster Temporal Cloud hoặc Cassandra 6 nodeCassandra 6 node NVMe

Tiết kiệm chi phí đáng kể nếu bạn tách task queue theo domain: booking-tq, payment-tq, notification-tq; mỗi worker pool scale độc lập theo workload. Một activity pool làm email có thể scale 50 replica trong giờ cao điểm, còn workflow pool chỉ cần 3 vì logic không chiếm CPU.

12. Alternatives và khi nào KHÔNG nên dùng Temporal

Temporal không phải viên đạn bạc. Vài lựa chọn thay thế đáng cân nhắc:

  • Restate: engine mới, event log-based, nhẹ hơn, tích hợp TypeScript/Java/.NET. Phù hợp workflow ngắn, cần latency thấp hơn Temporal (~ms).
  • DBOS: "database-native" — persist state trong chính Postgres của bạn, không cần cluster thứ hai. Phù hợp team nhỏ đã có Postgres OLTP.
  • Azure Durable Functions: tốt nếu stack đã all-in Azure và workflow không quá phức tạp.
  • AWS Step Functions: định nghĩa state machine bằng JSON, mạnh về serverless nhưng test khó, vendor lock.

Trường hợp không nên durable execution:

  1. Request-response <100ms: chi phí hop tới Temporal service cao hơn lợi ích.
  2. Streaming/event-driven thuần: dùng Kafka/Flink hợp hơn; Temporal là orchestration, không phải stream processor.
  3. Job đơn giản mỗi đêm: Hangfire / Quartz rẻ hơn nhiều; Temporal dành cho workflow nhiều bước, có nhánh và thời gian dài.

13. Lộ trình migration từ saga sang Temporal

Tuần 1-2
Dựng cluster Temporal dev + UI, viết workflow "hello-world" và một activity gọi service có sẵn. Mục tiêu là cả team cảm nhận được luồng replay, Web UI, và test harness.
Tuần 3-4
Chọn một saga đơn giản đang chạy (thường là "onboarding user" hoặc "refund"), viết lại dưới dạng workflow. Chạy song song hai hệ: saga cũ xử lý traffic thật, workflow mới shadow. So sánh kết quả.
Tháng 2
Chuyển 10% traffic sang Temporal, theo dõi metric, tuning retry và timeout. Viết runbook cho ops: retry from UI, terminate workflow, reset to event N.
Tháng 3
Full rollout, tắt saga cũ. Bắt đầu migrate workflow dài hơn (billing cycle, subscription renewal) — đây mới là nơi Temporal đem lại giá trị lớn nhất.
Tháng 4+
Chuẩn hoá patching/versioning, thiết lập SLA theo workflow ID pattern, và mở rộng cluster từ 1 namespace sang multi-tenant nếu tổ chức có nhiều bounded context.

14. Anti-pattern thường gặp

  • Ôm business logic thật vào workflow: tính toán giá, query DB trong workflow function — sai! Đưa vào activity.
  • Activity không idempotent: Temporal retry, activity sai idempotency sẽ trừ tiền hai lần. Luôn dùng idempotency key ở gateway.
  • Gửi object khổng lồ qua signal: payload signal đi vào history. Nếu gửi 5MB JSON, mỗi signal ngốn đáng kể. Truyền ID, activity tự fetch.
  • Không đặt StartToCloseTimeout: activity treo vĩnh viễn, workflow kẹt, không trigger retry.
  • Không continue-as-new cho workflow cronic: history phình, một ngày nào đó hit limit 50k event và workflow lỗi không rõ nguyên nhân.

15. Kết luận

Durable execution chưa phải mặc định cho mọi service, nhưng đã trưởng thành tới mức đáng đưa vào thư viện thiết kế hệ thống của mọi kỹ sư backend 2026. Khi bạn bắt đầu viết tay state machine thứ ba trong năm, khi pager gọi vì saga treo nửa đêm, khi báo cáo revenue lệch vì một refund job chạy thiếu — đó là lúc Temporal (hay một engine tương đương) có ROI rõ rệt. Với .NET 10, rào cản kỹ thuật gần như biến mất: SDK mature, source generator, tích hợp DI và OpenTelemetry tự nhiên, có thể triển khai onprem bằng Temporal OSS hoặc dùng Temporal Cloud nếu không muốn vận hành cluster.

Quy tắc đơn giản để quyết định: nếu luồng của bạn có bước, có thời gian, có compensation, có khả năng worker chết giữa chừng — thì nó là ứng cử viên cho durable execution. Còn nếu chỉ là CRUD thì hãy để nó yên.

Điểm cần nhớ

Workflow là code thuần — không I/O, không thời gian thật. Activity là nơi làm side-effect, có retry và timeout khai báo. History là nguồn sự thật, giữ nó gọn bằng continue-as-new. Signal để gửi sự kiện, query để đọc state, update để làm cả hai đồng bộ. Versioning bằng Workflow.Patched là bắt buộc cho mọi breaking change business logic.

16. Nguồn tham khảo