Skip to content

Commit

Permalink
(hangfire): #33 en-/disable jobs by ms feature management
Browse files Browse the repository at this point in the history
reworked: UniquePerQueueAttribute, so it does a deep compare of the jobs method parameters
added: JobCleanupExpirationTimeAttribute to be able to control how long job results are kept until deletion
added: documentation about the new attributes and filters
added: unit tests for new features and improved coverage on existing code
  • Loading branch information
fb-smit committed Feb 5, 2024
1 parent 0ad9719 commit fff8a2a
Show file tree
Hide file tree
Showing 18 changed files with 582 additions and 95 deletions.
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Version="8.0.1" Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageVersion Version="3.1.1" Include="Microsoft.FeatureManagement" />
<PackageVersion Version="3.6.133" Include="Nerdbank.GitVersioning" />
<PackageVersion Version="4.3.0" Include="App.Metrics.Abstractions" />
<PackageVersion Version="4.3.0" Include="App.Metrics.AspNetCore.All" />
Expand Down Expand Up @@ -100,4 +101,4 @@
<PackageVersion Version="15.1.0" Include="YamlDotNet" />
<PackageVersion Version="1.3.10" Include="EzSmb" />
</ItemGroup>
</Project>
</Project>
76 changes: 69 additions & 7 deletions PiBox.Plugins/Jobs/Hangfire/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ hangfire:
User: postgres
Password: postgres
InMemory: true
DashboardUser: awesome-user #if you don't set this, you can't access the hangfire dashboard
DashboardPassword: awesome-pw #if you don't set this, you can't access the hangfire dashboard
enableJobsByFeatureManagementConfig: false
allowedDashboardHost: localhost # you need to set this configuration to be able to access the dashboard from the specified host

featureManagement: # we can conveniently can use the microsoft feature management system to enable jobs based on configuration
hangfireTestJob: true # if you have enabled the 'enableJobsByFeatureManagementConfig: true' then you can configure here if your jobs should run on execution or not, useful for multiple environments etc.
```
HangfireConfiguration.cs
Expand All @@ -47,9 +50,9 @@ public class HangfireConfiguration
public string? Database { get; set; }
public string? User { get; set; }
public string? Password { get; set; }
public string? DashboardUser { get; set; }
public string? DashboardPassword { get; set; }
public bool InMemory { get; set; }
public string AllowedDashboardHost { get; set; }
public bool EnableJobsByFeatureManagementConfig { get; set; }
public int? PollingIntervalInMs { get; set; }
public int? WorkerCount { get; set; }
public string ConnectionString => $"Host={Host};Port={Port};Database={Database};Username={User};Password={Password};";
Expand Down Expand Up @@ -151,10 +154,69 @@ BackgroundJob.Enqueue<IEmailSender>(x => x.Send("hangfire@example.com"));
BackgroundJob.Enqueue(() => Console.WriteLine("Hello, world!"));
```

### Execution modes
### Attributes

#### UniquePerQueueAttribute
If you want the job only to be executed as one instance at any given point in time use the
```csharp
[UniquePerQueueAttribute("high")]
```

This ensures that there is only one job of the same type/name
and method parameters in processing or queued at any given point

#### JobCleanupExpirationTimeAttribute

With this you can specify how many days the results of a job
should be kept until it gets deleted

```csharp
[JobCleanupExpirationTimeAttribute(14)]
```

### Filters

#### EnabledByFeatureFilter

