Eld och Don't Ganska Glöm - Öfmjukelse utan stat (Svenska (Swedish))

Eld och Don't Ganska Glöm - Öfmjukelse utan stat

Friday, 12 December 2025

//

14 minute read

De flesta async-system kommer antingen ihåg för mycket (logs, köer , kvarlevda skräp du aldrig ville ha, )… eller så kommer de inte ihåg någonting alls. | ( | eld | - | och - | glöm svarta hål som försvinner när något går fel

Denna artikel introducerar något annorlunda

Ett mönster där varje async operation blir en liten spårbar, kontrollerbart, re-avtagbar, själv-tvättande arbetsenhet - och hela systemet stannar privatt , begränsat, , snabbt,

' är min tidigare artikels medföljare. Att lära sig LRUs - När överskott gör ditt system bättre. Att man utforskade hur ett begränsat minne + glittrande utplåning blir en överlevnadsmekanism

Den här utforskar den andra halvan bounded, tidiga straff - hur en liten rollande buffer av aktiva uppgifter blir en debugger , en händelselog, , och en återspelningsmaskin

Det exemplet kommer från min blogg's översättningsguidan | - | en liten gränssnitt för att översätta markerdown innehåll på |- | the |- | fly | . | Det ser triviellt ut |

Det här är Del 1 av en serie med två delar

NUGET!!!

Detta är nu även i det mest uppenbara packaget Nuget mer än 20.

NuGet Licens


källdates

Den fulla källkoden finns i mostlylucid.atoms GitHubs arkiv

Problemet -Async arbetsflöden skapa dolda tillstånd

ASP.NET-utvecklare tenderar att välja mellan tre dåliga alternativ

"Fire" - och - "forget

Du Task.Run() något , hoppas att det slutar, och när det exploderar får du ingenting . Ingen korrelation M SK3 ingen rekonstruering MSC4 ingen aning om vad som misslyckades

"Fire" - - - och - - vänta

Du blockerar trådar du inte borde blockera

köer, /, kanaler överallt

Nu har du

  • obegränsade köer
  • ihärdlig tillstånd du inte tänkt att behålla
  • PII lagrats oavsiktligt någonstans
  • ett distribuerat system där man bara ville ha en enkel operation

Distribuerad spårning

användbar… men externt. också : den kan inte spela upp någonting igen och läcker ofta data som du aldrig hade tänkt att lagra

Vad vi egentligen vill är

  • kort-liv
  • hemliga
  • lösbar
  • kontrollerbart
  • begränsat
  • möjlig att hämta om det behövs
  • och sedan var den borta

En """, bara tillräckligt lång, """, minne av vad systemet gör.


Pattern -Fire och Don't Klart Glöm

Här är idén i en mening.

Starta en async operation omedelbart, spåra den eksplicitt via en TaskCompletionSource

Det ligger halvvägs mellan

  • eventsourcing
  • att spåra
  • jobb köer
  • framtida löften
  • tidiga sammankomster

… 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

Anatomi av en tidig uppgift

Kerndatastrukturen är TranslateTask - en liten atomisk uträttelseenhet som stöds av 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);

Det innehåller:

  • En unik ID ( korrelerar över hela strömmen )
  • En tidsramp ( när den börjadeM SK1
  • Metadata (språk
  • En referens till den faktiska Task<TaskCompletion>
  • Tydlighet (beräknat när man får tillgång till den
  • Błędstaten (odlat från uppgiftsstaten)
  • Slutresultat ( endast om färdigt framgångsrikt

Det ', det är det , ., inget överlevt, ., inget skrivet på disken, ., inget lagret bortom en rullande fönstertil

AWS har stegfunktioner. Azure har hållbara funktioner. Du har ♫ … ♫ ♫ 30 ♫ rader kod som inte kräver en molnbil ♫' ♫


TaskCompletionSource Pattern -Störning av eld - och-Ska bort att elda

Magien händer i BackgroundTranslateServiceIstället för eld använder vi Uppbyggnadskällan - ett löfte - som en 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;
}

Vad är en TaskCompletionSource , "?"?

Om du inte använt TaskCompletionSource<T> före "," tänkte man på det som en lova att du kontrollerar det manuell. Till skillnad från en vanlig Task som slutar när dess arbete slutar, en TCS slutar du ringa SetResult(), SetException(), eller SetCanceled().

Det här låter dig

  1. Ge tillbaka en Task till telefonören omedelbart
  2. Gör det faktiska arbetet någon annanstans (skiljbar tråd , bakgrundstjänsterM SK2 etc.)
  3. Gör uppgiften när du bestämma att "'" är gjort

Det är bryggan mellan ", brand, - och -, glömt " och ", brand.


API-layret -Starta en översättning

När en user klickar "Verta", så ropar frontend på 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: n kommer tillbaka omedelbart med bara en uppgift 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


Rolling Operationsfenster -A Self-Cleaning Buffer

Den viktigaste insikten från LRU-artikel gäller även här:

Håll bara tillräckligt med historia för att avbugera och resonera kring systemet… och låt naturlig förruttning 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:

  • Är tilldana till user's rullande venster
  • Springer ut den äldsta om vi
  • Evaporeras helt efter 6 timmar (absolutt | ) | eller ♫ 1 | en timme av inaktivitet
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

Inget förvarande risk. Ingen PII i kaset ( bara uppgiftersidentifiseringar , tidstämningar M SK3 och språkkoder


Bounded Concurrency -The Governor Loop

Hintergrundtjänsterna implementerar fikst samtida partners loop med hjälp av en kanalreader 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 gaskraftverksledare: mer belastning → baktryck byggs ♫ ♫ → ♫ naturlig drivkraft ♫

Patternet ger dig

  • Inga överlastspiraler -kan't skapa oändliga uppgifter
  • Soft real-tidsbeteende -begränsad latens
  • Naturligt glättande av sprängningar -kanal bufferar spetsen
  • Stabilitet under last - bryter sig elegant

Completing of the TaskCompletionSource

När den faktiska översättningsarbeiten slutar

// 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);
    }
}

Kallaren's Task<TaskCompletion> - som de fick direkt när de ringde Translate() - slutar nu . De kan

  • await om de vill blockera
  • Kontroller IsCompleted att rösta
  • Kontroller IsFaulted för att se om det misslyckades
  • Kontroller Result för att få översatt innehåll

Statusprognosen -Deriving State from Task State

När vi visar upp uppgifter för användaren, projicerar vi den verkliga uppgifternas tillstånd i en modell

// 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;
    }
}

