TaskHandler 2.4.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package TaskHandler --version 2.4.1
                    
NuGet\Install-Package TaskHandler -Version 2.4.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TaskHandler" Version="2.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TaskHandler" Version="2.4.1" />
                    
Directory.Packages.props
<PackageReference Include="TaskHandler" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TaskHandler --version 2.4.1
                    
#r "nuget: TaskHandler, 2.4.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TaskHandler@2.4.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TaskHandler&version=2.4.1
                    
Install as a Cake Addin
#tool nuget:?package=TaskHandler&version=2.4.1
                    
Install as a Cake Tool

<img src="https://raw.githubusercontent.com/jchristn/TaskHandler/main/Assets/logo.png" width="250" height="250">

TaskHandler

NuGet Version NuGet

TaskHandler is a simple, lightweight C# library for managing asynchronous task queues with precise concurrency control. It provides a clean API for queuing tasks, controlling how many run concurrently, and monitoring their lifecycle, all without relinquishing control of your application.

Table of Contents

What is TaskHandler?

TaskHandler helps you manage concurrent asynchronous operations in scenarios where you need to:

  • Control concurrency: Limit how many tasks run simultaneously to prevent resource exhaustion
  • Queue tasks: Add tasks to a queue that automatically processes them as capacity becomes available
  • Monitor execution: Track task lifecycle through events (added, started, completed, faulted, canceled)
  • Maintain control: Unlike fire-and-forget approaches, TaskHandler gives you full visibility and control

Key Benefits

🎯 Precise Concurrency Control

Set a maximum number of concurrent tasks (e.g., 10 simultaneous operations) and TaskHandler ensures this limit is never exceeded. Perfect for:

  • Rate-limiting API calls
  • Managing database connection pools
  • Controlling parallel file I/O operations
  • Throttling resource-intensive operations

🔄 Automatic Queue Management

Tasks are automatically dequeued and executed as soon as capacity becomes available. No manual thread management or complex coordination code required.

📊 Complete Visibility

Monitor every stage of task execution with built-in events:

  • OnTaskAdded: When a task enters the queue
  • OnTaskStarted: When a task begins execution
  • OnTaskFinished: When a task completes successfully
  • OnTaskFaulted: When a task throws an exception
  • OnTaskCanceled: When a task is canceled (each task raises exactly one of Finished, Faulted, or Canceled)
  • Built-in OpenTelemetry-compatible metrics and traces (queue depth, concurrency in use, per-stage latency, outcomes, and errors by type) for Prometheus, Tempo, and Grafana. See Observability.

🛡️ Robust Error Handling

  • Tasks that fault don't crash your application
  • Exceptions in event handlers are caught and logged
  • Safe disposal ensures clean shutdown
  • State guards prevent invalid operations

⏱️ Timeout Support

Built-in TaskRunWithTimeout utility for running any task with a timeout, automatically canceling tasks that exceed their time limit.

Use Cases

API Rate Limiting

using System;
using TaskHandler;

// Limit to 5 concurrent API calls to respect rate limits
using (TaskQueue apiQueue = new TaskQueue(5))
{
    apiQueue.Start();

    foreach (var item in itemsToProcess)
    {
        apiQueue.AddTask(
            Guid.NewGuid(),
            $"Process-{item.Id}",
            null,
            async (token) => await ProcessApiCall(item, token)
        );
    }
}

Batch Database Operations

using System;
using System.Collections.Generic;
using TaskHandler;

// Process 1000 records with only 10 concurrent database connections
using (TaskQueue dbQueue = new TaskQueue(10))
{
    dbQueue.Logger = Console.WriteLine;
    dbQueue.Start();

    foreach (var record in records)
    {
        dbQueue.AddTask(
            Guid.NewGuid(),
            $"Save-{record.Id}",
            new Dictionary<string, object> { { "RecordId", record.Id } },
            async (token) => await SaveToDatabase(record, token)
        );
    }
}

Parallel File Processing

using System;
using System.IO;
using TaskHandler;

// Process files with 8 concurrent operations
using (TaskQueue fileQueue = new TaskQueue(8))
{
    fileQueue.OnTaskFinished += (sender, task) =>
    {
        Console.WriteLine($"Completed: {task.Name}");
    };
    fileQueue.Start();

    foreach (var file in filesToProcess)
    {
        fileQueue.AddTask(
            Guid.NewGuid(),
            $"Process-{Path.GetFileName(file)}",
            null,
            async (token) => await ProcessFile(file, token)
        );
    }
}

Web Scraping with Throttling

using System;
using TaskHandler;

// Scrape websites with controlled concurrency to be respectful
using (TaskQueue scrapeQueue = new TaskQueue(3))
{
    scrapeQueue.Start();

    foreach (var url in urlsToScrape)
    {
        scrapeQueue.AddTask(
            Guid.NewGuid(),
            $"Scrape-{url}",
            null,
            async (token) => await ScrapeWebsite(url, token)
        );
    }
}

Installation

Install via NuGet:

dotnet add package TaskHandler

Or using the Package Manager Console:

Install-Package TaskHandler

Quick Start

Basic Usage

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TaskHandler;

// Create a queue allowing 16 concurrent tasks (default is 32)
using (TaskQueue queue = new TaskQueue(16))
{
    // Add tasks to the queue
    for (int i = 0; i < 100; i++)
    {
        int taskNum = i;
        queue.AddTask(
            Guid.NewGuid(),
            $"Task-{taskNum}",
            new Dictionary<string, object> { { "TaskNumber", taskNum } },
            async (CancellationToken token) =>
            {
                Console.WriteLine($"Task {taskNum} starting");
                await Task.Delay(1000, token); // Simulate work
                Console.WriteLine($"Task {taskNum} completed");
            }
        );
    }

    // Start processing the queue
    queue.Start();

    // Check status
    Console.WriteLine($"Running: {queue.RunningCount}");
    Console.WriteLine($"Queued: {queue.QueuedCount}");

    // Wait for user input, then stop
    Console.ReadLine();
    queue.Stop();
}

With Event Handlers

using System;
using TaskHandler;

using (TaskQueue queue = new TaskQueue(10))
{
    // Optional: Enable logging
    queue.Logger = Console.WriteLine;

    // Track task lifecycle
    queue.OnTaskAdded += (sender, task) =>
    {
        Console.WriteLine($"[ADDED] {task.Name}");
    };

    queue.OnTaskStarted += (sender, task) =>
    {
        Console.WriteLine($"[STARTED] {task.Name} at {DateTime.Now}");
    };

    queue.OnTaskFinished += (sender, task) =>
    {
        Console.WriteLine($"[FINISHED] {task.Name}");
    };

    queue.OnTaskFaulted += (sender, task) =>
    {
        Console.WriteLine($"[FAULTED] {task.Name}");
        if (task.Task.Exception != null)
        {
            Console.WriteLine($"  Error: {task.Task.Exception.InnerException?.Message}");
        }
    };

    queue.OnTaskCanceled += (sender, task) =>
    {
        Console.WriteLine($"[CANCELED] {task.Name}");
    };

    queue.OnProcessingStarted += (sender, e) =>
    {
        Console.WriteLine("[QUEUE] Processing started");
    };

    queue.OnProcessingStopped += (sender, e) =>
    {
        Console.WriteLine("[QUEUE] Processing stopped");
    };

    // Start processing and add tasks...
}

Canceling Tasks

using System;
using System.Threading.Tasks;
using TaskHandler;

using (TaskQueue queue = new TaskQueue())
{
    // Cancel a specific task by GUID
    Guid taskId = Guid.NewGuid();
    queue.AddTask(taskId, "LongRunningTask", null, async (token) =>
    {
        await Task.Delay(10000, token); // Will be canceled before completion
    });

    queue.Start();
    await Task.Delay(2000);

    // Cancel the specific task (works for running and still-queued tasks)
    queue.Stop(taskId);

    // Or cancel all running tasks; tasks not yet started are kept for the next Start()
    queue.Stop();
}

