GH-4028: make UseDurableInbox() on a Redis stream a real durable inbox - #4031
Conversation
RedisStreamEndpoint was IDatabaseBackedEndpoint so that DurableReceiver would route scheduled retries to the endpoint's Redis-native sorted set. That marker also told DurableReceiver to skip the inbox INSERT and complete the delivery on receipt, so a "durable" Redis listener never wrote the inbox and XACKed every message before its handler ran -- at-most-once on a crash, the opposite of what the mode promises -- and the Redis tests ran UseDurableInbox() with no message store at all. Option B from the issue: drop IDatabaseBackedEndpoint. A Durable Redis listener now goes through the real inbox like every other transport -- INSERT before ACK, handled from the inbox, scheduled retries parked in the inbox -- and therefore requires a message store. Redis-native scheduled retries are kept for the modes where they belong: RedisStreamListener implements ISupportNativeScheduling with NativeSchedulingEnabled => Mode != Durable, parking the copy in the scheduled sorted set and then acknowledging the original; that is an improvement for Buffered (previously in-memory, lost on crash) and Inline (previously the inbox, which needed a store). A Durable listener deliberately reports false there: a listener-level reschedule bypasses the inbox and re-adding the same envelope id to the stream would collide with the inbox row and be discarded as a duplicate on redelivery. Tests: new durable_inbox_is_real_4028 (Redis + Postgres inbox: the row is Incoming while the handler is parked, the stream entry is already acknowledged, and a scheduled retry lands in the inbox with the Redis scheduled set untouched, then retries). EndToEndRetryTests and rate_limiting_end_to_end move to BufferedInMemory (they assert the Redis-native scheduled set, which is the non-durable path now); DatabaseBackedEndpointTests / NativeSchedulingRetryTests assert the endpoint is NOT IDatabaseBackedEndpoint and the listener is ISupportNativeScheduling. Full Wolverine.Redis.Tests 147/147. Docs: new "Durable Inbox" section with the behaviour change called out, and the scheduling sample no longer pairs UseDurableInbox() with "scheduled natively in Redis". Closes #4028 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewed. The fix is right and the reasoning in the description holds up — one real defect, which CI will not catch.
|
Closes #4028 (option B). Found while auditing arrival-side batching for #4026.
The defect
RedisStreamEndpointimplementedIDatabaseBackedEndpointto get Redis-native scheduled retries out ofDurableReceiver. That marker does two things inDurableReceiver, and Redis only wanted one:endpoint.ScheduleRetryAsync(intended);ShouldPersistBeforeProcessing = falseand completes the delivery on receipt — which for Redis isXACKbefore the handler runs, with no inbox write at all.So
ListenToRedisStream(...).UseDurableInbox()was at-most-once on a crash — the exact semantics the mode name says it avoids — and the Redis test suite ran "durable" listeners with no message store configured, which only worked because of this. With a real store configured, every completion also issued a mark-as-handledUPDATEagainst a row that never existed.The fix
RedisStreamEndpointis no longerIDatabaseBackedEndpoint. A Durable Redis listener now behaves like every other durable transport:INSERTinto the inbox before the stream entry is acknowledged, handled from the inbox, scheduled retries parked in the inbox. It requires a configured message store, as everywhere else.RedisStreamListener : ISupportNativeScheduling(NativeSchedulingEnabled => Mode != Durable): park the copy in the stream's scheduled sorted set, then ACK the original (that order so a crash in between costs a duplicate, not a loss). This is strictly better than before for Buffered (was in-memory scheduling, lost on crash) and Inline (was the inbox, which needed a store).RedisStreamEndpoint.ScheduleRetryAsyncstays as the method behind it, with docs explaining why.NativeSchedulingEnabled == false:MessageContext.ReScheduleAsyncprefers a listener-level rescheduler and never completes the envelope in that path, so re-adding the same envelope id to the stream would leave the inbox row orphaned and the redelivered copy would be discarded as a duplicate. The inbox is the only correct place for a durable scheduled retry.Tests
durable_inbox_is_real_4028(Redis + Postgres inbox): (a) with the handler parked, the inbox shows the rowIncomingand the consumer group's pending list is already empty — durable, then acked; (b) aScheduleRetrylands in the inbox asScheduled, the Redis scheduled sorted set stays empty, and the retry still runs; (c) the endpoint is notIDatabaseBackedEndpointEndToEndRetryTests,rate_limiting_end_to_end— both assert the Redis-native scheduled set, i.e. the non-durable path, and both usedUseDurableInbox()with no store; switched toBufferedInMemory()(the code path they were really testing)DatabaseBackedEndpointTests,NativeSchedulingRetryTests— now assert the endpoint is not database-backed and the listener owns native scheduling; theScheduleRetryAsyncsorted-set tests are unchangedWolverine.Redis.Testsdotnet build wolverine.slnx -c Release -f net9.0Docs / behaviour change
New "Durable Inbox" section in
docs/guide/messaging/transports/redis.mdwith a warning box describing the pre-6.30 behaviour and the migration: if you were runningUseDurableInbox()on Redis without a store you now need one; if you actually wanted Redis-native scheduled retries without a database, the defaultBufferedInMemory()(orProcessInline()) listener is that. The scheduling sample no longer pairsUseDurableInbox()with "scheduled natively in Redis".This is a behaviour change for anyone on Redis "durable" today — but the behaviour they had was not durable, so I'd argue it's a bug fix with a loud note rather than a major-version item. Flagging it explicitly so you can disagree.
🤖 Generated with Claude Code