This filter works in conjunction with the [microsoft feature management system](https://github.com/microsoft/FeatureManagement-Dotnet).
If you would like to be able to enable or disable the execution of your
jobs based on configuration this is the right tool for it.

**Default Feature management with file based configuration**
```yaml
hangfire:
enableJobsByFeatureManagementConfig: true

featureManagement:
hangfireTestJob: true
neverRunThisJob: false
```
This allows you to enable jobs based on configuration files.
If you have enabled the setting
```yaml
enableJobsByFeatureManagementConfig: true
```
then you can configure here, if your jobs should run
on execution or not, useful for multiple environments etc.
If your service supports hot reloading of configuration files,
you can enable/disable jobs at run time.
**Feature management with the [pibox unleash plugin](https://sia-digital.gitbook.io/pibox/plugins/management/unleash)**
This works in conjunction with the plugin PiBox.Plugins.Management.Unleash.
This replaces the ability of setting the features via files.
Instead one can use the unleash api/service
and use feature flags for enabling the jobs.
Just make sure that the name of the job matches the name of the
feature flag you are creating in unleash.
UniquePerQueueAttribute
The pibox unleash plugin then should do the rest of the heavy lifting.
this ensures that there is only one job of the same type/name in processing or queued at any given point!
Since the attribute resolves the feature on before executing the job,
changes to the configuration can be done at runtime with a maximal delay
based on how often the pibox unleash plugin refreshes its cache.
You can find more information in the documentation of the
[pibox unleash plugin](https://sia-digital.gitbook.io/pibox/plugins/management/unleash).
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Hangfire.Common;
using Hangfire.Server;
using Microsoft.Extensions.Logging;
using Microsoft.FeatureManagement;

namespace PiBox.Plugins.Jobs.Hangfire.Attributes
{
/// <summary>
/// Decide if to execute a job by enabled featured
/// </summary>
internal class EnabledByFeatureFilter : JobFilterAttribute, IServerFilter
{
private readonly IFeatureManager _featureManager;
private readonly ILogger<EnabledByFeatureFilter> _logger;

public EnabledByFeatureFilter(IFeatureManager featureManager, ILogger<EnabledByFeatureFilter> logger)
{
_featureManager = featureManager;
_logger = logger;
Order = 0;
}

public void OnPerforming(PerformingContext context)
{
var jobName = context.BackgroundJob.Job.Type.Name;
if (_featureManager.IsEnabledAsync(jobName).Result) return;
_logger.LogWarning("Execution of job {JobName} was cancelled due to not enabled feature {FeatureName}",
jobName, jobName);
context.Canceled = true;
}

public void OnPerformed(PerformedContext context)
{
// do nothing
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Hangfire.Common;
using Hangfire.States;
using Hangfire.Storage;

namespace PiBox.Plugins.Jobs.Hangfire.Attributes
{
public class JobCleanupExpirationTimeAttribute : JobFilterAttribute, IApplyStateFilter
{
private readonly int _cleanUpAfterDays;

public JobCleanupExpirationTimeAttribute(int cleanUpAfterDays)
{
_cleanUpAfterDays = cleanUpAfterDays;
Order = 100;
}

public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
{
context.JobExpirationTimeout = TimeSpan.FromDays(_cleanUpAfterDays);
}

public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
{
// nothing to do here
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace PiBox.Plugins.Jobs.Hangfire
namespace PiBox.Plugins.Jobs.Hangfire.Attributes
{
[AttributeUsage(AttributeTargets.Class)]
public class RecurringJobAttribute : Attribute
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Text.Json;
using Hangfire;
using Hangfire.Common;
using Hangfire.States;
using Hangfire.Storage;
using Hangfire.Storage.Monitoring;

namespace PiBox.Plugins.Jobs.Hangfire.Attributes
{
public class UniquePerQueueAttribute : JobFilterAttribute, IElectStateFilter
{
public string Queue { get; set; }

public bool CheckScheduledJobs { get; set; }

public bool CheckRunningJobs { get; set; }

public UniquePerQueueAttribute(string queue)
{
Queue = queue;
Order = 10;
}

private IEnumerable<JobEntity> GetJobs(ElectStateContext context)
{
IMonitoringApi monitoringApi = context.Storage.GetMonitoringApi();
List<JobEntity> jobs =
new List<JobEntity>();
foreach ((string key, EnqueuedJobDto enqueuedJobDto1) in monitoringApi.EnqueuedJobs(Queue, 0, 500))
{
string id = key;
EnqueuedJobDto enqueuedJobDto2 = enqueuedJobDto1;
jobs.Add(JobEntity.Parse(id, enqueuedJobDto2.Job));
}

if (CheckScheduledJobs)
{
foreach (KeyValuePair<string, ScheduledJobDto> pair in monitoringApi.ScheduledJobs(0, 500))
{
string id = pair.Key;
ScheduledJobDto scheduledJobDto3 = pair.Value;
jobs.Add(JobEntity.Parse(id, scheduledJobDto3.Job));
}
}

if (!CheckRunningJobs)
{
return jobs;
}

foreach (KeyValuePair<string, ProcessingJobDto> pair in
monitoringApi.ProcessingJobs(0, 500))
{
string id = pair.Key;
ProcessingJobDto processingJobDto3 = pair.Value;
jobs.Add(JobEntity.Parse(id, processingJobDto3.Job));
}

return jobs;
}

public void OnStateElection(ElectStateContext context)
{
if (!(context.CandidateState is EnqueuedState candidateState))
{
return;
}

candidateState.Queue = Queue;
BackgroundJob job = context.BackgroundJob;
var filteredArguments = job.Job.Args.Where(x => x.GetType() != typeof(CancellationToken)).ToList();
var jobArgs = JsonSerializer.Serialize(filteredArguments,
new JsonSerializerOptions() { IncludeFields = false });
var jobs = GetJobs(context);
var jobsWithArgs = jobs
.Select(x => new { JobEntity = x, ArgAsString = jobArgs }).ToList();
var alreadyExists = jobsWithArgs.Exists(x =>
x.JobEntity.Value.Method == job.Job.Method && x.ArgAsString == jobArgs && x.JobEntity.Id != job.Id);
if (!alreadyExists)
{
return;
}

context.CandidateState =
new DeletedState() { Reason = "Instance of the same job is already queued." };
}

private sealed record JobEntity(string Id, global::Hangfire.Common.Job Value)
{
public static JobEntity
Parse(string id, global::Hangfire.Common.Job job) =>
new(id, job);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.FeatureManagement;
using Newtonsoft.Json;
using PiBox.Hosting.Abstractions;
using PiBox.Hosting.Abstractions.Extensions;
using PiBox.Hosting.Abstractions.Plugins;
using PiBox.Hosting.Abstractions.Services;
using PiBox.Plugins.Jobs.Hangfire.Attributes;
using PiBox.Plugins.Jobs.Hangfire.Job;

namespace PiBox.Plugins.Jobs.Hangfire
Expand All @@ -26,6 +29,7 @@ public HangFirePlugin(HangfireConfiguration configuration, IImplementationResolv

public void ConfigureServices(IServiceCollection serviceCollection)
{
serviceCollection.AddFeatureManagement();
serviceCollection.AddSingleton<JobDetailCollection>();
serviceCollection.AddSingleton<IJobRegister>(sp => sp.GetRequiredService<JobDetailCollection>());
serviceCollection.AddHangfire(conf =>
Expand Down Expand Up @@ -56,6 +60,13 @@ public void ConfigureServices(IServiceCollection serviceCollection)

public void ConfigureApplication(IApplicationBuilder applicationBuilder)
{
if (_hangfireConfig.EnableJobsByFeatureManagementConfig)
{
GlobalJobFilters.Filters.Add(new EnabledByFeatureFilter(
applicationBuilder.ApplicationServices.GetRequiredService<IFeatureManager>(),
applicationBuilder.ApplicationServices.GetService<ILogger<EnabledByFeatureFilter>>()));
}

var urlAuthFilter = new HostAuthorizationFilter(_hangfireConfig.AllowedDashboardHost);
applicationBuilder.UseHangfireDashboard(options: new() { Authorization = new List<IDashboardAuthorizationFilter> { urlAuthFilter } });
var jobRegister = applicationBuilder.ApplicationServices.GetRequiredService<IJobRegister>();
Expand All @@ -78,7 +89,7 @@ public void ConfigureHealthChecks(IHealthChecksBuilder healthChecksBuilder)
tags: new[] { HealthCheckTag.Readiness.Value });
}

private class HostAuthorizationFilter : IDashboardAuthorizationFilter
internal class HostAuthorizationFilter : IDashboardAuthorizationFilter
{
private readonly string _allowedHost;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public class HangfireConfiguration
public string Database { get; set; }
public string User { get; set; }
public string Password { get; set; }
public string AllowedDashboardHost { get; set; }
public bool InMemory { get; set; }
public string AllowedDashboardHost { get; set; }
public bool EnableJobsByFeatureManagementConfig { get; set; }
public int? PollingIntervalInMs { get; set; }
public int? WorkerCount { get; set; }
public string ConnectionString => $"Host={Host};Port={Port};Database={Database};Username={User};Password={Password};";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="HangFire" />
<PackageReference Include="Hangfire.MemoryStorage" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="Microsoft.FeatureManagement" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
</ItemGroup>
Expand Down
Loading

0 comments on commit fff8a2a

Please sign in to comment.