Task with Timeout

TaskRunWithTimeout.Go<T> accepts any Task<T> regardless of the function signature that created it:

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using TaskHandler;

// Example 1: Simple Task<string>
using (CancellationTokenSource cts = new CancellationTokenSource())
{
    Func<CancellationToken, Task<string>> quickTask = async (token) =>
    {
        await Task.Delay(500, token);
        return "Success";
    };

    string result = await TaskRunWithTimeout.Go(quickTask(cts.Token), 2000, cts);
    Console.WriteLine(result); // "Success"
}

// Example 2: Multiple input parameters, different output type
using (CancellationTokenSource cts = new CancellationTokenSource())
{
    Func<string, int, CancellationToken, Task<double>> complexTask = async (name, count, token) =>
    {
        await Task.Delay(100, token);
        double result = count * 3.14;
        Console.WriteLine($"Processing {name}");
        return result;
    };

    double value = await TaskRunWithTimeout.Go(complexTask("test", 10, cts.Token), 1000, cts);
    Console.WriteLine(value); // 31.4
}

// Example 3: HTTP request with complex signature
using (CancellationTokenSource cts = new CancellationTokenSource())
{
    Func<HttpClient, string, int, CancellationToken, Task<string>> fetchTask = async (client, url, retries, token) =>
    {
        for (int i = 0; i < retries; i++)
        {
            try
            {
                return await client.GetStringAsync(url);
            }
            catch when (i < retries - 1)
            {
                await Task.Delay(100, token);
            }
        }
        throw new Exception("All retries failed");
    };

    using (HttpClient client = new HttpClient())
    {
        string html = await TaskRunWithTimeout.Go(
            fetchTask(client, "https://example.com", 3, cts.Token),
            5000,
            cts
        );
    }
}

// Example 4: Custom class return type
public class ProcessResult
{
    public int ItemsProcessed { get; set; }
    public TimeSpan Duration { get; set; }
}

using (CancellationTokenSource cts = new CancellationTokenSource())
{
    Func<List<string>, bool, CancellationToken, Task<ProcessResult>> processTask = async (items, parallel, token) =>
    {
        DateTime start = DateTime.UtcNow;
        // Process items...
        await Task.Delay(200, token);
        return new ProcessResult
        {
            ItemsProcessed = items.Count,
            Duration = DateTime.UtcNow - start
        };
    };

    ProcessResult result = await TaskRunWithTimeout.Go(
        processTask(myItems, true, cts.Token),
        3000,
        cts
    );
    Console.WriteLine($"Processed {result.ItemsProcessed} items in {result.Duration.TotalMilliseconds}ms");
}

// Example 5: Task that exceeds timeout
using (CancellationTokenSource cts = new CancellationTokenSource())
{
    Func<CancellationToken, Task<string>> slowTask = async (token) =>
    {
        await Task.Delay(5000, token);
        return "Should not reach here";
    };

    try
    {
        string result = await TaskRunWithTimeout.Go(slowTask(cts.Token), 1000, cts);
    }
    catch (TimeoutException ex)
    {
        Console.WriteLine($"Task timed out: {ex.Message}");
    }
}

API Reference

TaskQueue Class

Constructors
TaskQueue(int maxConcurrentTasks = 32, int maxQueueSize = -1)
TaskQueue(IQoSQueue<TaskDetails> scheduler, int maxConcurrentTasks = 32)
TaskQueue(TaskQueueOptions options)

The first creates a FIFO queue with a concurrency limit and optional queue size limit. The second hands start order (and capacity) to a QoSKit queue; see Scheduling. The third takes either form through TaskQueueOptions (Scheduler and MaxQueueSize cannot both be set).

Properties
Property Type Description
MaxConcurrentTasks int Maximum number of tasks that can run concurrently. Minimum: 1, Default: 32. Can be changed at any time: raising it starts waiting tasks immediately; lowering it never interrupts running tasks and applies as they finish
MaxQueueSize int Maximum number of tasks waiting to start. -1 for unbounded (default). Can only be changed before the queue is first started or given a task (a later change throws InvalidOperationException). With a scheduler, reports the scheduler's MaxDepth and cannot be set
Scheduler IQoSQueue<TaskDetails> The QoSKit queue deciding start order, or null for FIFO (read-only; for inspection only)
RunningCount int Number of currently running tasks (read-only)
QueuedCount int Number of tasks accepted but not yet started, including tasks waiting for a concurrency slot and tasks held while the queue is stopped (read-only)
RunningTasks ConcurrentDictionary<Guid, TaskDetails> Dictionary of currently running tasks (read-only)
IsRunning bool True while the queue is started (not stopped or disposed) and its background runner is active (read-only)
Logger Action<string> Optional callback for log messages
Methods
Method Description
AddTask(Guid guid, string name, Dictionary<string, object> metadata, Func<CancellationToken, Task> func) Adds a task without waiting. Works whether or not the queue is started. Returns TaskDetails. Throws InvalidOperationException if a bounded queue is full and ObjectDisposedException after dispose
AddTaskAsync(Guid guid, string name, Dictionary<string, object> metadata, Func<CancellationToken, Task> func, CancellationToken cancellationToken) Adds a task. Works whether or not the queue is started. Waits for space if a bounded queue is full (even while stopped). Returns Task<TaskDetails>. Throws ObjectDisposedException after (or during) dispose
Start() Starts processing. Tasks already queued, including tasks retained by a previous Stop(), start in the order they were added. A stopped queue can be started again
StartAsync(CancellationToken cancellationToken) Same as Start(). Returns Task
Stop() Cancels all running tasks and stops starting new ones. Tasks that have not started are kept and new tasks can still be added; they run after the next Start()
Stop(Guid guid) Cancels one task, whether running or still queued (also while stopped). A queued task never runs and its TaskHandle<T> is canceled immediately
StopAsync(bool waitForCompletion, CancellationToken cancellationToken) Same as Stop(). With waitForCompletion: true, also waits until the runner has exited and every canceled task has finished
WaitForCompletionAsync(CancellationToken cancellationToken) Waits until no tasks are queued or running. On a stopped queue that still holds tasks it waits until the queue is started and they finish (or the token is canceled)
Dispose() Cancels running tasks and drops tasks that have not started (each completes as canceled: handle canceled, OnTaskCanceled fired, counted in TotalCanceled). Does not wait. Safe to call more than once
DisposeAsync() Stops, waits for running tasks to finish responding to cancellation, then disposes as above. Returns ValueTask. Safe to call more than once
Events
Event Type Description
OnTaskAdded EventHandler<TaskDetails> Fired when a task is added to the queue
OnTaskStarted EventHandler<TaskDetails> Fired when a task begins execution
OnTaskFinished EventHandler<TaskDetails> Fired when a task's function completes successfully (including one that catches cancellation and returns normally)
OnTaskFaulted EventHandler<TaskDetails> Fired when a task's function throws (including an EnqueueAsync timeout)
OnTaskCanceled EventHandler<TaskDetails> Fired when a task ends canceled: its function observed cancellation, it was canceled with Stop(guid) before starting, or it was dropped by Dispose() before starting
OnProcessingStarted EventHandler Fired on each Start()
OnProcessingStopped EventHandler Fired once per Stop(), on Dispose() of a started queue, or if the background runner fails

Every task raises exactly one of OnTaskFinished, OnTaskFaulted, or OnTaskCanceled. Cancellation requests (from Stop() or Stop(guid)) do not raise an event themselves; the task's terminal event follows when it ends.

TaskDetails Class

Represents metadata and state for a queued or running task.

Property Type Description
Guid Guid Unique identifier for the task
Name string User-supplied name for the task
Metadata Dictionary<string, object> User-supplied metadata
Function Func<CancellationToken, Task> The async function to execute
Task Task The running Task instance (null until started)
TokenSource CancellationTokenSource Cancellation token source for this task
Token CancellationToken Cancellation token for this task

