Skip to content

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

Draft
ejsmith wants to merge 57 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 57 commits into
mainfrom
feat/messaging-jobs

Conversation

@ejsmith

@ejsmith ejsmith commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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 separate IQueue/IPubSub surfaces described in Part 2 were removed. What has landed on this branch:

  • Canonical DestinationAddress identity across the whole transport contract (send/receive/settle/stats/provisioning), with routing config doubling as topology declarations under explicit TopologyMode (Ensure / Validate / None).
  • Role-aware 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.
  • Core-owned retry/dead-letter (RetryPolicy, RejectAsync, DeadLetterOn<T>, forensics headers), handler delivery intent (Deliveries = Sent | Published | Both), declarative handlers + hosted startup topology.
  • Durable jobs runtime: typed payloads (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, IScheduledDispatchStore split for messaging-only providers.
  • Transports: in-memory, Redis Streams, AWS SQS/SNS; cross-provider conformance suites; Foundatio.Testing user-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 prime Foundatio.Queues / Foundatio.Messaging / Foundatio.Jobs abstractions, 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 usework 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/Foundatio and src/Foundatio.Extensions.Hosting.)

1. One message type per queue/topic

IQueue<T> is generic over a single CLR type; IMessageBus routes by Type with one Topic per 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 call DequeueImplAsync; 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"

