Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
fc6213b
feat: add message transport foundation
ejsmith Jun 25, 2026
754e366
feat: add transport-backed message queue
ejsmith Jun 25, 2026
36e8016
feat: add transport-backed pubsub
ejsmith Jun 25, 2026
1d78bf4
feat: add in-memory job runtime
ejsmith Jun 25, 2026
2bfb3d5
feat: add durable job scheduler
ejsmith Jun 25, 2026
6ef25db
refactor: use channels in in-memory transport
ejsmith Jun 25, 2026
9effb46
fix: route delays through job runtime
ejsmith Jun 27, 2026
8362933
feat: align messaging and job APIs
ejsmith Jun 27, 2026
8827af1
feat: centralize message routing
ejsmith Jun 27, 2026
2c0a362
feat: add messaging topology declarations
ejsmith Jun 27, 2026
6e9abde
fix: address messaging/jobs design-review feedback
ejsmith Jun 28, 2026
8eada6a
refactor: hoist shared MessageQueue/PubSub behavior into MessageClien…
ejsmith Jun 28, 2026
f4c38a8
fix: address messaging/jobs design-review feedback (round 2)
ejsmith Jun 28, 2026
a921f67
feat: core-owned retry/dead-letter, Reject settlement, and per-source…
ejsmith Jun 29, 2026
99d06e1
feat: address messaging/jobs review feedback (pub/sub addressing, mul…
ejsmith Jun 29, 2026
84298b7
feat: add AWS SQS/SNS transport and make the conformance harness cros…
ejsmith Jun 29, 2026
fa77483
Add Redis IJobRuntimeStore provider + cross-store conformance harness
ejsmith Jun 29, 2026
4911b28
Add Redis end-to-end integration tests: delayed-send fallback + CRON
ejsmith Jun 29, 2026
5e8eba9
Add Redis Streams message transport (at-least-once, ack/retry/dead-le…
ejsmith Jun 30, 2026
6bf54f8
Fix review P0 #1 (redelivery loop) + jobs runtime cluster
ejsmith Jun 30, 2026
6fae268
Fix review #6: auto-register the job runtime pump with the store
ejsmith Jun 30, 2026
eb1674c
Fix review P0 #2: centralize subscription addressing, unbreak AWS
ejsmith Jun 30, 2026
e20d2eb
Fix review #5 (send error model) + #7 (shared header codec)
ejsmith Jun 30, 2026
7f7ad57
Fix review #9/#16/#17/#22/#23: pull-loop, capability enforcement, misc
ejsmith Jun 30, 2026
1bb4b9e
Fix review #11/#12: no-silent-skip conformance harness + coverage
ejsmith Jun 30, 2026
5ec8f3a
Add opt-out for the auto-registered job runtime pump
ejsmith Jun 30, 2026
09418c9
Verification follow-ups: single job pump regardless of order; test ha…
ejsmith Jul 1, 2026
4f8e8c8
Add Foundatio.MessagingSample: scaled messaging + jobs demo
ejsmith Jul 1, 2026
c704c93
MessagingSample: richer CRON demo (Global vs PerNode scope + schedules)
ejsmith Jul 1, 2026
3934595
samples: drop legacy HostingSample; AppHost runs only the redesign sa…
ejsmith Jul 1, 2026
bdddc0a
Isolate legacy message bus into Foundatio.Messaging.Legacy namespace
ejsmith Jul 1, 2026
d3c5e72
Isolate legacy job types into Foundatio.Jobs.Legacy namespace
ejsmith Jul 1, 2026
4a289b5
Isolate legacy hosting job infra into Foundatio.Extensions.Hosting.Jo…
ejsmith Jul 1, 2026
9916753
Drop new->legacy doc reference from IJobWithExecutionContext
ejsmith Jul 1, 2026
471c5d0
Merge IJobWithExecutionContext into a single context-based IJob
ejsmith Jul 1, 2026
0f3c9fe
Declarative message handlers + fluent provider/CRON registration; idi…
ejsmith Jul 1, 2026
05132ea
Address review findings on the jobs/messaging API changes
ejsmith Jul 1, 2026
321ae96
One IMessageBus: the caller's verb decides delivery, handlers are top…
ejsmith Jul 1, 2026
adf1e3e
IMessageBus is the one messaging abstraction: IQueue and IPubSub are …
ejsmith Jul 1, 2026
8e500bb
Rename IReceivedMessage to IMessageContext
ejsmith Jul 2, 2026
38421c6
Remove pull receive from IMessageBus; harden the pull loop against sy…
ejsmith Jul 2, 2026
4c0726e
Failure-path conventions: DeadLetterOn, proven retry defaults, DLQ fo…
ejsmith Jul 2, 2026
81efb18
Address review findings on the failure-path conventions
ejsmith Jul 2, 2026
4838494
Add Foundatio.Testing: a messaging test harness
ejsmith Jul 2, 2026
65ae704
Address review findings on the messaging test harness
ejsmith Jul 2, 2026
93214d0
Fix CI: tolerate all-skipped runs in the env-gated integration test p…
ejsmith Jul 7, 2026
64414cb
Remove aspirational send options: DeduplicationId and PartitionKey
ejsmith Jul 9, 2026
59319e0
Role-aware transport capabilities; fix silent delay drop on AWS delay…
ejsmith Jul 9, 2026
e9e2c0a
One canonical DestinationAddress across the whole transport contract
ejsmith Jul 9, 2026
12baa62
Handler delivery intent: subscriptions declare Sent, Published, or Both
ejsmith Jul 9, 2026
c982247
Explicit topology modes: Ensure, Validate, or None
ejsmith Jul 9, 2026
60a52ba
Extract IScheduledDispatchStore from IJobRuntimeStore
ejsmith Jul 9, 2026
1e77cb3
Typed durable-job payloads: EnqueueAsync<TJob, TArgs>(args)
ejsmith Jul 9, 2026
a20cf37
Job execution scopes, bounded worker concurrency, decoupled pump
ejsmith Jul 9, 2026
beebbad
Supervised lease renewal/cancellation loops; shared-Key policies comp…
ejsmith Jul 10, 2026
9ec7adc
Rewrite the redesign guide and Foundatio skill for the final API
ejsmith Jul 10, 2026
1901c30
CI-feed package publishing is best-effort
ejsmith Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .agents/skills/foundatio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ query-docs(libraryId="/foundatiofx/foundatio", query="How to configure queue ret

Query with specific questions, not single keywords. All provider docs (Redis, Azure, AWS, Kafka, etc.) are included in the main library.


## Messaging/Jobs Redesign Notes

- New queue/pub-sub APIs live under `Foundatio.Messaging`: app-facing `IQueue`, `IPubSub`, shared `IReceivedMessage` / `IReceivedMessage<T>`, `QueueMessageOptions`, `QueueReceiveOptions`, `QueueConsumerOptions`, `PubSubMessageOptions`, and `PubSubSubscriptionOptions`.
- Route resolution is centralized in `IMessageRouter`: operation override > explicit route map > interface/base-type map > `MessageRouteAttribute` > configured default/convention. Configure with `.Messaging.ConfigureRouting(...)`; use `UseDefaultQueue(...)`, `UseDefaultTopic(...)`, `MapQueue(...)`, and `MapTopic(...)` as the normal path. `Destination`, `Source`, `Topic`, and `Subscription` are advanced operation overrides.
- Routing configuration also declares startup topology. `IMessageTopology.GetDeclarations()` returns configured queues/topics/subscriptions, `EnsureAsync()` creates them through `ISupportsProvisioning`, and `ValidateAsync()` checks that they already exist for apps without create permissions. Operation overrides are not included in topology declarations.
- Pub/sub topic routing and subscription identity are separate. Topic answers where an event is published; subscription answers the logical service/consumer group. Same subscription across instances means competing consumers; different subscriptions on the same topic fan out. `PubSubSubscriptionOptions.Key` is only the local duplicate-listener key.
- Raw/envelope paths make grouped/default routes usable: `queue.ReceiveAsync(...)`, `queue.StartConsumerAsync(Func<IReceivedMessage,...>)`, and `pubsub.SubscribeAsync(Func<IReceivedMessage,...>)`. Typed `ReceiveAsync<T>` / `SubscribeAsync<T>` remain the simple path.
- Listener startup returns handles: `StartConsumerAsync<T>` returns `IMessageConsumer`; `SubscribeAsync<T>` returns `IMessageSubscription`. Same-key duplicate registrations are idempotent only for the same handler/options; conflicting registrations throw. Use `RunConsumerAsync` or `RunSubscriptionAsync` only for blocking lifetime loops.
- Received-message settlement uses explicit verbs only: `CompleteAsync`, `AbandonAsync`, `DeadLetterAsync`, `RenewLockAsync`, and `ReportProgressAsync`. Unsupported capabilities should throw clearly instead of silently downgrading.
- New durable job runtime roles are separated: `IJobClient` submits and returns `JobHandle`, `IJobMonitor` queries state, `IJobRuntimeStore` persists runtime state, and `IJobWorker` claims and executes queued jobs. Job types persist stable registry names via `IJobTypeRegistry` / `.Jobs.Register<TJob>(name)`, not assembly-qualified names.
- In-memory setup for the redesign is `services.AddFoundatio().Messaging.ConfigureRouting(...).UseInMemory().Jobs.UseInMemoryRuntime()`.

## Core Interfaces

| Interface | Purpose | In-Memory | Production |
Expand Down
301 changes: 301 additions & 0 deletions docs/guide/messaging-jobs-redesign.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/Foundatio.Extensions.Hosting/Jobs/JobHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public static IServiceCollection AddJob(this IServiceCollection services, string
return services.AddJob(jobOptionsBuilder.Target);
}

/// <remarks>
/// Legacy/compat. This registers the in-process <see cref="ScheduledJobService"/>, which runs CRON occurrences
/// in-process and does not materialize durable, recoverable occurrences. The forward path in the redesigned runtime
/// is the durable scheduler — register it with <c>services.AddFoundatio().Jobs.UseInMemoryRuntime()</c> plus
/// <see cref="AddJobRuntimeService"/>, which materializes durable occurrences with retry, recovery, and
/// dead-lettering. Routing this default API onto the durable scheduler is a planned follow-up.
/// </remarks>
public static IServiceCollection AddCronJob(this IServiceCollection services, ScheduledJobOptions jobOptions)
{
if (jobOptions.JobFactory == null)
Expand Down Expand Up @@ -180,6 +187,11 @@ public static IServiceCollection AddDistributedCronJob(this IServiceCollection s
})));
}

