Fuego y no lo hagas Bastante. Olvidar - Ejecución efímera sin Estado (Español (Spanish))

Fuego y no lo hagas Bastante. Olvidar - Ejecución efímera sin Estado

Friday, 12 December 2025

//

14 minute read

La mayoría de los sistemas async recuerdan demasiado (logs, colas, basura persistente que nunca quisiste)... o no recuerdan nada en absoluto (agujeros negros de fuego y olvido que desaparecen en el momento en que algo sale mal).

Este artículo introduce algo diferente:

Un patrón donde cada operación asíncrona se convierte en una diminuta rastreable, inspeccionable, recuperable, Unidad de tareas de auto-limpieza -y todo el sistema permanece privado, limitado, rápido y determinista.

Es el compañero de mi artículo anterior, **Aprender LRUs - Cuando la superación de la capacidad mejora su sistema**Que uno exploró cómo la memoria limitada + la expiración deslizante se convierte en un mecanismo de supervivencia.

Esta explora la otra mitad: ejecución efímera limitada -cómo un pequeño búfer móvil de tareas activas se convierte en un depurador, un registro de eventos y un motor de repetición, sin guardar ningún dato del usuario.

El ejemplo viene del widget de traducción de mi blog - una pequeña interfaz de usuario para traducir contenido Markdown sobre la marcha. Parece trivial. Bajo el capó, implementa patrones que puedes robar para cualquier flujo de trabajo asíncrono.

Esto es Parte 1 de una serie de dos partes:

Esto está ahora en el paquete Nuget mayoritariamente lucid.efemerals también más de 20 patrones mayoritariamente lucid.efemerals y 'átomos'.

NuGet Licencia


El problema -Los flujos de trabajo de Async crean estado oculto

Los desarrolladores de ASP.NET tienden a elegir entre tres malas opciones:

Fuego y olvido

Tú Task.Run() algo, espero que termine, y cuando explota no consigues nada. Sin correlación, sin reconstrucción, ni idea de lo que falló.

Fuego y espera

Bloqueas hilos que no deberías bloquear, el rendimiento muere, la latencia explota, tus usuarios te odian.

Colas/canales por todas partes

Ahora tienes:

  • colas sin límite
  • estado persistente que no pretendías mantener
  • PII accidentalmente almacenado en algún lugar
  • un sistema distribuido donde sólo quería una operación simple

Rastreo distribuido

Útil... pero externo. Además: no puede reproducir nada y a menudo filtra datos que nunca tuvo la intención de almacenar.

Lo que realmente queremos es:

  • de corta duración
  • privado
  • debugable
  • inspeccionable
  • limitada
  • recuperable si es necesario
  • y luego se fue.

Un "lo suficientemente largo" memoria de lo que el sistema está haciendo - y nada más.


El patrón - Incendio y No lo hagas del todo. Olvídelo.

Esta es la idea en una frase:

Iniciar una operación asíncrona inmediatamente, rastrearla explícitamente a través de una TaskCompletionSource, limpiarla determinísticamente y mantener un pequeño búfer de rodadura de las últimas tareas para que pueda inspeccionarlas o recuperarlas, sin retener ningún contenido de usuario.

Se encuentra a mitad de camino entre:

  • abastecimiento de eventos
  • rastreo
  • colas de trabajo
  • futuros/promesas
  • sesiones efímeras

...sin convertirse en ninguno de ellos.

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

Anatomía de una tarea efímera

La estructura básica de los datos es una TranslateTask -una pequeña unidad de ejecución atómica respaldada por un 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);

Contiene:

  • Una identificación única (correla a través de todo el flujo)
  • Una marca de tiempo (cuando empezó)
  • Metadatos (idioma - no contenido de usuario!)
  • Una referencia a la realidad Task<TaskCompletion>
  • Duración (computada en el acceso)
  • Estado de error (derivado del estado de tarea)
  • Resultado final (solo si se ha completado correctamente)

