Skip to content

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

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

Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports#533
ejsmith wants to merge 92 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

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:

Workload Before After
In memory, concurrency 1 81,347 87,539
In memory, concurrency 8 80,149 84,685
In memory, concurrency 64 100,073 97,236
Redis tracking, longer validation 6,380 6,503

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.

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 and others added 2 commits June 28, 2026 00:01
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>
@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
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>
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs
Comment thread src/Foundatio/Messaging/InMemoryMessageTransport.cs Fixed
… 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>
Comment thread src/Foundatio/Messaging/MessageClientCore.cs Fixed
…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>
Comment thread src/Foundatio/Jobs/JobRuntime.cs Outdated
ejsmith and others added 5 commits June 29, 2026 16:44
…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>
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