/// <remarks>
/// Legacy/compat: registers the in-process <see cref="ScheduledJobService"/> CRON scheduler. For durable,
/// recoverable CRON occurrences use the redesigned runtime (<c>AddFoundatio().Jobs.UseInMemoryRuntime()</c> +
/// <see cref="AddJobRuntimeService"/>) instead.
/// </remarks>
public static IServiceCollection AddJobScheduler(this IServiceCollection services)
{
if (!services.Any(s => s.ServiceType == typeof(IHostedService) && s.ImplementationType == typeof(ScheduledJobService)))
Expand All @@ -194,6 +206,24 @@ public static IServiceCollection AddJobScheduler(this IServiceCollection service
return services;
}

/// <summary>
/// Registers the hosted pump that drives the durable job runtime (<see cref="Foundatio.Jobs.IJobRuntimeStore"/>):
/// materializing CRON occurrences, dispatching delayed/scheduled work, recovering stale occurrences, and running
/// jobs submitted via <see cref="Foundatio.Jobs.IJobClient"/>. Register the runtime store and job services first
/// (e.g. <c>services.AddFoundatio().Jobs.UseInMemoryRuntime()</c>).
/// </summary>
public static IServiceCollection AddJobRuntimeService(this IServiceCollection services, Action<JobRuntimeServiceOptions>? configure = null)
{
var options = new JobRuntimeServiceOptions();
configure?.Invoke(options);
services.AddSingleton(options);

if (!services.Any(s => s.ServiceType == typeof(IHostedService) && s.ImplementationType == typeof(JobRuntimeService)))
services.AddSingleton<IHostedService, JobRuntimeService>();

return services;
}

public static IServiceCollection AddJobLifetimeService(this IServiceCollection services)
{
services.AddSingleton<ShutdownHostIfNoJobsRunningService>();
Expand Down
103 changes: 103 additions & 0 deletions src/Foundatio.Extensions.Hosting/Jobs/JobRuntimeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Foundatio.Jobs;
using Foundatio.Utility;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Foundatio.Extensions.Hosting.Jobs;

/// <summary>
/// Options controlling the cadence and batch size of <see cref="JobRuntimeService"/>.
/// </summary>
public class JobRuntimeServiceOptions
{
/// <summary>
/// How often the runtime pump materializes CRON occurrences, dispatches due work, and runs queued jobs.
/// Defaults to one second so sub-minute CRON schedules and short delays are honored.
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(1);

/// <summary>
/// Maximum number of due dispatches and queued jobs claimed per pump iteration.
/// </summary>
public int BatchSize { get; set; } = 100;

/// <summary>
/// Maximum number of processing attempts for a durable job before a stale (lease-expired) instance is
/// dead-lettered instead of re-queued. Defaults to 3.
/// </summary>
public int MaxJobAttempts { get; set; } = 3;
}

/// <summary>
/// Drives the durable job runtime introduced by <see cref="IJobRuntimeStore"/>. Without this hosted service nothing
/// materializes CRON occurrences, dispatches delayed/scheduled work, recovers stale (lease-expired) occurrences, or
/// runs jobs submitted through <see cref="IJobClient"/> — the runtime store would accumulate work that never executes.
/// </summary>
public class JobRuntimeService : BackgroundService
{
private readonly JobScheduleProcessor _processor;
private readonly IJobWorker _worker;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private readonly JobRuntimeServiceOptions _options;

public JobRuntimeService(JobScheduleProcessor processor, IJobWorker worker, TimeProvider? timeProvider = null, ILoggerFactory? loggerFactory = null, JobRuntimeServiceOptions? options = null)
{
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_worker = worker ?? throw new ArgumentNullException(nameof(worker));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<JobRuntimeService>();
_options = options ?? new JobRuntimeServiceOptions();
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Job runtime pump starting (poll interval {PollInterval}, batch size {BatchSize})", _options.PollInterval, _options.BatchSize);

while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = _timeProvider.GetUtcNow();

// Materialize CRON occurrences due within the misfire window (deduped, idempotent).
await _processor.EnqueueDueOccurrencesAsync(now, stoppingToken).AnyContext();

// Claim and run due dispatches: CRON occurrences plus delayed queue/pub-sub messages. This also
// recovers occurrences whose processing lease expired (crash mid-run) and applies retry/dead-letter.
await _processor.RunDueOccurrencesAsync(now, _options.BatchSize, lease: null, stoppingToken).AnyContext();

// Recover plain (non-CRON) jobs whose processing lease expired (a worker crash mid-run): re-queue them
// while attempts remain, otherwise dead-letter them. Without this they would strand in Processing.
await _worker.RecoverStaleAsync(_options.MaxJobAttempts, _options.BatchSize, stoppingToken).AnyContext();

// Run jobs submitted via IJobClient that are sitting in the Queued state.
await _worker.RunQueuedAsync(_options.BatchSize, stoppingToken).AnyContext();
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error pumping job runtime: {Message}", ex.Message);
}

try
{
await _timeProvider.Delay(_options.PollInterval, stoppingToken).AnyContext();
}
catch (OperationCanceledException)
{
break;
}
}

_logger.LogInformation("Job runtime pump stopped");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Foundatio.Caching;
using Foundatio.Extensions.Hosting.Cronos;
using Foundatio.Cronos;
using Foundatio.Jobs;
using Foundatio.Lock;
using Foundatio.Messaging;
Expand Down
5 changes: 5 additions & 0 deletions src/Foundatio.Extensions.Hosting/Jobs/ScheduledJobService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

namespace Foundatio.Extensions.Hosting.Jobs;

/// <summary>
/// Legacy/compat in-process CRON scheduler used by <see cref="JobHostExtensions.AddCronJob(IServiceCollection, ScheduledJobOptions)"/>.
/// It runs occurrences in-process and does not materialize durable, recoverable occurrences. The redesigned runtime's
/// durable scheduler (<c>JobScheduleProcessor</c> driven by <see cref="JobRuntimeService"/>) is the forward path.
/// </summary>
public class ScheduledJobService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
Expand Down
Loading