En anteckning om arbetslägen

.NET's Task har några märkliga tillståndskombinationer:

  • IsCompleted är sant för båda framgångsrikt utförande och felaktiga uppgifter
  • IsCompletedSuccessfully är bara sant för framgång
  • IsFaulted innebär att det kastade en undantag
  • IsCanceled betyder att den har avgått

Så du måste kolla IsFaulted före att kolla IsCompleted, eller så ska du treatera misslyckanden som framgångar


Ui -HTMX Polling

Synundersökningar för uppdaterade med 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:

  • Om uppgifterna fortfarande pågår
  • Om alla uppgifter är utförda: sluta rösta ("none")

Det här är självregulerande val - stannar automatiskt när det finns något att titta på


Att hämta utfallet

När användaren klickar "View", får vi slutgiltiga ö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);
}

JavaScript befolkar sedan redaktören:

// 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);
        });
}

Varför är det här privat?

Eftersom:

  • Inget användarinnehåll lagras i kaset - endast uppgifter för uppgifter
  • innehållet existerar bara i uppgifternas resultat - som är i minnet, som ansluts till uppgifterna
  • Oknet är begränsat -max 5 uppgifter per användare
  • Allt bryts ner naturligt -sliding expiration cleans up inactive users
  • Inget rör vid disket - inga logs , inga köer M SK2 ingen databas
  • Du kan inte oavsiktligt behålla PII -, där, ', det finns inget ställe dit den ska gå

Det här är arkitekturen Jag önskar att webblader och frontend ramverk hade använts för sessioner.


Det uppväxande beteendet

Vad du får utan att tänka för hårt:

nytta Hur
Stabilitet Begränsad samtida genom kanal + mönster av mönsters
Negativ återkopplingsloop Mer belastning → baktryck → begränsad minne МSK3 konsekventa beteende
Debug-visibilitet De exakta operationerna är närvarande tillräckligt länge för att inspektera
Lösningsbarhet Completed results are accessible until they expire
Noll databevarande Privatsphäre och enkelhet sammanlänkade för en gång
Self-optimering av uitvoering Gamla uppgifter faller bort

Precis som LRU-キャッシュen hade täckt beteendemässiga minnen, täcker rollandets operationsfenster inspelningsbilden.


När man ska använda det här mönstret

Använd den när:

  • Användardata måste inte finnas kvar
  • Operationer är korta
  • Problemet löser
  • Privatlivsfrågor
  • Ladestabilitet
  • Du vill ha en omedelbar respons + bakgrundsprocess

Använd inte den när

  • Du behöver riktig hållbarhet (använd en riktig kö)
  • Operationer tar timmar (använda Hangfire eller liknande )
  • Du behöver exakt-en gång semantik | ( | använda en fördelad transaktion
  • Flera server behöver koordinera (använda Redis eller ett meddelandesmakare)

slutsats -Permanent utföring som en designfilosofi

Om LRU-artikel var att lära sig genom att glömma, den här handlar om utförd genom att avdunsta.

Systemet kommer ihåg precis tillräckligt mycket för att vara hjälpsamt - och inget mer .

Det's:

  • begränsat
  • deterministisk
  • hemliga
  • lösbar
  • lösbar
  • själv-tvättande
  • robust
  • och arkitektur-first

Det bästa? Du vet redan hur du ska implementera det TaskCompletionSource, a Channel, an IMemoryCache med skiftig utgångsdatum , och en bakgrundsarbetare.

"Fire" … och "Don"


Vad's Nästa?

I Del 2: Att bygga en återanvändbar ephemeral Execution Library, vi ' ska göra detta mönster till en droppe

  • EphemeralForEachAsync<T> - som Parallel.ForEachAsync men med operationsspårning
  • Keyed pipelines for per-entity sequential execution
  • EphemeralWorkCoordinator<T> - en lång
  • Namerade koordinatorer med "/". AddEphemeralWorkCoordinator<TCoordinator> ( som AddHttpClient)
  • Full DI integration med skalad och enton livslängd
  • jämförelse med andra tillvägagångssätt (TPL Dataflow

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.