IMessageBus is 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 WorkItemStatus published over IMessageBus (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. WorkItemJob is a separate ad-hoc persisted-job system on IQueue<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 count at ScheduledJobInstance.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 over IMessageBus is lossy (no durability/replay/resync).

7. Allocation-heavy / no binary path

Values are deep-cloned (QueueEntry.Value = value.DeepClone()); serialization always allocates a fresh byte[]; no ReadOnlyMemory<byte> path. Metrics gauges do sync-over-async (GetAwaiter().GetResult()), risking thread-pool starvation.

8. Other cross-cutting issues

  • Assembly-qualified type discriminator ("FullName, Assembly" + a version-stripping fallback in MessageBusBase) — brittle across services/versions.
  • Dead-letter access returns typed IEnumerable<T> — can't inspect poison/unknown payloads without the original type.
  • Poison messages produce a phantom IQueueEntry<T> with null Value — breaks the type contract.
  • Retry policy is global per queue, not per-message; no custom backoff per message.
  • No ordering guarantees modeled or documented (FIFO / partitions / sessions).
  • Trace/correlation propagation is manual and ad-hoc (CorrelationId doubles as traceparent; a magic "TraceState" property).
  • Sync + async disposal on the public interfaces (legacy baggage).
  • Metrics/events live in the base class every provider inherits, rather than centralized in core.
  • Continuous job loop has no exponential backoff / circuit-break on repeated failures (hot-spins at ~100ms).
  • Manual lock renewal required during processing; no auto-renew → orphan / split-brain risk.
  • DX/registration friction: triple-generic option builders; no message factory for testing handlers.

Part 2 — v2 Design

Two end-user APIs — IQueue (durable competing-consumer work) and IPubSub (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 single IMessageTransport + wire model (headers, transport messages, capabilities), serializer + content-type registry, type-routing registry, IPubSub.
  • Foundatio.QueuesIQueue, the unified IReceivedMessage<T>, worker/consume options.
  • Foundatio.JobsIJob, JobContext, IJobClient / IJobMonitor for app code, IJobRuntimeStore for 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

App      ──▶ IQueue / IPubSub / IJob
Core     ──▶ serialize (+content-type registry) · type→destination routing · multi-type/polymorphic dispatch ·
             priority lanes + bounded priority buffer (back-pressure) · Auto/Manual ack · retry/backoff/dead-letter ·
             due-time substrate (delay/backoff/CRON) · expiry · job-state · scheduler + monitor · tracing · metrics
Transport──▶ IMessageTransport  (one contract; bytes + headers; send/consume/complete/abandon + ISupportsX; queue vs topic = destination role)

Wire model & headers (Foundatio.Messaging)

public enum MessagePriority { Low = 0, Normal = 1, High = 2 }
public enum DeliveryGuarantee { AtMostOnce, AtLeastOnce }
public enum OrderingGuarantee { None, Fifo, PerPartition }

public sealed class MessageHeaders : IReadOnlyDictionary<string,string> {        // case-insensitive (Ordinal), immutable, FrozenDictionary
    public static MessageHeaders Empty { get; }
    public static MessageHeaders Create(IEnumerable<KeyValuePair<string,string>> headers);
    public string? GetValueOrDefault(string key);
    public Builder ToBuilder();
    public sealed class Builder { public Builder Add/Set/SetIfMissing(string k, string v); public bool Remove(string k); public MessageHeaders Build(); }
}
public static class KnownHeaders {   // reserved message.* namespace for system keys
    public const string MessageType="message.type", ContentType="message.content_type", CorrelationId="message.correlation_id",
        TraceParent="traceparent", TraceState="tracestate", Priority="message.priority",
        Expiration="message.expiration", Attempts="message.attempts", DeadLetterReason="message.dead_letter.reason";
}

// Transport-facing: PURE body bytes + SEPARATE headers. The transport owns how headers are physically carried.
public sealed record TransportMessage { public required ReadOnlyMemory<byte> Body { get; init; } public MessageHeaders Headers { get; init; } = MessageHeaders.Empty; public string? MessageId { get; init; } }
public sealed record TransportSendOptions { public MessagePriority Priority { get; init; } = MessagePriority.Normal; public DateTimeOffset? DeliverAt { get; init; } public string? DeduplicationId { get; init; } public string? PartitionKey { get; init; } } // Priority honored iff transport is ISupportsPriority; DeliverAt iff ISupportsDelayedDelivery
public sealed record TransportEntry { public required string Id { get; init; } public required string Destination { get; init; } public required ReadOnlyMemory<byte> Body { get; init; } public MessageHeaders Headers { get; init; } = MessageHeaders.Empty; public int DeliveryCount { get; init; } = 1; public DateTimeOffset? EnqueuedUtc { get; init; } public required Receipt Receipt { get; init; } }
public readonly struct Receipt { public object? TransportState { get; init; } }   // opaque, instance/connection-scoped, non-serializable
public sealed record ReceiveRequest { public int MaxMessages { get; init; } = 1; public TimeSpan? MaxWaitTime { get; init; } }
public sealed record SendItemResult { public string? MessageId { get; init; } public required bool Success { get; init; } public string? ErrorCode { get; init; } public bool IsRetryable { get; init; } }
public sealed record SendResult { public required IReadOnlyList<SendItemResult> Items { get; init; } public bool AllSucceeded { get; } }
public sealed class ReceiptExpiredException : Exception { }

// Capabilities are discovered by which interfaces a transport implements — pattern-match `is ISupportsX` — NOT a property on the base contract.
// The few descriptive facts that aren't method-presence are reported via an OPTIONAL interface (absence ⇒ safe defaults: at-most-once, unordered, no advertised limits):
public interface ITransportInfo {
    DeliveryGuarantee DeliveryGuarantee { get; }
    OrderingGuarantee Ordering { get; }
    IReadOnlySet<DestinationRole> SupportedRoles { get; }   // which roles this backend can provision (Queue/Topic/Subscription/…) — validated against the topology at startup
    int? MaxBatchSize { get; }
    long? MaxMessageBytes { get; }
}

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 DestinationRole of 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. SendAsync covers "publish" (a topic destination fans out). Both end-user APIs (IQueue, IPubSub) run over this one transport.

Required base (produce + ack):

public interface IMessageTransport : IAsyncDisposable {
    Task<SendResult> SendAsync(string destination, IReadOnlyList<TransportMessage> messages, TransportSendOptions options, CancellationToken ct = default); // destination = queue OR topic
    Task CompleteAsync(TransportEntry entry, CancellationToken ct = default);                                          // ack; throws ReceiptExpiredException if invalid; no-op on at-most-once
    Task AbandonAsync(TransportEntry entry, CancellationToken ct = default);                                           // immediate requeue
}

Consume: a transport implements ISupportsPull and/or ISupportsPush (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):

ISupportsPull               : IMessageTransport { Task<IReadOnlyList<TransportEntry>> ReceiveAsync(string source, ReceiveRequest request, CancellationToken ct); }                       // batch pull; empty list on timeout (never null/throw)
ISupportsPush               : IMessageTransport { Task<IPushSubscription> SubscribeAsync(string source, Func<TransportEntry,CancellationToken,Task> onMessage, PushOptions options, CancellationToken ct); }   // push delivery — one of pull/push required
ISupportsRedeliveryDelay    : IMessageTransport { Task AbandonAsync(TransportEntry e, TimeSpan redeliveryDelay, CancellationToken ct); }   // else core due-time hold
ISupportsDeadLetter         : IMessageTransport { Task DeadLetterAsync(TransportEntry e, string? reason, CancellationToken ct); }          // else core managed {dest}-deadletter
ISupportsLockRenewal        : IMessageTransport { Task RenewLockAsync(TransportEntry e, TimeSpan? duration, CancellationToken ct); }       // else no-op + log (un-emulable)
ISupportsVisibilityTimeout  : IMessageTransport { Task<IReadOnlyList<TransportEntry>> ReceiveAsync(string source, ReceiveRequest request, TimeSpan visibility, CancellationToken ct); } // un-emulable
ISupportsStats              : IMessageTransport { Task<QueueStats> GetStatsAsync(string destination, CancellationToken ct); }              // un-emulable; absent ⇒ depth unknown
ISupportsPriority           : IMessageTransport { }   // marker: honors TransportSendOptions.Priority + delivers higher-priority first (else core priority lanes)
ISupportsDelayedDelivery    : IMessageTransport { }   // marker: honors TransportSendOptions.DeliverAt (else core due-time substrate)
ISupportsExpiration         : IMessageTransport { }   // marker: honors the message.expiration header / TTL natively (else core enforces on receive)
ISupportsProvisioning       : IMessageTransport { Task EnsureAsync(IReadOnlyList<DestinationDeclaration> declarations, CancellationToken ct); Task DeleteAsync(string name, CancellationToken ct); Task<bool> ExistsAsync(string name, CancellationToken ct); }   // batch ensure (one-item list for dynamic); provisions queues/topics/subscriptions/bindings + related resources per DestinationRole coherently
public interface IPushSubscription : IAsyncDisposable { string Source { get; } }   // named handle so a push subscription can grow (Pause/Resume, Name, IsActive, stats…) without breaking callers

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 which DestinationRoles it can provision via ITransportInfo.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)

public enum AckMode { Auto, Manual }
public interface IReceivedMessage<out T> where T : class {
    T Message { get; }
    string Id { get; } MessageHeaders Headers { get; } string? CorrelationId { get; } string? MessageType { get; }
    MessagePriority Priority { get; } int Attempts { get; } bool IsHandled { get; }
    CancellationToken CancellationToken { get; }                          // host shutdown ∪ lock loss ∪ persisted cancel (when tracked)
    Task CompleteAsync(CancellationToken ct = default);
    Task RejectAsync(bool retry = true, string? reason = null, CancellationToken ct = default);  // retry→backoff→DLQ; !retry→DLQ
    Task DeadLetterAsync(string? reason = null, CancellationToken ct = default);
    Task RenewLockAsync(TimeSpan? duration = null, CancellationToken ct = default);              // no-op+log where unsupported
    Task ReportProgressAsync(int? percent = null, string? message = null, CancellationToken ct = default); // logs-only when not tracking
}

Queues (competing-consumer, at-least-once) and pub/sub (fan-out; guarantee per transport) share this exact consume model; Reject/DeadLetter are no-ops + flagged on at-most-once transports.

End-user produce APIs

public sealed record EnqueueOptions { public MessagePriority Priority { get; init; } = MessagePriority.Normal; public TimeSpan? Delay { get; init; } public DateTimeOffset? DeliverAt { get; init; } public TimeSpan? TimeToLive { get; init; } public string? CorrelationId { get; init; } public string? DeduplicationId { get; init; } public string? Destination { get; init; } public MessageHeaders? Headers { get; init; } }
public interface IQueue : IAsyncDisposable {
    Task<string> EnqueueAsync<T>(T message, EnqueueOptions? o = null, CancellationToken ct = default) where T : class;
    Task EnqueueBatchAsync<T>(IEnumerable<T> messages, EnqueueOptions? o = null, CancellationToken ct = default) where T : class;
}
public interface IPubSub : IAsyncDisposable {
    Task PublishAsync<T>(T message, PublishOptions? o = null, CancellationToken ct = default) where T : class;
    Task PublishBatchAsync<T>(IEnumerable<T> messages, PublishOptions? o = null, CancellationToken ct = default) where T : class;
}

Routing, dispatch & workers (core)

  • Type → destination: convention (OrderPlacedorder-placed) > [Queue]/[Topic] > fluent Map<T>(...) > custom fn. message.type = logical alias ([MessageType("...")]/registry; N inbound aliases → 1 type).
  • Per-type handler registration; core derives the consumer topology — handlers routing to the same destination share ONE worker that dispatches by message.typemost-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) + optional HandleUnknown.
  • Worker = handler-based (never poll). Auto ack default (return=Complete, throw=Reject(retry)); Manual opt-in. Single-message handler is the default (core auto-batches the fetch); optional batch handler (HandleBatch<T>) with per-message ack (unacked follow batch outcome). Core owns retry/backoff/attempt-cap/dead-letter.