TaskRunWithTimeout Class

Static utility for running tasks with timeout constraints.

Properties
Property Type Description
Logger Action<string> Optional callback for log messages
LogHeader string Prefix for log messages. Default: "[TaskRunWithTimeout] "
Methods
Task<T> Go<T>(Task<T> task, int timeoutMs, CancellationTokenSource tokenSource)

Executes a task with the specified timeout in milliseconds. Throws TimeoutException if the task exceeds the timeout.

Using Async API

using System;
using System.Threading.Tasks;
using TaskHandler;

// Create queue with backpressure (max 100 queued tasks)
using (TaskQueue queue = new TaskQueue(maxConcurrentTasks: 10, maxQueueSize: 100))
{
    // Start asynchronously
    await queue.StartAsync();

    // Add tasks asynchronously (waits if queue is full)
    for (int i = 0; i < 1000; i++)
    {
        await queue.AddTaskAsync(
            Guid.NewGuid(),
            $"Task-{i}",
            null,
            async (token) =>
            {
                await ProcessItemAsync(i, token);
            }
        );
    }

    // Wait for all tasks to complete
    await queue.WaitForCompletionAsync();

    // Stop and cleanup
    await queue.StopAsync(waitForCompletion: true);
}

Backpressure and Queue Limits

Problem: If tasks arrive faster than they can be processed, unbounded queues can cause out-of-memory errors.

Solution: Use MaxQueueSize to limit queue depth:

using System;
using System.Threading.Tasks;
using TaskHandler;

// Bounded queue - prevents memory exhaustion
using (TaskQueue queue = new TaskQueue(
    maxConcurrentTasks: 5,
    maxQueueSize: 50  // Max 50 queued tasks
))
{
    queue.Start();

    // This will wait (async) or throw (sync) if queue is full
    try
    {
        queue.AddTask(guid, name, null, taskFunc);  // Throws if full
    }
    catch (InvalidOperationException)
    {
        Console.WriteLine("Queue is full, backing off...");
    }

    // OR use async version which waits instead of throwing
    await queue.AddTaskAsync(guid, name, null, taskFunc);  // Waits if full
}

Advanced Topics

Understanding Task Lifecycle

  1. Added: Task is enqueued via AddTask()
  2. Started: Task is dequeued and begins execution (when RunningCount < MaxConcurrentTasks)
  3. Terminal State: Task reaches one of three end states:
    • Finished: Completed successfully
    • Faulted: Threw an exception
    • Canceled: Was canceled via cancellation token

Concurrency Control Details

TaskHandler uses an event-driven architecture with System.Threading.Channels:

  1. Tasks are immediately available when added to the channel
  2. A semaphore (SemaphoreSlim) enforces the MaxConcurrentTasks limit
  3. Task completions trigger continuations that release semaphore slots
  4. New tasks start instantly when slots become available (sub-millisecond latency)
  5. Zero CPU usage when idle

Benefits of event-driven architecture:

  • Latency: <1ms (vs 50ms average in older polling-based implementations)
  • Throughput: 10-100x higher for short tasks
  • CPU: 0% idle
  • Responsiveness: Instant task execution

Exception Handling Best Practices

TaskHandler provides multiple layers of exception handling to ensure robust error management. Understanding when and how to use each mechanism is crucial for building reliable applications.

Overview of Exception Handling Mechanisms
  1. OnTaskFaulted Event - Global error handler for all task exceptions
  2. TaskHandle<T> Exception Propagation - Per-task exception handling via try-catch
  3. Timeout Exceptions - Automatic TimeoutException for tasks exceeding time limits
  4. Statistics Monitoring - Track failure rates and metrics
  5. Safe Event Handlers - Event handler exceptions don't crash the queue
When to Use Each Approach
Approach Use When Example Scenarios
OnTaskFaulted You need centralized error handling for all tasks Logging all failures, alerting, global retry logic
TaskHandle<T> try-catch You need to handle exceptions for specific tasks Critical operations, per-task error recovery, user notifications
Both Together You need both global monitoring AND per-task handling Production systems with centralized logging + specific error handling
Statistics You need to monitor overall health and failure rates Performance monitoring, SLA tracking, alerting thresholds
1. Global Error Handling with OnTaskFaulted

Use OnTaskFaulted for centralized error handling across all tasks:

using TaskHandler;

TaskQueue queue = new TaskQueue();

// Global error handler - fires for ALL task exceptions
queue.OnTaskFaulted += (sender, task) =>
{
    // Access the exception (wrapped in AggregateException)
    if (task.Task?.Exception != null)
    {
        // Task exceptions are wrapped, so use InnerException or InnerExceptions
        Exception innerEx = task.Task.Exception.InnerException;

        Console.WriteLine($"[ERROR] Task '{task.Name}' (ID: {task.Guid}) failed");
        Console.WriteLine($"  Exception Type: {innerEx?.GetType().Name}");
        Console.WriteLine($"  Message: {innerEx?.Message}");
        Console.WriteLine($"  Stack Trace: {innerEx?.StackTrace}");

        // Access metadata for context
        if (task.Metadata != null && task.Metadata.ContainsKey("UserId"))
        {
            Console.WriteLine($"  User ID: {task.Metadata["UserId"]}");
        }
    }
};

queue.Start();

// All tasks will trigger OnTaskFaulted on exception
await queue.EnqueueAsync(
    "DatabaseOperation",
    async (token) =>
    {
        throw new InvalidOperationException("Database connection failed");
    }
);

Important: Task exceptions are wrapped in AggregateException, so access the actual exception via:

  • task.Task.Exception.InnerException - for single exceptions
  • task.Task.Exception.InnerExceptions - for multiple exceptions (rare in TaskQueue)
2. Per-Task Error Handling with TaskHandle<T>

Use TaskHandle<T> with try-catch when you need to handle exceptions for specific tasks:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

try
{
    // Enqueue task that returns a result
    TaskHandle<string> handle = await queue.EnqueueAsync(
        "ApiCall",
        async (token) =>
        {
            // Simulate API call that might fail
            if (new Random().Next(2) == 0)
            {
                throw new HttpRequestException("API request failed");
            }
            return "Success";
        }
    );

    // Await the result - exception thrown here if task faulted
    string result = await handle.Task;
    Console.WriteLine($"Task succeeded: {result}");
}
catch (HttpRequestException ex)
{
    // Handle specific exception type
    Console.WriteLine($"API call failed: {ex.Message}");
    // Implement retry logic, fallback, or user notification
}
catch (OperationCanceledException)
{
    // Task was canceled
    Console.WriteLine("Task was canceled");
}
catch (Exception ex)
{
    // Catch-all for unexpected exceptions
    Console.WriteLine($"Unexpected error: {ex.Message}");
}

Note: Both OnTaskFaulted AND the exception propagation occur when using TaskHandle<T>.

Combine global and per-task error handling for robust error management:

using System;
using System.Threading.Tasks;
using TaskHandler;

// Global error handler for logging and monitoring
TaskQueue queue = new TaskQueue();

queue.OnTaskFaulted += (sender, task) =>
{
    // Centralized logging for ALL failures
    Logger.LogError(
        task.Task?.Exception?.InnerException,
        "Task {TaskName} (ID: {TaskId}) faulted",
        task.Name,
        task.Guid
    );

    // Send to monitoring system
    MonitoringSystem.RecordFailure(task.Name, task.Task?.Exception?.InnerException);
};

queue.Start();

