diff --git a/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/POST.md b/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/POST.md new file mode 100644 index 0000000000..06e7e3bf10 --- /dev/null +++ b/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/POST.md @@ -0,0 +1,224 @@ +# Dynamic Background Jobs and Workers in ABP + +ABP's Background Jobs and Background Workers are two well-established infrastructure pieces. Background jobs handle fire-and-forget async tasks — sending emails, generating reports, processing orders. Background workers handle continuously running periodic tasks — syncing inventory, cleaning up expired data, pushing scheduled notifications. + +This works great, but it has one assumption: **you know all your job and worker types at compile time**. + +In practice, that assumption breaks down more often than you'd expect: + +- You're building a **plugin system** where third-party plugins need to register their own background processing logic at runtime — you can't pre-define an `IBackgroundJob` implementation in the host project for every possible plugin +- Your system needs to execute background tasks based on **external configuration** (database, API responses) — the task types and parameters are entirely unknown at compile time +- Your **multi-tenant SaaS platform** needs different sync intervals for different tenants — some every 30 seconds, some every 5 minutes — and you need to adjust these without restarting the application +- You're building a **low-code/no-code platform** where end users define automation workflows through a visual designer, and those workflows need to run as background jobs or scheduled tasks — the job types and scheduling parameters are entirely determined by end users at runtime, unknowable to developers at compile time + +ABP's **Dynamic Background Jobs** (`IDynamicBackgroundJobManager`) and **Dynamic Background Workers** (`IDynamicBackgroundWorkerManager`) are designed for exactly these scenarios. They let you register, enqueue, schedule, and manage background tasks by name at runtime, with no compile-time type binding required. + +## Dynamic Background Jobs + +`IDynamicBackgroundJobManager` offers two usage patterns, covering different levels of runtime flexibility. + +### Enqueue an Existing Typed Job by Name + +If you already have a typed background job (say, an `EmailSendingJob` registered via `[BackgroundJobName("emails")]`), you can enqueue it by name without referencing its args type: + +```csharp +public class OrderAppService : ApplicationService +{ + private readonly IDynamicBackgroundJobManager _dynamicJobManager; + + public OrderAppService(IDynamicBackgroundJobManager dynamicJobManager) + { + _dynamicJobManager = dynamicJobManager; + } + + public async Task PlaceOrderAsync(PlaceOrderInput input) + { + // Business logic... + + // Enqueue a confirmation email — no reference to EmailSendingJobArgs needed + await _dynamicJobManager.EnqueueAsync("emails", new + { + EmailAddress = input.CustomerEmail, + Subject = "Order Confirmed", + Body = $"Your order {input.OrderId} has been placed." + }); + } +} +``` + +The framework looks up the typed job configuration by name, serializes the anonymous object, deserializes it into the correct args type, and feeds it through the standard typed job pipeline. The caller doesn't need to `using` any specific project namespace. + +### Register a Runtime Dynamic Handler + +When you don't even have a job type — say a plugin decides at startup what processing logic to register — you can register a handler directly: + +```csharp +public override async Task OnApplicationInitializationAsync( + ApplicationInitializationContext context) +{ + var dynamicJobManager = context.ServiceProvider + .GetRequiredService(); + + // A plugin registers its own processing logic at startup + dynamicJobManager.RegisterHandler("SyncExternalCatalog", async (jobContext, ct) => + { + using var doc = JsonDocument.Parse(jobContext.JsonData); + var catalogUrl = doc.RootElement.GetProperty("url").GetString(); + + var httpClient = jobContext.ServiceProvider + .GetRequiredService() + .CreateClient(); + + var catalog = await httpClient.GetStringAsync(catalogUrl, ct); + // Process catalog data... + }); + + // Now you can enqueue jobs for this handler + await dynamicJobManager.EnqueueAsync("SyncExternalCatalog", new + { + Url = "https://partner-api.example.com/catalog" + }); +} +``` + +The handler receives a context object containing `JsonData` (the raw JSON string) and `ServiceProvider` (a scoped container). Resolving dependencies from `ServiceProvider` is the recommended approach — avoid capturing external state in the handler closure. + +There's one priority rule to keep in mind: **if a name matches both a typed job and a dynamic handler, the typed job wins**. Dynamic handlers never accidentally override existing typed jobs. + +> Dynamic jobs ultimately go through the standard typed job pipeline, so they **work with every background job provider** — Default, Hangfire, Quartz, RabbitMQ, TickerQ — without any provider-specific code. + +## Dynamic Background Workers + +`IDynamicBackgroundWorkerManager` lets you register periodic tasks at runtime and manage their full lifecycle: add, remove, update schedule. + +```csharp +public override async Task OnApplicationInitializationAsync( + ApplicationInitializationContext context) +{ + var workerManager = context.ServiceProvider + .GetRequiredService(); + + await workerManager.AddAsync( + "InventorySyncWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 30000 // 30 seconds + }, + async (workerContext, cancellationToken) => + { + var syncService = workerContext.ServiceProvider + .GetRequiredService(); + + await syncService.SyncAsync(cancellationToken); + } + ); +} +``` + +If you're using Hangfire or Quartz as your provider, you can use a cron expression instead of a fixed interval: + +```csharp +await workerManager.AddAsync( + "DailyReportWorker", + new DynamicBackgroundWorkerSchedule + { + CronExpression = "0 2 * * *" // Every day at 2:00 AM + }, + async (workerContext, cancellationToken) => + { + var reportService = workerContext.ServiceProvider + .GetRequiredService(); + + await reportService.GenerateDailyReportAsync(cancellationToken); + } +); +``` + +### Runtime Schedule Management + +Adding a worker is just the beginning. The real value of dynamic workers is that the entire lifecycle is controllable at runtime: + +```csharp +// Check if a worker is currently registered +bool exists = workerManager.IsRegistered("InventorySyncWorker"); + +// A tenant upgrades their plan — speed up sync from 30s to 10s +await workerManager.UpdateScheduleAsync( + "InventorySyncWorker", + new DynamicBackgroundWorkerSchedule { Period = 10000 } +); + +// Tenant disables the sync feature — remove the worker entirely +await workerManager.RemoveAsync("InventorySyncWorker"); +``` + +`UpdateScheduleAsync` only changes the schedule — the handler itself stays the same. For persistent providers like Hangfire and Quartz, `UpdateScheduleAsync` and `RemoveAsync` can operate on the persistent scheduling record even after an application restart, when the handler is no longer in memory. + +### Stopping All Workers + +When you need to stop all dynamic workers at once (e.g., as part of a graceful shutdown), call `StopAllAsync`: + +```csharp +await workerManager.StopAllAsync(cancellationToken); +``` + +All registered workers are stopped and cleaned up, and the handler registry is cleared. Calling `AddAsync` or `UpdateScheduleAsync` after this throws `ObjectDisposedException` — this is intentional, preventing new workers from being added during a shutdown sequence. + +## Provider Support + +Dynamic background jobs and dynamic background workers have different levels of provider support. + +**Dynamic background jobs** are compatible with all providers because they reuse the standard typed job pipeline: + +| Provider | Supported | +|---|---| +| Default (In-Memory) | ✅ | +| Hangfire | ✅ | +| Quartz | ✅ | +| RabbitMQ | ✅ | +| TickerQ | ✅ | + +**Dynamic background workers** have per-provider implementations: + +| Provider | AddAsync | RemoveAsync | UpdateScheduleAsync | Period | CronExpression | +|---|---|---|---|---|---| +| Default (In-Memory) | ✅ | ✅ | ✅ | ✅ | ❌ | +| Hangfire | ✅ | ✅ | ✅ | ✅ | ✅ | +| Quartz | ✅ | ✅ | ✅ | ✅ | ✅ | +| TickerQ | ❌ | ❌ | ❌ | — | — | + +TickerQ uses `FrozenDictionary` for function registration, which requires all functions to be registered before the application starts. Runtime dynamic registration is not possible. + +## Restart Behavior + +Dynamic handlers are stored **in memory** and are not persisted across application restarts. This is a deliberate design choice — handlers are code logic (delegates), and code logic is inherently not serializable. + +For persistent providers (Hangfire, Quartz), this means: enqueued jobs and recurring job entries survive a restart in the database, but the handlers need to be re-registered. If a handler is not re-registered, the job executor throws an exception (background jobs) or skips the execution with a warning log (background workers). + +The recommended approach is to register handlers in `OnApplicationInitializationAsync`, so they are automatically restored on every startup: + +```csharp +public override async Task OnApplicationInitializationAsync( + ApplicationInitializationContext context) +{ + var dynamicJobManager = context.ServiceProvider + .GetRequiredService(); + + // Re-registered on every startup — persistent jobs will find their handler + dynamicJobManager.RegisterHandler("SyncExternalCatalog", async (jobContext, ct) => + { + // handler logic... + }); +} +``` + +## Summary + +`IDynamicBackgroundJobManager` lets you enqueue jobs and register handlers by name at runtime, compatible with all background job providers, no compile-time types required. `IDynamicBackgroundWorkerManager` lets you add, remove, and update the schedule of periodic workers at runtime — Hangfire and Quartz providers also support cron expressions. Register handlers in `OnApplicationInitializationAsync` to ensure automatic recovery on every startup. + +## References + +- [Background Jobs](https://abp.io/docs/latest/framework/infrastructure/background-jobs) +- [Background Workers](https://abp.io/docs/latest/framework/infrastructure/background-workers) +- [Hangfire Background Job Manager](https://abp.io/docs/latest/framework/infrastructure/background-jobs/hangfire) +- [Quartz Background Job Manager](https://abp.io/docs/latest/framework/infrastructure/background-jobs/quartz) diff --git a/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/cover.jpg b/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/cover.jpg new file mode 100644 index 0000000000..f1c85ceb58 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-21-Dynamic-Background-Jobs-and-Workers-in-ABP/cover.jpg differ diff --git a/docs/en/framework/infrastructure/background-jobs/index.md b/docs/en/framework/infrastructure/background-jobs/index.md index 0d5e19442c..2f27055f46 100644 --- a/docs/en/framework/infrastructure/background-jobs/index.md +++ b/docs/en/framework/infrastructure/background-jobs/index.md @@ -346,6 +346,87 @@ If you don't want to use a distributed lock provider, you may go with the follow * Stop the background job manager (set `AbpBackgroundJobOptions.IsJobExecutionEnabled` to `false` as explained in the *Disable Job Execution* section) in all application instances except one of them, so only the single instance executes the jobs (while other application instances can still queue jobs). * Stop the background job manager (set `AbpBackgroundJobOptions.IsJobExecutionEnabled` to `false` as explained in the *Disable Job Execution* section) in all application instances and create a dedicated application (maybe a console application running in its own container or a Windows Service running in the background) to execute all the background jobs. This can be a good option if your background jobs consume high system resources (CPU, RAM or Disk), so you can deploy that background application to a dedicated server and your background jobs don't affect your application's performance. +## Dynamic Background Jobs + +ABP provides `IDynamicBackgroundJobManager` for scenarios where you need to enqueue jobs by name at runtime, without requiring a strongly-typed job args class at compile time. This is useful for plugin systems, dynamic workflows, or any case where job types are not known ahead of time. + +### Enqueue by Job Name (Typed Job) + +If a typed job is already registered (e.g., via `[BackgroundJobName("emails")]`), you can enqueue it by name: + +````csharp +public class MyService : ApplicationService +{ + private readonly IDynamicBackgroundJobManager _dynamicJobManager; + + public MyService(IDynamicBackgroundJobManager dynamicJobManager) + { + _dynamicJobManager = dynamicJobManager; + } + + public async Task DoSomethingAsync() + { + await _dynamicJobManager.EnqueueAsync("emails", new + { + EmailAddress = "user@abp.io", + Subject = "Hello", + Body = "World" + }); + } +} +```` + +The `IDynamicBackgroundJobManager` will look up the typed job configuration, deserialize the args to the expected type, and enqueue through the standard typed pipeline. + +### Dynamic Job Handlers + +You can also register dynamic handlers at runtime for jobs that don't have a pre-defined typed job class: + +````csharp +public override void OnApplicationInitialization(ApplicationInitializationContext context) +{ + var dynamicJobManager = context.ServiceProvider + .GetRequiredService(); + + dynamicJobManager.RegisterHandler("ProcessOrder", async (context, ct) => + { + var json = context.JsonData; + var serviceProvider = context.ServiceProvider; + // Process the order using JsonData and resolved services... + }); +} +```` + +Then enqueue jobs using the registered name: + +````csharp +await _dynamicJobManager.EnqueueAsync("ProcessOrder", new +{ + OrderId = "ORD-001", + Amount = 99.99 +}); +```` + +### Handler Management + +````csharp +// Check if a handler is registered +bool exists = _dynamicJobManager.IsHandlerRegistered("ProcessOrder"); + +// Unregister a handler +bool removed = _dynamicJobManager.UnregisterHandler("ProcessOrder"); +```` + +### How It Works + +- **Typed job path**: When the job name matches a registered typed job configuration, the args are serialized to JSON and deserialized to the expected args type, then enqueued through `IBackgroundJobManager.EnqueueAsync`. +- **Dynamic handler path**: When the job name matches a registered dynamic handler, the args are wrapped as `DynamicBackgroundJobArgs` (a public transport type used internally by the framework) and enqueued through `IBackgroundJobManager.EnqueueAsync`. When the job executes, the framework looks up the handler by name and invokes it. +- All dynamic jobs go through the **standard typed job pipeline**, which means they work with all providers (Default, Hangfire, Quartz, RabbitMQ, TickerQ) without any provider-specific changes. + +> **Note:** If the job name matches both a registered typed job configuration and a dynamic handler, **the typed job takes priority** and the dynamic handler is ignored. To avoid confusion, use distinct names for dynamic handlers that do not conflict with existing typed job names. + +> **Important:** Dynamic job handlers are stored **in memory only** and are not persisted across application restarts. When using a persistent provider (Hangfire, Quartz, RabbitMQ, TickerQ), enqueued jobs survive a restart but if no handler is re-registered, the job executor will throw an exception when the job is picked up. To ensure handlers are always available, register them in `OnApplicationInitialization` so they are re-registered on every startup. + ## Integrations Background job system is extensible and you can change the default background job manager with your own implementation or on of the pre-built integrations. diff --git a/docs/en/framework/infrastructure/background-workers/index.md b/docs/en/framework/infrastructure/background-workers/index.md index 6204857d8c..a8e558cea1 100644 --- a/docs/en/framework/infrastructure/background-workers/index.md +++ b/docs/en/framework/infrastructure/background-workers/index.md @@ -120,6 +120,66 @@ So, it resolves the given background worker and adds to the `IBackgroundWorkerMa While we generally add workers in `OnApplicationInitializationAsync`, there are no restrictions on that. You can inject `IBackgroundWorkerManager` anywhere and add workers at runtime. Background worker manager will stop and release all the registered workers when your application is being shut down. +### Dynamic Workers (Runtime Registration) + +You can add a runtime worker without pre-defining a dedicated worker class. Inject `IDynamicBackgroundWorkerManager` and pass a handler directly: + +````csharp +public class MyModule : AbpModule +{ + public override async Task OnApplicationInitializationAsync( + ApplicationInitializationContext context) + { + var dynamicWorkerManager = context.ServiceProvider + .GetRequiredService(); + + await dynamicWorkerManager.AddAsync( + "InventorySyncWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 30000 //30 seconds + //CronExpression = "*/30 * * * *" //Every 30 minutes. Only for Hangfire or Quartz integration. + }, + async (workerContext, cancellationToken) => + { + var inventorySyncAppService = workerContext + .ServiceProvider + .GetRequiredService(); + + await inventorySyncAppService.SyncAsync(cancellationToken); + } + ); + } +} +```` + +You can also **remove** a dynamic worker or **update its schedule** at runtime: + +````csharp +//Remove a dynamic worker +var removed = await dynamicWorkerManager.RemoveAsync("InventorySyncWorker"); + +//Update the schedule of a dynamic worker +var updated = await dynamicWorkerManager.UpdateScheduleAsync( + "InventorySyncWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 60000 //Change to 60 seconds + } +); +```` + +* `IDynamicBackgroundWorkerManager` is a **separate interface** from `IBackgroundWorkerManager`, dedicated to runtime (non-type-safe) worker management. +* `workerName` is the runtime identifier of the dynamic worker. If a worker with the same name already exists, it will be **replaced**. +* The `handler` receives a `DynamicBackgroundWorkerExecutionContext` containing the worker name and a scoped `IServiceProvider`. It is a good practice to **resolve dependencies** from the `workerContext.ServiceProvider` instead of constructor injection. +* At least one of `Period` or `CronExpression` must be set in `DynamicBackgroundWorkerSchedule`. +* **`CronExpression` is only supported by scheduler-backed providers ([Hangfire](./hangfire.md), [Quartz](./quartz.md)).** The default in-memory provider requires `Period` and does not support `CronExpression` alone. +* **[TickerQ](./tickerq.md) does not support dynamic background workers** because it uses `FrozenDictionary` for function registration, which requires all functions to be registered before the application starts. +* `RemoveAsync` stops and removes a dynamic worker. Returns `true` if the worker was found and removed. The exact semantics are provider-dependent — for persistent providers (Hangfire, Quartz), the persistent scheduling record is always cleaned up, but the return value may only reflect the in-memory registry state. +* `UpdateScheduleAsync` changes the schedule of an existing dynamic worker. The handler itself is not changed. Returns `true` if the schedule was updated. The exact semantics are provider-dependent — for persistent providers (Hangfire, Quartz), this also works correctly after an application restart, updating the persistent scheduling record even if the handler is no longer registered in memory. + +> **Important:** Dynamic worker handlers are stored **in memory only** and are not persisted across application restarts. When using a persistent scheduler provider (Hangfire or Quartz), the recurring job entries remain in the database after a restart, but the handlers will no longer be registered. Until the handler is re-registered, each scheduled execution will be **skipped with a warning log**. To ensure handlers are always available, register them in `OnApplicationInitializationAsync` so they are re-registered on every startup. + ## Options `AbpBackgroundWorkerOptions` class is used to [set options](../../fundamentals/options.md) for the background workers. Currently, there is only one option: diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/AbpBackgroundJobOptions.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/AbpBackgroundJobOptions.cs index 1a6cb6a9e9..8494f5df3e 100644 --- a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/AbpBackgroundJobOptions.cs +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/AbpBackgroundJobOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -46,7 +46,7 @@ public class AbpBackgroundJobOptions public BackgroundJobConfiguration GetJob(string name) { - var jobConfiguration = _jobConfigurationsByName.GetOrDefault(name); + var jobConfiguration = GetJobOrNull(name); if (jobConfiguration == null) { @@ -56,6 +56,11 @@ public class AbpBackgroundJobOptions return jobConfiguration; } + public BackgroundJobConfiguration? GetJobOrNull(string name) + { + return _jobConfigurationsByName.GetOrDefault(name); + } + public IReadOnlyList GetJobs() { return _jobConfigurationsByArgsType.Values.ToImmutableList(); diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DefaultDynamicBackgroundJobManager.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DefaultDynamicBackgroundJobManager.cs new file mode 100644 index 0000000000..7028e0f858 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DefaultDynamicBackgroundJobManager.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Json; + +namespace Volo.Abp.BackgroundJobs; + +public class DefaultDynamicBackgroundJobManager : IDynamicBackgroundJobManager, ITransientDependency +{ + private static readonly ConcurrentDictionary>> EnqueueDelegateCache = new(); + + protected IBackgroundJobManager BackgroundJobManager { get; } + protected IDynamicBackgroundJobHandlerRegistry HandlerRegistry { get; } + protected AbpBackgroundJobOptions BackgroundJobOptions { get; } + protected IJsonSerializer JsonSerializer { get; } + public ILogger Logger { get; set; } + + public DefaultDynamicBackgroundJobManager( + IBackgroundJobManager backgroundJobManager, + IDynamicBackgroundJobHandlerRegistry handlerRegistry, + IOptions backgroundJobOptions, + IJsonSerializer jsonSerializer) + { + BackgroundJobManager = backgroundJobManager; + HandlerRegistry = handlerRegistry; + BackgroundJobOptions = backgroundJobOptions.Value; + JsonSerializer = jsonSerializer; + Logger = NullLogger.Instance; + } + + public virtual async Task EnqueueAsync( + string jobName, + object args, + BackgroundJobPriority priority = BackgroundJobPriority.Normal, + TimeSpan? delay = null) + { + Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + Check.NotNull(args, nameof(args)); + + var jobConfiguration = BackgroundJobOptions.GetJobOrNull(jobName); + if (jobConfiguration != null) + { + return await EnqueueTypedJobAsync(jobConfiguration, args, priority, delay); + } + + if (HandlerRegistry.IsRegistered(jobName)) + { + return await EnqueueDynamicHandlerJobAsync(jobName, args, priority, delay); + } + + throw new AbpException( + $"No typed job configuration or dynamic handler registered for job name: {jobName}"); + } + + public virtual void RegisterHandler( + string jobName, + DynamicBackgroundJobHandler handler) + { + HandlerRegistry.Register(jobName, handler); + } + + public virtual bool UnregisterHandler(string jobName) + { + return HandlerRegistry.Unregister(jobName); + } + + public virtual bool IsHandlerRegistered(string jobName) + { + return HandlerRegistry.IsRegistered(jobName); + } + + protected virtual async Task EnqueueTypedJobAsync( + BackgroundJobConfiguration jobConfiguration, + object args, + BackgroundJobPriority priority, + TimeSpan? delay) + { + var argsType = jobConfiguration.ArgsType; + + // Normalize args to the expected type via JSON round-trip + var json = JsonSerializer.Serialize(args); + var typedArgs = JsonSerializer.Deserialize(argsType, json); + + var enqueueDelegate = GetOrCreateEnqueueDelegate(argsType); + return await enqueueDelegate(BackgroundJobManager, typedArgs, priority, delay); + } + + protected virtual Task EnqueueDynamicHandlerJobAsync( + string jobName, + object args, + BackgroundJobPriority priority, + TimeSpan? delay) + { + var jsonData = JsonSerializer.Serialize(args); + var dynamicArgs = new DynamicBackgroundJobArgs(jobName, jsonData); + return BackgroundJobManager.EnqueueAsync(dynamicArgs, priority, delay); + } + + private static Func> GetOrCreateEnqueueDelegate(Type argsType) + { + return EnqueueDelegateCache.GetOrAdd(argsType, static type => + { + var method = typeof(IBackgroundJobManager) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(m => + m.Name == nameof(IBackgroundJobManager.EnqueueAsync) + && m.IsGenericMethodDefinition + && m.GetParameters() is { Length: 3 } p + && p[1].ParameterType == typeof(BackgroundJobPriority) + && p[2].ParameterType == typeof(TimeSpan?)); + + if (method == null) + { + throw new AbpException( + $"Could not find the generic EnqueueAsync method on {nameof(IBackgroundJobManager)}."); + } + + var genericMethod = method.MakeGenericMethod(type); + + // Build: (manager, args, priority, delay) => manager.EnqueueAsync((TArgs)args, priority, delay) + var managerParam = Expression.Parameter(typeof(IBackgroundJobManager), "manager"); + var argsParam = Expression.Parameter(typeof(object), "args"); + var priorityParam = Expression.Parameter(typeof(BackgroundJobPriority), "priority"); + var delayParam = Expression.Parameter(typeof(TimeSpan?), "delay"); + + var call = Expression.Call( + managerParam, + genericMethod, + Expression.Convert(argsParam, type), + priorityParam, + delayParam); + + return Expression.Lambda>>( + call, managerParam, argsParam, priorityParam, delayParam).Compile(); + }); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobArgs.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobArgs.cs new file mode 100644 index 0000000000..45066b7586 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobArgs.cs @@ -0,0 +1,24 @@ +namespace Volo.Abp.BackgroundJobs; + +[BackgroundJobName(JobNameConstant)] +public class DynamicBackgroundJobArgs +{ + public const string JobNameConstant = "Abp.DynamicJob"; + + public string JobName { get; private set; } + + public string JsonData { get; private set; } + + // For serializers that require a parameterless constructor (e.g. System.Text.Json) + private DynamicBackgroundJobArgs() + { + JobName = string.Empty; + JsonData = string.Empty; + } + + public DynamicBackgroundJobArgs(string jobName, string jsonData) + { + JobName = Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + JsonData = Check.NotNull(jsonData, nameof(jsonData)); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutionContext.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutionContext.cs new file mode 100644 index 0000000000..6b207cac97 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutionContext.cs @@ -0,0 +1,22 @@ +using System; + +namespace Volo.Abp.BackgroundJobs; + +public class DynamicBackgroundJobExecutionContext +{ + public string JobName { get; } + + public string JsonData { get; } + + public IServiceProvider ServiceProvider { get; } + + public DynamicBackgroundJobExecutionContext( + string jobName, + string jsonData, + IServiceProvider serviceProvider) + { + JobName = Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + JsonData = Check.NotNull(jsonData, nameof(jsonData)); + ServiceProvider = Check.NotNull(serviceProvider, nameof(serviceProvider)); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutorJob.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutorJob.cs new file mode 100644 index 0000000000..5818e6de70 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobExecutorJob.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Threading; + +namespace Volo.Abp.BackgroundJobs; + +public class DynamicBackgroundJobExecutorJob : AsyncBackgroundJob, ITransientDependency +{ + protected IDynamicBackgroundJobHandlerRegistry HandlerRegistry { get; } + protected IServiceProvider ServiceProvider { get; } + + public DynamicBackgroundJobExecutorJob( + IDynamicBackgroundJobHandlerRegistry handlerRegistry, + IServiceProvider serviceProvider) + { + HandlerRegistry = handlerRegistry; + ServiceProvider = serviceProvider; + } + + public override async Task ExecuteAsync(DynamicBackgroundJobArgs args) + { + Logger.LogDebug( + "Executing dynamic job. TransportJobName: {TransportJobName}, EffectiveJobName: {EffectiveJobName}", + DynamicBackgroundJobArgs.JobNameConstant, + args.JobName + ); + + var handler = HandlerRegistry.Get(args.JobName); + if (handler == null) + { + throw new AbpException( + $"No dynamic job handler registered for: {args.JobName}. " + + $"The handler may have been unregistered or the application restarted since the job was enqueued."); + } + + var cancellationToken = ServiceProvider.GetRequiredService().Token; + var executionContext = new DynamicBackgroundJobExecutionContext(args.JobName, args.JsonData, ServiceProvider); + await handler(executionContext, cancellationToken); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandler.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandler.cs new file mode 100644 index 0000000000..f1f9d17602 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandler.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Represents a handler delegate for dynamic background jobs. +/// +public delegate Task DynamicBackgroundJobHandler(DynamicBackgroundJobExecutionContext context, CancellationToken cancellationToken); diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandlerRegistry.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandlerRegistry.cs new file mode 100644 index 0000000000..101277b789 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/DynamicBackgroundJobHandlerRegistry.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs; + +public class DynamicBackgroundJobHandlerRegistry : IDynamicBackgroundJobHandlerRegistry, ISingletonDependency +{ + protected ConcurrentDictionary Handlers { get; } + + public DynamicBackgroundJobHandlerRegistry() + { + Handlers = new ConcurrentDictionary(); + } + + public virtual void Register(string jobName, DynamicBackgroundJobHandler handler) + { + Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + Check.NotNull(handler, nameof(handler)); + + Handlers[jobName] = handler; + } + + public virtual bool Unregister(string jobName) + { + Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + return Handlers.TryRemove(jobName, out _); + } + + public virtual bool IsRegistered(string jobName) + { + Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + return Handlers.ContainsKey(jobName); + } + + public virtual DynamicBackgroundJobHandler? Get(string jobName) + { + Check.NotNullOrWhiteSpace(jobName, nameof(jobName)); + return Handlers.TryGetValue(jobName, out var handler) ? handler : null; + } + + public virtual IReadOnlyCollection GetAllNames() + { + return Handlers.Keys.ToList().AsReadOnly(); + } + + public virtual void Clear() + { + Handlers.Clear(); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobHandlerRegistry.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobHandlerRegistry.cs new file mode 100644 index 0000000000..57e75d1607 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobHandlerRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Volo.Abp.BackgroundJobs; + +public interface IDynamicBackgroundJobHandlerRegistry +{ + void Register(string jobName, DynamicBackgroundJobHandler handler); + + bool Unregister(string jobName); + + bool IsRegistered(string jobName); + + DynamicBackgroundJobHandler? Get(string jobName); + + IReadOnlyCollection GetAllNames(); + + void Clear(); +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobManager.cs b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobManager.cs new file mode 100644 index 0000000000..a94a71cd02 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs.Abstractions/Volo/Abp/BackgroundJobs/IDynamicBackgroundJobManager.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Manages dynamic background jobs that can be enqueued by name +/// without requiring a strongly-typed job args class at compile time. +/// Also supports registering dynamic job handlers at runtime. +/// +public interface IDynamicBackgroundJobManager +{ + /// + /// Enqueues a job by name with dynamic payload. + /// If a typed job configuration exists for this name, the args will be + /// deserialized to the configured args type and enqueued through the typed pipeline. + /// If a dynamic handler is registered, the args will be wrapped as + /// and enqueued through the standard typed pipeline. + /// + /// Name of the background job. + /// Job arguments (will be serialized to JSON). + /// Job priority. + /// Job delay (wait duration before first try). + /// Unique identifier of a background job. + Task EnqueueAsync( + string jobName, + object args, + BackgroundJobPriority priority = BackgroundJobPriority.Normal, + TimeSpan? delay = null); + + /// + /// Registers a dynamic job handler at runtime. + /// + /// Unique name for the dynamic job. + /// The handler delegate to execute when the job runs. + void RegisterHandler(string jobName, DynamicBackgroundJobHandler handler); + + /// + /// Unregisters a previously registered dynamic job handler. + /// + /// Name of the dynamic job to unregister. + /// True if the handler was found and removed; false otherwise. + bool UnregisterHandler(string jobName); + + /// + /// Checks whether a dynamic handler is registered for the given job name. + /// + /// Name of the dynamic job. + /// True if registered; false otherwise. + bool IsHandlerRegistered(string jobName); +} diff --git a/framework/src/Volo.Abp.BackgroundJobs.HangFire/Volo/Abp/BackgroundJobs/Hangfire/AbpDashboardOptionsProvider.cs b/framework/src/Volo.Abp.BackgroundJobs.HangFire/Volo/Abp/BackgroundJobs/Hangfire/AbpDashboardOptionsProvider.cs index e68489290a..2f861a33b5 100644 --- a/framework/src/Volo.Abp.BackgroundJobs.HangFire/Volo/Abp/BackgroundJobs/Hangfire/AbpDashboardOptionsProvider.cs +++ b/framework/src/Volo.Abp.BackgroundJobs.HangFire/Volo/Abp/BackgroundJobs/Hangfire/AbpDashboardOptionsProvider.cs @@ -24,7 +24,14 @@ public class AbpDashboardOptionsProvider : ITransientDependency var jobName = job.ToString(); if (job.Args.Count == 3 && job.Args.Last() is CancellationToken) { - jobName = AbpBackgroundJobOptions.GetJob(job.Args[1].GetType()).JobName; + if (job.Args[1] is DynamicBackgroundJobArgs dynamicJobArgs) + { + jobName = dynamicJobArgs.JobName; + } + else + { + jobName = AbpBackgroundJobOptions.GetJob(job.Args[1].GetType()).JobName; + } } return jobName; diff --git a/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireBackgroundWorkerManager.cs index 64a4a1be64..e23737d2a1 100644 --- a/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireBackgroundWorkerManager.cs +++ b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireBackgroundWorkerManager.cs @@ -162,19 +162,23 @@ public class HangfireBackgroundWorkerManager : BackgroundWorkerManager, ISinglet if (time.TotalSeconds <= 59) { - cron = $"*/{time.TotalSeconds} * * * * *"; + var seconds = Math.Max(1, (int)Math.Round(time.TotalSeconds)); + cron = $"*/{seconds} * * * * *"; } else if (time.TotalMinutes <= 59) { - cron = $"*/{time.TotalMinutes} * * * *"; + var minutes = Math.Max(1, (int)Math.Round(time.TotalMinutes)); + cron = $"*/{minutes} * * * *"; } else if (time.TotalHours <= 23) { - cron = $"0 */{time.TotalHours} * * *"; + var hours = Math.Max(1, (int)Math.Round(time.TotalHours)); + cron = $"0 */{hours} * * *"; } else if(time.TotalDays <= 31) { - cron = $"0 0 0 1/{time.TotalDays} * *"; + var days = Math.Max(1, (int)Math.Round(time.TotalDays)); + cron = $"0 0 0 1/{days} * *"; } else { diff --git a/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerAdapter.cs b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerAdapter.cs new file mode 100644 index 0000000000..56a5d7a5c8 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerAdapter.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ExceptionHandling; + +namespace Volo.Abp.BackgroundWorkers.Hangfire; + +public class HangfireDynamicBackgroundWorkerAdapter : ITransientDependency +{ + protected IDynamicBackgroundWorkerHandlerRegistry HandlerRegistry { get; } + protected IServiceProvider ServiceProvider { get; } + public ILogger Logger { get; set; } + + public HangfireDynamicBackgroundWorkerAdapter( + IDynamicBackgroundWorkerHandlerRegistry handlerRegistry, + IServiceProvider serviceProvider) + { + HandlerRegistry = handlerRegistry; + ServiceProvider = serviceProvider; + Logger = NullLogger.Instance; + } + + public virtual async Task DoWorkAsync(string workerName, CancellationToken cancellationToken = default) + { + var handler = HandlerRegistry.Get(workerName); + if (handler == null) + { + Logger.LogWarning("No handler registered for dynamic worker: {WorkerName}", workerName); + return; + } + + try + { + await handler(new DynamicBackgroundWorkerExecutionContext(workerName, ServiceProvider), cancellationToken); + } + catch (Exception ex) + { + // Swallow the exception to match the behavior of AsyncPeriodicBackgroundWorkerBase, + // which catches, notifies and logs without rethrowing. This prevents Hangfire from + // treating a single failed execution as a job failure and triggering retries. + await ServiceProvider.GetRequiredService() + .NotifyAsync(new ExceptionNotificationContext(ex)); + + Logger.LogException(ex); + } + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerManager.cs new file mode 100644 index 0000000000..a9b9026606 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers.Hangfire/Volo/Abp/BackgroundWorkers/Hangfire/HangfireDynamicBackgroundWorkerManager.cs @@ -0,0 +1,183 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Hangfire; +using Hangfire.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Hangfire; + +namespace Volo.Abp.BackgroundWorkers.Hangfire; + +[Dependency(ReplaceServices = true)] +public class HangfireDynamicBackgroundWorkerManager : IDynamicBackgroundWorkerManager, ISingletonDependency +{ + protected IServiceProvider ServiceProvider { get; } + protected IDynamicBackgroundWorkerHandlerRegistry HandlerRegistry { get; } + public ILogger Logger { get; set; } + + public HangfireDynamicBackgroundWorkerManager( + IServiceProvider serviceProvider, + IDynamicBackgroundWorkerHandlerRegistry handlerRegistry) + { + ServiceProvider = serviceProvider; + HandlerRegistry = handlerRegistry; + Logger = NullLogger.Instance; + } + + public virtual Task AddAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + Check.NotNull(handler, nameof(handler)); + + schedule.Validate(); + + var cronExpression = schedule.CronExpression; + if (cronExpression.IsNullOrWhiteSpace()) + { + var period = schedule.Period ?? DynamicBackgroundWorkerSchedule.DefaultPeriod; + cronExpression = GetCron(period); + } + + // Register the handler first so it is available the moment the recurring job fires. + HandlerRegistry.Register(workerName, handler); + try + { + ScheduleRecurringJob(workerName, cronExpression, cancellationToken); + } + catch + { + HandlerRegistry.Unregister(workerName); + throw; + } + + return Task.CompletedTask; + } + + public virtual Task RemoveAsync(string workerName, CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + + // Always remove the persistent recurring job regardless of in-memory registry state. + // This ensures cleanup works correctly after an application restart, when the registry + // is empty but the Hangfire recurring job may still exist in the database. + var recurringJobId = $"DynamicWorker:{workerName}"; + RecurringJob.RemoveIfExists(recurringJobId); + var wasRegistered = HandlerRegistry.Unregister(workerName); + + return Task.FromResult(wasRegistered); + } + + public virtual Task UpdateScheduleAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + + schedule.Validate(); + + var cronExpression = schedule.CronExpression; + if (cronExpression.IsNullOrWhiteSpace()) + { + var period = schedule.Period ?? DynamicBackgroundWorkerSchedule.DefaultPeriod; + cronExpression = GetCron(period); + } + + // Always update the persistent recurring job regardless of in-memory registry state. + // This ensures UpdateScheduleAsync works correctly after an application restart, + // when the registry is empty but the Hangfire recurring job may still exist in the database. + ScheduleRecurringJob(workerName, cronExpression, cancellationToken); + + return Task.FromResult(true); + } + + public virtual bool IsRegistered(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return HandlerRegistry.IsRegistered(workerName); + } + + public virtual Task StopAllAsync(CancellationToken cancellationToken = default) + { + HandlerRegistry.Clear(); + return Task.CompletedTask; + } + + protected virtual void ScheduleRecurringJob(string workerName, string cronExpression, CancellationToken cancellationToken) + { + var abpHangfireOptions = ServiceProvider.GetRequiredService>().Value; + var queueName = abpHangfireOptions.DefaultQueue; + var recurringJobId = $"DynamicWorker:{workerName}"; + + if (!JobStorage.Current.HasFeature(JobStorageFeatures.JobQueueProperty)) + { + Logger.LogWarning( + "Current storage doesn't support specifying queues ({QueueName}) directly for a specific job. Please use the QueueAttribute instead.", + queueName); + + RecurringJob.AddOrUpdate( + recurringJobId, + adapter => adapter.DoWorkAsync(workerName, CancellationToken.None), + cronExpression, + new RecurringJobOptions + { + TimeZone = TimeZoneInfo.Utc + }); + } + else + { + RecurringJob.AddOrUpdate( + recurringJobId, + queueName, + adapter => adapter.DoWorkAsync(workerName, CancellationToken.None), + cronExpression, + new RecurringJobOptions + { + TimeZone = TimeZoneInfo.Utc + }); + } + } + + protected virtual string GetCron(int period) + { + var time = TimeSpan.FromMilliseconds(period); + string cron; + + if (time.TotalSeconds <= 59) + { + var seconds = Math.Max(1, (int)Math.Round(time.TotalSeconds)); + cron = $"*/{seconds} * * * * *"; + } + else if (time.TotalMinutes <= 59) + { + var minutes = Math.Max(1, (int)Math.Round(time.TotalMinutes)); + cron = $"*/{minutes} * * * *"; + } + else if (time.TotalHours <= 23) + { + var hours = Math.Max(1, (int)Math.Round(time.TotalHours)); + cron = $"0 */{hours} * * *"; + } + else if (time.TotalDays <= 31) + { + var days = Math.Max(1, (int)Math.Round(time.TotalDays)); + cron = $"0 0 0 1/{days} * *"; + } + else + { + throw new AbpException($"Cannot convert period: {period} to cron expression."); + } + + return cron; + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerAdapter.cs b/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerAdapter.cs new file mode 100644 index 0000000000..d27395bab2 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerAdapter.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Quartz; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ExceptionHandling; + +namespace Volo.Abp.BackgroundWorkers.Quartz; + +public class QuartzDynamicBackgroundWorkerAdapter : IJob, ITransientDependency +{ + protected IDynamicBackgroundWorkerHandlerRegistry HandlerRegistry { get; } + protected IServiceProvider ServiceProvider { get; } + public ILogger Logger { get; set; } + + public QuartzDynamicBackgroundWorkerAdapter( + IDynamicBackgroundWorkerHandlerRegistry handlerRegistry, + IServiceProvider serviceProvider) + { + HandlerRegistry = handlerRegistry; + ServiceProvider = serviceProvider; + Logger = NullLogger.Instance; + } + + public virtual async Task Execute(IJobExecutionContext context) + { + var rawWorkerName = context.MergedJobDataMap.GetString(QuartzDynamicBackgroundWorkerManager.DynamicWorkerNameKey); + if (string.IsNullOrWhiteSpace(rawWorkerName)) + { + return; + } + + var workerName = rawWorkerName!; + var handler = HandlerRegistry.Get(workerName); + if (handler == null) + { + Logger.LogWarning("No handler registered for dynamic worker: {WorkerName}", workerName); + return; + } + + try + { + await handler( + new DynamicBackgroundWorkerExecutionContext(workerName, ServiceProvider), + context.CancellationToken); + } + catch (Exception ex) + { + // Swallow the exception to match the behavior of AsyncPeriodicBackgroundWorkerBase, + // which catches, notifies and logs without rethrowing. This prevents Quartz from + // treating a single failed execution as a job failure and triggering retries. + await ServiceProvider.GetRequiredService() + .NotifyAsync(new ExceptionNotificationContext(ex)); + + Logger.LogException(ex); + } + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerManager.cs new file mode 100644 index 0000000000..5a729ad974 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers.Quartz/Volo/Abp/BackgroundWorkers/Quartz/QuartzDynamicBackgroundWorkerManager.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Quartz; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundWorkers.Quartz; + +[Dependency(ReplaceServices = true)] +public class QuartzDynamicBackgroundWorkerManager : IDynamicBackgroundWorkerManager, ISingletonDependency +{ + public const string DynamicWorkerNameKey = "AbpDynamicWorkerName"; + + protected IScheduler Scheduler { get; } + protected IDynamicBackgroundWorkerHandlerRegistry HandlerRegistry { get; } + public ILogger Logger { get; set; } + + public QuartzDynamicBackgroundWorkerManager( + IScheduler scheduler, + IDynamicBackgroundWorkerHandlerRegistry handlerRegistry) + { + Scheduler = scheduler; + HandlerRegistry = handlerRegistry; + Logger = NullLogger.Instance; + } + + public virtual async Task AddAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + Check.NotNull(handler, nameof(handler)); + + schedule.Validate(); + + var jobKey = new JobKey($"DynamicWorker:{workerName}"); + var triggerKey = new TriggerKey($"DynamicWorker:{workerName}"); + var jobDetail = JobBuilder.Create() + .WithIdentity(jobKey) + .UsingJobData(DynamicWorkerNameKey, workerName) + .Build(); + + var trigger = BuildTrigger(schedule, jobDetail, triggerKey); + + // Register the handler first so it is available the moment the job fires. + HandlerRegistry.Register(workerName, handler); + try + { + // Use replace=true to avoid TOCTOU race between CheckExists and ScheduleJob. + await Scheduler.ScheduleJobs( + new Dictionary> + { + { jobDetail, new[] { trigger } } + }, + replace: true, + cancellationToken); + } + catch + { + HandlerRegistry.Unregister(workerName); + throw; + } + } + + public virtual async Task RemoveAsync(string workerName, CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + + // Always delete the persistent Quartz job regardless of in-memory registry state. + // This ensures cleanup works correctly after an application restart, when the registry + // is empty but the Quartz job may still exist in the scheduler store. + var jobKey = new JobKey($"DynamicWorker:{workerName}"); + var deleted = await Scheduler.DeleteJob(jobKey, cancellationToken); + var wasRegistered = HandlerRegistry.Unregister(workerName); + + return deleted || wasRegistered; + } + + public virtual async Task UpdateScheduleAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + + schedule.Validate(); + + var triggerKey = new TriggerKey($"DynamicWorker:{workerName}"); + var jobKey = new JobKey($"DynamicWorker:{workerName}"); + var jobDetail = JobBuilder.Create() + .WithIdentity(jobKey) + .UsingJobData(DynamicWorkerNameKey, workerName) + .Build(); + + var trigger = BuildTrigger(schedule, jobDetail, triggerKey); + + // Always attempt to reschedule the persistent job regardless of in-memory registry state. + // This ensures UpdateScheduleAsync works correctly after an application restart, + // when the registry is empty but the Quartz job may still exist in the scheduler store. + // RescheduleJob returns null if the trigger was not found, indicating the job did not exist. + var result = await Scheduler.RescheduleJob(triggerKey, trigger, cancellationToken); + return result != null; + } + + public virtual bool IsRegistered(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return HandlerRegistry.IsRegistered(workerName); + } + + public virtual Task StopAllAsync(CancellationToken cancellationToken = default) + { + HandlerRegistry.Clear(); + return Task.CompletedTask; + } + + protected virtual ITrigger BuildTrigger(DynamicBackgroundWorkerSchedule schedule, IJobDetail jobDetail, TriggerKey triggerKey) + { + var triggerBuilder = TriggerBuilder.Create() + .ForJob(jobDetail) + .WithIdentity(triggerKey); + + if (!schedule.CronExpression.IsNullOrWhiteSpace()) + { + triggerBuilder.WithCronSchedule(schedule.CronExpression); + } + else + { + triggerBuilder.WithSimpleSchedule(builder => + builder.WithInterval(TimeSpan.FromMilliseconds(schedule.Period!.Value)).RepeatForever()); + } + + return triggerBuilder.Build(); + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/TickerQDynamicBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/TickerQDynamicBackgroundWorkerManager.cs new file mode 100644 index 0000000000..6976f49cb5 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/TickerQDynamicBackgroundWorkerManager.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundWorkers.TickerQ; + +[Dependency(ReplaceServices = true)] +public class TickerQDynamicBackgroundWorkerManager : IDynamicBackgroundWorkerManager, ISingletonDependency +{ + public virtual Task AddAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default) + { + throw new AbpException( + "TickerQ does not support dynamic background worker registration at runtime. " + + "TickerQ uses FrozenDictionary for function registration, which requires all functions to be registered before the application starts. " + + "Please use Hangfire or Quartz provider for dynamic background workers."); + } + + public virtual Task RemoveAsync(string workerName, CancellationToken cancellationToken = default) + { + throw new AbpException( + "TickerQ does not support dynamic background worker registration at runtime. " + + "Please use Hangfire or Quartz provider for dynamic background workers."); + } + + public virtual Task UpdateScheduleAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + CancellationToken cancellationToken = default) + { + throw new AbpException( + "TickerQ does not support dynamic background worker registration at runtime. " + + "Please use Hangfire or Quartz provider for dynamic background workers."); + } + + public virtual bool IsRegistered(string workerName) + { + // TickerQ does not support runtime registration, so there are never any registered workers. + return false; + } + + public virtual Task StopAllAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/AbpBackgroundWorkersModule.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/AbpBackgroundWorkersModule.cs index 3b1b18e8b3..1d944817bf 100644 --- a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/AbpBackgroundWorkersModule.cs +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/AbpBackgroundWorkersModule.cs @@ -49,6 +49,10 @@ public class AbpBackgroundWorkersModule : AbpModule await context.ServiceProvider .GetRequiredService() .StopAsync(cancellationToken); + + await context.ServiceProvider + .GetRequiredService() + .StopAllAsync(cancellationToken); } } diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DefaultDynamicBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DefaultDynamicBackgroundWorkerManager.cs new file mode 100644 index 0000000000..683d42687f --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DefaultDynamicBackgroundWorkerManager.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Threading; + +namespace Volo.Abp.BackgroundWorkers; + +public class DefaultDynamicBackgroundWorkerManager : IDynamicBackgroundWorkerManager, ISingletonDependency +{ + protected IServiceProvider ServiceProvider { get; } + public ILogger Logger { get; set; } + + private readonly ConcurrentDictionary _dynamicWorkers; + private readonly SemaphoreSlim _semaphore; + private volatile bool _isDisposed; + + public DefaultDynamicBackgroundWorkerManager(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + Logger = NullLogger.Instance; + _dynamicWorkers = new ConcurrentDictionary(); + _semaphore = new SemaphoreSlim(1, 1); + } + + public virtual async Task AddAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + Check.NotNull(handler, nameof(handler)); + + schedule.Validate(); + + if (schedule.Period == null) + { + throw new AbpException( + $"The default in-memory background worker manager does not support CronExpression without Period for dynamic worker '{workerName}'. " + + "Please set Period, or use a scheduler-backed provider (Hangfire, Quartz, TickerQ)."); + } + + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(DefaultDynamicBackgroundWorkerManager)); + } + + if (_dynamicWorkers.TryRemove(workerName, out var existingWorker)) + { + await existingWorker.StopAsync(cancellationToken); + Logger.LogInformation("Replaced existing dynamic worker: {WorkerName}", workerName); + } + + var worker = CreateDynamicWorker(workerName, schedule, handler); + _dynamicWorkers[workerName] = worker; + + await worker.StartAsync(cancellationToken); + } + finally + { + _semaphore.Release(); + } + } + + public virtual async Task RemoveAsync(string workerName, CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + + await _semaphore.WaitAsync(cancellationToken); + try + { + if (!_dynamicWorkers.TryRemove(workerName, out var worker)) + { + return false; + } + + await worker.StopAsync(cancellationToken); + return true; + } + finally + { + _semaphore.Release(); + } + } + + public virtual async Task UpdateScheduleAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + CancellationToken cancellationToken = default) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + + schedule.Validate(); + + if (schedule.Period == null) + { + throw new AbpException( + $"The default in-memory background worker manager does not support CronExpression without Period for dynamic worker '{workerName}'. " + + "Please set Period, or use a scheduler-backed provider (Hangfire, Quartz, TickerQ)."); + } + + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(DefaultDynamicBackgroundWorkerManager)); + } + + if (!_dynamicWorkers.TryGetValue(workerName, out var worker)) + { + return false; + } + + worker.UpdateSchedule(schedule); + return true; + } + finally + { + _semaphore.Release(); + } + } + + public virtual bool IsRegistered(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return _dynamicWorkers.ContainsKey(workerName); + } + + public virtual async Task StopAllAsync(CancellationToken cancellationToken = default) + { + if (_isDisposed) + { + return; + } + + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + foreach (var kvp in _dynamicWorkers) + { + try + { + await kvp.Value.StopAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogException(ex); + } + } + + _dynamicWorkers.Clear(); + } + finally + { + _semaphore.Release(); + } + } + + protected virtual InMemoryDynamicBackgroundWorker CreateDynamicWorker( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler) + { + var timer = ServiceProvider.GetRequiredService(); + var serviceScopeFactory = ServiceProvider.GetRequiredService(); + + var worker = new InMemoryDynamicBackgroundWorker( + workerName, schedule, handler, timer, serviceScopeFactory); + + worker.ServiceProvider = ServiceProvider; + worker.LazyServiceProvider = ServiceProvider.GetRequiredService(); + + return worker; + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerExecutionContext.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerExecutionContext.cs new file mode 100644 index 0000000000..edb810d105 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerExecutionContext.cs @@ -0,0 +1,16 @@ +using System; + +namespace Volo.Abp.BackgroundWorkers; + +public class DynamicBackgroundWorkerExecutionContext +{ + public string WorkerName { get; } + + public IServiceProvider ServiceProvider { get; } + + public DynamicBackgroundWorkerExecutionContext(string workerName, IServiceProvider serviceProvider) + { + WorkerName = Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + ServiceProvider = Check.NotNull(serviceProvider, nameof(serviceProvider)); + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandler.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandler.cs new file mode 100644 index 0000000000..28f5c933d8 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandler.cs @@ -0,0 +1,6 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BackgroundWorkers; + +public delegate Task DynamicBackgroundWorkerHandler(DynamicBackgroundWorkerExecutionContext context, CancellationToken cancellationToken); \ No newline at end of file diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandlerRegistry.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandlerRegistry.cs new file mode 100644 index 0000000000..55e8617915 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerHandlerRegistry.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundWorkers; + +public class DynamicBackgroundWorkerHandlerRegistry : IDynamicBackgroundWorkerHandlerRegistry, ISingletonDependency +{ + protected ConcurrentDictionary Handlers { get; } + + public DynamicBackgroundWorkerHandlerRegistry() + { + Handlers = new ConcurrentDictionary(); + } + + public virtual void Register(string workerName, DynamicBackgroundWorkerHandler handler) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(handler, nameof(handler)); + + Handlers[workerName] = handler; + } + + public virtual bool Unregister(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return Handlers.TryRemove(workerName, out _); + } + + public virtual bool IsRegistered(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return Handlers.ContainsKey(workerName); + } + + public virtual DynamicBackgroundWorkerHandler? Get(string workerName) + { + Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + return Handlers.TryGetValue(workerName, out var handler) ? handler : null; + } + + public virtual IReadOnlyCollection GetAllNames() + { + return Handlers.Keys.ToList().AsReadOnly(); + } + + public virtual void Clear() + { + Handlers.Clear(); + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManagerExtensions.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManagerExtensions.cs new file mode 100644 index 0000000000..5fedbf1360 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManagerExtensions.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BackgroundWorkers; + +public static class DynamicBackgroundWorkerManagerExtensions +{ + /// + /// Adds a dynamic worker with the default schedule (). + /// + public static Task AddAsync( + this IDynamicBackgroundWorkerManager manager, + string workerName, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default) + { + return manager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = DynamicBackgroundWorkerSchedule.DefaultPeriod + }, + handler, + cancellationToken); + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerSchedule.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerSchedule.cs new file mode 100644 index 0000000000..6505492e44 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerSchedule.cs @@ -0,0 +1,28 @@ +using System; + +namespace Volo.Abp.BackgroundWorkers; + +public class DynamicBackgroundWorkerSchedule +{ + public const int DefaultPeriod = 60000; + + public int? Period { get; set; } + + public string? CronExpression { get; set; } + + public virtual void Validate() + { + if (Period.HasValue && Period.Value <= 0) + { + throw new ArgumentException( + $"Period must be greater than 0 when provided. Given value: {Period.Value}.", + nameof(Period)); + } + + if (Period == null && string.IsNullOrWhiteSpace(CronExpression)) + { + throw new ArgumentException( + "At least one of 'Period' or 'CronExpression' must be set."); + } + } +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerHandlerRegistry.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerHandlerRegistry.cs new file mode 100644 index 0000000000..0b7d6757dc --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerHandlerRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Volo.Abp.BackgroundWorkers; + +public interface IDynamicBackgroundWorkerHandlerRegistry +{ + void Register(string workerName, DynamicBackgroundWorkerHandler handler); + + bool Unregister(string workerName); + + bool IsRegistered(string workerName); + + DynamicBackgroundWorkerHandler? Get(string workerName); + + IReadOnlyCollection GetAllNames(); + + void Clear(); +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerManager.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerManager.cs new file mode 100644 index 0000000000..7e625e7c9c --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/IDynamicBackgroundWorkerManager.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BackgroundWorkers; + +/// +/// Manages dynamic background workers that are registered at runtime +/// without requiring a strongly-typed worker class. +/// +public interface IDynamicBackgroundWorkerManager +{ + /// + /// Adds a dynamic worker by name, schedule and handler. + /// If a worker with the same name already exists, it will be replaced. + /// + Task AddAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + CancellationToken cancellationToken = default); + + /// + /// Removes a previously added dynamic worker by name. + /// Always attempts to remove both the in-memory handler and any persistent scheduling record + /// (Hangfire RecurringJob or Quartz job), regardless of current in-memory state. + /// Returns true if the handler was registered in memory at the time of the call, or if + /// the persistent scheduling record was found and deleted (provider-dependent). + /// May return false after an application restart even if a persistent record was cleaned up, + /// when the provider cannot report whether a persistent record existed (e.g. Hangfire). + /// + Task RemoveAsync(string workerName, CancellationToken cancellationToken = default); + + /// + /// Updates the schedule of a previously added dynamic worker. + /// Returns true if the worker was found and updated; false otherwise. + /// + Task UpdateScheduleAsync( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + CancellationToken cancellationToken = default); + + /// + /// Checks whether a dynamic worker with the given name is registered. + /// + bool IsRegistered(string workerName); + + /// + /// Stops all dynamic workers and releases resources. + /// Called during application shutdown. + /// + Task StopAllAsync(CancellationToken cancellationToken = default); +} diff --git a/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/InMemoryDynamicBackgroundWorker.cs b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/InMemoryDynamicBackgroundWorker.cs new file mode 100644 index 0000000000..bab468a655 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundWorkers/Volo/Abp/BackgroundWorkers/InMemoryDynamicBackgroundWorker.cs @@ -0,0 +1,51 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Threading; + +namespace Volo.Abp.BackgroundWorkers; + +public class InMemoryDynamicBackgroundWorker : AsyncPeriodicBackgroundWorkerBase +{ + public string WorkerName { get; } + + private readonly DynamicBackgroundWorkerHandler _handler; + + public InMemoryDynamicBackgroundWorker( + string workerName, + DynamicBackgroundWorkerSchedule schedule, + DynamicBackgroundWorkerHandler handler, + AbpAsyncTimer timer, + IServiceScopeFactory serviceScopeFactory) + : base(timer, serviceScopeFactory) + { + WorkerName = Check.NotNullOrWhiteSpace(workerName, nameof(workerName)); + Check.NotNull(schedule, nameof(schedule)); + _handler = Check.NotNull(handler, nameof(handler)); + + Timer.Period = schedule.Period ?? DynamicBackgroundWorkerSchedule.DefaultPeriod; + CronExpression = schedule.CronExpression; + } + + public virtual void UpdateSchedule(DynamicBackgroundWorkerSchedule schedule) + { + Check.NotNull(schedule, nameof(schedule)); + + Timer.Stop(); + Timer.Period = schedule.Period ?? DynamicBackgroundWorkerSchedule.DefaultPeriod; + CronExpression = schedule.CronExpression; + Timer.Start(StartCancellationToken); + } + + protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext) + { + await _handler( + new DynamicBackgroundWorkerExecutionContext(WorkerName, workerContext.ServiceProvider), + workerContext.CancellationToken); + } + + public override string ToString() + { + return $"DynamicWorker:{WorkerName}"; + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobsTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobsTestModule.cs index f2464013fd..8f08d67c79 100644 --- a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobsTestModule.cs +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobsTestModule.cs @@ -1,4 +1,5 @@ -using Volo.Abp.Autofac; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Autofac; using Volo.Abp.Modularity; namespace Volo.Abp.BackgroundJobs; @@ -10,5 +11,22 @@ namespace Volo.Abp.BackgroundJobs; )] public class AbpBackgroundJobsTestModule : AbpModule { + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + } + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + // Register handler via the singleton registry (through the transient manager). + // The handler persists because IDynamicBackgroundJobHandlerRegistry is a singleton. + var dynamicJobManager = context.ServiceProvider.GetRequiredService(); + var tracker = context.ServiceProvider.GetRequiredService(); + + dynamicJobManager.RegisterHandler("TestDynamicJob", (ctx, ct) => + { + tracker.ExecutedJsonData.Add(ctx.JsonData); + return System.Threading.Tasks.Task.CompletedTask; + }); + } } diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobManager_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobManager_Tests.cs index 30ee049746..9ef0755d0f 100644 --- a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobManager_Tests.cs +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobManager_Tests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Shouldly; using Xunit; @@ -8,12 +8,18 @@ namespace Volo.Abp.BackgroundJobs; public class BackgroundJobManager_Tests : BackgroundJobsTestBase { private readonly IBackgroundJobManager _backgroundJobManager; + private readonly IDynamicBackgroundJobManager _dynamicBackgroundJobManager; private readonly IBackgroundJobStore _backgroundJobStore; + private readonly IBackgroundJobExecuter _backgroundJobExecuter; + private readonly DynamicJobExecutionTracker _tracker; public BackgroundJobManager_Tests() { _backgroundJobManager = GetRequiredService(); + _dynamicBackgroundJobManager = GetRequiredService(); _backgroundJobStore = GetRequiredService(); + _backgroundJobExecuter = GetRequiredService(); + _tracker = GetRequiredService(); } [Fact] @@ -31,4 +37,141 @@ public class BackgroundJobManager_Tests : BackgroundJobsTestBase jobIdAsString.ShouldNotBe(default); (await _backgroundJobStore.FindAsync(Guid.Parse(jobIdAsString))).ShouldNotBeNull(); } + + [Fact] + public async Task Should_Enqueue_Typed_Job_By_Name() + { + var jobName = BackgroundJobNameAttribute.GetName(); + var jobIdAsString = await _dynamicBackgroundJobManager.EnqueueAsync(jobName, new + { + Value = "42" + }); + jobIdAsString.ShouldNotBe(default); + + var jobInfo = await _backgroundJobStore.FindAsync(Guid.Parse(jobIdAsString)); + jobInfo.ShouldNotBeNull(); + jobInfo.JobName.ShouldBe(jobName); + } + + [Fact] + public async Task Should_Enqueue_Async_Typed_Job_By_Name() + { + var jobName = BackgroundJobNameAttribute.GetName(); + var jobIdAsString = await _dynamicBackgroundJobManager.EnqueueAsync(jobName, new { Value = "42" }); + jobIdAsString.ShouldNotBe(default); + + var jobInfo = await _backgroundJobStore.FindAsync(Guid.Parse(jobIdAsString)); + jobInfo.ShouldNotBeNull(); + jobInfo.JobName.ShouldBe(jobName); + } + + [Fact] + public async Task Should_Enqueue_Dynamic_Handler_Job() + { + var jobIdAsString = await _dynamicBackgroundJobManager.EnqueueAsync("TestDynamicJob", new { OrderId = "ORD-001" }); + jobIdAsString.ShouldNotBe(default); + + var jobInfo = await _backgroundJobStore.FindAsync(Guid.Parse(jobIdAsString)); + jobInfo.ShouldNotBeNull(); + jobInfo.JobName.ShouldBe(DynamicBackgroundJobArgs.JobNameConstant); + jobInfo.JobArgs.ShouldContain("TestDynamicJob"); + jobInfo.JobArgs.ShouldContain("ORD-001"); + } + + [Fact] + public async Task Should_Execute_Dynamic_Handler_Job() + { + var countBefore = _tracker.ExecutedJsonData.Count; + + await _backgroundJobExecuter.ExecuteAsync( + new JobExecutionContext( + ServiceProvider, + typeof(DynamicBackgroundJobExecutorJob), + new DynamicBackgroundJobArgs("TestDynamicJob", "{\"OrderId\":\"ORD-001\"}") + ) + ); + + _tracker.ExecutedJsonData.Count.ShouldBeGreaterThan(countBefore); + _tracker.ExecutedJsonData.ShouldContain(d => d.Contains("ORD-001")); + } + + [Fact] + public async Task Should_Prefer_Typed_Job_Over_Dynamic_Handler() + { + var typedJobName = BackgroundJobNameAttribute.GetName(); + _dynamicBackgroundJobManager.RegisterHandler(typedJobName, (_, _) => Task.CompletedTask); + + try + { + var jobIdAsString = await _dynamicBackgroundJobManager.EnqueueAsync(typedJobName, new { Value = "42" }); + jobIdAsString.ShouldNotBe(default); + + var jobInfo = await _backgroundJobStore.FindAsync(Guid.Parse(jobIdAsString)); + jobInfo.ShouldNotBeNull(); + jobInfo.JobName.ShouldBe(typedJobName); + jobInfo.JobName.ShouldNotBe(DynamicBackgroundJobArgs.JobNameConstant); + } + finally + { + _dynamicBackgroundJobManager.UnregisterHandler(typedJobName); + } + } + + [Fact] + public async Task Should_Throw_For_Unknown_Job_Name() + { + await Assert.ThrowsAsync(() => + _dynamicBackgroundJobManager.EnqueueAsync("NonExistentJob", new { Value = "42" }) + ); + } + + [Fact] + public void Should_Register_And_Unregister_Handler() + { + _dynamicBackgroundJobManager.IsHandlerRegistered("TestRegister").ShouldBeFalse(); + + _dynamicBackgroundJobManager.RegisterHandler("TestRegister", (_, _) => Task.CompletedTask); + _dynamicBackgroundJobManager.IsHandlerRegistered("TestRegister").ShouldBeTrue(); + + _dynamicBackgroundJobManager.UnregisterHandler("TestRegister").ShouldBeTrue(); + _dynamicBackgroundJobManager.IsHandlerRegistered("TestRegister").ShouldBeFalse(); + } + + [Fact] + public void Should_GetAllNames_From_Handler_Registry() + { + var registry = GetRequiredService(); + + registry.Register("RegistryJob1", (_, _) => Task.CompletedTask); + registry.Register("RegistryJob2", (_, _) => Task.CompletedTask); + + try + { + var names = registry.GetAllNames(); + names.ShouldContain("RegistryJob1"); + names.ShouldContain("RegistryJob2"); + } + finally + { + registry.Unregister("RegistryJob1"); + registry.Unregister("RegistryJob2"); + } + } + + [Fact] + public void Should_Clear_Handler_Registry() + { + var registry = GetRequiredService(); + + registry.Register("ClearJob1", (_, _) => Task.CompletedTask); + registry.Register("ClearJob2", (_, _) => Task.CompletedTask); + + registry.GetAllNames().ShouldContain("ClearJob1"); + registry.GetAllNames().ShouldContain("ClearJob2"); + + registry.Clear(); + + registry.IsRegistered("ClearJob1").ShouldBeFalse(); + registry.IsRegistered("ClearJob2").ShouldBeFalse(); + } } diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/DynamicJobExecutionTracker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/DynamicJobExecutionTracker.cs new file mode 100644 index 0000000000..3e496cdddd --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/DynamicJobExecutionTracker.cs @@ -0,0 +1,8 @@ +using System.Collections.Concurrent; + +namespace Volo.Abp.BackgroundJobs; + +public class DynamicJobExecutionTracker +{ + public ConcurrentBag ExecutedJsonData { get; } = new(); +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_StopAll_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_StopAll_Tests.cs new file mode 100644 index 0000000000..e7f8ae6bc6 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_StopAll_Tests.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.BackgroundWorkers; +using Xunit; + +namespace Volo.Abp.BackgroundWorkers; + +/// +/// Isolated tests for . +/// Kept in a separate class so the singleton manager is fresh per test (xUnit creates +/// a new test class instance — and therefore a new ABP application — for each test method). +/// +public class DynamicBackgroundWorkerManager_StopAll_Tests : BackgroundJobsTestBase +{ + private readonly IDynamicBackgroundWorkerManager _dynamicWorkerManager; + + public DynamicBackgroundWorkerManager_StopAll_Tests() + { + _dynamicWorkerManager = GetRequiredService(); + } + + [Fact] + public async Task Should_Stop_All_Workers_And_Clear_Registry() + { + var workerName1 = "stop-all-worker-1-" + Guid.NewGuid(); + var workerName2 = "stop-all-worker-2-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName1, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + await _dynamicWorkerManager.AddAsync( + workerName2, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + + _dynamicWorkerManager.IsRegistered(workerName1).ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName2).ShouldBeTrue(); + + await _dynamicWorkerManager.StopAllAsync(); + + _dynamicWorkerManager.IsRegistered(workerName1).ShouldBeFalse(); + _dynamicWorkerManager.IsRegistered(workerName2).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Throw_ObjectDisposedException_When_AddAsync_Called_After_StopAllAsync() + { + await _dynamicWorkerManager.StopAllAsync(); + + await Assert.ThrowsAsync(() => + _dynamicWorkerManager.AddAsync( + "post-stop-worker-" + Guid.NewGuid(), + new DynamicBackgroundWorkerSchedule { Period = 1000 }, + (_, _) => Task.CompletedTask + ) + ); + } + + [Fact] + public async Task Should_Throw_ObjectDisposedException_When_UpdateScheduleAsync_Called_After_StopAllAsync() + { + var workerName = "update-after-stop-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + + await _dynamicWorkerManager.StopAllAsync(); + + await Assert.ThrowsAsync(() => + _dynamicWorkerManager.UpdateScheduleAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 1000 } + ) + ); + } + + [Fact] + public async Task Should_Be_Idempotent_When_StopAllAsync_Called_Multiple_Times() + { + await _dynamicWorkerManager.AddAsync( + "idempotent-stop-" + Guid.NewGuid(), + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + + // Should not throw when called multiple times + await _dynamicWorkerManager.StopAllAsync(); + await _dynamicWorkerManager.StopAllAsync(); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_Tests.cs new file mode 100644 index 0000000000..a61c99fdc1 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundWorkers/DynamicBackgroundWorkerManager_Tests.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.BackgroundWorkers; +using Xunit; + +namespace Volo.Abp.BackgroundWorkers; + +public class DynamicBackgroundWorkerManager_Tests : BackgroundJobsTestBase +{ + private readonly IDynamicBackgroundWorkerManager _dynamicWorkerManager; + + public DynamicBackgroundWorkerManager_Tests() + { + _dynamicWorkerManager = GetRequiredService(); + } + + [Fact] + public async Task Should_Register_Dynamic_Worker() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = 1000 + }, + (_, _) => Task.CompletedTask + ); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Execute_Dynamic_Handler() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = 50 + }, + (context, _) => + { + if (context.WorkerName == workerName) + { + tcs.TrySetResult(true); + } + + return Task.CompletedTask; + } + ); + + var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(5000)); + completedTask.ShouldBe(tcs.Task); + (await tcs.Task).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Add_Dynamic_Worker_With_Default_Schedule() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName, + (_, _) => Task.CompletedTask + ); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Remove_Dynamic_Worker() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = 1000 + }, + (_, _) => Task.CompletedTask + ); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + + var result = await _dynamicWorkerManager.RemoveAsync(workerName); + result.ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Return_False_When_Removing_NonExistent_Worker() + { + var result = await _dynamicWorkerManager.RemoveAsync("non-existent-worker-" + Guid.NewGuid()); + result.ShouldBeFalse(); + } + + [Fact] + public async Task Should_Update_Dynamic_Worker_Schedule() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + var executionCount = 0; + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = 60000 + }, + (_, _) => + { + Interlocked.Increment(ref executionCount); + return Task.CompletedTask; + } + ); + + var result = await _dynamicWorkerManager.UpdateScheduleAsync( + workerName, + new DynamicBackgroundWorkerSchedule + { + Period = 50 + } + ); + + result.ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + + var timeout = TimeSpan.FromSeconds(5); + var startTime = DateTime.UtcNow; + while (executionCount == 0 && DateTime.UtcNow - startTime < timeout) + { + await Task.Delay(50); + } + + executionCount.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Should_Return_False_When_Updating_NonExistent_Worker() + { + var result = await _dynamicWorkerManager.UpdateScheduleAsync( + "non-existent-worker-" + Guid.NewGuid(), + new DynamicBackgroundWorkerSchedule { Period = 1000 } + ); + + result.ShouldBeFalse(); + } + + [Fact] + public async Task Should_Replace_Existing_Worker_When_Same_Name_Added() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + var secondHandlerTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 50 }, + (_, _) => + { + secondHandlerTcs.TrySetResult(true); + return Task.CompletedTask; + } + ); + + var completedTask = await Task.WhenAny(secondHandlerTcs.Task, Task.Delay(5000)); + completedTask.ShouldBe(secondHandlerTcs.Task); + (await secondHandlerTcs.Task).ShouldBeTrue(); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + + var removed = await _dynamicWorkerManager.RemoveAsync(workerName); + removed.ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Throw_When_Period_Is_Zero() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await Assert.ThrowsAsync(async () => + { + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 0 }, + (_, _) => Task.CompletedTask + ); + }); + } + + [Fact] + public async Task Should_Throw_When_Period_Is_Negative() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await Assert.ThrowsAsync(async () => + { + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = -1000 }, + (_, _) => Task.CompletedTask + ); + }); + } + + [Fact] + public async Task Should_Throw_When_No_Period_And_No_CronExpression() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + + await Assert.ThrowsAsync(async () => + { + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule(), + (_, _) => Task.CompletedTask + ); + }); + } + + [Fact] + public async Task Should_Continue_Running_After_Handler_Throws_Exception() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + var callCount = 0; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 50 }, + (_, _) => + { + var count = Interlocked.Increment(ref callCount); + if (count == 1) + { + throw new InvalidOperationException("Simulated failure"); + } + + tcs.TrySetResult(true); + return Task.CompletedTask; + } + ); + + var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(5000)); + completedTask.ShouldBe(tcs.Task); + callCount.ShouldBeGreaterThan(1); + } + + [Fact] + public async Task Should_Not_Be_Registered_After_Remove() + { + var workerName = "dynamic-worker-" + Guid.NewGuid(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 1000 }, + (_, _) => Task.CompletedTask + ); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + + await _dynamicWorkerManager.RemoveAsync(workerName); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Handle_Concurrent_Add_With_Same_Name() + { + var workerName = "concurrent-worker-" + Guid.NewGuid(); + var executedHandlerIds = new ConcurrentBag(); + + var tasks = Enumerable.Range(0, 10).Select(i => + _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => + { + executedHandlerIds.Add(i); + return Task.CompletedTask; + } + ) + ).ToList(); + + await Task.WhenAll(tasks); + + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeTrue(); + + var removed = await _dynamicWorkerManager.RemoveAsync(workerName); + removed.ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Handle_Concurrent_Add_And_Remove() + { + var workerNames = Enumerable.Range(0, 10) + .Select(i => $"concurrent-worker-{i}-" + Guid.NewGuid()) + .ToList(); + + var addTasks = workerNames.Select(name => + _dynamicWorkerManager.AddAsync( + name, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ) + ).ToList(); + + await Task.WhenAll(addTasks); + + foreach (var name in workerNames) + { + _dynamicWorkerManager.IsRegistered(name).ShouldBeTrue(); + } + + var removeTasks = workerNames.Select(name => + _dynamicWorkerManager.RemoveAsync(name) + ).ToList(); + + var results = await Task.WhenAll(removeTasks); + + results.ShouldAllBe(r => r); + + foreach (var name in workerNames) + { + _dynamicWorkerManager.IsRegistered(name).ShouldBeFalse(); + } + } + + [Fact] + public async Task Should_Handle_Concurrent_Add_Remove_Update() + { + var workerName = "concurrent-mixed-" + Guid.NewGuid(); + + await _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 60000 }, + (_, _) => Task.CompletedTask + ); + + var tasks = new List + { + _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 30000 }, + (_, _) => Task.CompletedTask + ), + _dynamicWorkerManager.UpdateScheduleAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 20000 } + ), + _dynamicWorkerManager.AddAsync( + workerName, + new DynamicBackgroundWorkerSchedule { Period = 10000 }, + (_, _) => Task.CompletedTask + ) + }; + + await Task.WhenAll(tasks); + + // After all concurrent operations, worker should still be in a consistent state + var isRegistered = _dynamicWorkerManager.IsRegistered(workerName); + isRegistered.ShouldBeTrue(); + + var removed = await _dynamicWorkerManager.RemoveAsync(workerName); + removed.ShouldBeTrue(); + _dynamicWorkerManager.IsRegistered(workerName).ShouldBeFalse(); + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/DemoAppQuartzModule.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/DemoAppQuartzModule.cs index 0d521baf3d..b8aaf6e519 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/DemoAppQuartzModule.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/DemoAppQuartzModule.cs @@ -1,6 +1,7 @@ using Volo.Abp.Autofac; using Volo.Abp.BackgroundJobs.DemoApp.Shared; using Volo.Abp.BackgroundJobs.Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.Modularity; namespace Volo.Abp.BackgroundJobs.DemoApp.Quartz; @@ -8,7 +9,8 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Quartz; [DependsOn( typeof(DemoAppSharedModule), typeof(AbpAutofacModule), - typeof(AbpBackgroundJobsQuartzModule) + typeof(AbpBackgroundJobsQuartzModule), + typeof(AbpBackgroundWorkersQuartzModule) )] public class DemoAppQuartzModule : AbpModule { diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/Volo.Abp.BackgroundJobs.DemoApp.Quartz.csproj b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/Volo.Abp.BackgroundJobs.DemoApp.Quartz.csproj index 8faef0c291..5f2be2b49b 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/Volo.Abp.BackgroundJobs.DemoApp.Quartz.csproj +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Quartz/Volo.Abp.BackgroundJobs.DemoApp.Quartz.csproj @@ -12,6 +12,7 @@ + diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/DemoAppSharedModule.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/DemoAppSharedModule.cs index 713355636f..f7fde67b44 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/DemoAppSharedModule.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/DemoAppSharedModule.cs @@ -1,5 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.BackgroundJobs.DemoApp.Shared.Jobs; +using Volo.Abp.BackgroundWorkers; using Volo.Abp.Modularity; using Volo.Abp.MultiTenancy; @@ -8,11 +12,86 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Shared [DependsOn(typeof(AbpMultiTenancyModule))] public class DemoAppSharedModule : AbpModule { - public override void OnPostApplicationInitialization(ApplicationInitializationContext context) + public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) { + var dynamicJobManager = context.ServiceProvider.GetRequiredService(); + + dynamicJobManager.RegisterHandler("CompileTimeDynamicJob", (ctx, ct) => + { + using (var doc = JsonDocument.Parse(ctx.JsonData)) + { + var value = doc.RootElement.TryGetProperty("value", out var prop) + ? prop.GetString() + : doc.RootElement.TryGetProperty("Value", out prop) + ? prop.GetString() + : null; + Console.WriteLine($"[DYNAMIC-COMPILE] {value}"); + return Task.CompletedTask; + } + }); + context.ServiceProvider .GetRequiredService() .CreateJobs(); + + await DynamicBackgroundWorkerDemoAsync(context); + } + + private async Task DynamicBackgroundWorkerDemoAsync(ApplicationInitializationContext context) + { + var dynamicWorkerManager = context.ServiceProvider + .GetService(); + + if (dynamicWorkerManager == null) + { + return; + } + + // AddAsync: Register a dynamic worker with a schedule and handler + await dynamicWorkerManager.AddAsync( + "DemoHeartbeatWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 5000 //5 seconds + }, + async (workerContext, cancellationToken) => + { + Console.WriteLine($"[{DateTime.Now}] DemoHeartbeatWorker executed."); + await Task.CompletedTask; + } + ); + + // IsRegistered: Check if a dynamic worker is registered + var isRegistered = dynamicWorkerManager.IsRegistered("DemoHeartbeatWorker"); + Console.WriteLine($"DemoHeartbeatWorker is registered: {isRegistered}"); + + // UpdateScheduleAsync: Update the schedule of an existing dynamic worker + var updated = await dynamicWorkerManager.UpdateScheduleAsync( + "DemoHeartbeatWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 10000 //Change to 10 seconds + } + ); + Console.WriteLine($"DemoHeartbeatWorker schedule updated: {updated}"); + + // RemoveAsync: Remove a dynamic worker + var removed = await dynamicWorkerManager.RemoveAsync("DemoHeartbeatWorker"); + Console.WriteLine($"DemoHeartbeatWorker removed: {removed}"); + + // Re-add the worker to keep it running for demo purposes + await dynamicWorkerManager.AddAsync( + "DemoHeartbeatWorker", + new DynamicBackgroundWorkerSchedule + { + Period = 10000 //10 seconds + }, + async (workerContext, cancellationToken) => + { + Console.WriteLine($"[{DateTime.Now}] DemoHeartbeatWorker executed."); + await Task.CompletedTask; + } + ); } } } diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/Jobs/SampleJobCreator.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/Jobs/SampleJobCreator.cs index b131c5943d..61a715d84d 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/Jobs/SampleJobCreator.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp.Shared/Jobs/SampleJobCreator.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System; +using System.Text.Json; +using System.Threading.Tasks; using Volo.Abp.DependencyInjection; using Volo.Abp.Threading; @@ -7,10 +9,14 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Shared.Jobs public class SampleJobCreator : ITransientDependency { private readonly IBackgroundJobManager _backgroundJobManager; + private readonly IDynamicBackgroundJobManager _dynamicBackgroundJobManager; - public SampleJobCreator(IBackgroundJobManager backgroundJobManager) + public SampleJobCreator( + IBackgroundJobManager backgroundJobManager, + IDynamicBackgroundJobManager dynamicBackgroundJobManager) { _backgroundJobManager = backgroundJobManager; + _dynamicBackgroundJobManager = dynamicBackgroundJobManager; } public void CreateJobs() @@ -20,10 +26,54 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Shared.Jobs public async Task CreateJobsAsync() { - await _backgroundJobManager.EnqueueAsync(new WriteToConsoleGreenJobArgs { Value = "test 1 (green)" }); - await _backgroundJobManager.EnqueueAsync(new WriteToConsoleGreenJobArgs { Value = "test 2 (green)" }); - await _backgroundJobManager.EnqueueAsync(new WriteToConsoleYellowJobArgs { Value = "test 1 (yellow)" }); - await _backgroundJobManager.EnqueueAsync(new WriteToConsoleYellowJobArgs { Value = "test 2 (yellow)" }); + // Type-safe enqueue (existing) + await _backgroundJobManager.EnqueueAsync(new WriteToConsoleGreenJobArgs { Value = "test 1 (green) - typed" }); + await _backgroundJobManager.EnqueueAsync(new WriteToConsoleYellowJobArgs { Value = "test 1 (yellow) - typed" }); + + // Register runtime dynamic handler + _dynamicBackgroundJobManager.RegisterHandler("RuntimeDynamicJob", (context, ct) => + { + using (var doc = JsonDocument.Parse(context.JsonData)) + { + var value = doc.RootElement.TryGetProperty("value", out var prop) + ? prop.GetString() + : doc.RootElement.TryGetProperty("Value", out prop) + ? prop.GetString() + : null; + Console.WriteLine($"[DYNAMIC-RUNTIME] {value}"); + return Task.CompletedTask; + } + }); + + // String-based enqueue with typed job (by name) + await _dynamicBackgroundJobManager.EnqueueAsync( + "GreenJob", + new WriteToConsoleGreenJobArgs { Value = "test 2 (green) - by name, typed args" } + ); + await _dynamicBackgroundJobManager.EnqueueAsync( + "YellowJob", + new WriteToConsoleYellowJobArgs { Value = "test 2 (yellow) - by name, typed args" } + ); + + // String-based enqueue with anonymous object (typed job path) + await _dynamicBackgroundJobManager.EnqueueAsync( + "GreenJob", + new { Value = "test 3 (green) - by name, dynamic", Time = DateTime.Now } + ); + await _dynamicBackgroundJobManager.EnqueueAsync( + "YellowJob", + new { Value = "test 3 (yellow) - by name, dynamic", Time = DateTime.Now } + ); + + // Dynamic job handlers + await _dynamicBackgroundJobManager.EnqueueAsync( + "CompileTimeDynamicJob", + new { Value = "test 4 (dynamic) - compile-time" } + ); + await _dynamicBackgroundJobManager.EnqueueAsync( + "RuntimeDynamicJob", + new { Value = "test 5 (dynamic) - runtime" } + ); } } } diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260119064307_Initial.Designer.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260320082618_Initial.Designer.cs similarity index 98% rename from modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260119064307_Initial.Designer.cs rename to modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260320082618_Initial.Designer.cs index 3225815926..fd21dc852f 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260119064307_Initial.Designer.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260320082618_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations { [DbContext(typeof(DemoAppDbContext))] - [Migration("20260119064307_Initial")] + [Migration("20260320082618_Initial")] partial class Initial { /// diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260119064307_Initial.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260320082618_Initial.cs similarity index 100% rename from modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260119064307_Initial.cs rename to modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260320082618_Initial.cs