public sealed record WorkerOptions { public AckMode AckMode { get; init; } = AckMode.Auto; public int Prefetch { get; init; } = 1; public int MaxConcurrency { get; init; } = 1; public int BatchSize { get; init; } = 1; public int MaxAttempts { get; init; } = 5; public Func<int,TimeSpan>? RedeliveryBackoff { get; init; } public bool TrackState { get; init; } = false; public PoisonDisposition OnUnknownType { get; init; } = PoisonDisposition.DeadLetter; public PoisonDisposition OnDeserializeFailure { get; init; } = PoisonDisposition.DeadLetter; }
public enum PoisonDisposition { DeadLetter, AbandonWithCap, RouteToFallback, CompleteAndDrop }

Priority lanes & back-pressure (core)

  • Core maps (destination, priority) → physical lane (orders, orders$high, …) — the lanes are part of the derived topology (provisioned per the provisioning mode); routes on send by EnqueueOptions.Priority. Native-priority transports (those implementing ISupportsPriority) use one destination + TransportSendOptions.Priority (no lanes).
  • Consume fills a bounded priority buffer (size = Prefetch) from the lanes (or native), drains high→low to the handler with MaxConcurrency, 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 — ISupportsProvisioning with a batch EnsureAsync(IReadOnlyList<DestinationDeclaration>) (a one-item list for dynamic ensure-on-first-use; the full set on startup), plus DeleteAsync/ExistsAsync — creating queues, topics, subscriptions, and bindings per DestinationRole, related resources coherently in one pass. Auto-creating backends (Redis, in-memory) don't implement it (create-on-send); brokers needing explicit creation do.

