fix(auditing): purge audit records per tenant so the retention job runs - #1413
Merged
Merged
Conversation
AuditRetentionJob is a Hangfire recurring job registered without a tenant, so it ran with no Finbuckle context and the default-on tenant filter on AuditRecords threw a NullReferenceException on every run. Audit tables grew without bound in every tenant. The job now lists tenants from IMultiTenantStore, opens a scope per tenant, sets IMultiTenantContextSetter, and sweeps inside that context. A failure in one tenant is logged and does not stop the others. Adds an integration test that seeds old and recent records in two tenants and runs the job from a tenant-less scope. Closes #1411 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.
Summary
AuditRetentionJob(Hangfire recurring jobauditing-retention) now purges audit records in every tenant. Before this change it failed on every run, so audit tables grew without limit.Root cause
The job is registered with
AddOrUpdateand no tenant job parameter, soFshJobActivatornever sets a Finbuckle tenant.AuditRecordsisIsMultiTenant()on aBaseDbContext, and the default-on tenant filter has nothing to compare against, soExecuteDeleteAsyncthrows. This is the same shape as #1405.Reproduced by the new test against the unfixed code:
So the job never purged anything. It wasn't purging one tenant only; every run threw.
Fix
This follows the pattern from #1406 (
SessionCleanupHostedService):IServiceScopeFactoryin place of a job-scopedAuditDbContext.IMultiTenantStore<AppTenantInfo>.GetAllAsync().IMultiTenantContextSetter, resolvesAuditDbContext(which also picks up a tenant's dedicated database) and runs the four event-type sweeps.TenantId, and the other tenants still run. Cancellation still propagates.Enabledswitch are unchanged.Test
Integration.Tests/Tests/Auditing/AuditRetentionJobTests.cs(Testcontainers):Enabled = true.Results:
main(NRE above), green after the fix.dotnet build src/FSH.Starter.slnx: 0 warnings, 0 errors.Tests.Auditing.*: 49/49 passed.Audit of background entry points (the issue's "also worth doing")
BaseDbContext?AuditRetentionJob(Auditing)AuditDbContext.AuditRecordsPurgeOrphanedFilesJob(Files)FilesDbContext.FileAssetsIgnoreQueryFilters()PurgeDeletedFilesJob(Files)FilesDbContext.FileAssetsIgnoreQueryFilters()MonthlyInvoiceJob(Billing)BillingDbContext : DbContext(plain)TenantExpiryScanJob(Multitenancy)TenantDbContextis the Finbuckle store contextIMultiTenantContextSetterper tenant before the outbox writeSessionCleanupHostedService(Identity)IdentityDbContext.UserSessionsRolePermissionSyncHostedService(Identity)IdentityDbContextAuditBackgroundWorker→SqlAuditSink(Auditing)AuditDbContext(insert)TenantIdand sets context per group (null → root)AuditingConfigurator(Auditing)OutboxDispatcherHostedService(BuildingBlocks)EventingDbContextHangfireStaleLockCleanupService(BuildingBlocks)NpgsqlConnectionon Hangfire tablesDatabaseOptionsStartupLogger(BuildingBlocks)OrphanedOutboxRecurringJobCleanupService(Host)IRecurringJobManagerNo other entry point throws the way this one did.
Note on the Files purge jobs (follow-up, not fixed here): they avoid the NRE with
IgnoreQueryFilters(), but without a tenant theFilesDbContextconnects to the default database. Tenants with a dedicated database therefore never get their file rows purged.PurgeDeletedFilesJobalso callsquotas.RecordAsync("", ...)with no tenant, so the storage-quota refund may be lost; its own comment says as much. Moving these jobs to the same per-tenant loop would fix both. I'm raising this separately because it changes quota behaviour.Closes #1411
🤖 Generated with Claude Code