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
Table of contents
- 1. Bài toán của workflow dài hơi trong microservices
- 2. Kiến trúc Temporal: Workflow, Activity, Task Queue
- 3. Temporal .NET SDK trong hệ sinh thái .NET 10
- 4. Replay determinism và luật ngầm khi viết workflow
- 5. Retry, timeout và heartbeat — ba tầng bảo vệ
- 6. Signals, Queries, Updates — giao tiếp hai chiều với workflow đang chạy
- 7. Saga orchestrator tự viết so với Temporal
- 8. Luồng thực tế: Order fulfillment pattern
- 9. Child workflow, continue-as-new, fan-out
- 10. Observability: trace, metric và Web UI
- 11. Sizing cluster Temporal cho tải thực tế
- 12. Alternatives và khi nào KHÔNG nên dùng Temporal
- 13. Lộ trình migration từ saga sang Temporal
- 14. Anti-pattern thường gặp
- 15. Kết luận
- 16. Nguồn tham khảo
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.
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 |
|---|---|---|
ScheduleToStart | Tối đa task chờ trong queue trước khi worker pick | Muốn phát hiện thiếu worker nhanh (vd: 30s) |
StartToClose | Tối đa từ lúc worker bắt đầu tới khi hoàn tất | Luôn đặt — bảo vệ activity "treo" |
ScheduleToClose | Tố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 |
Heartbeat | Tối đa giữa hai lần activity heartbeat | Bậ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ạnh | Saga tự viết (MassTransit / outbox) | Temporal |
|---|---|---|
| State machine | Mỗi saga là một class state, tự viết transition | Code async tuyến tính như hàm thường |
| Retry | Viết tay policy, idempotency key, circuit breaker | Khai báo RetryPolicy, engine lo hết |
| Timeout | Cron reaper quét DB tìm saga quá hạn | Built-in 4 loại timeout, chính xác tới giây |
| Compensation | Viết tay mỗi handler | Là code catch thông thường |
| Versioning | Khó — saga đang chạy không nâng cấp an toàn | Workflow.Patched cho code mới/cũ cùng tồn tại |
| Observability | Log rải rác, trace thủ công | Temporal Web UI hiển thị history đầy đủ, replay debug |
| Time-driven | Hangfire + cron, nguy cơ double fire | Workflow.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ải | Cluster Temporal | DB backend |
|---|---|---|
| 100 workflow/s, 5k activity/s | 3 node 4 vCPU 8GB | Postgres 1 primary 4 vCPU |
| 1.000 wf/s, 50k act/s | 5 node 8 vCPU 16GB | Postgres primary+replica 16 vCPU |
| 10.000 wf/s, 500k act/s | Cluster Temporal Cloud hoặc Cassandra 6 node | Cassandra 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:
- Request-response <100ms: chi phí hop tới Temporal service cao hơn lợi ích.
- Streaming/event-driven thuần: dùng Kafka/Flink hợp hơn; Temporal là orchestration, không phải stream processor.
- 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
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-newcho 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
Apache Kafka 4.0 KRaft 2026 - Loại bỏ ZooKeeper, KIP-848 Next-Gen Consumer Group và Tiered Storage cho Event Streaming Production
Native AOT trong .NET 10 2026 - Kiến trúc Ahead-of-Time, Trimming và Startup Siêu Nhanh cho Cloud-Native
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.