Skip to content

fix(events): delete expired inbox/outbox rows with FOR UPDATE SKIP LOCKED - #109

Merged
yilmaztayfun merged 1 commit into
masterfrom
fix/inbox-outbox-cleanup-skip-locked
Sep 23, 2026
Merged

yilmaztayfun merged 1 commit into
masterfrom
fix/inbox-outbox-cleanup-skip-locked

Conversation

@yilmaztayfun

Copy link
Copy Markdown
Contributor

Why

With multiple replicas (seen in test on vnext-morph-idm-worker-inbox: ~1000 errors/week, on every pod), inbox cleanup logs Error cleaning up old inbox messages with:

AetherDbConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s)

Each worker's cleanup read the same oldest Processed rows (ORDER BY … LIMIT) without a lock and removed them via RemoveRange. The first commit won; every other worker's per-row DELETE WHERE "Id" = … hit 0 rows and EF raised a concurrency exception. No data was lost, but it produced Error-level noise on every pod. It happened constantly because CleanupInterval was documented but never read, so cleanup ran on every polling cycle (every 100 ms when busy).

The outbox had the same pattern, and it was worse there. The cleanup exception wasn't caught locally, so RunAsync returned 0 even after publishing. OutboxBackgroundService then backed off to the idle interval, which adds publish latency under load.

What changed

  • New IInboxCleanupStore / IOutboxCleanupStore (Abstractions)
    • Npgsql: NpgsqlInboxCleanupStore / NpgsqlOutboxCleanupStore run a single DELETE … WHERE "Id" IN (SELECT … ORDER BY … LIMIT … FOR UPDATE SKIP LOCKED). Workers delete disjoint batches, never wait on each other and never fail on each other's rows. Same statement shape as the existing Npgsql lease stores.
    • EF Core fallback (SQL Server and others): EfCore*CleanupStore reads ids and then runs ExecuteDeleteAsync. A row another worker already deleted is simply not counted, with no exception.
    • Registration: follows the lease-store model. TryAddScoped fallback in AddAetherInbox/AddAetherOutbox, AddScoped override in AddAetherNpgsql, so registration order doesn't matter.
  • CleanupInterval is now honoured (inbox), and CleanupInterval + CleanupBatchSize are added to AetherOutboxOptions.
    • A full batch keeps cleanup due on the next cycle, so a backlog still drains one batch per cycle.
    • A short batch or a failure defers cleanup by the interval.
    • Timing uses the monotonic tick count and is due on the first cycle after startup.
  • Outbox cleanup has its own try/catch and can no longer discard the publish count.
  • IInboxStore.CleanupOldMessagesAsync is [Obsolete] and no longer called by Aether. EfCoreInboxStore's implementation delegates to the set-based delete.
  • Docs: docs/inbox-outbox/INTERNALS.md (new Retention Cleanup section) and README.md.

Behaviour changes for consumers

  • Cleanup now runs at most once per hour per worker by default, unless a backlog is draining. Set CleanupInterval = TimeSpan.Zero to get the old every-cycle behaviour.
  • Outbox cleanup batch size is now CleanupBatchSize (default 1000) instead of BatchSize (100).
  • Custom overrides of IInboxStore.CleanupOldMessagesAsync are no longer invoked. Register an IInboxCleanupStore or override InboxProcessor.CleanupOldMessagesAsync instead.

Tests

  • NpgsqlCleanupStoreTests (Testcontainers, 7 tests):
    • Only expired Processed rows are deleted, oldest first.
    • Worker A holds its batch's locks in an open transaction while worker B deletes the remaining rows without blocking (10 s guard) and without an exception.
    • The EF fallback returns 0 for rows that are already gone.
    • The Npgsql stores win in both registration orders.
    • Mutation check: removing SKIP LOCKED makes the concurrency test time out.
  • CleanupScheduleTests (6 tests): interval / full-batch / failure deferral for both processors, and outbox cleanup failure no longer zeroes RunAsync's count. It shares an xUnit collection with OutboxProcessorTraceTests, because that test's ActivityListener is process-wide and would otherwise capture Outbox.Process spans from these tests running in parallel.
  • Results: Infrastructure.Tests 230/230 (3 runs), AspNetCore.Tests 15/15, solution builds with 0 errors.
  • Postgres.Tests: 142/143. The one failure, MultiSchemaUnitOfWorkTests.Exceeds_max_context_limit_throws, also fails on clean master and is unrelated to this change.

Reviewer notes

  • Not exercised against a real SQL Server. The SQL Server provider doesn't register lease stores yet, so inbox/outbox isn't functional there anyway, and the EF fallback is provider-agnostic LINQ.
  • No migration is needed. The existing IX_InboxMessages_Cleanup(Status, HandledTime) and IX_OutboxMessages_Cleanup(ProcessedAt, CreatedAt) indexes cover the new queries.

🤖 Generated with Claude Code

…CKED

With several replicas, every worker's cleanup read the same oldest processed
rows without a lock and removed them via RemoveRange. The first commit won and
the rest failed with DbUpdateConcurrencyException ("expected to affect 1 row(s),
but actually affected 0"), logged at Error on every pod. On the outbox side the
exception also escaped to RunAsync, which returned 0 and pushed polling into
idle backoff right after publishing.

- Add IInboxCleanupStore / IOutboxCleanupStore. The Npgsql implementations run
  a single DELETE ... FOR UPDATE SKIP LOCKED, so workers delete disjoint
  batches without waiting or failing. The EF Core fallbacks (TryAdd) use a
  set-based ExecuteDelete that never raises a concurrency error.
- Honour CleanupInterval (it was documented but never read) and add it plus
  CleanupBatchSize to the outbox options. A full batch keeps cleanup due so a
  backlog still drains; a short batch or a failure defers by the interval.
- Wrap outbox cleanup in its own try/catch so it cannot discard the publish count.
- Mark IInboxStore.CleanupOldMessagesAsync [Obsolete]; Aether no longer calls it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yilmaztayfun
yilmaztayfun requested review from a team September 23, 2026 19:42
@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 0b94f50e-c4ca-4d2f-bfa5-8ac8da80e8f5


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@yilmaztayfun yilmaztayfun self-assigned this Sep 23, 2026
@yilmaztayfun
yilmaztayfun merged commit bd76660 into master Sep 23, 2026
4 of 5 checks passed
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.

1 participant