Fire et Don't Bien sûr. Forget - Exécution provisoire sans état (Français (French))

Fire et Don't Bien sûr. Forget - Exécution provisoire sans état

Friday, 12 December 2025

//

18 minute read

La plupart des systèmes asynques se souviennent soit trop de (logs, queuesM SK2 jets persistants que vous n'avez jamais voulus )…, soit ils ne se souvient rien du tout.

Cet article présente quelque chose de différent:

Un modèle où chaque opération async devient une petite Traçabilité, d'inspection, re-retrievable, auto-unité de tâche de nettoyage -et l’ensemble du système demeure privé , boundedM SK2 rapide, et déterministeMSC4

Il s’agit de mon article précédent, ,. Apprentissage des LRU - Lorsque l’excès de capacité rend votre système meilleur. On a étudié comment la durée de l’expiration des bouées devient un mécanisme de survie.

Ce dernier explore l’autre moitié. bounded, exécution éphémère - comment un petit tampon de roulement des tâches actives devient un débugger , un registre d’événements , et un moteur de replay M SK3 sans tenir des données sur les utilisateurs

L'exemple provient de mon blog's widget de traduction -une petite interface pour la traduction du contenu de marquage à l'aperçu sur -leM SK3fléchanceMSC4 Il semble trivialMska5 Sous le capuchonM Ska6 il met en œuvre des patrons que vous pouvez voler pour tout flux de travail asyncMka7

Il s'agit de Partie 1 d’une série de deux pièces, - et :

NUGET!!!

Cela est maintenant dans le mostlylucid.ephemerals Nuget paquet aussi plus que 20 les patrons éphémères de la plupart du temps clairs et 'atoms.

NuGet Licence


Les fichiers de source

Le code source complet se trouve dans le mostlylucid.atoms GitHub repository

Le problème -Async Workflows Créer un état caché

ASP.Les développeurs de NET ont tendance à choisir parmi trois mauvaises options

Fire-and-forget

Vous Task.Run() quelque chose, espérer qu’il se termine , et quand il explose vous n’obtiendrez rienMSC2 Aucune corrélationM SK3 aucune reconstruction, aucune idée de ce qui a échoué

Le feu-etM SK1attendre

Vous bloquez les threads que vous ne devriez pas bloquer't block. Le débit d’alimentation décède . La latence exploseM SK3 Vos utilisateurs te chassentMSC4

Couloirs/channels partout

Vous avez maintenant:

  • des listes non limitées
  • État persistant que vous n’avez pas voulu conserver
  • PII est accidentellement entreposé quelque part
  • un système distribué où vous voulez simplement une simple opération

Traçage distribué

Utilisable… mais externeM SK1 Également : il peut' ne jouer rien et souvent détruire des données que vous n'avez jamais voulu stockerMSC4

Ce que nous voulons en réalité, c’est

  • Short-lived
  • énergie privée
  • débugable
  • d'inspection
  • bounded
  • retrievable si nécessaire
  • et ensuite disparus

Une mémoire "justement assez longueM SK1 de ce que le système fait -et rien d'autre.


Le modèle -Fire et Don't Quite Forget (en anglais seulement)

Voici l’idée en une seule phrase.

Démarrer immédiatement une opération asynque, la suivre explicitement par l'entremise d'une source de tâcheCompletionSource, le nettoyer de façon déterministe , et conserver un petit tampon roulant des dernières tâches afin que vous puissiez les inspecter ou les récupérer.

Il se trouve à mi-chemin entre:

  • Source des événements
  • traçage
  • Enquête sur les emplois
  • futures/promises
  • Sessions temporaires

…sans qu’il ne devienne l’une ou l’autre d’entre elles .

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

Anatomie d'une tâche éphémère

La structure de base des données est une TranslateTask -une petite unité d'exécution atomique appuyée par une 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);

Il contient:

  • Un ID unique ( correspond à l’ensemble du flux )
  • Un indicateur de temps (au moment où il a commencéM SK1
  • Données métadonnées ( langage -sans contenu de l'utilisateur!)
  • Une référence à la Task<TaskCompletion>
  • Durée ( calculée à l’aide d’un accès )
  • état d’erreur ( dérivée de l’état des tâches)
  • Résultat final (seulement si rempli avec succèsM SK1

Que ce n’est pas le cas.

AWS a des fonctions étapenelles. Azure a des fonctionnalités durables . Vous disposez de lignes de code qui ne nécessitent pas une facture en nuage'


Modèle de source d'achèvement des tâches -Bridging Fire-andM SK2Forget to Fire -and+-Track

La magie se produit dans le BackgroundTranslateService. Au lieu du feu, nous utilisons un Source d'achèvement de la tâche -une promesse -construction similaire qui nous permet de revenir immédiatement alors que le travail se déroule dans l'arrière-plan.

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

Quelle source de clôture d'une tâche est-elle?

Si vous n’avez pas ' utilisé TaskCompletionSource<T> avant de le considérer comme un Je vous promets de contrôler manuellement. Contrairement à un ordinaire Task qui se termine lorsque son travail est terminé, un SCT s’achève lorsque vous établissement SetResult(), SetException(), ou SetCanceled().

Cela vous permet:

  1. Retourner un Task à l'appelant immédiatement
  2. Faire le travail réel ailleurs (fréquemment threadM SK1service de fond, etcMSC3
  3. Remplir la tâche lorsque vous Décider qu’elle est terminée

Il s’agit de la passerelle entre ", feu, - et -, oublier " et ", feu.


La couche de l'API -Le lancement d'une traduction

Lorsqu'un utilisateur clique sur "Translate",, l'interface appelle l'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'
    });
});

L'API retourne immédiatement avec un seul ID de tâche:

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

La réponse est instantanée. La traduction réelle se fait en arrière-plan. L'utilisateur peut faire un sondage ou simplement regarder la mise à jour de l'interface.


La fenêtre d'opération de roulement -A Self-Cleaning Buffer

Le principal point de vue tiré du Article LRU s’applique aussi ici:

Gardez juste assez d'historique pour débuger et expliquer le système… et laissez la détérioration naturelle supprimer tout le reste.

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

Chaque nouvelle tâche:

  • est ajouté à la fenêtre de roulement de l'utilisateur'
  • Expulse le plus vieux si nous 're à la capacité (5 maxM SK2
  • Évaporation totale après 6 heures (absolueM SK2 ou 1 heure d'inactivité SSK4sliding)
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

Aucun risque de conservation. Aucun PII dans le cache (juste les ID des tâchesM SK2 taches horairesMST3 et codes linguistiquesMSC4 Pas d'erreur en matière d'application de la GDPR MST5


Concurrence limitée -The Governor Loop

Le service de base met en œuvre un coïncidence fixe le cycle en utilisant un lecteur de canaux et 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");
        }
    }
}

