Skip to content

fix(identity): run session cleanup inside each tenant's context - #1406

Merged
iammukeshm merged 2 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-session-cleanup-per-tenant
Sep 28, 2026
Merged

iammukeshm merged 2 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-session-cleanup-per-tenant

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1405. Docs: fullstackhero/docs#256.

Problem

SessionCleanupHostedService is meant to delete, every hour, the UserSession rows that expired more than 30 days ago. It never deleted anything. Every run failed with a NullReferenceException thrown from lambda_method(Closure, QueryContext) inside ExecuteDeleteAsync, and the loop logged it as Error during session cleanup before trying again an hour later. The table kept growing in every tenant.

The cause is tenant isolation. The service opened a plain DI scope and ran db.UserSessions.Where(s => s.ExpiresAt < cutoff).ExecuteDeleteAsync(). A hosted service runs outside any request, so Finbuckle never resolves a tenant. UserSession is not an IGlobalEntity, so IdentityDbContext gives it the default-on tenant query filter, and that filter dereferences the current tenant, which is null here, when EF evaluates the query parameters. The regression test below reproduces the same exception and the same frame against the unfixed code.

Fix

The per-run method now reads every tenant from IMultiTenantStore<AppTenantInfo>.GetAllAsync() and, for each one, opens its own scope, sets IMultiTenantContextSetter.MultiTenantContext to that tenant, resolves IdentityDbContext and runs the same ExecuteDeleteAsync. This is the pattern RolePermissionSyncHostedService already uses in the same module. Because IdentityDbContext.OnConfiguring reads the tenant's connection string, this also reaches tenants that have their own database.

A failure in one tenant is caught, logged as Session cleanup failed for tenant {TenantId}, and the remaining tenants are still cleaned. Cancellation still propagates. If the tenant store itself cannot be read, the exception reaches the existing outer loop, which logs it and retries on the next interval as before. The interval (1 hour), the retention (30 days) and the success log (which now carries the tenant id) are otherwise unchanged. Nothing new is configured.

To let a test drive one run without waiting an hour, CleanupExpiredSessionsAsync is now internal and Modules.Identity adds InternalsVisibleTo("Integration.Tests"), which is how Chat, Files and Notifications already expose internals to the integration suite.

Tests

New integration test Sessions/SessionCleanupTests.Cleanup_Should_DeleteSessionsPastRetention_InEveryTenant_And_KeepTheRest. It creates a second tenant through the API, seeds sessions directly in both tenants (the root admin and the new tenant's admin as owners), resolves the registered SessionCleanupHostedService from the host and invokes one run. It asserts, per tenant and by session id, that:

  • a root-tenant session expired 31 days ago is deleted,
  • a second-tenant session expired 31 days ago is deleted,
  • a root-tenant session expired 1 day ago (inside retention) is kept,
  • a second-tenant session that expires in 7 days is kept.

The test waits for provisioning on the overall Status field of the provisioning response. Matching the word "Completed" anywhere in the body returns as soon as the first step finishes, before the tenant admin is seeded. The copies of that helper in the other tenant-isolation tests still use the substring match and return early in the same way; they are left untouched here.

Mutation gate. With the service file reverted to the unfixed logic (only the internal seam kept, so the test compiles), the test fails with System.NullReferenceException at lambda_method(Closure, QueryContext) → ExecuteDeleteAsync → SessionCleanupHostedService.CleanupExpiredSessionsAsync (Failed: 1, Passed: 0, Total: 1). With the fix restored it passes (Passed: 1, Total: 1), and the restored file's sha256 matches the fixed file byte for byte.

Full runs on this branch (Release, .NET 10):

Project Result
Architecture.Tests 55 passed, 0 failed
Framework.Tests 244 passed, 0 failed
Identity.Tests 319 passed, 0 failed
Multitenancy.Tests 92 passed, 0 failed
Integration.Tests 765 passed, 0 failed
Integration.Middleware.Tests 5 passed, 0 failed

The test covers tenants that share the default database. The dedicated-connection-string path goes through the same per-tenant context, but no test here provisions a tenant with its own database.

SessionCleanupHostedService ran ExecuteDeleteAsync from a plain DI scope.
A hosted service has no request, so no tenant is resolved, and the
default-on tenant filter on UserSessions threw a NullReferenceException
while EF evaluated the query parameters. Every hourly run failed and was
logged as "Error during session cleanup"; nothing was ever deleted.

The run now enumerates IMultiTenantStore<AppTenantInfo> and deletes in a
per-tenant scope with the tenant context set, the same shape as
RolePermissionSyncHostedService. That also reaches tenants with their own
connection string. A failure in one tenant is logged with its id and the
remaining tenants are still cleaned. Interval, retention and cancellation
are unchanged.

The per-run method is internal and Identity exposes internals to
Integration.Tests so the regression test can invoke one run directly.
The rest of the file already uses ConfigureAwait(false), as RolePermissionSyncHostedService does.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix, and it follows the pattern RolePermissionSyncHostedService already uses. Catching per tenant while still propagating cancellation is the right failure isolation, and the test that fails on the unfixed code is what I want. Good catch on the provisioning-wait helper returning early; I've filed that as part of #1412. While reviewing this I found what looks like the same bug in AuditRetentionJob (#1411), if you want to pick it up.

@iammukeshm
iammukeshm merged commit 5bf689e into fullstackhero:main Sep 28, 2026
16 checks passed
iammukeshm added a commit that referenced this pull request Sep 30, 2026
…visioning wait in tests (#1414)

* chore(docker): move API and migrator runtime images off dotnet/nightly

Both production Dockerfiles pulled mcr.microsoft.com/dotnet/nightly/aspnet,
left over from when .NET 10 was in preview. Nightly images are unsupported
and can change under you. Use the supported aspnet:10.0-noble-chiseled.

Also document the deliberate drift: the Dockerfiles (docker compose) stay
chiseled, while the csproj ContainerFamily (SDK publish, AWS deploy) is
full noble because chiseled strips libgssapi_krb5 and Npgsql's GSSAPI
probe then logs a noisy load error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(config): nest Production SMTP keys under MailOptions:Smtp

Host/Port/UserName/Password sat directly under MailOptions, where nothing
binds them (SmtpOptions lives at MailOptions:Smtp). Move them into the Smtp
section so Production overrides actually apply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(integration): share one provisioning wait that checks the overall Status

27 test classes carried their own copy of WaitForProvisioningAsync, and 26
treated "Completed" anywhere in the status body as done. The body lists
each step, so that matched as soon as the first step finished, before the
tenant admin was seeded - a likely flake source.

Extract the correct wait from #1406 (reads TenantProvisioningStatusDto and
trusts only its Status field, throws on Failed with the step and error)
into Infrastructure/TenantProvisioningWait and use it everywhere. The
timeout message now reports the last status and step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(config): default the Production SMTP port to 587, not 0

The keys now bind, so a Port of 0 would override the base 587 and break
an operator who only sets the host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session cleanup never deletes anything: NullReferenceException in the tenant filter on every run

2 participants