fix(identity): run session cleanup inside each tenant's context - #1406
Merged
iammukeshm merged 2 commits intoSep 28, 2026
Merged
iammukeshm merged 2 commits into
iammukeshm merged 2 commits into
Conversation
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
approved these changes
Sep 28, 2026
iammukeshm
left a comment
Member
There was a problem hiding this comment.
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.
This was referenced Sep 29, 2026
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>
This was referenced Sep 30, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1405. Docs: fullstackhero/docs#256.
Problem
SessionCleanupHostedServiceis meant to delete, every hour, theUserSessionrows that expired more than 30 days ago. It never deleted anything. Every run failed with aNullReferenceExceptionthrown fromlambda_method(Closure, QueryContext)insideExecuteDeleteAsync, and the loop logged it asError during session cleanupbefore 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.UserSessionis not anIGlobalEntity, soIdentityDbContextgives 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, setsIMultiTenantContextSetter.MultiTenantContextto that tenant, resolvesIdentityDbContextand runs the sameExecuteDeleteAsync. This is the patternRolePermissionSyncHostedServicealready uses in the same module. BecauseIdentityDbContext.OnConfiguringreads 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,
CleanupExpiredSessionsAsyncis nowinternalandModules.IdentityaddsInternalsVisibleTo("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 registeredSessionCleanupHostedServicefrom the host and invokes one run. It asserts, per tenant and by session id, that:The test waits for provisioning on the overall
Statusfield 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
internalseam kept, so the test compiles), the test fails withSystem.NullReferenceExceptionatlambda_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):
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.