Hi,
We're implementing a database-per-tenant architecture where each tenant has its own SQL Server database with its own AbpBackgroundJobs table. We need our custom background job worker to poll each tenant's job queue independently.
Setup:
• ABP v10.3.0 (EF Core, SQL Server) • Each tenant has a "Default" connection string configured via SaasTenantConnectionStrings • The tenant database contains an AbpBackgroundJobs table with waiting jobs (confirmed via SSMS)
────────────────────
Original Approach:
Our custom AsyncPeriodicBackgroundWorkerBase switches tenant context before polling:
using (_currentTenant.Change(tenantId)) { using var scope = serviceProvider.CreateScope(); var jobStore = scope.ServiceProvider.GetRequiredService<IBackgroundJobStore>();
var jobs = await jobStore.GetWaitingJobsAsync(null, 100);
// Returns 0 — despite waiting jobs existing in the tenant DB
}
We expected ICurrentTenant.Change(tenantId) to cause the EfCoreBackgroundJobStore to resolve the tenant's connection string (as it does for all our other DbContext queries), but it always queries the host database.
────────────────────
Diagnostic proof that the scope IS switching correctly:
using (_currentTenant.Change(tenantId)) { using var scope = serviceProvider.CreateScope(); var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); using var uow = uowManager.Begin(requiresNew: true, isTransactional: false);
// ✅ Connection string resolver returns tenant DB
var resolver = scope.ServiceProvider.GetRequiredService<IConnectionStringResolver>();
var connStr = await resolver.ResolveAsync(); // → "...Initial Catalog=db-tenant..."
// ✅ Our AppDbContext connects to tenant DB
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var actualDb = dbContext.Database.GetDbConnection().Database; // → "db-tenant"
// ✅ Direct EF query finds the job
var directJobs = await dbContext.BackgroundJobs
.Where(j => !j.IsAbandoned && j.NextTryTime <= now)
.ToListAsync(); // → Count: 1
// ❌ IBackgroundJobStore still returns 0
var jobStore = scope.ServiceProvider.GetRequiredService<IBackgroundJobStore>();
var storeJobs = await jobStore.GetWaitingJobsAsync(null, 100); // → Count: 0
}
This proves the tenant context switch works for our AppDbContext but IBackgroundJobStore ignores it entirely.
────────────────────
What we've tried (all unsuccessful):
- ReplaceDbContext — Added [ReplaceDbContext(typeof(IBackgroundJobsDbContext))] on our AppDbContext and registered options.ReplaceDbContext<IBackgroundJobsDbContext>() in the module. ❌
- AbpDbConnectionOptions remapping — Configured the "AbpBackgroundJobs" database to map to "Default" with IsUsedByTenants = true : Configure<AbpDbConnectionOptions>(options => { options.Databases.Configure("AbpBackgroundJobs", database => { database.MappedConnections.Add("Default"); database.IsUsedByTenants = true; }); });
3. IDataFilter suppression — Wrapped the call in using (_dataFilter.Disable<IMultiTenant>()) to bypass tenant filtering on the query. ❌ (This returned jobs from the host DB instead)
4. New UnitOfWork — Created a fresh scope + IUnitOfWorkManager.Begin(requiresNew: true) to force EF Core to re-evaluate the connection string. ❌
5. Named connection string on tenant — Added "AbpBackgroundJobs" as a named connection string on the tenant record (in addition to "Default"). ❌
────────────────────
Question:
Is there a supported way to make IBackgroundJobStore (specifically EfCoreBackgroundJobStore ) resolve to the tenant's database when ICurrentTenant is switched programmatically in a background worker context?
Or is the expected approach for DB-per-tenant job processing to bypass IBackgroundJobStore entirely and query the BackgroundJobRecord DbSet directly via our own DbContext (which does resolve correctly)?
Thank you.
2 Answer(s)
-
0
IBackgroundJobStore(EF Core implementation) uses the Background Jobs module’s own DbContext / connection name (by defaultAbpBackgroundJobs) and it is not yourAppDbContext. So the fact thatAppDbContextresolves the tenant DB correctly does not guarantee thatEfCoreBackgroundJobStorewill, unless the Background Jobs module is also configured for tenant-specific connection resolution and migrations in the same way.- In a DB-per-tenant setup, the supported/expected way is:
- ensure the Background Jobs module is installed/migrated into each tenant database (so each tenant really has its own
AbpBackgroundJobstable), and - run job polling/execution per tenant by switching tenant with
ICurrentTenant.Change(...)(your approach is conceptually correct), but only if the Background Jobs module’s DbContext is actually tenant-aware and mapped to the tenant connection string.
- ensure the Background Jobs module is installed/migrated into each tenant database (so each tenant really has its own
What to do (supported approach)
Make sure the Background Jobs module uses the tenant DB connection
- Ensure the tenant has a connection string for the connection name that the Background Jobs module uses (
AbpBackgroundJobsby default), or ensureAbpBackgroundJobsis mapped toDefaultfor tenants (your attempt #2 is the correct direction conceptually). - Then ensure your tenant DB migration process actually creates the Background Jobs tables in the tenant DB for that connection name.
- Ensure the tenant has a connection string for the connection name that the Background Jobs module uses (
Poll inside a Unit of Work after switching tenant Even though you tried it, the safe pattern is:
using (_currentTenant.Change(tenantId)) { using var scope = _serviceProvider.CreateScope(); var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); var jobStore = scope.ServiceProvider.GetRequiredService<IBackgroundJobStore>(); var jobs = await jobStore.GetWaitingJobsAsync(null, 100); await uow.CompleteAsync(); }If this still reads from host DB, it strongly indicates the Background Jobs module is still resolving its own connection as host (i.e., its DbContext is not using the tenant-mapped connection in your setup), even though
Defaultresolves fine for yourAppDbContext.If you need strict DB-per-tenant job queues, query via your tenant DbContext is acceptable Given your proof that direct querying through your tenant-resolving
AppDbContextreturns the expected rows, andIBackgroundJobStorecontinues to hit host DB, the practical approach in DB-per-tenant systems is to bypassIBackgroundJobStoreand query/processBackgroundJobRecordfrom the tenant DbContext that you know is correctly wired to tenant connection strings.This aligns with the general ABP guidance that
ICurrentTenant.Change(...)is the mechanism to operate per-tenant in background workers, and that “disable tenant filter” only helps in shared-db scenarios (not DB-per-tenant).
Notes on what won’t help here
Disable<IMultiTenant>()will not make DB-per-tenant work; it only changes filtering, not which database you connect to.- Replacing
IBackgroundJobsDbContextwith yourAppDbContexttypically won’t help unless the Background Jobs EF Core module’s DbContext/connection-name mapping is fully aligned with your tenant DB strategy.
Sources
- https://abp.io/docs/latest/framework/fundamentals/connection-strings (validated)
- https://abp.io/support/questions/2007 (validated)
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
This is by design, and it's actually two things stacking up, which is why none of your five attempts moved the needle:
- The background jobs
DbContextis marked[IgnoreMultiTenancy], andBackgroundJobRecordisn't a multi-tenant entity (noTenantId). The module is meant to live in the host database. - On top of that, ABP's EF Core repository treats any non-multi-tenant entity as host data. When it resolves the
DbContext, it temporarily switchesICurrentTenantto null for entities that aren'tIMultiTenant.BackgroundJobRecordisn't, so the connection string is pinned to host regardless of what tenant you switched to.
Because of point 2,
ReplaceDbContextcan't help — even if you point the store at a tenant-aware context, the repository still forces the tenant to null for this entity type. And the connection-string mapping / named-connection attempts never get consulted, since the tenant is already null by the time resolution runs.To answer your last question: yes, for DB-per-tenant job queues you route this through a tenant-aware
DbContextand bypass the host-pinning. The cleanest way is to replaceIBackgroundJobRepositorywith one bound to yourAppDbContext(which already resolves the tenant connection and has the table mapped), and overrideGetDbContextAsyncso it stops resetting the tenant:[Dependency(ReplaceServices = true)] [ExposeServices(typeof(IBackgroundJobRepository))] public class TenantAwareBackgroundJobRepository : EfCoreRepository<AppDbContext, BackgroundJobRecord, Guid>, IBackgroundJobRepository { private readonly IDbContextProvider<AppDbContext> _dbContextProvider; protected IClock Clock { get; } public TenantAwareBackgroundJobRepository( IDbContextProvider<AppDbContext> dbContextProvider, IClock clock) : base(dbContextProvider) { _dbContextProvider = dbContextProvider; Clock = clock; } // BackgroundJobRecord is not IMultiTenant, so the base repository would switch the // current tenant to null and read from the host DB. Keep the current tenant instead // so the connection string follows it. protected override Task<AppDbContext> GetDbContextAsync() { return _dbContextProvider.GetDbContextAsync(); } public virtual async Task<List<BackgroundJobRecord>> GetWaitingListAsync( string applicationName, int maxResultCount, CancellationToken cancellationToken = default) { var now = Clock.Now; return await (await GetDbSetAsync()) .Where(t => t.ApplicationName == applicationName) .Where(t => !t.IsAbandoned && t.NextTryTime <= now) .OrderByDescending(t => t.Priority) .ThenBy(t => t.TryCount) .ThenBy(t => t.NextTryTime) .Take(maxResultCount) .ToListAsync(GetCancellationToken(cancellationToken)); } }The built-in
BackgroundJobStorejust delegates toIBackgroundJobRepository, so once this replacement is in place,IBackgroundJobStore.GetWaitingJobsAsyncreads from the tenant DB when you switch tenant in your worker. No newDbContextand no extra migration — you're reusing the per-tenant table you already have.One thing to keep in mind: this makes the whole store tenant-aware, so enqueue/update/delete follow the current tenant too. Jobs added while a tenant is current get written to that tenant's DB, which is what you want for per-tenant queues. The built-in
BackgroundJobWorkerstill runs as host and only picks up host jobs, so polling each tenant DB stays the job of your own worker that switchesICurrentTenant— which is exactly what you're already doing.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - The background jobs