Nada ha persistido, nada escrito en disco, nada almacenado más allá de una ventana móvil.

AWS tiene funciones de paso. Azure tiene funciones duraderas. Tienes... 30 líneas de código que no requieren una factura de nube.


El patrón TaskCompletionSource -Bridging Fire-and-Olvide to Fire-and-Track

La magia sucede en el BackgroundTranslateService. En lugar de fuego-y-olvidar, usamos un TaskCompletionSource -una construcción prometedora que nos permite regresar inmediatamente mientras el trabajo ocurre en el fondo.

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

¿Qué es una TaskCompletionSource?

Si no has usado TaskCompletionSource<T> antes, pensar en ello como un Promete que controlas manualmente. A diferencia de un regular Task que se completa cuando su trabajo termina, un TCS se completa cuando tú Llamada SetResult(), SetException(), o SetCanceled().

Esto le permite:

  1. Devolver un Task al que llama inmediatamente
  2. Haga el trabajo real en otro lugar (hilo diferente, servicio de fondo, etc.)
  3. Completar la tarea cuando tú Decidir que está hecho.

Es el puente entre "fuego y olvido" y "fuego y rastro".


La capa de API -Comenzando una traducción

Cuando un usuario hace clic en "Traducir", la interfaz llama a la 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'
    });
});

La API devuelve inmediatamente con sólo un ID de tarea:

// 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 respuesta es instantánea. La traducción real se ejecuta en segundo plano. El usuario puede hacer una encuesta o simplemente ver la actualización de la interfaz de usuario.


La ventana de operación de rodadura - Un amortiguador de auto-limpieza

La perspicacia clave de la Artículo LRU se aplica también en este caso:

Mantenga la historia suficiente para depurar y razonar sobre el sistema... y dejar que la decadencia natural borre todo lo demás.

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

Cada nueva tarea:

  • Se añade a la ventana de rodadura del usuario
  • Expulsa el más viejo si estamos en capacidad (5 máx.)
  • Evapora completamente después de 6 horas (absoluta) o 1 hora de inactividad (deslizante)
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

No hay riesgo de retención. No hay PII en la caché (sólo identificaciones de tareas, marcas de tiempo y códigos de idioma).


La Condición Delimitada - El Loop Gobernador

El servicio de antecedentes aplica un Condición fija bucle usando un lector de canales y 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");
        }
    }
}

Esto es como un gobernador en una máquina de vapor: más carga → contrapresión construye → estrangulamiento natural → estabilidad.

El patrón le da:

  • Sin espiral de sobrecarga -no puede generar tareas ilimitadas
  • Comportamiento suave en tiempo real -latencia limitada
  • Alisado natural de ráfagas -el canal amortigua los picos
  • Estabilidad bajo carga - Se degrada con gracia.

Completando la TaskCompletionSource

Cuando termine el trabajo de traducción real, completamos el 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);
    }
}

La persona que llamó Task<TaskCompletion> - que recibieron inmediatamente cuando llamaron Translate() - Ahora se completan.

  • await si quieren bloquearlo
  • Comprobar IsCompleted a encuesta
  • Comprobar IsFaulted para ver si falló.
  • Comprobar Result para obtener el contenido traducido

Proyección del estado - Estado descendente del estado de tarea

Al mostrar tareas al usuario, proyectamos el estado de tarea en vivo en un modelo de vista:

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

Nota sobre el estado de la tarea

.NET's Task tiene algunas combinaciones peculiares de estado:

  • IsCompleted es cierto para ambos Finalización exitosa Y tareas fallidas
  • IsCompletedSuccessfully sólo es verdad para el éxito
  • IsFaulted significa que lanzó una excepción
  • IsCanceled significa que se canceló.

Así que debes comprobarlo. IsFaulted antes de comprobar IsCompleted, o tratarás los fracasos como éxitos.


La encuesta UI-HTMX

Las encuestas de visualización para las actualizaciones utilizando HTMX 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>