Il s’agit d’un Gouverneur d'un moteur à vapeur: plus de charge → accroissement de la pression de l'arrière

Le modèle vous donne:

  • Aucune spirale de surcharge -can't spawn unlimited tasks
  • Modèle de comportement du temps réel - latence limitée
  • Élimination naturelle des éruptions -le canal tamponne les pics
  • Stability under load - se dégrade gracieusement

Compléter la source de fin des tâches

Lorsque le travail de traduction est terminé, nous complétons la 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);
    }
}

L’appelant's Task<TaskCompletion> -qui ont été reçus immédiatement lorsqu’ils ont appelé Translate() -actuellement s’achève. Ils peuventM SK2

  • await si elles veulent bloquer
  • Vérifier IsCompleted d'enquêter
  • Vérifier IsFaulted pour voir s'il a échoué
  • Vérifier Result pour obtenir le contenu traduit

La projection de l’état -État dérivant de l'État de tâche

Lors de l’affichage des tâches à l’utilisateur,, nous projetons l’état de la tâche en direct dans un modèle de vue:

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

Note sur l'état de la tâche

.NET's Task a quelques combinaisons d’états étranges:

  • IsCompleted est vrai pour les deux exécution réussie ET tâches défectueuses
  • IsCompletedSuccessfully c'est seulement vrai pour le succès
  • IsFaulted signifie qu'il a lancé une exception
  • IsCanceled signifie qu'il a été annulé

Vous devez donc vérifier IsFaulted avant la vérification IsCompleted, ou vous ' traitez les défaillances comme des succèsM SK2


L’interface -HTMX Poll

Les sondages de vue pour les mises à jour utilisant 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>

Le bit intelligent: hx-trigger="@trigger" changements fondés sur l’état:

  • Si les tâches sont encore en cours: sondage tous 5 secondes
  • Si toutes les tâches sont accomplies: cesser de voter M SK1"none")

