Resilient Actions
One night, Cron was generating jobs as expected. The queue was accepting them as expected. The Worker was running as expected.
Then a single Action got stuck.
As a result:
- Cron continued generating new jobs;
- the queue continued growing;
- other Actions stopped executing;
- the issue was resolved only after a restart.
This is a classic example of how system reliability is determined not by how it behaves in a perfect world, but by how it handles failures.
An Action Must Be Designed for Failure
Every Action operates in a hostile environment:
- the network may stop responding;
- the database may become slow;
- an external API may hang;
- the server may restart;
- the action may be cancelled by the user.
For this reason, an Action should be designed as if failure is inevitable.
Always Use CancellationToken
Bad:
await http.GetStringAsync(url);
Good:
await http.GetStringAsync(url, ct);
Bad:
await Task.Delay(TimeSpan.FromMinutes(5));
Good:
await Task.Delay(TimeSpan.FromMinutes(5), ct);
If an operation does not support cancellation, the Worker cannot stop the Action gracefully.
Always Define Timeouts
Even when a CancellationToken is used.
Bad:
var http = new HttpClient();
Good:
var http = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
External resources are not required to respond quickly.
Sometimes they do not respond at all.
Log Execution Stages
Often the most useful log messages are the simplest ones:
ReportProgress(0, "Loading data");
ReportProgress(0, "Downloaded data");
ReportProgress(0, "Saving data");
ReportProgress(0, "Completed");
If an Action gets stuck, the last message immediately identifies where execution stopped.
For example:
Loading new
It becomes obvious that the problem occurred before the database save phase.
Make Actions Idempotent
After a failure, an Action may execute again.
If re-execution creates duplicate data, the system is not resilient.
Bad:
INSERT INTO Tokens (...)
Better:
INSERT ...
WHERE NOT EXISTS (...)
or
MERGE
or
UNIQUE INDEX
A repeated execution should be safe.
Do Not Store State in Memory
Bad:
private static int Processed;
The value will be lost after a restart.
Instead, store state in:
- a database;
- a queue;
- a persistent state store.
An Action should survive process restarts.
Break Large Operations into Smaller Actions
Bad:
DownloadAllHistory();
ProcessAllHistory();
SaveAllHistory();
Good:
download
process
aggregate
publish
Smaller Actions are easier to:
- cancel;
- retry;
- troubleshoot.
Verify Behavior After a Timeout
It is not enough to verify that an Action was cancelled.
You must prove that the queue continues to operate.
A good integration test is:
- The first job becomes stuck.
- A timeout occurs.
- The second job executes successfully.
- The queue continues processing.
This scenario closely matches real production behavior.
A Practical Rule
Whenever an Action contains an:
await
ask yourself:
What happens if this operation hangs forever?
If the answer is:
Nothing good.
Then the Action is not production-ready yet.
Summary
A simple, resilient Action has five characteristics:
- supports
CancellationToken; - defines reasonable timeouts;
- logs key execution stages;
- is idempotent;
- safely handles re-execution.
In an ideal world, failures never happen.
In production, they happen every day.
A good Action is not one that never fails.
A good Action is one after which the system continues to operate. 🚀🐈⬛
Top comments (1)
Nice list, and the timeout plus CancellationToken pair has one detail that helps with the logging point. Since .NET 5, when HttpClient.Timeout fires you get a TaskCanceledException whose InnerException is a TimeoutException, so a catch can check ex.InnerException is TimeoutException and log "timed out, safe to retry" separately from "the job was cancelled, stop". Without that check they both look like the same cancellation in the log, and the retry decision is usually the opposite, so it's worth the two extra lines.