La parte inteligente: hx-trigger="@trigger" cambios basados en el estado:

  • Si las tareas siguen en marcha: encuesta cada 5 segundos
  • Si se realizan todas las tareas: dejar de votar ("none")

Esto es votación autorreguladora - se detiene automáticamente cuando no hay nada que ver.


Recuperando el resultado

Cuando el usuario hace clic en "Ver", se obtiene la traducción completa:

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

El JavaScript entonces pobla el 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);
        });
}

Por qué esto es seguro para la privacidad

Porque:

  • No hay contenido de usuario almacenado en la caché -sólo metadatos de tareas (ID, marca de tiempo, idioma)
  • El contenido sólo existe en el resultado de la tarea -que está en la memoria, adjunta a la tarea
  • La ventana está delimitada -max 5 tareas por usuario, máximo 6 horas de retención
  • Todo decae naturalmente. -espiración deslizante limpia usuarios inactivos
  • Nada toca el disco -sin registros, sin colas, sin base de datos
  • No se puede retener accidentalmente PII - No hay a dónde ir.

Esta es la arquitectura Desearía que los navegadores y los marcos de interfaz se hubieran utilizado para las operaciones de sesión.


El comportamiento emergente

Lo que se obtiene sin pensar demasiado:

Beneficio # # Cómo

|---------|-----| | Estabilidad Condición limitada vía canal + patrón de semáforo | Lazo de retroalimentación negativo Más carga → presión de espalda → memoria limitada → comportamiento consistente | Visibilidad de depuración Las operaciones exactas están presentes el tiempo suficiente para inspeccionar | Capacidad de obtención Resultados completados son accesibles hasta que expiran | Cero retención de datos Privacidad y simplicidad alineadas por una vez | Ejecución autooptimizada Las viejas tareas caen, las relevantes permanecen.

Al igual que la memoria caché LRU agudizó la memoria de comportamiento, la ventana de operación de rodadura agudiza la vista de ejecución.


Cuándo usar este patrón

Úselo cuando:

  • Los datos del usuario no deben persistir
  • Las operaciones son de corta duración (segundos a minutos)
  • Asuntos de depuración
  • Cuestiones relativas a la privacidad
  • La estabilidad de la carga importa
  • Quiere respuesta instantánea + procesamiento de fondo

No lo uses cuando:

  • Necesitas una verdadera durabilidad (usar una cola real)
  • Las operaciones tardan horas (utilizan Hangfire o similares)
  • Usted necesita semántica exactamente una vez (usar una transacción distribuida)
  • Múltiples servidores necesitan coordinarse (usar Redis o un corredor de mensajes)

Conclusión - La ejecución efímera como filosofía de diseño

Si la Artículo LRU era sobre aprender olvidando, esta es sobre ejecutándose mediante evaporación.

El sistema recuerda exactamente lo suficiente para ser útil - y nada más.

Es:

  • limitada
  • determinista
  • privado
  • debugable
  • recuperable
  • autolimpieza
  • robusto
  • y arquitectura-primero

¿Lo mejor? Ya sabes cómo implementarlo: un TaskCompletionSource, a Channel, un IMemoryCache con la expiración deslizante, y un trabajador de fondo.

Fuego... y no lo olvides.


¿Qué sigue?

In Parte 2: Construcción de una biblioteca de ejecución efímera reutilizable, vamos a convertir este patrón en un ayudante:

  • EphemeralForEachAsync<T> - como Parallel.ForEachAsync pero con seguimiento de operación
  • Tuberías con llave para la ejecución secuencial per-entidad
  • EphemeralWorkCoordinator<T> - una cola de trabajo observable de larga duración
  • Coordinadores designados o mecanografiados con AddEphemeralWorkCoordinator<TCoordinator> (como AddHttpClient)
  • Integración DI completa con vida útil con alcance y singleton
  • Comparación con otros enfoques (TPL Dataflow, Channels, Background Services)

Vínculos

Finding related posts...
logo

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