Skip to content

Distributed mediator: durable queues, notifications, and worker pipelines - #149

Open
ejsmith wants to merge 50 commits into
mainfrom
queues
Open

ejsmith wants to merge 50 commits into
mainfrom
queues

Conversation

@ejsmith

@ejsmith ejsmith commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Features and developer experience

Adds background jobs and cross-node pub/sub using the handlers and middleware you already write.

  • Queue a handler: add [Queue] and get an acceptance receipt from EnqueueAsync. Each subscription has its own queue and retry budget by default, with automatic retries, dead letters, and lease renewal.
  • Broadcast an event: mark it with [DistributedNotification] or select it in configuration, then use the same PublishAsync call. Receiving nodes run their local handlers.
  • Start small, scale out: in-memory transports work out of the box. Add .UseAws() for SQS/SNS and Redis for shared job state. Run all workers together or deploy selected worker groups from the same build.
  • Keep your pipeline: handlers retain DI, middleware, result types, and cascading events. Enqueue middleware handles validation and tenant/user context; [QueueLock] coordinates work on shared resources.
  • See and control work: opt into TrackProgress for job status, progress, worker identity, heartbeat, and cancellation. Inspect dead letters, retry or purge individual messages or batches, and retain failed-job history.
  • Learn from working samples: a small console app and a Svelte 5 operations dashboard demonstrate setup, queue details, charts, display names, job inspection, and dead-letter management. A separate Try it page demonstrates the workflows; testing helpers support deterministic queue draining.

Performance

September 6 measurements: median completed messages/second from three paired runs against MassTransit 8.5.10 on the same LocalStack SQS/SNS transport:

Workload Foundatio MassTransit Difference
Queue, 128-byte payload 1,642 1,279 +28%
Queue, 4 KiB payload 1,491 1,134 +31%
Pub/sub, 128-byte payload 1,345 1,139 +18%

Queues use 8 producers/workers with job tracking off; pub/sub uses 40 producers and one subscriber. These are shared-host LocalStack measurements, not AWS capacity claims. MassTransit won some higher-concurrency queue runs. Foundatio notifications return after local buffering; MassTransit waits for broker acceptance. Full results, limitations, and reproduction steps.

Additional notes

  • Queues provide at-least-once delivery; handlers must tolerate duplicates. Notifications are best effort. No transactional outbox is included.
  • Validation: 1,007 .NET tests and 15 focused browser workflows passed; frontend checks/build and all PR gates passed.

Comment thread src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread samples/ConsoleSample/ServiceConfiguration.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/QueueWorker.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs Fixed
@ejsmith
ejsmith marked this pull request as ready for review April 7, 2026 19:32
Comment thread src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs Fixed
Comment thread src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs Outdated
Comment thread samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs Outdated
Comment thread src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs Outdated
Comment thread src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs Outdated
ejsmith and others added 14 commits September 5, 2026 21:41
New packages:
- Foundatio.Mediator.Distributed: IQueueClient / IPubSubClient /
  IQueueJobStateStore abstractions, [Queue] attribute + QueueMiddleware
  routing, QueueWorker hosted service, DistributedNotificationWorker,
  QueueContext (ack/abandon/renew/progress), QueueRetryDelay, in-memory
  implementations, worker registry and queue dashboard handlers.
- Foundatio.Mediator.Distributed.Aws: SqsQueueClient and SNS/SQS pub/sub.
- Foundatio.Mediator.Distributed.Redis: RedisQueueJobStateStore.

Sample: CleanArchitectureSample gains Aspire AppHost + ServiceDefaults,
Redis job state, queue dashboard UI, SSE live events, worker/api role
flags. Docs: four distributed guide pages.

Squashed from the 29 commits on queues (previous head fa998b4)
and rebased onto main daaab7a. Conflict resolutions: took main's
dependency versions everywhere; kept main's RequestDurationFilter and
"Orders"/"Products" endpoint groups; folded main's rate limiter into the
API-only block of Program.cs; replaced the VitePress nav entries with
Lume front matter on the new guide pages; merged the landing-page tile
changes into main's reformatted index.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Foundatio.Xunit.v3 13.0.4 (was a branch preview build), xunit.v3.mtp-v2
4.0.0, xunit.runner.visualstudio 4.0.0, Microsoft.NET.Test.Sdk 18.9.0,
Microsoft.Extensions.DependencyInjection 10.0.11, GitHubActionsTestLogger
3.0.5, matching tests/Foundatio.Mediator.Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Handlers that share a queue now share one message: the designated handler's
middleware enqueues once and the worker dispatches to every registration whose
message type accepts the concrete type carried in fm-message-type, so handlers
declared on an interface receive concrete events. Conflicting [Queue] settings
on a shared queue fail at registration.