// Per-task handling for specific error recovery
for (int i = 0; i < 100; i++)
{
    try
    {
        TaskHandle<string> handle = await queue.EnqueueAsync(
            $"ProcessItem-{i}",
            async (token) => await ProcessCriticalItem(i, token)
        );

        string result = await handle.Task;
        Console.WriteLine($"Item {i} processed: {result}");
    }
    catch (ValidationException ex)
    {
        // Handle validation errors - don't retry
        Console.WriteLine($"Item {i} validation failed: {ex.Message}");
    }
    catch (TransientException ex)
    {
        // Handle transient errors - implement retry
        Console.WriteLine($"Item {i} failed (transient), retrying...");
        await RetryItem(i);
    }
    catch (Exception ex)
    {
        // Unexpected exception - log and continue
        Console.WriteLine($"Item {i} failed unexpectedly: {ex.Message}");
    }
}

await queue.WaitForCompletionAsync();
4. Defensive Task Implementation

Implement error handling WITHIN your tasks for fine-grained control:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

await queue.EnqueueAsync(
    "RobustTask",
    async (token) =>
    {
        try
        {
            // Risky operation
            await RiskyDatabaseOperation(token);
        }
        catch (SqlException ex) when (ex.Number == 2601) // Duplicate key
        {
            // Handle specific SQL error - don't propagate
            Logger.LogWarning("Duplicate key detected, skipping");
            return; // Task completes successfully
        }
        catch (SqlException ex)
        {
            // Log SQL error with context
            Logger.LogError(ex, "Database operation failed");
            throw; // Re-throw to trigger OnTaskFaulted
        }
        catch (Exception ex)
        {
            // Log unexpected error
            Logger.LogError(ex, "Unexpected error in task");
            throw; // Re-throw to trigger OnTaskFaulted
        }
    }
);

Best Practice: Use try-catch within tasks when you need to:

  • Handle specific errors without failing the task
  • Add contextual logging before re-throwing
  • Clean up resources before propagating exceptions
  • Convert exceptions to domain-specific exceptions
5. Structured Logging with Full Exception Details

Implement comprehensive logging in your error handlers:

using Microsoft.Extensions.Logging;
using TaskHandler;

ILogger<MyService> logger = /* ... */;
TaskQueue queue = new TaskQueue();

queue.OnTaskFaulted += (sender, task) =>
{
    if (task.Task?.Exception != null)
    {
        Exception ex = task.Task.Exception.InnerException ?? task.Task.Exception;

        // Structured logging with all context
        logger.LogError(
            ex,
            "Task execution failed. " +
            "TaskName: {TaskName}, " +
            "TaskId: {TaskId}, " +
            "ExceptionType: {ExceptionType}, " +
            "Message: {Message}, " +
            "Metadata: {@Metadata}",
            task.Name,
            task.Guid,
            ex.GetType().FullName,
            ex.Message,
            task.Metadata
        );

        // Include stack trace for critical errors
        if (ex is CriticalException)
        {
            logger.LogCritical("Stack Trace: {StackTrace}", ex.StackTrace);
        }
    }
};
6. Statistics-Based Failure Monitoring

Monitor failure rates and queue health using statistics:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

// Enqueue tasks...
for (int i = 0; i < 1000; i++)
{
    await queue.EnqueueAsync($"Task-{i}", async (token) => await ProcessItem(i, token));
}

// Monitor periodically
while (queue.RunningCount > 0 || queue.QueuedCount > 0)
{
    TaskQueueStatistics stats = queue.GetStatistics();

    // Calculate failure rate
    double failureRate = stats.TotalEnqueued > 0
        ? (double)stats.TotalFailed / stats.TotalEnqueued
        : 0;

    // Alert on high failure rate
    if (failureRate > 0.10) // More than 10% failures
    {
        Logger.LogWarning(
            "High failure rate detected: {FailureRate:P} ({Failed}/{Total})",
            failureRate,
            stats.TotalFailed,
            stats.TotalEnqueued
        );

        // Take corrective action
        AlertingSystem.SendAlert($"TaskQueue failure rate: {failureRate:P}");
    }

    // Monitor for complete failure
    if (stats.TotalFailed > 0 && stats.TotalCompleted == 0)
    {
        Logger.LogCritical("All tasks are failing - possible systemic issue");
    }

    await Task.Delay(5000);
}

// Final statistics
TaskQueueStatistics finalStats = queue.GetStatistics();
Console.WriteLine($"Completed: {finalStats.TotalCompleted}");
Console.WriteLine($"Failed: {finalStats.TotalFailed}");
Console.WriteLine($"Canceled: {finalStats.TotalCanceled}");
Console.WriteLine($"Success Rate: {(double)finalStats.TotalCompleted / finalStats.TotalEnqueued:P}");
7. Retry Logic Pattern

Implement retry logic for transient failures:

using System;
using System.Threading.Tasks;
using TaskHandler;

public async Task<T> EnqueueWithRetry<T>(
    TaskQueue queue,
    string taskName,
    Func<CancellationToken, Task<T>> taskFunc,
    int maxRetries = 3,
    TimeSpan? retryDelay = null)
{
    retryDelay ??= TimeSpan.FromSeconds(2);

    for (int attempt = 0; attempt <= maxRetries; attempt++)
    {
        try
        {
            TaskHandle<T> handle = await queue.EnqueueAsync(taskName, taskFunc);
            T result = await handle.Task;
            return result; // Success
        }
        catch (Exception ex) when (IsTransientException(ex) && attempt < maxRetries)
        {
            Logger.LogWarning(
                "Task {TaskName} failed (attempt {Attempt}/{MaxAttempts}): {Message}",
                taskName,
                attempt + 1,
                maxRetries + 1,
                ex.Message
            );

            await Task.Delay(retryDelay.Value);
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Task {TaskName} failed permanently", taskName);
            throw; // Non-transient or max retries exceeded
        }
    }

    throw new InvalidOperationException("Retry logic failed");
}

private bool IsTransientException(Exception ex)
{
    return ex is HttpRequestException ||
           ex is TimeoutException ||
           (ex is SqlException sqlEx && new[] { 1205, -2 }.Contains(sqlEx.Number));
}

// Usage
try
{
    string result = await EnqueueWithRetry(
        queue,
        "ApiCall",
        async (token) => await CallExternalApi(token),
        maxRetries: 3,
        retryDelay: TimeSpan.FromSeconds(5)
    );
}
catch (Exception ex)
{
    Console.WriteLine($"All retries exhausted: {ex.Message}");
}
8. Circuit Breaker Pattern

Implement circuit breaker to prevent cascading failures:

using System;
using System.Threading.Tasks;
using TaskHandler;

public class CircuitBreaker
{
    private int _failureCount = 0;
    private DateTime? _openedAt = null;
    private readonly int _threshold;
    private readonly TimeSpan _timeout;

    public CircuitBreaker(int threshold = 5, TimeSpan? timeout = null)
    {
        _threshold = threshold;
        _timeout = timeout ?? TimeSpan.FromMinutes(1);
    }

    public bool IsOpen
    {
        get
        {
            if (_openedAt.HasValue && DateTime.UtcNow - _openedAt.Value > _timeout)
            {
                // Reset after timeout
                _failureCount = 0;
                _openedAt = null;
                return false;
            }
            return _failureCount >= _threshold;
        }
    }

    public void RecordFailure()
    {
        _failureCount++;
        if (_failureCount >= _threshold && !_openedAt.HasValue)
        {
            _openedAt = DateTime.UtcNow;
        }
    }

    public void RecordSuccess() => _failureCount = 0;
}

// Usage
CircuitBreaker circuitBreaker = new CircuitBreaker(threshold: 5, timeout: TimeSpan.FromMinutes(1));
TaskQueue queue = new TaskQueue();

queue.OnTaskFaulted += (sender, task) =>
{
    circuitBreaker.RecordFailure();
    Logger.LogWarning($"Circuit breaker failure count: {circuitBreaker._failureCount}");
};

queue.OnTaskFinished += (sender, task) =>
{
    circuitBreaker.RecordSuccess();
};

queue.Start();

