Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports - #533
Draft
ejsmith wants to merge 92 commits into
Draft
Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports#533ejsmith wants to merge 92 commits into
ejsmith wants to merge 92 commits into
Conversation
Durable jobs + CRON: - Add hosted JobRuntimeService that pumps occurrence materialization, due-dispatch, recovery, and queued-job execution (runtime previously never ran end-to-end). Register via AddJobRuntimeService(). - Enforce lease ownership in TryTransitionAsync (expectedNodeId) so a stale worker cannot overwrite the reclaiming node's terminal state. - Renew the claim on a heartbeat during execution and cancel the run when the lease is lost (RenewClaimAsync was dead code). - Strong, process-unique node identity (machine:pid:token). - Capped exponential CRON retry backoff (per-definition override). - Replace the hand-rolled cron parser with the vendored Cronos (moved into core Foundatio.Cronos); materialize every missed occurrence in the misfire window, not just the latest. Messaging: - Resilient consumer/subscription loops: a poison message or transient receive error no longer silently kills the consumer. - Fix in-memory push path double-settle (tolerate already-settled receipts in the safety-net abandon). - Honor visibility timeouts in the in-memory transport (reaper redelivers unsettled messages) so its advertised at-least-once guarantee is real. - Log handler exceptions instead of swallowing them. - Drop the misleading write-only content-type header. - Add ReceiveDeadLetteredAsync so poison payloads are inspectable. Conformance harness: - Replace silent capability skips with Assert.Skip. - Gate ordering assertions on the declared OrderingGuarantee. - Add visibility-timeout, competing-consumer, and DLQ-read scenarios. Tests cover lease-stomp rejection, manual ack, poison survival, multi-occurrence CRON, and the hosted runtime running a queued job. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tCore (M1, M2) M1 — remove the large duplication between MessageQueue and PubSub: - New internal MessageClientCore owns serialization, header/trace construction, routing-agnostic send, runtime-store scheduled dispatch, received-message creation with poison handling, auto/manual ack settlement, and the resilient consumer/subscription loop. - Unify the two near-identical handle classes into one MessageListenerHandle implementing both IMessageConsumer and IMessageSubscription. - Collapse the duplicated HandleMessageAsync overloads into a single generic method. - MessageQueue and PubSub become thin adapters mapping their option shapes onto the core. Fixes the prior drift: pub/sub subscriptions now also honor RedeliveryBackoff. M2 — enforce ITransportInfo.MaxBatchSize: oversized sends are split into chunks of at most MaxBatchSize (test via a fake transport). Behavior preserved — verified by the existing messaging, queue, and jobs suites plus the added chunking test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Pushed two commits addressing the messaging/jobs design-review feedback. All changes are green against the messaging, queue, and jobs suites (plus the conformance harness); full core suite passes.
|
Close gaps surfaced in design review of the messaging/jobs redesign: - Prove redelivery-delay and lock-renewal: InMemoryMessageTransport now implements ISupportsRedeliveryDelay (timer-based re-enqueue that wakes a blocked receiver) and ISupportsLockRenewal (extends the in-flight visibility window), with conformance tests for both. - Make the attempt counter transport-independent: ReceivedMessage.Attempts reconciles DeliveryCount with the message.attempts header so store-backed redelivery can't reset the count and loop forever. - Real back-pressure: the pull loop is now a SemaphoreSlim-gated continuous dispatcher (per-message slot release, opportunistic batch claim) instead of a Task.WhenAll batch barrier, eliminating head-of-line blocking. - Core-owned metrics: foundatio.messaging.* and foundatio.jobs.* counters and histograms emitted on FoundatioDiagnostics.Meter. - Receive-side trace continuity: handlers run inside a Consumer Activity linked to the producer's traceparent/tracestate. - Configurable job cancellation polling (default 1s instead of fixed 50ms). - Document the IQueue namespace collision and the using-alias remedy. Add BasicQueueTransport test double (pull-only, opaque headers, no time-based capabilities) and repoint the unsupported-lock and redelivery-fallback tests at it, keeping fallback coverage and proving the attempt reconciliation end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… type demux Address messaging/jobs design-review feedback on the transport-backed messaging API. The transport stays a thin set of primitives; the core owns serialization, routing, retry, and dead-lettering so behavior is identical across transports. Settlement: replace IReceivedMessage.AbandonAsync/DeadLetterAsync with a single RejectAsync(RejectOptions) verb (Terminal/Reason/RedeliveryDelay). Terminal reject moves the message to the transport's native dead-letter sink, else a configured RetryPolicy.DeadLetterDestination, else drops (honest at-most-once) instead of throwing. Consumers: one receive loop per source that demultiplexes by message type, so multiple typed consumers can share a destination without mis-dispatch; same-type consumers compete round-robin. An unmatched type increments foundatio.messaging.unhandled, throws UnhandledMessageTypeException (isolated per message so the loop and other handlers survive), retries, and dead-letters as "no-handler" after a lenient budget. Retry policy: add RetryPolicy (MaxAttempts/Backoff/DeadLetterDestination/UnmatchedMaxAttempts/UnmatchedBackoff), configurable via Messaging.ConfigureRetry and overridable per consumer. The broker delivery count is the crash-safe attempt counter; no broker-native redrive config. Capabilities: add MaxDeliveryDelay/MaxRedeliveryDelay/MaxVisibilityTimeout to the delay/visibility capability interfaces so an over-limit delay routes through the durable runtime store instead of being silently truncated by the broker. Docs: rewrite settlement section and add 'core owns behavior' + 'retry and dead-lettering' guidance. Add 7 tests covering cap-routing, Reject, multi-type demux, unmatched dead-letter, core-managed DLQ, and default-tier MaxAttempts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ti-type dispatch, job context, recovery, ownership, CRON)
Pub/sub addressing: the transport receive source for a subscription is now the topic-qualified "{topic}/{subscription}" composite (exposed as IMessageSubscription.Source), so the same subscription identity on two topics stays isolated instead of colliding on a bare name.
Multi-type dispatch: add IMessageTypeRegistry (stable name<->type, Type.FullName fallback, RegisterMessageType<T>) as the single wire-discriminator authority; interface/base-routed consumers now resolve the concrete payload type from the message.type header and deserialize the actual type (assignable to the route type) instead of raw-envelope-only. Removes the orphaned MessageTypeResolver/UseMessageTypeName router API.
Job execution context: IJobWithExecutionContext receives a JobExecutionContext (job id, attempt, store-backed progress, lease heartbeat, cancellation checks); remove the always-throwing ReportProgressAsync from IReceivedMessage.
Non-CRON job recovery: the runtime pump reclaims plain jobs stuck in Processing past their lease via IJobRuntimeStore.GetExpiredProcessingAsync (excludes CRON occurrences) + a lease+owner-aware TryReclaimExpiredAsync (re-queue while attempts remain, else dead-letter), closing the renew race that could double-run a live job.
Transport ownership: OwnsTransport flag so DI-built queue and pub/sub clients do not both dispose a shared singleton transport (the container disposes it once); direct construction still owns it.
CRON: mark the legacy in-process AddCronJob/AddJobScheduler/ScheduledJobService path as legacy/compat with docs pointing to the durable runtime (full reroute deferred).
Adds 8 tests (pub/sub isolation, interface concrete-deserialize, job context, stale recovery + reclaim guard + occurrence exclusion, DI dispose-once, delay cap routing) and updates the redesign guide. All messaging/queue/jobs tests pass; solution builds on net8 + net10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s-transport Adds a temporary in-repo Foundatio.Aws provider (AwsMessageTransport over SQS/SNS) to validate the redesigned IMessageTransport contract against a real broker, plus the contract refinements that validation surfaced. Verified against LocalStack: 8 conformance tests pass, 5 skip for capabilities SQS lacks (priority, per-message expiration, push, transport-native dead-letter); in-memory conformance and the full messaging/queue/jobs suite remain green. Transport contract refinements driven by the AWS implementation: - TransportSendOptions.DestinationRole: the caller states queue vs topic so a transport routes without inferring (SNS publish vs SQS send). MessageClientCore sets it from the dispatch kind. - TransportMessage.ContentType: lets a text-native broker (SQS/SNS) store a text body (e.g. JSON) directly instead of base64; binary still base64s. - MessageDestinationStats: lifetime counters (Enqueued/Dequeued/Completed/Abandoned/Errors/Timeouts) are now nullable (null = not reported, e.g. SQS exposes no lifetime completed count); Queued/Working/Deadletter remain best-effort gauges. - ReceiptExpiredException documented as a best-effort, transport-specific signal (SQS delete is idempotent). - InMemoryMessageTransport now wakes a blocked receive when a visibility window lapses (reclaim timer), matching real brokers so the harness can long-poll uniformly. AWS provider: SQS queues + SNS topics/subscriptions (raw delivery + queue policy), capability max-bounds (15-min delay, 12h visibility/redelivery), well-known headers surfaced as native attributes for SNS filter policies, ResourcePrefix for run isolation, LocalStack docker-compose + README. Harness: whole-second timing windows, eventual-consistency-tolerant stats (gauges only), and capability/opt-in gating so the suite runs across in-memory and real brokers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Validates the durable job runtime substrate against a real distributed store. RedisJobRuntimeStore implements all of IJobRuntimeStore using StackExchange.Redis transactions with hash-field conditions for optimistic concurrency (CAS), and a single Lua script for batch due-dispatch claiming. The reclaim path predicates on the exact observed lease value so a concurrent renew defeats a stale reclaim. Extracts a shared JobRuntimeStoreConformanceTests suite (in TestHarness) that both the in-memory reference and Redis run against the same invariants: state round-trips, optimistic transitions (status + node guards + patch application), leases/claims/steal-after-expiry, stale recovery excluding live leases and CRON occurrences (with the renew-during-reclaim race), scheduled-dispatch claim/complete/reschedule, and a contention test asserting exactly-one-winner for concurrent claims, transitions, and dispatch claiming. A FakeTimeProvider drives lease/expiry timing so the suite is fast and deterministic with no real sleeps. The Redis suite is gated on FOUNDATIO_REDIS_CONNECTION_STRING (skips when unset); each test isolates under a unique key prefix. Both suites: 6/6 green (Redis vs redis:7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conformance suite covers ScheduleDispatch/ClaimDueDispatches as primitives; these tests wire the real messaging core and CRON scheduler on top of the Redis IJobRuntimeStore and exercise the two paths the store exists to support: 1. A queue send whose delay exceeds the transport's MaxDeliveryDelay is routed into Redis (not truncated to the broker ceiling), stays time-gated (a drain before the due time claims nothing), and is pulled from Redis and handed to the transport once due. A within-cap delay still goes native and never touches the store. 2. CRON occurrences are materialized into Redis (Scheduled JobState + JobOccurrence dispatch), deduped by deterministic occurrence id, claimed and run to completion, retried-then-dead-lettered when they keep failing, and stale-reclaimed (via the Redis CAS reclaim) when an occurrence is stuck Processing under a dead node with an expired lease. Extracts a shared RedisTestConnection helper (gated on FOUNDATIO_REDIS_CONNECTION_STRING, unique key prefix per store) used by both the conformance and integration suites. Redis suite: 9/9 green (6 conformance + 3 integration) against redis:7; in-memory unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tter) Pivots the Redis pub/sub work to Streams + consumer groups, the Redis primitive that actually supports ack/reject/retry (native pub/sub is fire-and-forget and can't redeliver). A stream is a queue/topic; a consumer group is a subscription — the default group for a plain queue gives competing consumers, one group per named subscription gives topic fan-out. RedisStreamsMessageTransport implements ISupportsPull, VisibilityTimeout, LockRenewal, RedeliveryDelay, DeadLetter, Provisioning, Stats and ITransportInfo (AtLeastOnce/Fifo) — the same capability set as the AWS SQS transport plus native dead-letter. XADD produces, XREADGROUP consumes, XACK+XDEL completes, and reclaim (abandon, redelivery delay, lock expiry, crashed consumer) is driven by a per-group lease: a sorted set scored by visible-until (unix-ms) plus a hash of owner-token|delivery -count. Because the lease lives in Redis, a message held by a crashed instance is recovered by any other instance; a stale receipt is detected by the owner token and surfaced as ReceiptExpiredException. Streams has no native per-message delay/priority, so those route through the runtime store / are unsupported (the contract's core owns that). Validation against redis:7: the cross-transport conformance suite runs 10/14 (push/priority/expiration/delayed-delivery skip via capability gates) and integration tests cover cross-instance crash recovery, the core's retry-then-dead-letter machinery driving the transport unchanged, and PubSub fan-out. Full Redis suite 22/22 green and stable; in-memory unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial contract review findings, batch 1: - P0 #1: runtime-store redelivery computed nextAttempt from the raw transport DeliveryCount, which resets to 1 on re-send for every real broker (SQS, Redis Streams) — pinning the carried attempts header at 2 and redelivering forever (never reaching MaxAttempts). Now advances from the reconciled Attempts. Masked by the in-memory transport, so added a regression test on a DeliveryCount-resetting transport that asserts attempts advance 1->2->3 (fails 3!=2 without the fix). - #3: CRON occurrences are now excluded from the generic worker (JobQuery.ExcludeOccurrences, applied in both stores; RunQueuedAsync sets it) so the scheduler is the sole executor; and a terminal occurrence's dispatch is retired (CompleteDispatchAsync) instead of rescheduled +1min forever. - #4: Redis TryClaimAsync now guards the lease-steal CAS on the exact observed leaseExpiresUtc (mirroring TryReclaimExpiredAsync), so a concurrent same-owner renew invalidates the steal — no double-run. Conformance Leasing test now covers renew-defeats-steal. - #14: PerNode SkipIfRunning scope match no longer uses a fragile JobId EndsWith(":{scope}") (the default node id contains ':'); it extracts the scope precisely past the fixed-width timestamp. - #15: clarified the two attempt-budget knobs (ad-hoc total-attempts vs scheduled retries). Jobs suites green: 18 in-memory + 6 Redis conformance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Adds a unified
IMessageBus, typed background jobs throughIJobClient, and shared monitoring for jobs and broker-delivered work.Developer experience
Inject the ordinary application APIs:
SubscribeNodeAsyncbroadcasts. ImplementIMessageHandler<T>; automatic batching keeps single-message calls efficient.IJoborIJob<TArgs>for delays, progress, cancellation, persisted retries, and CRON.IScheduledJobManagersupports schedule edits and manual runs.IJobRuntimeStore,JobState, andIJobMonitorcover ordinary jobs and tracked broker work, including paginated history, counts, metadata, heartbeats, and cancellation. ChooseJobs.UseInMemory()orJobs.UseRedis().MessageOutcomevalues for success, retry, dead letter, or unsettled work.MessageProcessingContextsupplies progress and cooperative cancellation.AddFoundatio()configures producers;AddFoundatioWorker(...)starts configured processing. The Quickstart runs without Docker; the messaging sample shows LocalStack, Redis, multiple replicas, and monitoring. Mediator #335 builds queued handlers and live events on these APIs.Performance
Ordinary sends avoid batch bookkeeping; headers share immutable snapshots; in-memory receipts and unused settlement/renewal gates allocate less. Redis cancellation now expires and reads one job atomically.
Median jobs/sec, previous native version → this update:
In-memory allocations fall 15% (9% tracked). Redis throughput varied across short runs; longer runs used 13% less CPU and 3% fewer allocated bytes, with roughly level throughput. System .NET 10.0.11, Release, repeated local runs. Workload sizes, latency and raw results. Earlier messaging/MassTransit comparison.
Additional details
Broker-owned records never enter job-worker claims or recovery. The broker owns leases, retries and settlement; terminal tracking requires confirmed settlement. Queue delivery is at least once, node broadcasts are best effort, and enqueue/replay are not transactions with application data.
Validation: full build, 2,229 tests passed, 24 existing skips, Redis/LocalStack conformance and Quickstart verification. Coverage includes send failure outcomes, cancellation expiry, immutable headers, concurrent settlement and stale receipts. The broader comparison verified 4.32M jobs plus 46,504 jobs under cancellation and worker restarts. Its report retains two known CLR aborts that passed same-runtime retries. LocalStack results are not production AWS capacity.