Repository navigation
fix(events): delete expired inbox/outbox rows with FOR UPDATE SKIP LOCKED - #109
Merged
Merged
Conversation
…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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
|
4 tasks done
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.




Why
With multiple replicas (seen in test on
vnext-morph-idm-worker-inbox: ~1000 errors/week, on every pod), inbox cleanup logsError cleaning up old inbox messageswith:Each worker's cleanup read the same oldest
Processedrows (ORDER BY … LIMIT) without a lock and removed them viaRemoveRange. The first commit won; every other worker's per-rowDELETE 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 becauseCleanupIntervalwas 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
RunAsyncreturned0even after publishing.OutboxBackgroundServicethen backed off to the idle interval, which adds publish latency under load.What changed
IInboxCleanupStore/IOutboxCleanupStore(Abstractions)NpgsqlInboxCleanupStore/NpgsqlOutboxCleanupStorerun a singleDELETE … 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.EfCore*CleanupStorereads ids and then runsExecuteDeleteAsync. A row another worker already deleted is simply not counted, with no exception.TryAddScopedfallback inAddAetherInbox/AddAetherOutbox,AddScopedoverride inAddAetherNpgsql, so registration order doesn't matter.CleanupIntervalis now honoured (inbox), andCleanupInterval+CleanupBatchSizeare added toAetherOutboxOptions.try/catchand can no longer discard the publish count.IInboxStore.CleanupOldMessagesAsyncis[Obsolete]and no longer called by Aether.EfCoreInboxStore's implementation delegates to the set-based delete.docs/inbox-outbox/INTERNALS.md(new Retention Cleanup section) andREADME.md.Behaviour changes for consumers
CleanupInterval = TimeSpan.Zeroto get the old every-cycle behaviour.CleanupBatchSize(default 1000) instead ofBatchSize(100).IInboxStore.CleanupOldMessagesAsyncare no longer invoked. Register anIInboxCleanupStoreor overrideInboxProcessor.CleanupOldMessagesAsyncinstead.Tests
NpgsqlCleanupStoreTests(Testcontainers, 7 tests):Processedrows are deleted, oldest first.SKIP LOCKEDmakes the concurrency test time out.CleanupScheduleTests(6 tests): interval / full-batch / failure deferral for both processors, and outbox cleanup failure no longer zeroesRunAsync's count. It shares an xUnit collection withOutboxProcessorTraceTests, because that test'sActivityListeneris process-wide and would otherwise captureOutbox.Processspans from these tests running in parallel.MultiSchemaUnitOfWorkTests.Exceeds_max_context_limit_throws, also fails on cleanmasterand is unrelated to this change.Reviewer notes
IX_InboxMessages_Cleanup(Status, HandledTime)andIX_OutboxMessages_Cleanup(ProcessedAt, CreatedAt)indexes cover the new queries.🤖 Generated with Claude Code