// Check circuit breaker before enqueueing
for (int i = 0; i < 100; i++)
{
    if (circuitBreaker.IsOpen)
    {
        Logger.LogWarning("Circuit breaker is OPEN - skipping task");
        await Task.Delay(TimeSpan.FromSeconds(10)); // Back off
        continue;
    }

    await queue.EnqueueAsync($"Task-{i}", async (token) => await ProcessItem(i, token));
}
9. Event Handler Exception Safety

TaskHandler automatically catches exceptions in YOUR event handlers to prevent queue crashes:

TaskQueue queue = new TaskQueue();

// Logger captures event handler exceptions
queue.Logger = Console.WriteLine;

// This buggy event handler won't crash the queue
queue.OnTaskAdded += (sender, task) =>
{
    throw new Exception("Bug in event handler"); // Caught and logged
};

queue.OnTaskFaulted += (sender, task) =>
{
    int x = 0;
    int result = 10 / x; // Division by zero - caught and logged
};

queue.Start();

// Queue continues to function despite event handler bugs
await queue.EnqueueAsync("Task1", async (token) => await Task.Delay(100, token));

Output (via Logger):

[TaskHandler] exception in event handler: System.Exception: Bug in event handler
[TaskHandler] exception in event handler: System.DivideByZeroException: Attempted to divide by zero
10. Handling Multiple Exception Types

Process different exception types with specific handling:

using TaskHandler;

TaskQueue queue = new TaskQueue();

queue.OnTaskFaulted += (sender, task) =>
{
    Exception ex = task.Task?.Exception?.InnerException;

    switch (ex)
    {
        case TimeoutException timeoutEx:
            Logger.LogWarning("Task {TaskName} timed out after {Timeout}", task.Name, timeoutEx.Message);
            // Maybe retry with longer timeout
            break;

        case HttpRequestException httpEx:
            Logger.LogWarning("HTTP request failed for {TaskName}: {Message}", task.Name, httpEx.Message);
            // Maybe retry or use fallback
            break;

        case SqlException sqlEx:
            Logger.LogError(sqlEx, "Database error in {TaskName}", task.Name);
            // Maybe alert DBA
            break;

        case ValidationException validationEx:
            Logger.LogInformation("Validation failed for {TaskName}: {Message}", task.Name, validationEx.Message);
            // Don't alert - expected validation failure
            break;

        default:
            Logger.LogError(ex, "Unexpected error in {TaskName}", task.Name);
            // Alert for investigation
            break;
    }
};
Summary of Best Practices
  1. ✅ Always subscribe to OnTaskFaulted for centralized error logging and monitoring
  2. ✅ Use TaskHandle<T> with try-catch for tasks requiring specific error handling
  3. ✅ Implement defensive error handling within tasks when appropriate
  4. ✅ Monitor statistics to track failure rates and queue health
  5. ✅ Use structured logging with full exception context and metadata
  6. ✅ Implement retry logic for transient failures
  7. ✅ Consider circuit breakers for external dependencies
  8. ✅ Set the Logger property to capture event handler exceptions
  9. ✅ Access InnerException when handling exceptions in events (due to AggregateException wrapping)
  10. ✅ Test your error handling to ensure exceptions don't crash your application

Metadata Usage

Use the metadata dictionary to attach custom data to tasks:

var metadata = new Dictionary<string, object>
{
    { "UserId", 12345 },
    { "Priority", "High" },
    { "RetryCount", 0 },
    { "StartTime", DateTime.UtcNow }
};

queue.AddTask(Guid.NewGuid(), "UserOperation", metadata, async (token) =>
{
    // Task implementation
});

queue.OnTaskFinished += (sender, task) =>
{
    var userId = task.Metadata["UserId"];
    var startTime = (DateTime)task.Metadata["StartTime"];
    var duration = DateTime.UtcNow - startTime;
    Console.WriteLine($"User {userId} task completed in {duration.TotalMilliseconds}ms");
};

Multiple Start/Stop Cycles

TaskHandler supports starting and stopping the queue multiple times. Stop() pauses the queue rather than emptying it:

  • Running tasks are canceled through their CancellationToken.
  • Tasks that have not started stay queued, including one that was waiting for a concurrency slot. They run after the next Start(), in the order they were added.
  • Tasks can be added while the queue is stopped; they run after the next Start().
  • Only Dispose() discards unstarted tasks. Each one completes as canceled (TaskHandle<T> canceled, OnTaskCanceled fired, counted in TotalCanceled), so nothing awaiting a handle hangs.
TaskQueue queue = new TaskQueue(10);

// First batch
queue.AddTask(Guid.NewGuid(), "Task1", null, async (token) => await DoWork(token));
queue.Start();
await Task.Delay(5000);
queue.Stop();

// Second batch (same queue instance)
queue.AddTask(Guid.NewGuid(), "Task2", null, async (token) => await DoWork(token));
queue.Start();
await Task.Delay(5000);
queue.Stop();

queue.Dispose();

Graceful Shutdown

using System.Threading.Tasks;
using TaskHandler;

// Recommended approach: Use async methods
await using (TaskQueue queue = new TaskQueue())
{
    queue.Start();

    // Add and process tasks...

    // Wait for all tasks to complete
    await queue.WaitForCompletionAsync();
    await queue.StopAsync(waitForCompletion: true);
}
// DisposeAsync is called by the await using statement

Framework Support

TaskHandler targets multiple .NET frameworks:

  • .NET Standard 2.0
  • .NET Standard 2.1
  • .NET 8.0
  • .NET 10.0

This ensures compatibility with:

  • .NET Core 2.0+
  • .NET Framework 4.6.1+
  • .NET 5.0+
  • .NET 8.0+
  • .NET 10.0+
  • Xamarin
  • Unity (2021.2+)

Dependencies: QoSKit on every target (used only when you pass a scheduler; the default FIFO path does not touch it), plus System.Threading.Channels and System.Diagnostics.DiagnosticSource on the netstandard targets (and Microsoft.Bcl.AsyncInterfaces on netstandard2.0).

Testing

Tests are defined once in src/Test.Shared using Touchstone and executed by three runners. The corpus contains 188 positive and negative cases covering construction and validation, enqueue and execution, concurrency control, cancellation, lifecycle (including stop/restart retention, adding while stopped, and dispose drops), events, TaskHandle<T>, FIFO and QoSKit scheduling (priority order, overtaking a backlog, priority across several slots, aging under a continuous urgent stream, weighted fair and weighted round robin service ratios, class-based fairness with class-default, low-latency queuing with and without policing, capacity policies, eviction, unclassified rejection; each discipline test is mutation-checked to fail under plain FIFO), runtime MaxConcurrentTasks and MaxQueueSize changes, statistics, progress, priority, TaskInfo, TaskRunWithTimeout, and telemetry (every metric and span family, including failure, timeout, cancellation, rejection, drop, and no-listener paths).

cd src/Test.Automated && dotnet run          # Touchstone CLI runner
dotnet test src/Test.Xunit/Test.Xunit.csproj # xUnit host
dotnet test src/Test.Nunit/Test.Nunit.csproj # NUnit host

Advanced Features

TaskHandler v2.0.0 includes powerful features for advanced task management:

TaskHandle<T> - Tasks with Results

Enqueue tasks that return results and await them:

using System;
using System.Threading;
using System.Threading.Tasks;
using TaskHandler;

using (TaskQueue queue = new TaskQueue())
{
    queue.Start();

    // Enqueue a task that returns a value
    TaskHandle<string> handle = await queue.EnqueueAsync(
        "FetchData",
        async (CancellationToken token) =>
        {
            await Task.Delay(1000, token);
            return "Data retrieved successfully";
        }
    );

    // Await the result
    string result = await handle.Task;
    Console.WriteLine(result); // "Data retrieved successfully"
}

Multiple concurrent results:

var handles = new List<TaskHandle<int>>();