public sealed record DestinationOptions {
    public bool PriorityEnabled { get; init; }            // provision priority lanes / native max-priority
    public TimeSpan? DefaultMessageTtl { get; init; }
    public DeadLetterPolicy? DeadLetter { get; init; }    // DLQ target + native redrive/max-receive backstop (the attempt-cap policy stays core-owned)
    public TimeSpan? LockDuration { get; init; }          // default visibility/lock window
    public int? Partitions { get; init; }
    public IReadOnlyDictionary<string,string>? NativeOptions { get; init; }  // escape hatch for provider-specific settings
}
public sealed record DeadLetterPolicy { public string? Destination { get; init; } public int? MaxDeliveries { get; init; } }

public enum DestinationRole { Queue, Topic, Subscription, PriorityLane, DeadLetter }
public sealed record DestinationDeclaration { public required string Name { get; init; } public DestinationRole Role { get; init; } public DestinationOptions Options { get; init; } = new(); public string? TopicBinding { get; init; } } // a subscription/lane/DLQ names what it binds to

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 fixed Low/Normal/High enum), DLQ targets, and the job/due-time/state destinations → a set of declarations (name, role, DestinationOptions).

A hosted provisioner applies a ProvisioningMode:

  • Dynamic (default) — ensure-on-first-use: EnsureAsync([declaration]) (one-item list), lazy + cached.
  • OnStartup — provision the entire derived topology in one EnsureAsync(allDeclarations) call, fail-fast (everything exists before traffic); the transport creates related resources/bindings (topic + subscriptions + DLQ + redrive) coherently.
  • Validate — don't create; check ExistsAsync for every declaration on startup, fail-fast if missing (locked-down / externally-provisioned environments where the app lacks create permissions).
  • None — do nothing; assume pre-provisioned.

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 (stamps message.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 injects traceparent/tracestate on send and starts a consumer Activity on receive.

Due-time substrate + expiry (core)

  • One durable "make visible at T" mechanism backs delayed/scheduled send (Delay/DeliverAt), redelivery-backoff (Reject → reappear later), and CRON occurrences. Native per-message delay used where the transport implements ISupportsDelayedDelivery; 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 separate IDueTimeStore; 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.
  • TTL → absolute message.expiration (enqueue + TTL); enforced on receive (native broker TTL where present); expired ⇒ dead-letter expired (default) or drop.

Job client + runtime store (Foundatio.Jobs)

public enum JobStatus { Queued, Scheduled, Processing, Completed, Failed, Cancelled, DeadLettered }
public enum ScheduledDispatchKind { QueueMessage, PubSubMessage, JobOccurrence }
public sealed record JobState { /* JobId, Name, Status, Progress, ProgressMessage, Attempt, NodeId, timestamps, Error, CancellationRequested, ScheduledForUtc */ }
public sealed record ScheduledDispatchState { /* DispatchId, Kind, Destination, Body, Headers, Options, DueUtc, ClaimOwner, ClaimExpiresUtc, Attempts, JobId? */ }
public sealed record JobStatePatch { /* Status?, Progress?, ProgressMessage?, Error?, AttemptDelta?, NodeId?, LeaseExpiresUtc?, LastUpdatedUtc? */ }

// App-facing read model for dashboards and diagnostics.
public interface IJobMonitor {
    Task<JobState?> GetAsync(string jobId, CancellationToken ct = default);
    Task<IReadOnlyList<JobState>> QueryAsync(JobQuery query, CancellationToken ct = default);
}

// App-facing command surface. Normal users inject this, not the runtime store.
public interface IJobClient : IJobMonitor {
    Task<string> RunAsync<TJob>(RunJobOptions? options = null, CancellationToken ct = default) where TJob : IJob;
    Task<bool> RequestCancellationAsync(string jobId, CancellationToken ct = default);
}

// Runtime/provider-facing SPI. The same concrete implementation can back IJobClient/IJobMonitor,
// but app code should not need these methods.
public interface IJobRuntimeStore : IJobMonitor {                // ALL updates are field-level/partial — never whole-record replace
    Task CreateIfAbsentAsync(JobState initial, CancellationToken ct = default);                 // occurrence anchor + dedup (atomic)
    Task<bool> TryTransitionAsync(string jobId, JobStatus expectedStatus, JobStatus newStatus, JobStatePatch? patch = null, CancellationToken ct = default);
    Task<bool> TryClaimAsync(string jobId, string nodeId, TimeSpan lease, CancellationToken ct = default);
    Task<bool> RenewClaimAsync(string jobId, string nodeId, TimeSpan lease, CancellationToken ct = default);
    Task<bool> ReleaseClaimAsync(string jobId, string nodeId, CancellationToken ct = default);
    Task SetProgressAsync(string jobId, int? percent = null, string? message = null, CancellationToken ct = default);
    Task IncrementAttemptAsync(string jobId, CancellationToken ct = default);
    Task<bool> IsCancellationRequestedAsync(string jobId, CancellationToken ct = default);
    Task ScheduleDispatchAsync(ScheduledDispatchState dispatch, CancellationToken ct = default); // create/dedup pending due-time dispatch
    Task<IReadOnlyList<ScheduledDispatchState>> ClaimDueDispatchesAsync(DateTimeOffset now, int limit, string nodeId, TimeSpan lease, CancellationToken ct = default);
    Task CompleteDispatchAsync(string dispatchId, string nodeId, CancellationToken ct = default);
    Task ReleaseDispatchAsync(string dispatchId, string nodeId, DateTimeOffset nextDueUtc, CancellationToken ct = default);
}

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. ReportProgressAsync when untracked ⇒ logs only (never throws). Cancellation is requested through IJobClient and observed cooperatively by the runtime (context token + progress checkpoint). Delayed-send fallback, retry backoff, and CRON occurrence materialization use the scheduled-dispatch methods on IJobRuntimeStore so there is one durable coordination dependency to configure and test, without exposing those methods to normal app code.

Durable CRON (Foundatio.Jobs)

public interface IJob { Task<JobResult> RunAsync(JobContext ctx); }
public sealed class JobContext { /* JobId, Name, TriggerKind, Attempt, ScheduledForUtc, Services, CancellationToken */ public T? GetMessage<T>() where T:class; public Task ReportProgressAsync(int? percent=null, string? message=null, CancellationToken ct=default); public Task RenewLockAsync(TimeSpan? d=null, CancellationToken ct=default); }
public sealed record ScheduledJobDefinition { public required string Name { get; init; } public required string Cron { get; init; } public TimeZoneInfo? TimeZone { get; init; } public ScheduledJobScope Scope { get; init; } = ScheduledJobScope.Global; public OverlapPolicy Overlap { get; init; } = OverlapPolicy.SkipIfRunning; public TimeSpan? MisfireWindow { get; init; } public int MaxRetries { get; init; } = 3; public bool Enabled { get; init; } = true; }
public enum ScheduledJobScope { Global, PerNode }
public interface IJobScheduler { Task ScheduleAsync(ScheduledJobDefinition def, CancellationToken ct = default); Task UnscheduleAsync(string name, CancellationToken ct = default); Task<IReadOnlyList<ScheduledJobDefinition>> GetSchedulesAsync(CancellationToken ct = default); }

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 onto IQueue (always tracked) → worker resolves IJob from DI and runs it using TryClaimAsync + renewable ownership. A hosted monitor reconciles IJobRuntimeStore.QueryAsync vs worker liveness (heartbeat via lock-renewal/LastUpdated + node id): created-but-never-picked-up or started-then-stale occurrences are re-enqueued up to MaxRetries, 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

services.AddFoundatio(b => b.UseQueueTransport<InMemoryQueueTransport>().UsePubSubTransport<InMemoryPubSubTransport>().UseSerializer<SystemTextJsonSerializer>().Map<OrderShipped>(queue: "orders"));
services.AddQueueHandler<OrderPlaced>(async (msg, ct) => {}, o => o with { TrackState = true });   // delegate or class handler
services.AddPubSubHandler<CacheInvalidated>(async (msg, ct) => {});
services.AddJob<ReportJob>();                                   // one-off / manual via IJobClient
services.AddCronJob<NightlyRollupJob>("0 0 * * *", o => o with { Scope = ScheduledJobScope.Global });

Observability & stats (core)

Stats are split by authoritative vs derived — conflating them is what made this hard before:

  • Throughput / rates (sent, received, completed, abandoned, dead-lettered, errors, process-duration, queue-latency) are emitted as OpenTelemetry metrics from the core pipeline — counters/histograms tagged destination / message.type / priority / transport. These are flow metrics; a metrics backend (or an in-proc reader) aggregates them across nodes.
  • Current depth (queued / in-flight / dead-lettered) is queried from the transport via optional 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.
  • Tracked-work status (in-progress + progress %, completed/failed/cancelled, history) comes from IJobMonitor.QueryAsync — backed by the shared runtime store, so it's authoritative and cluster-wide for tracked jobs/occurrences.
  • Observable gauges never block: they read only in-memory counters or the cached transport-depth snapshot — never a synchronous transport call (fixes the old sync-over-async gauge that risked thread-pool starvation).
  • Full OpenTelemetry, built in: a Meter per area (Foundatio.Queues / Foundatio.Messaging / Foundatio.Jobs), the shared ActivitySource (producer + consumer spans, context propagated through headers), and structured ILogger. Wire up OTel → metrics + traces + logs with zero extra code.
  • High-performance logging patterns: all logging uses source-generated [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 with ILogger.IsEnabled(level); correlation/message ids flow via ILogger scopes. Logging is cheap enough to leave in the hot path.
  • Dashboard: core exposes the data — a per-destination stats snapshot API (core throughput counters + on-demand transport depth) and the IJobMonitor query — 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 — ISupportsPull and/or ISupportsPush (core adapts; prefers push when present). Optional ISupportsX, core-emulated when absent: redelivery-delay (→due-time via IJobRuntimeStore), dead-letter (→managed DLQ), priority (→lanes), delayed delivery (→durable due-time via IJobRuntimeStore), 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 — batch EnsureAsync(declarations); auto-creating backends need none — governed by ProvisioningMode). Descriptive facts (delivery guarantee, ordering, supported roles, batch/size limits) come from the optional ITransportInfo interface; absence ⇒ safe defaults. There is one IMessageTransport for both patterns — queue vs pub/sub is the destination's DestinationRole, not a separate interface.

Provider mapping (separate repos)

  • In-memory — reference, full surface, exact stats.
  • Azure Service Bus — native complete/abandon/DLQ/lock-renewal/scheduled-messages; no per-receive visibility; no native priority → core lanes.
  • AWS SQS/SNS — visibility = lock; redelivery-delay via ChangeMessageVisibility; batch/attrs ≤10 (transport packs headers); delay ≤900s else IJobRuntimeStore due-time fallback; no priority → lanes.
  • Redis — lists + sorted-sets (ZSET → native priority by score); first-class IJobRuntimeStore (SET NX / ZSET); pub/sub at-most-once, streams durable.
  • RabbitMQ — ack/nack/DLX; x-max-priority → native priority; no lock-renewal; delay via plugin/TTL else core.
  • Kafka — topic + consumer-group (subscription) roles; at-least-once via offset commit; no per-message complete/abandon, delay, priority, or DLQ (offset-based → core-emulated or unavailable); SupportedRoles excludes 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 a Create… factory).

  • Foundatio.TestHarness (NuGet) — a reusable, xUnit-based transport conformance suite. A transport author writes one small subclass and inherits the entire suite:
