From f7e34ccac03bea03247216505a39318a114413e4 Mon Sep 17 00:00:00 2001 From: Joshua Ryder Date: Thu, 12 Feb 2026 16:34:36 +0100 Subject: [PATCH] more improvements --- src/MinimalWorker.Generators/WorkerEmitter.cs | 106 ++++--- .../BackgroundWorkerExtensions.cs | 265 ++++++++++++++---- 2 files changed, 288 insertions(+), 83 deletions(-) diff --git a/src/MinimalWorker.Generators/WorkerEmitter.cs b/src/MinimalWorker.Generators/WorkerEmitter.cs index 9bd4ce6..9016766 100644 --- a/src/MinimalWorker.Generators/WorkerEmitter.cs +++ b/src/MinimalWorker.Generators/WorkerEmitter.cs @@ -104,6 +104,11 @@ public static string EmitSource(List workers) sb.AppendLine(" // Worker state tracking for observable gauges"); sb.AppendLine(" private static readonly System.Collections.Concurrent.ConcurrentDictionary _workerStates = new();"); sb.AppendLine(); + sb.AppendLine(" // Cached snapshot for gauge enumeration optimization"); + sb.AppendLine(" private static WorkerState[]? _cachedSnapshot;"); + sb.AppendLine(" private static int _snapshotVersion;"); + sb.AppendLine(" private static int _currentVersion;"); + sb.AppendLine(); sb.AppendLine(" internal sealed class WorkerState"); sb.AppendLine(" {"); sb.AppendLine(" public string WorkerId { get; set; } = string.Empty;"); @@ -114,6 +119,24 @@ public static string EmitSource(List workers) sb.AppendLine(" public long ConsecutiveFailures { get; set; }"); sb.AppendLine(" }"); sb.AppendLine(); + sb.AppendLine(" /// "); + sb.AppendLine(" /// Gets a cached snapshot of worker states, only re-allocating when workers are added/removed."); + sb.AppendLine(" /// "); + sb.AppendLine(" private static WorkerState[] GetWorkerStatesSnapshot()"); + sb.AppendLine(" {"); + sb.AppendLine(" var version = Volatile.Read(ref _currentVersion);"); + sb.AppendLine(" var cached = _cachedSnapshot;"); + sb.AppendLine(" if (cached != null && _snapshotVersion == version)"); + sb.AppendLine(" {"); + sb.AppendLine(" return cached;"); + sb.AppendLine(" }"); + sb.AppendLine(" // Version changed or no cache - create new snapshot"); + sb.AppendLine(" var snapshot = _workerStates.Values.ToArray();"); + sb.AppendLine(" _cachedSnapshot = snapshot;"); + sb.AppendLine(" _snapshotVersion = version;"); + sb.AppendLine(" return snapshot;"); + sb.AppendLine(" }"); + sb.AppendLine(); sb.AppendLine(" internal static void RegisterWorker(string workerId, string workerName, string workerType)"); sb.AppendLine(" {"); sb.AppendLine(" _workerStates[workerId] = new WorkerState"); @@ -125,6 +148,8 @@ public static string EmitSource(List workers) sb.AppendLine(" LastSuccessTimestamp = 0,"); sb.AppendLine(" ConsecutiveFailures = 0"); sb.AppendLine(" };"); + sb.AppendLine(" // Invalidate cache when worker is registered"); + sb.AppendLine(" Interlocked.Increment(ref _currentVersion);"); sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" internal static void RecordSuccess(string workerId)"); @@ -150,6 +175,8 @@ public static string EmitSource(List workers) sb.AppendLine(" {"); sb.AppendLine(" state.IsActive = false;"); sb.AppendLine(" }"); + sb.AppendLine(" // Invalidate cache when worker is deactivated"); + sb.AppendLine(" Interlocked.Increment(ref _currentVersion);"); sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" /// "); @@ -164,8 +191,8 @@ public static string EmitSource(List workers) sb.AppendLine(); sb.AppendLine(" private static System.Collections.Generic.IEnumerable> GetActiveWorkerMeasurements()"); sb.AppendLine(" {"); - sb.AppendLine(" // Take a snapshot to avoid collection modified exception during enumeration"); - sb.AppendLine(" foreach (var state in _workerStates.Values.ToArray())"); + sb.AppendLine(" // Use cached snapshot to avoid allocation on every metrics export"); + sb.AppendLine(" foreach (var state in GetWorkerStatesSnapshot())"); sb.AppendLine(" {"); sb.AppendLine(" var tags = new TagList"); sb.AppendLine(" {"); @@ -189,8 +216,8 @@ public static string EmitSource(List workers) sb.AppendLine(); sb.AppendLine(" private static System.Collections.Generic.IEnumerable> GetLastSuccessMeasurements()"); sb.AppendLine(" {"); - sb.AppendLine(" // Take a snapshot to avoid collection modified exception during enumeration"); - sb.AppendLine(" foreach (var state in _workerStates.Values.ToArray())"); + sb.AppendLine(" // Use cached snapshot to avoid allocation on every metrics export"); + sb.AppendLine(" foreach (var state in GetWorkerStatesSnapshot())"); sb.AppendLine(" {"); sb.AppendLine(" if (state.LastSuccessTimestamp > 0)"); sb.AppendLine(" {"); @@ -216,8 +243,8 @@ public static string EmitSource(List workers) sb.AppendLine(); sb.AppendLine(" private static System.Collections.Generic.IEnumerable> GetConsecutiveFailuresMeasurements()"); sb.AppendLine(" {"); - sb.AppendLine(" // Take a snapshot to avoid collection modified exception during enumeration"); - sb.AppendLine(" foreach (var state in _workerStates.Values.ToArray())"); + sb.AppendLine(" // Use cached snapshot to avoid allocation on every metrics export"); + sb.AppendLine(" foreach (var state in GetWorkerStatesSnapshot())"); sb.AppendLine(" {"); sb.AppendLine(" var tags = new TagList"); sb.AppendLine(" {"); @@ -285,11 +312,43 @@ private static void EmitWorkerLogMessages(StringBuilder sb) private static void EmitWorkerExtension(StringBuilder sb, List workers) { + // Build worker map first to determine signatures + int caseNum = 1; + var workerMap = new Dictionary(); + var signatureToMethod = new Dictionary(); + + foreach (var worker in workers) + { + // Build signature from worker parameters - strip global:: prefix and normalize spacing to match runtime format + // Runtime uses FormatTypeName which joins generic args with "," (no space), so we must do the same + var paramTypes = string.Join(",", worker.Parameters.Select(p => p.Type.Replace("global::", "").Replace(", ", ","))); + var signature = $"{worker.Type}:{paramTypes}"; + + if (!workerMap.ContainsKey(signature)) + { + workerMap[signature] = worker; + signatureToMethod[signature] = caseNum; + caseNum++; + } + } + sb.AppendLine("/// "); sb.AppendLine("/// Generated class to wire up background workers using a module initializer."); sb.AppendLine("/// "); sb.AppendLine("internal static class GeneratedBackgroundWorkerInitializer"); sb.AppendLine("{"); + + // Generate static dictionary for O(1) signature dispatch + sb.AppendLine(" // O(1) signature dispatch dictionary - initialized once, used for all lookups"); + sb.AppendLine(" private static readonly System.Collections.Generic.Dictionary> _workerInitializers = new()"); + sb.AppendLine(" {"); + foreach (var kvp in signatureToMethod) + { + sb.AppendLine($" {{ \"{kvp.Key}\", InitializeWorker_{kvp.Value} }},"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" [System.Runtime.CompilerServices.ModuleInitializer]"); sb.AppendLine(" internal static void Initialize()"); sb.AppendLine(" {"); @@ -303,42 +362,17 @@ private static void EmitWorkerExtension(StringBuilder sb, List r.Host == host)"); sb.AppendLine(" .ToList();"); sb.AppendLine(); - sb.AppendLine(" // Initialize each worker based on its parameter signature"); + sb.AppendLine(" // Initialize each worker using O(1) dictionary lookup"); sb.AppendLine(" foreach (var registration in registrations)"); sb.AppendLine(" {"); - - // Generate a switch statement to match workers by their unique signature - sb.AppendLine(" switch (registration.Signature)"); + sb.AppendLine(" if (_workerInitializers.TryGetValue(registration.Signature, out var initializer))"); sb.AppendLine(" {"); - - // Create a case for each unique worker signature - int caseNum = 1; - var workerMap = new Dictionary(); - foreach (var worker in workers) - { - // Build signature from worker parameters - strip global:: prefix and normalize spacing to match runtime format - // Runtime uses FormatTypeName which joins generic args with "," (no space), so we must do the same - var paramTypes = string.Join(",", worker.Parameters.Select(p => p.Type.Replace("global::", "").Replace(", ", ","))); - var signature = $"{worker.Type}:{paramTypes}"; - - if (!workerMap.ContainsKey(signature)) - { - workerMap[signature] = worker; - sb.AppendLine($" case \"{signature}\":"); - sb.AppendLine($" InitializeWorker_{caseNum}(registration);"); - sb.AppendLine(" break;"); - caseNum++; - } - } - - sb.AppendLine(" default:"); - sb.AppendLine(" // No matching worker initializer - this shouldn't happen"); - sb.AppendLine(" break;"); + sb.AppendLine(" initializer(registration);"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(); - + // Emit worker initialization methods - one for each unique signature caseNum = 1; foreach (var kvp in workerMap) @@ -346,7 +380,7 @@ private static void EmitWorkerExtension(StringBuilder sb, List /// Fluent builder interface for configuring background workers. +/// Provides methods for naming workers and handling errors. /// +/// +/// +/// Use the builder methods to customize worker behavior: +/// +/// +/// - Assigns a descriptive name for logs, metrics, and traces +/// - Provides custom error handling instead of application termination +/// +/// +/// +/// +/// host.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (token) => { }) +/// .WithName("data-sync") +/// .WithErrorHandler(ex => logger.LogError(ex, "Sync failed")); +/// +/// public interface IWorkerBuilder { /// - /// Sets a name for the worker. Used in logs, metrics, and traces for easier identification. + /// Sets a descriptive name for the worker. Used in logs, metrics, and distributed traces for easier identification. /// - /// The name to assign to the worker. + /// The name to assign to the worker. Should be unique and descriptive (e.g., "order-processor", "cache-cleanup"). /// The builder instance for method chaining. + /// + /// If not set, a default name "worker-{id}" is generated. + /// The name appears in: + /// + /// Log messages (category: MinimalWorker.{name}) + /// Metrics (worker.name tag) + /// Distributed traces (worker.name attribute) + /// + /// IWorkerBuilder WithName(string name); /// /// Sets an error handler for unhandled exceptions in the worker. - /// If not provided, exceptions will cause the application to terminate. /// - /// The error handler delegate. + /// The error handler delegate that receives the exception. /// The builder instance for method chaining. + /// + /// + /// Important: Without an error handler, unhandled exceptions will terminate the application (fail-fast behavior). + /// + /// + /// The error handler is called for each exception. After the handler returns: + /// + /// + /// Periodic/Cron workers: Continue running and will execute on next schedule + /// Continuous workers: Worker stops (user controls the loop) + /// + /// + /// + /// + /// host.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (api, token) => + /// { + /// await api.SyncDataAsync(token); + /// }) + /// .WithErrorHandler(ex => + /// { + /// telemetry.TrackException(ex); + /// // Worker continues on next interval + /// }); + /// + /// IWorkerBuilder WithErrorHandler(Action handler); } @@ -213,30 +263,49 @@ private static void EnsureInitialized(IHost host) } /// - /// Maps a background worker that continuously executes the specified delegate while the application is running. + /// Registers a continuous background worker that executes the specified delegate once when the application starts. /// /// The to register the background worker on. /// - /// A delegate representing the work to be executed. - /// It can return a for asynchronous work. - /// Dependency injection is supported for method parameters. + /// A delegate representing the work to be executed. Supported signatures: + /// + /// void or Task return types + /// Zero or more DI-resolved parameters + /// At most one parameter (auto-injected) + /// /// - /// A builder for configuring additional worker options like name and error handling. + /// An for configuring additional worker options. /// - /// The worker will start when the application starts and run in a continuous loop until shutdown. + /// + /// Scoping: A single DI scope is created for the worker's entire lifetime. + /// + /// + /// Important: The delegate executes exactly once. If you need repetition, include your own loop: + /// + /// + /// host.RunBackgroundWorker(async (CancellationToken token) => + /// { + /// while (!token.IsCancellationRequested) + /// { + /// // Your work here + /// await Task.Delay(1000, token); + /// } + /// }); + /// + /// /// This method uses source generators for strongly-typed, reflection-free, AOT-compatible execution. + /// /// /// - /// Example usage: /// - /// host.RunBackgroundWorker(async (CancellationToken token) => + /// host.RunBackgroundWorker(async (IMessageQueue queue, CancellationToken token) => /// { /// while (!token.IsCancellationRequested) /// { - /// Console.WriteLine("Running background task..."); - /// await Task.Delay(1000, token); + /// var message = await queue.DequeueAsync(token); + /// await ProcessMessageAsync(message); /// } - /// }).WithName("order-processor").WithErrorHandler(ex => Console.WriteLine(ex)); + /// }).WithName("message-processor").WithErrorHandler(ex => logger.LogError(ex, "Processing failed")); /// /// public static IWorkerBuilder RunBackgroundWorker(this IHost host, Delegate action) @@ -264,27 +333,38 @@ public static IWorkerBuilder RunBackgroundWorker(this IHost host, Delegate actio } /// - /// Maps a periodic background worker that executes the specified delegate at a fixed time interval. + /// Registers a periodic background worker that executes the specified delegate at a fixed time interval. /// /// The to register the background worker on. - /// The interval between executions. + /// The interval between executions. Must be greater than . /// - /// A delegate representing the work to be executed periodically. - /// It can return a for asynchronous work. - /// Dependency injection is supported for method parameters. + /// A delegate representing the work to be executed periodically. Supported signatures: + /// + /// void or Task return types + /// Zero or more DI-resolved parameters + /// At most one parameter (auto-injected) + /// /// - /// A builder for configuring additional worker options like name and error handling. + /// An for configuring additional worker options. + /// Thrown when is zero or negative. /// - /// The worker starts after the application is started and will execute the action repeatedly based on the specified interval. + /// + /// Scoping: A new DI scope is created for each execution. Scoped services are disposed after each run. + /// + /// + /// Important: Do NOT add your own loop - the framework handles repetition automatically. + /// The interval starts after each execution completes. + /// + /// /// This method uses source generators for strongly-typed, reflection-free, AOT-compatible execution. + /// /// /// - /// Example usage: /// - /// host.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (CancellationToken token) => + /// // Cleanup cache every 5 minutes + /// host.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (ICacheService cache, CancellationToken token) => /// { - /// Console.WriteLine("Running periodic task every 5 minutes..."); - /// await Task.CompletedTask; + /// await cache.CleanupExpiredEntriesAsync(token); /// }).WithName("cache-cleanup"); /// /// @@ -317,31 +397,53 @@ public static IWorkerBuilder RunPeriodicBackgroundWorker(this IHost host, TimeSp } /// - /// Maps a cron-scheduled background worker that executes the specified delegate according to a cron expression. + /// Registers a cron-scheduled background worker that executes the specified delegate according to a cron expression. /// /// The to register the background worker on. /// - /// A cron expression string defining the schedule. - /// Uses the standard cron format (minute, hour, day of month, month, day of week). + /// A cron expression string defining the schedule (UTC timezone). + /// Standard 5-field format: minute, hour, day-of-month, month, day-of-week. + /// Examples: + /// + /// "* * * * *" - Every minute + /// "*/15 * * * *" - Every 15 minutes + /// "0 * * * *" - Every hour at minute 0 + /// "0 0 * * *" - Daily at midnight + /// "0 0 * * 0" - Weekly on Sunday at midnight + /// /// /// - /// A delegate representing the work to be executed on the scheduled times. - /// It can return a for asynchronous work. - /// Dependency injection is supported for method parameters. + /// A delegate representing the work to be executed. Supported signatures: + /// + /// void or Task return types + /// Zero or more DI-resolved parameters + /// At most one parameter (auto-injected) + /// /// - /// A builder for configuring additional worker options like name and error handling. + /// An for configuring additional worker options. + /// Thrown when is null, empty, or whitespace. /// - /// The worker schedules the execution based on the next occurrence derived from the cron expression. + /// + /// Scoping: A new DI scope is created for each execution. Scoped services are disposed after each run. + /// + /// + /// Timezone: All cron expressions are evaluated in UTC. + /// + /// + /// Important: Do NOT add your own loop - the framework handles scheduling automatically. + /// + /// /// This method uses source generators for strongly-typed, reflection-free, AOT-compatible execution. + /// Uses NCrontab for cron expression parsing. + /// /// /// - /// Example usage: /// - /// host.RunCronBackgroundWorker("*/15 * * * *", async (CancellationToken token) => + /// // Generate report at 2 AM daily + /// host.RunCronBackgroundWorker("0 2 * * *", async (IReportService reports, CancellationToken token) => /// { - /// Console.WriteLine("Running cron task every 15 minutes..."); - /// await Task.CompletedTask; - /// }).WithName("nightly-report"); + /// await reports.GenerateDailyReportAsync(token); + /// }).WithName("daily-report"); /// /// public static IWorkerBuilder RunCronBackgroundWorker(this IHost host, string cronExpression, Delegate action) @@ -373,34 +475,103 @@ public static IWorkerBuilder RunCronBackgroundWorker(this IHost host, string cro } /// - /// Represents a registered background worker. + /// Represents a registered background worker with its configuration and metadata. /// + /// + /// This class is used internally by the source generator to create strongly-typed worker initializers. + /// It captures all information needed to start and manage a background worker. + /// public class WorkerRegistration { + /// + /// Gets or sets the unique identifier for this worker registration. + /// Auto-incremented for each registration within an application. + /// public int Id { get; set; } - public string? Name { get; set; } // Optional user-provided name for the worker + + /// + /// Gets or sets the optional user-provided name for the worker. + /// Set via . + /// + public string? Name { get; set; } + + /// + /// Gets or sets the delegate to execute. Contains the actual worker logic. + /// public Delegate Action { get; set; } = null!; + + /// + /// Gets or sets the type of worker (Continuous, Periodic, or Cron). + /// public WorkerType Type { get; set; } + + /// + /// Gets or sets the schedule configuration. + /// + /// For Periodic workers: interval + /// For Cron workers: cron expression + /// For Continuous workers: null + /// + /// public object? Schedule { get; set; } + + /// + /// Gets or sets the host this worker is registered on. + /// public IHost Host { get; set; } = null!; - public int ParameterCount { get; set; } // Number of parameters in the delegate - public Action? OnError { get; set; } // Optional error handler - public string Signature { get; set; } = string.Empty; // Unique signature based on parameter types /// - /// Gets the display name for this worker. Returns the user-provided name if set, - /// otherwise returns a generated name based on the worker ID. + /// Gets or sets the number of parameters in the delegate. + /// Used for signature matching during code generation. + /// + public int ParameterCount { get; set; } + + /// + /// Gets or sets the optional error handler for unhandled exceptions. + /// Set via . + /// If null, unhandled exceptions terminate the application. + /// + public Action? OnError { get; set; } + + /// + /// Gets or sets the unique signature based on worker type and parameter types. + /// Format: "{WorkerType}:{Param1Type},{Param2Type},...". + /// Used by the source generator to dispatch to the correct initializer. + /// + public string Signature { get; set; } = string.Empty; + + /// + /// Gets the display name for this worker. + /// Returns the user-provided name if set via , + /// otherwise returns a generated name "worker-{Id}". /// public string DisplayName => Name ?? $"worker-{Id}"; } /// - /// Type of background worker. + /// Specifies the type of background worker and its execution behavior. /// public enum WorkerType { + /// + /// A worker that executes once and runs until completion or cancellation. + /// The delegate is responsible for its own loop if repetition is needed. + /// Uses a single DI scope for its entire lifetime. + /// Continuous, + + /// + /// A worker that executes repeatedly at a fixed time interval. + /// The framework manages the execution loop automatically. + /// Creates a new DI scope for each execution. + /// Periodic, + + /// + /// A worker that executes according to a cron schedule (UTC timezone). + /// The framework manages the scheduling automatically. + /// Creates a new DI scope for each execution. + /// Cron } }