for (int i = 0; i < 100; i++)
{
    int value = i;
    var handle = await queue.EnqueueAsync(
        $"Calculate-{i}",
        async (token) =>
        {
            await Task.Delay(100, token);
            return value * 2;
        }
    );
    handles.Add(handle);
}

// Wait for all results
int[] results = await Task.WhenAll(handles.Select(h => h.Task));

Options Pattern - Fluent Configuration

Configure TaskQueue using the options pattern or factory method:

using TaskHandler;

// Using TaskQueueOptions constructor
var queue = new TaskQueue(new TaskQueueOptions
{
    MaxConcurrentTasks = 10,
    MaxQueueSize = 100,
    Logger = Console.WriteLine,
    OnTaskFinished = (sender, task) =>
    {
        Console.WriteLine($"Task {task.Name} completed");
    },
    OnTaskFaulted = (sender, task) =>
    {
        Console.WriteLine($"Task {task.Name} failed: {task.Task?.Exception?.Message}");
    }
});

// Or using factory method
var queue2 = TaskQueue.Create(options =>
{
    options.MaxConcurrentTasks = 20;
    options.MaxQueueSize = 200;
    options.Logger = msg => Debug.WriteLine(msg);
    options.OnTaskStarted = (sender, task) =>
    {
        Console.WriteLine($"Started: {task.Name}");
    };
});

queue.Start();

Scheduling: FIFO by Default, QoSKit for Priority and Fairness

By default a TaskQueue starts tasks in the order they were added (FIFO), backed by a System.Threading.Channels channel. When you need something smarter, pass a QoSKit queue of TaskDetails as the scheduler. Whenever a concurrency slot frees up, the TaskQueue asks the scheduler which waiting task starts next. Everything else (concurrency limit, events, handles, stop/restart/dispose, statistics, telemetry) works the same.

Strict priority (lower Priority first, FIFO within a priority; out-of-range values are clamped to the nearest band):

using QoSKit;
using TaskHandler;

TaskQueue queue = new TaskQueue(
    new PriorityQoSQueue<TaskDetails>(levels: 5, prioritySelector: t => t.Priority)
        .WithAging(30000),   // optional: promote tasks waiting longer than 30s so low priorities cannot starve
    maxConcurrentTasks: 4);

queue.Start();
await queue.EnqueueAsync("report", async token => await BuildReport(token), priority: (int)TaskPriority.Background);
await queue.EnqueueAsync("checkout", async token => await Charge(token), priority: (int)TaskPriority.Urgent);
// "checkout" starts before any waiting Background task.

Weighted fairness per tenant (heavier flows get proportionally more starts; keys come from task metadata):

TaskQueue queue = TaskQueue.Create(o =>
{
    o.MaxConcurrentTasks = 8;
    o.Scheduler = new WeightedFairQoSQueue<TaskDetails>(
        flowSelector: t => (string)t.Metadata["tenant"],
        flows: new[] { new WeightedFlow("gold", 5), new WeightedFlow("silver", 3), new WeightedFlow("bronze", 1) });
});

queue.AddTask(Guid.NewGuid(), "export", new Dictionary<string, object> { { "tenant", "gold" } }, ExportAsync);

Any QoSKit discipline works (ClassBasedWeightedFairQoSQueue, LowLatencyQoSQueue, WeightedRoundRobinQoSQueue, ...). Rules:

  • Capacity comes from the scheduler. Use its MaxDepth and OverflowPolicy instead of MaxQueueSize (setting both throws). Reject and DropNewest make AddTask/AddTaskAsync throw InvalidOperationException when full. Block makes AddTaskAsync wait for space (even while stopped). DropOldest evicts the oldest waiting task, which completes as canceled: handle canceled, OnTaskCanceled fired, counted in TotalCanceled, telemetry outcome dropped.
  • Unclassifiable tasks are rejected. If the scheduler cannot classify a task (for example an unknown tenant under UnknownKeyPolicy.Reject), the add throws InvalidOperationException and the rejection is recorded with error.type unclassified.
  • The TaskQueue owns the scheduler. It must be empty when passed in. The TaskQueue disposes it, and it must not be shared, written to directly, or given QoSKit persistence (tasks hold delegates and cannot be persisted). Read queue.Scheduler for inspection only.
  • Default priority is 0 (Urgent). AddTask and EnqueueAsync without a priority produce Priority = 0. With a priority scheduler, pass explicit priorities, or map the default in your selector.
  • Stop and dispose behave as for FIFO. Stop() retains waiting tasks (and the scheduler orders them on restart), Stop(guid) cancels a waiting task, and Dispose() drops every waiting task as canceled.
  • QoSKit's own telemetry (meter and activity source QoSKit) describes the scheduler itself. It is independent of TaskHandler's and can be subscribed to separately.

Per-Task Timeout Support

Set timeouts for individual tasks:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

// Task with 5-second timeout
Guid taskId = await queue.EnqueueAsync(
    "LongOperation",
    async (token) =>
    {
        await Task.Delay(10000, token); // This will timeout
    },
    timeout: TimeSpan.FromSeconds(5)
);

// Task will fault with TimeoutException after 5 seconds

Timeout with result handling:

try
{
    TaskHandle<string> handle = await queue.EnqueueAsync(
        "ApiCall",
        async (token) =>
        {
            await Task.Delay(10000, token);
            return "Data";
        },
        timeout: TimeSpan.FromSeconds(3)
    );

    string result = await handle.Task;
}
catch (TimeoutException ex)
{
    Console.WriteLine($"Task timed out: {ex.Message}");
}

Task Priority

Assign priorities to tasks (lower number = higher priority).

Note: by default, priority is recorded on the task (TaskDetails.Priority, TaskInfo.Priority, and the taskhandler.task.priority span attribute) but does not change execution order: tasks start in the order they were added. To start lower priority values first, give the queue a QoSKit PriorityQoSQueue scheduler, as shown in Scheduling.

using TaskHandler;

TaskQueue queue = new TaskQueue();

// Add high-priority task
await queue.EnqueueAsync(
    "UrgentTask",
    async (token) => await ProcessUrgentData(token),
    priority: (int)TaskPriority.Urgent  // Priority: 0
);

// Add normal-priority task
await queue.EnqueueAsync(
    "NormalTask",
    async (token) => await ProcessNormalData(token),
    priority: (int)TaskPriority.Normal  // Priority: 2
);

// Add background task
await queue.EnqueueAsync(
    "BackgroundTask",
    async (token) => await CleanupData(token),
    priority: (int)TaskPriority.Background  // Priority: 4
);

queue.Start();

Priority enum values:

  • TaskPriority.Urgent = 0 (highest)
  • TaskPriority.High = 1
  • TaskPriority.Normal = 2 (default)
  • TaskPriority.Low = 3
  • TaskPriority.Background = 4 (lowest)

GetRunningTasksInfo() - Immutable Task Information

Safely inspect running tasks without accessing mutable state:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

// Add some tasks
await queue.EnqueueAsync("Task1", async (token) => await Task.Delay(5000, token));
await queue.EnqueueAsync("Task2", async (token) => await Task.Delay(5000, token));

await Task.Delay(100); // Let tasks start

// Get read-only snapshot of running tasks
IReadOnlyCollection<TaskInfo> runningTasks = queue.GetRunningTasksInfo();

foreach (var task in runningTasks)
{
    Console.WriteLine($"Task: {task.Name}");
    Console.WriteLine($"  ID: {task.Id}");
    Console.WriteLine($"  Status: {task.Status}");
    Console.WriteLine($"  Priority: {task.Priority}");
    Console.WriteLine($"  Metadata count: {task.Metadata.Count}");
}

Combined Example - Advanced Features

using TaskHandler;

// Create queue with options pattern
var queue = TaskQueue.Create(options =>
{
    options.MaxConcurrentTasks = 5;
    options.MaxQueueSize = 50;
    options.Logger = Console.WriteLine;
    options.OnTaskFaulted = (sender, task) =>
    {
        if (task.Task?.Exception != null)
        {
            Console.WriteLine($"Task {task.Name} faulted: {task.Task.Exception.Message}");
        }
    };
});

