How to get a Hangfire Job Parameter into the method that is executed by that job
Asked Answered
T

2

5

I have installed Hangfire in an ASP.Net Core 5 web application. I followed the Getting Started guide on the Hangfire website and the initial installation and configuration were quick and it works.

As it is a multitenant application (DB per tenant) I need to be able to connect to the right database when the job is processed on the server.

I came across this well explained post which is exactly what I need as far as I can tell but I can't figure out the last step which is how to get the value from the job parameter (the tenant identification) into the method that Hangfire is executing.

I have created the Client filter

#region Hangfire Job Filters
#region Client filters
public class HfClientTenantFilter : IClientFilter
{
    private readonly IMultiTenant _multiTenant;
    public HfClientTenantFilter(IMultiTenant multiTenant)
    {
        _multiTenant = multiTenant;
    }
    public async void OnCreating(CreatingContext filterContext)
    {
        if (filterContext == null) throw new ArgumentNullException(nameof(filterContext));

        MdxTenantConfig tenantConfig = await _multiTenant.GetTenantConfig();
        filterContext.SetJobParameter("TenantId", tenantConfig.Id);
    }

    public void OnCreated(CreatedContext filterContext)
    {
        if (filterContext == null) throw new ArgumentNullException(nameof(filterContext));
    }
}

public class HfClientFilterProvider : IJobFilterProvider
{
    private readonly IMultiTenant _multiTenant;
    public HfClientFilterProvider(IMultiTenant multiTenant) {
        _multiTenant = multiTenant;
    }
    public IEnumerable<JobFilter> GetFilters(Job job)
    {
        return new JobFilter[]
        {
            new JobFilter(new CaptureCultureAttribute(), JobFilterScope.Global, null),
            new JobFilter(new HfClientTenantFilter(_multiTenant),JobFilterScope.Global, null)
        };
    }
}
#endregion Client filters

and Server filter:

#region Server filters
public class HfServerTenantFilter : IServerFilter
{
    private readonly IHfTenantProvider _hfTenantProvider;
    public HfServerTenantFilter(IHfTenantProvider hfTenantProvider)
    {
        _hfTenantProvider = hfTenantProvider;
    }

    public void OnPerforming(PerformingContext filterContext)
    {
        if (filterContext == null) throw new ArgumentNullException(nameof(filterContext));

        var tenantId = filterContext.GetJobParameter<string>("TenantId");
        // need to get the tenantId passed to the method that calls the creation of the DbContext
        _hfTenantProvider.HfSetTenant(tenantId);
    }

    public void OnPerformed(PerformedContext filterContext)
    {
        if (filterContext == null) throw new ArgumentNullException(nameof(filterContext));
    }
}

public class HfServerFilterProvider : IJobFilterProvider
{
    private readonly IHfTenantProvider _hfTenantProvider;
    public HfServerFilterProvider(IHfTenantProvider hfTenantProvider)
    {
        _hfTenantProvider = hfTenantProvider;
    }
    public IEnumerable<JobFilter> GetFilters(Job job)
    {
        return new JobFilter[]
        {
            new JobFilter(new CaptureCultureAttribute(), JobFilterScope.Global, null),
            new JobFilter(new HfServerTenantFilter(_hfTenantProvider), JobFilterScope.Global,  null),
        };
    }
}
#endregion Server filters
#endregion Hangfire Job Filters

Attaching the Server filter in Startup

services.AddHangfireServer(opt => {
                opt.FilterProvider = new HfServerFilterProvider(new HfTenantProvider());  
});

When executing in debug I see that the parameter TenantID is correctly set in the client filter and also correctly retrieved by the server filter.
I am now trying to make that value available to the method that is being executed but hasn't succeeded yet.
I tried using a scoped service:

#region Scoped Tenant provider service

public interface IHfTenantProvider {
    void HfSetTenant(string TenantCode);
    string HfGetTenant();
}

public class HfTenantProvider: IHfTenantProvider
{
    public string HfTenantCode;

    public void HfSetTenant(string TenantCode)
    {
        HfTenantCode = TenantCode;
    }

    public string HfGetTenant()
    {
        return HfTenantCode;
    }
}
#endregion Scoped Tenant provider service

and in Startup:

services.AddScoped<IHfTenantProvider, HfTenantProvider>();

In the OnPerforming method of the Server filter, I set the value in the Scoped service as retrieved from the Job Parameter (see above for full code) which is working as I can see in debugging.

_hfTenantProvider.HfSetTenant(tenantId);

This is the test method being scheduled:

public bool HfTest()
{
    try
    {
        BackgroundJobClient jobClient = GetJobClient();
        jobClient.Enqueue<MdxMetaCRUD>(crud => crud.HfTest());
    }
    catch (Exception)
    {
        return false;
    }
    return true;
}

and this is the method itself where I retrieve the value from the Scoped service which is, however null:

public async Task HfTest()
{
    string tenant =_hfTenantProvider.HfGetTenant();
    using (var _dbContext = _contextHelper.CreateContext(true, tenant))
    {
        Entity entity = new()
        {
            Name = "HFtest",
            Description = tenant,
            DefaultAuditTrackRetention = 1
        };
        await _dbContext.Entities.AddAsync(entity);
    }
}

The ContextHelper returns a new DbContext:

public ApplicationDbContext CreateContext(bool backgroundProcess, string backgroundTenant)
{
    return new ApplicationDbContext(_multiTenant, backgroundProcess, backgroundTenant);
}

The DbContext has an override to retrieve the connection string for the tenant (which is working fine in the user context). In case of a Job being executed by Hangfire, I am trying to pass the TenantId into _backgroundTenant

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    try
    {
        MdxTenantConfig tenantConfig = Task.Run(() => _multiTenant.GetTenantConfig(_backgroundProcess, _backgroundTenant)).Result;
        optionsBuilder.UseSqlServer(tenantConfig.ConnectionString);
        base.OnConfiguring(optionsBuilder);
    }
    catch //(Exception)
    {
        throw;
    }
}

I was hoping that the Job being launched (Server filters being retrieved) and the Job being executed were in the same scope (request) but it seems not.
Am I doing something wrong with the scoped service or is my approach wrong? I saw some articles referring to a "factory" but that is beyond my knowledge (for the moment).

Transcontinental answered 3/11, 2021 at 21:41 Comment(0)
B
2

You may try implementing your HfTenantProvider like this :

public class HfTenantProvider : IHfTenantProvider
{
    private static System.Threading.AsyncLocal<string> HfTenantCode { get; } = new System.Threading.AsyncLocal<string>();

    public void HfSetTenant(string TenantCode)
    {
        HfTenantCode.Value = TenantCode;
    }

    public string HfGetTenant()
    {
        return HfTenantCode.Value;
    }
}

I agree that this makes the scoping of the HfTenantProvider somewhat useless. Also, you may call HfSetTenant(null) in the OnPreformed method of your server filter. I think this is cleaner, even though this should not make big difference.

Brockwell answered 4/11, 2021 at 10:4 Comment(1)
Thanks, this works perfect!! In the meanwhile I have found a much simpler solution (for my scenario) that doesn't involve any of the Job Parameters/Filters. I will post it as well for others that are looking for a solution but I'll accept your answer as it is the correct solution for my original question. And I will do some reading to fully understand your solution :)Transcontinental
T
4

As mentioned above, the answer of @jbl is correct and works exacly as I requested in my original question. I hope this is useful for others.
I have however found a simpler solution in which I don't need Job Paramters or Filters and that fits my scenario. As I've seen that a lot of people are looking for guidance when implementing Hangfire in a multitenant environment I'm posting this solution as well and hope that it can help some people as well.

I my case the scheduling of the job is done by the user through the UI. This means that I always know the tenant when the job is scheduled.
So I simply pass the tenant id as a parameter to the method that is scheduled.
Instead of scheduling the job as:

backgroundClient.Enqueue<MyClass>(c => c.MyMethod(taskId)):

I schedule the job as following:

string tenantCode = _multiTenant.GetTenant();
backgroundClient.Enqueue<MyClass>(c => c.MyMethod(taskId, tenantCode)):

where _multiTenant is my implementation to retrieve the tenant id from HttpContextAccessor.

To execute the method against the correct database context I just need to pass tenantId when creating DbContext.

public async Task MyMethod(int taskId, string tenantCode)
{
    using (var _dbContext = _contextHelper.CreateContext(true, tenantCode))
    {
        ...

The override OnConfiguring of my DbContext can then fetch the tenant configuration (which includes the datasource string) straightaway.

Transcontinental answered 5/11, 2021 at 13:50 Comment(0)
B
2

You may try implementing your HfTenantProvider like this :

public class HfTenantProvider : IHfTenantProvider
{
    private static System.Threading.AsyncLocal<string> HfTenantCode { get; } = new System.Threading.AsyncLocal<string>();

    public void HfSetTenant(string TenantCode)
    {
        HfTenantCode.Value = TenantCode;
    }

    public string HfGetTenant()
    {
        return HfTenantCode.Value;
    }
}

I agree that this makes the scoping of the HfTenantProvider somewhat useless. Also, you may call HfSetTenant(null) in the OnPreformed method of your server filter. I think this is cleaner, even though this should not make big difference.

Brockwell answered 4/11, 2021 at 10:4 Comment(1)
Thanks, this works perfect!! In the meanwhile I have found a much simpler solution (for my scenario) that doesn't involve any of the Job Parameters/Filters. I will post it as well for others that are looking for a solution but I'll accept your answer as it is the correct solution for my original question. And I will do some reading to fully understand your solution :)Transcontinental

© 2022 - 2024 — McMap. All rights reserved.