Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports#533
Draft
ejsmith wants to merge 57 commits into
Draft
Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports#533ejsmith wants to merge 57 commits into
ejsmith wants to merge 57 commits into
Conversation
Comment on lines
+506
to
+510
| foreach (var state in queued) | ||
| { | ||
| if (await RunJobStateAsync(state, cancellationToken).ConfigureAwait(false)) | ||
| completed++; | ||
| } |
Comment on lines
+55
to
+59
| foreach (var declaration in declarations) | ||
| { | ||
| if (!await provisioning.ExistsAsync(declaration.Name, cancellationToken).AnyContext()) | ||
| missing.Add(declaration); | ||
| } |
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.
|
Comment on lines
+124
to
+186
| foreach (var definition in definitions) | ||
| { | ||
| if (!definition.Enabled) | ||
| continue; | ||
|
|
||
| var cron = ParseCron(definition.Cron); | ||
| var timeZone = definition.TimeZone ?? TimeZoneInfo.Utc; | ||
| var window = definition.MisfireWindow ?? DefaultMisfireWindow; | ||
| if (window < TimeSpan.Zero) | ||
| throw new ArgumentOutOfRangeException(nameof(definition), window, "MisfireWindow must be greater than or equal to zero."); | ||
|
|
||
| string scopeKey = GetScopeKey(definition); | ||
|
|
||
| // Materialize every occurrence that fell due within the misfire window, not just the most recent, so a | ||
| // scheduler that lagged behind the cadence does not silently drop intermediate ticks. Deterministic | ||
| // occurrence ids dedupe across overlapping windows and across nodes ticking simultaneously. | ||
| var occurrences = cron.GetOccurrences(utcNow - window, utcNow, timeZone, fromInclusive: true, toInclusive: true).ToList(); | ||
| if (occurrences.Count == 0) | ||
| continue; | ||
|
|
||
| if (definition.Overlap == OverlapPolicy.SkipIfRunning) | ||
| { | ||
| // Don't stampede: if a prior occurrence is still pending or running, skip this tick entirely; | ||
| // otherwise collapse the window to a single (most recent) catch-up occurrence. | ||
| if (await HasActiveOccurrenceAsync(definition.Name, scopeKey, cancellationToken).ConfigureAwait(false)) | ||
| continue; | ||
|
|
||
| occurrences = [occurrences[^1]]; | ||
| } | ||
|
|
||
| foreach (var occurrence in occurrences) | ||
| { | ||
| string jobId = CreateOccurrenceId(definition.Name, occurrence, scopeKey); | ||
|
|
||
| if (await _store.GetAsync(jobId, cancellationToken).ConfigureAwait(false) is not null) | ||
| continue; | ||
|
|
||
| await _store.CreateIfAbsentAsync(new JobState | ||
| { | ||
| JobId = jobId, | ||
| Name = definition.Name, | ||
| JobType = GetJobTypeName(definition.JobType), | ||
| Status = JobStatus.Scheduled, | ||
| CreatedUtc = utcNow, | ||
| LastUpdatedUtc = utcNow, | ||
| ScheduledForUtc = occurrence | ||
| }, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var dispatch = new ScheduledDispatchState | ||
| { | ||
| DispatchId = jobId, | ||
| Kind = ScheduledDispatchKind.JobOccurrence, | ||
| Destination = definition.Name, | ||
| Body = Array.Empty<byte>(), | ||
| Headers = CreateOccurrenceHeaders(definition, occurrence, scopeKey), | ||
| DueUtc = utcNow, | ||
| JobId = jobId | ||
| }; | ||
|
|
||
| await _store.ScheduleDispatchAsync(dispatch, cancellationToken).ConfigureAwait(false); | ||
| scheduled.Add(dispatch); | ||
| } | ||
| } |
Comment on lines
+628
to
+635
| foreach (var kvp in InFlight) | ||
| { | ||
| if (kvp.Value.VisibilityExpiresUtc is { } expiry && expiry <= now && InFlight.TryRemove(kvp.Key, out var inFlight)) | ||
| { | ||
| Interlocked.Increment(ref Abandoned); | ||
| Enqueue(inFlight.Message with { DeliveryCount = inFlight.Message.DeliveryCount + 1 }); | ||
| } | ||
| } |
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 on lines
+364
to
+368
| foreach (var timer in _redeliveryTimers.Keys) | ||
| { | ||
| if (_redeliveryTimers.TryRemove(timer, out _)) | ||
| timer.Dispose(); | ||
| } |
… 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>
Comment on lines
+746
to
+773
| foreach (var state in stale) | ||
| { | ||
| if (String.IsNullOrEmpty(state.NodeId)) | ||
| continue; | ||
|
|
||
| // TryReclaimExpiredAsync re-verifies (atomically) that the job is still owned by the same presumed-dead | ||
| // node and its lease is still expired, so a worker that renewed between the scan and here is not yanked out | ||
| // from under itself (no double-run). Attempts are incremented per run, so a job that keeps crashing is | ||
| // dead-lettered once it has consumed its attempt budget instead of being re-queued forever. | ||
| bool transitioned = state.Attempt >= maxAttempts | ||
| ? await _store.TryReclaimExpiredAsync(state.JobId, now, state.NodeId, JobStatus.DeadLettered, new JobStatePatch | ||
| { | ||
| Error = $"Lease expired after {state.Attempt} attempt(s) without completion.", | ||
| ClearNodeId = true, | ||
| ClearLeaseExpiresUtc = true, | ||
| CompletedUtc = now, | ||
| LastUpdatedUtc = now | ||
| }, cancellationToken).ConfigureAwait(false) | ||
| : await _store.TryReclaimExpiredAsync(state.JobId, now, state.NodeId, JobStatus.Queued, new JobStatePatch | ||
| { | ||
| ClearNodeId = true, | ||
| ClearLeaseExpiresUtc = true, | ||
| LastUpdatedUtc = now | ||
| }, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| if (transitioned) | ||
| recovered++; | ||
| } |
…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>
…mple Converge the sample story on the redesign: remove the legacy Foundatio.HostingSample (old IMessageBus + AddJob/AddCronJob/JobManager demo) and have the shared Aspire AppHost launch only the new Foundatio.MessagingSample (plus Redis + LocalStack). Removed from both solutions and the AppHost project reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the legacy IMessageBus surface (IMessageBus/IMessagePublisher/
IMessageSubscriber, MessageBusBase, InMemory/Null bus, Message,
Shared/InMemory options) into Foundatio.Messaging.Legacy so it no longer
collides with the redesigned messaging contract (IMessageTransport/IQueue/
IPubSub) that keeps the Foundatio.Messaging namespace.
Referencers that still consume the legacy bus (HybridCacheClient,
CacheLockProvider, WorkItemJob, the DI extensions, the hosting scheduled-job
services, and the test harness/tests) gain a `using Foundatio.Messaging.Legacy;`.
Repoint the Xunit logger-base doc crefs at the new IPubSub.SubscribeAsync{T}.
No behavior change: full solution builds clean; the in-memory messaging,
hybrid-cache, lock, and work-item-job suites stay green (448 passed, 1 skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the legacy job surface (JobRunner, JobBase/JobContext, JobOptions, IQueueJob/QueueJobBase/QueueEntryContext, JobWithLockBase, JobAttribute, and the WorkItemJob family) into Foundatio.Jobs.Legacy so the redesigned durable- jobs API (IJobRuntimeStore/IJobClient/IJobWorker/IJobScheduler, JobState, ScheduledJobDefinition, JobExecutionContext) owns a clean Foundatio.Jobs surface. IJob.cs is split: the shared IJob contract, the new IJobWithExecutionContext, and the shared TryRunAsync helper (used by the new JobWorker) stay in Foundatio.Jobs; the legacy IJobWithOptions and the continuous-run RunContinuousAsync extensions move to Foundatio.Jobs.Legacy (LegacyJobRunExtensions). Referencers that still consume the legacy job types (the hosting job infra, the test-harness job/queue fixtures, and the job tests) gain a `using Foundatio.Jobs.Legacy;`. No behavior change: full solution builds clean; the Jobs test namespace stays green (56 passed, 1 manual-only skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bs.Legacy Move the legacy in-process hosting job types — JobManager/IJobManager, the HostedJobService runner, the in-process ScheduledJobService CRON scheduler and its ScheduledJobInstance state, Cron, DynamicJob, and the HostedJob/ScheduledJob options+builders+registration — into Foundatio.Extensions.Hosting.Jobs.Legacy. Split JobHostExtensions: the new durable-runtime entry point (AddJobRuntimeService) stays in Foundatio.Extensions.Hosting.Jobs; the legacy registration API (AddJob/AddCronJob/AddDistributedCronJob/AddJobScheduler/ AddJobLifetimeService) moves to LegacyJobHostExtensions in the .Legacy namespace. The one durable-runtime bridge, JobRuntimeService, also stays. No external consumers referenced the legacy hosting types or DI methods, so the only fixups were an internal cref. No behavior change: full solution builds clean; the Jobs test namespace stays green (56 passed, 1 manual-only skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared IJobWithExecutionContext doc comment cross-referenced the legacy Foundatio.Jobs.Legacy.IJobWithOptions purely to keep the cref resolving after the jobs isolation. Reword to plain prose so the new/shared Foundatio.Jobs surface carries no documentation dependency on the legacy namespace. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new durable-jobs contract is now one interface: IJob.RunAsync(JobExecutionContext context). Every run is handed its context (cancellation token, job id, attempt, and store-backed progress/heartbeat helpers) and uses what it needs — no separate IJobWithExecutionContext marker to remember. JobExecutionContext gains a public "detached" constructor so a job can be run outside the durable runtime (tests, one-off invocations); its store-backed helpers become no-ops there. Because the old IJob (RunAsync(CancellationToken)) was shared with the legacy job system, legacy is forked onto its own self-contained contract: Foundatio.Jobs.Legacy gains its own IJob and its own JobResult/JobResultExtensions, plus a legacy TryRunAsync. The legacy job types, hosting runners, and legacy tests bind to those (dropping the now-ambiguous `using Foundatio.Jobs;`), leaving the new Foundatio.Jobs surface clean. Full solution builds (net8.0 + net10.0, warnings-as-errors); Jobs test namespace green (56 passed, 1 manual-only skip). Sample jobs updated to the new signature; the fuller sample rewrite (declarative handlers, fluent providers) follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omatic sample Messaging handlers are now declarative: AddFoundatio().Messaging.AddQueueHandler<T,THandler>() (competing consumers) and AddBroadcastHandler<T,THandler>() (fan-out via a per-instance subscription), plus delegate overloads. Handlers implement IMessageHandler<T> and are resolved from DI in a per-message scope; a single auto-registered hosted service starts and stops them. Programmatic StartConsumerAsync/SubscribeAsync remain for dynamic use. Fluent provider + CRON registration for clean, idiomatic startup code: - .Messaging.UseAws() (Foundatio.Aws) — SQS/SNS, binding ServiceUrl/Region/ResourcePrefix/ credentials from an "Aws" config section, overridable via a lambda. - .Messaging.UseRedis() / .Jobs.UseRedis() (Foundatio.Redis) — Redis Streams transport and Redis job runtime store, sharing one IConnectionMultiplexer (from DI or a connection string / the "Redis" configuration entry). - .Jobs.AddCronJob<TJob>(cron, o => ...) — registers a durable CRON schedule (CronJobOptions for scope/overlap/etc.); the runtime pump schedules all registered definitions on start, so no manual IJobScheduler.ScheduleAsync call is needed. The Aspire sample is rewritten to this surface: one AddFoundatio() chain with UseAws() + declarative handlers + UseRedis() + AddCronJob<>(); the hand-written MessagingWorkers and the dynamic UseTransport switch are gone. New DeclarativeRegistrationTests cover handler hosting/dispatch (queue class, queue delegate, broadcast) and AddCronJob registration + pump scheduling. Full solution builds (net8.0 + net10.0, warnings-as-errors); in-memory suite green (2004 passed), live Redis suite green (27 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- MessageHandlerHostedService: dispose every started consumer even if one DisposeAsync throws (a broker dropping mid-shutdown no longer leaks the rest), and roll back already-started consumers if StartAsync fails partway (a hosted service whose StartAsync throws is never sent StopAsync). - JobRuntimePumpService: schedule declaratively-registered CRON jobs before the Enabled short-circuit, so AddCronJob's "scheduled automatically" contract holds even when the pump is disabled for manual control. - WorkItemJobTests: drop the now-dead `using Foundatio.Jobs;` so the legacy test imports only Foundatio.Jobs.Legacy, removing a latent CS0104 ambiguity (IJob/ JobResult now exist in both namespaces). - UseRedis: document that messaging and jobs share one connection, so the connection string from the first UseRedis call wins. Found by an adversarial review of the two prior commits (all findings low/medium). Full solution builds (net8.0 + net10.0, warnings-as-errors); declarative + jobs suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| if (expectedAttempt < 3) | ||
| { | ||
| await received.RejectAsync(new RejectOptions { RedeliveryDelay = TimeSpan.FromMinutes(1) }, cancellationToken); | ||
| Assert.Equal(1, await processor.RunDueOccurrencesAsync(now.AddMinutes(expectedAttempt * 2), cancellationToken: cancellationToken)); |
Comment on lines
+625
to
+629
| foreach (var message in messages) | ||
| { | ||
| if (message.Body.Length > maxBytes) | ||
| throw _exceptionFactory($"Message of {message.Body.Length} bytes exceeds transport \"{_transport.GetType().Name}\" maximum of {maxBytes} bytes for destination \"{destination}\".", null); | ||
| } |
Comment on lines
+371
to
+375
| foreach (char c in name) | ||
| { | ||
| if (!(Char.IsAsciiLetterOrDigit(c) || c is '-' or '_')) | ||
| return false; | ||
| } |
Comment on lines
+397
to
+401
| foreach (var value in entry.Values) | ||
| { | ||
| if (value.Name == name) | ||
| return value.Value.IsNull ? null : value.Value.ToString(); | ||
| } |
…ology-free
The primary messaging client is now a single IMessageBus with two verbs:
SendAsync (a command / unit of work — exactly one handler instance across the
fleet processes it) and PublishAsync (an event — each subscribing service
receives one copy, its instances competing). Handler registration carries no
topology decision: AddHandler<T,THandler>(o => ...) replaces AddQueueHandler/
AddBroadcastHandler, wiring both a queue consumer (Send target) and a
service-identity subscription (Publish target) for the type. PerInstance opts a
handler into per-replica fan-out for published messages (cache invalidation,
config reload); Subscription forms an independent named subscriber group. The
underlying IQueue/IPubSub clients remain for advanced scenarios (pull receive,
programmatic consumers); MessageBus is a facade over them, and DI registers it
as the primary client.
The interface takes the IMessageBus name from the legacy bus, so the remaining
dual-namespace files flip to pure Foundatio.Messaging.Legacy (the builder's
legacy compat methods use an alias) — same pattern as the IJob fork. Producer
options are renamed to match the verbs: QueueMessageOptions -> MessageSendOptions,
PubSubMessageOptions -> MessagePublishOptions.
Because one type can now be both sent and published, transports segregate queue
and topic namespaces: Redis Streams prefixes stream keys by role ("q:"/"t:")
and the in-memory transport keys destinations the same way (subscriptions are
queue-shaped destinations a topic fans into, mirroring SNS->SQS). Without this
a same-named queue and topic shared one stream and cross-delivered.
Sample rewritten to the new surface: endpoints inject IMessageBus and the verb
reads as intent; the announcement handler demonstrates PerInstance.
Tests: DeclarativeRegistrationTests now proves send/publish isolation on one
type and once-per-service vs PerInstance across two simulated instances; a live
Redis test proves the same isolation on Streams. Full solution builds (net8 +
net10, warnings-as-errors); in-memory 2005 green; live Redis 28 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gone
MessageBus is now a first-class client over a single MessageClientCore, not a
facade: MessageQueue, PubSub, IQueue, IPubSub, and their option records are
deleted. The surface is one interface — SendAsync/PublishAsync (+batches),
ReceiveAsync (pull), and SubscribeAsync, where a subscription is one logical
attachment listening on the message type's two delivery channels (its send
destination and this subscriber's identity on its topic). One options type,
MessageSubscriptionOptions, serves declarative AddHandler and programmatic
SubscribeAsync alike.
Semantics hardened per adversarial review of the previous commit:
- Multiple handlers can attach to one message type (previously crashed at
startup on a consumer-key collision): default consumer keys are unique per
subscription, so handlers compete round-robin for sent messages while an
explicit Key still opts into one shared group.
- Each handler class is its own subscriber group ("{service}.{handler}" via
SubscriptionQualifier), so every handler registered for an event receives its
own copy of each published message — instead of handler classes
nondeterministically competing for one service copy.
- Redis Streams: completing/dead-lettering no longer XDELs entries on topic
streams (one group settling first must not delete a publish for slower
groups); ExistsAsync/DeleteAsync are role-aware (topology validation works,
topic cleanup no longer leaks or hits the wrong stream, subscription delete
destroys only its group); GetStatsAsync no longer creates phantom
streams/groups when probing.
- PerInstance subscription names are derived at subscription start, per
provider, fixing the shared-"unique"-name flaw for multiple providers.
- Legacy gets its own MessageBusException (Foundatio.Messaging.Legacy), so a
pure-legacy consumer can catch the legacy bus's exceptions without importing
the new namespace; the remaining seven dual-namespace imports are flipped to
pure-.Legacy.
Tests: multiple-handlers semantics (each gets published events, a send reaches
exactly one), send/publish same-type isolation via one subscription, idempotent
same-key registration (behavioral, not reference equality), and a live-Redis
regression test that a publish completed by one group is still delivered to a
slower group. Full solution builds (net8 + net10, warnings-as-errors);
in-memory 2006 green; live Redis 29 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The handler-facing type is the consumption context wrapping a message — payload plus delivery metadata (Attempts, headers) plus the operations valid during this delivery (Complete/Reject/RenewLock) — so name it that, matching the ecosystem convention (MassTransit ConsumeContext<T>.Message) and rhyming with the jobs side, where a job runs with a JobExecutionContext. Handlers now read context.Message instead of message.Message; internal CreateReceivedMessage/ ReceivedMessage names follow suit, and handler parameters are named context. Mechanical rename; no behavior change. Full solution builds; in-memory 2006 green; live Redis 29 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nc-empty transports The bus contract is now exactly three ideas: Send, Publish, Subscribe. ReceiveAsync/MessageReceiveOptions were queue-residue no bus-level abstraction in the ecosystem carries (MassTransit/NServiceBus/Wolverine/Rebus are all handler-based) — and they were asymmetric, pulling only the send channel. Advanced pull consumption remains where it belongs, on the transport (IMessageTransport / ISupportsPull). Converting the pull-based tests to subscriptions exposed a real core hole: the pull loop trusted transports to honor MaxWaitTime, so a transport whose ReceiveAsync completes synchronously-empty ran the loop inline forever (the subscribe call never returned) or hot-spun. The loop now starts via Task.Run and sleeps out the remainder of the poll window after an early empty poll. Tests move to a MessageCollector helper over SubscribeAsync with manual ack (inspect + settle deliveries explicitly); the pull-only poison-payload test is deleted (the push-path equivalent already covers poison dead-lettering). Full solution builds; in-memory 2005 green; live Redis 29 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rensics, topology logging
From the messaging-conventions research (the strongest cross-library convergence
points), all convention-over-config: better behavior with zero new required setup.
Fail-fast exception classification — the escape hatch every mature stack
independently grew (NServiceBus AddUnrecoverableException, MassTransit Ignore<T>,
Rebus IFailFastException, Wolverine's error DSL) distilled to one knob:
MessageSubscriptionOptions.DeadLetterOn<TException>() / DeadLetterWhen predicate
(per-subscription, overriding a RetryPolicy.DeadLetterWhen default). A matching
failure dead-letters on attempt 1 with reason "unrecoverable:{ExceptionType}"
instead of burning the whole attempt budget. Deserialization failures already
never retried; that stays structural.
Production-proven retry defaults: RetryPolicy.Backoff now defaults to
DefaultBackoff — immediate first retry, then 10s/20s/30s (capped) with ±20%
jitter, the curve mature stacks converged on. Policy-driven delays are
best-effort (RejectOptions.BestEffortDelay): a transport that can't honor the
delay redelivers immediately instead of failing the settle; explicit caller
delays stay strict. MaxConcurrency=1 keeps its value but now documents why it
deliberately diverges from libraries that default higher.
Dead-letter forensics: terminal messages are stamped with a documented header
contract (exception type/message/truncated stack, reconciled attempts, original
destination, failed-at) on both native and fallback sinks, so a dead message is
triageable with plain transport tooling. When a transport has no native sink and
no destination is configured, the message now parks at the derived
"{source}.deadletter" instead of being silently dropped. Failed attempts that
will retry log WARN; the terminal decision logs ERROR. The in-memory transport's
native dead-letter now honors the caller's (enriched) entry headers, matching
Redis.
Delivery semantics are never invisible: each subscription logs its effective
topology (send destination, topic/subscriber group, concurrency, retry posture)
at Info when it starts.
Tests cover DeadLetterOn first-attempt dead-lettering, the global predicate,
the forensics contract, the default backoff curve, derived-DLQ parking, and
best-effort delay degradation. Full solution builds; in-memory 2011 green;
live Redis 29 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All from an adversarial review of the previous commit (9 confirmed findings): - Derived dead-letter parking is best-effort: a park-send failure logs ERROR and drops (completing the original) instead of throwing — a terminal settle must never stall the consumer loop. MessageContext carries a logger for this. - The runtime-store retry fallback only serves queue-channel entries: a subscription-channel entry's Destination is the opaque topic-qualified address, and a queue re-send to that name would land where no subscription group reads. Policy (best-effort) delays on subscription channels degrade to immediate redelivery; explicit delays fail with a precise error. - The exhausted attempt count is recorded in a forensics-only header (message.dead_letter.attempts) instead of overwriting message.attempts, so a replayed dead-letter starts with a fresh retry budget; exception-less deaths (no-handler, unresolved-type) clear any stale exception forensics. - A handler that settles manually and then throws now logs a distinct "threw after settling" warning instead of a retry/dead-letter that the settle path will skip. - The unmatched-type path joins the WARN-while-retryable / ERROR-when-terminal convention (previously every retryable no-handler attempt logged ERROR via the loop), and the loop no longer double-logs UnhandledMessageTypeException. - LogSubscription logs the effective (clamped) concurrency; Key documents that shared-key subscriptions must configure identical failure policies; MessageBusOptions.RuntimeStore documents the pump requirement for hand-wired options. Full solution builds; in-memory 2011 green; live Redis 29 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New Foundatio.Testing package with MessagingTestHarness: run the real MessageBus over a recording in-memory transport, await quiescence, and assert on what actually moved through the bus. - RecordingMessageTransport (internal) decorates an owned InMemoryMessageTransport with its full capability set, recording sends by role (queue -> Sent, topic -> Published) and settlements (Handled, Abandoned, DeadLettered with reason + delivery count), and tracking every destination/source it has seen for idle detection. - Typed accessors (Sent<T>/Published<T>/Handled<T>/DeadLettered<T>) filter by the message.type wire discriminator and deserialize with the same serializer/type registry the bus uses, so assertions read as domain objects. - WaitForIdleAsync polls aggregate stats until nothing is queued or in flight, requiring two consecutive idle observations so a settling message that cascades into a new send is not missed; timing out throws with the busy destinations and their pending counts named. Delayed redeliveries live only in the inner transport's timer (neither queued nor working), so the decorator tracks scheduled-redelivery markers with a small grace window — without this, WaitForIdle would report idle while a retry was pending. - Messaging.UseTestHarness() registers the harness and points the bus at its transport; the harness resolves the container's serializer/registry so typed accessors agree with the wire format. This makes the core-owned failure path directly assertable in user tests: a poison message's retries (Abandoned) and terminal dead-letter (reason + forensics headers) are recorded facts, not log lines. Full solution builds; in-memory 2015 green; live Redis 29 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From an adversarial review of the previous commit: - WaitForIdleAsync honors Timeout.InfiniteTimeSpan as wait-until-idle (previously the -1ms sentinel produced an already-lapsed deadline, so the call threw TimeoutException immediately whenever the bus happened to be busy); other negative timeouts are rejected with ArgumentOutOfRangeException up front. - Added the missing Abandoned<T>() typed accessor so the retry path has the same typed access as sent/published/handled/dead-lettered. - DeadLetteredMessages documents its boundary: it records deaths made through the transport API; broker-internal deaths (e.g. TimeToLive lapsing before delivery happens inside the transport's receive path) surface in stats and ReceiveDeadLetteredAsync but are not recordable by a decorator. Verified-and-rejected (no change): the wall-clock redelivery-marker grace and the settle-then-record ordering are unreachable races — the redelivery timer and the idle poller share the same timer queue/thread pool (FIFO ordering means the lateness that would delay the timer delays the pruning polls behind it), and the inner settle is fully synchronous so the recording enqueue has no yield point after it. Full solution builds; in-memory 2015 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rojects Foundatio.Redis.Tests and Foundatio.Aws.Tests skip every test when their FOUNDATIO_*_CONNECTION_STRING variables are unset, and Microsoft.Testing.Platform fails a zero-tests session with exit code 8, breaking the build job. Ignore that exit code per project via TestingPlatformCommandLineArguments, the documented mechanism for intentionally all-skipped assemblies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DeduplicationId was settable but no transport implements broker dedup — it only leaked into the message id, aliasing distinct messages. PartitionKey was declared on TransportSendOptions and read nowhere. Both come back only when a capability contract and conformance tests exist for them (review feedback #10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed publishes Transport-wide marker interfaces (ISupportsDelayedDelivery/Priority/Expiration) could not express that SQS queues take a native DelaySeconds while SNS topics have no delay at all: a delayed publish within 15 minutes took the native path and SNS published immediately, silently dropping the delay. Capabilities and limits (delayed delivery + ceiling, priority, expiration, ordering, batch and size limits) now live on a TransportCapabilities record that ITransportInfo returns per destination role, and every core send-path decision — native-vs- runtime-store scheduling, priority/TTL validation, size checks, batch chunking — asks for the role it is actually targeting. Transports that cannot honor a future DeliverAt now refuse it loudly instead of accepting and dropping it (AWS topic branch, Redis Streams), with a new conformance fact enforcing that contract, plus a core regression test proving a delayed publish on a queue-delay-only transport routes through the runtime store. (Review feedback #2.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Destination identity was spread across bare strings, a separate DestinationRole
on the send options, DestinationDeclaration Name/Role/Source triples, and the
formatted "{topic}/{subscription}" SubscriptionAddress convention that
transports re-parsed at receive/settle/delete time. The same subscription was
even declared under two different names — the routing topology declared the
bare subscription name while the runtime subscribe path declared the formatted
string, so Redis consumer groups created by IMessageTopology.EnsureAsync and by
SubscribeAsync would not have agreed.
DestinationAddress (Name + Role + owning Topic for subscriptions) is now the
one identity on every path: send, receive, subscribe, settlement entries,
stats, dead-letter reads, and provisioning (declare/exists/delete). The role
rides with the address, so TransportSendOptions.DestinationRole is gone, the
SubscriptionAddress parse-and-prefix convention is deleted, transports derive
physical names structurally (Redis groups are named by the bare subscription
name scoped to the topic stream; AWS keeps EncodeResourceName(address.Key), so
no physical resources change), and topology and runtime declarations are equal
by construction. ScheduledDispatchState splits the overloaded Destination
field into a typed address for message dispatches and JobName for CRON
occurrences. New ProvisioningLifecycle conformance fact covers
ensure/exists/idempotent-re-ensure/delete. (Review feedback #3.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every subscription used to wire both delivery channels unconditionally — a command-only handler still got a subscriber group provisioned and an idle topic listener, and the core never consulted ITransportInfo.SupportedRoles, so a queue-only transport silently wired listeners on channels it could never feed. MessageSubscriptionOptions.Deliveries (flags: Sent | Published, default Both) states the intent: the default narrows to whatever channels the transport's SupportedRoles can serve (skipped channel logged at debug), an explicit single-channel request the transport cannot serve throws NotSupportedException, and a transport that can serve neither always throws. IMessageSubscription's channel properties are empty for unwired channels. (Review feedback #1.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Publishing and subscribing implicitly granted themselves topology administration — every publish ensured its topic, every subscribe created its subscription, IMessageTopology.ValidateAsync was registered but consumed nowhere, and an app on a locked-down broker had no supported way to run. MessageBusOptions.Topology (and Messaging.ConfigureTopology(...) in DI) now selects the policy: Ensure keeps today's create-on-use behavior and also ensures the declared topology at handler-host startup; Validate never creates — each destination is existence-checked once (cached) and a missing one fails loudly, with the declared topology validated at startup so a misprovisioned app stops at boot instead of erroring per send; None makes no topology calls at all. AWS's AutoCreateDestinations now also guards SNS topic creation (lookup + loud failure instead of CreateTopic when disabled); the explicit EnsureAsync provisioning path always creates, since that call is the administrative intent the option withholds from the data paths. (Review feedback #4.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runtime store bundled five concerns; the one messaging actually depends on — durable storage for delayed sends, store-parked retries, and occurrence triggers — is now its own four-member IScheduledDispatchStore contract, and MessageBusOptions.RuntimeStore takes exactly that, so a provider can offer durable message scheduling without implementing the full job runtime. IJobRuntimeStore composes it, so existing providers are unchanged. Job state, leases, and cancellation stay one contract on purpose: transitions verify ownership atomically and splitting them would break the compare-and-set semantics correctness depends on. (Review feedback #8, the modest version.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A durable job had no way to receive per-invocation data — JobState had no payload field, so every run of a type was identical and real work had to be smuggled through DI singletons. IJobClient.EnqueueAsync<TJob, TArgs>(args) serializes the arguments through the runtime's ISerializer into JobState.Payload with the argument type's full name as the stored discriminator; the job reads them back with JobExecutionContext.GetArguments<TArgs>() (HasArguments to probe), which throws a descriptive error naming the stored discriminator when the payload is absent or unreadable. CRON schedules carry arguments too (ScheduledJobDefinition/CronJobOptions.Arguments, serialized into each occurrence), the detached test context accepts an arguments object directly, the Redis store round-trips the new fields, and the store conformance suite asserts the payload survives persistence. (Review feedback #5.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three execution-model fixes. (1) Every job run now executes inside its own async DI scope, owned for exactly the run — scoped services (DbContexts, units of work) resolve per execution and are disposed when it ends, instead of silently resolving as effective singletons from the root container. (2) The worker gains a bounded pool: JobWorker(maxConcurrency) / JobRuntimePumpOptions.WorkerConcurrency caps in-flight queued jobs per node (default 1 preserves per-node ordering); a slot frees as each job settles, and TryTransition-guarded claims make the parallelism double-run-safe. (3) The pump's scheduling cadence is decoupled from job duration: CRON materialization runs every poll while dispatch/recovery/execution run as one overlapped pass (at most one in flight, drained on shutdown), and the schedule processor now materializes delayed queue/pub-sub message dispatches BEFORE running job occurrences, so the messaging delayed-delivery fallback is never head-of-line blocked by a long job claimed in the same batch. (Review feedback #6.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ared by identity Lease renewal and cancellation polling were fire-and-forget timers whose discarded tasks swallowed every exception: a store outage silently stopped renewals, the lease lapsed, another node reclaimed the job, and the still- running original double-executed its side effects. Both are now supervised async loops owned by the run — started with it, stopped and awaited when it ends. The renewal loop treats a clean "renewal denied" as lease lost (cancelling the run, as before) and now also treats renewal that keeps THROWING the same way once the lease window passes without one success; the cancellation poll loop rides through store failures since a missed poll only delays cooperative cancellation. Also closes the shared-Key policy-divergence footgun: subscriptions sharing a consumer Key form one competing group, and their RedeliveryBackoff / DeadLetterWhen delegates are now compared by identity instead of mere presence, so two members whose failure logic differs are rejected at subscribe time instead of settling the same message differently by receiver. (Review feedback #7.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both documents still described the superseded IQueue/IPubSub design. They now document what shipped: one IMessageBus whose verb decides delivery, topology-free handlers with delivery intent, canonical DestinationAddress with routing-as-topology under TopologyMode, core-owned retry/dead-letter over role-aware transport capabilities with the scheduled-dispatch fallback, the durable jobs runtime (typed payloads, per-run scopes, bounded concurrency, supervised leases), the Foundatio.Testing harness, and the legacy namespaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Push-triggered builds failed at Publish CI Packages when GitHub Packages returned 403 for Foundatio.Redis/Foundatio.Aws — those package names are linked to their original standalone repos, so this repo's GITHUB_TOKEN cannot push new versions of them. A CI-feed rejecting one package must not fail a build whose compile and tests passed: each failed push now surfaces as a warning annotation and the loop continues. Release publishing to NuGet on tags stays strict. Co-Authored-By: Claude Fable 5 <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.
Note
Status (2026-07-09): implementation has moved past the original proposal below. The design converged on one
IMessageBus— the caller's verb decides delivery (SendAsync= command to exactly one handler,PublishAsync= event fan-out) — and the separateIQueue/IPubSubsurfaces described in Part 2 were removed. What has landed on this branch:DestinationAddressidentity across the whole transport contract (send/receive/settle/stats/provisioning), with routing config doubling as topology declarations under explicitTopologyMode(Ensure / Validate / None).TransportCapabilities(queue vs. topic) with core-owned fallbacks — fixes silent delay drops (SQS delay vs. no SNS delay) by routing over-ceiling/unsupported delays through the durable scheduled-dispatch store.RetryPolicy,RejectAsync,DeadLetterOn<T>, forensics headers), handler delivery intent (Deliveries = Sent | Published | Both), declarative handlers + hosted startup topology.EnqueueAsync<TJob, TArgs>), per-run DI scopes, bounded worker concurrency, supervised lease renewal (cancels the run on lease loss or renewal outage), CRON occurrences with retry/dead-letter budgets,IScheduledDispatchStoresplit for messaging-only providers.Foundatio.Testinguser-facing test harness.Current API documentation:
docs/guide/messaging-jobs-redesign.md. The sections below are the original proposal, kept for the problem analysis and design rationale.Foundatio Messaging & Jobs — Breaking Redesign
A clean, breaking (major-version) rewrite of Foundatio's queue, pub/sub (
IMessageBus), work-item, and CRON-job abstractions into one coherent messaging + durable-jobs core. The current abstractions are single-type, poll-based, allocation-heavy, hard to implement transports for, and lack durable job state and durable scheduling. The new design moves all the hard parts into core, keeps transports tiny (bytes + headers), and unifies queues, pub/sub, and jobs on one substrate. This is still a major-version replacement for the primeFoundatio.Queues/Foundatio.Messaging/Foundatio.Jobsabstractions, but it should be delivered as a gated phased rollout, not a single big rewrite. The public replacement should be cut only after the transport substrate, conformance harness, and at least one real external provider mapping have proven the design.Approach — expose messaging patterns, not a generic bus
Foundatio deliberately does not try to be a generic, do-everything message bus or broker abstraction. It exposes the messaging patterns the vast majority of apps actually use — work queues (competing consumers; at-least-once; ack/retry/dead-letter) and pub/sub (fan-out) — plus durable jobs and scheduling built on the same substrate, as the first-class abstractions.
This is a deliberate departure from existing messaging libraries. Those tend to be extremely generic and highly configurable — powerful, but the team finds it genuinely hard and confusing to figure out how to implement the super-common patterns most apps need; the flexibility comes at the expense of approachability for the common case. Foundatio inverts that trade-off: the common patterns are the API and are obvious to use, transports stay swappable underneath, and advanced needs are met by clearly-scoped, opt-in capabilities rather than up-front configuration. Everything in this design serves that goal — which is also why the hard parts (serialization, routing, multi-type dispatch, priority, retry/dead-letter, back-pressure, durable scheduling) live in core and transports stay tiny.
Part 1 — Shortcomings of the current library
(Verified against
src/Foundatioandsrc/Foundatio.Extensions.Hosting.)1. One message type per queue/topic
IQueue<T>is generic over a single CLR type;IMessageBusroutes byTypewith oneTopicper bus instance — one queue/topic ⇒ one .NET type. No polymorphic handling: you can't put N message types on one destination and dispatch each to its own handler without N queues/topics or hand-rolled wrappers. No priority support within a destination.2. Heavyweight transports
Providers inherit fat base classes —
QueueBase<T,TOptions>has ~10 abstract overrides and bakes in serializer ownership, CLR-type↔string mapping, the worker loop, metrics, events, behaviors, poison handling, and resilience;MessageBusBase<TOptions>is similar. Transports serialize themselves. Consumption is poll-based (worker loops callDequeueImplAsync; in-memory polls on a 10s timeout) — no push. No back-pressure (unbounded enqueue). Capabilities are implicit — providers silently no-op or throw for unsupported features (e.g. delayed delivery falls back to an in-memory timer that's lost on restart) with no discovery.3. No batching
No batch enqueue/dequeue or batch publish/subscribe APIs — one round-trip per message caps throughput.
4. Pub/sub conflated as a generic "message bus"
IMessageBusis named like a generic bus but is pub/sub — Foundatio implements messaging patterns (work queues, pub/sub), not a generic bus. Subscribers effectively receive ALL messages and filter by assignable CLR type in-process (task-per-subscriber); topic is a per-instance option, so listening to a subset of topics needs multiple bus instances. No per-message ack/reject; fire-and-forget with no documented delivery guarantee; subscriber errors are swallowed (can't distinguish "no subscribers" from "handler threw").5. No generic, opt-in job-state tracking
No generic durable/queryable job state. Work-item progress is fire-and-forget
WorkItemStatuspublished overIMessageBus(not stored); CRON history is in-memory, capped ~10, per-node, lost on restart. No cancel-by-id (only host-shutdown token / lock release). Tracking isn't opt-in per job.WorkItemJobis a separate ad-hoc persisted-job system onIQueue<WorkItemData>, not unified with CRON/IJob.6. Non-durable distributed CRON
Distributed CRON runs the job body in-process under a distributed lock (two locks: a per-scheduled-minute lock held 1h + a per-job-running lock held 15m) and enqueues no durable occurrence — a node crash mid-run loses the run with no retry/dead-letter/monitor. Retries are unimplemented (
// TODO set next run time to retry, but need max retry countatScheduledJobInstance.cs:277&:296). Coarse per-minute cadence with misfire rules (skip if >1h late, fast-track if <10min out). No node identity for coordination/debugging; manual run has no idempotency/return/completion signal; distributed state sync overIMessageBusis lossy (no durability/replay/resync).7. Allocation-heavy / no binary path
Values are deep-cloned (
QueueEntry.Value = value.DeepClone()); serialization always allocates a freshbyte[]; noReadOnlyMemory<byte>path. Metrics gauges do sync-over-async (GetAwaiter().GetResult()), risking thread-pool starvation.8. Other cross-cutting issues
"FullName, Assembly"+ a version-stripping fallback inMessageBusBase) — brittle across services/versions.IEnumerable<T>— can't inspect poison/unknown payloads without the original type.IQueueEntry<T>with nullValue— breaks the type contract."TraceState"property).Part 2 — v2 Design
Two end-user APIs —
IQueue(durable competing-consumer work) andIPubSub(fan-out) — share one consume core, one wire model, one capability model, and the jobs layer. Transports are tiny (bytes + headers); core owns serialization, routing, multi-type dispatch, priority lanes, retry/backoff/dead-letter, back-pressure, the due-time substrate, job-state, scheduling, tracing, and metrics.Namespaces
Foundatio.Messaging— the singleIMessageTransport+ wire model (headers, transport messages, capabilities), serializer + content-type registry, type-routing registry,IPubSub.Foundatio.Queues—IQueue, the unifiedIReceivedMessage<T>, worker/consume options.Foundatio.Jobs—IJob,JobContext,IJobClient/IJobMonitorfor app code,IJobRuntimeStorefor provider/runtime coordination, scheduler, monitor, durable CRON.Foundatio.TestHarness— reusable transport conformance suite + core-behavior/jobs test bases (referenced by every provider repo); ships the in-memory reference transport that passes it.Layering
Wire model & headers (
Foundatio.Messaging)Transport contract (provider-facing) — one transport for both patterns
There is a single transport. Queue vs pub/sub is not a different interface — it's the
DestinationRoleof the destination, set at provisioning: send to a queue = competing-consumer; send to a topic = fan-out to its subscriptions; you consume a queue or a subscription identically.SendAsynccovers "publish" (a topic destination fans out). Both end-user APIs (IQueue,IPubSub) run over this one transport.Required base (produce + ack):
Consume: a transport implements
ISupportsPulland/orISupportsPush(at least one); core adapts both to the unified handler model and prefers push when present. The consume source is a queue or a subscription.Optional
ISupportsX(implement only if native; otherwise core emulates or flags):Capability discovery is
transport is ISupportsX(pattern-match) — no capabilities property; marker interfaces signal "honor this option/header natively" without adding a method. A transport advertises whichDestinationRoles it can provision viaITransportInfo.SupportedRoles, so "this backend can't do topics" is validated at startup rather than expressed as a missing interface. Per-pattern different brokers (e.g. SQS queues + Redis pub/sub) = named/keyed transport registration + a per-destination selector.Unified received message + ack (
Foundatio.Queues)Queues (competing-consumer, at-least-once) and pub/sub (fan-out; guarantee per transport) share this exact consume model;
Reject/DeadLetterare no-ops + flagged on at-most-once transports.End-user produce APIs
Routing, dispatch & workers (core)
OrderPlaced→order-placed) >[Queue]/[Topic]> fluentMap<T>(...)> custom fn.message.type= logical alias ([MessageType("...")]/registry; N inbound aliases → 1 type).message.type→ most-specific handler (base/interface fallback). Single registered type + missing discriminator ⇒ infer (raw-producer interop). Unknown/unresolvable or deserialize-failure ⇒ poison (default dead-letter, reason in header) + optionalHandleUnknown.HandleBatch<T>) with per-message ack (unacked follow batch outcome). Core owns retry/backoff/attempt-cap/dead-letter.Priority lanes & back-pressure (core)
(destination, priority)→ physical lane (orders,orders$high, …) — the lanes are part of the derived topology (provisioned per the provisioning mode); routes on send byEnqueueOptions.Priority. Native-priority transports (those implementingISupportsPriority) use one destination +TransportSendOptions.Priority(no lanes).Prefetch) from the lanes (or native), drains high→low to the handler withMaxConcurrency, with a configurable anti-starvation weight (every Nth pull services a lower lane). The buffer IS the back-pressure: full ⇒ stop pulling / pause push.Provisioning & topology (core)
Provisioning is a separate optional capability, not on the data-plane contract —
ISupportsProvisioningwith a batchEnsureAsync(IReadOnlyList<DestinationDeclaration>)(a one-item list for dynamic ensure-on-first-use; the full set on startup), plusDeleteAsync/ExistsAsync— creating queues, topics, subscriptions, and bindings perDestinationRole, related resources coherently in one pass. Auto-creating backends (Redis, in-memory) don't implement it (create-on-send); brokers needing explicit creation do.The transport maps what it can to native creation params (SQS redrive policy; Azure SB queue properties / lock duration / TTL; RabbitMQ declare args —
x-max-priority/message-ttl/DLX); core honors the rest itself.Core derives the topology (not hand-maintained): from
Map<T>, handler registrations, cron jobs, priority lanes (knowable from the fixedLow/Normal/Highenum), DLQ targets, and the job/due-time/state destinations → a set of declarations(name, role, DestinationOptions).A hosted provisioner applies a
ProvisioningMode:EnsureAsync([declaration])(one-item list), lazy + cached.EnsureAsync(allDeclarations)call, fail-fast (everything exists before traffic); the transport creates related resources/bindings (topic + subscriptions + DLQ + redrive) coherently.ExistsAsyncfor every declaration on startup, fail-fast if missing (locked-down / externally-provisioned environments where the app lacks create permissions).If the chosen mode needs provisioning but the transport lacks
ISupportsProvisioning(and doesn't auto-create), that's surfaced as a startup configuration error.Serialization (core)
ISerializer+ a content-type registry: a default serializer for send (stampsmessage.content_type); receive picks the serializer by content-type (mixed formats per destination + external interop). Body = pure bytes; transport owns header encoding (native attributes; pack-into-one under limits like SQS's 10; or fold into payload where it can't carry headers). Buffers owned/non-pooled in v1; the entry/receipt contract is shaped to enable pooling/zero-copy later. Trace context rides in headers — core injectstraceparent/tracestateon send and starts a consumerActivityon receive.Due-time substrate + expiry (core)
Delay/DeliverAt), redelivery-backoff (Reject → reappear later), and CRON occurrences. Native per-message delay used where the transport implementsISupportsDelayedDelivery; else core persists the pending dispatch in the same durable runtime store used for jobs (IJobRuntimeStore), claims due records with a short lease, and materializes them into the destination/lane when due (survives restarts). This intentionally avoids a separateIDueTimeStore; the requirement is one runtime store contract with due-time methods, not two parallel persistence abstractions. A delayed message enters its priority lane when due.message.expiration(enqueue + TTL); enforced on receive (native broker TTL where present); expired ⇒ dead-letterexpired(default) or drop.Job client + runtime store (
Foundatio.Jobs)Opt-in per registration (
WorkerOptions.TrackState; CRON occurrences are always tracked). When on, core auto-manages the lifecycle (Queued→Processing→Completed/Failed/Cancelled) through conditional transitions, claim ownership, and renewable leases; the handler only adds progress via the context.ReportProgressAsyncwhen untracked ⇒ logs only (never throws). Cancellation is requested throughIJobClientand observed cooperatively by the runtime (context token + progress checkpoint). Delayed-send fallback, retry backoff, and CRON occurrence materialization use the scheduled-dispatch methods onIJobRuntimeStoreso there is one durable coordination dependency to configure and test, without exposing those methods to normal app code.Durable CRON (
Foundatio.Jobs)Flow: scheduler tick (configurable, ~1s) → due occurrence id
{job}:{tick}:{scope}(Global=global, PerNode={nodeId}) →CreateIfAbsentAsync(atomic anchor + dedup; store implementations use native CAS/conditional writes or their own lock internally) →ScheduleDispatchAsync/claim due occurrence → materialize ontoIQueue(always tracked) → worker resolvesIJobfrom DI and runs it usingTryClaimAsync+ renewable ownership. A hosted monitor reconcilesIJobRuntimeStore.QueryAsyncvs worker liveness (heartbeat via lock-renewal/LastUpdated+ node id): created-but-never-picked-up or started-then-stale occurrences are re-enqueued up toMaxRetries, then aborted/dead-lettered (+ optional alert hook). Misfire = skip past the per-schedule window (catch-up within window). NodeId = config >FOUNDATIO_NODE_ID> machine name.DI / registration
Observability & stats (core)
Stats are split by authoritative vs derived — conflating them is what made this hard before:
destination/message.type/priority/transport. These are flow metrics; a metrics backend (or an in-proc reader) aggregates them across nodes.ISupportsStats— the broker is the source of truth, so out-of-band changes (purges, TTL expiry, other producers/consumers, redelivery) are reflected accurately. Broker depth is inherently cluster-wide (one query returns the whole destination's depth), approximate on some backends, and cached + rate-limited by core. No derived/aggregated depth counter is maintained — it would drift from reality.IJobMonitor.QueryAsync— backed by the shared runtime store, so it's authoritative and cluster-wide for tracked jobs/occurrences.Meterper area (Foundatio.Queues/Foundatio.Messaging/Foundatio.Jobs), the sharedActivitySource(producer + consumer spans, context propagated through headers), and structuredILogger. Wire up OTel → metrics + traces + logs with zero extra code.[LoggerMessage](compile-time, allocation-free, no boxing) with stable event ids and named structured fields — never string interpolation/concatenation in log calls; expensive/diagnostic messages are guarded withILogger.IsEnabled(level); correlation/message ids flow viaILoggerscopes. Logging is cheap enough to leave in the hot path.IJobMonitorquery — plus OTel. No first-party dashboard UI; build your own over the data or point a metrics backend at the OTel output.Capability model (summary)
Discovery: a transport's capabilities = the interfaces it implements (
is ISupportsX) — there is no capabilities property on the contract. Required base:Send,Complete,Abandon,Dispose, plus ≥1 consume capability —ISupportsPulland/orISupportsPush(core adapts; prefers push when present). OptionalISupportsX, core-emulated when absent: redelivery-delay (→due-time viaIJobRuntimeStore), dead-letter (→managed DLQ), priority (→lanes), delayed delivery (→durable due-time viaIJobRuntimeStore), expiration (→on-receive). Optional, not emulable (no-op/unavailable): lock-renewal, queue-depth stats (throughput metrics are always-on via core/OTel — only current depth needs the transport), visibility-timeout, provisioning (ISupportsProvisioning— batchEnsureAsync(declarations); auto-creating backends need none — governed byProvisioningMode). Descriptive facts (delivery guarantee, ordering, supported roles, batch/size limits) come from the optionalITransportInfointerface; absence ⇒ safe defaults. There is oneIMessageTransportfor both patterns — queue vs pub/sub is the destination'sDestinationRole, not a separate interface.Provider mapping (separate repos)
ChangeMessageVisibility; batch/attrs ≤10 (transport packs headers); delay ≤900s elseIJobRuntimeStoredue-time fallback; no priority → lanes.IJobRuntimeStore(SET NX / ZSET); pub/sub at-most-once, streams durable.x-max-priority→ native priority; no lock-renewal; delay via plugin/TTL else core.SupportedRolesexcludes queue-role semantics that need per-message ack.Testing — core tests + a reusable transport conformance harness
A first-class deliverable: shipping a new transport means referencing
Foundatio.TestHarness, supplying a factory, and getting a full pass/fail conformance suite — the same model the current library uses (a provider subclasses a base test and overrides aCreate…factory).Foundatio.TestHarness(NuGet) — a reusable, xUnit-based transport conformance suite. A transport author writes one small subclass and inherits the entire suite:ISupportsXthe transport implements (+ITransportInfo) and runs only the applicable tests — capabilities a transport doesn't claim are skipped, not failed. A minimal transport (send + consume + complete/abandon) passes a small suite; a full-featured one runs everything.ReceiptExpiredException; redelivery (native + core-emulated); dead-letter (native + core fallback); priority ordering + anti-starvation (lanes or native); expiration; delayed / due-time delivery; provisioning (Ensure/Exists/Delete+ roles/topology); back-pressure (bounded in-flight under a slow handler); fan-out vs competing-consumer (topic vs queue role); delivery-guarantee assertions; header + trace round-trip; cancellation + disposal.Performance & benchmarks
ReadOnlyMemory<byte>bodies, pooling-ready contract, bounded channels,TimeProvider, minimal hot-path allocation.[MemoryDiagnoser]+ a sustained throughput/latency rig), parameterized by the same factory as the conformance harness — enqueue/dequeue, priority-drain, dispatch, serialize, fan-out, back-pressure — so transports compare apples-to-apples and regressions are caught in CI.Migration / compat
Breaking major version. Separate
Foundatio.Compat.*packages re-expose the oldIQueue<T>/IMessageBus/WorkItemJob/ CRON APIs over the new core (namespace/registration swap; not drop-in).Phased build sequence
The work should be sequenced as vertical slices with explicit exit gates. Keep new public APIs internal/preview until the transport abstraction has passed both the in-memory reference implementation and one real provider. If the real provider exposes a mismatch, revise the abstraction before expanding into jobs, scheduler, or broad provider ports.
IMessageTransport+ in-memory reference transport + conformance harness + benchmark baseline.IQueueover the proven substrate: routing, dispatch, manual receive API, handler workers, Auto/Manual ack, retry/backoff/DLQ, back-pressure, priority lanes, due-time, expiry, tracing, and core behavior tests.IPubSubover the same substrate with topic/subscription roles, fan-out behavior, at-most-once/at-least-once semantics surfaced by capabilities, and pub/sub-specific conformance coverage.IJobClient/IJobMonitorfor app-facing job status, progress, manual run, and cancellation, backed byIJobRuntimeStorefor opt-in tracking, ownership/heartbeat, conditional state transitions, and fallback due-time scheduling; do not introduce a separateIDueTimeStore.IQueueandIJobRuntimeStore, including occurrence deduplication, scheduled-dispatch claim/materialize flow, misfire handling, stale-run recovery, retries, and dead-lettering.IQueue<T>,IMessageBus,WorkItemJob, and CRON compatibility over the new core.Non-goals
Sagas, routing DSL, exactly-once delivery (impossible in the general case), schema registry, workflow engine, broker-independent topology beyond simple destination/topic provisioning (
ISupportsProvisioning+DestinationOptions),IMemoryOwnerzero-copy (v1).Note on exactly-once: at-least-once + idempotent handlers gives effectively-once processing, and the design already ships the dedup primitives to support it — scheduler occurrence CAS (
CreateIfAbsentAsync), producerDeduplicationId, and consumer dedup-by-key via the lock/CAS store. True once-per-side-effect still requires the dedup record to commit in the same transaction as the side effect (transactional outbox/inbox); a distributed lock alone can't close the crash window between performing a side effect and acking the message.