await queue.StartAsync();

// Enqueue task with result, priority, and timeout
TaskHandle<string> handle = await queue.EnqueueAsync(
    "DataFetch",
    async (token) =>
    {
        await Task.Delay(2000, token);
        return "Important data";
    },
    priority: (int)TaskPriority.High,
    timeout: TimeSpan.FromSeconds(5)
);

// Monitor running tasks
IReadOnlyCollection<TaskInfo> running = queue.GetRunningTasksInfo();
Console.WriteLine($"Currently running: {running.Count} tasks");

// Await result
try
{
    string result = await handle.Task;
    Console.WriteLine($"Result: {result}");
}
catch (TimeoutException)
{
    Console.WriteLine("Task timed out");
}

// Graceful shutdown
await queue.WaitForCompletionAsync();
await queue.DisposeAsync();

Statistics and Progress Reporting

TaskHandler v2.1.0 includes features for statistics tracking and progress reporting:

Statistics and Metrics

Track queue performance and task metrics with GetStatistics():

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

// Enqueue and process tasks
for (int i = 0; i < 100; i++)
{
    await queue.EnqueueAsync(
        $"Task{i}",
        async (token) => await ProcessItem(i, token)
    );
}

await queue.WaitForCompletionAsync();

// Get statistics
TaskQueueStatistics stats = queue.GetStatistics();

Console.WriteLine($"Total Enqueued: {stats.TotalEnqueued}");
Console.WriteLine($"Total Completed: {stats.TotalCompleted}");
Console.WriteLine($"Total Failed: {stats.TotalFailed}");
Console.WriteLine($"Total Canceled: {stats.TotalCanceled}");
Console.WriteLine($"Current Queue Depth: {stats.CurrentQueueDepth}");
Console.WriteLine($"Currently Running: {stats.CurrentRunningCount}");
Console.WriteLine($"Average Execution Time: {stats.AverageExecutionTime.TotalMilliseconds:F2}ms");
Console.WriteLine($"Average Wait Time: {stats.AverageWaitTime.TotalMilliseconds:F2}ms");
Console.WriteLine($"Last Task Started: {stats.LastTaskStarted}");
Console.WriteLine($"Last Task Completed: {stats.LastTaskCompleted}");

await queue.DisposeAsync();

Available Statistics:

Statistic Type Description
TotalEnqueued long Total number of tasks enqueued since queue creation
TotalCompleted long Total number of tasks completed successfully
TotalFailed long Total number of tasks that failed (faulted)
TotalCanceled long Total number of tasks that were canceled, including tasks dropped by Dispose() before they started
CurrentQueueDepth int Current number of tasks accepted but not yet started (including tasks waiting for a slot or held while stopped)
CurrentRunningCount int Current number of tasks actively running
AverageExecutionTime TimeSpan Average execution time across completed tasks
AverageWaitTime TimeSpan Average time tasks spent waiting in queue before execution
LastTaskStarted DateTime? Timestamp when the most recent task started (null if none started)
LastTaskCompleted DateTime? Timestamp when the most recent task completed (null if none completed)

Example - Monitoring Performance:

TaskQueue queue = new TaskQueue(maxConcurrentTasks: 10);
queue.Start();

// Enqueue tasks
for (int i = 0; i < 1000; i++)
{
    await queue.EnqueueAsync($"Task{i}", async (token) => await ProcessTask(i, token));
}

// Monitor progress periodically
while (queue.RunningCount > 0 || queue.QueuedCount > 0)
{
    TaskQueueStatistics stats = queue.GetStatistics();
    double completionRate = (double)stats.TotalCompleted / stats.TotalEnqueued * 100;

    Console.WriteLine($"Progress: {completionRate:F1}% ({stats.TotalCompleted}/{stats.TotalEnqueued})");
    Console.WriteLine($"Running: {stats.CurrentRunningCount}, Queued: {stats.CurrentQueueDepth}");
    Console.WriteLine($"Avg Execution: {stats.AverageExecutionTime.TotalMilliseconds:F0}ms");

    await Task.Delay(1000);
}

await queue.DisposeAsync();

Progress Reporting

Report progress from within tasks using IProgress<TaskProgress>:

using TaskHandler;

TaskQueue queue = new TaskQueue();
queue.Start();

// Create progress reporter
Progress<TaskProgress> progress = new Progress<TaskProgress>(p =>
{
    Console.WriteLine($"Progress: {p.Current}/{p.Total} ({p.PercentComplete:F1}%) - {p.Message}");
});

// Enqueue task with progress reporting
TaskHandle<string> handle = await queue.EnqueueAsync(
    "DataProcessing",
    async (CancellationToken token, IProgress<TaskProgress> prog) =>
    {
        int totalItems = 100;

        for (int i = 0; i <= totalItems; i++)
        {
            // Report progress
            prog?.Report(new TaskProgress(i, totalItems, $"Processing item {i}"));

            // Do work
            await ProcessItem(i, token);
        }

        return "Processing complete";
    },
    progress
);

// Await result
string result = await handle.Task;
Console.WriteLine(result);

await queue.DisposeAsync();

Progress without result:

Progress<TaskProgress> progress = new Progress<TaskProgress>(p =>
{
    Console.WriteLine($"{p.PercentComplete:F0}% complete - {p.Message}");
});

Guid taskId = await queue.EnqueueAsync(
    "FileProcessing",
    async (CancellationToken token, IProgress<TaskProgress> prog) =>
    {
        int totalFiles = 50;

        for (int i = 0; i < totalFiles; i++)
        {
            prog?.Report(new TaskProgress(i + 1, totalFiles, $"Processing file {i + 1}"));
            await ProcessFile(files[i], token);
        }
    },
    progress
);

TaskProgress Properties:

Property Type Description
Current int Current progress value
Total int Total expected value for completion
PercentComplete double Percentage of completion (0-100)
Message string Optional message describing current progress state

Example - Download with Progress:

var queue = TaskQueue.Create(options =>
{
    options.MaxConcurrentTasks = 3;
    options.Logger = Console.WriteLine;
});

await queue.StartAsync();

// Download multiple files with progress
List<TaskHandle<bool>> downloads = new List<TaskHandle<bool>>();

foreach (string url in urlsToDownload)
{
    Progress<TaskProgress> progress = new Progress<TaskProgress>(p =>
    {
        Console.WriteLine($"{url}: {p.PercentComplete:F0}% ({p.Message})");
    });

    TaskHandle<bool> handle = await queue.EnqueueAsync(
        $"Download-{url}",
        async (CancellationToken token, IProgress<TaskProgress> prog) =>
        {
            using (HttpClient client = new HttpClient())
            {
                HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
                long? totalBytes = response.Content.Headers.ContentLength;

                using (Stream contentStream = await response.Content.ReadAsStreamAsync())
                using (FileStream fileStream = File.Create($"downloads/{Path.GetFileName(url)}"))
                {
                    byte[] buffer = new byte[8192];
                    long downloadedBytes = 0;
                    int bytesRead;

                    while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, token)) > 0)
                    {
                        await fileStream.WriteAsync(buffer, 0, bytesRead, token);
                        downloadedBytes += bytesRead;

                        if (totalBytes.HasValue)
                        {
                            int percentage = (int)((double)downloadedBytes / totalBytes.Value * 100);
                            prog?.Report(new TaskProgress(percentage, 100, $"{downloadedBytes}/{totalBytes.Value} bytes"));
                        }
                    }
                }

                return true;
            }
        },
        progress,
        priority: (int)TaskPriority.Normal,
        timeout: TimeSpan.FromMinutes(5)
    );

    downloads.Add(handle);
}