public class RedisMessageTransportTests : MessageTransportConformanceTests {
    protected override IMessageTransport CreateTransport() => new RedisMessageTransport(/* test connection */);
}
  • Capability-aware: the harness inspects which ISupportsX the 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.
  • Conformance coverage: produce + batch send; consume via pull and/or push; complete/abandon; receipt validity + 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.
  • In-memory reference transport ships in core and passes the full conformance suite — it proves the harness, is the default for app dev/testing, and is the baseline the core-behavior + benchmark suites run against.
  • Core-behavior suite (transport-agnostic, on in-memory): type→destination routing; multi-type + polymorphic dispatch (most-specific + base/interface fallback); discriminator inference; poison handling; Auto/Manual ack; retry → backoff → dead-letter; batch handler + partial-failure; serializer/content-type registry; priority; trace propagation; observability counters.
  • Jobs suite: opt-in tracking + partial state updates; occurrence dedup (create-if-absent) under concurrent schedulers; monitor retry/abort of lost/stuck occurrences; misfire policy; cooperative cancellation; PerNode node-id stability.
  • Compat suite: the old-API shims behave correctly over the new core.
  • The same transport factory powers the benchmark suite below, so a transport is conformance-tested and benchmarked with one identical plug-in.

Performance & benchmarks

  • Binary ReadOnlyMemory<byte> bodies, pooling-ready contract, bounded channels, TimeProvider, minimal hot-path allocation.
  • Transport-swappable benchmark suite (BenchmarkDotNet [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 old IQueue<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.

  1. Compatibility and break map — document old API behaviors that must survive in shims, source/binary breaking changes, provider capability gaps, migration constraints, and success criteria for the major release.
  2. Transport foundation — wire model + headers + serializer/content-type registry + capability discovery + minimal IMessageTransport + in-memory reference transport + conformance harness + benchmark baseline.
  3. Real-provider vertical slice — port one production transport early, preferably SQS or Azure Service Bus, and validate send/receive, settlement, batching, headers, tracing, delay limits, DLQ, provisioning, and capability reporting against the same conformance harness.
  4. Queue core — build IQueue over 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.
  5. Pub/sub core — build IPubSub over 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.
  6. Job client + runtime coordination — add IJobClient / IJobMonitor for app-facing job status, progress, manual run, and cancellation, backed by IJobRuntimeStore for opt-in tracking, ownership/heartbeat, conditional state transitions, and fallback due-time scheduling; do not introduce a separate IDueTimeStore.
  7. Durable CRON — add scheduler + monitor on top of IQueue and IJobRuntimeStore, including occurrence deduplication, scheduled-dispatch claim/materialize flow, misfire handling, stale-run recovery, retries, and dead-lettering.
  8. Provider ports — port Redis, Azure, AWS, RabbitMQ, Kafka, and other providers one at a time; each provider must pass its advertised conformance matrix and benchmark smoke suite before release.
  9. Compat and migration — ship old-API shims, migration guide, upgrade samples, and tests proving 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), IMemoryOwner zero-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), producer DeduplicationId, 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.

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 on lines +506 to +510
foreach (var state in queued)
{
if (await RunJobStateAsync(state, cancellationToken).ConfigureAwait(false))
completed++;
}
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 on lines +55 to +59
foreach (var declaration in declarations)
{
if (!await provisioning.ExistsAsync(declaration.Name, cancellationToken).AnyContext())
missing.Add(declaration);
}
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 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 });
}
}
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 on lines +364 to +368
foreach (var timer in _redeliveryTimers.Keys)
{
if (_redeliveryTimers.TryRemove(timer, out _))
timer.Dispose();
}
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 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++;
}
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>
ejsmith and others added 8 commits July 1, 2026 11:27
…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();
}
ejsmith and others added 19 commits July 1, 2026 16:42
…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>
@ejsmith ejsmith changed the title Messaging and jobs redesign in-memory slice Messaging and jobs redesign: one IMessageBus, durable jobs runtime, Redis Streams + AWS SQS/SNS transports Jul 10, 2026
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>
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