Also in this change:
- QueueMiddleware is a singleton, returns without running when the message is
  the inbound notification being re-published, waits for infrastructure once
  the host has started, writes a correlation id, runs IQueueHeaderProvider
  hooks, and starts a producer span.
- QueueWorker links consumer spans to the producer, resolves types through the
  allowlist, dead-letters undeserializable messages immediately, survives
  exceptions in the consumer loop, keeps renewing after a failed renewal,
  heartbeats the job state store, drains for ShutdownTimeout on stop, and never
  acknowledges finished work with the stopping token. RateLimited is retryable.
- IQueueClient.ReceiveAsync takes the visibility timeout; SQS applies it.
  QueueDefinition carries visibility, retention, and max attempts. Dead-letter
  receive and replay have default implementations.
- InMemoryQueueClient models leases: received messages are invisible until
  completed, abandoned, or expired, and delayed abandons are scheduled.
- QueueRetryPolicy.Schedule with RetryDelays = "5s,1m,15m,30m".
- DistributedNotificationOptions.IncludeAssignableTo/Exclude; the worker logs
  the resolved types and retries a failed subscribe.
- QueueJobState.Metadata via DistributedQueueOptions.JobMetadataProvider,
  IQueueJobStateStore.HeartbeatAsync.
- Meter Foundatio.Mediator.Distributed with counters, duration, in-flight and
  polled depth gauges.
- AddDistributedQueues fails fast when workers are disabled or filtered and no
  transport is registered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DistributedQueueOptions.Workers replaces WorkersEnabled, Group and Queues.
WorkerSelection.Parse("all" | "none" | "exports,imports,!ProcessGridExport")
makes the same build an API node, a full worker, or a chosen set of groups or
queues from a config value, and the sample reads it from --workers or
Distributed:Workers.

[QueueLock] serializes a queued handler on an IQueueLockProvider keyed by a
fixed key, the message's IHaveLockKey, or queue plus message id; a held lock
completes the message without running it. InMemoryQueueLockProvider is
registered automatically only with the in-memory queue client.

Tests cover shared-queue fan-out with one message, interface-typed dispatch,
one execution per inbound notification across two nodes, poison and unknown
type dead-lettering, header providers, job metadata, the enqueue-only guard,
worker selection, retry schedules, renewal after a failed renew, and drain on
stop. The in-memory client test for delayed abandon now asserts lease
semantics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every job write is now one conditional MULTI/EXEC keyed on the current Status field (retried with jitter on conflict, bounded), so a job is a member of exactly one status set even under concurrent updates. Status indexes are trimmed by creation time on write and dangling members are removed when a listing finds their hash gone; index TTLs are only ever extended so a short-lived write cannot expire an index that still holds live members. Non-terminal writes are floored at the new NonTerminalExpiry option (7 days) so queued and processing jobs do not vanish mid-flight, and cancellation keys share the job's TTL. Metadata is persisted as meta:* hash fields, HeartbeatAsync updates LastUpdatedUtc/LastHeartbeatUtc without touching Status, Attempt is now persisted by SetJobStateAsync, and the Redis 7-only EXPIRE NX on counter buckets is replaced with an unconditional EXPIRE in the same transaction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nters

Adds a 50-way concurrent status update test, expired-member listing and count tests, write-side trim coverage, real TTL assertions through the multiplexer (replacing the placeholder GetHashCode key lookup), per-field UpdateJobStatusAsync/UpdateJobProgressAsync coverage, metadata round-trips, heartbeat behavior, cancellation TTL parity, and counter bucketing across an hour boundary via a FakeTimeProvider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EnsureQueuesAsync now creates (or validates) each queue with the
VisibilityTimeout and MessageRetentionPeriod from its QueueDefinition,
creates the dead-letter queue, and sets a RedrivePolicy whose
maxReceiveCount sits above the worker's MaxAttempts so the library's own
dead-lettering runs first. Definitions SQS cannot honor (visibility over
12 hours, retention outside 1 minute to 14 days) are rejected up front.