// Wait for all downloads
bool[] results = await Task.WhenAll(downloads.Select(h => h.Task));
Console.WriteLine($"Downloaded {results.Count(r => r)}/{results.Length} files successfully");

await queue.DisposeAsync();

Combined Example - Statistics + Progress

using TaskHandler;

// Create queue with options
var queue = TaskQueue.Create(options =>
{
    options.MaxConcurrentTasks = 5;
    options.MaxQueueSize = 100;
    options.Logger = msg => Debug.WriteLine(msg);
});

await queue.StartAsync();

// Process items with progress tracking
int totalItems = 1000;
List<TaskHandle<int>> handles = new List<TaskHandle<int>>();

for (int i = 0; i < totalItems; i++)
{
    int itemId = i;

    Progress<TaskProgress> progress = new Progress<TaskProgress>(p =>
    {
        // Could update UI here
    });

    TaskHandle<int> handle = await queue.EnqueueAsync(
        $"Item-{itemId}",
        async (CancellationToken token, IProgress<TaskProgress> prog) =>
        {
            // Simulate multi-step processing
            for (int step = 0; step < 10; step++)
            {
                prog?.Report(new TaskProgress(step + 1, 10, $"Step {step + 1}"));
                await Task.Delay(10, token);
            }
            return itemId;
        },
        progress,
        priority: itemId < 100 ? (int)TaskPriority.High : (int)TaskPriority.Normal
    );

    handles.Add(handle);
}

// Monitor overall progress
while (queue.RunningCount > 0 || queue.QueuedCount > 0)
{
    TaskQueueStatistics stats = queue.GetStatistics();

    Console.WriteLine($"\n=== Queue Statistics ===");
    Console.WriteLine($"Progress: {stats.TotalCompleted}/{stats.TotalEnqueued} ({(double)stats.TotalCompleted / stats.TotalEnqueued * 100:F1}%)");
    Console.WriteLine($"Running: {stats.CurrentRunningCount}, Queued: {stats.CurrentQueueDepth}");
    Console.WriteLine($"Failed: {stats.TotalFailed}, Canceled: {stats.TotalCanceled}");
    Console.WriteLine($"Avg Execution: {stats.AverageExecutionTime.TotalMilliseconds:F0}ms");
    Console.WriteLine($"Avg Wait: {stats.AverageWaitTime.TotalMilliseconds:F0}ms");

    await Task.Delay(2000);
}

// Wait for all results
int[] results = await Task.WhenAll(handles.Select(h => h.Task));

// Final statistics
TaskQueueStatistics finalStats = queue.GetStatistics();
Console.WriteLine($"\n=== Final Statistics ===");
Console.WriteLine(finalStats.ToString());

await queue.DisposeAsync();

Observability (Metrics and Traces)

Starting with v2.2.0, TaskQueue and TaskRunWithTimeout emit metrics and traces through the standard .NET Meter and ActivitySource, both named TaskHandler. TaskHandler references no exporter or telemetry SDK; your application chooses where the data goes. With nothing subscribed, the cost is effectively zero.

Give each queue a short, fixed name. It becomes the taskhandler.queue.name label:

TaskQueue queue = TaskQueue.Create(o =>
{
    o.Name = "ingest";            // low-cardinality: never an id or user input
    o.MaxConcurrentTasks = 8;
    o.MaxQueueSize = 1000;
});

Subscribe from your host, for example with Radiant or the OpenTelemetry SDK:

settings.Sources.AddMeter(TaskHandlerTelemetryNames.MeterName);           // "TaskHandler"
settings.Sources.AddActivitySource(TaskHandlerTelemetryNames.ActivitySourceName);

What you get:

  • Where the time went: per-stage latency histograms for queued (waiting in the queue), slot_wait (waiting for a concurrency slot), and execute (your code), plus end-to-end duration.
  • What failed: completions by outcome (success, failure, timeout, canceled, dropped) and error.type, enqueue rejections (queue_full, queue_closed, canceled), cancellation requests by reason, event-handler exceptions, and runner crashes.
  • Capacity: queue depth and capacity, concurrency in use and limit, backpressure wait, processing state, and last-success timestamp.
  • Traces: a taskhandler enqueue producer span, a taskhandler task consumer span per task with stage:queued, stage:slot_wait, and stage:execute children. Trace context flows from the code that enqueues a task into the task itself, so spans your task creates join the caller's trace.

See TELEMETRY.md for the full metric and span catalog, PromQL, alerts, and a dashboard layout.

Feedback and Issues

Found a bug? Have a feature request? Please file an issue at: https://github.com/jchristn/TaskHandler/issues

Contributing

Contributions are welcome! Please see IMPROVEMENTS.md for planned enhancements and CLAUDE.md for coding standards.

License

This project is licensed under the MIT License.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 is compatible. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on TaskHandler:

Package Downloads
View.Models

Database models, services, and supporting classes for for View AI.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.4.2 101 10/3/2026
2.4.1 87 10/3/2026
2.4.0 84 10/3/2026
2.3.0 82 10/3/2026
2.2.0 92 10/3/2026
2.1.1 219 8/16/2026
2.0.0 2,716 10/19/2025
1.0.9 2,838 3/15/2025
1.0.8 342 3/7/2025
1.0.7 344 3/7/2025
1.0.6 320 1/31/2024
1.0.5 382 10/21/2023
1.0.3 382 10/8/2023
1.0.2 270 10/8/2023
1.0.1 398 10/1/2023
1.0.0 251 9/29/2023

v2.4.1: No library code changes. Adds a QoS Disciplines test suite proving weighted fair, aging, class-based, weighted round robin, and low-latency scheduling through TaskQueue (each mutation-checked against FIFO); docs updated. v2.4.0: Opt-in QoS scheduling. FIFO stays the default; pass a QoSKit queue (TaskQueue(IQoSQueue<TaskDetails>) or TaskQueueOptions.Scheduler) to start tasks by strict priority with aging, weighted fairness per tenant, and other disciplines, with the scheduler providing capacity and overflow policy. MaxConcurrentTasks can now be changed on a live queue; MaxQueueSize changes after first use throw instead of being ignored. Adds a QoSKit dependency. v2.3.0: Lifecycle fixes. Stop() retains unstarted tasks (they run after the next Start()) and tasks can be added while stopped; Dispose() completes every unstarted task as canceled (handles canceled, OnTaskCanceled fired) instead of hanging; each task raises exactly one terminal event; OnProcessingStopped fires once; Stop(guid) cancels queued tasks; adding after dispose throws ObjectDisposedException; StopAsync(waitForCompletion) and IsRunning fixed; fixed a hang when disposing with a task waiting for a slot. v2.2.0: Added built-in observability. TaskQueue and TaskRunWithTimeout emit OpenTelemetry-shaped metrics and traces through the BCL Meter and ActivitySource named TaskHandler (queue depth, concurrency in use, per-stage durations, outcomes, rejections, cancellations, errors by type), with trace context propagated from enqueue into task code. New TaskQueue.Name and TaskQueueOptions.Name label each queue. No new runtime dependency on net8.0+; netstandard targets add System.Diagnostics.DiagnosticSource. See TELEMETRY.md. v2.1.1: Fixed result-returning EnqueueAsync<T> overloads so that a task exceeding its timeout completes the TaskHandle<T> with a TimeoutException (previously it double-completed the handle, surfacing a cancellation plus an InvalidOperationException fault). Hardened TaskHandle<T> completion to be idempotent. Updated System.Threading.Channels and Microsoft.Bcl.AsyncInterfaces to 10.0.11. v2.1.0: Retargeted to net8.0 and net10.0 (dropped net6.0); still supports netstandard2.0 and netstandard2.1. v2.0.0: Event-driven architecture (10-100x faster), async API (StartAsync, StopAsync, DisposeAsync), task results (TaskHandle<T>), priorities, timeouts, statistics, progress reporting. BREAKING: Removed IterationDelayMs and QueuedTasks properties.