Skip to content

fix(auditing): purge audit records per tenant so the retention job runs - #1413

Merged
iammukeshm merged 1 commit into
mainfrom
fix/audit-retention-tenant-context
Sep 30, 2026
Merged

iammukeshm merged 1 commit into
mainfrom
fix/audit-retention-tenant-context

Conversation

@iammukeshm

Copy link
Copy Markdown
Member

Summary

AuditRetentionJob (Hangfire recurring job auditing-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 AddOrUpdate and no tenant job parameter, so FshJobActivator never sets a Finbuckle tenant. AuditRecords is IsMultiTenant() on a BaseDbContext, and the default-on tenant filter has nothing to compare against, so ExecuteDeleteAsync throws. This is the same shape as #1405.

Reproduced by the new test against the unfixed code:

Failed AuditRetentionJobTests.RunAsync_Should_PurgeRecordsPastRetention_InEveryTenant_And_KeepTheRest
  System.NullReferenceException : Object reference not set to an instance of an object.
   at lambda_method2449(Closure, QueryContext)
   ...
   at EntityFrameworkQueryableExtensions.ExecuteDeleteAsync[TSource](...)
   at AuditRetentionJob.SweepAsync(...) in AuditRetentionJob.cs:line 64
   at AuditRetentionJob.RunAsync(...) in AuditRetentionJob.cs:line 43

So the job never purged anything. It wasn't purging one tenant only; every run threw.

Fix

This follows the pattern from #1406 (SessionCleanupHostedService):

  • The job gets IServiceScopeFactory in place of a job-scoped AuditDbContext.
  • It loads tenants from IMultiTenantStore<AppTenantInfo>.GetAllAsync().
  • For each tenant it opens a scope, sets IMultiTenantContextSetter, resolves AuditDbContext (which also picks up a tenant's dedicated database) and runs the four event-type sweeps.
  • A failure in one tenant is caught and logged with its TenantId, and the other tenants still run. Cancellation still propagates.
  • The batched sub-query delete, the retention options and the Enabled switch are unchanged.

Test

Integration.Tests/Tests/Auditing/AuditRetentionJobTests.cs (Testcontainers):

  • Provisions a second tenant.
  • Seeds Activity records in root and in the new tenant: one 31 days old (past the 30-day retention) and one 1 day old in each.
  • Resolves the job from a fresh scope with no tenant, which matches what Hangfire's activator gives it, and runs it with Enabled = true.
  • Asserts that the old records are gone and the recent ones remain in both tenants.

Results:

  • Red on main (NRE above), green after the fix.
  • dotnet build src/FSH.Starter.slnx: 0 warnings, 0 errors.
  • Auditing.Tests: 66/66 passed.
  • Architecture.Tests: 55/55 passed.
  • Integration.Tests Tests.Auditing.*: 49/49 passed.

Audit of background entry points (the issue's "also worth doing")

Entry point Kind Touches tenant-filtered BaseDbContext? Tenant handling Verdict
AuditRetentionJob (Auditing) Hangfire recurring AuditDbContext.AuditRecords none, so it threw an NRE Fixed here
PurgeOrphanedFilesJob (Files) Hangfire recurring FilesDbContext.FileAssets IgnoreQueryFilters() No NRE. It only covers the shared database (see note)
PurgeDeletedFilesJob (Files) Hangfire recurring FilesDbContext.FileAssets IgnoreQueryFilters() No NRE. Same shared-database note, and the quota refund has no tenant (see note)
MonthlyInvoiceJob (Billing) Hangfire recurring No. BillingDbContext : DbContext (plain) n/a OK
TenantExpiryScanJob (Multitenancy) Hangfire recurring No. TenantDbContext is the Finbuckle store context Sets IMultiTenantContextSetter per tenant before the outbox write OK
SessionCleanupHostedService (Identity) BackgroundService IdentityDbContext.UserSessions Iterates tenants and sets context OK (#1406)
RolePermissionSyncHostedService (Identity) BackgroundService IdentityDbContext Iterates tenants and sets context OK
AuditBackgroundWorker → SqlAuditSink (Auditing) BackgroundService AuditDbContext (insert) Groups by TenantId and sets context per group (null → root) OK
AuditingConfigurator (Auditing) IHostedService No DB n/a OK
OutboxDispatcherHostedService (BuildingBlocks) BackgroundService EventingDbContext Drains per tenant and sets context before building the context OK
HangfireStaleLockCleanupService (BuildingBlocks) BackgroundService No. Raw NpgsqlConnection on Hangfire tables n/a OK
DatabaseOptionsStartupLogger (BuildingBlocks) IHostedService No DB n/a OK
OrphanedOutboxRecurringJobCleanupService (Host) BackgroundService No. Only IRecurringJobManager n/a OK

No 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 the FilesDbContext connects to the default database. Tenants with a dedicated database therefore never get their file rows purged. PurgeDeletedFilesJob also calls quotas.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

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>
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.

Audit retention job likely never purges: runs without a tenant context (same bug as #1405)

1 participant