SqsQueueClientOptions.AutoCreateQueues becomes the SqsProvisioningMode
enum (Create, Validate, None); the bool stays as an [Obsolete] shim.
Validate reports every missing queue and attribute mismatch in a single
exception, and Validate/None never call CreateQueue from URL lookups.
ProvisionAsync always creates or updates so a one-off command with
elevated IAM rights can run it for an app in Validate mode.

Bodies are sent as UTF-8 JSON text instead of base64, messages over
256 KiB (body plus attributes) are rejected with the queue, size and
message type in the error, and batches split on total size as well as
the 10-entry limit. The dead-letter negative cache is only populated on
QueueDoesNotExistException and expires after one minute.

UseAws registers SDK clients from the default credential chain when no
ServiceUrl is set instead of requiring pre-registration, and the option
docs list the IAM actions each role needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Per-node subscription queues are created with a short retention
(SubscriptionQueueRetention, default 5 minutes) and tagged fm:role,
fm:host and fm:heartbeat; the client refreshes the heartbeat every
HeartbeatInterval (default 2 minutes). On EnsureTopicsAsync or the first
subscribe, each client sweeps queues under its prefix whose heartbeat is
older than StaleSubscriptionAge (default 10 minutes), removing their SNS
subscriptions and the queue.

The SNS-to-SQS queue policy is now applied on the SubscribeAsync path as
well and is built from every topic ARN the client knows about. A stable
HostId that restarts inside SQS's 60-second re-creation window falls back
to a timestamped queue name instead of failing; disposal tolerates queues
and subscriptions that were already swept.

Bodies travel as UTF-8 text with headers as SNS message attributes (raw
delivery) instead of a base64 JSON envelope, publishing uses
PublishBatch in chunks of 10, and the poll loop deletes a batch before
invoking handlers concurrently, matching the at-most-once contract.
The shared queue tuple becomes a sealed record to avoid torn reads, and
the unused SqsPubSubClientOptions.TopicName is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fixture becomes a collection fixture with a container-runtime probe
that skips every test with a reason when Docker is unavailable, and
enables SQS_DELAY_RECENTLY_DELETED so the stable-host restart path is
exercised. New tests cover provisioning attributes and redrive policy,
Validate/None modes, visibility timeout locking, the dead-letter negative
cache expiry, raw JSON bodies and oversize rejection, stale-queue
sweeping, fan-out to two live hosts, heartbeat refresh and the policy
set by SubscribeAsync alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
QueueAdministrationHandler ships mediator handlers with no HTTP surface of
their own: queue overview and detail (depth, workers, counters, handlers),
tracked job listing, lookup and cancellation, and dead-letter peek, replay and
purge. Replay ignores messages dead-lettered after the operation began so a
poison message cannot be replayed in a loop. The library now runs the source
generator (interceptors disabled) so hosts discover these handlers by
referencing the package.

Foundatio.Mediator.Distributed.Testing adds RecordingQueueClient, which wraps
any queue client, exposes typed Sent<T>() assertions and DrainAsync(), and
InMemoryTransport, one queue/pub-sub/lock trio that several service providers
share to prove cross-node behaviour in one process.

QueueJobState.LastHeartbeatUtc is populated by the Redis store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Corrects the retry table, the job id location, and the transport option
names; documents worker selection, shared queues, interface-typed handlers,
retry schedules, [QueueLock], header providers, job metadata, graceful
shutdown, provisioning modes and IAM, subscription-queue cleanup, the Redis
key layout, the administration handlers, metrics and traces, and the testing
package. Adds Scaling Out, Operations, and Testing pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…meouts

distributed.props no longer pins 0.1.0-beta1 with MinVerSkip; branch builds
publish a new preview of every Distributed package again. The AWS and Redis
packages get their own descriptions and tags.

QueueWorker acknowledgements and state writes run under a disposed
per-operation timeout source instead of leaking a timer per call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ejsmith ejsmith changed the title Distributed mediator: queues, pub/sub, and worker infrastructure Distributed mediator: durable queues, notifications, and worker pipelines Sep 6, 2026
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