IdentitySessionCleanupBackgroundWorker never deletes tenant sessions — AbpSessions grows without bound
ABP version: 10.4.1 (Commercial) UI / DB / Tiered: Angular / EF Core + SQL Server / separate database per tenant Modules: Identity Pro (Session Management), Audit Logging
Summary
IdentitySessionCleanupBackgroundWorker runs on schedule and reports success, but it only ever deletes host-owned sessions. Every tenant's sessions accumulate for ever, because the worker never changes the tenant context and ABP's multi-tenant query filter therefore restricts the delete to TenantId IS NULL.
This is inconsistent with your own ExpiredAuditLogDeleterService, which handles the identical problem correctly.
Evidence: the SQL the worker actually emits
Captured from EF Core command logging with the worker running normally:
DELETE FROM [a]
FROM [AbpSessions] AS [a]
WHERE [a].[TenantId] IS NULL AND ([a].[LastAccessed] IS NULL OR [a].[LastAccessed] < @Subtract)
TenantId IS NULL is the multi-tenant global filter resolving against a null ICurrentTenant.Id, because background workers run in host context. IdentitySession implements IMultiTenant, so every tenant-owned row is invisible to this statement.
Observed effect in production
| Database | Rows with TenantId IS NULL (deletable) | Rows with TenantId set (never deleted) |
|---|---:|---:|
| Host | 4 | 437, across 26 tenants |
| One tenant DB | 0 | 9,877 |
The host figure shows the worker is functioning perfectly for the rows it can see — only 4 remain and all are within the retention window. In one tenant database, zero rows have a null TenantId, so the statement above can never match anything there. Its oldest session dates from the day that database was created.
The IdentitySessionCleanupBackgroundWorker Hangfire job records Succeeded on every run.
This is not a general ABP design stance — your audit module gets it right
ExpiredAuditLogDeleterService solves exactly this problem, for an entity that is also IMultiTenant:
TenantStore.GetListAsync(false)to enumerate tenantsCurrentTenant.Change(tenant.Id)for a per-tenant passCurrentTenant.Change(null)for an explicit host pass- retention read through
ISettingProviderinside each context, so per-tenant overrides are honoured
and it emits two statements per run:
-- host pass
WHERE [a].[TenantId] IS NULL AND ... AND [a].[ExecutionTime] < @minDate
-- per-tenant pass
WHERE [a].[TenantId] = @ef_filter__CurrentTenantId AND [a].[TenantId] = @CurrentTenant_Id AND [a].[ExecutionTime] < @minDate
IdentitySessionCleanupService.CleanAsync() takes only IdentitySessionManager and the options — no ITenantStore, no ICurrentTenant, no tenant loop.
Your own support answer on question #9458 ("Is BackgroundWorker Tenant Safe?") states that workers run in host context unless the tenant context is changed explicitly, and prescribes the ITenantStore + ICurrentTenant.Change pattern. The audit deleter follows it; the session cleanup does not.
Expected vs actual
Expected: sessions inactive beyond IdentitySessionCleanupOptions.InactiveTimeSpan are removed for the host and every tenant.
Actual: only host sessions are removed. Tenant sessions are retained indefinitely, in shared-database and database-per-tenant deployments alike — the filter behaves the same way in both.
Suggested fix
Give IdentitySessionCleanupService the same treatment as ExpiredAuditLogDeleterService: enumerate tenants via ITenantStore, and run the existing DeleteAllAsync(inactiveTimeSpan) once per tenant inside ICurrentTenant.Change(tenant.Id), plus one host pass.
Question
Is this a known issue, and is a fix planned? If not, is a custom worker that loops tenants and calls IIdentitySessionRepository.DeleteAllAsync(inactiveTimeSpan) per tenant the supported workaround, or would you recommend overriding IdentitySessionCleanupService instead?
3 Answer(s)
-
0
- In ABP 10.4.1,
IdentitySessionCleanupBackgroundWorker/ session cleanup runs in host context and (as you observed from the generated SQL) the multi-tenant filter limits the delete toTenantId IS NULL. So tenant-owned rows are not deleted unless the worker explicitly changes the tenant context. - I couldn’t verify the current implementation in source (SourceBot repository access wasn’t available in this environment), but based on the official module doc and ABP’s established multi-tenancy background-worker guidance, what you’re seeing is consistent with “runs on host unless you change tenant context”.
Supported workaround (recommended): implement your own cleanup worker/service that loops tenants and runs the delete inside
ICurrentTenant.Change(...). This is the same pattern ABP support recommends for background workers.Example implementation (per-tenant + host pass)
using System; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Volo.Abp.BackgroundWorkers; using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; // ITenantRepository (or use ITenantStore if you prefer) using Volo.Abp.Domain.Repositories; public class TenantAwareIdentitySessionCleanupWorker : AsyncPeriodicBackgroundWorkerBase { private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; private readonly IIdentitySessionRepository _identitySessionRepository; private readonly IdentitySessionCleanupOptions _options; public TenantAwareIdentitySessionCleanupWorker( AbpAsyncTimer timer, ICurrentTenant currentTenant, ITenantRepository tenantRepository, IIdentitySessionRepository identitySessionRepository, IOptions<IdentitySessionCleanupOptions> options) : base(timer) { _currentTenant = currentTenant; _tenantRepository = tenantRepository; _identitySessionRepository = identitySessionRepository; _options = options.Value; Timer.Period = (int)_options.CleanupPeriod.TotalMilliseconds; // or your desired period } protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext) { // 1) Host cleanup using (_currentTenant.Change(null)) { await _identitySessionRepository.DeleteAllAsync(_options.InactiveTimeSpan); } // 2) Tenant cleanup (important for shared-db and database-per-tenant) var tenants = await _tenantRepository.GetListAsync(includeDetails: false); foreach (var tenant in tenants) { using (_currentTenant.Change(tenant.Id)) { await _identitySessionRepository.DeleteAllAsync(_options.InactiveTimeSpan); } } } }Should you override
IdentitySessionCleanupServiceinstead?- If your goal is only to fix cleanup behavior, the safest/least invasive approach is a custom worker (as above) and disable/remove the default one if it’s registered by the module (so you don’t run two cleanups).
- Overriding/replacing the built-in cleanup service can also work, but it’s more likely to be impacted by internal changes in future module updates. A custom worker that calls the repository is usually the most stable workaround.
Is a fix planned / known issue?
- No verified source available here indicating an official bug report or a scheduled fix for 10.4.1 specifically (I couldn’t access ABP commercial source code or a public issue/PR that confirms it). Practically, you should treat it as a gap and implement the tenant-aware worker.
(links validated)
- https://abp.io/docs/10.4/modules/identity/session-management
- https://abp.io/support/questions/2007
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) - In ABP 10.4.1,
-
0
Thanks — the workaround is correct. I have implemented and verified it, so we are not blocked.
I would still like a maintainer's answer on the framework question, because the workaround does not address it.
The workaround works
A worker that enumerates tenants and calls
IIdentitySessionRepository.DeleteAllAsync(inactiveTimeSpan)insideICurrentTenant.Change(tenant.Id). Verified against a real environment: 13 tenants processed, 0 failures, 5,842 rows deleted on the first pass, and the emitted SQL now carries the per-tenant predicate:WHERE [a].[TenantId] = @ef_filter__CurrentTenantId AND ([a].[LastAccessed] IS NULL OR [a].[LastAccessed] < @Subtract)The question is whether this is a defect you intend to fix
This is not a general "background workers are host-context" limitation, because the Audit Logging module already solves it.
ExpiredAuditLogDeleterServicetakesITenantStore, enumerates tenants, and emits two statements per run:-- host pass WHERE [a].[TenantId] IS NULL AND ... AND [a].[ExecutionTime] < @minDate -- per-tenant pass WHERE [a].[TenantId] = @ef_filter__CurrentTenantId AND [a].[TenantId] = @CurrentTenant_Id AND [a].[ExecutionTime] < @minDateIdentitySessionCleanupService.CleanAsync()takes onlyIdentitySessionManagerand its options. NoITenantStore, noICurrentTenant, no tenant loop.So two ABP modules, both cleaning an
IMultiTenantentity on a schedule, behave differently. One retains tenant data for ever; the other does not. That reads as an oversight rather than a design decision, and it affects every multi-tenant deployment — shared database or database per tenant, since the filter behaves identically in both.Concretely:
- Is this accepted as a bug in the Identity Pro session module?
- If so, is a fix planned, and for which version?
- If it is intended behaviour, could the Session Management documentation state that tenant sessions are never cleaned automatically? Right now it describes
IdentitySessionCleanupBackgroundWorkeras removing inactive sessions, with no indication that this applies to the host only — which is why we did not catch it untilAbpSessionshad grown to five figures in a single tenant.
One correction to the sample, for anyone else who finds this
The sample resolves its dependencies once and reuses them across the tenant loop.
ICurrentTenant.Changeonly affects services resolved after it, and in a database-per-tenant deployment the DbContext binds its connection string on resolution — so a reused scope binds every iteration to the first tenant's database, and the delete runs against the wrong database.This matches the guidance in your own support question 9458: "Always create a new DI scope for each tenant to ensure correct service resolution." Our implementation creates a scope per tenant and resolves everything inside the
Changeblock. It would be worth correcting the sample, since on a shared database the bug is invisible and on a separate-database setup it deletes from the wrong place.Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
IdentitySessionCleanupServiceruns the delete once in the host context, so the multi-tenant filter limits it toTenantId IS NULLand tenant sessions are never removed. It should loop the tenants the same wayExpiredAuditLogDeleterServicedoes. We'll fix this in 10.7, and your ticket credit has been refunded.For now, override the service instead of adding another worker. The built-in worker resolves
IdentitySessionCleanupServicefrom DI, so it picks up the override and you keep the distributed lock and theIdentitySessionCleanupOptionssettings:using System.Threading.Tasks; using Microsoft.Extensions.Options; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.Identity.Session; using Volo.Abp.MultiTenancy; namespace MyCompanyName.MyProjectName; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IdentitySessionCleanupService))] public class TenantAwareIdentitySessionCleanupService : IdentitySessionCleanupService { protected ITenantStore TenantStore { get; } protected ICurrentTenant CurrentTenant { get; } protected IDataFilter DataFilter { get; } public TenantAwareIdentitySessionCleanupService( IOptionsMonitor<IdentitySessionCleanupOptions> cleanupOptions, IdentitySessionManager identitySessionManager, ITenantStore tenantStore, ICurrentTenant currentTenant, IDataFilter dataFilter) : base(cleanupOptions, identitySessionManager) { TenantStore = tenantStore; CurrentTenant = currentTenant; DataFilter = dataFilter; } public override async Task CleanAsync() { using (CurrentTenant.Change(null)) using (DataFilter.Disable<IMultiTenant>()) { await base.CleanAsync(); } foreach (var tenant in await TenantStore.GetListAsync()) { using (CurrentTenant.Change(tenant.Id)) { await base.CleanAsync(); } } } }Put it in your
.Domainproject and remove it when you upgrade to 10.7. The host pass runs with the multi-tenant filter disabled, so the tenant rows sitting in your host database are removed too, and the per-tenant pass covers the separate tenant databases.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)