Skip to content

Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports - #533

Draft
ejsmith wants to merge 94 commits into
mainfrom
feat/messaging-jobs
Draft

ejsmith wants to merge 94 commits into
mainfrom
feat/messaging-jobs

Conversation

@ejsmith

@ejsmith ejsmith commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Adds a unified IMessageBus, typed background jobs through IJobClient, and shared monitoring for jobs and broker-delivered work.

Developer experience

Inject the ordinary application APIs:

await bus.SendAsync(new SendReceipt(1001, "dev@example.com"));
await bus.PublishAsync(new OrderPlaced(1001, "Espresso Machine"));

var job = await jobs.EnqueueAsync<ResizeImageJob, ResizeArgs>(
    new ResizeArgs("product.png", 640, 480));
var completed = await job.WaitForCompletionAsync(TimeSpan.FromSeconds(30));
  • Messaging: competing consumers, durable service subscriptions, and separate best-effort SubscribeNodeAsync broadcasts. Implement IMessageHandler<T>; automatic batching keeps single-message calls efficient.
  • Jobs: implement IJob or IJob<TArgs> for delays, progress, cancellation, persisted retries, and CRON. IScheduledJobManager supports schedule edits and manual runs.
  • One job store: IJobRuntimeStore, JobState, and IJobMonitor cover ordinary jobs and tracked broker work, including paginated history, counts, metadata, heartbeats, and cancellation. Choose Jobs.UseInMemory() or Jobs.UseRedis().
  • Delivery controls: scoped handlers, bounded concurrency, prefetch, lease renewal, graceful drain, health checks, and returned MessageOutcome values for success, retry, dead letter, or unsettled work. MessageProcessingContext supplies progress and cooperative cancellation.
  • Operations: native Redis resource locks, queue statistics, dead-letter inspection/replay/deletion, and managed node-subscription cleanup. Fresh attempt tokens prevent stale updates from overwriting a newer attempt.

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

Short deliveries avoid starting an async renewal loop; a timer still supervises expiry from admission. Median jobs/sec, previous native version → this update:

Workload Before After
In memory, concurrency 1 93,045 100,537
In memory, concurrency 8 84,916 91,161
In memory, concurrency 64 95,696 100,346
Redis tracking, longer check 7,656 7,341

Untracked memory throughput improves 5–8%, with about 5% fewer allocations and 17% less CPU at concurrency 64. Longer Redis checks were 4% slower, with 5% less CPU; no Redis/SQS speedup is claimed. System .NET 10.0.12, Release, repeated local runs. Workloads, latency and raw results. LocalStack does not measure production AWS capacity.

Additional details

  • Broker records share the job store but cannot enter job-worker claims or recovery. Queue delivery is at least once; node broadcasts are best effort. Enqueue/replay are not transactions with business data.
  • External references now preserve Release configuration; the integration's CI detects unoptimized assemblies.
  • Full build and 2,235 tests pass (24 existing skips), including six lease lifecycle/race regressions, Redis/LocalStack conformance and Quickstart verification. 7.52M comparison jobs had no missing or duplicate deliveries. A ten-minute recovery run verified 69,354 accepted jobs, 40 cancellations, and recovery of all 32 interrupted deliveries, leaving no pending or failed jobs.

Comment thread src/Foundatio/Jobs/JobScheduler.cs Fixed
Comment thread src/Foundatio/Jobs/JobRuntime.cs Fixed
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
Comment thread src/Foundatio/Messaging/PubSub.cs Fixed
Comment thread src/Foundatio/Queues/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
Comment thread src/Foundatio/Queues/MessageQueue.cs Fixed
Comment thread src/Foundatio/Jobs/JobRuntime.cs Outdated
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/PubSub.cs Fixed
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/PubSub.cs Fixed
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/MessageQueue.cs Fixed
Comment thread src/Foundatio/Messaging/MessageTopology.cs
@ejsmith

ejsmith commented Jun 28, 2026

Copy link
Copy Markdown
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.

fix: address messaging/jobs design-review feedback

Durable jobs + CRON

  • Hosted runtime driver (JobRuntimeService + AddJobRuntimeService()): pumps occurrence materialization, due-dispatch, recovery, and queued-job execution. The runtime previously never ran end-to-end.
  • Lease ownership enforced in TryTransitionAsync (expectedNodeId) so a stale worker can't overwrite the reclaiming node's terminal state.
  • Lease renewal heartbeat during execution; the run is cancelled if the lease is lost (RenewClaimAsync was dead code).
  • Strong node identity (machine:pid:token), capped exponential retry backoff.
  • Replaced 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.
  • Fixed an in-memory push-path double-settle (tolerate already-settled receipts in the safety-net abandon).
  • In-memory transport now honors visibility timeouts (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

  • Silent capability skips → Assert.Skip; ordering assertions gated on the declared OrderingGuarantee; added visibility-timeout, competing-consumer, and DLQ-read scenarios.

refactor: hoist shared MessageQueue/PubSub behavior into MessageClientCore (M1, M2)

  • Removed the large duplication between MessageQueue and PubSub via an internal MessageClientCore; unified the two handle classes and the two HandleMessageAsync overloads. Pub/sub subscriptions now also honor RedeliveryBackoff (prior drift).
  • Enforce ITransportInfo.MaxBatchSize by chunking oversized sends.

Still intentionally not addressed (no external provider on this branch): proving the harness against a real transport before cutting the public API — that's the phased-rollout gate from the plan.

Comment thread src/Foundatio/Jobs/JobScheduler.cs Outdated
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs
Comment thread src/Foundatio/Jobs/JobRuntime.cs Fixed
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
Comment thread src/Foundatio/Messaging/MessageClientCore.cs Fixed
Comment thread src/Foundatio/Messaging/MessageClientCore.cs Fixed
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
Comment thread src/Foundatio/Messaging/MessageClientCore.cs Fixed
Comment thread src/Foundatio/Jobs/JobRuntime.cs Outdated
Comment thread tests/Foundatio.Tests/Queue/MessageQueueTests.cs Outdated
Comment thread src/Foundatio/Messaging/MessageClientCore.cs
Comment thread src/Foundatio.Aws/AwsMessageTransport.cs
Comment thread src/Foundatio.Redis/Messaging/RedisStreamsMessageTransport.cs
@ejsmith ejsmith changed the title Messaging and jobs redesign in-memory slice Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports Jul 10, 2026
ejsmith and others added 16 commits September 12, 2026 17:46
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>
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>
@ejsmith
ejsmith force-pushed the feat/messaging-jobs branch from 7e6e0a1 to 56a88f0 Compare September 12, 2026 23:51
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