De meeste async systemen herinneren zich te veel (logs, wachtrijen, aanhoudende troep die je nooit wilde)... of ze herinneren zich helemaal niets (brand-en-vergeten zwarte gaten die verdwijnen op het moment dat er iets mis gaat).
Dit artikel introduceert iets anders:
Een patroon waar elke async operatie een kleintje wordt trackable, gecontroleerd, opnieuw op te halen, zelfreinigende taakeenheid -en het hele systeem blijft privé, begrensd, snel en deterministisch.
Het is de metgezel van mijn eerdere artikel, Leren LRU's - Wanneer overschrijding van de capaciteit maakt uw systeem beter. Die ene onderzocht hoe begrensd geheugen + glijdend verlopen een overlevingsmechanisme wordt.
Deze verkent de andere helft: begrensde, kortstondige uitvoering -hoe een kleine rolbuffer van actieve taken een debugger, een event log en een replay engine wordt, zonder gebruikersgegevens te bewaren.
Het voorbeeld komt uit de vertaling van mijn blog widget - een kleine UI voor het vertalen markdown content on-the-fly. Het ziet er triviaal uit. Onder de motorkap, implementeert het patronen die u kunt stelen voor elke async workflow.
Dit is Deel 1 van een reeks van twee delen:
ASP.NET ontwikkelaars hebben de neiging om te kiezen uit drie slechte opties:
Jij Task.Run() Geen verband, geen reconstructie, geen idee wat mislukt is.
Je blokkeert draden die je niet moet blokkeren... de doorvoer sterft... de lattentie explodeert... je gebruikers haten je.
Nu heb je:
Nuttig... maar extern. Ook: het kan niets opnieuw afspelen en lekt vaak gegevens die je nooit van plan was op te slaan.
Wat we eigenlijk willen is:
Een "net lang genoeg" geheugen van wat het systeem doet - en niets meer.
Hier is het idee in één zin:
Kick off een async operatie onmiddellijk, track het expliciet via een TaskComplementationBron, opruim het deterministisch, en houd een kleine rollende buffer van de laatste paar taken, zodat u kunt inspecteren of ophalen ze - zonder het behoud van een gebruiker inhoud.
Het zit halverwege tussen:
...zonder één van hen te worden.
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
De kerngegevensstructuur is a TranslateTask -een kleine atomaire executie unit ondersteund door een 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);
Het bevat:
Task<TaskCompletion>Niets bleef bestaan, niets op schijf geschreven, niets opgeslagen achter een rolraam.
AWS heeft stapfuncties. Azure heeft duurzame functies. Je hebt... 30 regels code die geen cloud factuur nodig hebben.
De magie gebeurt in de BackgroundTranslateService. In plaats van vuur-en-vergeet, gebruiken we een TaakvoltooidBron -een belofte-achtige constructie die ons onmiddellijk terug laat keren terwijl het werk op de achtergrond gebeurt.
// 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;
}
Als u niet hebt gebruikt TaskCompletionSource<T> voor, zie het als een beloven dat u handmatig controleert. In tegenstelling tot een reguliere Task dat voltooid wanneer het werk klaar is, een TCS voltooid wanneer u oproep SetResult(), SetException(), of SetCanceled().
Dit laat je:
Task onmiddellijk naar de bellerHet is de brug tussen "vuur-en-vergeten" en "vuur-en-spoor."
Wanneer een gebruiker op "Vertalen" klikt, roept de frontend de API aan:
// 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'
});
});
De API geeft onmiddellijk terug met slechts een taak-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);
}
Het antwoord is direct. De werkelijke vertaling draait op de achtergrond. De gebruiker kan poll of gewoon kijken naar de UI-update.
Het belangrijkste inzicht van de LRU-artikel geldt ook hier:
Hou net genoeg geschiedenis om te debuggen en redeneren over het systeem... en laat natuurlijk verval al het andere wissen.
// 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; }
}
}
Elke nieuwe taak:
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
Geen retentierisico. Geen PII in de cache (alleen taak-ID's, tijdstempels en taalcodes). Geen GDPR hoofdpijn.
De background service implementeert een vaste concurrency lus met behulp van een kanaallezer en 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");
}
}
}
Dit is als een gouverneur op een stoommachine: meer belasting → tegendruk bouwt → natuurlijke throttling → stabiliteit.
Het patroon geeft je:
Wanneer het eigenlijke vertaalwerk klaar is, voltooien we de 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);
}
}
De beller's Task<TaskCompletion> -die ze meteen kregen toen ze belden. Translate() Ze kunnen...
await het als ze willen blokkerenIsCompleted om te peilenIsFaulted om te zien of het mislukt isResult om de vertaalde inhoud te krijgenBij het weergeven van taken aan de gebruiker, projecteren we de live taakstatus in een weergavemodel:
// 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 heeft een aantal eigenzinnige staat combinaties:
IsCompleted is waar voor beide succesvolle voltooiing EN foutieve takenIsCompletedSuccessfully is alleen waar voor succesIsFaulted Dat betekent dat het een uitzondering heeft gemaakt.IsCanceled betekent dat het geannuleerd is.Dus je moet controleren IsFaulted alvorens te controleren IsCompletedOf je behandelt mislukkingen als successen.
De peilingen voor updates met behulp van 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>
Het slimme stukje: hx-trigger="@trigger" veranderingen op basis van status:
"none")Dit is zelfregulerende peilingen -het stopt automatisch als er niets is om naar te kijken.
Wanneer de gebruiker op "View" klikt, halen we de complete vertaling op:
// 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);
}
De JavaScript bevolkt vervolgens de editor:
// 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);
});
}
Omdat:
Dit is de architectuur. Ik wou dat browsers en frontend kaders gebruikt hadden voor sessiebewerkingen.
Wat je krijgt zonder te hard na te denken:
Hoe kan ik je helpen? |---------|-----| | Stabiliteit Bounded concurrency via kanaal + emafore patroon | Negatieve terugkoppelingslus Meer belasting → tegendruk → begrensd geheugen → consistent gedrag | Zichtbaarheid debuggen De exacte operaties zijn net lang genoeg aanwezig om te inspecteren . | Ophalbaarheid Voltooide resultaten zijn toegankelijk tot ze aflopen . | Nul gegevensbewaring Privacy en eenvoud uitgelijnd voor een keer | Zelfoptimaliserende uitvoering Oude taken vallen weg, relevante blijven over
Net zoals de LRU-cache het gedragsgeheugen scherpde, scherpt het rollende operatievenster de uitvoeringsweergave aan.
Gebruik het wanneer:
Gebruik het niet als:
Indien de LRU-artikel was ongeveer leren door te vergeten, deze gaat over uitvoeren door verdamping.
Het systeem herinnert zich precies genoeg om behulpzaam te zijn -en niets meer.
Het is:
Het beste ding? Je weet al hoe het te implementeren: een TaskCompletionSource, a Channel, een IMemoryCache met een glijdend verloop, en een achtergrond werknemer.
Vuur... en vergeet het niet.
In **Deel 2: Bouwen van een herbruikbare Tijdelijke Executiebibliotheek**We veranderen dit patroon in een drop-in helper:
EphemeralForEachAsync<T> - zoals Parallel.ForEachAsync maar met trackingEphemeralWorkCoordinator<T> - een langlevende waarneembare werkrijAddEphemeralWorkCoordinator<TCoordinator> (zoals AddHttpClient)© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.