Il s'agit de auto-vote réglementaire -il s’arrête automatiquement lorsqu’il n’y a pas de choses à regarder


Récupération du résultat

Lorsque l'utilisateur clique sur "View", nous obtenons la traduction complétée

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

Le JavaScript remplit ensuite l’éditeur:

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

Pourquoi cela est une protection de la vie privée-Safe

Parce:

  • Aucun contenu d'utilisateur n'est stocké dans le cache -métadonnées de tâches seulement (ID,marquage du tempsM SK3 langageMSC4
  • Le contenu n’existe que dans le résultat de Task'. -qui est en mémoire, attaché à la tâche
  • La fenêtre est limitée -max 5 tâches par utilisateur, max 6 retenue des heures
  • Tout se décompose naturellement -sliding expiration nettoie les utilisateurs inactifs
  • Rien ne touche le disque -pas de registresM SK1 pas de couloirs , aucune base de données
  • Vous ne pouvez pas retenir accidentellement PII -il y a 'il n'y a pas de place pour aller

C'est l'architecture J'aimerais que les navigateurs et les cadres de front-end aient été utilisés pour les opérations de session.


Le comportement émergent

Ce que vous obtiendrez sans trop réfléchir:

Avantage MSC1 Comment SMC2
La stabilité Compétence limitée par voie de canal + Modèle sémaphore
Rétroaction négative Plus de charge → pression d'arrière MSC2 mémoire limitée MSC3 comportement cohérent SMC4
Visibilité de débugging Les opérations exactes sont présentes assez longtemps pour être inspectées.
Récupérabilité Les résultats obtenus sont accessibles jusqu’à leur expiration
Rétention de données zéro La protection des renseignements personnels et la simplicité sont alignées pour une fois
auto-optimisation de l'exécution Les tâches anciennes disparaissent, celles pertinentes restent

Tout comme le cache LRU a affiné la mémoire comportementale,, la fenêtre d’opérations de roulement affine la vue d’exécution.


Quand utiliser ce modèle

Utilisez-le lorsque:

  • Les données de l'utilisateur ne doivent pas persister
  • Les opérations sont courtes-livres (de secondes à minutesM SK2
  • Questions de débugging
  • Questions de protection des renseignements personnels
  • Questions liées à la stabilité de charge
  • Vous voulez une réponse instantanée + traitement de fond

Ne l’utilisez pas lorsque

  • Vous avez besoin d'une durabilité réelle (utilisez une queue réelle)
  • Les opérations prennent des heures (utiliser Hangfire ou similaireM SK1
  • Vous avez besoin de la sémantique exacte-une seule fois M SK1utiliser une transaction distribuée)
  • Plusieurs serveurs doivent coordonner (utiliser Redis ou un courtier de messages)

Conclusion -Exécution temporaire comme philosophie de conception

Si le Article LRU a été l'apprentissage en oublient, cette est sur exécuter par évaporation.

Le système se souvient exactement assez pour être utile -et rien de plusM SK1

Il n’y a pas de différence entre le nombre d’heures de travail et la durée des heures de travail.

  • bounded
  • déterministe
  • énergie privée
  • débugable
  • retrievable
  • auto- nettoyant
  • intégralité
  • et architecture-première

La meilleure chose? Vous savez déjà comment la mettre en œuvre: a TaskCompletionSource, a Channel, an IMemoryCache avec échéance de sliding, et un travailleur d’arrière-plan.

Fire… et DonM SK1t Quite Forget.


Qu’est-ce que's Suivant?

À l'intérieur Partie 2: Établissement d'une bibliothèque réutilisable d'exécution temporaire, nous ' ferons de ce modèle une chuteM SK2 dans l’aideur:

  • EphemeralForEachAsync<T> - comme Parallel.ForEachAsync mais avec le suivi des opérations
  • Lignes de conduite clés pour l'exécution séquentielle d'une entité per-
  • EphemeralWorkCoordinator<T> - une longue queue de travail observable
  • Nommés/coordonnateurs typés avec AddEphemeralWorkCoordinator<TCoordinator> (comme AddHttpClient)
  • Intégration complète du DI avec des durées de vie de portée et d'un seul ton
  • Comparaison avec d'autres approches (TPL Dataflow , ChannelsM SK2 Background Services)

Liens

Finding related posts...
logo

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