De flesta async-system kommer antingen ihåg för mycket (loggar, köer, ihållande skräp du aldrig ville ha)... eller så minns de ingenting alls (eld-och-glömma svarta hål som försvinner när något går fel).
Denna artikel introducerar något annat:
Ett mönster där varje async-operation blir en liten Spårbar, inspekteras, återinsättningsbar, självrengörande arbetsenhet -och hela systemet förblir privat, avgränsat, snabbt och deterministiskt.
Det är följeslagaren till min tidigare artikel, Lära LRU - När överträffa kapacitet gör ditt system bättre. Att man utforskade hur avgränsat minne + glidande utgång blir en överlevnadsmekanism.
Den här utforskar den andra halvan: avgränsad, flyktig avrättning -hur en liten rullande buffert av aktiva uppgifter blir en avlusare, en händelselogg, och en replay motor, utan att behålla några användardata.
Exemplet kommer från min bloggs översättning widget - en liten UI för att översätta markdown innehåll i farten. Det ser trivialt ut. Under huven, det implementerar mönster som du kan stjäla för alla async arbetsflöden.
Det här är Häfte 1 i en serie med två delar:
ASP.NET utvecklare tenderar att välja mellan tre dåliga alternativ:
Du där. Task.Run() När den sprängs får man inget samband, ingen rekonstruktion, ingen aning om vad som misslyckades.
Genomströmning dör, latens exploderar och användarna hatar dig.
Och nu har du:
Användbar... men extern. Dessutom: det kan inte spela upp något och ofta läcker data du aldrig tänkt att lagra.
Vad vi egentligen vill ha är:
Ett "bara tillräckligt långt" minne av vad systemet gör - och inget mer.
Här är idén i en mening:
Starta en async-operation omedelbart, spåra den explicit via en TaskCompletionSource, rensa upp den deterministiskt, och hålla en liten rullande buffert av de senaste uppgifterna så att du kan inspektera eller hämta dem - utan att behålla något användarinnehåll.
Det sitter halvvägs mellan:
...utan att bli någon av dem.
flowchart TB
subgraph Client["Client Request"]
R[Start Translation]
end
subgraph API["API Layer"]
A[Create TaskId] --> B[Create TaskCompletionSource]
B --> C[Queue to Channel]
C --> D[Return TaskId Immediately]
end
subgraph Cache["Ephemeral Cache (Bounded)"]
E[Store TranslateTask]
F[Max 5 per user]
G[6hr absolute / 1hr sliding expiry]
end
subgraph Worker["Background Worker"]
H[Read from Channel]
H --> I[Execute Translation]
I --> J[Complete TCS]
end
R --> A
B --> E
E --> F --> G
C --> H
style Cache fill:none,stroke:#10b981,stroke-width:2px
style Worker fill:none,stroke:#6366f1,stroke-width:2px
Den centrala datastrukturen är en TranslateTask -en liten atomär avrättningsenhet som backas upp av en Task<TaskCompletion>:
// From TranslateTask.cs
public class TranslateTask(
string taskId,
DateTime startTime,
string language,
Task<TaskCompletion>? task)
: TranslateResultTask(taskId, startTime, language)
{
public Task<TaskCompletion>? Task { get; init; } = task;
}
public record TaskCompletion(
string? TranslatedMarkdown,
string OriginalMarkdown,
string Language,
bool Complete,
DateTime? EndTime);
Den innehåller:
Task<TaskCompletion>Inget kvarstod, inget skrivet till diskett, inget lagrat utanför ett rullande fönster.
AWS har stegfunktioner. Azure har hållbara funktioner. Du har ... 30 rader kod som inte kräver en molnräkning.
Magin händer i BackgroundTranslateService. I stället för eld-och-glömma, använder vi en Källa för slutförande av uppgift -en löfteslik konstruktion som låter oss återvända omedelbart medan arbetet sker i bakgrunden.
// From BackgroundTranslateService.cs
private readonly Channel<(PageTranslationModel, TaskCompletionSource<TaskCompletion>)>
_translations = Channel.CreateUnbounded<(PageTranslationModel, TaskCompletionSource<TaskCompletion>)>();
private async Task<Task<TaskCompletion>> Translate(PageTranslationModel message)
{
// Create a TaskCompletionSource that will eventually hold the result
var tcs = new TaskCompletionSource<TaskCompletion>();
// Send the translation request along with the TCS to be processed
await _translations.Writer.WriteAsync((message, tcs));
// Return the Task immediately -caller can await it or check status later
return tcs.Task;
}
Om du inte har använt TaskCompletionSource<T> före, se det som en lova att du styr manuellt. Till skillnad från en vanlig Task som slutför när dess arbete avslutas, en TCS färdig när du samtal SetResult(), SetException(), eller SetCanceled().
Detta låter dig:
Task till den som ringer omedelbartDet är bron mellan "eld-och-glöm" och "eld-och-spår".
När en användare klickar på "Translate" anropar gränssnittet API:
// From translations.js
fetch('/api/translate/start-translation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Language: shortCode,
OriginalMarkdown: markdown
})
})
.then(response => response.json())
.then(taskId => {
// Got a taskId immediately -translation is running in background
console.log("Task ID:", taskId);
// Poll for updates via HTMX
htmx.ajax('get', "/editor/get-translations", {
target: '#translations',
swap: 'innerHTML'
});
});
API:et returneras omedelbart med bara ett aktivitets-ID:
// From TranslateAPI.cs
[HttpPost("start-translation")]
public async Task<Results<Ok<string>, BadRequest<string>>> StartTranslation(
[FromBody] MarkdownTranslationModel model)
{
if (!backgroundTranslateService.TranslationServiceUp)
return TypedResults.BadRequest("Translation service is down");
// Create a unique identifier for this translation task
var taskId = Guid.NewGuid().ToString("N");
var userId = Request.GetUserId(Response);
// Trigger translation -returns Task<TaskCompletion> immediately
var translationTask = await backgroundTranslateService.Translate(model);
// Wrap it in our trackable TranslateTask
var translateTask = new TranslateTask(taskId, DateTime.Now, model.Language, translationTask);
// Store in the ephemeral cache (bounded, self-cleaning)
translateCacheService.AddTask(userId, translateTask);
// Return the task ID to the client -they can poll for status
return TypedResults.Ok(taskId);
}
Svaret är omedelbart. Den faktiska översättningen körs i bakgrunden. Användaren kan välja eller bara titta på UI- uppdateringen.
Den viktigaste insikten från LRU-artikel gäller även här:
Håll bara tillräckligt med historia för att felsöka och resonera om systemet... och låta naturligt förfall radera allt annat.
// From TranslateCacheService.cs
public class TranslateCacheService(IMemoryCache memoryCache)
{
public void AddTask(string userId, TranslateTask task)
{
if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
{
var currentTasks = tasks?.Tasks ?? new List<TranslateTask>();
currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
// Keep only the 5 most recent tasks -bounded window
if (currentTasks.Count >= 5)
{
var lastTask = currentTasks.Last();
currentTasks.Remove(lastTask);
}
currentTasks.Add(task);
currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
tasks!.Tasks = currentTasks;
memoryCache.Set(userId, tasks, new MemoryCacheEntryOptions
{
AbsoluteExpiration = tasks.AbsoluteExpiration,
SlidingExpiration = TimeSpan.FromHours(1)
});
}
else
{
// First task for this user
var cachedTasks = new CachedTasks
{
Tasks = new List<TranslateTask> { task },
AbsoluteExpiration = DateTime.Now.AddHours(6)
};
memoryCache.Set(userId, cachedTasks, new MemoryCacheEntryOptions
{
AbsoluteExpiration = cachedTasks.AbsoluteExpiration,
SlidingExpiration = TimeSpan.FromHours(1)
});
}
}
public List<TranslateTask> GetTasks(string userId)
{
if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
return tasks?.Tasks ?? new List<TranslateTask>();
return new List<TranslateTask>();
}
private class CachedTasks
{
public List<TranslateTask> Tasks { get; set; } = new();
public DateTime AbsoluteExpiration { get; set; }
}
}
Varje ny uppgift:
flowchart LR
subgraph Window["Per-User Task Window (Max 5)"]
T1[Oldest Task] --- T2[Older] --- T3[Recent] --- T4[Newer] --- T5[Newest]
end
New[New Task] -->|Enqueue| Window
T1 -->|Evicted| Gone[(Expired)]
style Window fill:none,stroke:#10b981,stroke-width:2px
style Gone fill:none,stroke:#ef4444,stroke-width:2px
Ingen risk för retention. Ingen PII i cache (bara uppgifts-ID, tidsstämplar och språkkoder). Ingen GDPR-huvudvärk.
Bakgrundstjänsten implementerar en Fast överensstämmelse loop med hjälp av en kanalläsare och Task.WhenAny:
// From BackgroundTranslateService.cs
private async Task TranslateFilesAsync(CancellationToken cancellationToken)
{
var processingTasks = new List<Task>();
while (!cancellationToken.IsCancellationRequested)
{
// Fill up to IPCount concurrent tasks (e.g., 4 parallel translations)
while (processingTasks.Count < markdownTranslatorService.IPCount &&
!cancellationToken.IsCancellationRequested)
{
var item = await _translations.Reader.ReadAsync(cancellationToken);
var translateModel = item.Item1;
var tcs = item.Item2;
// Start the task and add it to the list
var task = TranslateTask(cancellationToken, translateModel, item, tcs);
processingTasks.Add(task);
}
// Wait for ANY of the tasks to complete
var completedTask = await Task.WhenAny(processingTasks);
processingTasks.Remove(completedTask);
// Handle exceptions if needed
try
{
await completedTask;
}
catch (Exception ex)
{
logger.LogError(ex, "Error translating markdown");
}
}
}
Det här är som en Guvernör på en ångmaskin: mer belastning → mottryck bygger → naturlig strypning → stabilitet.
Mönstret ger dig:
När det faktiska översättningsarbetet är avslutat, slutför vi TCS:
// From BackgroundTranslateService.cs
private async Task TranslateTask(
CancellationToken cancellationToken,
PageTranslationModel translateModel,
(PageTranslationModel, TaskCompletionSource<TaskCompletion>) item,
TaskCompletionSource<TaskCompletion> tcs)
{
try
{
await retryPolicy.ExecuteAsync(async () =>
{
// Do the actual translation work
var translatedMarkdown = await markdownTranslatorService.TranslateMarkdown(
translateModel.OriginalMarkdown,
translateModel.Language,
cancellationToken);
// SUCCESS: Complete the TCS with the result
tcs.SetResult(new TaskCompletion(
translatedMarkdown,
translateModel.OriginalMarkdown,
translateModel.Language,
true,
DateTime.Now));
});
}
catch (TranslateException e)
{
// FAILURE: Complete the TCS with an exception
tcs.SetException(new Exception($"Translation failed after 3 retries: {e.Message}"));
}
catch (Exception e)
{
// UNEXPECTED: Complete the TCS with the exception
tcs.SetException(e);
}
}
Den som ringer Task<TaskCompletion> -som de fick direkt när de ringde Translate() - Nu är de färdiga.
await det om de vill blockeraIsCompleted till undersökningIsFaulted för att se om den misslyckadesResult för att få det översatta innehålletNär vi visar uppgifter för användaren projicerar vi liveuppgiftstillståndet i en vymodell:
// From TranslateTask.cs
public TranslateResultTask(TranslateTask task, bool includeMarkdown = false)
{
TaskId = task.TaskId;
StartTime = task.StartTime;
Language = task.Language;
// Check for faulted state first -a faulted task is also "completed" in .NET terms
if (task.Task?.IsFaulted == true)
{
Failed = true;
Completed = false;
TotalMilliseconds = (int)(DateTime.Now - task.StartTime).TotalMilliseconds;
}
else if (task.Task?.IsCompletedSuccessfully == true)
{
Completed = true;
Failed = false;
var endTime = task.Task.Result.EndTime;
TotalMilliseconds = (int)((endTime - task.StartTime)!).Value.TotalMilliseconds;
EndTime = endTime;
}
else
{
// Still in progress
Completed = false;
Failed = false;
TotalMilliseconds = (int)(DateTime.Now - task.StartTime).TotalMilliseconds;
}
if (Completed && includeMarkdown)
{
var result = task.Task?.Result;
if (result == null) return;
OriginalMarkdown = result.OriginalMarkdown;
TranslatedMarkdown = result.TranslatedMarkdown;
}
}
.NET:s Task har några udda tillståndskombinationer:
IsCompleted är sant för båda framgångsrikt slutförande OCH felaktiga uppgifterIsCompletedSuccessfully gäller endast för framgångIsFaulted betyder att det kastade ett undantagIsCanceled betyder att det ställdes inSå du måste kolla IsFaulted Före kontroll IsCompletedAnnars behandlar du misslyckanden som framgångar.
Vyundersökningar för uppdateringar med hjälp av HTMX's hx-trigger:
@* From _GetTranslations.cshtml *@
@{
var allCompleted = Model.All(x => x.Completed);
var trigger = allCompleted ? "none" : "every 5s";
}
<div class="translationpoller"
hx-get="/editor/get-translations"
hx-swap="outerHTML"
hx-trigger="@trigger">
<table class="table">
@foreach (var item in Model)
{
<tr>
<td>
@if (item.Completed)
{
<a href="#" x-on:click.prevent="viewTranslation('@item.TaskId')">View</a>
}
else if (item.Failed)
{
<text>Failed</text>
}
else
{
<text>Processing</text>
}
</td>
<td>
@if (item.Completed)
{
<i class='bx bx-check text-green'></i>
}
else if (item.Failed)
{
<i class='bx bx-x text-red'></i>
}
else
{
<img src="~/img/3-dots-bounce.svg" />
}
</td>
<td>@item.Language</td>
<td>@TimeSpan.FromMilliseconds(item.TotalMilliseconds).Humanize()</td>
</tr>
}
</table>
</div>
Den smarta biten: hx-trigger="@trigger" Förändringar baserade på tillstånd:
"none")Det här är Självreglerande röstning -Det slutar automatiskt när det inte finns något att titta på.
När användaren klickar på "Visa" hämtar vi den färdiga översättningen:
// From TranslateAPI.cs
[HttpGet("get-translation/{taskId}")]
public async Task<Results<JsonHttpResult<TranslateResultTask>, BadRequest<string>>> GetTranslation(
string taskId)
{
var userId = Request.GetUserId(Response);
var tasks = translateCacheService.GetTasks(userId);
var translationTask = tasks.FirstOrDefault(t => t.TaskId == taskId);
if (translationTask == null)
return TypedResults.BadRequest("Task not found");
// Include the markdown content in the response
var result = new TranslateResultTask(translationTask, includeMarkdown: true);
return TypedResults.Json(result);
}
JavaScriptet fyller sedan i editorn:
// From translations.js
export function viewTranslation(taskId) {
fetch(`/api/translate/get-translation/${taskId}`)
.then(response => response.json())
.then(data => {
// Show the translated content area
document.getElementById("translatedcontent").classList.remove("hidden");
// Populate the editors
var originalMde = window.mostlylucid.simplemde.getinstance('translatedcontentarea');
originalMde.value(data.originalMarkdown);
var mde = window.mostlylucid.simplemde.getinstance('markdowneditor');
mde.value(data.translatedMarkdown);
});
}
Därför att:
Det här är arkitekturen. Jag önskar att webbläsare och frontendramar hade använts för sessionsoperationer.
Vad du får utan att tänka för mycket:
VÄLKOMMEN FRÅN FLYGPLATSEN |---------|-----| | Stabilitet till Gränsad konvergens via kanal + semaformönster | Negativ återkopplingsslinga Mer belastning → mottryck → avgränsat minne → konsekvent beteende | Felsökningssynlighet Den exakta verksamheten är närvarande bara tillräckligt länge för att inspektera | Återvinningsförmåga Slutförda resultat är tillgängliga tills de upphör att gälla | Noll lagring av uppgifter med integritet och enkelhet anpassad för en gångs skull | Självoptimerande genomförande på gamla uppgifter faller bort, relevanta förblir med
Precis som LRU-cachen skärpte beteendeminnet skärper det rullande driftfönstret körvyn.
Använd den när:
Använd den inte när:
Om inte annat anges ska LRU-artikel var ungefär Att lära sig genom att glömma, Den här handlar om Genomförande genom avdunstning.
Systemet kommer ihåg exakt tillräckligt för att vara till hjälp - och inget mer.
Det är:
Du vet redan hur man genomför det: en TaskCompletionSource, , , , . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Channel, en IMemoryCache med glidande utgång, och en bakgrundsarbetare.
Eld... och glöm inte.
Till **Del 2: Att bygga ett återanvändbart efemärt avrättningsbibliotek**Vi förvandlar mönstret till en drop-in-hjälpare.
EphemeralForEachAsync<T> - Som jag sa. Parallel.ForEachAsync men med driftspårningEphemeralWorkCoordinator<T> - en långlivad observerbar arbetsköAddEphemeralWorkCoordinator<TCoordinator> (som AddHttpClient)© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.